From e371b7552e14ea69b3bdc987154c20cde2fa451e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Aug 2026 08:45:43 +0200 Subject: [PATCH 01/11] Fix via_device race in victron_gx (#178076) --- homeassistant/components/victron_gx/hub.py | 41 ++++++++++++++++------ tests/components/victron_gx/test_init.py | 24 ++++++------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py index ebc81ae652859..0d6d54cd379ba 100644 --- a/homeassistant/components/victron_gx/hub.py +++ b/homeassistant/components/victron_gx/hub.py @@ -28,7 +28,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.redact import async_redact_data from .const import CONF_INSTALLATION_ID, CONF_SERIAL, DOMAIN @@ -40,7 +40,7 @@ type VictronGxConfigEntry = ConfigEntry[Hub] type NewMetricCallback = Callable[ - [VictronVenusDevice, VictronVenusMetric, DeviceInfo, str], None + [VictronVenusDevice, VictronVenusMetric, dr.DeviceInfo, str], None ] @@ -64,6 +64,8 @@ def __init__(self, hass: HomeAssistant, entry: VictronGxConfigEntry) -> None: config = {**entry.data, **entry.options} self.hass = hass self.host = config[CONF_HOST] + self._config_entry_id = entry.entry_id + self._device_registry = dr.async_get(hass) self._hub = VictronVenusHub( host=self.host, @@ -120,15 +122,39 @@ def _on_new_metric( if TYPE_CHECKING: assert hub.installation_id is not None device_info = Hub._map_device_info(device, hub.installation_id) + if device.parent_device is not None: + device_info["via_device_id"] = self._ensure_device_registered( + device.parent_device, hub.installation_id + ) callback = self.new_metric_callbacks.get(metric.metric_kind) if callback is not None: callback(device, metric, device_info, hub.installation_id) + def _ensure_device_registered( + self, device: VictronVenusDevice, installation_id: str + ) -> str: + """Register a device and its ancestors (parents first), returning its id. + + Devices are discovered lazily as their metrics arrive, so a child's parent + may not be in the registry yet; registering the ancestor chain here lets + children link to it via via_device_id instead of the deprecated via_device. + """ + device_info = Hub._map_device_info(device, installation_id) + if device.parent_device is not None: + device_info["via_device_id"] = self._ensure_device_registered( + device.parent_device, installation_id + ) + device_entry = self._device_registry.async_get_or_create( + config_entry_id=self._config_entry_id, + **device_info, + ) + return device_entry.id + @staticmethod def _map_device_info( device: VictronVenusDevice, installation_id: str - ) -> DeviceInfo: - device_info = DeviceInfo( + ) -> dr.DeviceInfo: + return dr.DeviceInfo( identifiers={(DOMAIN, f"{installation_id}_{device.unique_id}")}, manufacturer=( device.manufacturer @@ -139,13 +165,6 @@ def _map_device_info( model=device.model, serial_number=device.serial_number, ) - # Set via_device based on parent_device relationship - if device.parent_device is not None: - device_info["via_device"] = ( - DOMAIN, - f"{installation_id}_{device.parent_device.unique_id}", - ) - return device_info def is_device_connected(self, device_identifiers: set[tuple[str, str]]) -> bool: """Check if a device is currently known to the hub.""" diff --git a/tests/components/victron_gx/test_init.py b/tests/components/victron_gx/test_init.py index da5c0ced7d32a..7f8b0cacd3258 100644 --- a/tests/components/victron_gx/test_init.py +++ b/tests/components/victron_gx/test_init.py @@ -154,21 +154,17 @@ async def test_hub_start_success( assert victron_hub.installation_id == MOCK_INSTALLATION_ID -async def test_child_device_via_device_links_to_parent_in_registry( +async def test_device_via_device_links( hass: HomeAssistant, init_integration: tuple[VictronVenusHub, MockConfigEntry], device_registry: dr.DeviceRegistry, ) -> None: - """Test non-root device is linked to its parent device in the HA device registry.""" - victron_hub, _mock_config_entry = init_integration + """Test a child device links to its missing parent via via_device_id.""" + victron_hub, mock_config_entry = init_integration - # Inject a system metric first so system_0 is registered as the gateway device. - await inject_message( - victron_hub, - f"N/{MOCK_INSTALLATION_ID}/system/0/SystemState/State", - '{"value": 9}', - ) - # Inject a battery metric; its parent_device resolves to system_0. + # Inject only a battery metric. Its parent (system_0) has no metric of its + # own here, so it is not registered on its own; the child must trigger + # registration of the missing parent to be able to link to it. await inject_message( victron_hub, f"N/{MOCK_INSTALLATION_ID}/battery/0/Dc/0/Current", @@ -177,15 +173,15 @@ async def test_child_device_via_device_links_to_parent_in_registry( await finalize_injection(victron_hub) await hass.async_block_till_done() - system_device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_system_0")} + system_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MOCK_INSTALLATION_ID}_system_0"), mock_config_entry.entry_id ) assert system_device is not None # The GX gateway has no parent — it IS the root. assert system_device.via_device_id is None - battery_device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_INSTALLATION_ID}_battery_0")} + battery_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{MOCK_INSTALLATION_ID}_battery_0"), mock_config_entry.entry_id ) assert battery_device is not None # Battery is a child of the GX gateway, not an orphan. From 6593fe97578b5c684b87dbfa1c13de93e5a6460f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 4 Aug 2026 09:53:18 +0200 Subject: [PATCH 02/11] Use uv run --no-sync for agents (#178127) --- .claude/skills/bump-dependency/SKILL.md | 10 +++++----- .github/copilot-instructions.md | 4 ++-- AGENTS.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude/skills/bump-dependency/SKILL.md b/.claude/skills/bump-dependency/SKILL.md index 77f4c9daa516c..8774ad8710301 100644 --- a/.claude/skills/bump-dependency/SKILL.md +++ b/.claude/skills/bump-dependency/SKILL.md @@ -19,7 +19,7 @@ Follow these systematic steps to successfully bump a python package requirement - [ ] **2. Discover Codebase References**: Search the codebase to find all `manifest.json` and requirements files referencing the package. - [ ] **3. Resolve Version/Tag Details**: Run the integrated validation helper script to resolve version details, GitHub repo, release tag format, and formatted PR links: ```bash - uv run python3 ./.claude/skills/bump-dependency/scripts/resolve_dependency.py [--new-version ] + uv run --no-sync python3 ./.claude/skills/bump-dependency/scripts/resolve_dependency.py [--new-version ] ``` - [ ] **4. Plan-Validate-Execute (Draft Plan)**: Before modifying any files, write a brief, structured plan outlining the integrations to change, old version, new version, and the resolved comparison link. Show this draft plan to the user. @@ -33,7 +33,7 @@ Follow these systematic steps to successfully bump a python package requirement - [ ] **7. Apply Bump to manifests**: Update the version constraint string in all identified `manifest.json` files (e.g., change `"package==1.0.0"` to `"package==1.1.0"`). - [ ] **8. Regenerate Core Requirements**: Run the requirements generator to update all derivative requirements and constraint files: ```bash - uv run python3 -m script.gen_requirements_all + uv run --no-sync python3 -m script.gen_requirements_all ``` - [ ] **9. Validate Requirements**: Check `git diff` to ensure that only the targeted `manifest.json` files and `requirements_all.txt` (and potentially standard constraints) were modified. No unrelated files must be affected. - [ ] **10. Local Venv Verification**: Install the exact targeted package version directly inside the virtual environment: @@ -44,14 +44,14 @@ Follow these systematic steps to successfully bump a python package requirement ### Phase C: Validation Loop (Tests & Lint) - [ ] **11. Run Integration Tests**: Execute the pytest suite for all integrations that consume the bumped package: ```bash - uv run pytest tests/components/ + uv run --no-sync pytest tests/components/ ``` - *Validation Loop*: If tests fail, analyze the error, apply appropriate fixes, and re-run pytest until all tests pass cleanly. - [ ] **12. Run prek Lint Checks**: Run the local prek hooks on modified files: ```bash - uv run prek run + uv run --no-sync prek run ``` - - *Validation Loop*: If prek checks report any formatting or linting violations, fix them and repeat `uv run prek run` until it passes completely without errors. + - *Validation Loop*: If prek checks report any formatting or linting violations, fix them and repeat `uv run --no-sync prek run` until it passes completely without errors. ### Phase D: User Confirmation & PR Creation - [ ] **13. Commit Changes**: Commit the clean changes: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7d64835dc6673..17898f85ba54c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -153,7 +153,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - Run "python3" in current virtual environment to ensure the correct Python version is used for testing. - When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. If uv reports that no download was found for the required Python version, the environment is running an outdated version of uv; upgrade it with `curl -LsSf https://astral.sh/uv/install.sh | sh` and run `script/setup` again. - .vscode/tasks.json contains useful commands used for development. -- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues. +- After finishing a code session, run `uv run --no-sync prek run --all-files` to check for linting and formatting issues. ## Python Syntax Notes @@ -163,7 +163,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Testing -- Use `uv run pytest` to run tests +- Use `uv run --no-sync pytest` to run tests - After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `python3 -m script.translations develop --integration `. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`. - When writing or modifying tests, ensure all test function parameters have type annotations. - Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. diff --git a/AGENTS.md b/AGENTS.md index 882d266537a93..c71ccde55e451 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom - Run "python3" in current virtual environment to ensure the correct Python version is used for testing. - When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. If uv reports that no download was found for the required Python version, the environment is running an outdated version of uv; upgrade it with `curl -LsSf https://astral.sh/uv/install.sh | sh` and run `script/setup` again. - .vscode/tasks.json contains useful commands used for development. -- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues. +- After finishing a code session, run `uv run --no-sync prek run --all-files` to check for linting and formatting issues. ## Python Syntax Notes @@ -26,7 +26,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## Testing -- Use `uv run pytest` to run tests +- Use `uv run --no-sync pytest` to run tests - After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `python3 -m script.translations develop --integration `. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`. - When writing or modifying tests, ensure all test function parameters have type annotations. - Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. From f79f26a6ac503fe4306eebfd3ae886f1d039d088 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Aug 2026 09:54:41 +0200 Subject: [PATCH 03/11] Fix via_device race in proxmoxve (#177748) --- .../components/proxmoxve/__init__.py | 14 ++- .../components/proxmoxve/coordinator.py | 33 ++++++ homeassistant/components/proxmoxve/entity.py | 68 +++++------ tests/components/proxmoxve/test_init.py | 110 +++++++++++++++++- 4 files changed, 186 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 7327c7563d0be..1f39c5d0a6d17 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -4,7 +4,7 @@ from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ( AUTH_OTHER, @@ -14,7 +14,7 @@ CONF_REALM, DEFAULT_REALM, ) -from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator +from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, node_device_info PLATFORMS = [ Platform.BINARY_SENSOR, @@ -32,6 +32,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + + # Register node devices before forwarding platforms so that child devices + # (VMs, containers, storages) can deterministically resolve their via_device. + device_registry = dr.async_get(hass) + for node_data in coordinator.data.values(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **node_device_info(coordinator, node_data), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index 09b04b21d7a1f..d3f47e2d95439 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -10,6 +10,7 @@ from proxmoxer.core import ResourceException import requests from requests.exceptions import ConnectTimeout, SSLError +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -64,6 +65,32 @@ class ProxmoxNodeData: backups: list[dict[str, Any]] = field(default_factory=list) +def proxmox_base_url(coordinator: ProxmoxCoordinator) -> URL: + """Return the base URL for the Proxmox VE.""" + data = coordinator.config_entry.data + return URL.build( + scheme="https", + host=data[CONF_HOST], + port=data[CONF_PORT], + ) + + +def node_device_info( + coordinator: ProxmoxCoordinator, node_data: ProxmoxNodeData +) -> dr.DeviceInfo: + """Return the device info for a Proxmox VE node device.""" + return dr.DeviceInfo( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}") + }, + name=node_data.node.get("node", str(node_data.node["id"])), + model="Node", + configuration_url=proxmox_base_url(coordinator).with_fragment( + f"v1:0:=node/{node_data.node['node']}" + ), + ) + + class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): """Data Update Coordinator for Proxmox VE integration.""" @@ -268,6 +295,12 @@ def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None: _LOGGER.debug("New nodes found: %s", new_nodes) self.known_nodes.update(new_nodes) new_node_data = [data[node_name] for node_name in new_nodes] + device_registry = dr.async_get(self.hass) + for node_data in new_node_data: + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + **node_device_info(self, node_data), + ) for nodes_callback in self.new_nodes_callbacks: nodes_callback(new_node_data) diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py index 815b4b06837e4..ec852040e305d 100644 --- a/homeassistant/components/proxmoxve/entity.py +++ b/homeassistant/components/proxmoxve/entity.py @@ -2,25 +2,18 @@ from typing import Any, override -from yarl import URL - -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ProxmoxCoordinator, ProxmoxNodeData - - -def _proxmox_base_url(coordinator: ProxmoxCoordinator) -> URL: - """Return the base URL for the Proxmox VE.""" - data = coordinator.config_entry.data - return URL.build( - scheme="https", - host=data[CONF_HOST], - port=data[CONF_PORT], - ) +from .coordinator import ( + ProxmoxCoordinator, + ProxmoxNodeData, + node_device_info, + proxmox_base_url, +) class ProxmoxCoordinatorEntity(CoordinatorEntity[ProxmoxCoordinator]): @@ -44,16 +37,7 @@ def __init__( self.device_id = node_data.node["id"] self.device_name = node_data.node["node"] self.entity_description = entity_description - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, f"{coordinator.config_entry.entry_id}_node_{self.device_id}") - }, - name=node_data.node.get("node", str(self.device_id)), - model="Node", - configuration_url=_proxmox_base_url(coordinator).with_fragment( - f"v1:0:=node/{node_data.node['node']}" - ), - ) + self._attr_device_info = node_device_info(coordinator, node_data) self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}" @@ -95,12 +79,16 @@ def __init__( }, name=f"Storage ({self.device_name})", model="Storage", - configuration_url=_proxmox_base_url(coordinator).with_fragment( + configuration_url=proxmox_base_url(coordinator).with_fragment( f"v1:0:=storage/{self._node_name}/{storage_data['storage']}" ), - via_device=( - DOMAIN, - f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + ), + config_entry_id=coordinator.config_entry.entry_id, ), ) @@ -150,12 +138,16 @@ def __init__( }, name=self.device_name, model="VM", - configuration_url=_proxmox_base_url(coordinator).with_fragment( + configuration_url=proxmox_base_url(coordinator).with_fragment( f"v1:0:=qemu/{vm_data['vmid']}" ), - via_device=( - DOMAIN, - f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + ), + config_entry_id=coordinator.config_entry.entry_id, ), ) @@ -207,12 +199,16 @@ def __init__( }, name=self.device_name, model="Container", - configuration_url=_proxmox_base_url(coordinator).with_fragment( + configuration_url=proxmox_base_url(coordinator).with_fragment( f"v1:0:=lxc/{container_data['vmid']}" ), - via_device=( - DOMAIN, - f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + ( + DOMAIN, + f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}", + ), + config_entry_id=coordinator.config_entry.entry_id, ), ) diff --git a/tests/components/proxmoxve/test_init.py b/tests/components/proxmoxve/test_init.py index 1e60573015fbe..4be8dabce9f26 100644 --- a/tests/components/proxmoxve/test_init.py +++ b/tests/components/proxmoxve/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from proxmoxer import AuthenticationError from proxmoxer.core import ResourceException import pytest @@ -16,6 +17,7 @@ DOMAIN, ) from homeassistant.components.proxmoxve.coordinator import ( + DEFAULT_UPDATE_INTERVAL, ProxmoxNodesNotFoundError, ProxmoxPermissionsError, ) @@ -34,7 +36,11 @@ from . import setup_integration -from tests.common import MockConfigEntry, async_load_json_array_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, +) @pytest.mark.parametrize( @@ -400,6 +406,108 @@ async def test_new_container_creates_entity( ) +@pytest.mark.parametrize( + "child_identifier", + ["vm_100", "vm_101", "container_200", "container_201", "storage_local"], +) +@pytest.mark.usefixtures("mock_proxmox_client") +async def test_child_devices_link_to_node( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + child_identifier: str, +) -> None: + """Test that VM/container/storage devices link to their node via via_device_id.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + entry_id = mock_config_entry.entry_id + node_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry_id}_node_node/pve1"), entry_id + ) + assert node_device is not None + + child_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry_id}_{child_identifier}"), entry_id + ) + assert child_device is not None + assert child_device.via_device_id == node_device.id + + +async def test_new_node_registers_device_before_children( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a node discovered after setup registers its device before its children. + + Regression test for a race where a newly discovered node's VM/container/ + storage entities were built before the node's own device was registered, + causing via_device_id resolution to raise ValueError. + + Without audit permissions the node surfaces no entities of its own, so the + node device is only registered by the coordinator: without that explicit + registration its child (the configured VM, whose entities are always + created) cannot resolve its via_device_id. + """ + mock_proxmox_client.access.permissions.get.return_value = {} + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + # setup_integration enables disabled-by-default entities, which schedules a + # debounced config entry reload; let it settle so it doesn't coincide with + # (and mask, via a fresh setup) the refresh that discovers the new node. + freezer.tick(DEFAULT_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # A second node, bringing its own VM, appears on the next refresh. + pve2_vm = { + **(await async_load_json_array_fixture(hass, "nodes/qemu.json", DOMAIN))[0], + "vmid": 300, + "name": "vm-pve2", + } + pve2_node_mock = MagicMock() + pve2_node_mock.qemu.get.return_value = [pve2_vm] + pve2_node_mock.lxc.get.return_value = [] + pve2_node_mock.storage.get.return_value = [] + pve2_node_mock.tasks.get.return_value = [] + + default_node_mock = mock_proxmox_client._node_mock + mock_proxmox_client._nodes_mock.side_effect = lambda node: ( + pve2_node_mock if node == "pve2" else default_node_mock + ) + mock_proxmox_client.nodes.get.return_value = [ + node + for node in mock_proxmox_client._all_nodes + if node["node"] in ("pve1", "pve2") + ] + + freezer.tick(DEFAULT_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entry_id = mock_config_entry.entry_id + node_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry_id}_node_node/pve2"), entry_id + ) + assert node_device is not None + + vm_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{entry_id}_vm_300"), entry_id + ) + assert vm_device is not None + assert vm_device.via_device_id == node_device.id + + # The new node's VM entity was built and populated from the refresh. + state = hass.states.get("binary_sensor.vm_pve2_status") + assert state is not None + assert state.state == STATE_ON + + async def test_stale_devices_removed( hass: HomeAssistant, mock_proxmox_client: MagicMock, From 1ec48f8ec9c6d775a32055cb8e13b9f108c6f05a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Aug 2026 09:57:33 +0200 Subject: [PATCH 04/11] Adapt drop_connect to set via_device_id in DeviceInfo (#178104) --- .../components/drop_connect/entity.py | 16 ++--- tests/components/drop_connect/test_device.py | 58 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 tests/components/drop_connect/test_device.py diff --git a/homeassistant/components/drop_connect/entity.py b/homeassistant/components/drop_connect/entity.py index 69d8f5c56970b..f2af5f22c9a9a 100644 --- a/homeassistant/components/drop_connect/entity.py +++ b/homeassistant/components/drop_connect/entity.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,11 +43,12 @@ def __init__( identifiers={(DOMAIN, unique_id)}, ) if entry_data[CONF_DEVICE_TYPE] != DEV_HUB: - self._attr_device_info.update( - { - "via_device": ( - DOMAIN, - entry_data[CONF_DEVICE_OWNER_ID], - ) - } + # The owner hub lives in a separate config entry created independently + # by MQTT discovery, so it may not exist yet. Link best-effort when + # present; an identifier can match devices from several config entries + # and we can't tell which is the owner hub, so link to the first. + via_devices = dr.async_get(coordinator.hass).async_get_devices( + identifiers={(DOMAIN, entry_data[CONF_DEVICE_OWNER_ID])} ) + if via_devices: + self._attr_device_info["via_device_id"] = via_devices[0].id diff --git a/tests/components/drop_connect/test_device.py b/tests/components/drop_connect/test_device.py new file mode 100644 index 0000000000000..e37dbf12c6ab6 --- /dev/null +++ b/tests/components/drop_connect/test_device.py @@ -0,0 +1,58 @@ +"""Test DROP device registry linkage.""" + +import pytest + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .common import config_entry_hub, config_entry_softener + +HUB_IDENTIFIER = (DOMAIN, "DROP-1_C0FFEE_255") +SOFTENER_IDENTIFIER = (DOMAIN, "DROP-1_C0FFEE_0") + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_sub_device_links_to_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a sub-device links to its hub via via_device_id when the hub exists.""" + hub_entry = config_entry_hub() + hub_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(hub_entry.entry_id) + await hass.async_block_till_done() + + softener_entry = config_entry_softener() + softener_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(softener_entry.entry_id) + await hass.async_block_till_done() + + hub_device = device_registry.async_get_device_by_identifier( + HUB_IDENTIFIER, hub_entry.entry_id + ) + assert hub_device is not None + + softener_device = device_registry.async_get_device_by_identifier( + SOFTENER_IDENTIFIER, softener_entry.entry_id + ) + assert softener_device is not None + assert softener_device.via_device_id == hub_device.id + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_sub_device_without_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a sub-device set up without its hub is not linked and does not crash.""" + softener_entry = config_entry_softener() + softener_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(softener_entry.entry_id) + await hass.async_block_till_done() + + softener_device = device_registry.async_get_device_by_identifier( + SOFTENER_IDENTIFIER, softener_entry.entry_id + ) + assert softener_device is not None + assert softener_device.via_device_id is None From 4e0c981240dc8e1574da8cf50f5aa40262c19ea9 Mon Sep 17 00:00:00 2001 From: Jeroen de Jong Date: Tue, 4 Aug 2026 10:08:37 +0200 Subject: [PATCH 05/11] Add silence-alarm button to ToGrill (#177360) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/togrill/__init__.py | 1 + homeassistant/components/togrill/button.py | 57 +++++++++++++ homeassistant/components/togrill/icons.json | 5 ++ .../components/togrill/manifest.json | 2 +- homeassistant/components/togrill/strings.json | 5 ++ requirements_all.txt | 2 +- .../togrill/snapshots/test_button.ambr | 51 ++++++++++++ tests/components/togrill/test_button.py | 81 +++++++++++++++++++ 8 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/togrill/button.py create mode 100644 tests/components/togrill/snapshots/test_button.ambr create mode 100644 tests/components/togrill/test_button.py diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py index bea0797b6cb5a..c7706e030a933 100644 --- a/homeassistant/components/togrill/__init__.py +++ b/homeassistant/components/togrill/__init__.py @@ -7,6 +7,7 @@ from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator _PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.EVENT, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/togrill/button.py b/homeassistant/components/togrill/button.py new file mode 100644 index 0000000000000..89a78a74640ff --- /dev/null +++ b/homeassistant/components/togrill/button.py @@ -0,0 +1,57 @@ +"""Support for button entities.""" + +from typing import override + +from togrill_bluetooth.packets import PacketA5Write + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ToGrillConfigEntry +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + +ENTITY_DESCRIPTIONS = ( + ButtonEntityDescription( + key="silence", + translation_key="silence", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillButton(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + ) + + +class ToGrillButton(ToGrillEntity, ButtonEntity): + """Representation of a button.""" + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ButtonEntityDescription, + ) -> None: + """Initialize.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @override + async def async_press(self) -> None: + """Silence any active alarm on the device.""" + await self._write_packet(PacketA5Write()) diff --git a/homeassistant/components/togrill/icons.json b/homeassistant/components/togrill/icons.json index 55b6c504b7ea9..f4486909e01db 100644 --- a/homeassistant/components/togrill/icons.json +++ b/homeassistant/components/togrill/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "silence": { + "default": "mdi:alarm-off" + } + }, "select": { "grill_type": { "default": "mdi:grill", diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json index 9897c9921d39d..91500c1094a16 100644 --- a/homeassistant/components/togrill/manifest.json +++ b/homeassistant/components/togrill/manifest.json @@ -16,5 +16,5 @@ "iot_class": "local_push", "loggers": ["togrill_bluetooth"], "quality_scale": "bronze", - "requirements": ["togrill-bluetooth==0.8.1"] + "requirements": ["togrill-bluetooth==0.9.0"] } diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json index 4f3b404c4e0d7..b790953771059 100644 --- a/homeassistant/components/togrill/strings.json +++ b/homeassistant/components/togrill/strings.json @@ -28,6 +28,11 @@ } }, "entity": { + "button": { + "silence": { + "name": "Silence alarm" + } + }, "event": { "event": { "name": "Event", diff --git a/requirements_all.txt b/requirements_all.txt index 08acfb05ff548..86989e72426c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3208,7 +3208,7 @@ tmb==0.0.4 todoist-api-python==3.1.0 # homeassistant.components.togrill -togrill-bluetooth==0.8.1 +togrill-bluetooth==0.9.0 # homeassistant.components.tolo tololib==1.2.2 diff --git a/tests/components/togrill/snapshots/test_button.ambr b/tests/components/togrill/snapshots/test_button.ambr new file mode 100644 index 0000000000000..0cea6ea669f39 --- /dev/null +++ b/tests/components/togrill/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_setup[button.pro_05_silence_alarm-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': 'button', + 'entity_category': None, + 'entity_id': 'button.pro_05_silence_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Silence alarm', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silence alarm', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'silence', + 'unique_id': '00000000-0000-0000-0000-000000000001_silence', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[button.pro_05_silence_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Pro-05 Silence alarm', + }), + 'context': , + 'entity_id': 'button.pro_05_silence_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/togrill/test_button.py b/tests/components/togrill/test_button.py new file mode 100644 index 0000000000000..78e5af3a6f9ee --- /dev/null +++ b/tests/components/togrill/test_button.py @@ -0,0 +1,81 @@ +"""Test buttons for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import PacketA5Write + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test the buttons.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.BUTTON]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +async def test_press( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test pressing the silence button.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.BUTTON]) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + target={ + ATTR_ENTITY_ID: "button.pro_05_silence_alarm", + }, + blocking=True, + ) + + mock_client.write.assert_any_call(PacketA5Write()) + + +async def test_press_disconnected( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, +) -> None: + """Test pressing the button while disconnected raises.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.BUTTON]) + + mock_client.is_connected = False + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + target={ + ATTR_ENTITY_ID: "button.pro_05_silence_alarm", + }, + blocking=True, + ) From ccecd15845b3664670626d58b244a1ca69523182 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Aug 2026 01:26:13 -0700 Subject: [PATCH 06/11] Bump google-health-api to 0.8.0 (#178119) --- homeassistant/components/google_health/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_health/manifest.json b/homeassistant/components/google_health/manifest.json index 3ff971efe198a..62fcffbb04b58 100644 --- a/homeassistant/components/google_health/manifest.json +++ b/homeassistant/components/google_health/manifest.json @@ -8,5 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["google-health-api==0.6.0"] + "requirements": ["google-health-api==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86989e72426c8..23c821b18913b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ google-cloud-texttospeech==2.25.1 google-genai==2.16.0 # homeassistant.components.google_health -google-health-api==0.6.0 +google-health-api==0.8.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 From 5dd59f211b3b7062b945bd3249b889f91f91fd9a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 4 Aug 2026 18:30:48 +1000 Subject: [PATCH 07/11] Bump tesla-fleet-api to 1.8.0 (#178121) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 37aaf456b5ad5..fc4a559635a8b 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.7.6"] + "requirements": ["tesla-fleet-api==1.8.0"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 3538c729578b5..5ed7ed8e4a1c4 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.7.6", "teslemetry-stream==0.10.0"] + "requirements": ["tesla-fleet-api==1.8.0", "teslemetry-stream==0.10.0"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 02f8c9a28edc6..e6f7ac522b7b8 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.6"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23c821b18913b..682e5a934decc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3169,7 +3169,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.7.6 +tesla-fleet-api==1.8.0 # homeassistant.components.powerwall tesla-powerwall==0.5.3 From a44a6820e0a4d2b461c508b198c021681378274b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Aug 2026 01:31:14 -0700 Subject: [PATCH 08/11] Add test for _abort_if_unique_id_configured in Google Health (#178122) --- tests/components/google_health/conftest.py | 3 +- .../google_health/test_config_flow.py | 63 +++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py index e4aacb2d0607d..2cac5679f779e 100644 --- a/tests/components/google_health/conftest.py +++ b/tests/components/google_health/conftest.py @@ -44,6 +44,7 @@ CLIENT_SECRET = "5678" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" +HEALTH_USER_ID = "mock-health-user-id" def _rollup_fixture( @@ -101,7 +102,7 @@ def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title="Google Health", - unique_id="mock-health-user-id", + unique_id=HEALTH_USER_ID, entry_id="01J0BC4QM2YBRP6H5G933CETT7", data={ "auth_implementation": DOMAIN, diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py index 96f7c63d70b52..d1c9ece4c95ae 100644 --- a/tests/components/google_health/test_config_flow.py +++ b/tests/components/google_health/test_config_flow.py @@ -22,6 +22,8 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from .conftest import HEALTH_USER_ID + from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -92,10 +94,59 @@ async def test_full_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Test" - assert result["result"].unique_id == "mock-health-user-id" + assert result["result"].unique_id == HEALTH_USER_ID assert len(hass.config_entries.async_entries(DOMAIN)) == 1 +@pytest.mark.usefixtures( + "current_request_with_host", + "mock_setup_entry", + "setup_credentials", + "mock_google_health_client", +) +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test config flow aborts when account is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HEALTH_USER_ID, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures( "current_request_with_host", "mock_setup_entry", "setup_credentials" ) @@ -332,7 +383,7 @@ async def test_reauth_flow( "scope": " ".join(OAUTH_SCOPES), }, }, - unique_id="mock-health-user-id", + unique_id=HEALTH_USER_ID, ) config_entry.add_to_hass(hass) @@ -380,7 +431,7 @@ async def test_reauth_flow( IDENTITY_URL, json={ "name": "users/me/identity", - "healthUserId": "mock-health-user-id", + "healthUserId": HEALTH_USER_ID, }, ) @@ -423,7 +474,7 @@ async def test_reconfigure_flow( "scope": HealthApiScope.PROFILE_READ, }, }, - unique_id="mock-health-user-id", + unique_id=HEALTH_USER_ID, ) config_entry.add_to_hass(hass) @@ -463,7 +514,7 @@ async def test_reconfigure_flow( IDENTITY_URL, json={ "name": "users/me/identity", - "healthUserId": "mock-health-user-id", + "healthUserId": HEALTH_USER_ID, }, ) @@ -507,7 +558,7 @@ async def test_reconfigure_flow_wrong_account( "scope": " ".join(OAUTH_SCOPES), }, }, - unique_id="mock-health-user-id", + unique_id=HEALTH_USER_ID, ) config_entry.add_to_hass(hass) From fa43cd504a0099bc6fc7ebf349fec7c33fce296e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:08:22 +0100 Subject: [PATCH 09/11] Update infrared-protocols to 9.0.0 (#178120) --- homeassistant/components/infrared/manifest.json | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index f45cb58301228..85905aed7af93 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==8.2.1"] + "requirements": ["infrared-protocols==9.0.0"] } diff --git a/requirements.txt b/requirements.txt index b291a8420f241..8515365c9d995 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.7.30 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==8.2.1 +infrared-protocols==9.0.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 diff --git a/requirements_all.txt b/requirements_all.txt index 682e5a934decc..9664c2aff4517 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1371,7 +1371,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==8.2.1 +infrared-protocols==9.0.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 From 0556c48ef96f38389b6503c61c10e99a995fd3d7 Mon Sep 17 00:00:00 2001 From: Linkplay2020 <65423368+Linkplay2020@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:12:59 +0800 Subject: [PATCH 10/11] Translate WiiM browse media errors (#178118) Co-authored-by: Tao Jiang --- homeassistant/components/wiim/media_player.py | 17 +++- .../components/wiim/quality_scale.yaml | 5 +- tests/components/wiim/test_media_player.py | 78 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index f12c65631a012..82f0e1b3e7bdf 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -85,6 +85,8 @@ def media_player_exception_wrap[ _R, ]( func: Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Awaitable[_R]], + *, + update_ha_state: bool = True, ) -> Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Coroutine[Any, Any, _R]]: """Wrap media player commands to handle SDK exceptions consistently.""" @@ -114,13 +116,25 @@ async def _wrap( }, ) from err - self._update_ha_state_from_sdk_cache() + if update_ha_state: + self._update_ha_state_from_sdk_cache() return result return _wrap +def browse_media_exception_wrap[ + _WiimMediaPlayerEntityT: WiimMediaPlayerEntity, + **_P, + _R, +]( + func: Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Awaitable[_R]], +) -> Callable[Concatenate[_WiimMediaPlayerEntityT, _P], Coroutine[Any, Any, _R]]: + """Wrap browse media calls without refreshing entity state after success.""" + return media_player_exception_wrap(func, update_ha_state=False) + + async def async_setup_entry( hass: HomeAssistant, entry: WiimConfigEntry, @@ -742,6 +756,7 @@ async def async_select_source(self, source: str) -> None: source ) + @browse_media_exception_wrap @override async def async_browse_media( self, diff --git a/homeassistant/components/wiim/quality_scale.yaml b/homeassistant/components/wiim/quality_scale.yaml index f5b7a95f9f953..c946e54fe5979 100644 --- a/homeassistant/components/wiim/quality_scale.yaml +++ b/homeassistant/components/wiim/quality_scale.yaml @@ -31,10 +31,7 @@ rules: test-before-setup: done unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - - The calls to the api can be changed to return bool, and services can then raise HomeAssistantError + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py index 183d78c7e8ffa..3b9ad731fee0a 100644 --- a/tests/components/wiim/test_media_player.py +++ b/tests/components/wiim/test_media_player.py @@ -994,6 +994,33 @@ async def test_browse_media_service_returns_wiim_library( assert [child.title for child in queue_browse.children] == ["Song A", "Song B"] +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_browse_media_does_not_refresh_entity_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, +) -> None: + """Test browsing media does not refresh entity state after success.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + original_volume = state.attributes[ATTR_MEDIA_VOLUME_LEVEL] + mock_wiim_device.volume = 75 + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_BROWSE_MEDIA, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + return_response=True, + ) + + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == original_volume + + async def test_browse_media_service_includes_media_sources_when_supported( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -1099,6 +1126,57 @@ async def test_browse_media_error_uses_translation( assert exc_info.value.translation_placeholders == translation_placeholders +@pytest.mark.parametrize( + ("sdk_method", "media_content_id"), + [ + pytest.param( + "async_get_presets", + "wiim_library/library_root/favorites", + id="presets", + ), + pytest.param( + "async_get_queue_snapshot", + "wiim_library/library_root/playlists", + id="queue", + ), + ], +) +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_browse_media_sdk_error_uses_translation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + *, + sdk_method: str, + media_content_id: str, +) -> None: + """Test browse media SDK errors raise a translated Home Assistant error.""" + await setup_integration(hass, mock_config_entry) + getattr(mock_wiim_device, sdk_method).side_effect = WiimRequestException( + "request failed" + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_BROWSE_MEDIA, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + ATTR_MEDIA_CONTENT_ID: media_content_id, + }, + blocking=True, + return_response=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "command_failed" + assert exc_info.value.translation_placeholders == { + "command": "async_browse_media", + "entity_id": MEDIA_PLAYER_ENTITY_ID, + } + + async def test_join_and_unjoin_services_use_resolved_member_udns( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 71b79ac87e8817dbc8ab5a0ac52ce4642101d988 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 4 Aug 2026 19:14:31 +1000 Subject: [PATCH 11/11] Fix teslemetry streaming enum sensors swallowing None updates (#175746) --- homeassistant/components/teslemetry/sensor.py | 68 ++++++++-------- tests/components/teslemetry/test_sensor.py | 80 +++++++++++++++++++ 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 070f87c7848e8..daf4c41019ba2 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -834,8 +834,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="di_state_f", streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateF( - lambda value: ( - None if value is None else callback(DRIVE_INVERTER_STATES.get(value)) + lambda value: callback( + None if value is None else DRIVE_INVERTER_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -846,8 +846,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="di_state_r", streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateR( - lambda value: ( - None if value is None else callback(DRIVE_INVERTER_STATES.get(value)) + lambda value: callback( + None if value is None else DRIVE_INVERTER_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -858,8 +858,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="di_state_rel", streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateREL( - lambda value: ( - None if value is None else callback(DRIVE_INVERTER_STATES.get(value)) + lambda value: callback( + None if value is None else DRIVE_INVERTER_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -870,8 +870,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="di_state_rer", streaming_listener=lambda vehicle, callback: vehicle.listen_DiStateRER( - lambda value: ( - None if value is None else callback(DRIVE_INVERTER_STATES.get(value)) + lambda value: callback( + None if value is None else DRIVE_INVERTER_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -1012,8 +1012,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="sentry_mode", streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode( - lambda value: ( - None if value is None else callback(SENTRY_MODE_STATES.get(value)) + lambda value: callback( + None if value is None else SENTRY_MODE_STATES.get(value) ) ), options=list(SENTRY_MODE_STATES.values()), @@ -1045,10 +1045,10 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="forward_collision_warning", streaming_listener=lambda vehicle, callback: ( vehicle.listen_ForwardCollisionWarning( - lambda value: ( + lambda value: callback( None if value is None - else callback(FORWARD_COLLISION_SENSITIVITIES.get(value)) + else FORWARD_COLLISION_SENSITIVITIES.get(value) ) ) ), @@ -1070,10 +1070,10 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="guest_mode_mobile_access_state", streaming_listener=lambda vehicle, callback: ( vehicle.listen_GuestModeMobileAccessState( - lambda value: ( + lambda value: callback( None if value is None - else callback(GUEST_MODE_MOBILE_ACCESS_STATES.get(value)) + else GUEST_MODE_MOBILE_ACCESS_STATES.get(value) ) ) ), @@ -1123,8 +1123,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="lane_departure_avoidance", streaming_listener=lambda vehicle, callback: ( vehicle.listen_LaneDepartureAvoidance( - lambda value: ( - None if value is None else callback(LANE_ASSIST_LEVELS.get(value)) + lambda value: callback( + None if value is None else LANE_ASSIST_LEVELS.get(value) ) ) ), @@ -1250,8 +1250,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="powershare_status", streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareStatus( - lambda value: ( - None if value is None else callback(POWER_SHARE_STATES.get(value)) + lambda value: callback( + None if value is None else POWER_SHARE_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -1263,10 +1263,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="powershare_stop_reason", streaming_listener=lambda vehicle, callback: ( vehicle.listen_PowershareStopReason( - lambda value: ( - None - if value is None - else callback(POWER_SHARE_STOP_REASONS.get(value)) + lambda value: callback( + None if value is None else POWER_SHARE_STOP_REASONS.get(value) ) ) ), @@ -1278,8 +1276,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="powershare_type", streaming_listener=lambda vehicle, callback: vehicle.listen_PowershareType( - lambda value: ( - None if value is None else callback(POWER_SHARE_TYPES.get(value)) + lambda value: callback( + None if value is None else POWER_SHARE_TYPES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -1302,10 +1300,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): key="scheduled_charging_mode", streaming_listener=lambda vehicle, callback: ( vehicle.listen_ScheduledChargingMode( - lambda value: ( - None - if value is None - else callback(SCHEDULED_CHARGING_MODES.get(value)) + lambda value: callback( + None if value is None else SCHEDULED_CHARGING_MODES.get(value) ) ) ), @@ -1328,8 +1324,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="speed_limit_warning", streaming_listener=lambda vehicle, callback: vehicle.listen_SpeedLimitWarning( - lambda value: ( - None if value is None else callback(SPEED_ASSIST_LEVELS.get(value)) + lambda value: callback( + None if value is None else SPEED_ASSIST_LEVELS.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -1340,8 +1336,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="tonneau_tent_mode", streaming_listener=lambda vehicle, callback: vehicle.listen_TonneauTentMode( - lambda value: ( - None if value is None else callback(TONNEAU_TENT_MODE_STATES.get(value)) + lambda value: callback( + None if value is None else TONNEAU_TENT_MODE_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -1368,8 +1364,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="lights_turn_signal", streaming_listener=lambda vehicle, callback: vehicle.listen_LightsTurnSignal( - lambda value: ( - None if value is None else callback(TURN_SIGNAL_STATES.get(value)) + lambda value: callback( + None if value is None else TURN_SIGNAL_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, @@ -1391,8 +1387,8 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): TeslemetryVehicleSensorEntityDescription( key="hvac_power_state", streaming_listener=lambda vehicle, callback: vehicle.listen_HvacPower( - lambda value: ( - None if value is None else callback(HVAC_POWER_STATES.get(value)) + lambda value: callback( + None if value is None else HVAC_POWER_STATES.get(value) ) ), device_class=SensorDeviceClass.ENUM, diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index 6c3e3d5d6d5a7..d40129f5a1362 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from teslemetry_stream import Signal +from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform @@ -116,6 +117,85 @@ async def test_sensors_streaming( assert quota_state.state == "21.2" +@pytest.mark.parametrize( + ("key", "signal", "raw_value", "state"), + [ + ("di_state_f", Signal.DI_STATE_F, "Standby", "standby"), + ("di_state_r", Signal.DI_STATE_R, "Standby", "standby"), + ("di_state_rel", Signal.DI_STATE_REL, "Standby", "standby"), + ("di_state_rer", Signal.DI_STATE_RER, "Standby", "standby"), + ("sentry_mode", Signal.SENTRY_MODE, "Armed", "armed"), + ( + "forward_collision_warning", + Signal.FORWARD_COLLISION_WARNING, + "Average", + "average", + ), + ( + "guest_mode_mobile_access_state", + Signal.GUEST_MODE_MOBILE_ACCESS_STATE, + "Authenticated", + "authenticated", + ), + ( + "lane_departure_avoidance", + Signal.LANE_DEPARTURE_AVOIDANCE, + "Warning", + "warning", + ), + ("powershare_status", Signal.POWERSHARE_STATUS, "Enabled", "enabled"), + ("powershare_stop_reason", Signal.POWERSHARE_STOP_REASON, "Fault", "fault"), + ("powershare_type", Signal.POWERSHARE_TYPE, "Home", "home"), + ( + "scheduled_charging_mode", + Signal.SCHEDULED_CHARGING_MODE, + "StartAt", + "start_at", + ), + ("speed_limit_warning", Signal.SPEED_LIMIT_WARNING, "Chime", "chime"), + ("tonneau_tent_mode", Signal.TONNEAU_TENT_MODE, "Active", "active"), + ("lights_turn_signal", Signal.LIGHTS_TURN_SIGNAL, "Left", "left"), + ("hvac_power_state", Signal.HVAC_POWER, "On", "on"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_streaming_enum_none_clears_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + key: str, + signal: Signal, + raw_value: str, + state: str, +) -> None: + """A None streamed value must clear the entity, not leave it stale.""" + await setup_platform(hass, [Platform.SENSOR]) + vin = VEHICLE_DATA_ALT["response"]["vin"] + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, f"{vin}-{key}") + assert entity_id is not None + + mock_add_listener.send( + { + "vin": vin, + "data": {signal: raw_value}, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == state + + mock_add_listener.send( + { + "vin": vin, + "data": {signal: None}, + "createdAt": "2024-10-04T10:45:18.537Z", + } + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_energy_history_no_time_series( hass: HomeAssistant, freezer: FrozenDateTimeFactory,