From 1963268a0346a51aac1a28935b3c5739cddb3e2a Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Sat, 29 Aug 2026 10:37:03 +0100 Subject: [PATCH 01/37] Improve Monzo API errors, icon, and test coverage (#180320) --- homeassistant/components/monzo/coordinator.py | 8 +++-- homeassistant/components/monzo/icons.json | 7 ++++ homeassistant/components/monzo/strings.json | 6 ++++ tests/components/monzo/test_helpers.py | 25 ++++++++++++++ tests/components/monzo/test_sensor.py | 33 ++++++++++++++----- 5 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 tests/components/monzo/test_helpers.py diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index 8dd8a020533188..df633ca61bb723 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -62,6 +62,7 @@ async def _async_update_data(self) -> MonzoData: raise ConfigEntryAuthFailed from err except InvalidMonzoAPIResponseError as err: message = "Invalid Monzo API response." + translation_key = "invalid_api_response" if err.missing_key: _LOGGER.debug( "%s\nMissing key: %s\nResponse:\n%s", @@ -69,8 +70,11 @@ async def _async_update_data(self) -> MonzoData: err.missing_key, pformat(err.response), ) - message += " Enabling debug logging for details." - raise UpdateFailed(message) from err + translation_key = "invalid_api_response_with_details" + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key=translation_key, + ) from err data = MonzoData( accounts={account["id"]: account for account in accounts}, diff --git a/homeassistant/components/monzo/icons.json b/homeassistant/components/monzo/icons.json index c0432b96dd7895..3d477b3d1cc764 100644 --- a/homeassistant/components/monzo/icons.json +++ b/homeassistant/components/monzo/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "event": { + "transaction": { + "default": "mdi:bank-transfer" + } + } + }, "services": { "deposit_into_pot": { "service": "mdi:bank-transfer-in" diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index 14b99a3ccf6951..b6630075c2cc94 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -73,6 +73,12 @@ "invalid_account": { "message": "The selected device ({device_name}) is not an account belonging to this Monzo connection." }, + "invalid_api_response": { + "message": "The Monzo API returned an invalid response." + }, + "invalid_api_response_with_details": { + "message": "The Monzo API returned an invalid response. Enable debug logging for details." + }, "invalid_device": { "message": "The selected Monzo device could not be found." }, diff --git a/tests/components/monzo/test_helpers.py b/tests/components/monzo/test_helpers.py new file mode 100644 index 00000000000000..c5c462e04da662 --- /dev/null +++ b/tests/components/monzo/test_helpers.py @@ -0,0 +1,25 @@ +"""Tests for Monzo helpers.""" + +from homeassistant.components.monzo.helpers import ( + get_account_name, + get_authenticated_owner_name, +) + + +def test_authenticated_owner_name_without_user_id() -> None: + """Test an owner cannot be selected without an authenticated user ID.""" + assert get_authenticated_owner_name([], None) is None + + +def test_account_name_ignores_malformed_owner() -> None: + """Test malformed owner metadata does not affect a joint account name.""" + account = { + "name": "Joint Account", + "owners": [ + "invalid owner", + {"preferred_name": "Jake Martin"}, + {"preferred_name": "Jane Martin"}, + ], + } + + assert get_account_name(account) == "Joint Account — Jake Martin & Jane Martin" diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py index 312aa6c3dd963c..baeffa8b435233 100644 --- a/tests/components/monzo/test_sensor.py +++ b/tests/components/monzo/test_sensor.py @@ -285,27 +285,44 @@ async def test_all_entities( ) +@pytest.mark.parametrize( + ("api_error", "expected_log_messages"), + [ + pytest.param( + InvalidMonzoAPIResponseError(), + ("The Monzo API returned an invalid response",), + id="invalid-response", + ), + pytest.param( + InvalidMonzoAPIResponseError({"acc_id": None}, "account_id"), + ( + "The Monzo API returned an invalid response. Enable debug logging for details", + "account_id", + "acc_id", + ), + id="missing-key", + ), + ], +) async def test_update_failed( hass: HomeAssistant, - snapshot: SnapshotAssertion, monzo: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, caplog: pytest.LogCaptureFixture, + api_error: InvalidMonzoAPIResponseError, + expected_log_messages: tuple[str, ...], ) -> None: - """Test all entities.""" + """Test an invalid API response makes entities unavailable.""" await setup_integration(hass, polling_config_entry) - monzo.user_account.accounts.side_effect = InvalidMonzoAPIResponseError( - {"acc_id": None}, "account_id" - ) + monzo.user_account.accounts.side_effect = api_error freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert "Invalid Monzo API response." in caplog.text - assert "account_id" in caplog.text - assert "acc_id" in caplog.text + for message in expected_log_messages: + assert message in caplog.text entity_id = await async_get_entity_id( hass, TEST_ACCOUNTS[0]["id"], ACCOUNT_SENSORS[0] From 0f7daec37aaa89891151464acf9a1793d95c04e5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 29 Aug 2026 12:00:53 +0200 Subject: [PATCH 02/37] Add missing icons to switches for Midea (#180596) --- homeassistant/components/midea/icons.json | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/homeassistant/components/midea/icons.json b/homeassistant/components/midea/icons.json index 441a746711fc42..a1bf5b1924371e 100644 --- a/homeassistant/components/midea/icons.json +++ b/homeassistant/components/midea/icons.json @@ -71,6 +71,47 @@ "target_indoor_fan_speed": { "default": "mdi:fan-clock" } + }, + "switch": { + "anion": { + "default": "mdi:air-purifier" + }, + "aux_heating": { + "default": "mdi:radiator" + }, + "disinfect": { + "default": "mdi:bacteria" + }, + "night_light": { + "default": "mdi:weather-night" + }, + "out_silent": { + "default": "mdi:volume-mute" + }, + "prompt_tone": { + "default": "mdi:bullhorn" + }, + "screen_display": { + "default": "mdi:monitor" + }, + "self_clean": { + "default": "mdi:broom" + }, + "smart_eye": { + "default": "mdi:eye" + }, + "sound": { + "default": "mdi:volume-high" + }, + "tbh": { + "default": "mdi:heating-coil" + }, + "zone1_curve": { + "default": "mdi:chart-bell-curve" + }, + "zone2_curve": { + "default": "mdi:chart-bell-curve" + } } } } From c93f3d3781d657bff779d020b1653f8baa9c3d68 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 29 Aug 2026 12:15:06 +0200 Subject: [PATCH 03/37] Add initial update platform to Proxmox (#167620) --- .../components/proxmoxve/__init__.py | 1 + homeassistant/components/proxmoxve/const.py | 1 + .../components/proxmoxve/coordinator.py | 37 +++++- .../components/proxmoxve/diagnostics.py | 12 +- homeassistant/components/proxmoxve/helpers.py | 60 +++++++++ .../components/proxmoxve/strings.json | 5 + homeassistant/components/proxmoxve/update.py | 120 +++++++++++++++++ tests/components/proxmoxve/__init__.py | 5 + tests/components/proxmoxve/conftest.py | 6 + .../proxmoxve/fixtures/nodes/update.json | 57 +++++++++ .../proxmoxve/fixtures/nodes/version.json | 5 + .../proxmoxve/snapshots/test_diagnostics.ambr | 5 + .../proxmoxve/snapshots/test_update.ambr | 63 +++++++++ tests/components/proxmoxve/test_update.py | 121 ++++++++++++++++++ 14 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/proxmoxve/update.py create mode 100644 tests/components/proxmoxve/fixtures/nodes/update.json create mode 100644 tests/components/proxmoxve/fixtures/nodes/version.json create mode 100644 tests/components/proxmoxve/snapshots/test_update.ambr create mode 100644 tests/components/proxmoxve/test_update.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 1f39c5d0a6d179..57b5a4730b81a2 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -20,6 +20,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, + Platform.UPDATE, ] diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index 8985a2a77ec991..5963711b6ed38d 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -42,5 +42,6 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" SYSAUDIT = "Sys.Audit" + SYSMOD = "Sys.Modify" SYSPOWER = "Sys.PowerMgmt" VMAUDIT = "VM.Audit" diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index d3f47e2d954397..bd550cd41d181f 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -35,7 +35,9 @@ DEFAULT_VERIFY_SSL, DOMAIN, NODE_ONLINE, + ProxmoxPermission, ) +from .helpers import is_granted type ProxmoxConfigEntry = ConfigEntry[ProxmoxCoordinator] @@ -52,6 +54,8 @@ class NodeResources: containers: list[dict[str, Any]] storages: list[dict[str, Any]] backups: list[dict[str, Any]] + version: dict[str, Any] + update: list[dict[str, Any]] | bool @dataclass(slots=True, kw_only=True) @@ -63,6 +67,8 @@ class ProxmoxNodeData: containers: dict[int, dict[str, Any]] = field(default_factory=dict) storages: dict[str, dict[str, Any]] = field(default_factory=dict) backups: list[dict[str, Any]] = field(default_factory=list) + version: dict[str, Any] = field(default_factory=dict) + update: list[dict[str, Any]] | bool = False def proxmox_base_url(coordinator: ProxmoxCoordinator) -> URL: @@ -212,6 +218,8 @@ async def _async_update_data(self) -> dict[str, ProxmoxNodeData]: }, storages={s["storage"]: s for s in resources.storages}, backups=resources.backups, + version=resources.version, + update=resources.update, ) self._async_add_remove_nodes(data) @@ -258,7 +266,7 @@ def _init_proxmox(self) -> None: raise ProxmoxServerError from err def _fetch_all_nodes(self) -> list[tuple[dict[str, Any], NodeResources]]: - """Fetch all nodes with their VMs, containers, storages, and backups.""" + """Fetch all nodes with their VMs, containers, storages, etc.""" nodes = self.proxmox.nodes.get() or [] return [(node, self._get_node_data(node)) for node in nodes] @@ -266,13 +274,20 @@ def _get_node_data( self, node: dict[str, Any], ) -> NodeResources: - """Get vms, containers, storages, and backups for a node.""" + """Get vms, containers, storages, etc. for a node.""" if node.get("status") != NODE_ONLINE: _LOGGER.debug( "Node %s is offline, skipping VM/container/storage fetch", node[CONF_NODE], ) - return NodeResources(vms=[], containers=[], storages=[], backups=[]) + return NodeResources( + vms=[], + containers=[], + storages=[], + backups=[], + version={}, + update=False, + ) vms = self.proxmox.nodes(node[CONF_NODE]).qemu.get() or [] containers = self.proxmox.nodes(node[CONF_NODE]).lxc.get() or [] @@ -281,9 +296,23 @@ def _get_node_data( self.proxmox.nodes(node[CONF_NODE]).tasks.get(typefilter="vzdump", limit=1) or [] ) + version = self.proxmox.nodes(node[CONF_NODE]).version.get() or {} + update: list | bool = False + if is_granted( + self.permissions, + p_type="nodes", + p_id=node[CONF_NODE], + permission=ProxmoxPermission.SYSMOD, + ): + update = self.proxmox.nodes(node[CONF_NODE]).apt.update.get() or [] return NodeResources( - vms=vms, containers=containers, storages=storages, backups=backups + vms=vms, + containers=containers, + storages=storages, + backups=backups, + version=version, + update=update, ) def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None: diff --git a/homeassistant/components/proxmoxve/diagnostics.py b/homeassistant/components/proxmoxve/diagnostics.py index a42133dfd51b94..0581bca7a65926 100644 --- a/homeassistant/components/proxmoxve/diagnostics.py +++ b/homeassistant/components/proxmoxve/diagnostics.py @@ -18,10 +18,14 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a Proxmox VE config entry.""" + devices = {} + # De-noise updates list + for node, node_data in config_entry.runtime_data.data.items(): + d = asdict(node_data) + d.pop("update", None) + devices[node] = d + return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), - "devices": { - node: asdict(node_data) - for node, node_data in config_entry.runtime_data.data.items() - }, + "devices": devices, } diff --git a/homeassistant/components/proxmoxve/helpers.py b/homeassistant/components/proxmoxve/helpers.py index c7e96bcd300be8..c968cc5583a9f6 100644 --- a/homeassistant/components/proxmoxve/helpers.py +++ b/homeassistant/components/proxmoxve/helpers.py @@ -1,8 +1,24 @@ """Helpers for Proxmox VE.""" +from dataclasses import dataclass +from typing import Any + +from packaging.version import parse as parse_version + from .const import ProxmoxPermission +@dataclass(frozen=True) +class ProxmoxUpdateInfo: + """Describes Proxmox VE update information.""" + + latest_version: str | None = None + latest_version_id: str | None = None + total_updates: int = 0 + proxmox_updates: int = 0 + other_updates: int = 0 + + def is_granted( permissions: dict[str, dict[str, int]], p_type: str = "vms", @@ -16,3 +32,47 @@ def is_granted( if value is not None: return value == 1 return False + + +def is_proxmox_package(update: dict[str, Any]) -> bool: + """Indicate if the given update is related to Proxmox VE.""" + package = update.get("Package", "") + origin = update.get("Origin", "") + title = update.get("Title", "") + return ( + package.startswith(("pve-", "libpve-")) + or "proxmox" in origin.lower() + or "proxmox" in title.lower() + ) + + +def latest_version(versions: list[str]) -> str: + """Return the latest version from a list of version strings.""" + # Fix proxmox -pve1 style suffixes + safe_versions = [v.split("-")[0] for v in versions] + return max(safe_versions, key=parse_version) + + +def update_version( + current_version: str, + updates: list[dict[str, Any]], +) -> ProxmoxUpdateInfo: + """Return the updated version based on the current version and updates.""" + + count = len(updates) + pve_count = sum(is_proxmox_package(u) for u in updates) + other_count = len(updates) - pve_count + versions = [current_version] + [ + u["Version"] for u in updates if is_proxmox_package(u) + ] + + latest = latest_version(versions) if pve_count else current_version + return ProxmoxUpdateInfo( + latest_version=latest if count else current_version, + latest_version_id=f"{latest}-p{pve_count}-d{other_count}" + if count + else current_version, + total_updates=count, + proxmox_updates=pve_count, + other_updates=other_count, + ) diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 2637252502eeb1..b5b75b26e327ab 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -296,6 +296,11 @@ "vm_uptime": { "name": "Uptime" } + }, + "update": { + "node_update": { + "name": "Software update" + } } }, "exceptions": { diff --git a/homeassistant/components/proxmoxve/update.py b/homeassistant/components/proxmoxve/update.py new file mode 100644 index 00000000000000..75fbac78339e34 --- /dev/null +++ b/homeassistant/components/proxmoxve/update.py @@ -0,0 +1,120 @@ +"""Update platform for Proxmox VE.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData +from .entity import ProxmoxNodeEntity +from .helpers import ProxmoxUpdateInfo, update_version + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ProxmoxNodeUpdateEntityDescription(UpdateEntityDescription): + """Describes Proxmox node update entity.""" + + installed_version: Callable[[ProxmoxNodeData], str] + update_info: Callable[[ProxmoxNodeData], ProxmoxUpdateInfo | bool] + + +NODE_UPDATES: tuple[ProxmoxNodeUpdateEntityDescription, ...] = ( + ProxmoxNodeUpdateEntityDescription( + key="node_update", + translation_key="node_update", + entity_category=EntityCategory.CONFIG, + installed_version=lambda node_data: node_data.version.get("version", "unknown"), + update_info=lambda node_data: update_version( + node_data.version.get("version", "unknown"), + node_data.update if isinstance(node_data.update, list) else [], + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ProxmoxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Proxmox VE update entities.""" + coordinator = entry.runtime_data + + def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: + """Add update entities for newly discovered nodes.""" + async_add_entities( + ProxmoxNodeUpdateEntity(coordinator, entity_description, node) + for node in nodes + for entity_description in NODE_UPDATES + if node.update + is not False # Only create entities for nodes where update information is available + ) + + coordinator.new_nodes_callbacks.append(_async_add_new_nodes) + _async_add_new_nodes( + [ + node_data + for node_data in coordinator.data.values() + if node_data.node["node"] in coordinator.known_nodes + ] + ) + + +class ProxmoxNodeUpdateEntity(ProxmoxNodeEntity, UpdateEntity): + """Represents a Proxmox VE node software update.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + entity_description: ProxmoxNodeUpdateEntityDescription + + @property + @override + def installed_version(self) -> str | None: + """Return installed version.""" + return self.entity_description.installed_version( + self.coordinator.data[self.device_name] + ) + + @property + @override + def latest_version(self) -> str | None: + """Return latest version.""" + update_info = self._update_info() + return update_info.latest_version_id if update_info else None + + @property + @override + def release_summary(self) -> str | None: + """Return the release summary for the update.""" + url = self.device_info.get("configuration_url") if self.device_info else None + update_info = self._update_info() + if update_info and update_info.total_updates > 0: + return f"A total of {update_info.total_updates} package update(s) are pending installation: of these {update_info.proxmox_updates} relate to Proxmox and {update_info.other_updates} to other updates. Please visit the [Proxmox VE node]({url}) for details on the pending updates and to upgrade to {update_info.latest_version}." + return None + + @property + @override + def available(self) -> bool: + """Return if the update platform is available.""" + return self._update_info() is not None + + @override + def release_notes(self) -> str | None: + """Return the release notes for the update.""" + return self.release_summary + + def _update_info(self) -> ProxmoxUpdateInfo | None: + """Return update info or None if unavailable.""" + info = self.entity_description.update_info( + self.coordinator.data[self.device_name] + ) + return info if isinstance(info, ProxmoxUpdateInfo) else None diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 07c70348383c7b..3667225079e57f 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -43,16 +43,21 @@ "/vms": {"VM.Snapshot": 1}, "/vms/101": {"VM.Snapshot": 0}, } +SYSMOD_PERMISSIONS = { + "/": {"Sys.Modify": 1}, +} MERGED_PERMISSIONS = { key: { **AUDIT_PERMISSIONS.get(key, {}), **POWER_PERMISSIONS.get(key, {}), **SNAPSHOT_PERMISSIONS.get(key, {}), + **SYSMOD_PERMISSIONS.get(key, {}), } for key in set(AUDIT_PERMISSIONS) | set(POWER_PERMISSIONS) | set(SNAPSHOT_PERMISSIONS) + | set(SYSMOD_PERMISSIONS) } PVEVMUSER_PERMISSIONS = deepcopy(MERGED_PERMISSIONS) diff --git a/tests/components/proxmoxve/conftest.py b/tests/components/proxmoxve/conftest.py index 66e5209ccddc50..e1f050ccb2d095 100644 --- a/tests/components/proxmoxve/conftest.py +++ b/tests/components/proxmoxve/conftest.py @@ -132,6 +132,12 @@ def mock_proxmox_client(): not in mock_instance.access.permissions.get.return_value.get("/nodes", []) else load_json_array_fixture("nodes/tasks.json", DOMAIN) ) + node_mock.version.get.return_value = load_json_object_fixture( + "nodes/version.json", DOMAIN + ) + node_mock.apt.update.get.return_value = load_json_array_fixture( + "nodes/update.json", DOMAIN + ) qemu_by_vmid = {int(vm["vmid"]): vm for vm in qemu_list} lxc_by_vmid = {int(vm["vmid"]): vm for vm in lxc_list} diff --git a/tests/components/proxmoxve/fixtures/nodes/update.json b/tests/components/proxmoxve/fixtures/nodes/update.json new file mode 100644 index 00000000000000..7a272f14d02519 --- /dev/null +++ b/tests/components/proxmoxve/fixtures/nodes/update.json @@ -0,0 +1,57 @@ +[ + { + "Arch": "amd64", + "Description": "This package provides the 'host' DNS lookup utility in the form that is bundled with the BIND 9 sources.", + "OldVersion": "1:9.20.18-1~deb13u1", + "Origin": "Debian", + "Package": "bind9-host", + "Priority": "standard", + "Section": "net", + "Title": "DNS Lookup Utility", + "Version": "1:9.20.21-1~deb13u1" + }, + { + "Arch": "all", + "Description": "This package contains the Proxmox Virtual Environment management tools.", + "OldVersion": "9.1.6", + "Origin": "Proxmox", + "Package": "pve-manager", + "Priority": "optional", + "Section": "admin", + "Title": "Proxmox Virtual Environment Management Tools", + "Version": "9.1.7" + }, + { + "Arch": "all", + "Description": "This package contains the base library used by other Proxmox VE components.", + "OldVersion": "9.1.7", + "Origin": "Proxmox", + "Package": "libpve-common-perl", + "Priority": "optional", + "Section": "perl", + "Title": "Proxmox VE base library", + "Version": "9.1.9" + }, + { + "Arch": "amd64", + "Description": "The Corosync Cluster Engine is a Group Communication System with additional features for implementing high availability within applications. The project provides four C Application Programming Interface features: . * A closed process group communication model with virtual synchrony guarantees for creating replicated state machines. * A simple availability manager that restarts the application process when it has failed. * A configuration and statistics in-memory database that provide the ability to set, retrieve, and receive change notifications of information. * A quorum system that notifies applications when quorum is achieved or lost. . This package contains the Corosync daemon and some administration tools.", + "OldVersion": "3.1.10-pve1", + "Origin": "Proxmox", + "Package": "corosync", + "Priority": "optional", + "Section": "admin", + "Title": "cluster engine daemon and utilities", + "Version": "3.1.10-pve2" + }, + { + "Arch": "all", + "Description": "This package contains the binary firmware for various modules used in the pve-kernel.", + "OldVersion": "3.18-1", + "Origin": "Proxmox", + "Package": "pve-firmware", + "Priority": "optional", + "Section": "misc", + "Title": "Binary firmware code for the pve-kernel", + "Version": "3.18-2" + } +] diff --git a/tests/components/proxmoxve/fixtures/nodes/version.json b/tests/components/proxmoxve/fixtures/nodes/version.json new file mode 100644 index 00000000000000..b76caed3299b80 --- /dev/null +++ b/tests/components/proxmoxve/fixtures/nodes/version.json @@ -0,0 +1,5 @@ +{ + "release": "9.1", + "repoid": "abcdefghijklmnop", + "version": "9.1.6" +} diff --git a/tests/components/proxmoxve/snapshots/test_diagnostics.ambr b/tests/components/proxmoxve/snapshots/test_diagnostics.ambr index b14f3294e236e2..690e3fb734955e 100644 --- a/tests/components/proxmoxve/snapshots/test_diagnostics.ambr +++ b/tests/components/proxmoxve/snapshots/test_diagnostics.ambr @@ -129,6 +129,11 @@ 'used_fraction': 0.73, }), }), + 'version': dict({ + 'release': '9.1', + 'repoid': 'abcdefghijklmnop', + 'version': '9.1.6', + }), 'vms': dict({ '100': dict({ 'cpu': 0.15, diff --git a/tests/components/proxmoxve/snapshots/test_update.ambr b/tests/components/proxmoxve/snapshots/test_update.ambr new file mode 100644 index 00000000000000..169283b73a1b2c --- /dev/null +++ b/tests/components/proxmoxve/snapshots/test_update.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_all_entities[update.pve1_software_update-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': 'update', + 'entity_category': , + 'entity_id': 'update.pve1_software_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Software update', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Software update', + 'platform': 'proxmoxve', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'node_update', + 'unique_id': '1234_node/pve1_node_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[update.pve1_software_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/proxmoxve/icon.png', + : 'pve1 Software update', + : False, + : '9.1.6', + : '9.1.9-p4-d1', + : 'A total of 5 package update(s) are pending installation: of these 4 relate to Proxmox and 1 to other updates. Please visit the [Proxmox VE node](https://127.0.0.1:8006/#v1:0:=node/pve1) for details on the pending updates and to upgrade to 9.1.9.', + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.pve1_software_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/proxmoxve/test_update.py b/tests/components/proxmoxve/test_update.py new file mode 100644 index 00000000000000..d15f2327ca3d18 --- /dev/null +++ b/tests/components/proxmoxve/test_update.py @@ -0,0 +1,121 @@ +"""Tests for the Proxmox VE update platform.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import AUDIT_PERMISSIONS, MERGED_PERMISSIONS, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + +ENTITY_ID = "update.pve1_software_update" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + # Ensure Sys.Modify permissions to ensure update status can be determined + mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_update_entities_ignored( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that updates entities are not created with only auditor permissions.""" + mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_ID) is None + + +async def test_update_unavailable_on_permission_change( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that updates entities are unavailable with only auditor permissions.""" + mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(ENTITY_ID).state is not None + + mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + +async def test_update_up_to_date( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that updates are up to date when no updates are pending.""" + mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS + mock_proxmox_client.nodes.return_value.apt.update.get.return_value = [] + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state.attributes.get("latest_version") == "9.1.6" + assert state.state == STATE_OFF + + +async def test_update_release_notes( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that updates release notes are correctly set.""" + mock_proxmox_client.access.permissions.get.return_value = MERGED_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + {"id": 1, "type": "update/release_notes", "entity_id": ENTITY_ID} + ) + result = await ws_client.receive_json() + assert "5 package" in result["result"] From 6a5d0472eb6442a018049f19890e4e1f040dc687 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 12:26:07 +0200 Subject: [PATCH 04/37] Add diagnostics to SolarEdge Modbus (#180599) --- .../solaredge_modbus/diagnostics.py | 70 ++++++++ .../solaredge_modbus/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 161 ++++++++++++++++++ .../solaredge_modbus/test_diagnostics.py | 26 +++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/solaredge_modbus/diagnostics.py create mode 100644 tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr create mode 100644 tests/components/solaredge_modbus/test_diagnostics.py diff --git a/homeassistant/components/solaredge_modbus/diagnostics.py b/homeassistant/components/solaredge_modbus/diagnostics.py new file mode 100644 index 00000000000000..925f12234efd01 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/diagnostics.py @@ -0,0 +1,70 @@ +"""Diagnostics support for the SolarEdge Modbus integration.""" + +from typing import Any + +from modbus_connection.model import Component, RegisterField + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import ( + SolarEdgeModbusConfigEntry, + SolarEdgeModbusDataUpdateCoordinator, +) + +TO_REDACT = {"serial_number"} + + +def _component_data(component: Component) -> dict[str, Any]: + """Every decoded register field and computed property of a component. + + Only the classes below the library's own base count: its plumbing, such as + the Modbus unit a component reads through, is not device data. + """ + names = { + name + for klass in type(component).__mro__ + if issubclass(klass, Component) and klass is not Component + for name, attribute in vars(klass).items() + if not name.startswith("_") and isinstance(attribute, (RegisterField, property)) + } + return {name: getattr(component, name) for name in sorted(names)} + + +def _poll_data(coordinator: SolarEdgeModbusDataUpdateCoordinator) -> dict[str, Any]: + """What a coordinator's most recent poll got out of the device.""" + return { + "updated": sorted(coordinator.data.updated), + "failed": { + subsystem: str(error) + for subsystem, error in coordinator.data.failed.items() + }, + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry. + + What the inverter reports about itself and about what is attached to it, + whether or not this integration has entities for it yet. + """ + runtime_data = entry.runtime_data + solaredge = runtime_data.solaredge + + data: dict[str, Any] = { + "polls": {"readings": _poll_data(runtime_data.readings)}, + "common": _component_data(solaredge.common), + "inverter": _component_data(solaredge.inverter), + "mmppt": ( + [_component_data(module) for module in solaredge.mmppt.modules] + if solaredge.mmppt is not None + else None + ), + "meters": [_component_data(meter) for meter in solaredge.meters], + "batteries": [_component_data(battery) for battery in solaredge.batteries], + "unresponsive_blocks": sorted(solaredge.unresponsive_blocks), + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml index 315e541395649c..75a8d222258d7b 100644 --- a/homeassistant/components/solaredge_modbus/quality_scale.yaml +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -51,7 +51,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done docs-data-update: todo diff --git a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..b1c0d683353cb2 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr @@ -0,0 +1,161 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'batteries': list([ + dict({ + 'dc_current': -0.0, + 'dc_power': 0.0, + 'dc_voltage': 403.8467102050781, + 'energy_available': 9581.759765625, + 'energy_exported': 0, + 'energy_imported': 0, + 'energy_max': 9700.0, + 'manufacturer': '', + 'max_charge_peak_power': 6000.0, + 'max_charge_power': 5000.0, + 'max_discharge_peak_power': 6100.0, + 'max_discharge_power': 5100.0, + 'model': '', + 'rated_energy': 9700.0, + 'serial_number': '', + 'state_of_energy': 99.94781494140625, + 'state_of_health': 100.0, + 'status': 6, + 'temperature_average': 23.608806610107422, + 'temperature_max': 24.5, + 'version': '', + }), + dict({ + 'dc_current': -0.0, + 'dc_power': 0.0, + 'dc_voltage': 404.8029479980469, + 'energy_available': 9706.5595703125, + 'energy_exported': 0, + 'energy_imported': 0, + 'energy_max': 9700.0, + 'manufacturer': '', + 'max_charge_peak_power': 0.0, + 'max_charge_power': 0.0, + 'max_discharge_peak_power': 0.0, + 'max_discharge_power': 0.0, + 'model': '', + 'rated_energy': 9700.0, + 'serial_number': '', + 'state_of_energy': 99.2170181274414, + 'state_of_health': 100.0, + 'status': 6, + 'temperature_average': 23.861509323120117, + 'temperature_max': 0.0, + 'version': '', + }), + ]), + 'common': dict({ + 'device_address': 0, + 'manufacturer': 'SolarEdge', + 'model': 'SE10000H-AUSNBBX14', + 'option': '', + 'serial_number': '**REDACTED**', + 'version': '', + }), + 'inverter': dict({ + 'ac_current': 39.99, + 'ac_current_a': 0.0, + 'ac_current_b': 0.0, + 'ac_current_c': 0.0, + 'ac_energy': 18785652, + 'ac_frequency': 50.037, + 'ac_power': 9490, + 'ac_power_factor': 98, + 'ac_va': 9600, + 'ac_var': 1449, + 'ac_voltage_ab': 250.2, + 'ac_voltage_an': 250.2, + 'ac_voltage_bc': 0.0, + 'ac_voltage_bn': 0.0, + 'ac_voltage_ca': 0.0, + 'ac_voltage_cn': 0.0, + 'dc_current': 0, + 'dc_power': 9635, + 'dc_voltage': 0, + 'did': 101, + 'on_grid': True, + 'status': 4, + 'temperature_cabinet': 0.0, + 'temperature_heatsink': 47.67, + 'temperature_other': 0.0, + 'temperature_transformer': 0.0, + 'vendor_status': 4, + 'vendor_status_extended': 0, + }), + 'meters': list([ + dict({ + 'ac_current': 30.08, + 'ac_current_a': 10.0, + 'ac_current_b': 10.02, + 'ac_current_c': 10.06, + 'ac_frequency': 50.02, + 'ac_power': 5279, + 'ac_power_a': 1750, + 'ac_power_b': 1760, + 'ac_power_c': 1769, + 'ac_power_factor': 98, + 'ac_power_factor_a': 0, + 'ac_power_factor_b': 0, + 'ac_power_factor_c': 0, + 'ac_va': 5350, + 'ac_va_a': 0, + 'ac_va_b': 0, + 'ac_va_c': 0, + 'ac_var': 869, + 'ac_var_a': 0, + 'ac_var_b': 0, + 'ac_var_c': 0, + 'ac_voltage_ab': 400, + 'ac_voltage_an': 231, + 'ac_voltage_bc': 401, + 'ac_voltage_bn': 232, + 'ac_voltage_ca': 402, + 'ac_voltage_cn': 233, + 'ac_voltage_ll': 0, + 'ac_voltage_ln': 232, + 'apparent_energy_exported': 0, + 'apparent_energy_imported': 0, + 'did': 203, + 'energy_exported': 2324624.0, + 'energy_exported_a': 774000.0, + 'energy_exported_b': 775000.0, + 'energy_exported_c': 775624.0, + 'energy_imported': 12320555.0, + 'energy_imported_a': 4106000.0, + 'energy_imported_b': 4107000.0, + 'energy_imported_c': 4107555.0, + 'events': 0, + 'manufacturer': '', + 'model': '', + 'option': '', + 'reactive_energy_q1': 0, + 'reactive_energy_q2': 0, + 'reactive_energy_q3': 0, + 'reactive_energy_q4': 0, + 'serial_number': '', + 'version': '', + }), + ]), + 'mmppt': None, + 'polls': dict({ + 'readings': dict({ + 'failed': dict({ + }), + 'updated': list([ + 'batteries[0]', + 'batteries[1]', + 'common', + 'inverter', + 'meters[0]', + ]), + }), + }), + 'unresponsive_blocks': list([ + ]), + }) +# --- diff --git a/tests/components/solaredge_modbus/test_diagnostics.py b/tests/components/solaredge_modbus/test_diagnostics.py new file mode 100644 index 00000000000000..980bdfd9155065 --- /dev/null +++ b/tests/components/solaredge_modbus/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the SolarEdge Modbus diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """The diagnostics dump matches the snapshot.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 9575dd00a7aea6cc25834578f52f423787760dd5 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Sat, 29 Aug 2026 12:33:01 +0200 Subject: [PATCH 05/37] Migrate Lyngdorf core to the lyngdorf 2.0 API (#180529) --- homeassistant/components/lyngdorf/__init__.py | 19 +++++------ .../components/lyngdorf/config_flow.py | 20 ++++++----- homeassistant/components/lyngdorf/entity.py | 12 ++----- homeassistant/components/lyngdorf/models.py | 4 +-- tests/components/lyngdorf/conftest.py | 33 ++++++++++--------- tests/components/lyngdorf/test_config_flow.py | 2 +- tests/components/lyngdorf/test_init.py | 32 ++++++++++++++---- .../components/lyngdorf/test_media_player.py | 5 +-- tests/components/lyngdorf/test_number.py | 2 +- tests/components/lyngdorf/test_remote.py | 2 +- 10 files changed, 73 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py index 7237091935e12f..a34ae51c7020e4 100644 --- a/homeassistant/components/lyngdorf/__init__.py +++ b/homeassistant/components/lyngdorf/__init__.py @@ -2,7 +2,7 @@ import logging -from lyngdorf.device import async_create_receiver, lookup_receiver_model +from lyngdorf import LyngdorfReceiver, create_receiver, lookup_model from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -37,14 +37,14 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: LyngdorfConfigEntry ) -> bool: """Set up Lyngdorf from a config entry.""" - lyngdorf_model = lookup_receiver_model(config_entry.data[CONF_MODEL]) + lyngdorf_model = lookup_model(config_entry.data[CONF_MODEL]) assert lyngdorf_model is not None try: - receiver = await async_create_receiver( + receiver: LyngdorfReceiver = await create_receiver( config_entry.data[CONF_HOST], lyngdorf_model ) - await receiver.async_connect() + await receiver.connect() except TimeoutError as err: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -72,7 +72,7 @@ async def async_setup_entry( ) zone_b_device_info: DeviceInfo | None = None - if lyngdorf_model.has_zone_b_feature(): + if receiver.zone_b is not None: # Register the main device up front so Zone B can resolve its via_device_id. device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -113,16 +113,13 @@ def _log_availability_change() -> None: else: _LOGGER.info("Lyngdorf %s is unavailable", host) - receiver.register_notification_callback(_log_availability_change) - config_entry.async_on_unload( - lambda: receiver.un_register_notification_callback(_log_availability_change) - ) + config_entry.async_on_unload(receiver.on_change(_log_availability_change)) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def _async_disconnect(event: Event) -> None: """Disconnect from receiver.""" - await receiver.async_disconnect() + await receiver.disconnect() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect) @@ -139,5 +136,5 @@ async def async_unload_entry( config_entry, PLATFORMS ) if unload_ok: - await config_entry.runtime_data.receiver.async_disconnect() + await config_entry.runtime_data.receiver.disconnect() return unload_ok diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index 94366cc67b62a4..18c39e8406ee21 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -4,11 +4,12 @@ from typing import Any, override from urllib.parse import urlparse -from lyngdorf.const import LyngdorfModel -from lyngdorf.device import ( - async_find_receiver_model, - async_get_device_serial, - lookup_receiver_model, +from lyngdorf import ( + LyngdorfModel, + discover_model, + discover_ssdp_location, + fetch_device_serial, + lookup_model, ) import voluptuous as vol @@ -89,7 +90,7 @@ async def async_step_user( async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]: """Return the model and serial of the device at a host.""" try: - model = await async_find_receiver_model(host) + model = await discover_model(host) except TimeoutError as err: raise TimeoutConnect from err except OSError as err: @@ -98,7 +99,8 @@ async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]: raise UnsupportedModel try: - serial = await async_get_device_serial(host) + location = await discover_ssdp_location(host) + serial = await fetch_device_serial(location) if location else None except TimeoutError as err: raise TimeoutConnect from err except OSError as err: @@ -165,7 +167,7 @@ async def async_step_ssdp( assert self._host try: - model = await async_find_receiver_model(self._host) + model = await discover_model(self._host) except TimeoutError, OSError: return self.async_abort(reason="cannot_connect") if not model: @@ -226,7 +228,7 @@ async def _async_set_info_from_discovery( raise AbortFlow("cannot_connect") device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" - if not (model := lookup_receiver_model(device_model_name)): + if not (model := lookup_model(device_model_name)): _LOGGER.warning( "SSDP discovered device with unrecognized model name %r at %s", device_model_name, diff --git a/homeassistant/components/lyngdorf/entity.py b/homeassistant/components/lyngdorf/entity.py index 4c95d27e86d1a5..7b30f495119f34 100644 --- a/homeassistant/components/lyngdorf/entity.py +++ b/homeassistant/components/lyngdorf/entity.py @@ -2,7 +2,7 @@ from typing import override -from lyngdorf.device import Receiver +from lyngdorf import LyngdorfReceiver from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -16,7 +16,7 @@ class LyngdorfEntity(Entity): _attr_available = True _attr_should_poll = False - def __init__(self, receiver: Receiver, device_info: DeviceInfo) -> None: + def __init__(self, receiver: LyngdorfReceiver, device_info: DeviceInfo) -> None: """Initialize the entity.""" self._receiver = receiver self._attr_device_info = device_info @@ -25,15 +25,9 @@ def __init__(self, receiver: Receiver, device_info: DeviceInfo) -> None: async def async_added_to_hass(self) -> None: """Register notification callback when added to hass.""" await super().async_added_to_hass() - self._receiver.register_notification_callback(self._handle_receiver_update) + self.async_on_remove(self._receiver.on_change(self._handle_receiver_update)) self._update_availability() - @override - async def async_will_remove_from_hass(self) -> None: - """Unregister notification callback when removed from hass.""" - await super().async_will_remove_from_hass() - self._receiver.un_register_notification_callback(self._handle_receiver_update) - @callback def _handle_receiver_update(self) -> None: """Handle receiver updates.""" diff --git a/homeassistant/components/lyngdorf/models.py b/homeassistant/components/lyngdorf/models.py index 879cca55c27e38..18a70d0e5fd778 100644 --- a/homeassistant/components/lyngdorf/models.py +++ b/homeassistant/components/lyngdorf/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from lyngdorf.device import Receiver +from lyngdorf import LyngdorfReceiver from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo @@ -12,7 +12,7 @@ class LyngdorfRuntimeData: """Runtime data for Lyngdorf integration.""" - receiver: Receiver + receiver: LyngdorfReceiver device_info: DeviceInfo zone_b_device_info: DeviceInfo | None diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index f44ac0535592bb..bae4238c3d8fc8 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -5,8 +5,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, Mock, patch -from lyngdorf.const import LyngdorfModel -from lyngdorf.device import Receiver +from lyngdorf import LyngdorfModel, LyngdorfReceiver from lyngdorf.models.base import NumericRange from lyngdorf.remote import RemoteKey import pytest @@ -58,10 +57,8 @@ def mock_setup_entry() -> Generator[None]: @pytest.fixture def mock_receiver() -> Generator[MagicMock]: """Return a mocked Lyngdorf receiver.""" - with patch( - "homeassistant.components.lyngdorf.async_create_receiver" - ) as create_mock: - receiver = MagicMock(spec=Receiver) + with patch("homeassistant.components.lyngdorf.create_receiver") as create_mock: + receiver = MagicMock(spec=LyngdorfReceiver) receiver.name = "Mock Lyngdorf" receiver.connected = True receiver.has_remote_keys = True @@ -148,19 +145,25 @@ def mock_receiver() -> Generator[MagicMock]: @pytest.fixture def mock_get_device_serial() -> Generator[AsyncMock]: - """Return a mocked async_get_device_serial function.""" - with patch( - "homeassistant.components.lyngdorf.config_flow.async_get_device_serial", - new=AsyncMock(return_value="0050c27c76b2"), - ) as serial_mock: + """Return a mocked fetch_device_serial function.""" + with ( + patch( + "homeassistant.components.lyngdorf.config_flow.discover_ssdp_location", + new=AsyncMock(return_value="http://127.0.0.1:8080/desc.xml"), + ), + patch( + "homeassistant.components.lyngdorf.config_flow.fetch_device_serial", + new=AsyncMock(return_value="0050c27c76b2"), + ) as serial_mock, + ): yield serial_mock @pytest.fixture def mock_find_receiver_model() -> Generator[AsyncMock]: - """Return a mocked async_find_receiver_model function.""" + """Return a mocked discover_model function.""" with patch( - "homeassistant.components.lyngdorf.config_flow.async_find_receiver_model", + "homeassistant.components.lyngdorf.config_flow.discover_model", new=AsyncMock(return_value=LyngdorfModel.MP_60), ) as find_mock: yield find_mock @@ -168,7 +171,7 @@ def mock_find_receiver_model() -> Generator[AsyncMock]: def notify_receiver_update(receiver: MagicMock) -> None: """Fire every notification callback the entities registered.""" - for call in receiver.register_notification_callback.call_args_list: + for call in receiver.on_change.call_args_list: call.args[0]() @@ -195,7 +198,7 @@ async def init_integration( mock_config_entry.add_to_hass(hass) with ( - patch("homeassistant.components.lyngdorf.lookup_receiver_model") as lookup, + patch("homeassistant.components.lyngdorf.lookup_model") as lookup, patch("homeassistant.components.lyngdorf.PLATFORMS", platforms), ): lookup.return_value = LyngdorfModel.MP_60 diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index 7dabfb3a8ea463..96f8520ded253c 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock -from lyngdorf.const import LyngdorfModel +from lyngdorf import LyngdorfModel import pytest from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN diff --git a/tests/components/lyngdorf/test_init.py b/tests/components/lyngdorf/test_init.py index 180aad6420a634..0e80319acb3741 100644 --- a/tests/components/lyngdorf/test_init.py +++ b/tests/components/lyngdorf/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from lyngdorf.const import LyngdorfModel +from lyngdorf import LyngdorfModel import pytest from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN @@ -31,10 +31,10 @@ async def test_setup_entry_connection_failures( ) -> None: """Test setup retries when connecting to the receiver fails.""" mock_config_entry.add_to_hass(hass) - mock_receiver.async_connect.side_effect = exc + mock_receiver.connect.side_effect = exc with patch( - "homeassistant.components.lyngdorf.lookup_receiver_model", + "homeassistant.components.lyngdorf.lookup_model", return_value=LyngdorfModel.MP_60, ): await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -54,7 +54,7 @@ async def test_receiver_disconnects_on_hass_stop( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - mock_receiver.async_disconnect.assert_awaited_once() + mock_receiver.disconnect.assert_awaited_once() async def test_unload_entry( @@ -69,6 +69,23 @@ async def test_unload_entry( assert init_integration.state is ConfigEntryState.NOT_LOADED +async def test_unload_releases_receiver_subscriptions( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test every receiver subscription is released when the entry unloads.""" + unsubscribe = mock_receiver.on_change.return_value + registered = mock_receiver.on_change.call_count + assert registered > 0 + assert unsubscribe.call_count == 0 + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert unsubscribe.call_count == registered + + async def test_zone_b_via_device_id( init_integration: MockConfigEntry, device_registry: dr.DeviceRegistry, @@ -115,7 +132,7 @@ async def test_mac_connection_registered_when_serial_is_mac( entry.add_to_hass(hass) with patch( - "homeassistant.components.lyngdorf.lookup_receiver_model", + "homeassistant.components.lyngdorf.lookup_model", return_value=LyngdorfModel.MP_60, ): await hass.config_entries.async_setup(entry.entry_id) @@ -131,17 +148,18 @@ async def test_mac_connection_registered_when_serial_is_mac( assert mac_connections == expected_mac_connections -@pytest.mark.usefixtures("mock_receiver") async def test_no_zone_b_device_for_model_without_zone_b( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, device_registry: dr.DeviceRegistry, ) -> None: """Test no Zone B device is created for a model without Zone B.""" mock_config_entry.add_to_hass(hass) + mock_receiver.zone_b = None with patch( - "homeassistant.components.lyngdorf.lookup_receiver_model", + "homeassistant.components.lyngdorf.lookup_model", return_value=LyngdorfModel.TDAI_3400, ): await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py index f1d4811eb02811..4a7ee8b634ed96 100644 --- a/tests/components/lyngdorf/test_media_player.py +++ b/tests/components/lyngdorf/test_media_player.py @@ -117,17 +117,18 @@ async def test_entities( await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) -@pytest.mark.usefixtures("mock_receiver") async def test_no_zone_b_entity_for_model_without_zone_b( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test no Zone B media player entity is created for a model without Zone B.""" mock_config_entry.add_to_hass(hass) + mock_receiver.zone_b = None with patch( - "homeassistant.components.lyngdorf.lookup_receiver_model", + "homeassistant.components.lyngdorf.lookup_model", return_value=LyngdorfModel.TDAI_3400, ): await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/lyngdorf/test_number.py b/tests/components/lyngdorf/test_number.py index 303c3f6a1f980e..fe361e3b308867 100644 --- a/tests/components/lyngdorf/test_number.py +++ b/tests/components/lyngdorf/test_number.py @@ -144,7 +144,7 @@ async def test_entities_absent_for_controls_the_model_lacks( with ( patch( - "homeassistant.components.lyngdorf.lookup_receiver_model", + "homeassistant.components.lyngdorf.lookup_model", return_value=LyngdorfModel.MP_60, ), patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.NUMBER]), diff --git a/tests/components/lyngdorf/test_remote.py b/tests/components/lyngdorf/test_remote.py index d38ac70c542445..a16db247b97053 100644 --- a/tests/components/lyngdorf/test_remote.py +++ b/tests/components/lyngdorf/test_remote.py @@ -151,7 +151,7 @@ async def test_no_entity_for_model_without_remote_keys( with ( patch( - "homeassistant.components.lyngdorf.lookup_receiver_model", + "homeassistant.components.lyngdorf.lookup_model", return_value=LyngdorfModel.TDAI_3400, ), patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.REMOTE]), From 51765a13c1bd8a829dd2f0d3630fcd20f7d2c8ce Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 29 Aug 2026 13:01:46 +0200 Subject: [PATCH 06/37] Fix deviceinfo missing mac and s/n for Midea (#180594) --- homeassistant/components/midea/__init__.py | 42 ++++++- homeassistant/components/midea/config_flow.py | 26 ++++- homeassistant/components/midea/const.py | 1 + homeassistant/components/midea/diagnostics.py | 6 +- tests/components/midea/conftest.py | 1 + tests/components/midea/const.py | 5 +- .../midea/snapshots/test_diagnostics.ambr | 2 +- tests/components/midea/test_config_flow.py | 16 ++- tests/components/midea/test_init.py | 104 +++++++++++++++++- 9 files changed, 189 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 6c4b462d0680fa..57e0821ccff332 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -1,6 +1,7 @@ """The Midea integration.""" from collections.abc import Mapping +from functools import partial from typing import Any from midealocal.const import ProtocolVersion @@ -11,6 +12,7 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PORT, @@ -21,8 +23,9 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .const import CONF_KEY, CONF_SUBTYPE, LOGGER +from .const import CONF_KEY, CONF_SN, CONF_SUBTYPE, LOGGER from .entity import MideaConfigEntry _PLATFORMS: list[Platform] = [ @@ -133,6 +136,43 @@ async def _close_device() -> None: return True +async def async_migrate_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: + """Migrate an old config entry.""" + if entry.minor_version < 2: + # minor_version 2 adds mac/serial number; discovery already parses both + # from the device reply, so backfill them for pre-existing entries. + discovered = await hass.async_add_executor_job( + partial(discover, ip_address=entry.data[CONF_IP_ADDRESS]) + ) + device = discovered.get(entry.data[CONF_DEVICE_ID], {}) + new_data = {**entry.data} + if mac := device.get(CONF_MAC): + new_data[CONF_MAC] = mac + if serial_number := device.get(CONF_SN): + new_data[CONF_SN] = serial_number + + # Drop any network-mac connection with an empty value so only a real + # address remains; the device registry never removes these on its own. + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + connections = { + connection + for connection in device_entry.connections + if connection[0] != dr.CONNECTION_NETWORK_MAC + or str(connection[1]).lower() not in ("", "none") + } + if connections != device_entry.connections: + device_registry.async_update_device( + device_entry.id, new_connections=connections + ) + + hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/midea/config_flow.py b/homeassistant/components/midea/config_flow.py index 154524d38dd717..45fb9b06c2bbb9 100644 --- a/homeassistant/components/midea/config_flow.py +++ b/homeassistant/components/midea/config_flow.py @@ -21,6 +21,7 @@ CONF_DEVICE, CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PASSWORD, @@ -32,7 +33,15 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from .const import CONF_ACCOUNT, CONF_KEY, CONF_SERVER, CONF_SUBTYPE, DOMAIN, LOGGER +from .const import ( + CONF_ACCOUNT, + CONF_KEY, + CONF_SERVER, + CONF_SN, + CONF_SUBTYPE, + DOMAIN, + LOGGER, +) from .device_catalog import MIDEA_DEVICE_NAMES DEFAULT_CLOUD: str = get_default_cloud() @@ -91,7 +100,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): """ VERSION = 1 - MINOR_VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """MideaConfigFlow class.""" @@ -456,6 +465,8 @@ async def async_step_auto( CONF_IP_ADDRESS: device.get(CONF_IP_ADDRESS), CONF_PORT: device.get(CONF_PORT), CONF_MODEL: device.get(CONF_MODEL), + CONF_MAC: device.get(CONF_MAC), + CONF_SN: device.get(CONF_SN), } # MUST get a auth passed token/key for v3 device, disable add before pass @@ -541,6 +552,8 @@ def _found_device_to_user_input(self) -> dict[str, Any]: CONF_SUBTYPE: self.found_device.get(CONF_SUBTYPE) or 0, CONF_TOKEN: self.found_device.get(CONF_TOKEN) or "", CONF_KEY: self.found_device.get(CONF_KEY) or "", + CONF_MAC: self.found_device.get(CONF_MAC), + CONF_SN: self.found_device.get(CONF_SN), } async def _async_create_midea_entry( @@ -589,6 +602,10 @@ async def _async_create_midea_entry( CONF_TOKEN: user_input[CONF_TOKEN], CONF_KEY: user_input[CONF_KEY], } + if mac := user_input.get(CONF_MAC): + data[CONF_MAC] = mac + if serial_number := user_input.get(CONF_SN): + data[CONF_SN] = serial_number return self.async_create_entry( title=name, @@ -679,6 +696,9 @@ async def async_step_manually( user_input[CONF_KEY] = keys["key"] user_input[CONF_TOKEN] = keys["token"] + user_input[CONF_MAC] = device.get(CONF_MAC) + user_input[CONF_SN] = device.get(CONF_SN) + self.found_device = { CONF_DEVICE_ID: user_input[CONF_DEVICE_ID], CONF_NAME: self.found_device.get(CONF_NAME), @@ -689,6 +709,8 @@ async def async_step_manually( CONF_MODEL: user_input[CONF_MODEL], CONF_TOKEN: user_input[CONF_TOKEN], CONF_KEY: user_input[CONF_KEY], + CONF_MAC: user_input[CONF_MAC], + CONF_SN: user_input[CONF_SN], } return await self._async_create_midea_entry(user_input) diff --git a/homeassistant/components/midea/const.py b/homeassistant/components/midea/const.py index 3db444cd41ae04..9d2cd5a439d942 100644 --- a/homeassistant/components/midea/const.py +++ b/homeassistant/components/midea/const.py @@ -12,6 +12,7 @@ CONF_SUBTYPE = "subtype" CONF_ACCOUNT = "account" CONF_SERVER = "server" +CONF_SN = "sn" PRESET_MODE_NONE = "none" diff --git a/homeassistant/components/midea/diagnostics.py b/homeassistant/components/midea/diagnostics.py index 35e6041f90875b..1f805ab14b218d 100644 --- a/homeassistant/components/midea/diagnostics.py +++ b/homeassistant/components/midea/diagnostics.py @@ -3,13 +3,13 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_MAC, CONF_TOKEN from homeassistant.core import HomeAssistant -from .const import CONF_KEY +from .const import CONF_KEY, CONF_SN from .entity import MideaConfigEntry -TO_REDACT = {CONF_TOKEN, CONF_KEY} +TO_REDACT = {CONF_TOKEN, CONF_KEY, CONF_MAC, CONF_SN} async def async_get_config_entry_diagnostics( diff --git a/tests/components/midea/conftest.py b/tests/components/midea/conftest.py index 5316401235cbfc..c47b75e3f5d410 100644 --- a/tests/components/midea/conftest.py +++ b/tests/components/midea/conftest.py @@ -174,6 +174,7 @@ def mock_config_entry() -> Callable[[DummyDevice], MockConfigEntry]: def _create(device: DummyDevice) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, + minor_version=2, data={ **BASE_DATA, CONF_TYPE: device.device_type, diff --git a/tests/components/midea/const.py b/tests/components/midea/const.py index 9b527275ca893c..1b79090df57f3b 100644 --- a/tests/components/midea/const.py +++ b/tests/components/midea/const.py @@ -2,11 +2,12 @@ from midealocal.const import ProtocolVersion -from homeassistant.components.midea.const import CONF_KEY, CONF_SUBTYPE +from homeassistant.components.midea.const import CONF_KEY, CONF_SN, CONF_SUBTYPE from homeassistant.components.midea.device_catalog import MIDEA_DEVICE_NAMES from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_PORT, CONF_PROTOCOL, @@ -38,6 +39,8 @@ TEST_DEVICE_ID: { **BASE_DATA, CONF_TYPE: TEST_TYPE, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, } } diff --git a/tests/components/midea/snapshots/test_diagnostics.ambr b/tests/components/midea/snapshots/test_diagnostics.ambr index b381ae53f3aada..d3c0fb464e231e 100644 --- a/tests/components/midea/snapshots/test_diagnostics.ambr +++ b/tests/components/midea/snapshots/test_diagnostics.ambr @@ -29,7 +29,7 @@ 'discovery_keys': dict({ }), 'domain': 'midea', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/midea/test_config_flow.py b/tests/components/midea/test_config_flow.py index c1468e76643a0c..b0fa9a5ef3feae 100644 --- a/tests/components/midea/test_config_flow.py +++ b/tests/components/midea/test_config_flow.py @@ -17,6 +17,7 @@ CONF_ACCOUNT, CONF_KEY, CONF_SERVER, + CONF_SN, CONF_SUBTYPE, DOMAIN, ) @@ -26,6 +27,7 @@ CONF_DEVICE, CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PASSWORD, @@ -44,9 +46,11 @@ TEST_DEVICE_ID, TEST_IP_ADDRESS, TEST_KEY, + TEST_MAC_ADDRESS, TEST_MODEL, TEST_PORT, TEST_PROTOCOL, + TEST_SERIAL_NUMBER, TEST_SUBTYPE, TEST_TOKEN, TEST_TYPE, @@ -104,6 +108,8 @@ async def test_manual_flow_success(hass: HomeAssistant) -> None: CONF_SUBTYPE: TEST_SUBTYPE, CONF_TOKEN: TEST_TOKEN, CONF_KEY: TEST_KEY, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, } @@ -1056,7 +1062,13 @@ async def test_auto_flow_v1_v2_success_when_cloud_down( ) -> None: """Test v1/v2 devices are added without ever using the cloud, even if it is down.""" mock_devices = { - TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE, CONF_PROTOCOL: protocol}, + TEST_DEVICE_ID: { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + CONF_PROTOCOL: protocol, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, + }, } result = await hass.config_entries.flow.async_init( @@ -1105,6 +1117,8 @@ async def test_auto_flow_v1_v2_success_when_cloud_down( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_DEVICE_ID] == TEST_DEVICE_ID assert result["data"][CONF_PROTOCOL] == protocol + assert result["data"][CONF_MAC] == TEST_MAC_ADDRESS + assert result["data"][CONF_SN] == TEST_SERIAL_NUMBER async def test_login_credentials_step_renders_with_cloud_servers( diff --git a/tests/components/midea/test_init.py b/tests/components/midea/test_init.py index 794a84800f83ac..f524aab80da272 100644 --- a/tests/components/midea/test_init.py +++ b/tests/components/midea/test_init.py @@ -4,11 +4,12 @@ from midealocal.const import DeviceType, ProtocolVersion -from homeassistant.components.midea.const import CONF_KEY, CONF_SUBTYPE, DOMAIN +from homeassistant.components.midea.const import CONF_KEY, CONF_SN, CONF_SUBTYPE, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, + CONF_MAC, CONF_MODEL, CONF_NAME, CONF_PORT, @@ -17,10 +18,11 @@ CONF_TYPE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import DummyDevice -from .const import TEST_DEVICE_ID +from .const import TEST_DEVICE_ID, TEST_IP_ADDRESS, TEST_MAC_ADDRESS, TEST_SERIAL_NUMBER from tests.common import MockConfigEntry @@ -45,7 +47,7 @@ async def test_async_setup(hass: HomeAssistant) -> None: async def test_unload_entry(hass: HomeAssistant) -> None: """Test async_unload_entry unloads platforms and closes the device.""" - entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=2) entry.add_to_hass(hass) device = DummyDevice(DeviceType.AC) with patch( @@ -62,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_async_setup_entry_paths(hass: HomeAssistant) -> None: """Test async_setup_entry for success and no-device return.""" - entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=2) entry.add_to_hass(hass) with patch( "homeassistant.components.midea.device_selector", @@ -74,6 +76,7 @@ async def test_async_setup_entry_paths(hass: HomeAssistant) -> None: entry2 = MockConfigEntry( domain=DOMAIN, data={**_ENTRY_DATA, CONF_DEVICE_ID: TEST_DEVICE_ID + 1}, + minor_version=2, ) entry2.add_to_hass(hass) with patch( @@ -94,7 +97,7 @@ async def test_setup_entry_not_ready_on_connect_failure( It can also leave the socket open in that case (e.g. when authentication fails), so the socket must be closed explicitly to avoid a ResourceWarning. """ - entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=2) entry.add_to_hass(hass) device = DummyDevice(DeviceType.AC) with ( @@ -164,3 +167,94 @@ async def test_setup_entry_no_recovery_when_device_not_discovered( await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY assert entry.data[CONF_IP_ADDRESS] == _ENTRY_DATA[CONF_IP_ADDRESS] + + +async def test_migrate_entry_backfills_mac_and_serial_number( + hass: HomeAssistant, +) -> None: + """Test migration to minor_version 2 backfills mac and serial number.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=1) + entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.midea.discover", + return_value={ + TEST_DEVICE_ID: { + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, + } + }, + ), + patch( + "homeassistant.components.midea.device_selector", + return_value=DummyDevice(DeviceType.AC), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert entry.data[CONF_MAC] == TEST_MAC_ADDRESS + assert entry.data[CONF_SN] == TEST_SERIAL_NUMBER + + +async def test_migrate_entry_drops_empty_mac_connection( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test migration removes a leftover empty network-mac connection.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=1) + entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(TEST_DEVICE_ID))}, + connections={ + (dr.CONNECTION_NETWORK_MAC, "None"), + (dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS), + }, + ) + + with ( + patch( + "homeassistant.components.midea.discover", + return_value={ + TEST_DEVICE_ID: { + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_MAC: TEST_MAC_ADDRESS, + CONF_SN: TEST_SERIAL_NUMBER, + } + }, + ), + patch( + "homeassistant.components.midea.device_selector", + return_value=DummyDevice(DeviceType.AC), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, str(TEST_DEVICE_ID)), entry.entry_id + ) + assert device_entry is not None + assert device_entry.connections == {(dr.CONNECTION_NETWORK_MAC, TEST_MAC_ADDRESS)} + + +async def test_migrate_entry_without_discovery_result(hass: HomeAssistant) -> None: + """Test migration still completes when discovery finds nothing.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA, minor_version=1) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.midea.discover", return_value={}), + patch( + "homeassistant.components.midea.device_selector", + return_value=DummyDevice(DeviceType.AC), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert CONF_MAC not in entry.data + assert CONF_SN not in entry.data From d8c3a5dd0b2b0ff9be9c008297a20cec636196df Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 13:03:28 +0200 Subject: [PATCH 07/37] Remove the vendor status sensor from SolarEdge Modbus (#180607) --- .../components/solaredge_modbus/icons.json | 3 -- .../components/solaredge_modbus/sensor.py | 7 --- .../components/solaredge_modbus/strings.json | 3 -- .../snapshots/test_sensor.ambr | 50 ------------------- 4 files changed, 63 deletions(-) diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index 5477fcd56fc529..49cff864afa282 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -3,9 +3,6 @@ "sensor": { "inverter_status": { "default": "mdi:solar-power" - }, - "vendor_status": { - "default": "mdi:information-outline" } } } diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py index aa34fb7b411f09..a200ef30d8d9ee 100644 --- a/homeassistant/components/solaredge_modbus/sensor.py +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -273,13 +273,6 @@ class SolarEdgeModbusSensorEntityDescription(SensorEntityDescription): inverter.status.name.lower() if inverter.status else None ), ), - SolarEdgeModbusSensorEntityDescription( - key="vendor_status", - translation_key="vendor_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda inverter: inverter.vendor_status, - ), ) diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 9bf17117598c52..096a0eab09dd5a 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -99,9 +99,6 @@ "throttled": "Throttled" } }, - "vendor_status": { - "name": "Vendor status" - }, "voltage_phase_ab": { "name": "Voltage phase A-B" }, diff --git a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr index d0055341ecf0da..c2eb360a9b00b5 100644 --- a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr @@ -712,56 +712,6 @@ 'state': '47.67', }) # --- -# name: test_sensors[sensor.solaredge_se10000h_vendor_status-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': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.solaredge_se10000h_vendor_status', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Vendor status', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Vendor status', - 'platform': 'solaredge_modbus', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'vendor_status', - 'unique_id': '7E123ABC_vendor_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.solaredge_se10000h_vendor_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'SolarEdge SE10000H Vendor status', - }), - 'context': , - 'entity_id': 'sensor.solaredge_se10000h_vendor_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- # name: test_sensors[sensor.solaredge_se10000h_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ From 754691c5d6ad421d3d13efdbe8a9bf517032797b Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:10:56 +0200 Subject: [PATCH 08/37] Gate the Sofar waiting-ends sensor on an active countdown (#180605) --- homeassistant/components/sofar/sensor.py | 10 +++++++- tests/components/sofar/test_sensor.py | 30 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sofar/sensor.py b/homeassistant/components/sofar/sensor.py index f446a9af5e4d76..01d78cedfb9a06 100644 --- a/homeassistant/components/sofar/sensor.py +++ b/homeassistant/components/sofar/sensor.py @@ -7,6 +7,7 @@ from typing import cast, override from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.modern.enums import SystemState from homeassistant.components.sensor import ( RestoreSensor, @@ -142,6 +143,9 @@ def native_value(self) -> str | int | float | date | None: class SofarCountdownSensor(SofarSensor): """Defines a Sofar countdown, published as the moment it runs out.""" + # A positive register alone doesn't mean the countdown is active. + _ACTIVE_STATES = (SystemState.WAITING, SystemState.CHECKING) + def __init__( self, runtime_data: SofarRuntimeData, @@ -156,7 +160,11 @@ def __init__( def native_value(self) -> datetime | None: component = getattr(self.coordinator.device, self.entity_description.component) seconds = getattr(component, self.entity_description.key) - if not isinstance(seconds, int) or seconds <= 0: + if ( + not isinstance(seconds, int) + or seconds <= 0 + or component.system_state not in self._ACTIVE_STATES + ): # A restart must not land inside the finished countdown's slack. self._deadline = _deadline_filter() return None diff --git a/tests/components/sofar/test_sensor.py b/tests/components/sofar/test_sensor.py index 86998a9b50acc7..ab325c2584c52a 100644 --- a/tests/components/sofar/test_sensor.py +++ b/tests/components/sofar/test_sensor.py @@ -426,6 +426,7 @@ async def test_countdown_holds_its_deadline_until_it_restarts( """Test a countdown ticking with the clock keeps one deadline.""" mock_config_entry.add_to_hass(hass) unit = mock_connection.for_unit(1) + unit.holding[0x0404] = 0 # Waiting unit.holding[0x0417] = 300 with patch( @@ -460,6 +461,34 @@ async def test_countdown_holds_its_deadline_until_it_restarts( assert hass.states.get(entity_id).state != deadline +async def test_countdown_ignores_a_stale_register_while_grid_connected( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_connection: MockModbusConnection, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a positive register is ignored once the inverter is connected.""" + mock_config_entry.add_to_hass(hass) + unit = mock_connection.for_unit(1) + unit.holding[0x0404] = 2 # Grid connected + unit.holding[0x0417] = 60 # Left over from the last startup wait + + with patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: mock_connection.for_unit( + unit_id + ), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_waiting_time" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_countdown_restarting_after_idle_gets_a_new_deadline( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -470,6 +499,7 @@ async def test_countdown_restarting_after_idle_gets_a_new_deadline( """Test a finished countdown's deadline is not reused by the next one.""" mock_config_entry.add_to_hass(hass) unit = mock_connection.for_unit(1) + unit.holding[0x0404] = 0 # Waiting unit.holding[0x0417] = 10 with patch( From 193aa5113aca3ab2468395aed3e97b9c7a911493 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 29 Aug 2026 12:16:35 +0100 Subject: [PATCH 09/37] Update Mealie minimum to 3.2 (#180609) --- homeassistant/components/mealie/const.py | 2 +- homeassistant/components/mealie/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index d75dc20d745ff0..c267a8a8d693f2 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -20,4 +20,4 @@ ATTR_SEARCH_TERMS = "search_terms" ATTR_RESULT_LIMIT = "result_limit" -MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v2.0.0") +MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v3.2.0") diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index fc491c63b5279d..6c5e79403e79b9 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -16,7 +16,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "ingress_url": "Ingress URLs are only used for accessing the Mealie UI. Use your Home Assistant IP address and the network port within the configuration tab of the Mealie app.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "mealie_version": "Minimum required version is v2.0.0. Please upgrade Mealie and then retry.", + "mealie_version": "Minimum required version is v3.2.0. Please upgrade Mealie and then retry.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { From 6b0e63568a293f047239504407819ad446b9ff61 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 13:17:04 +0200 Subject: [PATCH 10/37] Ask MCP authorization servers for a refresh token (#180604) --- homeassistant/components/mcp/config_flow.py | 6 +++++- tests/components/mcp/test_config_flow.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index d0b0747494e655..8d01a016d487af 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -315,7 +315,11 @@ def authorization_server(self) -> AuthorizationServer: @override def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - data = {} + data = { + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } if self.data and (scopes := self.data[CONF_SCOPE]) is not None: data[CONF_SCOPE] = " ".join(scopes) data.update(super().extra_authorize_data) diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 17303fe0bf7a0a..048593e258eccf 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -341,7 +341,9 @@ async def perform_oauth_flow( assert result["url"] == ( f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={OAUTH_CALLBACK_URL}" - f"&state={state}{scope_param}" + f"&state={state}" + # Asked for so the server hands back a refresh token + f"&access_type=offline&prompt=consent{scope_param}" ) client = await hass_client_no_auth() From a84fd450013d16e67954d42750611ee50617cb8a Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:52:51 +0300 Subject: [PATCH 11/37] Add MQTT token pairing to Victron GX (#180590) --- .../components/victron_gx/config_flow.py | 173 ++++++-- homeassistant/components/victron_gx/const.py | 1 + .../components/victron_gx/manifest.json | 4 + .../components/victron_gx/strings.json | 15 +- homeassistant/generated/ssdp.py | 4 + .../components/victron_gx/test_config_flow.py | 393 +++++++++++++++++- 6 files changed, 534 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/victron_gx/config_flow.py b/homeassistant/components/victron_gx/config_flow.py index a74d8d72120745..f4b0361b24f7b8 100644 --- a/homeassistant/components/victron_gx/config_flow.py +++ b/homeassistant/components/victron_gx/config_flow.py @@ -5,10 +5,22 @@ from typing import Any, override from urllib.parse import urlparse -from victron_mqtt import AuthenticationError, CannotConnectError, Hub as VictronVenusHub +from victron_mqtt import ( + AuthenticationError, + CannotConnectError, + Hub as VictronVenusHub, + PairingError, + PairingToken, + request_pairing_token, +) import voluptuous as vol -from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_REAUTH, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_HOST, CONF_MODEL, @@ -21,10 +33,11 @@ from homeassistant.helpers.redact import async_redact_data from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo -from .const import CONF_INSTALLATION_ID, CONF_SERIAL, DOMAIN +from .const import CONF_INSTALLATION_ID, CONF_MQTT_TOKEN_PAIRING, CONF_SERIAL, DOMAIN DEFAULT_HOST = "venus.local" DEFAULT_PORT = 1883 +DEFAULT_SSL_PORT = 8883 _LOGGER = logging.getLogger(__name__) @@ -46,11 +59,10 @@ STEP_SSDP_AUTH_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_USERNAME, default=""): selector.TextSelector(), vol.Optional(CONF_PASSWORD, default=""): selector.TextSelector( selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) ), - vol.Optional(CONF_SSL, default=False): selector.BooleanSelector(), + vol.Optional(CONF_SSL): selector.BooleanSelector(), } ) @@ -110,6 +122,8 @@ def __init__(self) -> None: self.installation_id: str | None = None self.friendly_name: str | None = None self.model_name: str | None = None + self.mqtt_token_pairing = False + self.ssdp_use_ssl = True @override async def async_step_user( @@ -177,6 +191,7 @@ async def async_step_ssdp( self.installation_id = discovery_info.upnp["X_VrmPortalId"] self.model_name = discovery_info.upnp["modelName"] self.friendly_name = discovery_info.upnp["friendlyName"] + self.mqtt_token_pairing = discovery_info.upnp.get("X_MqttTokenPairing") == "1" await self.async_set_unique_id(self.installation_id) @@ -207,25 +222,6 @@ async def async_step_ssdp( "name": self.friendly_name or self.hostname } - # Verify connectivity before showing the confirmation dialog - try: - ssdp_conf = { - CONF_HOST: self.hostname, - CONF_PORT: DEFAULT_PORT, - CONF_SERIAL: self.serial, - CONF_INSTALLATION_ID: self.installation_id, - } - await validate_input(ssdp_conf) - except AuthenticationError: - return await self.async_step_ssdp_auth() - except CannotConnectError: - return self.async_abort(reason="cannot_connect") - except Exception: - _LOGGER.exception( - "Unexpected error validating SSDP discovery for Victron GX" - ) - return self.async_abort(reason="unknown") - return await self.async_step_ssdp_confirm() async def async_step_ssdp_confirm( @@ -236,25 +232,118 @@ async def async_step_ssdp_confirm( assert self.installation_id is not None if user_input is not None: + data: dict[str, Any] = { + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_SSL_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + CONF_MODEL: self.model_name, + CONF_SSL: True, + } + try: + await validate_input(data) + except AuthenticationError: + if self.mqtt_token_pairing: + return await self.async_step_ssdp_token_pairing() + return await self.async_step_ssdp_auth() + except CannotConnectError: + data[CONF_PORT] = DEFAULT_PORT + data[CONF_SSL] = False + self.ssdp_use_ssl = False + try: + await validate_input(data) + except AuthenticationError: + if self.mqtt_token_pairing: + return await self.async_step_ssdp_token_pairing() + return await self.async_step_ssdp_auth() + except CannotConnectError: + if self.mqtt_token_pairing: + return await self.async_step_ssdp_token_pairing() + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception( + "Unexpected error validating SSDP discovery for Victron GX" + ) + return self.async_abort(reason="unknown") + except Exception: + _LOGGER.exception( + "Unexpected error validating SSDP discovery for Victron GX" + ) + return self.async_abort(reason="unknown") + return self.async_create_entry( title=ENTRY_TITLE_FORMAT.format( installation_id=self.installation_id, host=self.hostname, - port=DEFAULT_PORT, + port=data[CONF_PORT], ), - data={ + data=data, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="ssdp_confirm", + description_placeholders={"name": self.friendly_name or self.hostname}, + ) + + async def async_step_ssdp_token_pairing( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle automatic token pairing with the GX device.""" + assert self.hostname is not None + assert self.installation_id is not None + + errors: dict[str, str] = {} + if user_input is not None: + try: + credentials: PairingToken = await request_pairing_token( + self.hostname, self.installation_id + ) + except PairingError: + errors["base"] = "pairing_failed" + except Exception: + _LOGGER.exception("Failed to connect to GX device for token pairing") + errors["base"] = "cannot_connect" + else: + data: dict[str, Any] = { CONF_HOST: self.hostname, - CONF_PORT: DEFAULT_PORT, + CONF_PORT: DEFAULT_SSL_PORT, CONF_SERIAL: self.serial, CONF_INSTALLATION_ID: self.installation_id, CONF_MODEL: self.model_name, - }, - ) + CONF_MQTT_TOKEN_PAIRING: True, + CONF_USERNAME: credentials.token_name, + CONF_PASSWORD: credentials.password, + CONF_SSL: True, + } + try: + await validate_input(data) + except AuthenticationError: + errors["base"] = "invalid_auth" + except CannotConnectError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating paired credentials") + errors["base"] = "unknown" + else: + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=data + ) + return self.async_create_entry( + title=ENTRY_TITLE_FORMAT.format( + installation_id=self.installation_id, + host=self.hostname, + port=DEFAULT_SSL_PORT, + ), + data=data, + ) self._set_confirm_only() return self.async_show_form( - step_id="ssdp_confirm", - description_placeholders={"name": self.friendly_name or self.hostname}, + step_id="ssdp_token_pairing", + errors=errors, + description_placeholders={CONF_HOST: self.hostname}, ) async def async_step_ssdp_auth( @@ -271,14 +360,16 @@ async def async_step_ssdp_auth( "SSDP auth user input received: %s", async_redact_data(user_input, TO_REDACT), ) + use_ssl = user_input.get(CONF_SSL, self.ssdp_use_ssl) data: dict[str, Any] = { CONF_HOST: self.hostname, - CONF_PORT: DEFAULT_PORT, + CONF_PORT: DEFAULT_SSL_PORT if use_ssl else DEFAULT_PORT, CONF_SERIAL: self.serial, CONF_INSTALLATION_ID: self.installation_id, - CONF_USERNAME: user_input.get(CONF_USERNAME), - CONF_PASSWORD: user_input.get(CONF_PASSWORD), - CONF_SSL: user_input.get(CONF_SSL), + CONF_MODEL: self.model_name, + CONF_USERNAME: "remoteconsole", + CONF_PASSWORD: user_input.get(CONF_PASSWORD) or None, + CONF_SSL: use_ssl, } try: @@ -298,7 +389,7 @@ async def async_step_ssdp_auth( title=ENTRY_TITLE_FORMAT.format( installation_id=self.installation_id, host=self.hostname, - port=DEFAULT_PORT, + port=data[CONF_PORT], ), data=data, ) @@ -306,7 +397,8 @@ async def async_step_ssdp_auth( return self.async_show_form( step_id="ssdp_auth", data_schema=self.add_suggested_values_to_schema( - STEP_SSDP_AUTH_DATA_SCHEMA, user_input + STEP_SSDP_AUTH_DATA_SCHEMA, + user_input or {CONF_SSL: self.ssdp_use_ssl}, ), errors=errors, description_placeholders={CONF_HOST: self.hostname}, @@ -368,6 +460,13 @@ async def async_step_reconfigure( async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: """Handle reauthentication.""" + reauth_entry = self._get_reauth_entry() + if reauth_entry.data.get(CONF_MQTT_TOKEN_PAIRING): + self.hostname = reauth_entry.data[CONF_HOST] + self.serial = reauth_entry.data.get(CONF_SERIAL) + self.installation_id = reauth_entry.data[CONF_INSTALLATION_ID] + self.model_name = reauth_entry.data.get(CONF_MODEL) + return await self.async_step_ssdp_token_pairing() return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/victron_gx/const.py b/homeassistant/components/victron_gx/const.py index b97e67e05876e1..6b51c92c58ba8a 100644 --- a/homeassistant/components/victron_gx/const.py +++ b/homeassistant/components/victron_gx/const.py @@ -3,6 +3,7 @@ DOMAIN = "victron_gx" CONF_INSTALLATION_ID = "installation_id" +CONF_MQTT_TOKEN_PAIRING = "mqtt_token_pairing" CONF_SERIAL = "serial" # Binary sensor enum ids must be "on" for on and "off" for off. diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index fbfbf52675f971..d1832f469e87c8 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -12,6 +12,10 @@ { "X_MqttOnLan": "1", "manufacturer": "Victron Energy" + }, + { + "X_MqttTokenPairing": "1", + "manufacturer": "Victron Energy" } ] } diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index e5f5e14660de71..c81ec9e17a73c4 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -118,6 +118,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "pairing_failed": "Token pairing failed. Ensure pairing mode is active on the GX device and try again.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -153,21 +154,23 @@ }, "ssdp_auth": { "data": { - "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "username": "[%key:common::config_flow::data::username%]" + "password": "GX password", + "ssl": "[%key:common::config_flow::data::ssl%]" }, "data_description": { "password": "[%key:component::victron_gx::config::step::user::data_description::password%]", - "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]", - "username": "[%key:component::victron_gx::config::step::user::data_description::username%]" + "ssl": "[%key:component::victron_gx::config::step::user::data_description::ssl%]" }, - "description": "Authentication is required to connect to {host}.", + "description": "Enter the GX password for the GX device at {host}.", "title": "Authenticate Victron GX" }, "ssdp_confirm": { "description": "Do you want to set up the Victron GX device {name}?" }, + "ssdp_token_pairing": { + "description": "Enable pairing mode on the GX device before submitting:\n\n- Via the GX user interface: **Settings** > **Integrations** > **MQTT Devices** > **Pairing mode**\n- On GX devices without a built-in screen: Quickly double-press the built-in button\n\nPairing mode is active for 120 seconds.", + "title": "Pair with GX device" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 8409d1d8f43627..cdf5317a2725d7 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -389,6 +389,10 @@ "X_MqttOnLan": "1", "manufacturer": "Victron Energy", }, + { + "X_MqttTokenPairing": "1", + "manufacturer": "Victron Energy", + }, ], "webostv": [ { diff --git a/tests/components/victron_gx/test_config_flow.py b/tests/components/victron_gx/test_config_flow.py index c3a31e0b4478a3..0a7a2c03985268 100644 --- a/tests/components/victron_gx/test_config_flow.py +++ b/tests/components/victron_gx/test_config_flow.py @@ -3,11 +3,20 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from victron_mqtt import AuthenticationError, CannotConnectError +from victron_mqtt import ( + AuthenticationError, + CannotConnectError, + PairingError, + PairingToken, +) -from homeassistant.components.victron_gx.config_flow import DEFAULT_PORT +from homeassistant.components.victron_gx.config_flow import ( + DEFAULT_PORT, + DEFAULT_SSL_PORT, +) from homeassistant.components.victron_gx.const import ( CONF_INSTALLATION_ID, + CONF_MQTT_TOKEN_PAIRING, CONF_SERIAL, DOMAIN, ) @@ -47,6 +56,25 @@ def assert_entry_title( assert result["title"] == f"Victron OS {installation_id} ({host}:{port})" +def ssdp_discovery_info(token_pairing: str = "0") -> SsdpServiceInfo: + """Return Victron GX SSDP discovery data.""" + upnp = { + "serialNumber": MOCK_SERIAL, + "X_VrmPortalId": MOCK_INSTALLATION_ID, + "modelName": MOCK_MODEL, + "friendlyName": MOCK_FRIENDLY_NAME, + "X_MqttOnLan": "1", + "X_MqttTokenPairing": token_pairing, + "manufacturer": "Victron Energy", + } + return SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location="http://192.168.1.100:80/", + upnp=upnp, + ) + + @pytest.fixture def mock_victron_hub(): """Mock the Victron Hub.""" @@ -234,13 +262,14 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == MOCK_INSTALLATION_ID - assert_entry_title(result) + assert_entry_title(result, port=DEFAULT_SSL_PORT) assert result["data"] == { CONF_HOST: MOCK_HOST, - CONF_PORT: DEFAULT_PORT, + CONF_PORT: DEFAULT_SSL_PORT, CONF_SERIAL: MOCK_SERIAL, CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, CONF_MODEL: MOCK_MODEL, + CONF_SSL: True, } @@ -280,6 +309,13 @@ async def test_ssdp_discovery_error( data=discovery_info, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason @@ -342,6 +378,13 @@ async def test_ssdp_flow_auth_required( data=discovery_info, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "ssdp_auth" @@ -352,22 +395,22 @@ async def test_ssdp_flow_auth_required( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == MOCK_INSTALLATION_ID - assert_entry_title(result) + assert_entry_title(result, port=DEFAULT_SSL_PORT) assert result["data"] == { CONF_HOST: MOCK_HOST, - CONF_PORT: DEFAULT_PORT, + CONF_PORT: DEFAULT_SSL_PORT, CONF_SERIAL: MOCK_SERIAL, CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, - CONF_USERNAME: "test-username", + CONF_MODEL: MOCK_MODEL, + CONF_USERNAME: "remoteconsole", CONF_PASSWORD: "test-password", - CONF_SSL: False, + CONF_SSL: True, } @@ -399,6 +442,13 @@ async def test_ssdp_auth_invalid_credentials( data=discovery_info, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "ssdp_auth" @@ -410,8 +460,8 @@ async def test_ssdp_auth_invalid_credentials( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_USERNAME: "wrong-user", CONF_PASSWORD: "wrong-password", + CONF_SSL: False, }, ) @@ -425,8 +475,8 @@ async def test_ssdp_auth_invalid_credentials( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_USERNAME: "test-user", CONF_PASSWORD: "test-password", + CONF_SSL: False, }, ) @@ -438,7 +488,8 @@ async def test_ssdp_auth_invalid_credentials( CONF_PORT: DEFAULT_PORT, CONF_SERIAL: MOCK_SERIAL, CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, - CONF_USERNAME: "test-user", + CONF_MODEL: MOCK_MODEL, + CONF_USERNAME: "remoteconsole", CONF_PASSWORD: "test-password", CONF_SSL: False, } @@ -482,6 +533,13 @@ async def test_ssdp_auth_error( data=discovery_info, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "ssdp_auth" @@ -490,8 +548,8 @@ async def test_ssdp_auth_error( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_USERNAME: "test-user", CONF_PASSWORD: "test-password", + CONF_SSL: False, }, ) @@ -641,6 +699,48 @@ async def test_reauth_flow_clears_credentials( assert mock_config_entry.data[CONF_SSL] is False +async def test_reauth_flow_renews_pairing_token( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test reauthentication renews generated pairing credentials.""" + token_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_INSTALLATION_ID, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_SSL_PORT, + CONF_USERNAME: "token/homeassistant/old", + CONF_PASSWORD: "old-password", + CONF_SSL: True, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_MQTT_TOKEN_PAIRING: True, + CONF_MODEL: MOCK_MODEL, + CONF_SERIAL: MOCK_SERIAL, + }, + title=(f"Victron OS {MOCK_INSTALLATION_ID} ({MOCK_HOST}:{DEFAULT_SSL_PORT})"), + ) + token_entry.add_to_hass(hass) + + result = await token_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_token_pairing" + + with patch( + "homeassistant.components.victron_gx.config_flow.request_pairing_token", + return_value=PairingToken("token/homeassistant/new", "new-generated-password"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert token_entry.data[CONF_USERNAME] == "token/homeassistant/new" + assert token_entry.data[CONF_PASSWORD] == "new-generated-password" + assert token_entry.data[CONF_MQTT_TOKEN_PAIRING] is True + mock_victron_hub.return_value.connect.assert_awaited_once() + + @pytest.mark.parametrize( ("exception", "error"), [ @@ -923,3 +1023,270 @@ async def test_ssdp_flow_abort_on_invalid_hostname( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_ssdp_flow_falls_back_to_plain_mqtt( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test SSDP setup falls back to plain MQTT when TLS is unavailable.""" + mock_victron_hub.return_value.connect.side_effect = [ + CannotConnectError("TLS unavailable"), + None, + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp_discovery_info(), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert_entry_title(result) + assert result["data"][CONF_PORT] == DEFAULT_PORT + assert result["data"][CONF_SSL] is False + + +@pytest.mark.parametrize( + ("second_exception", "result_type", "result_key", "result_value"), + [ + pytest.param( + AuthenticationError("Authentication required"), + FlowResultType.FORM, + "step_id", + "ssdp_auth", + id="authentication-required", + ), + pytest.param( + Exception("Unexpected error"), + FlowResultType.ABORT, + "reason", + "unknown", + id="unexpected-error", + ), + ], +) +async def test_ssdp_flow_tls_fallback_error( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + second_exception: Exception, + result_type: FlowResultType, + result_key: str, + result_value: str, +) -> None: + """Test errors while falling back from TLS during SSDP setup.""" + mock_victron_hub.return_value.connect.side_effect = [ + CannotConnectError("TLS unavailable"), + second_exception, + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp_discovery_info(), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is result_type + assert result[result_key] == result_value + + +async def test_ssdp_flow_preserves_plain_mqtt_for_authentication( + hass: HomeAssistant, mock_victron_hub: MagicMock +) -> None: + """Test SSDP authentication preserves the discovered plaintext transport.""" + mock_victron_hub.return_value.connect.side_effect = [ + CannotConnectError("TLS unavailable"), + AuthenticationError("Authentication required"), + None, + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp_discovery_info(), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "test-password"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert_entry_title(result) + assert result["data"][CONF_PORT] == DEFAULT_PORT + assert result["data"][CONF_SSL] is False + + +@pytest.mark.parametrize( + "initial_exceptions", + [ + pytest.param( + [AuthenticationError("Authentication required"), None], + id="authentication-required", + ), + pytest.param( + [ + CannotConnectError("TLS unavailable"), + AuthenticationError("Authentication required"), + None, + ], + id="plain-mqtt-authentication-required", + ), + pytest.param( + [ + CannotConnectError("TLS unavailable"), + CannotConnectError("Plain MQTT unavailable"), + None, + ], + id="mqtt-unavailable", + ), + ], +) +async def test_ssdp_token_pairing_success( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + initial_exceptions: list[Exception | None], +) -> None: + """Test SSDP token pairing creates an entry with generated credentials.""" + mock_victron_hub.return_value.connect.side_effect = initial_exceptions + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp_discovery_info("1"), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_token_pairing" + + with patch( + "homeassistant.components.victron_gx.config_flow.request_pairing_token", + return_value=PairingToken( + token_name="token/homeassistant/homeassistant_abc123", + password="generated-password", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert_entry_title(result, port=DEFAULT_SSL_PORT) + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_PORT: DEFAULT_SSL_PORT, + CONF_SERIAL: MOCK_SERIAL, + CONF_INSTALLATION_ID: MOCK_INSTALLATION_ID, + CONF_MODEL: MOCK_MODEL, + CONF_MQTT_TOKEN_PAIRING: True, + CONF_USERNAME: "token/homeassistant/homeassistant_abc123", + CONF_PASSWORD: "generated-password", + CONF_SSL: True, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + pytest.param( + PairingError("Pairing mode inactive"), + "pairing_failed", + id="pairing-rejected", + ), + pytest.param( + ConnectionError("Connection refused"), + "cannot_connect", + id="connection-error", + ), + ], +) +async def test_ssdp_token_pairing_request_error( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors while requesting token pairing credentials.""" + mock_victron_hub.return_value.connect.side_effect = AuthenticationError( + "Authentication required" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp_discovery_info("1"), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + with patch( + "homeassistant.components.victron_gx.config_flow.request_pairing_token", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_token_pairing" + assert result["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + pytest.param( + AuthenticationError("Invalid credentials"), + "invalid_auth", + id="invalid-auth", + ), + pytest.param( + CannotConnectError("Cannot connect"), + "cannot_connect", + id="cannot-connect", + ), + pytest.param(Exception("Unexpected error"), "unknown", id="unknown"), + ], +) +async def test_ssdp_token_pairing_validation_error( + hass: HomeAssistant, + mock_victron_hub: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors while validating generated pairing credentials.""" + mock_victron_hub.return_value.connect.side_effect = [ + AuthenticationError("Authentication required"), + exception, + ] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp_discovery_info("1"), + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + with patch( + "homeassistant.components.victron_gx.config_flow.request_pairing_token", + return_value=PairingToken("token/homeassistant/test", "password"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "ssdp_token_pairing" + assert result["errors"] == {"base": error} From eeec7a551e259d34fb543ac2151867afa8117e77 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 14:15:46 +0200 Subject: [PATCH 12/37] Refresh the Alexa to-do list after writing to it (#180598) --- .../components/alexa_devices/coordinator.py | 56 ++-- .../components/alexa_devices/todo.py | 110 ++++---- tests/components/alexa_devices/test_todo.py | 250 +++++++++++++++++- 3 files changed, 351 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 3430c2a5abc4e9..faf557f3c74037 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -1,5 +1,6 @@ """Support for Alexa Devices.""" +from asyncio import Lock from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from datetime import timedelta @@ -167,6 +168,7 @@ def __init__( } self._todo_list_items: dict[str, dict[str, AmazonListItem]] = {} + self._todo_refresh_lock = Lock() self.api.on_todo_event.append(self.todo_event_handler) self.api.on_todo_event.freeze() @@ -308,23 +310,45 @@ async def sync_todo_list_items(self) -> None: todo_list.id ] = await self.api.get_todo_list_items(todo_list.id) + async def refresh_todo_list_items(self, list_id: str) -> None: + """Refresh the cached items of a single to-do list. + + Cached items are otherwise only filled by the initial sync and by + pushed events, so a write of our own needs a pull to become visible. + + The pulls are serialized, as an older answer landing last would leave + the cache behind with nothing to repair it. + """ + async with self._todo_refresh_lock, alexa_api_call(self): + self._todo_list_items[list_id] = await self.api.get_todo_list_items(list_id) + + # Reading the list back proves the API answers again + self.last_update_success = True + + self.async_update_listeners() + async def todo_event_handler(self, list_event: AmazonListEvent) -> None: - """Handle changes on To-Do lists.""" - if list_event.type == AmazonListEventType.DELETED: - self._todo_list_items.get(list_event.list_id, {}).pop( - list_event.item_id, None - ) - elif ( - list_event.type - in (AmazonListEventType.UPDATED, AmazonListEventType.CREATED) - ) and list_event.items: - if list_event.list_id not in self._todo_list_items: - # List was newly created after initial sync - self._todo_list_items[list_event.list_id] = {} - - self._todo_list_items[list_event.list_id][list_event.item_id] = ( - list_event.items - ) + """Handle changes on To-Do lists. + + Takes the refresh lock, so an event arriving while a list is being + read back is applied on top of that read instead of under it. + """ + async with self._todo_refresh_lock: + if list_event.type == AmazonListEventType.DELETED: + self._todo_list_items.get(list_event.list_id, {}).pop( + list_event.item_id, None + ) + elif ( + list_event.type + in (AmazonListEventType.UPDATED, AmazonListEventType.CREATED) + ) and list_event.items: + if list_event.list_id not in self._todo_list_items: + # List was newly created after initial sync + self._todo_list_items[list_event.list_id] = {} + + self._todo_list_items[list_event.list_id][list_event.item_id] = ( + list_event.items + ) self.async_update_listeners() diff --git a/homeassistant/components/alexa_devices/todo.py b/homeassistant/components/alexa_devices/todo.py index bd5d05219c7bcf..fa145d1232e4fa 100644 --- a/homeassistant/components/alexa_devices/todo.py +++ b/homeassistant/components/alexa_devices/todo.py @@ -126,6 +126,8 @@ async def async_create_todo_item(self, item: TodoItem) -> None: self._list.name, ) + await self.coordinator.refresh_todo_list_items(self._list.id) + @override async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete items from the to-do list.""" @@ -133,25 +135,29 @@ async def async_delete_todo_items(self, uids: list[str]) -> None: list_items_lookup = self.coordinator.todo_list_items[self._list.id] - for uid in uids: - existing_item = list_items_lookup[uid] + try: + for uid in uids: + existing_item = list_items_lookup[uid] - LOGGER.debug( - "Deleting item %s (ID: %s) with version %s", - existing_item.name, - uid, - existing_item.version, - ) - async with alexa_api_call(self.coordinator): - await self.coordinator.api.delete_todo_list_item( - self._list.id, uid, existing_item.version + LOGGER.debug( + "Deleting item %s (ID: %s) with version %s", + existing_item.name, + uid, + existing_item.version, ) - LOGGER.debug( - "Successfully deleted item %s (ID: %s) with version %s", - existing_item.name, - uid, - existing_item.version, - ) + async with alexa_api_call(self.coordinator): + await self.coordinator.api.delete_todo_list_item( + self._list.id, uid, existing_item.version + ) + LOGGER.debug( + "Successfully deleted item %s (ID: %s) with version %s", + existing_item.name, + uid, + existing_item.version, + ) + finally: + # A later delete can fail after an earlier one went through + await self.coordinator.refresh_todo_list_items(self._list.id) @override async def async_update_todo_item(self, item: TodoItem) -> None: @@ -166,39 +172,53 @@ async def async_update_todo_item(self, item: TodoItem) -> None: existing_item = list_items_lookup[item.uid] - if has_completed_changed := ( + has_completed_changed = ( existing_item.status == AmazonListItemStatus.COMPLETE - ) != (item.status == TodoItemStatus.COMPLETED): - # Update the checked status - LOGGER.debug( - "Updating item %s with checked status %s", item.uid, item.status - ) + ) != (item.status == TodoItemStatus.COMPLETED) + has_renamed = existing_item.name != item.summary - async with alexa_api_call(self.coordinator): - await self.coordinator.api.set_todo_list_item_checked_status( - self._list.id, - item.uid, - item.status == TodoItemStatus.COMPLETED, - existing_item.version, + if not has_completed_changed and not has_renamed: + return + + try: + if has_completed_changed: + # Update the checked status + LOGGER.debug( + "Updating item %s with checked status %s", item.uid, item.status ) - LOGGER.debug( - "Successfully updated item %s with checked status %s", - item.uid, - item.status, - ) + async with alexa_api_call(self.coordinator): + await self.coordinator.api.set_todo_list_item_checked_status( + self._list.id, + item.uid, + item.status == TodoItemStatus.COMPLETED, + existing_item.version, + ) + + LOGGER.debug( + "Successfully updated item %s with checked status %s", + item.uid, + item.status, + ) - if existing_item.name != item.summary: - # Name has changed, update it - LOGGER.debug("Updating item %s with new name %s", item.uid, item.summary) + if has_renamed: + # Name has changed, update it + LOGGER.debug( + "Updating item %s with new name %s", item.uid, item.summary + ) - # If both have changed -> Increase item version by 1 - version = existing_item.version + int(has_completed_changed) + # If both have changed -> Increase item version by 1 + version = existing_item.version + int(has_completed_changed) - async with alexa_api_call(self.coordinator): - await self.coordinator.api.rename_todo_list_item( - self._list.id, item.uid, item.summary, version + async with alexa_api_call(self.coordinator): + await self.coordinator.api.rename_todo_list_item( + self._list.id, item.uid, item.summary, version + ) + LOGGER.debug( + "Successfully updated item %s with new name %s", + item.uid, + item.summary, ) - LOGGER.debug( - "Successfully updated item %s with new name %s", item.uid, item.summary - ) + finally: + # A rename can fail after the status change went through + await self.coordinator.refresh_todo_list_items(self._list.id) diff --git a/tests/components/alexa_devices/test_todo.py b/tests/components/alexa_devices/test_todo.py index c751975cd765d3..c3d496d9833ca9 100644 --- a/tests/components/alexa_devices/test_todo.py +++ b/tests/components/alexa_devices/test_todo.py @@ -1,5 +1,7 @@ """Test Alexa Devices todo entities.""" +import asyncio +from dataclasses import replace from typing import Any from unittest.mock import AsyncMock, patch @@ -26,6 +28,7 @@ from homeassistant.config_entries import ConfigEntryState 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 homeassistant.util import slugify @@ -115,7 +118,7 @@ async def test_all_entities( """Test all entities.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( - side_effect=lambda list_id: mock_todo_items.get(list_id, {}) + side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.TODO]): @@ -132,12 +135,31 @@ async def test_add_todo_item( ) -> None: """Test adding a todo item.""" mock_amazon_devices_client.todo_lists = mock_todo_lists - mock_amazon_devices_client.get_todo_list_items = AsyncMock(return_value={}) + list_items: dict[str, AmazonListItem] = {} + mock_amazon_devices_client.get_todo_list_items = AsyncMock( + side_effect=lambda list_id: dict(list_items) + ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID + assert hass.states.get(entity_id).state == "0" + + # Amazon has the item from the moment the call returns + mock_amazon_devices_client.add_todo_list_item = AsyncMock( + side_effect=lambda list_id, name: list_items.update( + { + "item_6": AmazonListItem( + id="item_6", + name=name, + status=AmazonListItemStatus.ACTIVE, + version=1, + ) + } + ) + ) + await hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, @@ -148,6 +170,132 @@ async def test_add_todo_item( mock_amazon_devices_client.add_todo_list_item.assert_called_once_with( "todo_list_id", "New Task" ) + assert hass.states.get(entity_id).state == "1" + + +async def test_concurrent_writes_keep_the_newest_answer( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_todo_lists: list[AmazonListInfo], +) -> None: + """Test a slow read of an older list does not overwrite a newer one.""" + mock_amazon_devices_client.todo_lists = mock_todo_lists + list_items: dict[str, AmazonListItem] = {} + mock_amazon_devices_client.get_todo_list_items = AsyncMock( + side_effect=lambda list_id: dict(list_items) + ) + + await setup_integration(hass, mock_config_entry) + + entity_id = MOCK_TODO_LIST_ENTITY_ID + + def add_item(list_id: str, name: str) -> None: + list_items[name] = AmazonListItem( + id=name, name=name, status=AmazonListItemStatus.ACTIVE, version=1 + ) + + mock_amazon_devices_client.add_todo_list_item = AsyncMock(side_effect=add_item) + + # Hold the first read until both items have been written + released = asyncio.Event() + reads = 0 + + async def read_items(list_id: str) -> dict[str, AmazonListItem]: + nonlocal reads + reads += 1 + items = dict(list_items) + if reads == 1: + await released.wait() + return items + + mock_amazon_devices_client.get_todo_list_items = AsyncMock(side_effect=read_items) + + writes = asyncio.gather( + *[ + hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + {ATTR_ENTITY_ID: entity_id, "item": item}, + blocking=True, + ) + for item in ("First task", "Second task") + ] + ) + await asyncio.sleep(0) + released.set() + await writes + + assert hass.states.get(entity_id).state == "2" + + +async def test_pushed_event_survives_a_refresh( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_todo_lists: list[AmazonListInfo], +) -> None: + """Test an event arriving during a read back is not lost by that read.""" + mock_amazon_devices_client.todo_lists = mock_todo_lists + list_items: dict[str, AmazonListItem] = {} + mock_amazon_devices_client.get_todo_list_items = AsyncMock( + side_effect=lambda list_id: dict(list_items) + ) + + await setup_integration(hass, mock_config_entry) + coordinator = mock_config_entry.runtime_data + + entity_id = MOCK_TODO_LIST_ENTITY_ID + + def add_item(list_id: str, name: str) -> None: + list_items[name] = AmazonListItem( + id=name, name=name, status=AmazonListItemStatus.ACTIVE, version=1 + ) + + mock_amazon_devices_client.add_todo_list_item = AsyncMock(side_effect=add_item) + + # Hold the read back until the event has been handled + released = asyncio.Event() + + async def read_items(list_id: str) -> dict[str, AmazonListItem]: + items = dict(list_items) + await released.wait() + return items + + mock_amazon_devices_client.get_todo_list_items = AsyncMock(side_effect=read_items) + + write = asyncio.create_task( + hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + {ATTR_ENTITY_ID: entity_id, "item": "Written task"}, + blocking=True, + ) + ) + await asyncio.sleep(0) + + # Alexa reports an item of its own while the read back is in flight + pushed = asyncio.create_task( + coordinator.todo_event_handler( + AmazonListEvent( + list_id="todo_list_id", + item_id="item_6", + type=AmazonListEventType.CREATED, + items=AmazonListItem( + id="item_6", + name="Spoken task", + status=AmazonListItemStatus.ACTIVE, + version=1, + ), + ) + ) + ) + await asyncio.sleep(0) + released.set() + await write + await pushed + + assert hass.states.get(entity_id).state == "2" async def test_delete_todo_item( @@ -160,13 +308,22 @@ async def test_delete_todo_item( """Test deleting a todo item.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( - side_effect=lambda list_id: mock_todo_items.get(list_id, {}) + side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID + assert hass.states.get(entity_id).state == "1" + + # Amazon has dropped the item from the moment the call returns + mock_amazon_devices_client.delete_todo_list_item = AsyncMock( + side_effect=lambda list_id, item_id, version: mock_todo_items[list_id].pop( + item_id + ) + ) + # Delete item_2 await hass.services.async_call( TODO_DOMAIN, @@ -178,6 +335,48 @@ async def test_delete_todo_item( mock_amazon_devices_client.delete_todo_list_item.assert_called_once_with( "todo_list_id", "item_2", 1 ) + assert hass.states.get(entity_id).state == "0" + + +async def test_delete_todo_items_partial_failure( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_todo_lists: list[AmazonListInfo], + mock_todo_items: dict[str, Any], +) -> None: + """Test a delete that went through is not left in the cache by a later failure.""" + mock_amazon_devices_client.todo_lists = mock_todo_lists + mock_amazon_devices_client.get_todo_list_items = AsyncMock( + side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) + ) + + await setup_integration(hass, mock_config_entry) + + entity_id = MOCK_TODO_LIST_ENTITY_ID + + assert hass.states.get(entity_id).state == "1" + + # Amazon drops item_2 and then stops answering + def delete_item(list_id: str, item_id: str, version: int) -> None: + if item_id == "item_3": + raise CannotConnect + mock_todo_items[list_id].pop(item_id) + + mock_amazon_devices_client.delete_todo_list_item = AsyncMock( + side_effect=delete_item + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.REMOVE_ITEM, + {ATTR_ENTITY_ID: entity_id, "item": ["item_2", "item_3"]}, + blocking=True, + ) + + # Reading the list back worked, so the entity stays usable + assert hass.states.get(entity_id).state == "0" async def test_update_todo_item( @@ -190,7 +389,7 @@ async def test_update_todo_item( """Test updating a todo item.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( - side_effect=lambda list_id: mock_todo_items.get(list_id, {}) + side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) await setup_integration(hass, mock_config_entry) @@ -254,6 +453,49 @@ async def test_update_todo_item( ) +async def test_update_todo_item_refreshes_state( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_todo_lists: list[AmazonListInfo], + mock_todo_items: dict[str, Any], +) -> None: + """Test the entity reflects an updated item once the call returns.""" + mock_amazon_devices_client.todo_lists = mock_todo_lists + mock_amazon_devices_client.get_todo_list_items = AsyncMock( + side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) + ) + + await setup_integration(hass, mock_config_entry) + + entity_id = MOCK_TODO_LIST_ENTITY_ID + + assert hass.states.get(entity_id).state == "1" + + # Amazon has the item checked from the moment the call returns + def check_item(list_id: str, item_id: str, checked: bool, version: int) -> None: + mock_todo_items[list_id][item_id] = replace( + mock_todo_items[list_id][item_id], status=AmazonListItemStatus.COMPLETE + ) + + mock_amazon_devices_client.set_todo_list_item_checked_status = AsyncMock( + side_effect=check_item + ) + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + { + ATTR_ENTITY_ID: entity_id, + "item": "item_2", + "status": TodoItemStatus.COMPLETED, + }, + blocking=True, + ) + + assert hass.states.get(entity_id).state == "0" + + @pytest.mark.parametrize( ("initial_lists", "updated_lists"), [ From 92983e4022f6552990dc1141b6ae7916eb4f5093 Mon Sep 17 00:00:00 2001 From: StellarSea Date: Sat, 29 Aug 2026 21:16:34 +0900 Subject: [PATCH 13/37] Fix SmartThings AC temperature range unit conversion (#180299) Co-authored-by: Claude Opus 5 Co-authored-by: TheJulianJES --- .../components/smartthings/climate.py | 37 ++++++++++++++--- tests/components/smartthings/test_climate.py | 40 +++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 63e57b50a8f64a..9ff307942241c4 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -29,6 +29,10 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import ( + TemperatureConverter, + TemperatureDeltaConverter, +) from . import FullDevice, SmartThingsConfigEntry from .const import DOMAIN, MAIN, UNIT_MAP @@ -608,27 +612,50 @@ def _get_setpoint_range_value(self, key: str) -> float | None: return None return setpoint_range.get(key) + @property + def _setpoint_range_unit(self) -> str: + """Return the unit the cooling setpoint range is reported in.""" + if ( + unit := self._internal_state[Capability.THERMOSTAT_COOLING_SETPOINT][ + Attribute.COOLING_SETPOINT_RANGE + ].unit + ) is None: + return self.temperature_unit + return UNIT_MAP[unit] + @property @override def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - return self._get_setpoint_range_value("step") + if (step := self._get_setpoint_range_value("step")) is None: + return None + return TemperatureDeltaConverter.convert( + step, self._setpoint_range_unit, self.temperature_unit + ) @property @override def min_temp(self) -> float: """Return the minimum temperature.""" if (minimum := self._get_setpoint_range_value("minimum")) is None: - return DEFAULT_MIN_TEMP - return minimum + return TemperatureConverter.convert( + DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + ) + return TemperatureConverter.convert( + minimum, self._setpoint_range_unit, self.temperature_unit + ) @property @override def max_temp(self) -> float: """Return the maximum temperature.""" if (maximum := self._get_setpoint_range_value("maximum")) is None: - return DEFAULT_MAX_TEMP - return maximum + return TemperatureConverter.convert( + DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + ) + return TemperatureConverter.convert( + maximum, self._setpoint_range_unit, self.temperature_unit + ) @property @override diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index be68cc0da838e1..77b48acc339ba6 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -51,6 +51,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import ( set_attribute_value, @@ -843,6 +844,45 @@ async def test_ac_setpoint_range_update( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1 +@pytest.mark.parametrize("device_fixture", ["aux_ac"]) +async def test_ac_default_temperature_range_in_device_unit( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the default temperature range is expressed in the unit of the device.""" + hass.config.units = US_CUSTOMARY_SYSTEM + devices.get_device_status.return_value[MAIN][Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit = "F" + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.aux_a_c_on_off") + assert state.attributes[ATTR_MIN_TEMP] == 45 + assert state.attributes[ATTR_MAX_TEMP] == 95 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_01001"]) +async def test_ac_setpoint_range_converted_to_device_unit( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the setpoint range is converted when it uses another unit.""" + hass.config.units = US_CUSTOMARY_SYSTEM + devices.get_device_status.return_value[MAIN][Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit = "F" + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.theater_aire_dormitorio_principal") + assert state.attributes[ATTR_MIN_TEMP] == 61 + assert state.attributes[ATTR_MAX_TEMP] == 86 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.8 + + @pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) async def test_thermostat_set_fan_mode( hass: HomeAssistant, From 325bc5f91fa44b9a387faa3a53cdbd0b7bdba723 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 29 Aug 2026 14:17:17 +0200 Subject: [PATCH 14/37] FIx airflow and power rate availability for Midea (#180613) --- homeassistant/components/midea/select.py | 15 +++++++++ tests/components/midea/test_select.py | 43 ++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/midea/select.py b/homeassistant/components/midea/select.py index 02f3c0db46a4d5..b2ef4afcaba019 100644 --- a/homeassistant/components/midea/select.py +++ b/homeassistant/components/midea/select.py @@ -21,6 +21,8 @@ class MideaSelectEntityDescription(SelectEntityDescription): models: list[DeviceType] options_attribute: str """Device property name returning the list of valid option strings.""" + requires_power: bool = False + """Entity is only operable while the device's ``power`` attribute is on.""" SELECTS: list[MideaSelectEntityDescription] = [ @@ -47,18 +49,21 @@ class MideaSelectEntityDescription(SelectEntityDescription): translation_key="wind_lr_angle", models=[DeviceType.AC], options_attribute="wind_lr_angles", + requires_power=True, ), MideaSelectEntityDescription( key="wind_ud_angle", translation_key="wind_ud_angle", models=[DeviceType.AC], options_attribute="wind_ud_angles", + requires_power=True, ), MideaSelectEntityDescription( key="rate_select", translation_key="rate_select", models=[DeviceType.AC], options_attribute="rate_selects", + requires_power=True, ), MideaSelectEntityDescription( key="silent_level", @@ -126,6 +131,16 @@ class MideaSelect(MideaEntity, SelectEntity): entity_description: MideaSelectEntityDescription + @property + @override + def available(self) -> bool: + """Return entity availability.""" + if not super().available: + return False + if self.entity_description.requires_power: + return bool(self._device.get_attribute("power")) + return True + @property @override def options(self) -> list[str]: diff --git a/tests/components/midea/test_select.py b/tests/components/midea/test_select.py index 29259f2cfd86dd..b6dd04cc09b5fb 100644 --- a/tests/components/midea/test_select.py +++ b/tests/components/midea/test_select.py @@ -22,13 +22,13 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, 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 .conftest import DummyDevice, SetDeviceAttribute, entity_entries from .const import TEST_DEVICE_ID from tests.common import MockConfigEntry, snapshot_platform @@ -230,6 +230,45 @@ async def test_ac_angle_and_rate_selects( ) +@pytest.mark.parametrize( + "unique_id", + [ + f"{TEST_DEVICE_ID}_wind_lr_angle", + f"{TEST_DEVICE_ID}_wind_ud_angle", + f"{TEST_DEVICE_ID}_rate_select", + ], +) +async def test_ac_selects_unavailable_when_power_off( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + set_device_attribute: SetDeviceAttribute, + unique_id: str, +) -> None: + """Test AC angle/rate selects are unavailable while the unit is powered off.""" + device = _ac_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entity_id = entity_entries(hass, config_entry)[unique_id].entity_id + assert (state := hass.states.get(entity_id)) is not None + assert state.state != STATE_UNAVAILABLE + + await set_device_attribute(device, ACAttributes.power, False) + assert (state := hass.states.get(entity_id)) is not None + assert state.state == STATE_UNAVAILABLE + + await set_device_attribute(device, ACAttributes.power, True) + assert (state := hass.states.get(entity_id)) is not None + assert state.state != STATE_UNAVAILABLE + + device.available = False + device.notify_update({"available": False}) + await hass.async_block_till_done() + assert (state := hass.states.get(entity_id)) is not None + assert state.state == STATE_UNAVAILABLE + + async def test_fa_selects_do_not_overlap_fan_platform( hass: HomeAssistant, mock_config_entry: Callable[[DummyDevice], MockConfigEntry], From 304788935a97a9b12de4889aa3fec7aae68b2560 Mon Sep 17 00:00:00 2001 From: Thomas Rupprecht Date: Sat, 29 Aug 2026 14:18:06 +0200 Subject: [PATCH 15/37] Report a sleeping Roborock Q7 job as paused (#180432) Co-authored-by: Claude Opus 5 --- homeassistant/components/roborock/vacuum.py | 3 +++ tests/components/roborock/test_vacuum.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 853572e4ff9765..ee8ba8bb49687d 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -75,6 +75,9 @@ WorkStatusMapping.SLEEPING: VacuumActivity.IDLE, WorkStatusMapping.WAITING_FOR_ORDERS: VacuumActivity.IDLE, WorkStatusMapping.PAUSED: VacuumActivity.PAUSED, + # An untouched pause falls asleep after ~10 minutes; the job is kept and + # resumes on the next start, so this is a pause rather than an idle state. + WorkStatusMapping.WORKING_SLEEP: VacuumActivity.PAUSED, WorkStatusMapping.DOCKING: VacuumActivity.RETURNING, WorkStatusMapping.CHARGING: VacuumActivity.DOCKED, WorkStatusMapping.SWEEP_MOPING: VacuumActivity.CLEANING, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 00980de44328ab..b0afaf43b1a3a5 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -6,6 +6,7 @@ import pytest from roborock import RoborockException +from roborock.data import WorkStatusMapping from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXFanLevel from roborock.roborock_typing import RoborockCommand from syrupy.assertion import SnapshotAssertion @@ -890,6 +891,26 @@ async def test_q7_activity_none_status( assert vacuum.state == "unknown" +async def test_q7_working_sleep_is_paused( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q7_vacuum: FakeDevice, +) -> None: + """Test a cleaning job that fell asleep is reported as paused.""" + assert fake_q7_vacuum.b01_q7_properties is not None + fake_q7_vacuum.b01_q7_properties._props_data.status = ( + WorkStatusMapping.WORKING_SLEEP + ) + + coordinator = setup_entry.runtime_data.b01_q7[0] + await coordinator.async_refresh() + await hass.async_block_till_done() + + vacuum = hass.states.get(Q7_ENTITY_ID) + assert vacuum + assert vacuum.state == "paused" + + @pytest.fixture(name="q10_vacuum_api", autouse=False) def fake_q10_vacuum_api_fixture( fake_q10_vacuum: FakeDevice, From 5be245735f44d2e7434cc64a4d379f9c0b5da4cd Mon Sep 17 00:00:00 2001 From: moryoav Date: Sat, 29 Aug 2026 15:26:41 +0300 Subject: [PATCH 16/37] Add Besen sensor platform (#180585) --- homeassistant/components/besen/const.py | 2 +- homeassistant/components/besen/manifest.json | 2 +- homeassistant/components/besen/sensor.py | 197 ++++ homeassistant/components/besen/strings.json | 13 + requirements_all.txt | 2 +- tests/components/besen/conftest.py | 40 +- .../besen/snapshots/test_sensor.ambr | 1045 +++++++++++++++++ tests/components/besen/test_sensor.py | 180 +++ tests/components/besen/test_switch.py | 13 +- 9 files changed, 1474 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/besen/sensor.py create mode 100644 tests/components/besen/snapshots/test_sensor.ambr create mode 100644 tests/components/besen/test_sensor.py diff --git a/homeassistant/components/besen/const.py b/homeassistant/components/besen/const.py index f9f2f853fb7746..052084ab68f140 100644 --- a/homeassistant/components/besen/const.py +++ b/homeassistant/components/besen/const.py @@ -7,4 +7,4 @@ DOMAIN: Final = "besen" NAME: Final = "Besen" -PLATFORMS: Final = [Platform.SWITCH] +PLATFORMS: Final = [Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/besen/manifest.json b/homeassistant/components/besen/manifest.json index 0be7d16a437b64..531b1d7e5408c5 100644 --- a/homeassistant/components/besen/manifest.json +++ b/homeassistant/components/besen/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["besen==0.3.4"] + "requirements": ["besen==0.4.0"] } diff --git a/homeassistant/components/besen/sensor.py b/homeassistant/components/besen/sensor.py new file mode 100644 index 00000000000000..04de805d343ed1 --- /dev/null +++ b/homeassistant/components/besen/sensor.py @@ -0,0 +1,197 @@ +"""Sensor platform for Besen.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from besen.models import BesenData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BesenConfigEntry +from .coordinator import BesenCoordinator +from .entity import BesenEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class BesenSensorEntityDescription(SensorEntityDescription): + """Describe a Besen sensor entity.""" + + value_fn: Callable[[BesenData], float | int | None] + three_phase_only: bool = False + + +SENSOR_DESCRIPTIONS: tuple[BesenSensorEntityDescription, ...] = ( + BesenSensorEntityDescription( + key="charging_power", + translation_key="charging_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.charge.power, + ), + BesenSensorEntityDescription( + key="total_energy", + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda data: data.charge.total_energy, + ), + BesenSensorEntityDescription( + key="session_energy", + translation_key="session_energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda data: data.charge.session_energy, + ), + BesenSensorEntityDescription( + key="internal_temperature", + translation_key="internal_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.charge.inner_temp_c, + ), + BesenSensorEntityDescription( + key="external_temperature", + translation_key="external_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.charge.outer_temp, + ), + BesenSensorEntityDescription( + key="l1_voltage", + translation_key="l1_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.charge.l1_voltage, + ), + BesenSensorEntityDescription( + key="l1_current", + translation_key="l1_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.charge.l1_amperage, + ), + BesenSensorEntityDescription( + key="l2_voltage", + translation_key="l2_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + three_phase_only=True, + value_fn=lambda data: data.charge.l2_voltage, + ), + BesenSensorEntityDescription( + key="l2_current", + translation_key="l2_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + three_phase_only=True, + value_fn=lambda data: data.charge.l2_amperage, + ), + BesenSensorEntityDescription( + key="l3_voltage", + translation_key="l3_voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + three_phase_only=True, + value_fn=lambda data: data.charge.l3_voltage, + ), + BesenSensorEntityDescription( + key="l3_current", + translation_key="l3_current", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + three_phase_only=True, + value_fn=lambda data: data.charge.l3_amperage, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BesenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Besen sensors.""" + + coordinator = entry.runtime_data + async_add_entities( + BesenSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + if not description.three_phase_only or coordinator.data.info.phases == 3 + ) + + +class BesenSensor(BesenEntity, SensorEntity): + """Representation of a Besen sensor.""" + + entity_description: BesenSensorEntityDescription + + def __init__( + self, + coordinator: BesenCoordinator, + description: BesenSensorEntityDescription, + ) -> None: + """Initialize a Besen sensor.""" + + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/besen/strings.json b/homeassistant/components/besen/strings.json index 4fb426652c0c7f..b7345780d09885 100644 --- a/homeassistant/components/besen/strings.json +++ b/homeassistant/components/besen/strings.json @@ -38,6 +38,19 @@ } }, "entity": { + "sensor": { + "charging_power": { "name": "Charging power" }, + "external_temperature": { "name": "External temperature" }, + "internal_temperature": { "name": "Internal temperature" }, + "l1_current": { "name": "L1 current" }, + "l1_voltage": { "name": "L1 voltage" }, + "l2_current": { "name": "L2 current" }, + "l2_voltage": { "name": "L2 voltage" }, + "l3_current": { "name": "L3 current" }, + "l3_voltage": { "name": "L3 voltage" }, + "session_energy": { "name": "Session energy" }, + "total_energy": { "name": "Total energy" } + }, "switch": { "charging": { "name": "Charge" } } diff --git a/requirements_all.txt b/requirements_all.txt index 3ffad0728a2dea..64b98edfa1b3b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -661,7 +661,7 @@ batinfo==0.4.2 beautifulsoup4==4.13.3 # homeassistant.components.besen -besen==0.3.4 +besen==0.4.0 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/tests/components/besen/conftest.py b/tests/components/besen/conftest.py index ee4d5fb1707731..9cc0a1ffa34dd0 100644 --- a/tests/components/besen/conftest.py +++ b/tests/components/besen/conftest.py @@ -6,9 +6,9 @@ from besen.models import BesenData, ChargerConfig, ChargerInfo, ChargeStatus import pytest -from homeassistant.components.besen.const import DOMAIN +from homeassistant.components.besen.const import DOMAIN, PLATFORMS from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN, Platform from homeassistant.core import HomeAssistant from . import publish_besen_state @@ -52,6 +52,8 @@ def charger_state( charger_status: bool | None = True, available: bool = True, authenticated: bool = True, + phases: int = 1, + charge: ChargeStatus | None = None, ) -> BesenData: """Return a populated charger state.""" @@ -59,19 +61,30 @@ def charger_state( info=ChargerInfo( address=FIXTURE_ADDRESS, serial="SERIAL", - phases=1, + phases=phases, manufacturer="Besen", model="BS20", hardware_version="HW1", software_version="SW1", ), config=ChargerConfig(device_name="Garage", rssi=-55), - charge=ChargeStatus( - charger_status=charger_status, - current_energy=3500, - total_energy=1.2, - current_amount=12.3, - inner_temp_c=24.5, + charge=( + charge + if charge is not None + else ChargeStatus( + charger_status=charger_status, + power=3500, + total_energy=12.3, + session_energy=1.2, + inner_temp_c=24.5, + outer_temp=22.5, + l1_voltage=230.0, + l1_amperage=15.2, + l2_voltage=231.0, + l2_amperage=15.1, + l3_voltage=232.0, + l3_amperage=15.0, + ) ), available=available, authenticated=authenticated, @@ -179,9 +192,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: async def setup_integration( hass: HomeAssistant, entry: MockConfigEntry, + platforms: list[Platform] | None = None, ) -> None: """Set up the Besen integration.""" entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.besen.PLATFORMS", + platforms if platforms is not None else PLATFORMS, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/besen/snapshots/test_sensor.ambr b/tests/components/besen/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..ef3c9a4baf5cc6 --- /dev/null +++ b/tests/components/besen/snapshots/test_sensor.ambr @@ -0,0 +1,1045 @@ +# serializer version: 1 +# name: test_sensor_state[single_phase][sensor.garage_charging_power-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.garage_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'AA:BB_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage Charging power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3500', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_external_temperature-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': , + 'entity_id': 'sensor.garage_external_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External temperature', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': 'AA:BB_external_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Garage External temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_internal_temperature-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.garage_internal_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Internal temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Internal temperature', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'internal_temperature', + 'unique_id': 'AA:BB_internal_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_internal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Garage Internal temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_internal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.5', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_l1_current-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': , + 'entity_id': 'sensor.garage_l1_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L1 current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L1 current', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l1_current', + 'unique_id': 'AA:BB_l1_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_l1_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Garage L1 current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_l1_voltage-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': , + 'entity_id': 'sensor.garage_l1_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L1 voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L1 voltage', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l1_voltage', + 'unique_id': 'AA:BB_l1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_l1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Garage L1 voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_session_energy-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.garage_session_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Session energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session energy', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'session_energy', + 'unique_id': 'AA:BB_session_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_session_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage Session energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_session_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_total_energy-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.garage_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'AA:BB_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[single_phase][sensor.garage_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage Total energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.3', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_charging_power-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.garage_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'AA:BB_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage Charging power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3500', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_external_temperature-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': , + 'entity_id': 'sensor.garage_external_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External temperature', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': 'AA:BB_external_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Garage External temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_internal_temperature-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.garage_internal_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Internal temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Internal temperature', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'internal_temperature', + 'unique_id': 'AA:BB_internal_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_internal_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Garage Internal temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_internal_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.5', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l1_current-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': , + 'entity_id': 'sensor.garage_l1_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L1 current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L1 current', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l1_current', + 'unique_id': 'AA:BB_l1_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l1_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Garage L1 current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l1_voltage-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': , + 'entity_id': 'sensor.garage_l1_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L1 voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L1 voltage', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l1_voltage', + 'unique_id': 'AA:BB_l1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Garage L1 voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l2_current-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': , + 'entity_id': 'sensor.garage_l2_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L2 current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L2 current', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l2_current', + 'unique_id': 'AA:BB_l2_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l2_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Garage L2 current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l2_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.1', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l2_voltage-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': , + 'entity_id': 'sensor.garage_l2_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L2 voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L2 voltage', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l2_voltage', + 'unique_id': 'AA:BB_l2_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l2_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Garage L2 voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l2_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.0', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l3_current-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': , + 'entity_id': 'sensor.garage_l3_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L3 current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L3 current', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l3_current', + 'unique_id': 'AA:BB_l3_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l3_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Garage L3 current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l3_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l3_voltage-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': , + 'entity_id': 'sensor.garage_l3_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'L3 voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'L3 voltage', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'l3_voltage', + 'unique_id': 'AA:BB_l3_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_l3_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Garage L3 voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_l3_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232.0', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_session_energy-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.garage_session_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Session energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session energy', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'session_energy', + 'unique_id': 'AA:BB_session_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_session_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage Session energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_session_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_total_energy-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.garage_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'AA:BB_total_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_state[three_phase][sensor.garage_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage Total energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.3', + }) +# --- diff --git a/tests/components/besen/test_sensor.py b/tests/components/besen/test_sensor.py new file mode 100644 index 00000000000000..27495e588b2b67 --- /dev/null +++ b/tests/components/besen/test_sensor.py @@ -0,0 +1,180 @@ +"""Tests for the Besen sensor platform.""" + +from unittest.mock import Mock + +from besen.models import ChargeStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EntityCategory, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import publish_besen_state +from .conftest import charger_state, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +POWER_ENTITY_ID = "sensor.garage_charging_power" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("phases", [1, 3], ids=["single_phase", "three_phase"]) +async def test_sensor_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + phases: int, +) -> None: + """Test sensor states and registry data.""" + + mock_besen_client.state = charger_state(phases=phases) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + mock_besen_client.async_start.assert_awaited_once() + + +async def test_sensor_updates_from_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test sensor states update from client push data.""" + + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + publish_besen_state( + mock_besen_client, + charger_state( + charge=ChargeStatus( + power=7200, + total_energy=123.45, + session_energy=4.56, + inner_temp_c=26.5, + ) + ), + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(POWER_ENTITY_ID)) is not None + assert state.state == "7200" + assert (state := hass.states.get("sensor.garage_total_energy")) is not None + assert state.state == "123.45" + assert (state := hass.states.get("sensor.garage_session_energy")) is not None + assert state.state == "4.56" + assert (state := hass.states.get("sensor.garage_internal_temperature")) is not None + assert state.state == "26.5" + + +async def test_sensor_unknown_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test a missing measurement is unknown while the charger is available.""" + + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + publish_besen_state(mock_besen_client, charger_state(charge=ChargeStatus())) + await hass.async_block_till_done() + + assert (state := hass.states.get(POWER_ENTITY_ID)) is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("available", "authenticated"), + [ + (False, True), + (True, False), + ], +) +async def test_sensor_unavailable_from_client_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + available: bool, + authenticated: bool, +) -> None: + """Test sensor availability follows client connection and authentication.""" + + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + publish_besen_state( + mock_besen_client, + charger_state(available=available, authenticated=authenticated), + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(POWER_ENTITY_ID)) is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_diagnostic_sensors_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test diagnostic sensors are disabled by default.""" + + mock_besen_client.state = charger_state(phases=3) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + diagnostic_entries = { + entry.unique_id: entry + for entry in entries + if entry.entity_category is EntityCategory.DIAGNOSTIC + } + assert set(diagnostic_entries) == { + f"{mock_besen_client.address}_external_temperature", + f"{mock_besen_client.address}_l1_current", + f"{mock_besen_client.address}_l1_voltage", + f"{mock_besen_client.address}_l2_current", + f"{mock_besen_client.address}_l2_voltage", + f"{mock_besen_client.address}_l3_current", + f"{mock_besen_client.address}_l3_voltage", + } + for entry in diagnostic_entries.values(): + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entry.entity_id) is None + + +@pytest.mark.parametrize(("phases", "expected"), [(1, False), (3, True)]) +async def test_three_phase_sensor_filtering( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + phases: int, + expected: bool, +) -> None: + """Test L2 and L3 sensors are added only for three-phase chargers.""" + + mock_besen_client.state = charger_state(phases=phases) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + three_phase_unique_ids = { + f"{mock_besen_client.address}_l2_voltage", + f"{mock_besen_client.address}_l2_current", + f"{mock_besen_client.address}_l3_voltage", + f"{mock_besen_client.address}_l3_current", + } + assert three_phase_unique_ids.issubset(unique_ids) is expected diff --git a/tests/components/besen/test_switch.py b/tests/components/besen/test_switch.py index 5156f1df9f1d67..27b59cbd9a7d94 100644 --- a/tests/components/besen/test_switch.py +++ b/tests/components/besen/test_switch.py @@ -15,6 +15,7 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,7 +39,7 @@ async def test_switch_state( ) -> None: """Test switch entity state and registry data.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) mock_besen_client.async_start.assert_awaited_once() @@ -51,7 +52,7 @@ async def test_switch_updates_from_client( ) -> None: """Test switch state updates from client push data.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) publish_besen_state(mock_besen_client, charger_state(charger_status=False)) await hass.async_block_till_done() @@ -68,7 +69,7 @@ async def test_switch_updates_on_refresh( ) -> None: """Test switch state updates when the coordinator refreshes.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) mock_besen_client.state = charger_state(charger_status=False) await async_update_entity(hass, ENTITY_ID) @@ -95,7 +96,7 @@ async def test_switch_unavailable_from_client_state( ) -> None: """Test switch availability follows client availability and authentication.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) publish_besen_state( mock_besen_client, @@ -115,7 +116,7 @@ async def test_switch_services( ) -> None: """Test switch turn on and turn off services.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) await hass.services.async_call( SWITCH_DOMAIN, @@ -155,7 +156,7 @@ async def test_switch_command_failure( side_effect=CommandFailed("failed") ) - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( From 790cada2d759926cb8458bc444c8ff8da45d81f5 Mon Sep 17 00:00:00 2001 From: Miodrag Obradovic <99922455+Kjubikstronk@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:27:23 +0200 Subject: [PATCH 17/37] Remove stale xfail in hdmi_cec switch status test (#180532) --- tests/components/hdmi_cec/test_switch.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/components/hdmi_cec/test_switch.py b/tests/components/hdmi_cec/test_switch.py index 45fdb4ebcd3dd6..891803f1bb3823 100644 --- a/tests/components/hdmi_cec/test_switch.py +++ b/tests/components/hdmi_cec/test_switch.py @@ -155,13 +155,6 @@ async def test_device_status_change( await hass.async_block_till_done() state = hass.states.get("switch.hdmi_3") - if power_status in (POWER_ON, 4) and status is not None: - pytest.xfail( - reason=( - "`CecSwitchEntity.is_on` returns `False` here" - " instead of `true` as expected." - ) - ) assert state.state == expected_state From 0cae37c56bf494cdd38f8d609c1c0bb0c51efde2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 14:42:11 +0200 Subject: [PATCH 18/37] Manage the Peblar autocharge vehicle list (#180606) --- homeassistant/components/peblar/const.py | 1 + homeassistant/components/peblar/icons.json | 9 ++ homeassistant/components/peblar/services.py | 96 +++++++++++-- homeassistant/components/peblar/services.yaml | 36 +++++ homeassistant/components/peblar/strings.json | 45 ++++++ .../peblar/fixtures/system_information.json | 2 +- .../peblar/snapshots/test_diagnostics.ambr | 2 +- tests/components/peblar/test_services.py | 132 ++++++++++++++++++ 8 files changed, 311 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index cc2129baa495d6..0982e1ef235215 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -7,6 +7,7 @@ DOMAIN: Final = "peblar" +CONF_EVCC_ID: Final = "evcc_id" CONF_UID: Final = "uid" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index cbdf76a8a1cc2d..7ce7b3698d3ee8 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -85,11 +85,20 @@ "add_rfid_token": { "service": "mdi:card-plus" }, + "add_vehicle_token": { + "service": "mdi:car-connected" + }, "delete_rfid_token": { "service": "mdi:card-remove" }, + "delete_vehicle_token": { + "service": "mdi:car-off" + }, "list_rfid_tokens": { "service": "mdi:card-account-details" + }, + "list_vehicle_tokens": { + "service": "mdi:car-multiple" } } } diff --git a/homeassistant/components/peblar/services.py b/homeassistant/components/peblar/services.py index 6f141dd773b77f..d63369aaf01809 100644 --- a/homeassistant/components/peblar/services.py +++ b/homeassistant/components/peblar/services.py @@ -6,7 +6,7 @@ from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError, PeblarError import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_DESCRIPTION +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_ALIAS, CONF_DESCRIPTION from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -20,24 +20,31 @@ async_register_admin_service, ) -from .const import CONF_UID, DOMAIN +from .const import CONF_EVCC_ID, CONF_UID, DOMAIN from .coordinator import PeblarConfigEntry SERVICE_ADD_RFID_TOKEN = "add_rfid_token" +SERVICE_ADD_VEHICLE_TOKEN = "add_vehicle_token" SERVICE_DELETE_RFID_TOKEN = "delete_rfid_token" +SERVICE_DELETE_VEHICLE_TOKEN = "delete_vehicle_token" SERVICE_LIST_RFID_TOKENS = "list_rfid_tokens" +SERVICE_LIST_VEHICLE_TOKENS = "list_vehicle_tokens" CHARGER_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): str}) + TOKEN_SCHEMA = CHARGER_SCHEMA.extend({vol.Required(CONF_UID): str}) ADD_TOKEN_SCHEMA = TOKEN_SCHEMA.extend({vol.Required(CONF_DESCRIPTION): str}) +VEHICLE_SCHEMA = CHARGER_SCHEMA.extend({vol.Required(CONF_EVCC_ID): str}) +ADD_VEHICLE_SCHEMA = VEHICLE_SCHEMA.extend({vol.Required(CONF_ALIAS): str}) + -def _get_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: - """Return the Peblar client for the charger the call targets. +def _get_rfid_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: + """Return the client, for a charger that has an RFID reader. - Every action here manages the standalone authorization list, which - lives on the RFID reader, so a charger without one is turned away - here instead of failing somewhere inside the charger. + The standalone authorization list lives on the reader, so a charger + without one is turned away here instead of failing somewhere inside + the charger. """ entry: PeblarConfigEntry = async_get_config_entry(hass, DOMAIN, entry_id) @@ -51,6 +58,26 @@ def _get_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: return entry.runtime_data.user_configuration_coordinator.peblar +def _get_autocharge_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: + """Return the client, for a charger that can do autocharge. + + Autocharge identifies a car by what its own controller presents over + the power line, so it takes the PLC hardware. Without it the charger + still hands out the list, but refuses to change it, so this turns the + charger away rather than let that come back as a failed request. + """ + entry: PeblarConfigEntry = async_get_config_entry(hass, DOMAIN, entry_id) + + if not entry.runtime_data.system_information.hardware_has_plc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_autocharge_hardware", + translation_placeholders={"charger": entry.title}, + ) + + return entry.runtime_data.user_configuration_coordinator.peblar + + @asynccontextmanager async def _handle_peblar_errors( hass: HomeAssistant, entry_id: str @@ -88,7 +115,7 @@ def async_setup_services(hass: HomeAssistant) -> None: async def _handle_list_rfid_tokens(call: ServiceCall) -> ServiceResponse: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - peblar = _get_peblar(hass, entry_id) + peblar = _get_rfid_peblar(hass, entry_id) async with _handle_peblar_errors(hass, entry_id): tokens = await peblar.rfid_tokens() return { @@ -103,7 +130,7 @@ async def _handle_list_rfid_tokens(call: ServiceCall) -> ServiceResponse: async def _handle_add_rfid_token(call: ServiceCall) -> None: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - peblar = _get_peblar(hass, entry_id) + peblar = _get_rfid_peblar(hass, entry_id) async with _handle_peblar_errors(hass, entry_id): await peblar.add_rfid_token( rfid_token_uid=call.data[CONF_UID], @@ -112,10 +139,37 @@ async def _handle_add_rfid_token(call: ServiceCall) -> None: async def _handle_delete_rfid_token(call: ServiceCall) -> None: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - peblar = _get_peblar(hass, entry_id) + peblar = _get_rfid_peblar(hass, entry_id) async with _handle_peblar_errors(hass, entry_id): await peblar.delete_rfid_token(uid=call.data[CONF_UID]) + async def _handle_list_vehicle_tokens(call: ServiceCall) -> ServiceResponse: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_autocharge_peblar(hass, entry_id) + async with _handle_peblar_errors(hass, entry_id): + vehicles = await peblar.vehicle_tokens() + return { + "vehicles": [ + {CONF_EVCC_ID: vehicle.evcc_id, CONF_ALIAS: vehicle.alias} + for vehicle in vehicles + ] + } + + async def _handle_add_vehicle_token(call: ServiceCall) -> None: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_autocharge_peblar(hass, entry_id) + async with _handle_peblar_errors(hass, entry_id): + await peblar.add_vehicle_token( + evcc_id=call.data[CONF_EVCC_ID], + alias=call.data[CONF_ALIAS], + ) + + async def _handle_delete_vehicle_token(call: ServiceCall) -> None: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_autocharge_peblar(hass, entry_id) + async with _handle_peblar_errors(hass, entry_id): + await peblar.delete_vehicle_token(evcc_id=call.data[CONF_EVCC_ID]) + async_register_admin_service( hass, DOMAIN, @@ -138,3 +192,25 @@ async def _handle_delete_rfid_token(call: ServiceCall) -> None: _handle_delete_rfid_token, schema=TOKEN_SCHEMA, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LIST_VEHICLE_TOKENS, + _handle_list_vehicle_tokens, + schema=CHARGER_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ADD_VEHICLE_TOKEN, + _handle_add_vehicle_token, + schema=ADD_VEHICLE_SCHEMA, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DELETE_VEHICLE_TOKEN, + _handle_delete_vehicle_token, + schema=VEHICLE_SCHEMA, + ) diff --git a/homeassistant/components/peblar/services.yaml b/homeassistant/components/peblar/services.yaml index 179a2de9a4ed96..633ec8e43b70f9 100644 --- a/homeassistant/components/peblar/services.yaml +++ b/homeassistant/components/peblar/services.yaml @@ -33,3 +33,39 @@ delete_rfid_token: required: true selector: text: + +list_vehicle_tokens: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + +add_vehicle_token: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + evcc_id: + required: true + selector: + text: + alias: + required: true + selector: + text: + +delete_vehicle_token: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + evcc_id: + required: true + selector: + text: diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index e16824fc3dfe4d..62d6f7678c1676 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -206,6 +206,9 @@ "communication_error": { "message": "An error occurred while communicating with the Peblar EV charger: {error}" }, + "no_autocharge_hardware": { + "message": "{charger} has no power line communication hardware, so it cannot use autocharge." + }, "no_rfid_hardware": { "message": "{charger} has no RFID reader, so it has no standalone authorization list." }, @@ -232,6 +235,24 @@ }, "name": "Add RFID token" }, + "add_vehicle_token": { + "description": "Adds a vehicle to the charger's autocharge list, so it is authorized to charge as soon as it is plugged in.", + "fields": { + "alias": { + "description": "A human-readable label for this vehicle.", + "name": "Alias" + }, + "config_entry_id": { + "description": "The Peblar EV charger to add the vehicle to.", + "name": "Peblar EV charger" + }, + "evcc_id": { + "description": "The identifier the vehicle presents to the charger.", + "name": "EVCC ID" + } + }, + "name": "Add autocharge vehicle" + }, "delete_rfid_token": { "description": "Deletes an RFID token from the charger's standalone authorization list.", "fields": { @@ -246,6 +267,20 @@ }, "name": "Delete RFID token" }, + "delete_vehicle_token": { + "description": "Deletes a vehicle from the charger's autocharge list.", + "fields": { + "config_entry_id": { + "description": "The Peblar EV charger to delete the vehicle from.", + "name": "Peblar EV charger" + }, + "evcc_id": { + "description": "The identifier of the vehicle to delete.", + "name": "EVCC ID" + } + }, + "name": "Delete autocharge vehicle" + }, "list_rfid_tokens": { "description": "Returns the RFID tokens configured in the charger's standalone authorization list.", "fields": { @@ -255,6 +290,16 @@ } }, "name": "List RFID tokens" + }, + "list_vehicle_tokens": { + "description": "Returns the vehicles configured in the charger's autocharge list.", + "fields": { + "config_entry_id": { + "description": "The Peblar EV charger to list autocharge vehicles for.", + "name": "Peblar EV charger" + } + }, + "name": "List autocharge vehicles" } } } diff --git a/tests/components/peblar/fixtures/system_information.json b/tests/components/peblar/fixtures/system_information.json index 13fae9af99454b..9731c59a18e178 100644 --- a/tests/components/peblar/fixtures/system_information.json +++ b/tests/components/peblar/fixtures/system_information.json @@ -22,7 +22,7 @@ "HwHasLte": false, "HwHasMeter": true, "HwHasMeterDisplay": true, - "HwHasPlc": false, + "HwHasPlc": true, "HwHasRfid": true, "HwHasRs485": true, "HwHasShutter": false, diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index a1f1bd19da4a70..12d671c5100c61 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -62,7 +62,7 @@ 'HwHasLte': False, 'HwHasMeter': True, 'HwHasMeterDisplay': True, - 'HwHasPlc': False, + 'HwHasPlc': True, 'HwHasRfid': True, 'HwHasRs485': True, 'HwHasShutter': False, diff --git a/tests/components/peblar/test_services.py b/tests/components/peblar/test_services.py index 94764e5d76b0bf..b4c51a32b4e716 100644 --- a/tests/components/peblar/test_services.py +++ b/tests/components/peblar/test_services.py @@ -8,14 +8,18 @@ PeblarConnectionError, PeblarError, PeblarRfidToken, + PeblarVehicleToken, ) import pytest from homeassistant.components.peblar.const import DOMAIN from homeassistant.components.peblar.services import ( SERVICE_ADD_RFID_TOKEN, + SERVICE_ADD_VEHICLE_TOKEN, SERVICE_DELETE_RFID_TOKEN, + SERVICE_DELETE_VEHICLE_TOKEN, SERVICE_LIST_RFID_TOKENS, + SERVICE_LIST_VEHICLE_TOKENS, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant @@ -277,3 +281,131 @@ async def test_charger_without_rfid_reader( assert excinfo.value.translation_domain == DOMAIN assert excinfo.value.translation_key == "no_rfid_hardware" + + +async def test_list_vehicle_tokens( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test list_vehicle_tokens returns the vehicles on the charger.""" + mock_peblar.vehicle_tokens.return_value = [ + PeblarVehicleToken(evcc_id="EVCC-1234", alias="The blue one"), + PeblarVehicleToken(evcc_id="EVCC-5678", alias="The other one"), + ] + + result = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_VEHICLE_TOKENS, + {"config_entry_id": init_integration.entry_id}, + blocking=True, + return_response=True, + ) + + assert result == { + "vehicles": [ + {"evcc_id": "EVCC-1234", "alias": "The blue one"}, + {"evcc_id": "EVCC-5678", "alias": "The other one"}, + ] + } + mock_peblar.vehicle_tokens.assert_called_once_with() + + +async def test_add_vehicle_token( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test add_vehicle_token calls the library with the right arguments.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_VEHICLE_TOKEN, + { + "config_entry_id": init_integration.entry_id, + "evcc_id": "EVCC-1234", + "alias": "The blue one", + }, + blocking=True, + ) + + mock_peblar.add_vehicle_token.assert_called_once_with( + evcc_id="EVCC-1234", + alias="The blue one", + ) + + +async def test_delete_vehicle_token( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test delete_vehicle_token calls the library with the right arguments.""" + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_VEHICLE_TOKEN, + { + "config_entry_id": init_integration.entry_id, + "evcc_id": "EVCC-1234", + }, + blocking=True, + ) + + mock_peblar.delete_vehicle_token.assert_called_once_with(evcc_id="EVCC-1234") + + +AUTOCHARGE_CALLS: list[tuple[str, dict[str, Any]]] = [ + (SERVICE_LIST_VEHICLE_TOKENS, {}), + (SERVICE_ADD_VEHICLE_TOKEN, {"evcc_id": "EVCC-1234", "alias": "The blue one"}), + (SERVICE_DELETE_VEHICLE_TOKEN, {"evcc_id": "EVCC-1234"}), +] + + +@pytest.mark.parametrize("mock_peblar", [{"HwHasRfid": False}], indirect=True) +@pytest.mark.parametrize(("service", "service_data"), AUTOCHARGE_CALLS) +@pytest.mark.usefixtures("mock_peblar") +async def test_autocharge_does_not_need_an_rfid_reader( + hass: HomeAssistant, + init_integration: MockConfigEntry, + service: str, + service_data: dict[str, Any], +) -> None: + """Test the autocharge list is a separate one from the RFID list. + + Autocharge identifies a car by what its own controller presents, so it + has nothing to do with the reader. + """ + await hass.services.async_call( + DOMAIN, + service, + {"config_entry_id": init_integration.entry_id, **service_data}, + blocking=True, + return_response=service == SERVICE_LIST_VEHICLE_TOKENS, + ) + + +@pytest.mark.parametrize("mock_peblar", [{"HwHasPlc": False}], indirect=True) +@pytest.mark.parametrize(("service", "service_data"), AUTOCHARGE_CALLS) +@pytest.mark.usefixtures("mock_peblar") +async def test_autocharge_needs_power_line_communication( + hass: HomeAssistant, + init_integration: MockConfigEntry, + service: str, + service_data: dict[str, Any], +) -> None: + """Test a charger that cannot do autocharge is turned away. + + Checked against a charger without the hardware: it answers 200 with + null on the list, and 403 on adding and deleting. Letting that come + back as a failed request tells the user nothing. + """ + with pytest.raises(ServiceValidationError) as excinfo: + await hass.services.async_call( + DOMAIN, + service, + {"config_entry_id": init_integration.entry_id, **service_data}, + blocking=True, + return_response=service == SERVICE_LIST_VEHICLE_TOKENS, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "no_autocharge_hardware" From 7ceb81674f36c2ac756b887b36c9913beffcbdc3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 14:43:23 +0200 Subject: [PATCH 19/37] Add meter support to SolarEdge Modbus (#180572) --- .../components/solaredge_modbus/__init__.py | 60 +- .../components/solaredge_modbus/const.py | 3 + .../solaredge_modbus/coordinator.py | 1 + .../components/solaredge_modbus/entity.py | 89 +- .../solaredge_modbus/quality_scale.yaml | 8 +- .../components/solaredge_modbus/sensor.py | 376 +++- .../components/solaredge_modbus/strings.json | 33 + tests/components/solaredge_modbus/conftest.py | 34 +- .../solaredge_modbus/fixtures/se10000h.json | 56 + .../snapshots/test_diagnostics.ambr | 8 +- .../snapshots/test_sensor.ambr | 1590 +++++++++++++++++ .../components/solaredge_modbus/test_init.py | 187 +- .../solaredge_modbus/test_sensor.py | 111 ++ 13 files changed, 2511 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index b9b93256494a0b..e32533594ca81d 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -20,13 +20,20 @@ ) from homeassistant.helpers import device_registry as dr -from .const import CONF_UNIT_ID, DOMAIN, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER +from .const import ( + CONF_UNIT_ID, + DOMAIN, + LOGGER, + SUBSYSTEM_COMMON, + SUBSYSTEM_INVERTER, + SUBSYSTEM_METERS, +) from .coordinator import ( SolarEdgeModbusConfigEntry, SolarEdgeModbusDataUpdateCoordinator, SolarEdgeModbusRuntimeData, ) -from .entity import inverter_device_info +from .entity import inverter_device_info, meter_identity from .helpers import create_modbus_params PLATFORMS = [Platform.SENSOR] @@ -81,27 +88,62 @@ async def async_setup_entry( translation_key="identity_unavailable", ) - # The platforms read the inverter's DID once, so without it the phase + # The platforms read a component's DID once, so without it the phase # entities would stay missing until a reload. - if SUBSYSTEM_INVERTER in readings.data.failed: + measuring = {SUBSYSTEM_INVERTER} + measuring.update(f"meters[{index}]" for index in range(len(solaredge.meters))) + if measuring & readings.data.failed.keys(): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="measurements_unavailable", ) - # Built once here: every entity hangs on the same device. - entry.runtime_data = SolarEdgeModbusRuntimeData( - readings=readings, device_info=inverter_device_info(solaredge, serial_number) + # Registered up front: a meter sub-device can only name the inverter it + # hangs off once that device has an ID. + device_info = inverter_device_info(solaredge, serial_number) + inverter = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **device_info ) - dr.async_get(hass).async_get_or_create( - config_entry_id=entry.entry_id, **entry.runtime_data.device_info + entry.runtime_data = SolarEdgeModbusRuntimeData( + readings=readings, device_info=device_info, inverter_device_id=inverter.id ) + # A block that stayed silent while probing is taken for absent, so a meter + # that timed out cannot be told from one that was unwired. Its device stays + # where it is until the device says for itself that it is gone. + if SUBSYSTEM_METERS in solaredge.unresponsive_blocks: + LOGGER.warning( + "%s did not answer for its meters while probing, so their entities" + " are missing until it does; reloading probes again", + entry.title, + ) + else: + _async_remove_stale_devices(hass, entry, solaredge, serial_number) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +def _async_remove_stale_devices( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + solaredge: SolarEdge, + serial_number: str, +) -> None: + """Remove devices for meters no longer attached to the inverter.""" + current = {(DOMAIN, serial_number)} + current.update( + (DOMAIN, f"{serial_number}_meter_{meter_identity(meter, index)}") + for index, meter in enumerate(solaredge.meters, 1) + ) + + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if not current.intersection(device.identifiers): + device_registry.async_remove_device(device.id) + + async def async_unload_entry( hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry ) -> bool: diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 18f62b8ed8fc12..980df2f3c5c1ce 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -21,5 +21,8 @@ SUBSYSTEM_COMMON: Final = "common" SUBSYSTEM_INVERTER: Final = "inverter" +# How the library names the meter block it probes for. +SUBSYSTEM_METERS: Final = "meters" + # Local Modbus is cheap to read and PV production moves fast. SCAN_INTERVAL: Final = timedelta(seconds=10) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py index 2d6e7818ad6d2a..7a19ea96bd1670 100644 --- a/homeassistant/components/solaredge_modbus/coordinator.py +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -144,6 +144,7 @@ class SolarEdgeModbusRuntimeData: readings: SolarEdgeModbusDataUpdateCoordinator device_info: DeviceInfo + inverter_device_id: str @property def solaredge(self) -> SolarEdge: diff --git a/homeassistant/components/solaredge_modbus/entity.py b/homeassistant/components/solaredge_modbus/entity.py index 3c5d41e87f60c2..132306d6b33ff7 100644 --- a/homeassistant/components/solaredge_modbus/entity.py +++ b/homeassistant/components/solaredge_modbus/entity.py @@ -1,12 +1,14 @@ """Base entities for the SolarEdge Modbus integration. -Every identity derives from the inverter's serial number, which the config -flow stores as the config entry unique ID. +Each meter attached to the inverter is its own sub-device, linked to the +inverter as its parent; everything else belongs to the inverter. All +identities derive from the inverter's serial number, which the config flow +stores as the config entry unique ID. """ from typing import TYPE_CHECKING, override -from solaredged import SolarEdge +from solaredged import Meter, SolarEdge from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -39,8 +41,19 @@ def inverter_name(model: str | None) -> str: return f"SolarEdge {commercial}" +def meter_identity(meter: Meter, index: int) -> str: + """Return what tells a meter apart from the next one in its place. + + A meter that reports a serial number is known by it, so replacing one is a + different device rather than the same slot with other numbers in it. Not + every meter reports one, and then the slot it is wired to is all there is. + That fallback says so, since a bare number could be a serial itself. + """ + return meter.serial_number or f"slot_{index}" + + def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo: - """Return device information for the inverter.""" + """Return device information for the inverter itself.""" common = solaredge.common return DeviceInfo( identifiers={(DOMAIN, serial_number)}, @@ -53,10 +66,8 @@ def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo ) -class SolarEdgeModbusInverterEntity( - CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinator] -): - """Defines a SolarEdge Modbus entity on the inverter device.""" +class SolarEdgeModbusEntity(CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinator]): + """Defines a SolarEdge Modbus entity.""" _attr_has_entity_name = True @@ -64,26 +75,76 @@ def __init__( self, *, entry: SolarEdgeModbusConfigEntry, + subsystem: str, description: EntityDescription, + key_prefix: str = "", ) -> None: - """Initialize a SolarEdge Modbus inverter entity.""" + """Initialize a SolarEdge Modbus entity.""" super().__init__(coordinator=entry.runtime_data.readings) self.entity_description = description + self._subsystem = subsystem serial_number = entry.unique_id if TYPE_CHECKING: assert serial_number is not None - self._attr_unique_id = f"{serial_number}_{description.key}" - self._attr_device_info = entry.runtime_data.device_info + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_{key_prefix}{description.key}" @property @override def available(self) -> bool: - """Return whether the inverter answered the most recent poll. + """Return whether this entity's sub-system answered the last poll. A poll can come back partial, and an entity that reports a value from an earlier read as if it were current is lying about the device. """ - return ( - super().available and SUBSYSTEM_INVERTER not in self.coordinator.data.failed + return super().available and self._subsystem not in self.coordinator.data.failed + + +class SolarEdgeModbusInverterEntity(SolarEdgeModbusEntity): + """Defines a SolarEdge Modbus entity on the inverter device.""" + + def __init__( + self, + *, + entry: SolarEdgeModbusConfigEntry, + description: EntityDescription, + ) -> None: + """Initialize a SolarEdge Modbus inverter entity.""" + super().__init__( + entry=entry, subsystem=SUBSYSTEM_INVERTER, description=description + ) + self._attr_device_info = entry.runtime_data.device_info + + +class SolarEdgeModbusMeterEntity(SolarEdgeModbusEntity): + """Defines a SolarEdge Modbus entity on a meter sub-device.""" + + def __init__( + self, + *, + entry: SolarEdgeModbusConfigEntry, + description: EntityDescription, + index: int, + ) -> None: + """Initialize a SolarEdge Modbus meter entity.""" + meter = entry.runtime_data.solaredge.meters[index - 1] + identity = meter_identity(meter, index) + super().__init__( + entry=entry, + subsystem=f"meters[{index - 1}]", + description=description, + key_prefix=f"meter_{identity}_", + ) + self._index = index + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._serial_number}_meter_{identity}")}, + manufacturer=meter.manufacturer or "SolarEdge", + # What a meter reports is a part number, like "SE-MTR-3Y-400V-A", + # and there is no shorter name it is sold under to put beside it. + model_id=meter.model or None, + name=f"Meter {index}", + serial_number=meter.serial_number or None, + via_device_id=entry.runtime_data.inverter_device_id, ) diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml index 75a8d222258d7b..332c05eaa4dcae 100644 --- a/homeassistant/components/solaredge_modbus/quality_scale.yaml +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -61,9 +61,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: - status: exempt - comment: A config entry is one inverter, which is one device. + dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -74,9 +72,7 @@ rules: repair-issues: status: exempt comment: No repairable issues are raised. - stale-devices: - status: exempt - comment: A config entry is one inverter, so no device can go stale. + stale-devices: todo # Platinum async-dependency: done diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py index a200ef30d8d9ee..0f9ca9e4b974bc 100644 --- a/homeassistant/components/solaredge_modbus/sensor.py +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import override -from solaredged import Inverter, InverterStatus, SunSpecDID +from solaredged import Inverter, InverterStatus, Meter, SunSpecDID from homeassistant.components.sensor import ( RestoreSensor, @@ -31,23 +31,45 @@ from .const import LOGGER from .coordinator import SolarEdgeModbusConfigEntry -from .entity import SolarEdgeModbusInverterEntity +from .entity import SolarEdgeModbusInverterEntity, SolarEdgeModbusMeterEntity PARALLEL_UPDATES = 0 # Per-phase points only carry data on split- and three-phase inverters. _MULTI_PHASE = (SunSpecDID.SPLIT_PHASE_INVERTER, SunSpecDID.THREE_PHASE_INVERTER) +# Meters report per-phase points from two phases up; the third phase only on a +# three-phase meter, and line-to-neutral voltages on everything but a delta. +_MULTI_PHASE_METER = ( + SunSpecDID.SPLIT_PHASE_METER, + SunSpecDID.THREE_PHASE_WYE_METER, + SunSpecDID.THREE_PHASE_DELTA_METER, +) +_THREE_PHASE_METER = ( + SunSpecDID.THREE_PHASE_WYE_METER, + SunSpecDID.THREE_PHASE_DELTA_METER, +) +# A delta meter has no neutral, so it measures nothing against one. +_NEUTRAL_METER = ( + SunSpecDID.SINGLE_PHASE_METER, + SunSpecDID.SPLIT_PHASE_METER, + SunSpecDID.THREE_PHASE_WYE_METER, +) +_PHASE_NEUTRAL_METER = ( + SunSpecDID.SPLIT_PHASE_METER, + SunSpecDID.THREE_PHASE_WYE_METER, +) + @dataclass(frozen=True, kw_only=True) -class SolarEdgeModbusSensorEntityDescription(SensorEntityDescription): +class SolarEdgeModbusSensorEntityDescription[ComponentT](SensorEntityDescription): """Describes a SolarEdge Modbus sensor entity.""" - exists_fn: Callable[[Inverter], bool] = lambda _: True - value_fn: Callable[[Inverter], StateType] + exists_fn: Callable[[ComponentT], bool] = lambda _: True + value_fn: Callable[[ComponentT], StateType] -INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription, ...] = ( +INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Inverter], ...] = ( SolarEdgeModbusSensorEntityDescription( key="ac_power", device_class=SensorDeviceClass.POWER, @@ -276,9 +298,302 @@ class SolarEdgeModbusSensorEntityDescription(SensorEntityDescription): ) +METER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Meter], ...] = ( + SolarEdgeModbusSensorEntityDescription( + key="ac_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda meter: meter.ac_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_exported", + translation_key="energy_exported", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_exported, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_imported", + translation_key="energy_imported", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda meter: meter.energy_imported, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_power_phase_a", + translation_key="power_phase_a", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.ac_power_a, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_power_phase_b", + translation_key="power_phase_b", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.ac_power_b, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_power_phase_c", + translation_key="power_phase_c", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + exists_fn=lambda meter: meter.did in _THREE_PHASE_METER, + value_fn=lambda meter: meter.ac_power_c, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda meter: meter.ac_current, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current_phase_a", + translation_key="current_phase_a", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.ac_current_a, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current_phase_b", + translation_key="current_phase_b", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.ac_current_b, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current_phase_c", + translation_key="current_phase_c", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + exists_fn=lambda meter: meter.did in _THREE_PHASE_METER, + value_fn=lambda meter: meter.ac_current_c, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did in _NEUTRAL_METER, + value_fn=lambda meter: meter.ac_voltage_ln, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_an", + translation_key="voltage_phase_an", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did in _PHASE_NEUTRAL_METER, + value_fn=lambda meter: meter.ac_voltage_an, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_bn", + translation_key="voltage_phase_bn", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did in _PHASE_NEUTRAL_METER, + value_fn=lambda meter: meter.ac_voltage_bn, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_cn", + translation_key="voltage_phase_cn", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did is SunSpecDID.THREE_PHASE_WYE_METER, + value_fn=lambda meter: meter.ac_voltage_cn, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_ab", + translation_key="voltage_phase_ab", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.ac_voltage_ab, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_bc", + translation_key="voltage_phase_bc", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did in _THREE_PHASE_METER, + value_fn=lambda meter: meter.ac_voltage_bc, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_ca", + translation_key="voltage_phase_ca", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + exists_fn=lambda meter: meter.did in _THREE_PHASE_METER, + value_fn=lambda meter: meter.ac_voltage_ca, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda meter: meter.ac_frequency, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda meter: meter.ac_va, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_reactive_power", + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda meter: meter.ac_var, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_power_factor", + device_class=SensorDeviceClass.POWER_FACTOR, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + value_fn=lambda meter: meter.ac_power_factor, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_exported_phase_a", + translation_key="energy_exported_phase_a", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.energy_exported_a, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_exported_phase_b", + translation_key="energy_exported_phase_b", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.energy_exported_b, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_exported_phase_c", + translation_key="energy_exported_phase_c", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + exists_fn=lambda meter: meter.did in _THREE_PHASE_METER, + value_fn=lambda meter: meter.energy_exported_c, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_imported_phase_a", + translation_key="energy_imported_phase_a", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.energy_imported_a, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_imported_phase_b", + translation_key="energy_imported_phase_b", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER, + value_fn=lambda meter: meter.energy_imported_b, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_imported_phase_c", + translation_key="energy_imported_phase_c", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + exists_fn=lambda meter: meter.did in _THREE_PHASE_METER, + value_fn=lambda meter: meter.energy_imported_c, + ), +) + + def _inverter_sensor( entry: SolarEdgeModbusConfigEntry, - description: SolarEdgeModbusSensorEntityDescription, + description: SolarEdgeModbusSensorEntityDescription[Inverter], ) -> SensorEntity: """Build an inverter sensor, monotonic where its state class asks for it.""" if description.state_class is SensorStateClass.TOTAL_INCREASING: @@ -288,6 +603,21 @@ def _inverter_sensor( return SolarEdgeModbusInverterSensorEntity(entry=entry, description=description) +def _meter_sensor( + entry: SolarEdgeModbusConfigEntry, + description: SolarEdgeModbusSensorEntityDescription[Meter], + index: int, +) -> SensorEntity: + """Build a meter sensor, monotonic where its state class asks for it.""" + if description.state_class is SensorStateClass.TOTAL_INCREASING: + return SolarEdgeModbusMeterEnergySensorEntity( + entry=entry, description=description, index=index + ) + return SolarEdgeModbusMeterSensorEntity( + entry=entry, description=description, index=index + ) + + async def async_setup_entry( hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry, @@ -296,17 +626,25 @@ async def async_setup_entry( """Set up SolarEdge Modbus sensor entities based on a config entry.""" solaredge = entry.runtime_data.solaredge - async_add_entities( + entities: list[SensorEntity] = [ _inverter_sensor(entry, description) for description in INVERTER_SENSORS if description.exists_fn(solaredge.inverter) + ] + entities.extend( + _meter_sensor(entry, description, index) + for index, meter in enumerate(solaredge.meters, 1) + for description in METER_SENSORS + if description.exists_fn(meter) ) + async_add_entities(entities) + class SolarEdgeModbusInverterSensorEntity(SolarEdgeModbusInverterEntity, SensorEntity): """Defines a SolarEdge Modbus inverter sensor entity.""" - entity_description: SolarEdgeModbusSensorEntityDescription + entity_description: SolarEdgeModbusSensorEntityDescription[Inverter] @property @override @@ -315,6 +653,20 @@ def native_value(self) -> StateType: return self.entity_description.value_fn(self.coordinator.solaredge.inverter) +class SolarEdgeModbusMeterSensorEntity(SolarEdgeModbusMeterEntity, SensorEntity): + """Defines a SolarEdge Modbus meter sensor entity.""" + + entity_description: SolarEdgeModbusSensorEntityDescription[Meter] + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn( + self.coordinator.solaredge.meters[self._index - 1] + ) + + class SolarEdgeModbusEnergySensorEntity(RestoreSensor): """Keeps a lifetime-energy sensor monotonic across glitches and restarts. @@ -370,3 +722,9 @@ class SolarEdgeModbusInverterEnergySensorEntity( SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusInverterSensorEntity ): """Defines a monotonic SolarEdge Modbus inverter energy sensor entity.""" + + +class SolarEdgeModbusMeterEnergySensorEntity( + SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusMeterSensorEntity +): + """Defines a monotonic SolarEdge Modbus meter energy sensor entity.""" diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 096a0eab09dd5a..d008b7653e1623 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -86,6 +86,30 @@ "dc_voltage": { "name": "DC voltage" }, + "energy_exported": { + "name": "Energy exported" + }, + "energy_exported_phase_a": { + "name": "Energy exported phase A" + }, + "energy_exported_phase_b": { + "name": "Energy exported phase B" + }, + "energy_exported_phase_c": { + "name": "Energy exported phase C" + }, + "energy_imported": { + "name": "Energy imported" + }, + "energy_imported_phase_a": { + "name": "Energy imported phase A" + }, + "energy_imported_phase_b": { + "name": "Energy imported phase B" + }, + "energy_imported_phase_c": { + "name": "Energy imported phase C" + }, "inverter_status": { "name": "Status", "state": { @@ -99,6 +123,15 @@ "throttled": "Throttled" } }, + "power_phase_a": { + "name": "Power phase A" + }, + "power_phase_b": { + "name": "Power phase B" + }, + "power_phase_c": { + "name": "Power phase C" + }, "voltage_phase_ab": { "name": "Voltage phase A-B" }, diff --git a/tests/components/solaredge_modbus/conftest.py b/tests/components/solaredge_modbus/conftest.py index 998fe382ba1e09..64071d6d0abef6 100644 --- a/tests/components/solaredge_modbus/conftest.py +++ b/tests/components/solaredge_modbus/conftest.py @@ -29,6 +29,13 @@ PORT = 1502 UNIT_ID = 1 SERIAL_NUMBER = "7E123ABC" +METER_SERIAL_NUMBER = "7E4A11C2" + +# Where a meter's block starts, how far the next one sits, and where in it the +# serial number lives, as SunSpec lays them out. +METER_BASE = 40121 +METER_STRIDE = 174 +METER_SERIAL_BASE = 40171 def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]: @@ -49,7 +56,8 @@ async def async_seed_unit( The capture predates several of the points this integration reads, so those registers carry hand-picked values instead: distinct per point, and consistent with what the device did report (phase values sum to the - recorded totals, apparent power exceeds real power). Pass + recorded totals, apparent power exceeds real power). The meter's identity + block is hand-picked the same way, since the capture skips it. Pass ``serial_registers`` to override the inverter serial number ("7E123ABC" as captured). """ @@ -64,6 +72,30 @@ async def async_seed_unit( ) +def add_second_meter(unit: MockModbusUnit, serial_number: str) -> None: + """Wire a second meter onto a seeded unit. + + Every address of a meter shifts by the SunSpec stride per meter, so the + first meter's block, copied one stride up, is a second meter that reports + the same measurements under its own serial number. + """ + block = { + address + METER_STRIDE: value + for address, value in unit.holding.items() + if METER_BASE <= address < METER_BASE + METER_STRIDE + } + padded = serial_number.ljust(32, "\0").encode() + block.update( + { + METER_SERIAL_BASE + METER_STRIDE + index: ( + (padded[index * 2] << 8) | padded[index * 2 + 1] + ) + for index in range(16) + } + ) + unit.holding.update(block) + + @pytest.fixture async def mock_modbus_unit( hass: HomeAssistant, mock_modbus_connection: MockModbusConnection diff --git a/tests/components/solaredge_modbus/fixtures/se10000h.json b/tests/components/solaredge_modbus/fixtures/se10000h.json index 604743a5f8bd0f..a6e6d9a1afe0b0 100644 --- a/tests/components/solaredge_modbus/fixtures/se10000h.json +++ b/tests/components/solaredge_modbus/fixtures/se10000h.json @@ -63,6 +63,62 @@ "40106": 65534, "40107": 4, "40108": 4, + "40123": 21359, + "40124": 27745, + "40125": 29253, + "40126": 25703, + "40127": 25856, + "40128": 0, + "40129": 0, + "40130": 0, + "40131": 0, + "40132": 0, + "40133": 0, + "40134": 0, + "40135": 0, + "40136": 0, + "40137": 0, + "40138": 0, + "40139": 21317, + "40140": 11597, + "40141": 21586, + "40142": 11571, + "40143": 22829, + "40144": 13360, + "40145": 12374, + "40146": 11585, + "40147": 0, + "40148": 0, + "40149": 0, + "40150": 0, + "40151": 0, + "40152": 0, + "40153": 0, + "40154": 0, + "40163": 13102, + "40164": 12590, + "40165": 12288, + "40166": 0, + "40167": 0, + "40168": 0, + "40169": 0, + "40170": 0, + "40171": 14149, + "40172": 13377, + "40173": 12593, + "40174": 17202, + "40175": 0, + "40176": 0, + "40177": 0, + "40178": 0, + "40179": 0, + "40180": 0, + "40181": 0, + "40182": 0, + "40183": 0, + "40184": 0, + "40185": 0, + "40186": 0, "40188": 203, "40190": 3008, "40191": 1000, diff --git a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr index b1c0d683353cb2..e086af93533c44 100644 --- a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr @@ -130,15 +130,15 @@ 'energy_imported_b': 4107000.0, 'energy_imported_c': 4107555.0, 'events': 0, - 'manufacturer': '', - 'model': '', + 'manufacturer': 'SolarEdge', + 'model': 'SE-MTR-3Y-400V-A', 'option': '', 'reactive_energy_q1': 0, 'reactive_energy_q2': 0, 'reactive_energy_q3': 0, 'reactive_energy_q4': 0, - 'serial_number': '', - 'version': '', + 'serial_number': '**REDACTED**', + 'version': '3.1.0', }), ]), 'mmppt': None, diff --git a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr index c2eb360a9b00b5..a240a8825adc1e 100644 --- a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr @@ -1,4 +1,1594 @@ # serializer version: 1 +# name: test_sensors[sensor.meter_1_apparent_power-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': , + 'entity_id': 'sensor.meter_1_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'apparent_power', + : 'Meter 1 Apparent power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5350', + }) +# --- +# name: test_sensors[sensor.meter_1_current-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.meter_1_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.08', + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_a-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': , + 'entity_id': 'sensor.meter_1_current_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_current_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.02', + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_c-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': , + 'entity_id': 'sensor.meter_1_current_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.06', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported-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.meter_1_energy_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2324.624', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_a-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': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '774.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '775.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_c-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': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '775.624', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported-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.meter_1_energy_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12320.555', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_a-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': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4106.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4107.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_c-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': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4107.555', + }) +# --- +# name: test_sensors[sensor.meter_1_frequency-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': , + 'entity_id': 'sensor.meter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'Meter 1 Frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.02', + }) +# --- +# name: test_sensors[sensor.meter_1_power-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.meter_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5279', + }) +# --- +# name: test_sensors[sensor.meter_1_power_factor-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': , + 'entity_id': 'sensor.meter_1_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power factor', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.meter_1_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power_factor', + : 'Meter 1 Power factor', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_a-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': , + 'entity_id': 'sensor.meter_1_power_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1750', + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_power_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1760', + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_c-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': , + 'entity_id': 'sensor.meter_1_power_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1769', + }) +# --- +# name: test_sensors[sensor.meter_1_reactive_power-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': , + 'entity_id': 'sensor.meter_1_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_reactive_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Meter 1 Reactive power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '869', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage-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': , + 'entity_id': 'sensor.meter_1_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase A-B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase A-B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_ab', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_ab', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase A-B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '400', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_n-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': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_n', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase A-N', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase A-N', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_an', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_an', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_n-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase A-N', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_n', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_c-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': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase B-C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase B-C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_bc', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_bc', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase B-C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '401', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_n-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': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_n', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase B-N', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase B-N', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_bn', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_bn', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_n-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase B-N', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_n', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_a-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': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase C-A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase C-A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_ca', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_ca', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase C-A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '402', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_n-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': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_n', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase C-N', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase C-N', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_cn', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_cn', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_n-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase C-N', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_n', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233', + }) +# --- # name: test_sensors[sensor.solaredge_se10000h_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py index 4d4d240cadb62d..3b052d34a8498c 100644 --- a/tests/components/solaredge_modbus/test_init.py +++ b/tests/components/solaredge_modbus/test_init.py @@ -3,7 +3,11 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError +from modbus_connection import ( + IllegalDataAddressError, + ModbusTimeoutError, + ServerDeviceFailureError, +) from modbus_connection.mock import MockModbusConnection, MockModbusUnit import pytest @@ -14,7 +18,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from .conftest import SERIAL_NUMBER, async_seed_unit, tcp_data +from .conftest import METER_SERIAL_NUMBER, SERIAL_NUMBER, async_seed_unit, tcp_data from tests.common import MockConfigEntry, async_fire_time_changed @@ -67,6 +71,185 @@ async def test_inverter_that_does_not_name_itself( assert inverter.model_id is None +async def test_meter_is_a_sub_device_of_the_inverter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A meter is real hardware of its own, hanging off the inverter.""" + await _setup(hass, mock_config_entry) + + inverter = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id + ) + assert inverter is not None + + meter = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"), + mock_config_entry.entry_id, + ) + assert meter is not None + assert meter.via_device_id == inverter.id + assert meter.name == "Meter 1" + assert meter.model_id == "SE-MTR-3Y-400V-A" + assert meter.serial_number == METER_SERIAL_NUMBER + + +async def test_meter_without_a_serial_number_is_known_by_its_slot( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Not every meter names itself, and then its place on the inverter does. + + The fallback says which slot it is rather than just the number, so it + cannot be read as a serial number that happens to be short. + """ + mock_modbus_unit.holding.update(dict.fromkeys(range(40171, 40187), 0)) + + await _setup(hass, mock_config_entry) + + meter = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_meter_slot_1"), mock_config_entry.entry_id + ) + assert meter is not None + assert meter.serial_number is None + + +async def test_meter_that_left_the_installation_is_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A meter taken off the inverter does not linger as a device. + + Which meters are attached is read while the entry is set up, so a meter + that was removed is gone by the time the entry loads again. + """ + await _setup(hass, mock_config_entry) + + meter_identifier = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}") + assert ( + device_registry.async_get_device_by_identifier( + meter_identifier, mock_config_entry.entry_id + ) + is not None + ) + + mock_modbus_unit.fail_read(40188, IllegalDataAddressError()) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device_by_identifier( + meter_identifier, mock_config_entry.entry_id + ) + is None + ) + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id + ) + is not None + ) + + +async def test_setup_retry_when_a_meter_is_unreadable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A meter that answers the probe but not the poll holds up setup. + + Which sensors a meter offers is decided from its DID, once, so an entry + accepted without it would be missing its phase measurements until a reload. + """ + mock_modbus_unit.fail_read(40190, ServerDeviceFailureError()) + + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_meter_that_did_not_answer_the_probe_is_kept( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Silence while probing is not proof that a meter is gone. + + The library takes a block that does not answer for absent, which keeps the + rest of the device usable. Removing the device on that would throw away a + meter's history over a single timeout. + """ + await _setup(hass, mock_config_entry) + + meter_identifier = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}") + assert ( + device_registry.async_get_device_by_identifier( + meter_identifier, mock_config_entry.entry_id + ) + is not None + ) + + mock_modbus_unit.fail_read(40188, ModbusTimeoutError("timed out")) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device_by_identifier( + meter_identifier, mock_config_entry.entry_id + ) + is not None + ) + + +async def test_replaced_meter_is_a_new_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Another meter in the same place is another device. + + Its counters start where the old meter's did not, and reusing the device + would hold the new readings against the old meter's totals. + """ + await _setup(hass, mock_config_entry) + + replacement = "7E5B22D3" + padded = replacement.ljust(32, "\0").encode() + mock_modbus_unit.holding.update( + { + 40171 + index: (padded[index * 2] << 8) | padded[index * 2 + 1] + for index in range(16) + } + ) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"), + mock_config_entry.entry_id, + ) + is None + ) + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_meter_{replacement}"), + mock_config_entry.entry_id, + ) + is not None + ) + + async def test_single_late_answer_is_retried( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/solaredge_modbus/test_sensor.py b/tests/components/solaredge_modbus/test_sensor.py index 77bacc18a38229..f18439a606d3db 100644 --- a/tests/components/solaredge_modbus/test_sensor.py +++ b/tests/components/solaredge_modbus/test_sensor.py @@ -13,6 +13,8 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from .conftest import add_second_meter + from tests.common import ( MockConfigEntry, async_fire_time_changed, @@ -132,6 +134,115 @@ async def test_phase_currents_on_three_phase( assert hass.states.get("sensor.solaredge_se10000h_current_phase_c") is not None +async def test_two_meters_are_told_apart( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A second meter reads its own block and goes unavailable on its own. + + Both meters report the same measurements here, so what is worth proving is + that each entity reaches the meter it belongs to: the values move apart + when one block does, and only that meter's entities go unavailable when it + stops answering. + """ + add_second_meter(mock_modbus_unit, "7E5B22D3") + + await _setup_sensor_platform(hass, mock_config_entry) + + first = hass.states.get("sensor.meter_1_power") + second = hass.states.get("sensor.meter_2_power") + assert first is not None + assert second is not None + assert first.state == second.state + + # Only the second meter's power register moves. + mock_modbus_unit.holding[40206 + 174] = 1000 + + await _tick(hass, freezer) + + assert hass.states.get("sensor.meter_1_power") == first + second = hass.states.get("sensor.meter_2_power") + assert second is not None + assert second.state != first.state + + # Only the second meter falls silent. + mock_modbus_unit.fail_read(40297, ModbusTimeoutError("timed out")) + + await _tick(hass, freezer) + await _tick(hass, freezer) + + assert hass.states.get("sensor.meter_2_power").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.meter_1_power").state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_delta_meter_measures_nothing_against_a_neutral( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A delta meter has no neutral, so no voltage is measured against one.""" + mock_modbus_unit.holding[40188] = 204 # SunSpec three-phase delta meter + + await _setup_sensor_platform(hass, mock_config_entry) + + # Line-to-line is all a delta meter has. + assert hass.states.get("sensor.meter_1_voltage_phase_a_b") is not None + assert hass.states.get("sensor.meter_1_voltage_phase_b_c") is not None + + assert hass.states.get("sensor.meter_1_voltage") is None + assert hass.states.get("sensor.meter_1_voltage_phase_a_n") is None + assert hass.states.get("sensor.meter_1_voltage_phase_b_n") is None + assert hass.states.get("sensor.meter_1_voltage_phase_c_n") is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_split_phase_meter_has_two_phases( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A split-phase meter measures two phases, so the third is not offered.""" + mock_modbus_unit.holding[40188] = 202 # SunSpec split-phase meter + + await _setup_sensor_platform(hass, mock_config_entry) + + assert hass.states.get("sensor.meter_1_power_phase_a") is not None + assert hass.states.get("sensor.meter_1_power_phase_b") is not None + assert hass.states.get("sensor.meter_1_voltage_phase_a_n") is not None + + assert hass.states.get("sensor.meter_1_power_phase_c") is None + assert hass.states.get("sensor.meter_1_voltage_phase_c_n") is None + assert hass.states.get("sensor.meter_1_voltage_phase_b_c") is None + + +async def test_meter_energy_ignores_transient_zero( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A meter accumulator transiently reporting zero is held at the last maximum.""" + await _setup_sensor_platform(hass, mock_config_entry) + + state = hass.states.get("sensor.meter_1_energy_exported") + assert state is not None + initial = float(state.state) + assert initial > 0 + + # The meter block transiently reports "not accumulated" (zero). + mock_modbus_unit.holding[40226] = 0 + mock_modbus_unit.holding[40227] = 0 + + await _tick(hass, freezer) + + state = hass.states.get("sensor.meter_1_energy_exported") + assert state is not None + assert float(state.state) == initial + + async def test_lifetime_energy_never_goes_backwards( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 129ff239a35998727c47207ae8ba91f4ba36d2b3 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Sat, 29 Aug 2026 15:18:59 +0200 Subject: [PATCH 20/37] Handle malformed ViCare payloads as an update failure (#180407) --- .../components/vicare/coordinator.py | 4 +- tests/components/vicare/test_init.py | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/coordinator.py b/homeassistant/components/vicare/coordinator.py index e052681717ff4e..3472c724afc82c 100644 --- a/homeassistant/components/vicare/coordinator.py +++ b/homeassistant/components/vicare/coordinator.py @@ -9,6 +9,7 @@ PyViCareDeviceCommunicationError, PyViCareInternalServerError, PyViCareInvalidCredentialsError, + PyViCareInvalidDataError, PyViCareRateLimitError, ) import requests @@ -65,8 +66,9 @@ def _refresh(self) -> None: raise ConfigEntryAuthFailed from err except ( PyViCareDeviceCommunicationError, - PyViCareRateLimitError, PyViCareInternalServerError, + PyViCareInvalidDataError, + PyViCareRateLimitError, requests.RequestException, ) as err: raise UpdateFailed(str(err)) from err diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py index 4ef536b48bb4e6..10f370691e50f7 100644 --- a/tests/components/vicare/test_init.py +++ b/tests/components/vicare/test_init.py @@ -10,6 +10,7 @@ PyViCareInternalServerError, PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError, + PyViCareInvalidDataError, ) from homeassistant.components.vicare.const import DOMAIN @@ -503,6 +504,44 @@ async def test_coordinator_recovers_after_transient_failure( assert state.state != STATE_UNAVAILABLE +async def test_coordinator_handles_invalid_data( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A malformed payload fails the refresh without an unexpected error.""" + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + mock_vicare = MockPyViCare(fixtures) + service = mock_vicare.devices[0].service + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.SENSOR]), + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + sensor_id = "sensor.model0_outside_temperature" + service.fetch_all_features.side_effect = PyViCareInvalidDataError( + {"error": "no data"} + ) + caplog.clear() + freezer.tick(timedelta(seconds=120)) + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(sensor_id).state == STATE_UNAVAILABLE + assert "Unexpected error fetching" not in caplog.text + + async def test_per_device_failure_isolation( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From cba8d3db5daa7a2a4e4ce767875762e26b8b28c1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 15:19:37 +0200 Subject: [PATCH 21/37] Translate the update install and skip errors (#180615) --- homeassistant/components/update/__init__.py | 40 ++++++++++++++++---- homeassistant/components/update/strings.json | 26 +++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index f4e0a5312bba33..58761ff95ddcad 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -139,7 +139,11 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None entity.installed_version == entity.latest_version or entity.latest_version is None ): - raise HomeAssistantError(f"No update available for {entity.entity_id}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_update_available", + translation_placeholders={"entity_id": entity.entity_id}, + ) # If version is specified, but not supported by the entity. if ( @@ -147,19 +151,27 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features ): raise HomeAssistantError( - f"Installing a specific version is not supported for {entity.entity_id}" + translation_domain=DOMAIN, + translation_key="specific_version_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, ) # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] ) and UpdateEntityFeature.BACKUP not in entity.supported_features: - raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="backup_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, + ) # Update is already in progress. if entity.in_progress is not False: raise HomeAssistantError( - f"Update installation already in progress for {entity.entity_id}" + translation_domain=DOMAIN, + translation_key="update_in_progress", + translation_placeholders={"entity_id": entity.entity_id}, ) await entity.async_install_with_progress(version, backup) @@ -169,7 +181,9 @@ async def async_skip(entity: UpdateEntity, service_call: ServiceCall) -> None: """Service call wrapper to validate the call.""" if entity.auto_update: raise HomeAssistantError( - f"Skipping update is not supported for {entity.entity_id}" + translation_domain=DOMAIN, + translation_key="skip_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, ) await entity.async_skip() @@ -178,7 +192,9 @@ async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) - """Service call wrapper to validate the call.""" if entity.auto_update: raise HomeAssistantError( - f"Clearing skipped update is not supported for {entity.entity_id}" + translation_domain=DOMAIN, + translation_key="clear_skipped_not_supported", + translation_placeholders={"entity_id": entity.entity_id}, ) await entity.async_clear_skipped() @@ -362,9 +378,17 @@ def update_percentage(self) -> int | float | None: async def async_skip(self) -> None: """Skip the current offered version to update.""" if (latest_version := self.latest_version) is None: - raise HomeAssistantError(f"Cannot skip an unknown version for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_version_to_skip", + translation_placeholders={"entity_id": self.entity_id}, + ) if self.installed_version == latest_version: - raise HomeAssistantError(f"No update available to skip for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_update_available_to_skip", + translation_placeholders={"entity_id": self.entity_id}, + ) self.__skipped_version = latest_version self.async_write_ha_state() diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index fe27e41434b88f..306223b844198c 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -87,6 +87,32 @@ "name": "Firmware" } }, + "exceptions": { + "backup_not_supported": { + "message": "Backup is not supported for {entity_id}." + }, + "clear_skipped_not_supported": { + "message": "Clearing skipped update is not supported for {entity_id}." + }, + "no_update_available": { + "message": "No update available for {entity_id}." + }, + "no_update_available_to_skip": { + "message": "No update available to skip for {entity_id}." + }, + "skip_not_supported": { + "message": "Skipping update is not supported for {entity_id}." + }, + "specific_version_not_supported": { + "message": "Installing a specific version is not supported for {entity_id}." + }, + "unknown_version_to_skip": { + "message": "Cannot skip an unknown version for {entity_id}." + }, + "update_in_progress": { + "message": "Update installation already in progress for {entity_id}." + } + }, "services": { "clear_skipped": { "description": "Removes the skipped version marker from an update.", From 6e287f4b23f5b1091e399b36162be9612b8d83fa Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 15:23:52 +0200 Subject: [PATCH 22/37] Add serial support to SolarEdge Modbus (#180580) --- .../solaredge_modbus/config_flow.py | 156 ++++++++--- .../components/solaredge_modbus/const.py | 8 +- .../components/solaredge_modbus/helpers.py | 20 +- .../components/solaredge_modbus/manifest.json | 2 +- .../components/solaredge_modbus/strings.json | 55 +++- .../solaredge_modbus/test_config_flow.py | 257 ++++++++++++++---- 6 files changed, 399 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/solaredge_modbus/config_flow.py b/homeassistant/components/solaredge_modbus/config_flow.py index 90606be3d17c0a..d9d9c9d3fd46f7 100644 --- a/homeassistant/components/solaredge_modbus/config_flow.py +++ b/homeassistant/components/solaredge_modbus/config_flow.py @@ -7,25 +7,34 @@ import voluptuous as vol from homeassistant.components.modbus import async_get_temporary_unit -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SerialPortSelector, TextSelector, ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( + CONF_BAUDRATE, CONF_UNIT_ID, + DEFAULT_BAUDRATE, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER, + TYPE_SERIAL, TYPE_TCP, ) from .entity import inverter_name @@ -33,7 +42,27 @@ SECTION_MORE_OPTIONS = "more_options" -STEP_USER = vol.Schema( +# Almost every inverter answers on the factory-default device ID, so that +# setting is tucked away in a collapsed section. +MORE_OPTIONS = { + vol.Required(SECTION_MORE_OPTIONS): section( + vol.Schema( + { + vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, max=247, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + } + ), + {"collapsed": True}, + ) +} + +STEP_TCP = vol.Schema( { vol.Required(CONF_HOST): TextSelector(), vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( @@ -44,42 +73,61 @@ ), vol.Coerce(int), ), - # Almost every inverter answers on the factory-default device ID, so - # that setting is tucked away in a collapsed section. - vol.Required(SECTION_MORE_OPTIONS): section( - vol.Schema( - { - vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All( - NumberSelector( - NumberSelectorConfig( - min=1, max=247, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Coerce(int), - ), - } + **MORE_OPTIONS, + } +) + +STEP_SERIAL = vol.Schema( + { + vol.Required(CONF_DEVICE): SerialPortSelector(), + vol.Required(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, step=1, mode=NumberSelectorMode.BOX) ), - {"collapsed": True}, + vol.Coerce(int), ), + **MORE_OPTIONS, } ) -def _flatten(user_input: dict[str, Any]) -> dict[str, Any]: +def _flatten(connection_type: str, user_input: dict[str, Any]) -> dict[str, Any]: """Flatten the sectioned form input into config entry data.""" - data = {CONF_TYPE: TYPE_TCP, **user_input} + data = {CONF_TYPE: connection_type, **user_input} data[CONF_UNIT_ID] = data.pop(SECTION_MORE_OPTIONS)[CONF_UNIT_ID] - # One connection is shared per host and port, so spelling matters. - data[CONF_HOST] = data[CONF_HOST].lower() + + if connection_type == TYPE_TCP: + # One connection is shared per host and port, so spelling matters. + data[CONF_HOST] = data[CONF_HOST].lower() return data +def _needs_relink(entry: ConfigEntry, data: Mapping[str, Any]) -> bool: + """Whether probing these settings clashes with the connection in use. + + Everything talking to one device shares a single connection, which cannot + serve two different sets of line settings at once. Changing the baud rate + of the port an entry is polling is the case that needs that entry out of + the way before the new settings can be probed. + + An entry waiting to retry counts as being on the bus: the retry would set + it up on its old settings while the probe runs on the new ones. Unloading + it cancels that. + """ + if entry.state not in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY): + return False + + current = create_modbus_params(entry.data) + new = create_modbus_params(data) + + return new.endpoint == current.endpoint and new != current + + def _sectioned(data: Mapping[str, Any]) -> dict[str, Any]: """Shape config entry data back into the sectioned form input.""" return { - CONF_HOST: data[CONF_HOST], - CONF_PORT: data[CONF_PORT], + **{key: value for key, value in data.items() if key != CONF_UNIT_ID}, SECTION_MORE_OPTIONS: {CONF_UNIT_ID: data[CONF_UNIT_ID]}, } @@ -165,11 +213,34 @@ async def async_step_zeroconf_confirm( async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Ask where the inverter is, then probe it.""" + """Let the user pick how the inverter is reached.""" + return self.async_show_menu( + step_id="user", menu_options=[TYPE_TCP, TYPE_SERIAL] + ) + + async def async_step_tcp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle an inverter reached over the network.""" + return await self._async_step_link(TYPE_TCP, STEP_TCP, user_input) + + async def async_step_serial( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle an inverter reached over RS485.""" + return await self._async_step_link(TYPE_SERIAL, STEP_SERIAL, user_input) + + async def _async_step_link( + self, + connection_type: str, + schema: vol.Schema, + user_input: dict[str, Any] | None, + ) -> ConfigFlowResult: + """Ask for the link settings, then probe the inverter behind them.""" errors: dict[str, str] = {} if user_input is not None: - data = _flatten(user_input) + data = _flatten(connection_type, user_input) errors, solaredge = await self._async_validate(data) if solaredge is not None: await self.async_set_unique_id(solaredge.common.serial_number) @@ -179,7 +250,7 @@ async def async_step_user( ) return self.async_show_form( - step_id="user", data_schema=STEP_USER, errors=errors + step_id=connection_type, data_schema=schema, errors=errors ) async def async_step_reconfigure( @@ -188,25 +259,42 @@ async def async_step_reconfigure( """Handle reconfiguration of how the inverter is reached. The inverter may move to another address or device ID (a new gateway, a - changed setting), but it must stay the same inverter: the probed serial - number has to match the entry's unique ID. + rewired RS485 bus), but it must stay the same inverter: the probed + serial number has to match the entry's unique ID. """ errors: dict[str, str] = {} entry = self._get_reconfigure_entry() + connection_type = entry.data[CONF_TYPE] + schema = STEP_SERIAL if connection_type == TYPE_SERIAL else STEP_TCP if user_input is not None: - data = _flatten(user_input) + data = _flatten(connection_type, user_input) + + relinking = False + if _needs_relink(entry, data): + # A failed unload leaves the entry loaded; leave it be then and + # let the probe report whatever it runs into. + relinking = await self.hass.config_entries.async_unload(entry.entry_id) + errors, solaredge = await self._async_validate(data) if solaredge is not None: - if solaredge.common.serial_number == entry.unique_id: - return self.async_update_reload_and_abort(entry, data_updates=data) - return self.async_abort(reason="wrong_device") + await self.async_set_unique_id(solaredge.common.serial_number) + + # Anything other than the inverter this entry is for leaves it off + # the bus, so put it back before reporting what happened. A match + # falls through: the reload below brings it up on the new settings. + if relinking and self.unique_id != entry.unique_id: + await self.hass.config_entries.async_setup(entry.entry_id) + + if solaredge is not None: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort(entry, data_updates=data) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - STEP_USER, user_input or _sectioned(entry.data) + schema, user_input or _sectioned(entry.data) ), errors=errors, ) diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 980df2f3c5c1ce..8339aa22eb38e3 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -7,13 +7,15 @@ DOMAIN: Final = "solaredge_modbus" LOGGER = logging.getLogger(__package__) +CONF_BAUDRATE: Final = "baudrate" CONF_UNIT_ID: Final = "unit_id" -# How the inverter is reached is stored from the start, so that an inverter on -# something other than the network needs no migration to say so. +TYPE_SERIAL: Final = "serial" TYPE_TCP: Final = "tcp" -# SolarEdge's factory defaults: Modbus TCP on port 1502, device ID 1. +# SolarEdge's factory defaults: Modbus TCP on port 1502, RS485 at 115200 baud +# 8N1, device ID 1. +DEFAULT_BAUDRATE: Final = 115200 DEFAULT_PORT: Final = 1502 DEFAULT_UNIT_ID: Final = 1 diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py index 5a33382a1f9d5e..245b8834f0decf 100644 --- a/homeassistant/components/solaredge_modbus/helpers.py +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -3,11 +3,23 @@ from collections.abc import Mapping from typing import Any -from modbus_connection import ModbusTcpParams +from modbus_connection import ModbusSerialParams, ModbusTcpParams -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from .const import CONF_BAUDRATE, TYPE_SERIAL -def create_modbus_params(data: Mapping[str, Any]) -> ModbusTcpParams: - """Build the Modbus link parameters from config entry data.""" + +def create_modbus_params( + data: Mapping[str, Any], +) -> ModbusSerialParams | ModbusTcpParams: + """Build the Modbus link parameters from config entry data. + + The library's serial defaults are 8N1, which is what SolarEdge's RS485 + ports speak; only the baud rate is worth asking for. + """ + if data[CONF_TYPE] == TYPE_SERIAL: + return ModbusSerialParams( + device=data[CONF_DEVICE], baudrate=data[CONF_BAUDRATE] + ) return ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT]) diff --git a/homeassistant/components/solaredge_modbus/manifest.json b/homeassistant/components/solaredge_modbus/manifest.json index 4f0612f184b8d4..c3dffccb2b7fcd 100644 --- a/homeassistant/components/solaredge_modbus/manifest.json +++ b/homeassistant/components/solaredge_modbus/manifest.json @@ -3,7 +3,7 @@ "name": "SolarEdge Modbus", "codeowners": ["@frenck"], "config_flow": true, - "dependencies": ["modbus"], + "dependencies": ["modbus", "usb"], "documentation": "https://www.home-assistant.io/integrations/solaredge_modbus", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index d008b7653e1623..246f7afc4f77ed 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -7,38 +7,64 @@ "no_serial_number": "[%key:component::solaredge_modbus::config::error::no_serial_number%]", "no_solaredge_device": "[%key:component::solaredge_modbus::config::error::no_solaredge_device%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "wrong_device": "The device at that address and device ID is a different inverter than the one this entry is set up for." + "wrong_device": "The inverter answering on that connection and device ID is a different one than this entry is set up for." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "ev_charger": "That device is a SolarEdge EV charger. It answers as an inverter, but serves no measurements over Modbus.", "no_serial_number": "The inverter did not report a serial number, which is needed to identify it.", - "no_solaredge_device": "The device at that address and device ID does not answer as a SolarEdge inverter." + "no_solaredge_device": "No SolarEdge inverter answers on that connection and device ID." }, "step": { "reconfigure": { "data": { + "baudrate": "[%key:component::solaredge_modbus::config::step::serial::data::baudrate%]", + "device": "[%key:component::solaredge_modbus::config::step::serial::data::device%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "[%key:component::solaredge_modbus::config::step::user::data_description::host%]", - "port": "[%key:component::solaredge_modbus::config::step::user::data_description::port%]" + "baudrate": "[%key:component::solaredge_modbus::config::step::serial::data_description::baudrate%]", + "device": "[%key:component::solaredge_modbus::config::step::serial::data_description::device%]", + "host": "[%key:component::solaredge_modbus::config::step::tcp::data_description::host%]", + "port": "[%key:component::solaredge_modbus::config::step::tcp::data_description::port%]" }, - "description": "Update how this inverter is reached, for example after it moved to another address or its device ID changed.", + "description": "Update how this inverter is reached, for example after it moved to another address or its bus was rewired.", "sections": { "more_options": { "data": { - "unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data::unit_id%]" + "unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data::unit_id%]" }, "data_description": { - "unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data_description::unit_id%]" + "unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data_description::unit_id%]" }, - "name": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::name%]" + "name": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::name%]" } } }, - "user": { + "serial": { + "data": { + "baudrate": "Baud rate", + "device": "Serial port" + }, + "data_description": { + "baudrate": "The baud rate of the RS485 bus, as configured on the inverter. The SolarEdge default is 115200.", + "device": "The serial port the inverter's RS485 bus is wired to." + }, + "description": "Set up an inverter wired to this machine over RS485.", + "sections": { + "more_options": { + "data": { + "unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data::unit_id%]" + }, + "data_description": { + "unit_id": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::data_description::unit_id%]" + }, + "name": "[%key:component::solaredge_modbus::config::step::tcp::sections::more_options::name%]" + } + } + }, + "tcp": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" @@ -47,7 +73,7 @@ "host": "The hostname or IP address of your SolarEdge inverter. Modbus TCP has to be enabled on the inverter first, in the installer settings.", "port": "The TCP port the inverter listens on for Modbus requests. The SolarEdge default is 1502." }, - "description": "Connect to your SolarEdge inverter over Modbus to monitor your solar energy production locally.", + "description": "Set up an inverter reached over the network.", "sections": { "more_options": { "data": { @@ -60,6 +86,13 @@ } } }, + "user": { + "description": "Connect to your SolarEdge inverter over Modbus to monitor your solar energy production locally.", + "menu_options": { + "serial": "Serial (RS485)", + "tcp": "Network (Modbus TCP)" + } + }, "zeroconf_confirm": { "description": "Do you want to set up {name} at {host}?", "title": "Discovered SolarEdge inverter" @@ -169,7 +202,7 @@ "message": "The configured Modbus device does not answer as a SolarEdge inverter." }, "wrong_inverter": { - "message": "The device at this address is a different inverter than the one this entry was set up for. Reconfigure the entry to point at the right device." + "message": "A different inverter is answering than the one this entry was set up for. Reconfigure the entry to point at the right device." } } } diff --git a/tests/components/solaredge_modbus/test_config_flow.py b/tests/components/solaredge_modbus/test_config_flow.py index a8dbcd3325b43c..bfeac3975480ae 100644 --- a/tests/components/solaredge_modbus/test_config_flow.py +++ b/tests/components/solaredge_modbus/test_config_flow.py @@ -2,20 +2,25 @@ from ipaddress import ip_address from typing import Any +from unittest.mock import patch -from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError +from modbus_connection import ModbusTimeoutError, ModbusUnit, ServerDeviceFailureError from modbus_connection.mock import MockModbusConnection, MockModbusUnit import pytest +from solaredged import SolarEdge from homeassistant.components.solaredge_modbus.config_flow import SECTION_MORE_OPTIONS from homeassistant.components.solaredge_modbus.const import ( + CONF_BAUDRATE, CONF_UNIT_ID, + DEFAULT_BAUDRATE, DEFAULT_UNIT_ID, DOMAIN, + TYPE_SERIAL, TYPE_TCP, ) -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState +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 homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -25,6 +30,7 @@ from tests.common import MockConfigEntry TITLE = "SolarEdge SE10000H" +SERIAL_PORT = "/dev/ttyUSB0" # An inverter announcing itself, as captured from a real one. DISCOVERY_HOST = "10.148.42.116" @@ -60,6 +66,49 @@ def _user_input(unit_id: int = UNIT_ID) -> dict[str, Any]: } +def _serial_input( + baudrate: int = DEFAULT_BAUDRATE, unit_id: int = UNIT_ID +) -> dict[str, Any]: + """Form input for the serial step, with the sectioned device ID.""" + return { + CONF_DEVICE: SERIAL_PORT, + CONF_BAUDRATE: baudrate, + SECTION_MORE_OPTIONS: {CONF_UNIT_ID: unit_id}, + } + + +def _serial_entry(baudrate: int = DEFAULT_BAUDRATE) -> MockConfigEntry: + """A config entry for an inverter on an RS485 bus.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=SERIAL_NUMBER, + data={ + CONF_TYPE: TYPE_SERIAL, + CONF_DEVICE: SERIAL_PORT, + CONF_BAUDRATE: baudrate, + CONF_UNIT_ID: UNIT_ID, + }, + ) + + +async def _start_user_flow(hass: HomeAssistant, connection_type: str) -> str: + """Open the flow, pick a connection type, and return the flow ID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": connection_type} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == connection_type + + return result["flow_id"] + + def _model_registers(model: str) -> dict[int, int]: """Registers holding a model name in the SunSpec common block.""" padded = model.ljust(32, "\0").encode() @@ -71,34 +120,42 @@ def _model_registers(model: str) -> dict[int, int]: async def test_user_flow_tcp(hass: HomeAssistant) -> None: """An inverter on the network is probed and its entry created.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TITLE # read from the device + assert result["title"] == TITLE # named after the model it reports assert result["data"] == tcp_data() assert result["result"].unique_id == SERIAL_NUMBER # the inverter serial +async def test_user_flow_serial(hass: HomeAssistant) -> None: + """An inverter on an RS485 bus is probed and its entry created.""" + flow_id = await _start_user_flow(hass, TYPE_SERIAL) + + result = await hass.config_entries.flow.async_configure(flow_id, _serial_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["data"] == { + CONF_TYPE: TYPE_SERIAL, + CONF_DEVICE: SERIAL_PORT, + CONF_BAUDRATE: DEFAULT_BAUDRATE, + CONF_UNIT_ID: UNIT_ID, + } + assert result["result"].unique_id == SERIAL_NUMBER + + async def test_user_flow_cannot_connect( hass: HomeAssistant, mock_modbus_unit: MockModbusUnit ) -> None: """An unresponsive device surfaces cannot_connect, then the flow recovers.""" mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) assert result["type"] is FlowResultType.FORM @@ -125,12 +182,7 @@ async def test_user_flow_partial_answer( """ mock_modbus_unit.fail_read(40069, ServerDeviceFailureError()) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) assert result["type"] is FlowResultType.FORM @@ -154,17 +206,18 @@ async def test_user_flow_no_solaredge_device( unit = mock_modbus_connection.for_unit(2) unit.holding.update(dict.fromkeys(range(40000, 40004), 0)) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input(2)) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_solaredge_device"} + # The inverter is on another device ID. + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_user_flow_no_serial_number( hass: HomeAssistant, mock_modbus_connection: MockModbusConnection @@ -175,17 +228,18 @@ async def test_user_flow_no_serial_number( await async_seed_unit(hass, unit) unit.holding.update(dict.fromkeys(range(40052, 40068), 0)) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input(3)) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_serial_number"} + # The inverter that does name itself is on another device ID. + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_user_flow_ev_charger( hass: HomeAssistant, mock_modbus_connection: MockModbusConnection @@ -195,17 +249,18 @@ async def test_user_flow_ev_charger( await async_seed_unit(hass, unit) unit.holding.update(_model_registers("SE-EV-SA-KIT")) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input(4)) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "ev_charger"} + # The inverter sits next to the charger, on another device ID. + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + async def test_user_flow_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -213,12 +268,7 @@ async def test_user_flow_already_configured( """Setting up the same inverter twice aborts.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - flow_id = result["flow_id"] + flow_id = await _start_user_flow(hass, TYPE_TCP) result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) assert result["type"] is FlowResultType.ABORT @@ -302,6 +352,121 @@ async def test_reconfigure_flow_cannot_connect( assert result["reason"] == "reconfigure_successful" +async def test_reconfigure_flow_new_line_settings(hass: HomeAssistant) -> None: + """New line settings need the entry off the bus before they can be probed.""" + entry = _serial_entry() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _serial_input(baudrate=9600) + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_BAUDRATE] == 9600 + assert entry.state is ConfigEntryState.LOADED + + +async def test_reconfigure_flow_new_line_settings_cannot_connect( + hass: HomeAssistant, mock_modbus_unit: MockModbusUnit +) -> None: + """A failed probe puts the entry back on the bus it was taken off.""" + entry = _serial_entry() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _serial_input(baudrate=9600) + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert entry.data[CONF_BAUDRATE] == DEFAULT_BAUDRATE + # Setting the entry back up runs into the same dead device, so it lands in + # retry rather than staying unloaded with nothing scheduled to fix it. + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_reconfigure_flow_new_line_settings_wrong_device( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """A rejected reconfigure puts the entry back on the bus it was taken off.""" + entry = _serial_entry() + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await async_seed_unit( + hass, + mock_modbus_connection.for_unit(2), + serial_registers=OTHER_SERIAL_REGISTERS, + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _serial_input(baudrate=9600, unit_id=2) + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + assert entry.data[CONF_BAUDRATE] == DEFAULT_BAUDRATE + assert entry.state is ConfigEntryState.LOADED + + +async def test_reconfigure_flow_new_line_settings_while_retrying( + hass: HomeAssistant, mock_modbus_unit: MockModbusUnit +) -> None: + """An entry waiting to retry must not connect behind the probe's back. + + A retry sets the entry up on the settings it still has, and one connection + cannot serve two sets of line settings at once, so a retry landing halfway + through the probe would fail one of the two. Unloading the entry first + cancels the retry, and the probe has the bus to itself. + """ + entry = _serial_entry() + entry.add_to_hass(hass) + + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_RETRY + + # The inverter answers again, on a bus that now runs at another rate. + mock_modbus_unit.fail_read(40000, None) + + states: list[ConfigEntryState] = [] + probe = SolarEdge.async_probe + + async def probe_watching_the_entry(unit: ModbusUnit) -> SolarEdge: + """Record whether the entry could still be reaching for the bus.""" + states.append(entry.state) + return await probe(unit) + + result = await entry.start_reconfigure_flow(hass) + with patch.object(SolarEdge, "async_probe", probe_watching_the_entry): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _serial_input(baudrate=9600) + ) + await hass.async_block_till_done() + + # The reload at the end probes again, so only the first one is the flow's. + assert states[0] is ConfigEntryState.NOT_LOADED + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_BAUDRATE] == 9600 + assert entry.state is ConfigEntryState.LOADED + + async def test_zeroconf_discovery(hass: HomeAssistant) -> None: """An announced inverter is probed, confirmed and set up.""" result = await hass.config_entries.flow.async_init( From 8094598c2fe8f6ea13322d4ceb294841ee85f2f0 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Sat, 29 Aug 2026 15:25:10 +0200 Subject: [PATCH 23/37] Use Home Assistant's shared aiohttp session in Lyngdorf (#180620) --- homeassistant/components/lyngdorf/__init__.py | 5 +- .../components/lyngdorf/config_flow.py | 9 +- .../components/lyngdorf/quality_scale.yaml | 4 +- tests/components/lyngdorf/conftest.py | 176 +++++++++--------- tests/components/lyngdorf/test_config_flow.py | 10 +- tests/components/lyngdorf/test_init.py | 12 ++ 6 files changed, 124 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py index a34ae51c7020e4..19db70df72eeea 100644 --- a/homeassistant/components/lyngdorf/__init__.py +++ b/homeassistant/components/lyngdorf/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -42,7 +43,9 @@ async def async_setup_entry( try: receiver: LyngdorfReceiver = await create_receiver( - config_entry.data[CONF_HOST], lyngdorf_model + config_entry.data[CONF_HOST], + lyngdorf_model, + session=async_get_clientsession(hass), ) await receiver.connect() except TimeoutError as err: diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py index 18c39e8406ee21..16f0d62664f376 100644 --- a/homeassistant/components/lyngdorf/config_flow.py +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MODEL_NAME, @@ -100,7 +101,13 @@ async def _async_probe(self, host: str) -> tuple[LyngdorfModel, str]: try: location = await discover_ssdp_location(host) - serial = await fetch_device_serial(location) if location else None + serial = ( + await fetch_device_serial( + location, session=async_get_clientsession(self.hass) + ) + if location + else None + ) except TimeoutError as err: raise TimeoutConnect from err except OSError as err: diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml index c20a592b33a620..2b82bf6ecfda3b 100644 --- a/homeassistant/components/lyngdorf/quality_scale.yaml +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -84,7 +84,5 @@ rules: # Platinum async-dependency: todo - inject-websession: - status: exempt - comment: Integration uses local TCP, not HTTP. + inject-websession: done strict-typing: todo diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index bae4238c3d8fc8..1233278c1b8b60 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -55,92 +55,98 @@ def mock_setup_entry() -> Generator[None]: @pytest.fixture -def mock_receiver() -> Generator[MagicMock]: - """Return a mocked Lyngdorf receiver.""" +def mock_create_receiver() -> Generator[MagicMock]: + """Return a mocked create_receiver factory.""" with patch("homeassistant.components.lyngdorf.create_receiver") as create_mock: - receiver = MagicMock(spec=LyngdorfReceiver) - receiver.name = "Mock Lyngdorf" - receiver.connected = True - receiver.has_remote_keys = True - receiver.available_remote_keys = frozenset( - { - RemoteKey.UP, - RemoteKey.DOWN, - RemoteKey.ENTER, - RemoteKey.MENU, - RemoteKey.DIGIT_0, - } - ) - - # Diagnostics reports the whole receiver, so every property it reads - # needs a value here; an unset one is a mock the response cannot encode. - receiver.model = LyngdorfModel.MP_60 - receiver.max_volume = 0.0 - receiver.room_perfect_position = "Focus 1" - receiver.available_room_perfect_positions = ["Global", "Focus 1"] - receiver.voicing = "Neutral" - receiver.available_voicings = ["Neutral", "Music", "Movie"] - receiver.lipsync = None - receiver.lipsync_range = NumericRange(0, 500, 1) - for _t in ("bass", "treble"): - setattr(receiver, f"trim_{_t}", None) - setattr(receiver, f"trim_{_t}_range", NumericRange(-12.0, 12.0, 0.1)) - for _t in ("centre", "height", "lfe", "surround"): - setattr(receiver, f"trim_{_t}", None) - setattr(receiver, f"trim_{_t}_range", NumericRange(-10.0, 10.0, 0.1)) - - receiver.volume_range = NumericRange(-99.9, 24.0, 0.1) - receiver.zone_b_volume_range = NumericRange(-99.9, 24.0, 0.1) - - receiver.power_on = False - receiver.volume = -40.0 - receiver.mute_enabled = False - receiver.source = None - receiver.available_sources = [] - receiver.sound_mode = None - receiver.available_sound_modes = [] - - receiver.audio_information = "Stereo" - receiver.video_information = "4K HDR" - receiver.audio_input = "optical" - receiver.video_input = "hdmi" - receiver.streaming_source = "AirPlay" - receiver.available_audio_inputs = ["optical", "aux"] - receiver.available_video_inputs = ["hdmi"] - receiver.available_stream_types = ["AirPlay", "DLNA"] - - receiver.now_playing = None - receiver.has_position = False - receiver.position_ms = None - receiver.position_updated_at = None - receiver.shuffle = None - receiver.repeat = None - receiver.can_shuffle = False - receiver.available_repeat_modes = frozenset() - - receiver.lipsync = 50 - receiver.lipsync_range = NumericRange(0, 500, 1) - receiver.trim_bass = 3.0 - receiver.trim_treble = 0.0 - receiver.trim_centre = 0.0 - receiver.trim_height = 4.0 - receiver.trim_lfe = 3.0 - receiver.trim_surround = 0.0 - receiver.trim_bass_range = NumericRange(-12.0, 12.0, 0.1) - receiver.trim_treble_range = NumericRange(-12.0, 12.0, 0.1) - for _trim in ("centre", "height", "lfe", "surround"): - setattr(receiver, f"trim_{_trim}_range", NumericRange(-10.0, 10.0, 0.1)) - - receiver.zone_b_power_on = False - receiver.zone_b_volume = -40.0 - receiver.zone_b_mute_enabled = False - receiver.zone_b_source = None - receiver.zone_b_available_sources = [] - receiver.zone_b_audio_input = "aux" - receiver.zone_b_streaming_source = "DLNA" - - create_mock.return_value = receiver - yield receiver + yield create_mock + + +@pytest.fixture +def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: + """Return a mocked Lyngdorf receiver.""" + receiver = MagicMock(spec=LyngdorfReceiver) + receiver.name = "Mock Lyngdorf" + receiver.connected = True + receiver.has_remote_keys = True + receiver.available_remote_keys = frozenset( + { + RemoteKey.UP, + RemoteKey.DOWN, + RemoteKey.ENTER, + RemoteKey.MENU, + RemoteKey.DIGIT_0, + } + ) + + # Diagnostics reports the whole receiver, so every property it reads + # needs a value here; an unset one is a mock the response cannot encode. + receiver.model = LyngdorfModel.MP_60 + receiver.max_volume = 0.0 + receiver.room_perfect_position = "Focus 1" + receiver.available_room_perfect_positions = ["Global", "Focus 1"] + receiver.voicing = "Neutral" + receiver.available_voicings = ["Neutral", "Music", "Movie"] + receiver.lipsync = None + receiver.lipsync_range = NumericRange(0, 500, 1) + for _t in ("bass", "treble"): + setattr(receiver, f"trim_{_t}", None) + setattr(receiver, f"trim_{_t}_range", NumericRange(-12.0, 12.0, 0.1)) + for _t in ("centre", "height", "lfe", "surround"): + setattr(receiver, f"trim_{_t}", None) + setattr(receiver, f"trim_{_t}_range", NumericRange(-10.0, 10.0, 0.1)) + + receiver.volume_range = NumericRange(-99.9, 24.0, 0.1) + receiver.zone_b_volume_range = NumericRange(-99.9, 24.0, 0.1) + + receiver.power_on = False + receiver.volume = -40.0 + receiver.mute_enabled = False + receiver.source = None + receiver.available_sources = [] + receiver.sound_mode = None + receiver.available_sound_modes = [] + + receiver.audio_information = "Stereo" + receiver.video_information = "4K HDR" + receiver.audio_input = "optical" + receiver.video_input = "hdmi" + receiver.streaming_source = "AirPlay" + receiver.available_audio_inputs = ["optical", "aux"] + receiver.available_video_inputs = ["hdmi"] + receiver.available_stream_types = ["AirPlay", "DLNA"] + + receiver.now_playing = None + receiver.has_position = False + receiver.position_ms = None + receiver.position_updated_at = None + receiver.shuffle = None + receiver.repeat = None + receiver.can_shuffle = False + receiver.available_repeat_modes = frozenset() + + receiver.lipsync = 50 + receiver.lipsync_range = NumericRange(0, 500, 1) + receiver.trim_bass = 3.0 + receiver.trim_treble = 0.0 + receiver.trim_centre = 0.0 + receiver.trim_height = 4.0 + receiver.trim_lfe = 3.0 + receiver.trim_surround = 0.0 + receiver.trim_bass_range = NumericRange(-12.0, 12.0, 0.1) + receiver.trim_treble_range = NumericRange(-12.0, 12.0, 0.1) + for _trim in ("centre", "height", "lfe", "surround"): + setattr(receiver, f"trim_{_trim}_range", NumericRange(-10.0, 10.0, 0.1)) + + receiver.zone_b_power_on = False + receiver.zone_b_volume = -40.0 + receiver.zone_b_mute_enabled = False + receiver.zone_b_source = None + receiver.zone_b_available_sources = [] + receiver.zone_b_audio_input = "aux" + receiver.zone_b_streaming_source = "DLNA" + + mock_create_receiver.return_value = receiver + return receiver @pytest.fixture diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py index 96f8520ded253c..a71267b438da41 100644 --- a/tests/components/lyngdorf/test_config_flow.py +++ b/tests/components/lyngdorf/test_config_flow.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MODEL_NAME, @@ -26,8 +27,10 @@ MOCK_SERIAL = "0050c27c76b2" -@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") -async def test_user_flow(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_user_flow( + hass: HomeAssistant, mock_get_device_serial: AsyncMock +) -> None: """Test the user configuration flow with serial lookup.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,6 +51,9 @@ async def test_user_flow(hass: HomeAssistant) -> None: assert config_entry.data[CONF_HOST] == "192.168.1.100" assert config_entry.data[CONF_SERIAL_NUMBER] == MOCK_SERIAL assert config_entry.title == "mp-60" + assert mock_get_device_serial.call_args.kwargs[ + "session" + ] is async_get_clientsession(hass) @pytest.mark.usefixtures("mock_find_receiver_model") diff --git a/tests/components/lyngdorf/test_init.py b/tests/components/lyngdorf/test_init.py index 0e80319acb3741..241790cd14570a 100644 --- a/tests/components/lyngdorf/test_init.py +++ b/tests/components/lyngdorf/test_init.py @@ -10,6 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from tests.common import MockConfigEntry @@ -69,6 +70,17 @@ async def test_unload_entry( assert init_integration.state is ConfigEntryState.NOT_LOADED +async def test_uses_the_home_assistant_websession( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_create_receiver: MagicMock, +) -> None: + """Test the receiver is given Home Assistant's shared aiohttp session.""" + assert mock_create_receiver.call_args.kwargs["session"] is async_get_clientsession( + hass + ) + + async def test_unload_releases_receiver_subscriptions( hass: HomeAssistant, init_integration: MockConfigEntry, From 14723912f1c5efd574dc047ff9c3010c37fb90cb Mon Sep 17 00:00:00 2001 From: Imou-OpenPlatform Date: Sat, 29 Aug 2026 21:58:35 +0800 Subject: [PATCH 24/37] Add DHCP discovery to the Imou integration (#180083) --- homeassistant/components/imou/manifest.json | 13 ++ .../components/imou/quality_scale.yaml | 9 +- homeassistant/components/imou/strings.json | 3 +- homeassistant/generated/dhcp.py | 44 +++++++ tests/components/imou/test_config_flow.py | 114 +++++++++++++++++- 5 files changed, 175 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json index 000e1209a6d4fc..93e17708345044 100644 --- a/homeassistant/components/imou/manifest.json +++ b/homeassistant/components/imou/manifest.json @@ -3,6 +3,19 @@ "name": "Imou", "codeowners": ["@Imou-OpenPlatform"], "config_flow": true, + "dhcp": [ + { "macaddress": "1C4D89*" }, + { "macaddress": "302450*" }, + { "macaddress": "38AF29*" }, + { "macaddress": "3CE36B*" }, + { "macaddress": "3CEF8C*" }, + { "macaddress": "906A94*" }, + { "macaddress": "A0BD1D*" }, + { "macaddress": "A83162*" }, + { "macaddress": "AC3DFA*" }, + { "macaddress": "B44C3B*" }, + { "macaddress": "FCB69D*" } + ], "documentation": "https://www.home-assistant.io/integrations/imou", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/imou/quality_scale.yaml b/homeassistant/components/imou/quality_scale.yaml index fce93ed824f2f7..21e8faea2146af 100644 --- a/homeassistant/components/imou/quality_scale.yaml +++ b/homeassistant/components/imou/quality_scale.yaml @@ -48,13 +48,10 @@ rules: diagnostics: todo discovery-update-info: status: exempt - comment: Cloud service integration, does not support discovery. + comment: Cloud hub; DHCP only surfaces the integration. Device IP is not stored or updated. discovery: - status: exempt - comment: >- - Devices are reached via Imou Open Platform cloud APIs (App ID / secret). No - supported local discovery flow today; example cues if investigated later: - hostname `IPC-ABCD.imou.local`, MAC `aa:bb:cc:dd:ee:ff`. + status: done + comment: DHCP matches known Imou / Lechange MAC OUI prefixes. Setup still uses Open Platform App ID and secret. docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index 52b0f2b3872d1a..889a603f56b1e1 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index d932937f4e518b..12352d462bf8c9 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -504,6 +504,50 @@ "domain": "iaqualink", "hostname": "iaqualink-*", }, + { + "domain": "imou", + "macaddress": "1C4D89*", + }, + { + "domain": "imou", + "macaddress": "302450*", + }, + { + "domain": "imou", + "macaddress": "38AF29*", + }, + { + "domain": "imou", + "macaddress": "3CE36B*", + }, + { + "domain": "imou", + "macaddress": "3CEF8C*", + }, + { + "domain": "imou", + "macaddress": "906A94*", + }, + { + "domain": "imou", + "macaddress": "A0BD1D*", + }, + { + "domain": "imou", + "macaddress": "A83162*", + }, + { + "domain": "imou", + "macaddress": "AC3DFA*", + }, + { + "domain": "imou", + "macaddress": "B44C3B*", + }, + { + "domain": "imou", + "macaddress": "FCB69D*", + }, { "domain": "incomfort", "hostname": "rfgateway", diff --git a/tests/components/imou/test_config_flow.py b/tests/components/imou/test_config_flow.py index d8d475d7606308..d6e8eaad8917bf 100644 --- a/tests/components/imou/test_config_flow.py +++ b/tests/components/imou/test_config_flow.py @@ -16,14 +16,21 @@ CONF_APP_SECRET, DOMAIN, ) -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import TEST_APP_ID, TEST_APP_SECRET, USER_INPUT from tests.common import MockConfigEntry +DHCP_DISCOVERY = DhcpServiceInfo( + ip="127.0.0.1", + hostname="imou", + macaddress="1c4d895f7a29", +) + async def test_user_flow_success( hass: HomeAssistant, @@ -150,3 +157,108 @@ async def test_user_flow_success_per_region( assert result["title"] == "Imou" assert result["data"] == user_input assert result["result"].unique_id == user_input[CONF_APP_ID] + + +async def test_dhcp_discovery_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_imou_openapi_client: AsyncMock, +) -> None: + """DHCP discovery opens the existing user login form and creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Imou" + assert result["data"] == USER_INPUT + assert result["result"].unique_id == USER_INPUT[CONF_APP_ID] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_aborts_when_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Any existing Imou entry suppresses further DHCP discovery.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_aborts_when_user_flow_in_progress( + hass: HomeAssistant, +) -> None: + """DHCP discovery does not sit beside an unfinished manual setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_dhcp_discovery_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_imou_openapi_client: AsyncMock, +) -> None: + """Bad credentials stay on the user step, then recover to CREATE_ENTRY.""" + mock_imou_openapi_client.async_get_token.side_effect = ( + InvalidAppIdOrSecretException("fail") + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "invalid_auth" + + mock_imou_openapi_client.async_get_token.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Imou" + assert result["data"] == USER_INPUT + assert result["result"].unique_id == USER_INPUT[CONF_APP_ID] + assert len(mock_setup_entry.mock_calls) == 1 From eac53b5dc3b0f21cb3d7e3e0cbba5d4fc642172b Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 29 Aug 2026 16:01:55 +0200 Subject: [PATCH 25/37] Add support for priority to SMTP integration (#180358) --- homeassistant/components/smtp/const.py | 1 + homeassistant/components/smtp/notify.py | 12 ++++++++++++ homeassistant/components/smtp/services.py | 4 ++++ homeassistant/components/smtp/services.yaml | 14 ++++++++++++++ homeassistant/components/smtp/strings.json | 13 +++++++++++++ tests/components/smtp/snapshots/test_notify.ambr | 3 ++- tests/components/smtp/test_notify.py | 4 +++- 7 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index 8a5feccbe6f752..935d077ea2925e 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -11,6 +11,7 @@ ATTR_MEDIA_SOURCE: Final = "media_source" ATTR_FILENAME: Final = "filename" ATTR_CONTENT_ID: Final = "content_id" +ATTR_PRIORITY: Final = "priority" CONF_ENCRYPTION: Final = "encryption" CONF_SERVER: Final = "server" diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index f1a9645db8492c..112b4bf74d3ed3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -64,6 +64,7 @@ ATTR_HTML, ATTR_IMAGES, ATTR_MEDIA_SOURCE, + ATTR_PRIORITY, CONF_ENCRYPTION, CONF_ENTRY, CONF_SENDER_NAME, @@ -109,6 +110,14 @@ } ) +MAP_X_PRIORITY = { + "highest": "1 (Highest)", + "high": "2 (High)", + "normal": "3 (Normal)", + "low": "4 (Low)", + "lowest": "5 (Lowest)", +} + async def async_get_service( hass: HomeAssistant, @@ -225,6 +234,9 @@ async def smtp_send_message( msg.set_content(message) msg.add_header("Subject", title or ATTR_TITLE_DEFAULT) + if ATTR_PRIORITY in kwargs: + msg.add_header("X-Priority", MAP_X_PRIORITY[kwargs[ATTR_PRIORITY]]) + if ATTR_HTML in kwargs: msg.add_alternative(kwargs[ATTR_HTML], subtype="html") diff --git a/homeassistant/components/smtp/services.py b/homeassistant/components/smtp/services.py index c0c019b3e79f75..5b75759765e0d0 100644 --- a/homeassistant/components/smtp/services.py +++ b/homeassistant/components/smtp/services.py @@ -18,6 +18,7 @@ ATTR_FILENAME, ATTR_HTML, ATTR_MEDIA_SOURCE, + ATTR_PRIORITY, DOMAIN, ) @@ -40,6 +41,9 @@ ) ], ), + vol.Optional(ATTR_PRIORITY): vol.In( + ["highest", "high", "normal", "low", "lowest"] + ), } ) diff --git a/homeassistant/components/smtp/services.yaml b/homeassistant/components/smtp/services.yaml index 3683d7cd09b497..32d1ad75eed7de 100644 --- a/homeassistant/components/smtp/services.yaml +++ b/homeassistant/components/smtp/services.yaml @@ -44,3 +44,17 @@ send_message: selector: text: translation_key: attachments + priority: + required: false + selector: + select: + options: + - highest + - high + - normal + - low + - lowest + mode: dropdown + translation_key: "priority" + sort: false + example: highest diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index a8ae6784003ea9..0a428705cb66b5 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -183,6 +183,15 @@ "starttls": "STARTTLS", "tls": "SSL/TLS" } + }, + "priority": { + "options": { + "high": "High", + "highest": "Highest", + "low": "Low", + "lowest": "Lowest", + "normal": "Normal" + } } }, "services": { @@ -201,6 +210,10 @@ "description": "The plain text message body of the email notification.", "name": "Message" }, + "priority": { + "description": "Priority of the email notification. Support for this setting varies between email clients.", + "name": "Priority" + }, "title": { "description": "Subject for your email notification.", "name": "Title" diff --git a/tests/components/smtp/snapshots/test_notify.ambr b/tests/components/smtp/snapshots/test_notify.ambr index 9ea3311bb87c14..9cfdfc7e872f71 100644 --- a/tests/components/smtp/snapshots/test_notify.ambr +++ b/tests/components/smtp/snapshots/test_notify.ambr @@ -70,6 +70,7 @@ ''' MIME-Version: 1.0 Subject: Home Assistant + X-Priority: 1 (Highest) Content-Type: multipart/alternative; boundary="===============0000000000000000001==" From: Home Assistant @@ -89,7 +90,7 @@ Content-Transfer-Encoding: 7bit MIME-Version: 1.0 - + --===============0000000000000000001==-- diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index c6a1774f39553e..e91a41ac7ddc84 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -28,6 +28,7 @@ ATTR_FILENAME, ATTR_HTML, ATTR_MEDIA_SOURCE, + ATTR_PRIORITY, CONF_ENTRY, DOMAIN, ) @@ -400,7 +401,8 @@ async def test_smtp_send_message( { ATTR_ENTITY_ID: "notify.home_assistant_recipient", ATTR_MESSAGE: "Hello World", - ATTR_HTML: """""", + ATTR_HTML: """""", + ATTR_PRIORITY: "highest", }, blocking=True, ) From e05cb085b9e9c54898c2e7470a509fcf6d7e479c Mon Sep 17 00:00:00 2001 From: Samuel Siburian Date: Sat, 29 Aug 2026 21:02:43 +0700 Subject: [PATCH 26/37] Recognize AirGradient 0-1PS as an outdoor model (#180313) --- homeassistant/components/airgradient/const.py | 14 +++++++------- tests/components/airgradient/test_init.py | 7 ++++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/airgradient/const.py b/homeassistant/components/airgradient/const.py index 23e45576f0408d..69c7ae79758965 100644 --- a/homeassistant/components/airgradient/const.py +++ b/homeassistant/components/airgradient/const.py @@ -71,6 +71,11 @@ class ModelCapabilities: } ) +OUTDOOR_CAPABILITIES = ModelCapabilities( + config=COMMON_LEGACY_CONFIG, + actions=frozenset({CO2_CALIBRATION}), +) + MODEL_CAPABILITIES: tuple[tuple[str, ModelCapabilities], ...] = ( ( "I-9PSL", @@ -86,13 +91,8 @@ class ModelCapabilities: actions=frozenset({CO2_CALIBRATION, LED_BAR_TEST}), ), ), - ( - "O-1", - ModelCapabilities( - config=COMMON_LEGACY_CONFIG, - actions=frozenset({CO2_CALIBRATION}), - ), - ), + ("O-1", OUTDOOR_CAPABILITIES), + ("0-1PS", OUTDOOR_CAPABILITIES), ( "DIY", ModelCapabilities( diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py index 575792175d7ee4..3d80003dee85ac 100644 --- a/tests/components/airgradient/test_init.py +++ b/tests/components/airgradient/test_init.py @@ -8,7 +8,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airgradient.const import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN, get_model_capabilities from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -19,6 +19,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed +def test_outdoor_model_alias() -> None: + """Test the outdoor model firmware typo uses outdoor capabilities.""" + assert get_model_capabilities("0-1PS") == get_model_capabilities("O-1PPT") + + async def test_device_info( hass: HomeAssistant, snapshot: SnapshotAssertion, From 8813f3137f8ea30b1c4b7d983d6f89da6662806a Mon Sep 17 00:00:00 2001 From: Eddie Reasoner <136942618+EReaso@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:03:08 -0600 Subject: [PATCH 27/37] Deprecate Google Wifi uptime sensor (#177525) --- homeassistant/components/google_wifi/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 5deaa9d7f46aba..54568dbbf17023 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -60,6 +60,7 @@ class GoogleWifiSensorEntityDescription(SensorEntityDescription): sensor_key="updateNewVersion", icon="mdi:update", ), + # deprecated: The uptime sensor is deprecated and will be removed in 2027.4.0. Use last_restart instead. GoogleWifiSensorEntityDescription( key=ATTR_UPTIME, primary_key="system", From 037d2cfa16ddcc99a013722c6f7fe5dc6c98342d Mon Sep 17 00:00:00 2001 From: Patrick Vorgers Date: Sat, 29 Aug 2026 16:03:32 +0200 Subject: [PATCH 28/37] aws_s3: warmup loader caches to remove blocking listdir calls (#180413) --- homeassistant/components/aws_s3/__init__.py | 2 ++ .../components/aws_s3/config_flow.py | 2 ++ tests/components/aws_s3/test_config_flow.py | 12 ++++++++++- tests/components/aws_s3/test_init.py | 20 +++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aws_s3/__init__.py b/homeassistant/components/aws_s3/__init__.py index e28f22634f8f6f..b4aeab94a45619 100644 --- a/homeassistant/components/aws_s3/__init__.py +++ b/homeassistant/components/aws_s3/__init__.py @@ -3,6 +3,7 @@ import logging from typing import cast +from aiobotocore.config import AioConfig from aiobotocore.session import AioSession from botocore.exceptions import ClientError, ConnectionError, ParamValidationError @@ -37,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool: endpoint_url=data.get(CONF_ENDPOINT_URL), aws_secret_access_key=data[CONF_SECRET_ACCESS_KEY], aws_access_key_id=data[CONF_ACCESS_KEY_ID], + config=AioConfig(warm_up_loader_caches=True), ).__aenter__() await client.head_bucket(Bucket=data[CONF_BUCKET]) except ClientError as err: diff --git a/homeassistant/components/aws_s3/config_flow.py b/homeassistant/components/aws_s3/config_flow.py index 36280a08470957..4b01794771a15f 100644 --- a/homeassistant/components/aws_s3/config_flow.py +++ b/homeassistant/components/aws_s3/config_flow.py @@ -3,6 +3,7 @@ from typing import Any, override from urllib.parse import urlparse +from aiobotocore.config import AioConfig from aiobotocore.session import AioSession from botocore.exceptions import ClientError, ConnectionError, ParamValidationError import voluptuous as vol @@ -77,6 +78,7 @@ async def async_step_user( endpoint_url=user_input.get(CONF_ENDPOINT_URL), aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY], aws_access_key_id=user_input[CONF_ACCESS_KEY_ID], + config=AioConfig(warm_up_loader_caches=True), ) as client: await client.head_bucket(Bucket=user_input[CONF_BUCKET]) except ClientError: diff --git a/tests/components/aws_s3/test_config_flow.py b/tests/components/aws_s3/test_config_flow.py index 90d2ce3311c661..17d1e86cc04207 100644 --- a/tests/components/aws_s3/test_config_flow.py +++ b/tests/components/aws_s3/test_config_flow.py @@ -79,7 +79,17 @@ async def test_flow( expected_data: dict, ) -> None: """Test config flow with and without prefix, including prefix normalization.""" - result = await _async_start_flow(hass, user_input) + create_client = AsyncMock(name="create_client") + create_client.__aenter__.return_value.head_bucket.return_value = {} + + with patch( + "homeassistant.components.aws_s3.config_flow.AioSession.create_client", + return_value=create_client, + ) as patched_create_client: + result = await _async_start_flow(hass, user_input) + + assert patched_create_client.call_args.kwargs["config"].warm_up_loader_caches + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == expected_title assert result["data"] == expected_data diff --git a/tests/components/aws_s3/test_init.py b/tests/components/aws_s3/test_init.py index ee247bfce1de72..04afa23f388c6e 100644 --- a/tests/components/aws_s3/test_init.py +++ b/tests/components/aws_s3/test_init.py @@ -73,3 +73,23 @@ async def test_setup_entry_head_bucket_error( ) await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_warms_loader_caches( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that create_client passes warm_up_loader_caches config.""" + with patch( + "homeassistant.components.aws_s3.AioSession.create_client" + ) as create_client: + client_ctx = AsyncMock() + client = AsyncMock() + client_ctx.__aenter__.return_value = client + create_client.return_value = client_ctx + + await setup_integration(hass, mock_config_entry) + + create_client.assert_called_once() + _, kwargs = create_client.call_args + assert kwargs["config"].warm_up_loader_caches is True From ddee1c2697e25bba5a292cdd0804e03daa858324 Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:05:14 +0200 Subject: [PATCH 29/37] Add select entities for Sofar charger mode and EPS mode (#180385) --- homeassistant/components/sofar/__init__.py | 2 +- homeassistant/components/sofar/select.py | 89 ++++++++++ homeassistant/components/sofar/strings.json | 23 +++ .../sofar/snapshots/test_select.ambr | 133 +++++++++++++++ tests/components/sofar/test_select.py | 155 ++++++++++++++++++ 5 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sofar/select.py create mode 100644 tests/components/sofar/snapshots/test_select.ambr create mode 100644 tests/components/sofar/test_select.py diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index 0a6fd89442fd5d..6499636d5621a7 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SELECT, Platform.SENSOR] _IDENTITY_ATTEMPTS = 3 diff --git a/homeassistant/components/sofar/select.py b/homeassistant/components/sofar/select.py new file mode 100644 index 00000000000000..b9e6b201e0ecda --- /dev/null +++ b/homeassistant/components/sofar/select.py @@ -0,0 +1,89 @@ +"""Support for Sofar selects.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import IntEnum +from typing import override + +from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.modern.enums import ChargerUseMode, EpsControlMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SofarConfigEntry +from .entity import SofarEntity, SofarEntityDescription + +PARALLEL_UPDATES = 1 + + +def _enum_options(enum_type: type[IntEnum]) -> list[str]: + """The select options a device enum maps to.""" + return [member.name.lower() for member in enum_type] + + +@dataclass(frozen=True, kw_only=True) +class SofarSelectEntityDescription(SelectEntityDescription, SofarEntityDescription): + """Describe a Sofar select entity.""" + + options_enum: type[IntEnum] + write_fn: Callable[[SofarInverter, int], Awaitable[None]] + + +SELECT_DESCRIPTIONS: tuple[SofarSelectEntityDescription, ...] = ( + SofarSelectEntityDescription( + key="charger_use_mode", + component="charger", + translation_key="charger_use_mode", + options=_enum_options(ChargerUseMode), + options_enum=ChargerUseMode, + write_fn=lambda device, value: device.charger.write("charger_use_mode", value), + ), + SofarSelectEntityDescription( + key="eps_control", + component="eps", + translation_key="eps_control", + options=_enum_options(EpsControlMode), + options_enum=EpsControlMode, + write_fn=lambda device, value: device.eps.async_write_control( + EpsControlMode(value) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SofarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sofar Inverter Modbus select platform.""" + runtime_data = entry.runtime_data + served = runtime_data.served_components + async_add_entities( + SofarSelect(runtime_data, description) + for description in SELECT_DESCRIPTIONS + if description.component in served + ) + + +class SofarSelect(SofarEntity, SelectEntity): + """Defines a Sofar select entity.""" + + entity_description: SofarSelectEntityDescription + + @property + @override + def current_option(self) -> str | None: + """Return the currently selected option.""" + component = getattr(self.coordinator.device, self.entity_description.component) + value: IntEnum | None = getattr(component, self.entity_description.key) + return value.name.lower() if value is not None else None + + @override + async def async_select_option(self, option: str) -> None: + """Write the selected option to the device.""" + value = self.entity_description.options_enum[option.upper()] + await self.entity_description.write_fn(self.coordinator.device, value.value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 55c3e68343bfe2..cd21b53efd044a 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -40,6 +40,29 @@ "name": "RTC sync" } }, + "select": { + "charger_use_mode": { + "name": "Charger use mode", + "state": { + "feed_in_priority_mode": "Feed-in priority mode", + "generator_mode": "Generator mode", + "off_grid_mode": "Off-grid mode", + "passive_mode": "Passive mode", + "peak_cut_mode": "Peak cut mode", + "self_use": "Self use", + "time_of_use": "Time of use", + "timing_mode": "Timing mode" + } + }, + "eps_control": { + "name": "EPS mode", + "state": { + "turn_off": "Turn off", + "turn_on_enable_cold_start": "Turn on, enable cold start", + "turn_on_prohibit_cold_start": "Turn on, prohibit cold start" + } + } + }, "sensor": { "active_power_load_sys": { "name": "Active power load system" diff --git a/tests/components/sofar/snapshots/test_select.ambr b/tests/components/sofar/snapshots/test_select.ambr new file mode 100644 index 00000000000000..959a09e9461ca9 --- /dev/null +++ b/tests/components/sofar/snapshots/test_select.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_hybrid_entities[select.hydxxktl_3p_charger_use_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'self_use', + 'time_of_use', + 'timing_mode', + 'passive_mode', + 'peak_cut_mode', + 'off_grid_mode', + 'generator_mode', + 'feed_in_priority_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hydxxktl_3p_charger_use_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charger use mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charger use mode', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_use_mode', + 'unique_id': 'SP1XXES100XX_charger_use_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_hybrid_entities[select.hydxxktl_3p_charger_use_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P Charger use mode', + : list([ + 'self_use', + 'time_of_use', + 'timing_mode', + 'passive_mode', + 'peak_cut_mode', + 'off_grid_mode', + 'generator_mode', + 'feed_in_priority_mode', + ]), + }), + 'context': , + 'entity_id': 'select.hydxxktl_3p_charger_use_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_use', + }) +# --- +# name: test_hybrid_entities[select.hydxxktl_3p_eps_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'turn_off', + 'turn_on_prohibit_cold_start', + 'turn_on_enable_cold_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hydxxktl_3p_eps_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'EPS mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'EPS mode', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eps_control', + 'unique_id': 'SP1XXES100XX_eps_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_hybrid_entities[select.hydxxktl_3p_eps_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P EPS mode', + : list([ + 'turn_off', + 'turn_on_prohibit_cold_start', + 'turn_on_enable_cold_start', + ]), + }), + 'context': , + 'entity_id': 'select.hydxxktl_3p_eps_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'turn_off', + }) +# --- diff --git a/tests/components/sofar/test_select.py b/tests/components/sofar/test_select.py new file mode 100644 index 00000000000000..e86c5ff8552202 --- /dev/null +++ b/tests/components/sofar/test_select.py @@ -0,0 +1,155 @@ +"""Test the Sofar Inverter Modbus select platform.""" + +from unittest.mock import patch + +from modbus_connection import ModbusError +from modbus_connection.mock import MockModbusConnection +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.sofar.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + MOCK_HYBRID_MODEL, + MOCK_HYBRID_SERIAL, + MOCK_MODEL, + MOCK_SERIAL, + MOCK_USER_INPUT, + seed_hybrid_inverter, + seed_pv_inverter, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _setup_hybrid( + hass: HomeAssistant, +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a hybrid inverter with only the select platform loaded.""" + connection = MockModbusConnection() + seed_hybrid_inverter(connection.for_unit(1)) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_HYBRID_SERIAL, + data=MOCK_USER_INPUT, + title=MOCK_HYBRID_MODEL, + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.SELECT]), + patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: connection.for_unit( + unit_id + ), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return entry, connection + + +async def _setup_pv( + hass: HomeAssistant, +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a PV-only inverter with only the select platform loaded.""" + connection = MockModbusConnection() + seed_pv_inverter(connection.for_unit(1)) + entry = MockConfigEntry( + domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.SELECT]), + patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: connection.for_unit( + unit_id + ), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return entry, connection + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hybrid_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the select entities a hybrid inverter serves.""" + entry, _ = await _setup_hybrid(hass) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_pv_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a PV-only inverter gets no select entities.""" + entry, _ = await _setup_pv(hass) + assert er.async_entries_for_config_entry(entity_registry, entry.entry_id) == [] + + +@pytest.mark.parametrize( + ("key", "initial", "option"), + [ + pytest.param( + "charger_use_mode", "self_use", "feed_in_priority_mode", id="charger" + ), + pytest.param("eps_control", "turn_off", "turn_on_enable_cold_start", id="eps"), + ], +) +async def test_select_option( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + key: str, + initial: str, + option: str, +) -> None: + """Test selecting an option writes it and refreshes the read-back.""" + await _setup_hybrid(hass) + entity_id = entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_{key}" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + assert state.state == initial + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + assert (state := hass.states.get(entity_id)) is not None + assert state.state == option + + +async def test_select_option_modbus_error( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a write failure propagates as-is.""" + _, connection = await _setup_hybrid(hass) + charger_id = entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_charger_use_mode" + ) + assert charger_id is not None + connection.for_unit(1).fail_write(0x1110, ModbusError("busy")) + + with pytest.raises(ModbusError, match="busy"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: charger_id, ATTR_OPTION: "feed_in_priority_mode"}, + blocking=True, + ) From 84c8c76523323303bad62154ce38449b382643cd Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:06:55 +0200 Subject: [PATCH 30/37] Raise Sofar to the silver quality scale (#180292) --- homeassistant/components/sofar/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sofar/manifest.json b/homeassistant/components/sofar/manifest.json index a6da9e9a9f753c..ee28bda72f9483 100644 --- a/homeassistant/components/sofar/manifest.json +++ b/homeassistant/components/sofar/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/sofar", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["sofar-modbus==0.6.0"] } From b9991954a4784233faa71811ca2ccb8298ffed93 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Sat, 29 Aug 2026 19:43:21 +0530 Subject: [PATCH 31/37] Add Persang Infrared integration (#178107) --- CODEOWNERS | 2 + .../components/persang_infrared/__init__.py | 18 ++ .../persang_infrared/config_flow.py | 51 +++++ .../components/persang_infrared/const.py | 4 + .../components/persang_infrared/entity.py | 23 +++ .../components/persang_infrared/manifest.json | 11 ++ .../persang_infrared/media_player.py | 121 ++++++++++++ .../persang_infrared/quality_scale.yaml | 127 ++++++++++++ .../components/persang_infrared/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + tests/components/persang_infrared/__init__.py | 1 + tests/components/persang_infrared/conftest.py | 72 +++++++ .../snapshots/test_media_player.ambr | 55 ++++++ .../persang_infrared/test_config_flow.py | 71 +++++++ .../components/persang_infrared/test_init.py | 19 ++ .../persang_infrared/test_media_player.py | 187 ++++++++++++++++++ 17 files changed, 789 insertions(+) create mode 100644 homeassistant/components/persang_infrared/__init__.py create mode 100644 homeassistant/components/persang_infrared/config_flow.py create mode 100644 homeassistant/components/persang_infrared/const.py create mode 100644 homeassistant/components/persang_infrared/entity.py create mode 100644 homeassistant/components/persang_infrared/manifest.json create mode 100644 homeassistant/components/persang_infrared/media_player.py create mode 100644 homeassistant/components/persang_infrared/quality_scale.yaml create mode 100644 homeassistant/components/persang_infrared/strings.json create mode 100644 tests/components/persang_infrared/__init__.py create mode 100644 tests/components/persang_infrared/conftest.py create mode 100644 tests/components/persang_infrared/snapshots/test_media_player.ambr create mode 100644 tests/components/persang_infrared/test_config_flow.py create mode 100644 tests/components/persang_infrared/test_init.py create mode 100644 tests/components/persang_infrared/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 0c74cbd05d2364..e62ce07ea5b6e1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1410,6 +1410,8 @@ CLAUDE.md @home-assistant/core /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 +/homeassistant/components/persang_infrared/ @Dr-Blank +/tests/components/persang_infrared/ @Dr-Blank /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/pglab/ @pglab-electronics diff --git a/homeassistant/components/persang_infrared/__init__.py b/homeassistant/components/persang_infrared/__init__.py new file mode 100644 index 00000000000000..5f9f3eeb3b2053 --- /dev/null +++ b/homeassistant/components/persang_infrared/__init__.py @@ -0,0 +1,18 @@ +"""Persang Infrared integration for Home Assistant.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Persang IR from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Persang IR config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/persang_infrared/config_flow.py b/homeassistant/components/persang_infrared/config_flow.py new file mode 100644 index 00000000000000..e30a98fa00d0cf --- /dev/null +++ b/homeassistant/components/persang_infrared/config_flow.py @@ -0,0 +1,51 @@ +"""Config flow for the Persang Infrared integration.""" + +from typing import Any, override + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig + +from .const import CONF_INFRARED_EMITTER_ENTITY_ID, DOMAIN + + +class PersangIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Persang IR.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_emitters") + + if user_input is not None: + entity_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID] + + await self.async_set_unique_id(entity_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Persang speaker", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + ) diff --git a/homeassistant/components/persang_infrared/const.py b/homeassistant/components/persang_infrared/const.py new file mode 100644 index 00000000000000..59bd246f316aea --- /dev/null +++ b/homeassistant/components/persang_infrared/const.py @@ -0,0 +1,4 @@ +"""Constants for the Persang Infrared integration.""" + +DOMAIN = "persang_infrared" +CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id" diff --git a/homeassistant/components/persang_infrared/entity.py b/homeassistant/components/persang_infrared/entity.py new file mode 100644 index 00000000000000..f90f3f88d46e94 --- /dev/null +++ b/homeassistant/components/persang_infrared/entity.py @@ -0,0 +1,23 @@ +"""Common entity for the Persang Infrared integration.""" + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + + +class PersangIrEntity(InfraredEmitterConsumerEntity): + """Persang IR base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None: + """Initialize Persang IR entity.""" + self._infrared_emitter_entity_id = infrared_entity_id + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name="Persang speaker", + manufacturer="Persang", + ) diff --git a/homeassistant/components/persang_infrared/manifest.json b/homeassistant/components/persang_infrared/manifest.json new file mode 100644 index 00000000000000..ee2b9e56b87965 --- /dev/null +++ b/homeassistant/components/persang_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "persang_infrared", + "name": "Persang Infrared", + "codeowners": ["@Dr-Blank"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/persang_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/persang_infrared/media_player.py b/homeassistant/components/persang_infrared/media_player.py new file mode 100644 index 00000000000000..07ab91000f45ba --- /dev/null +++ b/homeassistant/components/persang_infrared/media_player.py @@ -0,0 +1,121 @@ +"""Media player platform for the Persang Infrared integration.""" + +from typing import override + +from infrared_protocols.codes.persang.speaker import PersangSpeakerCode + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_INFRARED_EMITTER_ENTITY_ID +from .entity import PersangIrEntity + +PARALLEL_UPDATES = 1 + +RESTORED_STATES = ( + MediaPlayerState.ON, + MediaPlayerState.OFF, + MediaPlayerState.PLAYING, + MediaPlayerState.PAUSED, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Persang IR media player from a config entry.""" + infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID] + async_add_entities([PersangIrMediaPlayer(entry, infrared_entity_id)]) + + +class PersangIrMediaPlayer(PersangIrEntity, MediaPlayerEntity, RestoreEntity): + """Persang IR speaker media player entity.""" + + _attr_name = None + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + ) + + @override + async def async_added_to_hass(self) -> None: + """Restore the last assumed state.""" + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None and ( + last_state.state in RESTORED_STATES + ): + self._attr_state = MediaPlayerState(last_state.state) + + @override + async def async_turn_on(self) -> None: + """Send the power command.""" + await self._send_command(PersangSpeakerCode.POWER.to_command()) + self._attr_state = MediaPlayerState.ON + self.async_write_ha_state() + + @override + async def async_turn_off(self) -> None: + """Send the power command.""" + await self._send_command(PersangSpeakerCode.POWER.to_command()) + self._attr_state = MediaPlayerState.OFF + self.async_write_ha_state() + + @override + async def async_volume_up(self) -> None: + """Send the volume up command.""" + await self._send_command(PersangSpeakerCode.VOLUME_UP.to_command()) + + @override + async def async_volume_down(self) -> None: + """Send the volume down command.""" + await self._send_command(PersangSpeakerCode.VOLUME_DOWN.to_command()) + + @override + async def async_mute_volume(self, mute: bool) -> None: + """Send the mute command.""" + await self._send_command(PersangSpeakerCode.MUTE.to_command()) + self._attr_is_volume_muted = mute + self.async_write_ha_state() + + @override + async def async_media_play(self) -> None: + """Send the play/pause command.""" + await self._send_command(PersangSpeakerCode.PLAY_PAUSE.to_command()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + @override + async def async_media_pause(self) -> None: + """Send the play/pause command.""" + await self._send_command(PersangSpeakerCode.PLAY_PAUSE.to_command()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + @override + async def async_media_next_track(self) -> None: + """Send the next track command.""" + await self._send_command(PersangSpeakerCode.NEXT.to_command()) + + @override + async def async_media_previous_track(self) -> None: + """Send the previous track command.""" + await self._send_command(PersangSpeakerCode.PREVIOUS.to_command()) diff --git a/homeassistant/components/persang_infrared/quality_scale.yaml b/homeassistant/components/persang_infrared/quality_scale.yaml new file mode 100644 index 00000000000000..f4e31311defae0 --- /dev/null +++ b/homeassistant/components/persang_infrared/quality_scale.yaml @@ -0,0 +1,127 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no connection to test in the config flow. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + docs-data-update: + status: exempt + comment: | + This integration does not fetch data from devices. + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Each config entry creates a single device. + entity-category: + status: exempt + comment: | + The media player entity is the primary entity and does not need a category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default. + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: + status: exempt + comment: | + This integration does not use custom icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration does not have repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols, which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not make HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/persang_infrared/strings.json b/homeassistant/components/persang_infrared/strings.json new file mode 100644 index 00000000000000..a74f5916bfb4c1 --- /dev/null +++ b/homeassistant/components/persang_infrared/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "This Persang speaker has already been configured with this transmitter.", + "no_emitters": "No infrared transmitter entities found. Please set up an infrared device first." + }, + "step": { + "user": { + "data": { + "infrared_emitter_entity_id": "Infrared transmitter" + }, + "data_description": { + "infrared_emitter_entity_id": "The infrared transmitter entity to use for sending commands." + }, + "description": "Select the infrared transmitter entity to use for controlling your Persang speaker.", + "title": "Set up Persang IR speaker" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6630e10402be57..2b010508cc0772 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -599,6 +599,7 @@ "peblar", "peco", "pegel_online", + "persang_infrared", "pglab", "philips_js", "pi_hole", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d08f636404027..79a286946fcffe 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5497,6 +5497,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "persang_infrared": { + "name": "Persang Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "pge": { "name": "Pacific Gas & Electric (PG&E)", "integration_type": "virtual", diff --git a/tests/components/persang_infrared/__init__.py b/tests/components/persang_infrared/__init__.py new file mode 100644 index 00000000000000..f8a98e13edb487 --- /dev/null +++ b/tests/components/persang_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the Persang Infrared integration.""" diff --git a/tests/components/persang_infrared/conftest.py b/tests/components/persang_infrared/conftest.py new file mode 100644 index 00000000000000..e752198be3ed52 --- /dev/null +++ b/tests/components/persang_infrared/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the Persang Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from infrared_protocols.codes.persang.speaker import PersangSpeakerCode +import pytest + +from homeassistant.components.persang_infrared import PLATFORMS +from homeassistant.components.persang_infrared.const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.infrared import ( + EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID, +) +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JTEST0000000000000000000", + title="Persang speaker", + data={CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID}, + unique_id=MOCK_INFRARED_EMITTER_ENTITY_ID, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return PLATFORMS + + +@pytest.fixture +def mock_persang_to_command() -> Generator[MagicMock]: + """Patch ``PersangSpeakerCode.to_command`` to return the code itself. + + This lets tests assert on the code enum rather than on raw NEC timings. + """ + with patch.object( + PersangSpeakerCode, + "to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ) as mock: + yield mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mock_persang_to_command: MagicMock, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the Persang Infrared integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.persang_infrared.PLATFORMS", platforms): + 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/persang_infrared/snapshots/test_media_player.ambr b/tests/components/persang_infrared/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..efde2b6bcfb10b --- /dev/null +++ b/tests/components/persang_infrared/snapshots/test_media_player.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_entities[media_player.persang_speaker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.persang_speaker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'persang_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000000', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.persang_speaker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'speaker', + : 'Persang speaker', + : , + }), + 'context': , + 'entity_id': 'media_player.persang_speaker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/persang_infrared/test_config_flow.py b/tests/components/persang_infrared/test_config_flow.py new file mode 100644 index 00000000000000..38612298d9a011 --- /dev/null +++ b/tests/components/persang_infrared/test_config_flow.py @@ -0,0 +1,71 @@ +"""Tests for the Persang Infrared config flow.""" + +import pytest + +from homeassistant.components.persang_infrared.const import ( + CONF_INFRARED_EMITTER_ENTITY_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_INFRARED_EMITTER_ENTITY_ID + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Persang speaker" + assert result["data"] == { + CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID + } + assert result["result"].unique_id == MOCK_INFRARED_EMITTER_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test user flow aborts when the transmitter is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_emitters" diff --git a/tests/components/persang_infrared/test_init.py b/tests/components/persang_infrared/test_init.py new file mode 100644 index 00000000000000..aec19fa8a36c10 --- /dev/null +++ b/tests/components/persang_infrared/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Persang Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + entry = init_integration + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/persang_infrared/test_media_player.py b/tests/components/persang_infrared/test_media_player.py new file mode 100644 index 00000000000000..2d3f1d66b144ad --- /dev/null +++ b/tests/components/persang_infrared/test_media_player.py @@ -0,0 +1,187 @@ +"""Tests for the Persang Infrared media player platform.""" + +from unittest.mock import patch + +from infrared_protocols.codes.persang.speaker import PersangSpeakerCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_UP, + MediaPlayerState, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import MOCK_INFRARED_EMITTER_ENTITY_ID + +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared.common import MockInfraredEmitterEntity + +MEDIA_PLAYER_ENTITY_ID = "media_player.persang_speaker" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.MEDIA_PLAYER] + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the media player entity is created with the correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entry = device_registry.async_get_device_by_identifier( + ("persang_infrared", mock_config_entry.entry_id), mock_config_entry.entry_id + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id + + +@pytest.mark.parametrize( + ("service", "expected_code", "expected_state"), + [ + (SERVICE_TURN_ON, PersangSpeakerCode.POWER, MediaPlayerState.ON), + (SERVICE_TURN_OFF, PersangSpeakerCode.POWER, MediaPlayerState.OFF), + (SERVICE_MEDIA_PLAY, PersangSpeakerCode.PLAY_PAUSE, MediaPlayerState.PLAYING), + (SERVICE_MEDIA_PAUSE, PersangSpeakerCode.PLAY_PAUSE, MediaPlayerState.PAUSED), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_state_changing_commands( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + expected_code: PersangSpeakerCode, + expected_state: MediaPlayerState, +) -> None: + """Test commands that send a code and update the assumed state.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [expected_code] + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "expected_code"), + [ + (SERVICE_VOLUME_UP, PersangSpeakerCode.VOLUME_UP), + (SERVICE_VOLUME_DOWN, PersangSpeakerCode.VOLUME_DOWN), + (SERVICE_MEDIA_NEXT_TRACK, PersangSpeakerCode.NEXT), + (SERVICE_MEDIA_PREVIOUS_TRACK, PersangSpeakerCode.PREVIOUS), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_stateless_commands( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + service: str, + expected_code: PersangSpeakerCode, +) -> None: + """Test commands that only send a code.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [expected_code] + + +@pytest.mark.parametrize("mute", [True, False]) +@pytest.mark.usefixtures("init_integration") +async def test_mute( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + mute: bool, +) -> None: + """Test muting sends the mute toggle and tracks the requested state.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: mute}, + blocking=True, + ) + + assert mock_infrared_emitter_entity.send_command_calls == [PersangSpeakerCode.MUTE] + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is mute + + +@pytest.mark.usefixtures("init_integration") +async def test_state_unknown_without_restored_state(hass: HomeAssistant) -> None: + """Test the entity starts as unknown when nothing was restored.""" + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "restored_state", + [ + MediaPlayerState.ON, + MediaPlayerState.OFF, + MediaPlayerState.PLAYING, + MediaPlayerState.PAUSED, + ], +) +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_restore_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], + restored_state: MediaPlayerState, +) -> None: + """Test the assumed state is restored across restarts.""" + mock_restore_cache(hass, [State(MEDIA_PLAYER_ENTITY_ID, restored_state)]) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.persang_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state + assert state.state == restored_state + + +@pytest.mark.usefixtures("init_integration") +async def test_media_player_availability_follows_ir_entity( + hass: HomeAssistant, +) -> None: + """Test the media player becomes unavailable when the IR entity is.""" + await assert_availability_follows_source_entity( + hass, MEDIA_PLAYER_ENTITY_ID, MOCK_INFRARED_EMITTER_ENTITY_ID + ) From 592f806e5f84f83478fcd7d8255bf65c64987841 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 29 Aug 2026 16:24:33 +0200 Subject: [PATCH 32/37] Specify integration domain in deprecation notice (#180628) --- homeassistant/helpers/device_registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index dc0ef4e8af5723..3a4a76a72a071e 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2484,6 +2484,7 @@ def async_get_or_create( # noqa: C901 "`async_update_device`", core_behavior=ReportBehavior.LOG, breaks_in_ha_version="2027.8.0", + integration_domain=config_entry.domain, ) self._async_purge_colliding_deleted_devices(device, identifiers, connections) From 3f020adf0f01dcb7b5efd5c3b13f4eb76fa8aaae Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:27:17 +0200 Subject: [PATCH 33/37] Bump google-maps-routing to 0.11.0 (#180622) --- homeassistant/components/google_travel_time/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index f26c7fe17dbd48..c1ead8fd4c2ecb 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["google", "homeassistant.helpers.location"], - "requirements": ["google-maps-routing==0.6.15"] + "requirements": ["google-maps-routing==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 64b98edfa1b3b0..51a767f3c6159c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ google-genai==2.16.0 google-health-api==0.9.0 # homeassistant.components.google_travel_time -google-maps-routing==0.6.15 +google-maps-routing==0.11.0 # homeassistant.components.nest google-nest-sdm==9.2.1 From 99ae47aa40d020cb23bb5fd93ad52296771ed253 Mon Sep 17 00:00:00 2001 From: Martin <32802427+mstu01@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:30:02 +0200 Subject: [PATCH 34/37] Fix swallowed exceptions in action handlers for MJPEG IP Camera (#180353) --- homeassistant/components/mjpeg/camera.py | 36 ++++--- homeassistant/components/mjpeg/strings.json | 8 ++ tests/components/mjpeg/test_camera.py | 114 ++++++++++++++++++++ 3 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/components/mjpeg/test_camera.py diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index cd0c13f549a282..81e41971c1e188 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -21,6 +21,7 @@ HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_web, async_get_clientsession, @@ -29,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client -from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER +from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN TIMEOUT = 10 BUFFER_SIZE = 102400 @@ -155,14 +156,19 @@ async def async_camera_image( return await response.read() - # pylint: disable-next=home-assistant-action-swallowed-exception - except TimeoutError: - LOGGER.error("Timeout getting camera image from %s", self.name) + except TimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_getting_image", + translation_placeholders={"name": str(self.name)}, + ) from err except aiohttp.ClientError as err: - LOGGER.error("Error getting new camera image from %s: %s", self.name, err) - - return None + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_getting_image", + translation_placeholders={"name": str(self.name)}, + ) from err def _get_httpx_auth(self) -> httpx.Auth: """Return a httpx auth object.""" @@ -192,13 +198,19 @@ async def _async_digest_or_fallback_camera_image(self) -> bytes | None: stream.aiter_bytes(BUFFER_SIZE) ) - except TimeoutError: - LOGGER.error("Timeout getting camera image from %s", self.name) + except (TimeoutError, httpx.TimeoutException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_getting_image", + translation_placeholders={"name": str(self.name)}, + ) from err except httpx.HTTPError as err: - LOGGER.error("Error getting new camera image from %s: %s", self.name, err) - - return None + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_getting_image", + translation_placeholders={"name": str(self.name)}, + ) from err async def _handle_async_mjpeg_digest_stream( self, request: web.Request diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json index fa7861a8263d73..e8ab9d50bde178 100644 --- a/homeassistant/components/mjpeg/strings.json +++ b/homeassistant/components/mjpeg/strings.json @@ -20,6 +20,14 @@ } } }, + "exceptions": { + "error_getting_image": { + "message": "Error getting a new image from {name}." + }, + "timeout_getting_image": { + "message": "Timeout getting an image from {name}." + } + }, "options": { "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/tests/components/mjpeg/test_camera.py b/tests/components/mjpeg/test_camera.py new file mode 100644 index 00000000000000..d07b3fa48e2466 --- /dev/null +++ b/tests/components/mjpeg/test_camera.py @@ -0,0 +1,114 @@ +"""Test the MJPEG IP Camera camera platform.""" + +import aiohttp +import httpx +import pytest +import respx + +from homeassistant.components.camera import async_get_image +from homeassistant.components.mjpeg.const import ( + CONF_MJPEG_URL, + CONF_STILL_IMAGE_URL, + DOMAIN, +) +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +ENTITY_CAMERA = "camera.my_mjpeg_camera" + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + pytest.param(TimeoutError, "timeout_getting_image", id="timeout"), + pytest.param(aiohttp.ClientError, "error_getting_image", id="client_error"), + ], +) +async def test_camera_image_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + exception: type[Exception], + translation_key: str, +) -> None: + """Test that a failed still image request raises instead of returning nothing.""" + aioclient_mock.get("http://example.com/still", exc=exception) + + with pytest.raises(HomeAssistantError) as exc_info: + await async_get_image(hass, ENTITY_CAMERA) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + + +@pytest.mark.usefixtures("init_integration") +async def test_camera_image( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that a still image is returned.""" + aioclient_mock.get("http://example.com/still", content=b"image_bytes") + + image = await async_get_image(hass, ENTITY_CAMERA) + + assert image.content == b"image_bytes" + + +@pytest.fixture +def mock_digest_config_entry() -> MockConfigEntry: + """Return a mocked config entry that uses digest authentication.""" + return MockConfigEntry( + title="My MJPEG Camera", + domain=DOMAIN, + data={}, + options={ + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "supersecret", + CONF_STILL_IMAGE_URL: "http://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: True, + }, + ) + + +@respx.mock +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + pytest.param(TimeoutError, "timeout_getting_image", id="timeout"), + pytest.param( + httpx.TimeoutException, "timeout_getting_image", id="httpx_timeout" + ), + pytest.param(httpx.HTTPError, "error_getting_image", id="http_error"), + ], +) +async def test_digest_camera_image_error( + hass: HomeAssistant, + mock_digest_config_entry: MockConfigEntry, + exception: type[Exception], + translation_key: str, +) -> None: + """Test that a failed digest image request raises instead of returning nothing.""" + mock_digest_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_digest_config_entry.entry_id) + await hass.async_block_till_done() + + respx.get("http://example.com/still").mock(side_effect=exception("boom")) + respx.get("https://example.com/mjpeg").mock(side_effect=exception("boom")) + + with pytest.raises(HomeAssistantError) as exc_info: + await async_get_image(hass, ENTITY_CAMERA) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key From 599f9b563a8c7c279f867123c83341627bfe1fd9 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Sat, 29 Aug 2026 16:35:56 +0200 Subject: [PATCH 35/37] Migrate the Lyngdorf number platform to the lyngdorf 2.0 API (#180627) --- homeassistant/components/lyngdorf/number.py | 64 ++++++++++--------- tests/components/lyngdorf/conftest.py | 42 ++++++++++-- .../lyngdorf/snapshots/test_diagnostics.ambr | 2 +- tests/components/lyngdorf/test_number.py | 62 +++++++++++------- 4 files changed, 110 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/lyngdorf/number.py b/homeassistant/components/lyngdorf/number.py index cdf186a032e7d6..e7edb15a69e3ce 100644 --- a/homeassistant/components/lyngdorf/number.py +++ b/homeassistant/components/lyngdorf/number.py @@ -1,11 +1,10 @@ """Number platform for Lyngdorf integration.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, override -from lyngdorf.device import Receiver -from lyngdorf.models.base import NumericRange +from lyngdorf import LyngdorfReceiver, NumericControl, NumericRange, Trim from homeassistant.components.number import ( NumberDeviceClass, @@ -27,9 +26,11 @@ class LyngdorfNumberEntityDescription(NumberEntityDescription): """Describe a Lyngdorf number entity.""" - value_fn: Callable[[Receiver], float | None] - set_value_fn: Callable[[Receiver, float], None] - range_fn: Callable[[Receiver], NumericRange | None] + # Whether the model has this control at all. Must not depend on the device + # having reported a value, or the entity is dropped at startup. + range_fn: Callable[[LyngdorfReceiver], NumericRange | None] + control_fn: Callable[[LyngdorfReceiver], NumericControl | None] + set_value_fn: Callable[[NumericControl, float], Awaitable[None]] NUMBER_ENTITIES: tuple[LyngdorfNumberEntityDescription, ...] = ( @@ -39,28 +40,28 @@ class LyngdorfNumberEntityDescription(NumberEntityDescription): device_class=NumberDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MILLISECONDS, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.lipsync, - # The device takes lip sync as whole milliseconds. - set_value_fn=lambda r, v: r.set_lipsync(round(v)), range_fn=lambda r: r.lipsync_range, + control_fn=lambda r: r.lipsync, + # The device takes lip sync as whole milliseconds. + set_value_fn=lambda c, v: c.set(round(v)), ), LyngdorfNumberEntityDescription( key="trim_bass", translation_key="trim_bass", native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.trim_bass, - set_value_fn=lambda r, v: r.set_trim_bass(v), - range_fn=lambda r: r.trim_bass_range, + range_fn=lambda r: c.range if (c := r.trims.get(Trim.BASS)) else None, + control_fn=lambda r: r.trims.get(Trim.BASS), + set_value_fn=lambda c, v: c.set(v), ), LyngdorfNumberEntityDescription( key="trim_treble", translation_key="trim_treble", native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.trim_treble, - set_value_fn=lambda r, v: r.set_trim_treble(v), - range_fn=lambda r: r.trim_treble_range, + range_fn=lambda r: c.range if (c := r.trims.get(Trim.TREBLE)) else None, + control_fn=lambda r: r.trims.get(Trim.TREBLE), + set_value_fn=lambda c, v: c.set(v), ), LyngdorfNumberEntityDescription( key="trim_centre", @@ -68,9 +69,9 @@ class LyngdorfNumberEntityDescription(NumberEntityDescription): entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.trim_centre, - set_value_fn=lambda r, v: r.set_trim_centre(v), - range_fn=lambda r: r.trim_centre_range, + range_fn=lambda r: c.range if (c := r.trims.get(Trim.CENTER)) else None, + control_fn=lambda r: r.trims.get(Trim.CENTER), + set_value_fn=lambda c, v: c.set(v), ), LyngdorfNumberEntityDescription( key="trim_height", @@ -78,9 +79,9 @@ class LyngdorfNumberEntityDescription(NumberEntityDescription): entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.trim_height, - set_value_fn=lambda r, v: r.set_trim_height(v), - range_fn=lambda r: r.trim_height_range, + range_fn=lambda r: c.range if (c := r.trims.get(Trim.HEIGHT)) else None, + control_fn=lambda r: r.trims.get(Trim.HEIGHT), + set_value_fn=lambda c, v: c.set(v), ), LyngdorfNumberEntityDescription( key="trim_lfe", @@ -88,9 +89,9 @@ class LyngdorfNumberEntityDescription(NumberEntityDescription): entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.trim_lfe, - set_value_fn=lambda r, v: r.set_trim_lfe(v), - range_fn=lambda r: r.trim_lfe_range, + range_fn=lambda r: c.range if (c := r.trims.get(Trim.LFE)) else None, + control_fn=lambda r: r.trims.get(Trim.LFE), + set_value_fn=lambda c, v: c.set(v), ), LyngdorfNumberEntityDescription( key="trim_surround", @@ -98,9 +99,9 @@ class LyngdorfNumberEntityDescription(NumberEntityDescription): entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, entity_category=EntityCategory.CONFIG, - value_fn=lambda r: r.trim_surround, - set_value_fn=lambda r, v: r.set_trim_surround(v), - range_fn=lambda r: r.trim_surround_range, + range_fn=lambda r: c.range if (c := r.trims.get(Trim.SURROUND)) else None, + control_fn=lambda r: r.trims.get(Trim.SURROUND), + set_value_fn=lambda c, v: c.set(v), ), ) @@ -114,7 +115,6 @@ async def async_setup_entry( runtime_data = config_entry.runtime_data receiver = runtime_data.receiver - # A None range means the model has no such control at all. async_add_entities( LyngdorfNumber(receiver, config_entry, runtime_data.device_info, description) for description in NUMBER_ENTITIES @@ -129,7 +129,7 @@ class LyngdorfNumber(LyngdorfEntity, NumberEntity): def __init__( self, - receiver: Receiver, + receiver: LyngdorfReceiver, config_entry: LyngdorfConfigEntry, device_info: DeviceInfo, description: LyngdorfNumberEntityDescription, @@ -172,9 +172,11 @@ def native_step(self) -> float: @property def native_value(self) -> float | None: """Return the current value.""" - return self.entity_description.value_fn(self._receiver) + control = self.entity_description.control_fn(self._receiver) + return control.value if control is not None else None @override async def async_set_native_value(self, value: float) -> None: """Set the value.""" - self.entity_description.set_value_fn(self._receiver, value) + if (control := self.entity_description.control_fn(self._receiver)) is not None: + await self.entity_description.set_value_fn(control, value) diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 1233278c1b8b60..f00cf05616f06f 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -3,11 +3,17 @@ from __future__ import annotations from collections.abc import Generator +from typing import Self from unittest.mock import AsyncMock, MagicMock, Mock, patch -from lyngdorf import LyngdorfModel, LyngdorfReceiver -from lyngdorf.models.base import NumericRange -from lyngdorf.remote import RemoteKey +from lyngdorf import ( + LyngdorfModel, + LyngdorfReceiver, + NumericControl, + NumericRange, + RemoteKey, + Trim, +) import pytest from homeassistant.components.lyngdorf.const import ( @@ -54,6 +60,26 @@ def mock_setup_entry() -> Generator[None]: yield +class _FloatControl(float): + """A float that is also a control, as the library's 1.x values are.""" + + def __new__(cls, value: float, value_range: NumericRange) -> Self: + """Return a float carrying the control interface alongside it.""" + control = super().__new__(cls, value) + control.value = value + control.range = value_range + control.set = AsyncMock() + return control + + +def _control(value: float | None, value_range: NumericRange) -> MagicMock: + """Return a mocked numeric control.""" + control = MagicMock(spec=NumericControl) + control.value = value + control.range = value_range + return control + + @pytest.fixture def mock_create_receiver() -> Generator[MagicMock]: """Return a mocked create_receiver factory.""" @@ -124,8 +150,16 @@ def mock_receiver(mock_create_receiver: MagicMock) -> MagicMock: receiver.can_shuffle = False receiver.available_repeat_modes = frozenset() - receiver.lipsync = 50 + receiver.lipsync = _FloatControl(50, NumericRange(0, 500, 1)) receiver.lipsync_range = NumericRange(0, 500, 1) + receiver.trims = { + Trim.BASS: _control(3.0, NumericRange(-12.0, 12.0, 0.1)), + Trim.TREBLE: _control(0.0, NumericRange(-12.0, 12.0, 0.1)), + Trim.CENTER: _control(0.0, NumericRange(-10.0, 10.0, 0.1)), + Trim.HEIGHT: _control(4.0, NumericRange(-10.0, 10.0, 0.1)), + Trim.LFE: _control(3.0, NumericRange(-10.0, 10.0, 0.1)), + Trim.SURROUND: _control(0.0, NumericRange(-10.0, 10.0, 0.1)), + } receiver.trim_bass = 3.0 receiver.trim_treble = 0.0 receiver.trim_centre = 0.0 diff --git a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr index 95ce728a221654..ae8f67c0dfabc4 100644 --- a/tests/components/lyngdorf/snapshots/test_diagnostics.ambr +++ b/tests/components/lyngdorf/snapshots/test_diagnostics.ambr @@ -89,7 +89,7 @@ 'Movie', ]), 'connected': True, - 'lipsync': 50, + 'lipsync': 50.0, 'max_volume': 0.0, 'model': 'MP_60', 'mute_enabled': False, diff --git a/tests/components/lyngdorf/test_number.py b/tests/components/lyngdorf/test_number.py index fe361e3b308867..7535385de77681 100644 --- a/tests/components/lyngdorf/test_number.py +++ b/tests/components/lyngdorf/test_number.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch -from lyngdorf.const import LyngdorfModel +from lyngdorf import LyngdorfModel, Trim import pytest from syrupy.assertion import SnapshotAssertion @@ -52,7 +52,7 @@ async def test_set_lipsync( mock_receiver: MagicMock, ) -> None: """Test setting the lipsync value.""" - mock_receiver.lipsync = 0 + mock_receiver.lipsync.value = 0 notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -67,26 +67,18 @@ async def test_set_lipsync( blocking=True, ) - mock_receiver.set_lipsync.assert_called_once_with(75) + mock_receiver.lipsync.set.assert_awaited_once_with(75) @pytest.mark.parametrize( - ("entity_id", "attribute", "method"), + ("entity_id", "trim"), [ - pytest.param(TRIM_BASS_ENTITY_ID, "trim_bass", "set_trim_bass", id="bass"), - pytest.param( - TRIM_TREBLE_ENTITY_ID, "trim_treble", "set_trim_treble", id="treble" - ), - pytest.param( - TRIM_CENTRE_ENTITY_ID, "trim_centre", "set_trim_centre", id="centre" - ), - pytest.param( - TRIM_HEIGHT_ENTITY_ID, "trim_height", "set_trim_height", id="height" - ), - pytest.param(TRIM_LFE_ENTITY_ID, "trim_lfe", "set_trim_lfe", id="lfe"), - pytest.param( - TRIM_SURROUND_ENTITY_ID, "trim_surround", "set_trim_surround", id="surround" - ), + pytest.param(TRIM_BASS_ENTITY_ID, Trim.BASS, id="bass"), + pytest.param(TRIM_TREBLE_ENTITY_ID, Trim.TREBLE, id="treble"), + pytest.param(TRIM_CENTRE_ENTITY_ID, Trim.CENTER, id="centre"), + pytest.param(TRIM_HEIGHT_ENTITY_ID, Trim.HEIGHT, id="height"), + pytest.param(TRIM_LFE_ENTITY_ID, Trim.LFE, id="lfe"), + pytest.param(TRIM_SURROUND_ENTITY_ID, Trim.SURROUND, id="surround"), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") @@ -94,11 +86,10 @@ async def test_set_trim( hass: HomeAssistant, mock_receiver: MagicMock, entity_id: str, - attribute: str, - method: str, + trim: Trim, ) -> None: """Test setting each trim value.""" - setattr(mock_receiver, attribute, 0.0) + mock_receiver.trims[trim].value = 0.0 notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -113,7 +104,7 @@ async def test_set_trim( blocking=True, ) - getattr(mock_receiver, method).assert_called_once_with(-6.0) + mock_receiver.trims[trim].set.assert_awaited_once_with(-6.0) async def test_number_none_values( @@ -123,7 +114,7 @@ async def test_number_none_values( ) -> None: """Test a number shows unknown when the device reports nothing.""" mock_receiver.lipsync = None - mock_receiver.trim_bass = None + mock_receiver.trims[Trim.BASS].value = None notify_receiver_update(mock_receiver) await hass.async_block_till_done() @@ -131,6 +122,29 @@ async def test_number_none_values( assert hass.states.get(TRIM_BASS_ENTITY_ID).state == STATE_UNKNOWN +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity_created_before_the_device_reports_a_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test a control the model has still gets an entity before its first report.""" + mock_receiver.lipsync = None + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.lyngdorf.lookup_model", + return_value=LyngdorfModel.MP_60, + ), + patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.NUMBER]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(LIPSYNC_ENTITY_ID).state == STATE_UNKNOWN + + @pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_receiver") async def test_entities_absent_for_controls_the_model_lacks( hass: HomeAssistant, @@ -139,7 +153,7 @@ async def test_entities_absent_for_controls_the_model_lacks( ) -> None: """Test no entity is created where the model has no such control.""" mock_receiver.lipsync_range = None - mock_receiver.trim_surround_range = None + del mock_receiver.trims[Trim.SURROUND] mock_config_entry.add_to_hass(hass) with ( From 1273fd566fce03aa1c3874e93be8603686fef041 Mon Sep 17 00:00:00 2001 From: HansKDev Date: Sat, 29 Aug 2026 16:37:33 +0200 Subject: [PATCH 36/37] Fix Growatt V1 total output power (#176513) --- .../components/growatt_server/coordinator.py | 60 +++++++++++- tests/components/growatt_server/conftest.py | 5 + .../components/growatt_server/test_sensor.py | 96 ++++++++++++++++++- 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 7a6c07bc3ba6c5..44b18f0d7e1d2a 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -3,6 +3,7 @@ import datetime import json import logging +import math from typing import TYPE_CHECKING, Any, override import growattServer @@ -38,10 +39,46 @@ type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData] SCAN_INTERVAL = datetime.timedelta(minutes=5) +_MAX_POWER_READING_AGE = datetime.timedelta(minutes=10) _LOGGER = logging.getLogger(__name__) +def _latest_power_value( + power_overview: dict[str, Any], now: datetime.datetime +) -> float | None: + """Return the newest recent power value from a plant power overview.""" + latest_time: datetime.datetime | None = None + latest_power: float | None = None + + for reading in power_overview.get("powers", []): + power = reading.get("power") + timestamp = reading.get("time") + if power is None or not isinstance(timestamp, str): + continue + try: + reading_time = dt_util.parse_datetime(timestamp) + except ValueError: + continue + if reading_time is None: + continue + if reading_time.tzinfo is None: + reading_time = reading_time.replace(tzinfo=dt_util.get_default_time_zone()) + if reading_time > now or now - reading_time > _MAX_POWER_READING_AGE: + continue + try: + power_value = float(power) + except TypeError, ValueError: + continue + if not math.isfinite(power_value): + continue + if latest_time is None or reading_time > latest_time: + latest_time = reading_time + latest_power = power_value + + return latest_power + + class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator to manage Growatt data fetching.""" @@ -205,8 +242,27 @@ def _sync_update_data(self) -> dict[str, Any]: ) from err total_info["todayEnergy"] = total_info["today_energy"] total_info["totalEnergy"] = total_info["total_energy"] - # V1 API returns current_power in kW, convert to W - total_info["invTodayPpv"] = total_info["current_power"] * 1000 + # The plant power overview is authoritative for instantaneous power. + # Plant energy overview's current_power is only a fallback on error; + # it can report zero while production continues. + current_power = total_info["current_power"] * 1000 + now = dt_util.now() + try: + power_overview = self.api.plant_power_overview( + self.plant_id, now.date() + ) + except (growattServer.GrowattV1ApiError, RequestException) as err: + _LOGGER.debug( + "Failed to fetch plant power overview for %s: %s", + self.plant_id, + err, + ) + else: + if ( + latest_power := _latest_power_value(power_overview, now) + ) is not None: + current_power = latest_power + total_info["invTodayPpv"] = current_power else: # Classic API: use plant_info as before. # Copy the response to avoid mutating the dict returned by the library diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 1bade818640643..cc68f609f9efc5 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -29,6 +29,7 @@ def mock_growatt_v1_api(): Methods mocked for integration setup: - device_list: Called during async_setup_entry to discover devices - plant_energy_overview: Called by total coordinator during first refresh + - plant_power_overview: Called for the total coordinator's current power Methods mocked for MIN device coordinator refresh: - min_detail: Provides device state (e.g., acChargeEnable, chargePowerCommand) @@ -145,6 +146,10 @@ def mock_growatt_v1_api(): "total_energy": 1250.0, "current_power": 2.5, } + mock_v1_api.plant_power_overview.return_value = { + "count": 0, + "powers": [], + } # Called by switch/number entities during turn_on/turn_off/set_value mock_v1_api.min_write_parameter.return_value = None diff --git a/tests/components/growatt_server/test_sensor.py b/tests/components/growatt_server/test_sensor.py index 5f68610acc465a..e84bd31346d847 100644 --- a/tests/components/growatt_server/test_sensor.py +++ b/tests/components/growatt_server/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the Growatt Server sensor platform.""" -from datetime import timedelta -from unittest.mock import patch +from datetime import date, timedelta +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import growattServer @@ -183,6 +183,98 @@ async def test_sensor_coordinator_updates( assert state.state == "25.0" +@pytest.mark.parametrize( + ("powers", "expected_state"), + [ + pytest.param( + [ + {"time": "2026-07-14 18:50", "power": 1000.0}, + {"time": "2026-07-14 19:05", "power": 700.0}, + {"time": "invalid", "power": 900.0}, + {"time": "2026-07-14 18:55", "power": 880.6}, + {"time": "2026-07-14 18:57", "power": None}, + ], + "880.6", + id="newest-valid-reading", + ), + pytest.param( + [{"time": "2026-07-14 18:55", "power": 0.0}], + "0.0", + id="nighttime-zero", + ), + pytest.param( + [{"time": "2026-07-14 18:45", "power": 900.0}], + "2500.0", + id="stale-reading", + ), + pytest.param( + [ + {"time": None, "power": 900.0}, + {"time": "2026-02-30 12:00", "power": 900.0}, + ], + "2500.0", + id="invalid-timestamps", + ), + pytest.param( + [ + {"time": "2026-07-14 18:55", "power": "nan"}, + {"time": "2026-07-14 18:56", "power": "inf"}, + ], + "2500.0", + id="non-finite-power", + ), + pytest.param([], "2500.0", id="missing-readings"), + ], +) +@pytest.mark.freeze_time("2026-07-14 17:00:00+00:00") +async def test_v1_total_output_power_uses_recent_plant_power( + hass: HomeAssistant, + mock_growatt_v1_api: MagicMock, + mock_config_entry: MockConfigEntry, + powers: list[dict[str, str | float | None]], + expected_state: str, +) -> None: + """Test V1 total output power uses the newest recent plant power reading.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + mock_growatt_v1_api.plant_power_overview.return_value = { + "count": len(powers), + "powers": powers, + } + + with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_plant_total_output_power") + assert state is not None + assert state.state == expected_state + mock_growatt_v1_api.plant_power_overview.assert_called_once_with( + "123456", date(2026, 7, 14) + ) + + +@pytest.mark.freeze_time("2026-07-14 17:00:00+00:00") +async def test_v1_total_output_power_falls_back_on_power_api_error( + hass: HomeAssistant, + mock_growatt_v1_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test V1 total output power remains available when plant power fails.""" + mock_growatt_v1_api.plant_power_overview.side_effect = ( + growattServer.GrowattV1ApiError( + message="Rate limited", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Too many requests", + ) + ) + + with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_plant_total_output_power") + assert state is not None + assert state.state == "2500.0" + + async def test_sensor_unavailable_on_coordinator_error( hass: HomeAssistant, mock_growatt_v1_api, From 5f77b4b4619bdea0f0b945b74256d4087d787370 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:56:00 +0200 Subject: [PATCH 37/37] Add water_heater platform to Plugwise (#177480) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/plugwise/const.py | 9 + .../components/plugwise/strings.json | 14 +- .../components/plugwise/water_heater.py | 166 +++++++++++++++ .../plugwise/snapshots/test_water_heater.ambr | 125 ++++++++++++ tests/components/plugwise/test_init.py | 6 +- tests/components/plugwise/test_number.py | 10 +- .../components/plugwise/test_water_heater.py | 191 ++++++++++++++++++ 7 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/plugwise/water_heater.py create mode 100644 tests/components/plugwise/snapshots/test_water_heater.ambr create mode 100644 tests/components/plugwise/test_water_heater.py diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index e57dcaba3549aa..08debbe9977b12 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -19,6 +19,7 @@ FLOW_TYPE: Final = "flow_type" GATEWAY: Final = "gateway" LOCATION: Final = "location" +LOWER_BOUND: Final = "lower_bound" PW_TYPE: Final = "plugwise_type" REBOOT: Final = "reboot" SMILE: Final = "smile" @@ -27,6 +28,7 @@ STRETCH: Final = "stretch" STRETCH_USERNAME: Final = "stretch" UNKNOWN_SMILE: Final = "Unknown Smile" +UPPER_BOUND: Final = "upper_bound" PLATFORMS: Final[list[str]] = [ Platform.BINARY_SENSOR, @@ -36,6 +38,7 @@ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.WATER_HEATER, ] ZEROCONF_MAP: Final[dict[str, str]] = { "smile": "Smile P1", @@ -44,6 +47,8 @@ "stretch": "Stretch", } +type BinarySensorType = Literal["dhw_state"] + type NumberType = Literal[ "boiler_temperature", "dhw_temperature", @@ -65,6 +70,7 @@ "regulation_modes", "zone_profiles", ] +type WaterHeaterType = Literal["dhw_temperature"] # Default directives DEFAULT_MAX_TEMP: Final = 30 @@ -88,3 +94,6 @@ SELECT_REGULATION_MODE: Final = "select_regulation_mode" SELECT_SCHEDULE: Final = "select_schedule" SELECT_ZONE_PROFILE: Final = "select_zone_profile" + +# Water_heater constants +DHW_TEMP: Final = "dhw_temperature" diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 17e5014cec70e8..1bcc8b2730f221 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -113,9 +113,9 @@ "name": "DHW mode", "state": { "auto": "[%key:common::state::auto%]", - "boost": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::boost%]", - "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", - "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "boost": "Boost", + "comfort": "Comfort", + "eco": "Eco", "off": "[%key:common::state::off%]" } }, @@ -305,6 +305,11 @@ "relay": { "name": "Relay" } + }, + "water_heater": { + "dhw_temperature": { + "name": "Domestic hot water" + } } }, "exceptions": { @@ -329,6 +334,9 @@ "set_schedule_first": { "message": "Failed setting HVACMode, set a schedule first." }, + "temperature_out_of_range": { + "message": "Temperature must be between {min_temp} and {max_temp} {temperature_unit}." + }, "unsupported_firmware": { "message": "[%key:component::plugwise::config::error::unsupported%]" } diff --git a/homeassistant/components/plugwise/water_heater.py b/homeassistant/components/plugwise/water_heater.py new file mode 100644 index 00000000000000..f975b6b2740c32 --- /dev/null +++ b/homeassistant/components/plugwise/water_heater.py @@ -0,0 +1,166 @@ +"""Plugwise water heater component for Home Assistant.""" + +from dataclasses import dataclass +from typing import Any, Final, override + +from homeassistant.components.water_heater import ( + STATE_GAS, + STATE_HEAT_PUMP, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter + +from .const import ( + DHW_TEMP, + DOMAIN, + LOWER_BOUND, + UPPER_BOUND, + BinarySensorType, + WaterHeaterType, +) +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator +from .entity import PlugwiseEntity +from .util import plugwise_command + +PARALLEL_UPDATES = 0 + +FAIL_SET_TEMP: Final = "temperature_out_of_range" +OPERATION_LIST: Final[list[str]] = [STATE_GAS, STATE_HEAT_PUMP, STATE_OFF] + + +@dataclass(frozen=True, kw_only=True) +class PlugwiseWaterHeaterEntityDescription(WaterHeaterEntityDescription): + """Class describing Plugwise WaterHeater entities.""" + + key: WaterHeaterType + state_key: BinarySensorType + + +WATERHEATER_TYPES = ( + PlugwiseWaterHeaterEntityDescription( + key=DHW_TEMP, + translation_key=DHW_TEMP, + state_key="dhw_state", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PlugwiseConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Plugwise water_heater from a config entry.""" + coordinator = entry.runtime_data + + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + async_add_entities( + PlugwiseWaterHeaterEntity(coordinator, device_id, description) + for device_id in coordinator.new_devices + for description in WATERHEATER_TYPES + if description.key in coordinator.data[device_id] + ) + + _add_entities() + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + +class PlugwiseWaterHeaterEntity(PlugwiseEntity, WaterHeaterEntity): + """Representation of a Plugwise water heater.""" + + entity_description: PlugwiseWaterHeaterEntityDescription + + def __init__( + self, + coordinator: PlugwiseDataUpdateCoordinator, + device_id: str, + description: PlugwiseWaterHeaterEntityDescription, + ) -> None: + """Initialise the water_heater.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_max_temp = self.device[description.key][UPPER_BOUND] + self._attr_min_temp = self.device[description.key][LOWER_BOUND] + self._attr_operation_list = OPERATION_LIST + self._attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + self._attr_target_temperature_step = max( + self.device[description.key]["resolution"], 1.0 + ) + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_unique_id = f"{device_id}-{description.key}" + + @property + @override + def current_operation(self) -> str: + """Return current readable operation mode.""" + if ( + binary_sensors := self.device.get("binary_sensors", {}) + ) and binary_sensors.get(self.entity_description.state_key, False): + if "outdoor_air_temperature" in self.device["sensors"]: + if binary_sensors.get("secondary_boiler_state", False): + return STATE_GAS + + return STATE_HEAT_PUMP + + return STATE_GAS + + return STATE_OFF + + @property + @override + def current_temperature(self) -> float | None: + """Return the current water temperature.""" + return self.device[self.entity_description.key].get("current") + + @property + @override + def target_temperature(self) -> float | None: + """Return the water temperature we try to reach.""" + return self.device[self.entity_description.key].get("setpoint") + + @plugwise_command + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = float(kwargs[ATTR_TEMPERATURE]) + if temperature < self._attr_min_temp or temperature > self._attr_max_temp: + temperature_unit = self.hass.config.units.temperature_unit + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=FAIL_SET_TEMP, + translation_placeholders={ + "temperature": str(temperature), + "max_temp": str( + TemperatureConverter.convert( + self._attr_max_temp, + UnitOfTemperature.CELSIUS, + temperature_unit, + ) + ), + "min_temp": str( + TemperatureConverter.convert( + self._attr_min_temp, + UnitOfTemperature.CELSIUS, + temperature_unit, + ) + ), + "temperature_unit": temperature_unit, + }, + ) + + await self.coordinator.api.set_number( + self._dev_id, + self.entity_description.key, + temperature, + ) diff --git a/tests/components/plugwise/snapshots/test_water_heater.ambr b/tests/components/plugwise/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000000..84cab2bbd16561 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_water_heater.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_adam_water_heater_snapshot[platforms0][water_heater.opentherm_domestic_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 40.0, + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.opentherm_domestic_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Domestic hot water', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Domestic hot water', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'dhw_temperature', + 'unique_id': 'e4684553153b44afbef2200885f379dc-dhw_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_water_heater_snapshot[platforms0][water_heater.opentherm_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 37.3, + : 'OpenTherm Domestic hot water', + : 60.0, + : 40.0, + : , + : None, + : None, + : 1.0, + : 60.0, + }), + 'context': , + 'entity_id': 'water_heater.opentherm_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_water_heater_snapshot[platforms0-False-anna_loria_cooling_active][water_heater.opentherm_domestic_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 35.0, + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.opentherm_domestic_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Domestic hot water', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Domestic hot water', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'dhw_temperature', + 'unique_id': 'bfb5ee0a88e14e5f97bfa725a760cc49-dhw_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_water_heater_snapshot[platforms0-False-anna_loria_cooling_active][water_heater.opentherm_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 52.9, + : 'OpenTherm Domestic hot water', + : 60.0, + : 35.0, + : , + : None, + : None, + : 1.0, + : 53.0, + }), + 'context': , + 'entity_id': 'water_heater.opentherm_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index ef04c737e3f4bd..c41bbf87d5ff5b 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -285,7 +285,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 56 + == 57 ) assert ( len( @@ -317,7 +317,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 63 + == 64 ) assert ( len( @@ -348,7 +348,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 56 + == 57 ) assert ( len( diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 9fac1bc342d6d0..e877e38f4bef2a 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -34,7 +34,7 @@ async def test_adam_number_entities( async def test_adam_temperature_offset_change( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test changing of the temperature_offset number.""" + """Test changing an Adam temperature_offset number.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -54,7 +54,7 @@ async def test_adam_temperature_offset_change( async def test_adam_temperature_offset_out_of_bounds_change( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test changing of the temperature_offset number beyond limits.""" + """Test changing an Adam temperature_offset number beyond limits.""" with pytest.raises(ServiceValidationError, match="valid range"): await hass.services.async_call( NUMBER_DOMAIN, @@ -74,7 +74,7 @@ async def test_adam_dhw_setpoint_change( mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test changing of number entities.""" + """Test changing an Adam domestic_hot_water_setpoint number.""" state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") assert state assert float(state.state) == 60.0 @@ -112,10 +112,10 @@ async def test_anna_number_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_anna_max_boiler_temp_change( +async def test_anna_boiler_temperature_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test changing of number entities.""" + """Test changing an Anna maximum_boiler_temperature_setpoint number.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/plugwise/test_water_heater.py b/tests/components/plugwise/test_water_heater.py new file mode 100644 index 00000000000000..1a8ffbbffc943c --- /dev/null +++ b/tests/components/plugwise/test_water_heater.py @@ -0,0 +1,191 @@ +"""Tests for the Plugwise water_heater platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.water_heater import ( + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_OFF, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( + "homeassistant.components.plugwise.coordinator.Smile.async_update" +) + + +@pytest.mark.parametrize("platforms", [(WATER_HEATER_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_water_heater_snapshot( + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam water_heater snapshot with dhw_state off.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +async def test_adam_water_heater_setpoint_change( + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test Adam water_heater setpoint-change.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.opentherm_domestic_hot_water", + ATTR_TEMPERATURE: 55, + }, + blocking=True, + ) + assert mock_smile_adam_jip.set_number.call_count == 1 + mock_smile_adam_jip.set_number.assert_called_with( + "e4684553153b44afbef2200885f379dc", + "dhw_temperature", + 55.0, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.opentherm_domestic_hot_water", + ATTR_TEMPERATURE: 65, + }, + blocking=True, + ) + assert mock_smile_adam_jip.set_number.call_count == 1 + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.opentherm_domestic_hot_water", + ATTR_TEMPERATURE: 15, + }, + blocking=True, + ) + assert mock_smile_adam_jip.set_number.call_count == 1 + + +@pytest.mark.parametrize("chosen_env", ["anna_loria_cooling_active"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.parametrize("platforms", [(WATER_HEATER_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_water_heater_snapshot( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Anna water_heater snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_water_heater_states( + hass: HomeAssistant, + mock_smile_anna: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Anna water_heater states.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("water_heater.opentherm_domestic_hot_water")) + assert state.state == STATE_OFF + + data = mock_smile_anna.async_update.return_value + data["1cbf783bb11e4a7c8a6843dee3a86927"]["binary_sensors"]["dhw_state"] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("water_heater.opentherm_domestic_hot_water")) + assert state.state == STATE_HEAT_PUMP + + data = mock_smile_anna.async_update.return_value + data["1cbf783bb11e4a7c8a6843dee3a86927"]["binary_sensors"][ + "secondary_boiler_state" + ] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("water_heater.opentherm_domestic_hot_water")) + assert state.state == STATE_GAS + + +async def test_adam_water_heater_active_state( + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Adam water_heater active state.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + data = mock_smile_adam_jip.async_update.return_value + data["e4684553153b44afbef2200885f379dc"]["binary_sensors"]["dhw_state"] = True + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("water_heater.opentherm_domestic_hot_water")) + assert state.state == STATE_GAS + + +async def test_adam_water_heater_setpoint_error_uses_configured_unit( + hass: HomeAssistant, + mock_smile_adam_jip: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test out-of-range setpoint errors use the configured temperature unit.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.opentherm_domestic_hot_water", + ATTR_TEMPERATURE: 145, + }, + blocking=True, + ) + + assert exc_info.value.translation_placeholders == { + "temperature": "62.77777777777778", + "max_temp": "140.0", + "min_temp": "104.0", + "temperature_unit": "°F", + }