From 6a49a2579947ca7d31e2903b18ade120b34b4810 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 16 Feb 2026 10:43:28 +0100 Subject: [PATCH 01/31] Handle orphaned ignored config entries (#153093) Co-authored-by: Martin Hjelmare --- .../components/homeassistant/repairs.py | 41 ++++ .../components/homeassistant/strings.json | 18 ++ homeassistant/config_entries.py | 55 ++++- .../components/homeassistant/test_repairs.py | 207 ++++++++++++++++++ tests/test_config_entries.py | 56 +++++ 5 files changed, 376 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/repairs.py b/homeassistant/components/homeassistant/repairs.py index cff123da17aac3..d631c13b569dac 100644 --- a/homeassistant/components/homeassistant/repairs.py +++ b/homeassistant/components/homeassistant/repairs.py @@ -50,6 +50,44 @@ async def async_step_ignore( ) +class OrphanedConfigEntryFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self.entry_id = data["entry_id"] + self.description_placeholders = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["confirm", "ignore"], + description_placeholders=self.description_placeholders, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + await self.hass.config_entries.async_remove(self.entry_id) + return self.async_create_entry(data={}) + + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_get(self.hass).async_ignore( + DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True + ) + return self.async_abort( + reason="issue_ignored", + description_placeholders=self.description_placeholders, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -58,4 +96,7 @@ async def async_create_fix_flow( if issue_id.split(".", maxsplit=1)[0] == "integration_not_found": assert data return IntegrationNotFoundFlow(data) + if issue_id.split(".", maxsplit=1)[0] == "orphaned_ignored_entry": + assert data + return OrphanedConfigEntryFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index e23a165005d76a..ccd44c0be5e6ae 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -162,6 +162,24 @@ "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}", "title": "Unused YAML configuration for the {platform} integration" }, + "orphaned_ignored_config_entry": { + "fix_flow": { + "abort": { + "issue_ignored": "Non-existent ignored integration {domain} ignored." + }, + "step": { + "init": { + "description": "There is an ignored orphaned config entry for the `{domain}` integration. This can happen when an integration is removed, but the config entry is still present in Home Assistant.\n\nTo resolve this, press **Remove** to clean up the orphaned entry.", + "menu_options": { + "confirm": "Remove", + "ignore": "Ignore" + }, + "title": "[%key:component::homeassistant::issues::orphaned_ignored_config_entry::title%]" + } + } + }, + "title": "Orphaned ignored config entry for {domain}" + }, "platform_only": { "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant.", "title": "The {domain} integration does not support YAML configuration under its own key" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index dc69d6695826c6..a89c5869a2faf3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -69,7 +69,13 @@ ) from .helpers.frame import ReportBehavior, report_usage from .helpers.json import json_bytes, json_bytes_sorted, json_fragment -from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType +from .helpers.typing import ( + UNDEFINED, + ConfigType, + DiscoveryInfoType, + NoEventData, + UndefinedType, +) from .loader import async_suggest_report_issue from .setup import ( SetupPhases, @@ -2237,6 +2243,53 @@ async def async_initialize(self) -> None: self._entries = entries self.async_update_issues() + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries + ) + + async def _async_scan_orphan_ignored_entries( + self, event: Event[NoEventData] + ) -> None: + """Scan for ignored entries that can be removed. + + Orphaned ignored entries are entries that are in ignored state + for integrations that are no longer available. + """ + remove_candidates = [ + entry + for entry in self.async_entries( + include_ignore=True, + include_disabled=False, + ) + if entry.source == SOURCE_IGNORE + ] + + if not remove_candidates: + return + + for entry in remove_candidates: + try: + await loader.async_get_integration(self.hass, entry.domain) + except loader.IntegrationNotFound: + _LOGGER.info( + "Integration for ignored config entry %s not found. Creating repair issue", + entry, + ) + ir.async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + issue_id=f"orphaned_ignored_entry.{entry.entry_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="orphaned_ignored_config_entry", + translation_placeholders={"domain": entry.domain}, + data={ + "domain": entry.domain, + "entry_id": entry.entry_id, + }, + ) + async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: """Set up a config entry. diff --git a/tests/components/homeassistant/test_repairs.py b/tests/components/homeassistant/test_repairs.py index d9329744694332..76035e6002265b 100644 --- a/tests/components/homeassistant/test_repairs.py +++ b/tests/components/homeassistant/test_repairs.py @@ -1,6 +1,10 @@ """Test the Homeassistant repairs module.""" +from typing import Any + +from homeassistant import config_entries from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -117,3 +121,206 @@ async def test_integration_not_found_ignore_step( issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) assert issue is not None assert issue.dismissed_version is not None + + +async def test_orphaned_config_entry_confirm_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Test the orphaned_config_entry issue confirm step.""" + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + entry = MockConfigEntry(domain="test_issued", source=config_entries.SOURCE_IGNORE) + entry_valid = MockConfigEntry(domain="test_valid") + issue_id = f"orphaned_ignored_entry.{entry.entry_id}" + + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "minor_version": 5, + "data": { + "entries": [ + { + "created_at": entry.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_issued", + "entry_id": entry.entry_id, + "minor_version": 1, + "modified_at": entry.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "ignore", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + { + "created_at": entry_valid.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_valid", + "entry_id": entry_valid.entry_id, + "minor_version": 1, + "modified_at": entry_valid.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + ] + }, + } + + await hass.config_entries.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.translation_placeholders == {"domain": "test_issued"} + + data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == { + "entry_id": entry.entry_id, + "domain": "test_issued", + } + + data = await process_repair_fix_flow(http_client, flow_id) + + assert data["type"] == "menu" + + # Apply fix + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "confirm"} + ) + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(entry.entry_id) is None + assert hass.config_entries.async_get_entry(entry_valid.entry_id) is not None + + assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + + +async def test_orphaned_config_entry_ignore_step( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Test the orphaned_config_entry issue ignore step.""" + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + await hass.async_block_till_done() + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + entry = MockConfigEntry(domain="test_issued", source=config_entries.SOURCE_IGNORE) + entry_valid = MockConfigEntry(domain="test_valid") + issue_id = f"orphaned_ignored_entry.{entry.entry_id}" + + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "minor_version": 5, + "data": { + "entries": [ + { + "created_at": entry.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_issued", + "entry_id": entry.entry_id, + "minor_version": 1, + "modified_at": entry.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "ignore", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + { + "created_at": entry_valid.created_at.isoformat(), + "data": {}, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test_valid", + "entry_id": entry_valid.entry_id, + "minor_version": 1, + "modified_at": entry_valid.modified_at.isoformat(), + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Title probably no-one will read", + "unique_id": None, + "version": 1, + }, + ] + }, + } + + await hass.config_entries.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.translation_placeholders == {"domain": "test_issued"} + + data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["step_id"] == "init" + assert data["description_placeholders"] == { + "entry_id": entry.entry_id, + "domain": "test_issued", + } + + data = await process_repair_fix_flow(http_client, flow_id) + + assert data["type"] == "menu" + + # Apply fix + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "ignore"} + ) + + assert data["type"] == "abort" + assert data["reason"] == "issue_ignored" + + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(entry.entry_id) + + # Assert the issue is resolved + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) + assert issue is not None + assert issue.dismissed_version is not None diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c1f570e3c29fea..4269c75232e085 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9875,3 +9875,59 @@ async def async_step_confirm(self, user_input=None): # User flows should not be marked as dismiss protected context = _get_flow_context(manager, result["flow_id"]) assert "dismiss_protected" not in context + + +async def test_orphaned_ignored_entries( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Test orphaned ignored entries scanner creates a repair issue for missing (custom) integration.""" + await hass.config_entries.async_initialize() + entry = MockConfigEntry( + domain="ghost_orphan_domain", source=config_entries.SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + # Not sure if this is the best way. Let's wait a review + async def _raise(hass_param: HomeAssistant, domain: str) -> None: + raise loader.IntegrationNotFound(domain) + + with patch( + "homeassistant.loader.async_get_integration", new=AsyncMock(side_effect=_raise) + ): + await hass.config_entries._async_scan_orphan_ignored_entries(None) + + # If all went according to plan, an issue has been created. + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"orphaned_ignored_entry.{entry.entry_id}" + ) + + assert issue is not None, "Expected repair issue for orphaned ignored entry" + assert issue.is_fixable + assert issue.is_persistent + assert issue.translation_key == "orphaned_ignored_config_entry" + assert issue.data == { + "domain": "ghost_orphan_domain", + "entry_id": entry.entry_id, + } + + +async def test_orphaned_ignored_entries_existing_integration( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Just to be very thorough: test no repair issue is created for ignored entry with existing (custom) integration.""" + await hass.config_entries.async_initialize() + + # This time we mock we have an actual existing integration + mock_integration(hass, MockModule(domain="existing_domain")) + + entry = MockConfigEntry( + domain="existing_domain", source=config_entries.SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() From 5d1cb4df94a240e98ec4ad656021de759dba43f0 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 16 Feb 2026 11:31:16 +0100 Subject: [PATCH 02/31] Fix orphaned ignored typo (#163137) --- homeassistant/components/homeassistant/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index ccd44c0be5e6ae..95fc7c5aa5b5cb 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -165,7 +165,7 @@ "orphaned_ignored_config_entry": { "fix_flow": { "abort": { - "issue_ignored": "Non-existent ignored integration {domain} ignored." + "issue_ignored": "Non-existent integration {domain} ignored." }, "step": { "init": { From e48bd885816e16537879af85e1de282702fffda3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Feb 2026 11:38:40 +0100 Subject: [PATCH 03/31] Improve GitHub Actions workflow metadata and concurrency settings (#163117) --- .github/workflows/builder.yml | 36 ++++++++++--------- .github/workflows/ci.yaml | 6 ++-- .github/workflows/codeql.yml | 6 ++-- .github/workflows/detect-duplicate-issues.yml | 8 +++-- .../workflows/detect-non-english-issues.yml | 8 +++-- .github/workflows/lock.yml | 9 +++-- .github/workflows/restrict-task-creation.yml | 11 ++++-- .github/workflows/stale.yml | 9 +++-- .github/workflows/translations.yml | 4 +++ 9 files changed, 64 insertions(+), 33 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3d0e2bfd8f80a4..673bfd848d53b0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -20,13 +20,17 @@ env: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: init: name: Initialize build if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest permissions: - contents: read + contents: read # To check out the repository outputs: version: ${{ steps.version.outputs.version }} channel: ${{ steps.version.outputs.channel }} @@ -88,9 +92,9 @@ jobs: needs: init runs-on: ${{ matrix.os }} permissions: - contents: read - packages: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + id-token: write # For cosign signing strategy: fail-fast: false matrix: @@ -270,9 +274,9 @@ jobs: needs: ["init", "build_base"] runs-on: ubuntu-latest permissions: - contents: read - packages: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + id-token: write # For cosign signing strategy: matrix: machine: @@ -372,9 +376,9 @@ jobs: needs: ["init", "build_base"] runs-on: ubuntu-latest permissions: - contents: read - packages: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + id-token: write # For cosign signing strategy: fail-fast: false matrix: @@ -509,8 +513,8 @@ jobs: needs: ["init", "build_base"] runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: read # To check out the repository + id-token: write # For PyPI trusted publishing if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository @@ -550,10 +554,10 @@ jobs: name: Build and test hassfest image runs-on: ubuntu-latest permissions: - contents: read - packages: write - attestations: write - id-token: write + contents: read # To check out the repository + packages: write # To push to GHCR + attestations: write # For build provenance attestation + id-token: write # For build provenance attestation needs: ["init"] if: github.repository_owner == 'home-assistant' env: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc89d0c027b4f4..9108af65115e52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -78,8 +78,8 @@ jobs: name: Collect information & changes data runs-on: ubuntu-24.04 permissions: - contents: read - pull-requests: read + contents: read # To check out the repository + pull-requests: read # For paths-filter to detect changed files outputs: # In case of issues with the partial run, use the following line instead: # test_full_suite: 'true' @@ -1561,7 +1561,7 @@ jobs: - pytest-mariadb timeout-minutes: 10 permissions: - id-token: write + id-token: write # For Codecov OIDC upload # codecov/test-results-action currently doesn't support tokenless uploads # therefore we can't run it on forks if: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8e34dfef04ddc3..bbfeb2f8769c4a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,9 +17,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 360 permissions: - actions: read - contents: read - security-events: write + actions: read # To read workflow information for CodeQL + contents: read # To check out the repository + security-events: write # To upload CodeQL results steps: - name: Check out code from GitHub diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 81e828757092ca..f6a47f060a3a37 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -7,12 +7,16 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + jobs: detect-duplicates: + name: Detect duplicate issues runs-on: ubuntu-latest permissions: - issues: write - models: read + issues: write # To comment on and label issues + models: read # For AI-based duplicate detection steps: - name: Check if integration label was added and extract details diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 34e5be2e906ce6..c311579569e0dc 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -7,12 +7,16 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + jobs: detect-language: + name: Detect non-English issues runs-on: ubuntu-latest permissions: - issues: write - models: read + issues: write # To comment on, label, and close issues + models: read # For AI-based language detection steps: - name: Check issue language diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index cb69d77b2e231a..59ffbf324d78a1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -7,13 +7,18 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: lock: + name: Lock inactive threads if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest permissions: - issues: write - pull-requests: write + issues: write # To lock issues + pull-requests: write # To lock pull requests steps: - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml index 5dbcf0d10e1246..96828d06931b54 100644 --- a/.github/workflows/restrict-task-creation.yml +++ b/.github/workflows/restrict-task-creation.yml @@ -7,11 +7,15 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + jobs: add-no-stale: + name: Add no-stale label runs-on: ubuntu-latest permissions: - issues: write + issues: write # To add labels to issues if: >- github.event.issue.type.name == 'Task' || github.event.issue.type.name == 'Epic' @@ -29,10 +33,11 @@ jobs: }); check-authorization: + name: Check authorization runs-on: ubuntu-latest permissions: - contents: read - issues: write + contents: read # To read CODEOWNERS file + issues: write # To comment on, label, and close issues # Only run if this is a Task issue type (from the issue form) if: github.event.issue.type.name == 'Task' steps: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c8eb41d0850daa..e24c2762aaaab6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,13 +8,18 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: stale: + name: Mark stale issues and PRs if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest permissions: - issues: write - pull-requests: write + issues: write # To label and close stale issues + pull-requests: write # To label and close stale PRs steps: # The 60 day stale policy for PRs # Used for: diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f8edc9f51e0b57..d400affd34dc2c 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -11,6 +11,10 @@ on: permissions: {} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + env: DEFAULT_PYTHON: "3.14.2" From 63f4653a3b4d0386621dae18bb4624a698bf9ddc Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Mon, 16 Feb 2026 11:38:59 +0100 Subject: [PATCH 04/31] Fix Matter translation key not set for primary entities (#161708) --- homeassistant/components/matter/entity.py | 9 +- .../matter/snapshots/test_climate.ambr | 20 +- .../components/matter/snapshots/test_fan.ambr | 10 +- .../matter/snapshots/test_light.ambr | 18 +- .../matter/snapshots/test_lock.ambr | 10 +- .../matter/snapshots/test_switch.ambr | 18 +- .../matter/snapshots/test_vacuum.ambr | 8 +- .../matter/snapshots/test_valve.ambr | 2 +- .../matter/snapshots/test_water_heater.ambr | 2 +- tests/components/matter/test_adapter.py | 12 -- tests/components/matter/test_entity.py | 192 ++++++++++++++++++ 11 files changed, 243 insertions(+), 58 deletions(-) create mode 100644 tests/components/matter/test_entity.py diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index f0718dead21537..a463b123fb6aea 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -124,8 +124,13 @@ def __init__( and ep.has_attribute(None, entity_info.primary_attribute) ): self._name_postfix = str(self._endpoint.endpoint_id) - if self._platform_translation_key and not self.translation_key: - self._attr_translation_key = self._platform_translation_key + # Always set translation_key for state_attributes translations. + # For primary entities (no postfix), suppress the translated name, + # so only the device name is used. + if self._platform_translation_key and not self.translation_key: + self._attr_translation_key = self._platform_translation_key + if not self._name_postfix: + self._attr_name = None # Matter labels can be used to modify the entity name # by appending the text. diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 5ef6b6f9c9afee..be27ffe9a9e815 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000064-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -102,7 +102,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000014-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -167,7 +167,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -232,7 +232,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -299,7 +299,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -368,7 +368,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -437,7 +437,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -508,7 +508,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -580,7 +580,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) @@ -647,7 +647,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index 83f2b1836d174b..52d5dcc5f82a86 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'fan', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) @@ -103,7 +103,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'fan', 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) @@ -172,7 +172,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'fan', 'unique_id': '00000000000004D2-0000000000000049-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) @@ -237,7 +237,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'fan', 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) @@ -306,7 +306,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'fan', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterFan-514-0', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index 759a221098a5ed..da23798ef345b4 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -115,7 +115,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -393,7 +393,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -452,7 +452,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -511,7 +511,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -568,7 +568,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -630,7 +630,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -701,7 +701,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) @@ -768,7 +768,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-MatterLight-6-0', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_lock.ambr b/tests/components/matter/snapshots/test_lock.ambr index 85abc801c69d87..b983bb1e2d685a 100644 --- a/tests/components/matter/snapshots/test_lock.ambr +++ b/tests/components/matter/snapshots/test_lock.ambr @@ -30,7 +30,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'lock', 'unique_id': '00000000000004D2-0000000000000014-MatterNodeDevice-1-MatterLock-257-0', 'unit_of_measurement': None, }) @@ -81,7 +81,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'lock', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLock-257-0', 'unit_of_measurement': None, }) @@ -132,7 +132,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'lock', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterLock-257-0', 'unit_of_measurement': None, }) @@ -183,7 +183,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'lock', 'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-MatterLock-257-0', 'unit_of_measurement': None, }) @@ -234,7 +234,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'lock', 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-1-MatterLock-257-0', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 2fc485f367be2f..25e04f62cb1fac 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -130,7 +130,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-MatterPlug-6-0', 'unit_of_measurement': None, }) @@ -180,7 +180,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-MatterPlug-6-0', 'unit_of_measurement': None, }) @@ -328,7 +328,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000025-MatterNodeDevice-1-MatterSwitch-6-0', 'unit_of_measurement': None, }) @@ -428,7 +428,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterSwitch-6-0', 'unit_of_measurement': None, }) @@ -578,7 +578,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterSwitch-6-0', 'unit_of_measurement': None, }) @@ -677,7 +677,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterSwitch-6-0', 'unit_of_measurement': None, }) @@ -875,7 +875,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterPlug-6-0', 'unit_of_measurement': None, }) @@ -1174,7 +1174,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MatterSwitch-6-0', 'unit_of_measurement': None, }) @@ -1323,7 +1323,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'switch', 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterPlug-6-0', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index 7a8adfc8c29ce2..aacdc2525ff379 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -30,7 +30,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-000000000000002F-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, }) @@ -80,7 +80,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-0000000000000028-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, }) @@ -130,7 +130,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, }) @@ -180,7 +180,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vacuum', 'unique_id': '00000000000004D2-0000000000000061-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index 7f2262663f0a6f..edb5fe95d968ef 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -30,7 +30,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'valve', 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-MatterValve-129-4', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/snapshots/test_water_heater.ambr b/tests/components/matter/snapshots/test_water_heater.ambr index ca80c653a88099..b68870971497ef 100644 --- a/tests/components/matter/snapshots/test_water_heater.ambr +++ b/tests/components/matter/snapshots/test_water_heater.ambr @@ -38,7 +38,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'water_heater', 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-MatterWaterHeater-513-18', 'unit_of_measurement': None, }) diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index b2567c1add12af..6cc967b056638c 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -155,18 +155,6 @@ async def test_device_registry_single_node_composed_device( assert len(device_registry.devices) == 1 -@pytest.mark.usefixtures("matter_node") -@pytest.mark.parametrize("node_fixture", ["inovelli_vtm31"]) -async def test_multi_endpoint_name(hass: HomeAssistant) -> None: - """Test that the entity name gets postfixed if the device has multiple primary endpoints.""" - entity_state = hass.states.get("light.inovelli_light_1") - assert entity_state - assert entity_state.name == "Inovelli Light (1)" - entity_state = hass.states.get("light.inovelli_light_6") - assert entity_state - assert entity_state.name == "Inovelli Light (6)" - - async def test_get_clean_name() -> None: """Test get_clean_name helper. diff --git a/tests/components/matter/test_entity.py b/tests/components/matter/test_entity.py new file mode 100644 index 00000000000000..5f70d03ed130fc --- /dev/null +++ b/tests/components/matter/test_entity.py @@ -0,0 +1,192 @@ +"""Test Matter entity behavior.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize( + ("node_fixture", "entity_id", "expected_translation_key", "expected_name"), + [ + ("mock_onoff_light", "light.mock_onoff_light", "light", "Mock OnOff Light"), + ("mock_door_lock", "lock.mock_door_lock", "lock", "Mock Door Lock"), + ("mock_thermostat", "climate.mock_thermostat", "thermostat", "Mock Thermostat"), + ("mock_valve", "valve.mock_valve", "valve", "Mock Valve"), + ("mock_fan", "fan.mocked_fan_switch", "fan", "Mocked Fan Switch"), + ("eve_energy_plug", "switch.eve_energy_plug", "switch", "Eve Energy Plug"), + ("mock_vacuum_cleaner", "vacuum.mock_vacuum", "vacuum", "Mock Vacuum"), + ( + "silabs_water_heater", + "water_heater.water_heater", + "water_heater", + "Water Heater", + ), + ], +) +async def test_single_endpoint_platform_translation_key( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, + expected_translation_key: str, + expected_name: str, +) -> None: + """Test single-endpoint entities on platforms with _platform_translation_key. + + The translation key must always be present for state_attributes translations + and icon translations. When there is no endpoint postfix, the entity name + should be suppressed (None) so only the device name is displayed. + """ + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.translation_key == expected_translation_key + # No original_name means the entity name is suppressed, + # so only the device name is shown + assert entry.original_name is None + + state = hass.states.get(entity_id) + assert state is not None + # The friendly name should be just the device name (no entity name appended) + assert state.name == expected_name + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["inovelli_vtm31"]) +async def test_multi_endpoint_entity_translation_key( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that multi-endpoint entities have a translation key and a name postfix. + + When a device has the same primary attribute on multiple endpoints, + the entity name gets postfixed with the endpoint ID. The translation key + must still always be set for translations. + """ + # Endpoint 1 + entry_1 = entity_registry.async_get("light.inovelli_light_1") + assert entry_1 is not None + assert entry_1.translation_key == "light" + assert entry_1.original_name == "Light (1)" + + state_1 = hass.states.get("light.inovelli_light_1") + assert state_1 is not None + assert state_1.name == "Inovelli Light (1)" + + # Endpoint 6 + entry_6 = entity_registry.async_get("light.inovelli_light_6") + assert entry_6 is not None + assert entry_6.translation_key == "light" + assert entry_6.original_name == "Light (6)" + + state_6 = hass.states.get("light.inovelli_light_6") + assert state_6 is not None + assert state_6.name == "Inovelli Light (6)" + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["eve_energy_20ecn4101"]) +async def test_label_modified_entity_translation_key( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that label-modified entities have a translation key and a label postfix. + + When a device uses Matter labels to differentiate endpoints, + the entity name gets the label as a postfix. The translation key + must still always be set for translations. + """ + # Top outlet + entry_top = entity_registry.async_get("switch.eve_energy_20ecn4101_switch_top") + assert entry_top is not None + assert entry_top.translation_key == "switch" + assert entry_top.original_name == "Switch (top)" + + state_top = hass.states.get("switch.eve_energy_20ecn4101_switch_top") + assert state_top is not None + assert state_top.name == "Eve Energy 20ECN4101 Switch (top)" + + # Bottom outlet + entry_bottom = entity_registry.async_get( + "switch.eve_energy_20ecn4101_switch_bottom" + ) + assert entry_bottom is not None + assert entry_bottom.translation_key == "switch" + assert entry_bottom.original_name == "Switch (bottom)" + + state_bottom = hass.states.get("switch.eve_energy_20ecn4101_switch_bottom") + assert state_bottom is not None + assert state_bottom.name == "Eve Energy 20ECN4101 Switch (bottom)" + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v4"]) +async def test_description_translation_key_not_overridden( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a description-level translation key is not overridden. + + When an entity description already sets translation_key (e.g. "child_lock"), + the _platform_translation_key logic should not override it. The entity keeps + its description-level translation key and name. + """ + entry = entity_registry.async_get("switch.eve_thermo_20ebp1701_child_lock") + assert entry is not None + # The description-level translation key should be preserved, not overridden + # by _platform_translation_key ("switch") + assert entry.translation_key == "child_lock" + assert entry.original_name == "Child lock" + + state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock") + assert state is not None + assert state.name == "Eve Thermo 20EBP1701 Child lock" + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["air_quality_sensor"]) +async def test_entity_name_from_description_translation_key( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity name derived from an explicit description translation key. + + Sensor entities do not set _platform_translation_key on the platform class. + When the entity description sets translation_key explicitly, the entity name + is derived from that translation key. + """ + entry = entity_registry.async_get( + "sensor.lightfi_aq1_air_quality_sensor_air_quality" + ) + assert entry is not None + assert entry.translation_key == "air_quality" + assert entry.original_name == "Air quality" + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_air_quality") + assert state is not None + assert state.name == "lightfi-aq1-air-quality-sensor Air quality" + + +@pytest.mark.usefixtures("matter_node") +@pytest.mark.parametrize("node_fixture", ["mock_temperature_sensor"]) +async def test_entity_name_from_device_class( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity name derived from device class when no translation key is set. + + Sensor entities do not set _platform_translation_key on the platform class. + When the entity description also has no translation_key, the entity name + is derived from the device class instead. + """ + entry = entity_registry.async_get("sensor.mock_temperature_sensor_temperature") + assert entry is not None + assert entry.translation_key is None + # Name is derived from the device class + assert entry.original_name == "Temperature" + + state = hass.states.get("sensor.mock_temperature_sensor_temperature") + assert state is not None + assert state.name == "Mock Temperature Sensor Temperature" From 3f00403c66617b0ea97c75c5f98c2143bdd05ed1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:54:02 +0100 Subject: [PATCH 05/31] Fix incorrect use of Platform enum in evohome tests (#163143) --- tests/components/evohome/test_climate.py | 34 +++++++++---------- tests/components/evohome/test_water_heater.py | 24 ++++++------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 799578e6eb6601..60793ecef09162 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, @@ -25,7 +26,6 @@ ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -53,7 +53,7 @@ async def test_setup_platform( async for _ in setup_evohome(hass, config, install=install): pass - for x in hass.states.async_all(Platform.CLIMATE): + for x in hass.states.async_all(CLIMATE_DOMAIN): assert x == snapshot(name=f"{x.entity_id}-state") @@ -76,14 +76,14 @@ async def test_entities_update_over_time( # stay inside this context to have the mocked RESTful API async for _ in setup_evohome(hass, config, install=install): - for x in hass.states.async_all(Platform.CLIMATE): + for x in hass.states.async_all(CLIMATE_DOMAIN): assert x == snapshot(name=f"{x.entity_id}-state-initial") freezer.tick(timedelta(hours=12)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - for x in hass.states.async_all(Platform.CLIMATE): + for x in hass.states.async_all(CLIMATE_DOMAIN): assert x == snapshot(name=f"{x.entity_id}-state-updated") @@ -100,7 +100,7 @@ async def test_ctl_set_hvac_mode( # SERVICE_SET_HVAC_MODE: HVACMode.OFF with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: ctl_id, @@ -119,7 +119,7 @@ async def test_ctl_set_hvac_mode( # SERVICE_SET_HVAC_MODE: HVACMode.HEAT with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: ctl_id, @@ -148,7 +148,7 @@ async def test_ctl_set_temperature( # Entity climate.xxx does not support this service with pytest.raises(HomeAssistantError): await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ctl_id, @@ -171,7 +171,7 @@ async def test_ctl_turn_off( # SERVICE_TURN_OFF with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: ctl_id, @@ -202,7 +202,7 @@ async def test_ctl_turn_on( # SERVICE_TURN_ON with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: ctl_id, @@ -233,7 +233,7 @@ async def test_zone_set_hvac_mode( # SERVICE_SET_HVAC_MODE: HVACMode.HEAT with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: zone_id, @@ -247,7 +247,7 @@ async def test_zone_set_hvac_mode( # SERVICE_SET_HVAC_MODE: HVACMode.OFF with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: zone_id, @@ -282,7 +282,7 @@ async def test_zone_set_preset_mode( # SERVICE_SET_PRESET_MODE: none with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, { ATTR_ENTITY_ID: zone_id, @@ -296,7 +296,7 @@ async def test_zone_set_preset_mode( # SERVICE_SET_PRESET_MODE: permanent with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, { ATTR_ENTITY_ID: zone_id, @@ -316,7 +316,7 @@ async def test_zone_set_preset_mode( # SERVICE_SET_PRESET_MODE: temporary with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, { ATTR_ENTITY_ID: zone_id, @@ -350,7 +350,7 @@ async def test_zone_set_temperature( # SERVICE_SET_TEMPERATURE: temperature with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: zone_id, @@ -383,7 +383,7 @@ async def test_zone_turn_off( # SERVICE_TURN_OFF with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: zone_id, @@ -412,7 +412,7 @@ async def test_zone_turn_on( # SERVICE_TURN_ON with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: zone_id, diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index 012844d547fcb8..ce52f43db35925 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -15,15 +15,11 @@ from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, ATTR_OPERATION_MODE, + DOMAIN as WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, SERVICE_SET_OPERATION_MODE, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from .conftest import setup_evohome @@ -49,7 +45,7 @@ async def test_setup_platform( async for _ in setup_evohome(hass, config, install=install): pass - for x in hass.states.async_all(Platform.WATER_HEATER): + for x in hass.states.async_all(WATER_HEATER_DOMAIN): assert x == snapshot(name=f"{x.entity_id}-state") @@ -68,7 +64,7 @@ async def test_set_operation_mode( # SERVICE_SET_OPERATION_MODE: auto with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_SET_OPERATION_MODE, { ATTR_ENTITY_ID: DHW_ENTITY_ID, @@ -82,7 +78,7 @@ async def test_set_operation_mode( # SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint) with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_SET_OPERATION_MODE, { ATTR_ENTITY_ID: DHW_ENTITY_ID, @@ -102,7 +98,7 @@ async def test_set_operation_mode( # SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint) with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_SET_OPERATION_MODE, { ATTR_ENTITY_ID: DHW_ENTITY_ID, @@ -129,7 +125,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non # set_away_mode: off with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, { ATTR_ENTITY_ID: DHW_ENTITY_ID, @@ -143,7 +139,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non # set_away_mode: on with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_SET_AWAY_MODE, { ATTR_ENTITY_ID: DHW_ENTITY_ID, @@ -162,7 +158,7 @@ async def test_turn_off(hass: HomeAssistant, evohome: EvohomeClient) -> None: # turn_off with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: DHW_ENTITY_ID, @@ -180,7 +176,7 @@ async def test_turn_on(hass: HomeAssistant, evohome: EvohomeClient) -> None: # turn_on with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn: await hass.services.async_call( - Platform.WATER_HEATER, + WATER_HEATER_DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: DHW_ENTITY_ID, From d464806281ab152dcaff53ed67657905922d1a30 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:01:55 +0100 Subject: [PATCH 06/31] Fix incorrect use of Platform enum in huum tests (#163145) --- tests/components/huum/test_climate.py | 5 +++-- tests/components/huum/test_light.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py index 3e0a07f92325f9..c8eb7c77566902 100644 --- a/tests/components/huum/test_climate.py +++ b/tests/components/huum/test_climate.py @@ -7,6 +7,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, HVACMode, @@ -48,7 +49,7 @@ async def test_set_hvac_mode( mock_huum.status = SaunaStatus.ONLINE_HEATING await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, @@ -70,7 +71,7 @@ async def test_set_temperature( mock_huum.status = SaunaStatus.ONLINE_HEATING await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, diff --git a/tests/components/huum/test_light.py b/tests/components/huum/test_light.py index 8ad12a36f4ead9..db9ee0f6952717 100644 --- a/tests/components/huum/test_light.py +++ b/tests/components/huum/test_light.py @@ -4,6 +4,7 @@ from syrupy.assertion import SnapshotAssertion +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -46,7 +47,7 @@ async def test_light_turn_off( assert state.state == STATE_ON await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, @@ -68,7 +69,7 @@ async def test_light_turn_on( assert state.state == STATE_OFF await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, From 9477fa44716ee7e25b2e8947dd5f447f73ce8f39 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:02:23 +0100 Subject: [PATCH 07/31] Fix incorrect use of Platform enum in flexit_bacnet tests (#163144) --- tests/components/flexit_bacnet/test_climate.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index a3922d48b1b815..6eb82df0b9e7fe 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -15,6 +15,7 @@ ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, PRESET_HOME, SERVICE_SET_HVAC_MODE, @@ -61,7 +62,7 @@ async def test_set_hvac_preset_mode( mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY mock_flexit_bacnet.operation_mode = 2 # OPERATION_MODE_AWAY await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -81,7 +82,7 @@ async def test_set_hvac_preset_mode( mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME mock_flexit_bacnet.operation_mode = 3 # OPERATION_MODE_HOME await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -100,7 +101,7 @@ async def test_set_hvac_preset_mode( mock_flexit_bacnet.set_ventilation_mode.side_effect = asyncio.TimeoutError with pytest.raises(HomeAssistantError): await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -125,7 +126,7 @@ async def test_set_hvac_mode( mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_STOP mock_flexit_bacnet.operation_mode = 1 # OPERATION_MODE_OFF await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, @@ -140,7 +141,7 @@ async def test_set_hvac_mode( mock_flexit_bacnet.set_ventilation_mode.side_effect = asyncio.TimeoutError with pytest.raises(HomeAssistantError): await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, @@ -183,7 +184,7 @@ async def test_set_temperature( # Set ventilation mode to HOME and set temperature to 22.5°C mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -198,7 +199,7 @@ async def test_set_temperature( # Change ventilation mode to AWAY and set temperature mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -214,7 +215,7 @@ async def test_set_temperature( mock_flexit_bacnet.set_air_temp_setpoint_away.side_effect = ConnectionError with pytest.raises(HomeAssistantError): await hass.services.async_call( - Platform.CLIMATE, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, From b8885791f7f215fad899a8df7bb6b79519528845 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:02:52 +0100 Subject: [PATCH 08/31] Fix incorrect use of Platform enum in roborock tests (#163142) --- tests/components/roborock/test_vacuum.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index e9b873cbbf16a8..fae52cc9dc85e5 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -16,6 +16,7 @@ SET_VACUUM_GOTO_POSITION_SERVICE_NAME, ) from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -105,7 +106,7 @@ async def test_commands( data = {ATTR_ENTITY_ID: ENTITY_ID, **(service_params or {})} await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, service, data, blocking=True, @@ -149,7 +150,7 @@ async def refresh_properties() -> None: data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, SERVICE_START, data, blocking=True, @@ -170,7 +171,7 @@ async def test_failed_user_command( pytest.raises(HomeAssistantError, match="Error while calling fake_command"), ): await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, SERVICE_SEND_COMMAND, data, blocking=True, @@ -350,7 +351,7 @@ async def test_q7_state_changing_commands( data = {ATTR_ENTITY_ID: Q7_ENTITY_ID, **(service_params or {})} await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, service, data, blocking=True, @@ -381,7 +382,7 @@ async def test_q7_locate_command( assert vacuum await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, SERVICE_LOCATE, {ATTR_ENTITY_ID: Q7_ENTITY_ID}, blocking=True, @@ -400,7 +401,7 @@ async def test_q7_set_fan_speed_command( assert vacuum await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, {ATTR_ENTITY_ID: Q7_ENTITY_ID, "fan_speed": "quiet"}, blocking=True, @@ -420,7 +421,7 @@ async def test_q7_send_command( assert vacuum await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, SERVICE_SEND_COMMAND, {ATTR_ENTITY_ID: Q7_ENTITY_ID, "command": "test_command"}, blocking=True, @@ -464,7 +465,7 @@ async def test_q7_failed_commands( with pytest.raises(HomeAssistantError, match=f"Error while calling {command_name}"): await hass.services.async_call( - Platform.VACUUM, + VACUUM_DOMAIN, service, data, blocking=True, From 4510ca79944825803aca0be46d6689d12d0170ec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:08:43 +0100 Subject: [PATCH 09/31] Fix incorrect use of Platform enum in wmspro tests (#163152) --- tests/components/wmspro/test_cover.py | 12 ++++++------ tests/components/wmspro/test_light.py | 13 ++++++------- tests/components/wmspro/test_switch.py | 6 +++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index 72b251223dd744..3051d86fe4f812 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -6,6 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.wmspro.const import DOMAIN from homeassistant.components.wmspro.cover import SCAN_INTERVAL from homeassistant.const import ( @@ -16,7 +17,6 @@ SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -124,7 +124,7 @@ async def test_cover_open_and_close( before = len(mock_hub_status.mock_calls) await hass.services.async_call( - Platform.COVER, + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, @@ -143,7 +143,7 @@ async def test_cover_open_and_close( before = len(mock_hub_status.mock_calls) await hass.services.async_call( - Platform.COVER, + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, @@ -207,7 +207,7 @@ async def test_cover_open_to_pos( before = len(mock_hub_status.mock_calls) await hass.services.async_call( - Platform.COVER, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: entity.entity_id, "position": 50}, blocking=True, @@ -271,7 +271,7 @@ async def test_cover_open_and_stop( before = len(mock_hub_status.mock_calls) await hass.services.async_call( - Platform.COVER, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: entity.entity_id, "position": 80}, blocking=True, @@ -290,7 +290,7 @@ async def test_cover_open_and_stop( before = len(mock_hub_status.mock_calls) await hass.services.async_call( - Platform.COVER, + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, diff --git a/tests/components/wmspro/test_light.py b/tests/components/wmspro/test_light.py index 749c1d9104b13a..487a54a8da58a9 100644 --- a/tests/components/wmspro/test_light.py +++ b/tests/components/wmspro/test_light.py @@ -5,7 +5,7 @@ from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.components.wmspro.const import DOMAIN from homeassistant.components.wmspro.light import SCAN_INTERVAL from homeassistant.const import ( @@ -14,7 +14,6 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -97,7 +96,7 @@ async def test_light_turn_on_and_off( before = len(mock_hub_status_prod_dimmer.mock_calls) await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, @@ -116,7 +115,7 @@ async def test_light_turn_on_and_off( before = len(mock_hub_status_prod_dimmer.mock_calls) await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, @@ -155,7 +154,7 @@ async def test_light_dimm_on_and_off( before = len(mock_hub_status_prod_dimmer.mock_calls) await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, @@ -174,7 +173,7 @@ async def test_light_dimm_on_and_off( before = len(mock_hub_status_prod_dimmer.mock_calls) await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity.entity_id, ATTR_BRIGHTNESS: 128}, blocking=True, @@ -193,7 +192,7 @@ async def test_light_dimm_on_and_off( before = len(mock_hub_status_prod_dimmer.mock_calls) await hass.services.async_call( - Platform.LIGHT, + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, diff --git a/tests/components/wmspro/test_switch.py b/tests/components/wmspro/test_switch.py index 823d553a44bdc9..7daefc68e8d12a 100644 --- a/tests/components/wmspro/test_switch.py +++ b/tests/components/wmspro/test_switch.py @@ -5,6 +5,7 @@ from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.wmspro.const import DOMAIN from homeassistant.components.wmspro.switch import SCAN_INTERVAL from homeassistant.const import ( @@ -13,7 +14,6 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -97,7 +97,7 @@ async def test_switch_turn_on_and_off( before = len(mock_hub_status_prod_load_switch.mock_calls) await hass.services.async_call( - Platform.SWITCH, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, @@ -115,7 +115,7 @@ async def test_switch_turn_on_and_off( before = len(mock_hub_status_prod_load_switch.mock_calls) await hass.services.async_call( - Platform.SWITCH, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity.entity_id}, blocking=True, From 37af004a37aaf807fed301b6ca2b21d56d4b5b0d Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:20:44 +0100 Subject: [PATCH 10/31] Deprecate async_listen in labs (#162648) --- .../components/kitchen_sink/__init__.py | 26 ++++++++++++++----- homeassistant/components/labs/helpers.py | 8 ++++++ homeassistant/components/template/__init__.py | 24 ++++++++++------- tests/components/labs/test_init.py | 6 ++++- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 15f7314ee7ac23..5fc498cc94d49d 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -7,11 +7,16 @@ from __future__ import annotations import datetime +from functools import partial from random import random import voluptuous as vol -from homeassistant.components.labs import async_is_preview_feature_enabled, async_listen +from homeassistant.components.labs import ( + EventLabsUpdatedData, + async_is_preview_feature_enabled, + async_subscribe_preview_feature, +) from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance from homeassistant.components.recorder.models import ( StatisticData, @@ -128,16 +133,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Subscribe to labs feature updates for kitchen_sink preview repair entry.async_on_unload( - async_listen( + async_subscribe_preview_feature( hass, domain=DOMAIN, preview_feature="special_repair", - listener=lambda: _async_update_special_repair(hass), + listener=partial(_async_update_special_repair, hass), ) ) # Check if lab feature is currently enabled and create repair if so - _async_update_special_repair(hass) + await _async_update_special_repair(hass) return True @@ -166,15 +171,22 @@ async def async_remove_config_entry_device( return True -@callback -def _async_update_special_repair(hass: HomeAssistant) -> None: +async def _async_update_special_repair( + hass: HomeAssistant, + event_data: EventLabsUpdatedData | None = None, +) -> None: """Create or delete the special repair issue. Creates a repair issue when the special_repair lab feature is enabled, and deletes it when disabled. This demonstrates how lab features can interact with Home Assistant's repair system. """ - if async_is_preview_feature_enabled(hass, DOMAIN, "special_repair"): + enabled = ( + event_data["enabled"] + if event_data is not None + else async_is_preview_feature_enabled(hass, DOMAIN, "special_repair") + ) + if enabled: async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/labs/helpers.py b/homeassistant/components/labs/helpers.py index 81454cbe811aa0..2045487ec8463f 100644 --- a/homeassistant/components/labs/helpers.py +++ b/homeassistant/components/labs/helpers.py @@ -7,6 +7,7 @@ from homeassistant.const import EVENT_LABS_UPDATED from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.frame import report_usage from .const import LABS_DATA from .models import EventLabsUpdatedData @@ -79,6 +80,8 @@ def async_listen( ) -> Callable[[], None]: """Listen for changes to a specific preview feature. + Deprecated: use async_subscribe_preview_feature instead. + Args: hass: HomeAssistant instance domain: Integration domain @@ -88,6 +91,11 @@ def async_listen( Returns: Callable to unsubscribe from the listener """ + report_usage( + "calls `async_listen` which is deprecated, " + "use `async_subscribe_preview_feature` instead", + breaks_in_ha_version="2027.3.0", + ) async def _listener(_event_data: EventLabsUpdatedData) -> None: listener() diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 35c629c8af3592..c1a136a29ef0ac 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Coroutine -from functools import partial import logging from typing import Any @@ -13,7 +12,10 @@ DOMAIN as AUTOMATION_DOMAIN, NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG, ) -from homeassistant.components.labs import async_listen as async_labs_listen +from homeassistant.components.labs import ( + EventLabsUpdatedData, + async_subscribe_preview_feature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, @@ -22,7 +24,7 @@ CONF_UNIQUE_ID, SERVICE_RELOAD, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryError, HomeAssistantError from homeassistant.helpers import discovery, issue_registry as ir from homeassistant.helpers.device import ( @@ -99,18 +101,19 @@ async def _reload_config(call: Event | ServiceCall) -> None: async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) - @callback - def new_triggers_conditions_listener() -> None: + async def _handle_new_triggers_conditions( + _event_data: EventLabsUpdatedData, + ) -> None: """Handle new_triggers_conditions flag change.""" hass.async_create_task( _reload_config(ServiceCall(hass, DOMAIN, SERVICE_RELOAD)) ) - async_labs_listen( + async_subscribe_preview_feature( hass, AUTOMATION_DOMAIN, NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG, - new_triggers_conditions_listener, + _handle_new_triggers_conditions, ) return True @@ -139,12 +142,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, (entry.options["template_type"],) ) + async def _handle_entry_reload(_event_data: EventLabsUpdatedData) -> None: + hass.config_entries.async_schedule_reload(entry.entry_id) + entry.async_on_unload( - async_labs_listen( + async_subscribe_preview_feature( hass, AUTOMATION_DOMAIN, NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG, - partial(hass.config_entries.async_schedule_reload, entry.entry_id), + _handle_entry_reload, ) ) diff --git a/tests/components/labs/test_init.py b/tests/components/labs/test_init.py index 46f2ca01a236e8..889db785372324 100644 --- a/tests/components/labs/test_init.py +++ b/tests/components/labs/test_init.py @@ -360,7 +360,9 @@ async def test_preview_feature_to_dict_is_built_in( assert result["is_built_in"] is expected_default -async def test_async_listen_helper(hass: HomeAssistant) -> None: +async def test_async_listen_helper( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the async_listen helper function for preview feature events.""" # Load kitchen_sink integration hass.config.components.add("kitchen_sink") @@ -383,6 +385,8 @@ def test_listener() -> None: listener=test_listener, ) + assert ("calls `async_listen` which is deprecated") in caplog.text + # Fire event for the subscribed feature hass.bus.async_fire( EVENT_LABS_UPDATED, From 75b5248e2a35f595f695f260ed23fc58b63f1dec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:28:08 +0100 Subject: [PATCH 11/31] Fix incorrect use of Platform enum in utility_meter tests (#163153) --- tests/components/utility_meter/test_init.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index f5858e0344d50f..ecd151c799ed71 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -14,6 +14,7 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.utility_meter import ( select as um_select, sensor as um_sensor, @@ -26,7 +27,6 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_START, - Platform, UnitOfEnergy, ) from homeassistant.core import Event, HomeAssistant, State, callback @@ -139,7 +139,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: ) assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, Platform.SENSOR, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() # restore from cache @@ -172,7 +172,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: } assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, Platform.SENSOR, config) + assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) From 3ee20d5e5c3e5e9351d2c3b53481669261f6d630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Feb 2026 13:32:39 +0100 Subject: [PATCH 12/31] Add `ppm` to `NITROGEN_DIOXIDE` units (#162983) --- homeassistant/components/number/const.py | 3 ++- homeassistant/components/sensor/const.py | 3 ++- homeassistant/util/unit_conversion.py | 2 ++ tests/util/test_unit_conversion.py | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 83777d47322a52..78ee067bc55edd 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -272,7 +272,7 @@ class NumberDeviceClass(StrEnum): NITROGEN_DIOXIDE = "nitrogen_dioxide" """Amount of NO2. - Unit of measurement: `ppb` (parts per billion), `μg/m³` + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` """ NITROGEN_MONOXIDE = "nitrogen_monoxide" @@ -544,6 +544,7 @@ class NumberDeviceClass(StrEnum): NumberDeviceClass.MOISTURE: {PERCENTAGE}, NumberDeviceClass.NITROGEN_DIOXIDE: { CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, NumberDeviceClass.NITROGEN_MONOXIDE: { diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index a1ee3e0417e07a..0a7fac2157657d 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -286,7 +286,7 @@ class SensorDeviceClass(StrEnum): NITROGEN_DIOXIDE = "nitrogen_dioxide" """Amount of NO2. - Unit of measurement: `ppb` (parts per billion), `μg/m³` + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` """ NITROGEN_MONOXIDE = "nitrogen_monoxide" @@ -639,6 +639,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.MOISTURE: {PERCENTAGE}, SensorDeviceClass.NITROGEN_DIOXIDE: { CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, SensorDeviceClass.NITROGEN_MONOXIDE: { diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 1f9c0c8ab902e1..c0461b82f3fcf7 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -494,12 +494,14 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter): UNIT_CLASS = "nitrogen_dioxide" _UNIT_CONVERSION: dict[str | None, float] = { CONCENTRATION_PARTS_PER_BILLION: 1e9, + CONCENTRATION_PARTS_PER_MILLION: 1e6, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: ( _NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6 ), } VALID_UNITS = { CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, } diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 9deeecbd604fc6..4c2ae80f020577 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -413,6 +413,30 @@ 62.744976, CONCENTRATION_PARTS_PER_BILLION, ), + ( + 1, + CONCENTRATION_PARTS_PER_MILLION, + 1912.503, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + 120, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + 0.062744976, + CONCENTRATION_PARTS_PER_MILLION, + ), + ( + 100, + CONCENTRATION_PARTS_PER_BILLION, + 0.1, + CONCENTRATION_PARTS_PER_MILLION, + ), + ( + 0.5, + CONCENTRATION_PARTS_PER_MILLION, + 500, + CONCENTRATION_PARTS_PER_BILLION, + ), ], NitrogenMonoxideConcentrationConverter: [ ( From 27d715e26a37bc7f79a4c3dea440f1a9896d5ba7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:47:29 +0100 Subject: [PATCH 13/31] Fix incorrect use of Platform enum in zha tests (#163150) --- tests/components/zha/test_fan.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 3e4ed9cb6f853f..33585fd4d8a32f 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -156,14 +156,14 @@ async def async_turn_on(hass: HomeAssistant, entity_id, percentage=None): if value is not None } - await hass.services.async_call(Platform.FAN, SERVICE_TURN_ON, data, blocking=True) + await hass.services.async_call(FAN_DOMAIN, SERVICE_TURN_ON, data, blocking=True) async def async_turn_off(hass: HomeAssistant, entity_id): """Turn fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(Platform.FAN, SERVICE_TURN_OFF, data, blocking=True) + await hass.services.async_call(FAN_DOMAIN, SERVICE_TURN_OFF, data, blocking=True) async def async_set_percentage(hass: HomeAssistant, entity_id, percentage=None): @@ -175,7 +175,7 @@ async def async_set_percentage(hass: HomeAssistant, entity_id, percentage=None): } await hass.services.async_call( - Platform.FAN, SERVICE_SET_PERCENTAGE, data, blocking=True + FAN_DOMAIN, SERVICE_SET_PERCENTAGE, data, blocking=True ) From 5f3cb37ee6e9eb1303a39eaf5ab269bca3932347 Mon Sep 17 00:00:00 2001 From: Glenn de Haan Date: Mon, 16 Feb 2026 13:55:08 +0100 Subject: [PATCH 14/31] Fix HDFury volt symbol (#163160) --- homeassistant/components/hdfury/strings.json | 4 ++-- tests/components/hdfury/snapshots/test_switch.ambr | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hdfury/strings.json b/homeassistant/components/hdfury/strings.json index 982c0d69ed5c4f..b5addd5668a184 100644 --- a/homeassistant/components/hdfury/strings.json +++ b/homeassistant/components/hdfury/strings.json @@ -164,10 +164,10 @@ "name": "Relay" }, "tx0plus5": { - "name": "TX0 force +5v" + "name": "TX0 force +5V" }, "tx1plus5": { - "name": "TX1 force +5v" + "name": "TX1 force +5V" } } }, diff --git a/tests/components/hdfury/snapshots/test_switch.ambr b/tests/components/hdfury/snapshots/test_switch.ambr index 0000a844857557..403e4f3846bfd2 100644 --- a/tests/components/hdfury/snapshots/test_switch.ambr +++ b/tests/components/hdfury/snapshots/test_switch.ambr @@ -755,12 +755,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'TX0 force +5v', + 'object_id_base': 'TX0 force +5V', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'TX0 force +5v', + 'original_name': 'TX0 force +5V', 'platform': 'hdfury', 'previous_unique_id': None, 'suggested_object_id': None, @@ -773,7 +773,7 @@ # name: test_switch_entities[switch.hdfury_vrroom_02_tx0_force_5v-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'HDFury VRROOM-02 TX0 force +5v', + 'friendly_name': 'HDFury VRROOM-02 TX0 force +5V', }), 'context': , 'entity_id': 'switch.hdfury_vrroom_02_tx0_force_5v', @@ -804,12 +804,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'TX1 force +5v', + 'object_id_base': 'TX1 force +5V', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'TX1 force +5v', + 'original_name': 'TX1 force +5V', 'platform': 'hdfury', 'previous_unique_id': None, 'suggested_object_id': None, @@ -822,7 +822,7 @@ # name: test_switch_entities[switch.hdfury_vrroom_02_tx1_force_5v-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'HDFury VRROOM-02 TX1 force +5v', + 'friendly_name': 'HDFury VRROOM-02 TX1 force +5V', }), 'context': , 'entity_id': 'switch.hdfury_vrroom_02_tx1_force_5v', From 6bbe80da725d0f098944e05c1ce78658eed51073 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:56:28 +0100 Subject: [PATCH 15/31] Fix incorrect use of Platform enum in threshold tests (#163154) --- .../threshold/test_binary_sensor.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index 259009c6319cf3..11691d153c7c97 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -2,6 +2,7 @@ import pytest +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.threshold.const import ( ATTR_HYSTERESIS, ATTR_LOWER, @@ -68,7 +69,7 @@ async def test_sensor_upper( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") @@ -114,7 +115,7 @@ async def test_sensor_lower( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") @@ -176,7 +177,7 @@ async def test_sensor_upper_hysteresis( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") @@ -238,7 +239,7 @@ async def test_sensor_lower_hysteresis( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") @@ -300,7 +301,7 @@ async def test_sensor_in_range_no_hysteresis( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") @@ -393,7 +394,7 @@ async def test_sensor_in_range_with_hysteresis( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") @@ -428,7 +429,7 @@ async def test_sensor_in_range_unknown_state( } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() hass.states.async_set( @@ -478,7 +479,7 @@ async def test_sensor_lower_zero_threshold(hass: HomeAssistant) -> None: } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", 16) @@ -506,7 +507,7 @@ async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: } } - assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", -10) @@ -535,7 +536,7 @@ async def test_sensor_no_lower_upper( } } - await async_setup_component(hass, Platform.BINARY_SENSOR, config) + await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() assert "Lower or Upper thresholds are not provided" in caplog.text From b664f2ca9ac2e8dac8e0a15e339979ed41470e1b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 16 Feb 2026 13:56:54 +0100 Subject: [PATCH 16/31] Remove unused MQTT CONF_COLOR_MODE const and abbreviation (#163146) --- homeassistant/components/mqtt/abbreviations.py | 1 - homeassistant/components/mqtt/const.py | 1 - 2 files changed, 2 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 89857efc149264..22f725be4d6519 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -19,7 +19,6 @@ "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", "clr_temp_cmd_tpl": "color_temp_command_template", - "clrm": "color_mode", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", "clr_temp_cmd_t": "color_temp_command_topic", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 96300977722c7a..7d601aad1fa6be 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -71,7 +71,6 @@ CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" CONF_BRIGHTNESS_TEMPLATE = "brightness_template" CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" -CONF_COLOR_MODE = "color_mode" CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" From 9977c58aaaafea44b227c845071b421cdc322279 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:59:37 +0100 Subject: [PATCH 17/31] Fix incorrect use of Platform enum in wsdot tests (#163151) --- tests/components/wsdot/test_sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index 8fac370f50e161..f7afaeffdc85b4 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -4,6 +4,7 @@ from syrupy.assertion import SnapshotAssertion +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.wsdot.const import CONF_TRAVEL_TIMES, DOMAIN from homeassistant.const import ( CONF_API_KEY, @@ -37,7 +38,7 @@ async def test_travel_sensor_platform_setup( """Test the wsdot Travel Time sensor still supports setup from platform config.""" assert await async_setup_component( hass, - Platform.SENSOR, + SENSOR_DOMAIN, { Platform.SENSOR: [ { @@ -62,7 +63,7 @@ async def test_travel_sensor_platform_setup_bad_routes( """Test the wsdot Travel Time sensor platform upgrade skips unknown route ids.""" assert await async_setup_component( hass, - Platform.SENSOR, + SENSOR_DOMAIN, { Platform.SENSOR: [ { From 26f852d934c93aba796a20443bebbfb41898708b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:11:44 +0100 Subject: [PATCH 18/31] Fix incorrect use of Platform enum in homematicip_cloud tests (#163149) --- tests/components/homematicip_cloud/test_valve.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/components/homematicip_cloud/test_valve.py b/tests/components/homematicip_cloud/test_valve.py index 5c2840dc28f410..c92c05b34f1251 100644 --- a/tests/components/homematicip_cloud/test_valve.py +++ b/tests/components/homematicip_cloud/test_valve.py @@ -1,7 +1,10 @@ """Test HomematicIP Cloud valve entities.""" -from homeassistant.components.valve import SERVICE_OPEN_VALVE, ValveState -from homeassistant.const import Platform +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + ValveState, +) from homeassistant.core import HomeAssistant from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics @@ -25,7 +28,7 @@ async def test_watering_valve( assert ha_state.state == ValveState.CLOSED await hass.services.async_call( - Platform.VALVE, SERVICE_OPEN_VALVE, {"entity_id": entity_id}, blocking=True + VALVE_DOMAIN, SERVICE_OPEN_VALVE, {"entity_id": entity_id}, blocking=True ) await async_manipulate_test_data( From 2d2ea3d31cce8c0c0d15bac20f46e77f69650b27 Mon Sep 17 00:00:00 2001 From: hanwg Date: Mon, 16 Feb 2026 21:24:24 +0800 Subject: [PATCH 19/31] Cleanup unused code for Telegram bot (#163147) --- .../components/telegram_bot/__init__.py | 2 +- homeassistant/components/telegram_bot/bot.py | 98 ++++++------------- 2 files changed, 31 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e418336eaa269a..442f2c5bb66adb 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -528,7 +528,7 @@ async def _call_service( service_name = service.service kwargs = dict(service.data) - kwargs[ATTR_TARGET] = chat_id + kwargs[ATTR_CHAT_ID] = chat_id messages: dict[str, JsonValueType] | None = None if service_name == SERVICE_SEND_MESSAGE: diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 754dc84305c634..e4aae644b1a825 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -86,7 +86,6 @@ ATTR_REPLYMARKUP, ATTR_RESIZE_KEYBOARD, ATTR_STICKER_ID, - ATTR_TARGET, ATTR_TEXT, ATTR_TIMEOUT, ATTR_TITLE, @@ -332,35 +331,6 @@ def _get_msg_ids( inline_message_id = msg_data[ATTR_INLINE_MESSAGE_ID] return message_id, inline_message_id - def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]: - """Validate chat_id targets or return default target (first). - - :param target: optional list of integers ([12234, -12345]) - :return list of chat_id targets (integers) - """ - allowed_chat_ids: list[int] = [ - subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values() - ] - - if target is None: - return [allowed_chat_ids[0]] - - chat_ids = [target] if isinstance(target, int) else target - valid_chat_ids = [ - chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids - ] - if not valid_chat_ids: - raise ServiceValidationError( - "Invalid chat IDs", - translation_domain=DOMAIN, - translation_key="invalid_chat_ids", - translation_placeholders={ - "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), - "bot_name": self.config.title, - }, - ) - return valid_chat_ids - def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" @@ -476,7 +446,7 @@ async def _send_msgs( :return: dict with chat_id keys and message_id values for successful sends """ - chat_ids = self.get_target_chat_ids(kwargs_msg.pop(ATTR_TARGET, None)) + chat_ids = [kwargs_msg.pop(ATTR_CHAT_ID)] msg_ids: dict[str, JsonValueType] = {} for chat_id in chat_ids: _LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id) @@ -561,8 +531,8 @@ async def _send_msg( async def send_message( self, - message: str = "", - target: Any = None, + message: str, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: @@ -575,7 +545,7 @@ async def send_message( "Error sending message", params[ATTR_MESSAGE_TAG], text, - target=target, + chat_id=chat_id, parse_mode=params[ATTR_PARSER], disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV], disable_notification=params[ATTR_DISABLE_NOTIF], @@ -588,12 +558,11 @@ async def send_message( async def delete_message( self, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> bool: """Delete a previously sent message.""" - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) deleted: bool = await self._send_msg( @@ -613,12 +582,11 @@ async def delete_message( async def edit_message_media( self, media_type: str, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: Any, ) -> Any: "Edit message media of a previously sent message." - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -690,12 +658,11 @@ async def edit_message_media( async def edit_message( self, type_edit: str, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Edit a previously sent message.""" - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -779,25 +746,24 @@ async def answer_callback_query( async def send_chat_action( self, + chat_id: int, chat_action: str = "", - target: Any = None, context: Context | None = None, **kwargs: Any, ) -> dict[str, JsonValueType]: """Send a chat action to pre-allowed chat IDs.""" result: dict[str, JsonValueType] = {} - for chat_id in self.get_target_chat_ids(target): - _LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id) - is_successful = await self._send_msg( - self.bot.send_chat_action, - "Error sending action", - None, - chat_id=chat_id, - action=chat_action, - message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID), - context=context, - ) - result[str(chat_id)] = is_successful + _LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id) + is_successful = await self._send_msg( + self.bot.send_chat_action, + "Error sending action", + None, + chat_id=chat_id, + action=chat_action, + message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID), + context=context, + ) + result[str(chat_id)] = is_successful return result async def send_file( @@ -827,7 +793,7 @@ async def send_file( self.bot.send_photo, "Error sending photo", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], photo=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -844,7 +810,7 @@ async def send_file( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], sticker=file_content, disable_notification=params[ATTR_DISABLE_NOTIF], reply_to_message_id=params[ATTR_REPLY_TO_MSGID], @@ -859,7 +825,7 @@ async def send_file( self.bot.send_video, "Error sending video", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], video=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -876,7 +842,7 @@ async def send_file( self.bot.send_document, "Error sending document", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], document=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -893,7 +859,7 @@ async def send_file( self.bot.send_voice, "Error sending voice", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], voice=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -909,7 +875,7 @@ async def send_file( self.bot.send_animation, "Error sending animation", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], animation=file_content, caption=kwargs.get(ATTR_CAPTION), disable_notification=params[ATTR_DISABLE_NOTIF], @@ -935,7 +901,7 @@ async def send_sticker( self.bot.send_sticker, "Error sending sticker", params[ATTR_MESSAGE_TAG], - target=kwargs.get(ATTR_TARGET), + chat_id=kwargs[ATTR_CHAT_ID], sticker=stickerid, disable_notification=params[ATTR_DISABLE_NOTIF], reply_to_message_id=params[ATTR_REPLY_TO_MSGID], @@ -950,7 +916,6 @@ async def send_location( self, latitude: Any, longitude: Any, - target: Any = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: @@ -962,7 +927,7 @@ async def send_location( self.bot.send_location, "Error sending location", params[ATTR_MESSAGE_TAG], - target=target, + chat_id=kwargs[ATTR_CHAT_ID], latitude=latitude, longitude=longitude, disable_notification=params[ATTR_DISABLE_NOTIF], @@ -978,7 +943,6 @@ async def send_poll( options: Sequence[str | InputPollOption], is_anonymous: bool | None, allows_multiple_answers: bool | None, - target: Any = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> dict[str, JsonValueType]: @@ -989,7 +953,7 @@ async def send_poll( self.bot.send_poll, "Error sending poll", params[ATTR_MESSAGE_TAG], - target=target, + chat_id=kwargs[ATTR_CHAT_ID], question=question, options=options, is_anonymous=is_anonymous, @@ -1004,12 +968,11 @@ async def send_poll( async def leave_chat( self, - chat_id: int | None = None, + chat_id: int, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Remove bot from chat.""" - chat_id = self.get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) return await self._send_msg( self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context @@ -1018,13 +981,12 @@ async def leave_chat( async def set_message_reaction( self, reaction: str, - chat_id: int | None = None, + chat_id: int, is_big: bool = False, context: Context | None = None, **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" - chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) From 8dc9937ba465390f020afb8f508ce83fb8fa4117 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:27:58 +0100 Subject: [PATCH 20/31] Prefer explicit parametrize in litterrobot tests (#163155) --- tests/components/litterrobot/test_vacuum.py | 28 +++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 6a3a974b874037..1ce3779a5cc9ee 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -24,10 +24,6 @@ VACUUM_UNIQUE_ID = "LR3C012345-litter_box" -COMPONENT_SERVICE_DOMAIN = { - SERVICE_SET_SLEEP_MODE: DOMAIN, -} - async def test_vacuum( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock @@ -106,23 +102,35 @@ async def test_activities( @pytest.mark.parametrize( - ("service", "command", "extra"), + ("service_domain", "service", "command", "extra"), [ - (SERVICE_START, "start_cleaning", None), - (SERVICE_STOP, "set_power_status", None), + (VACUUM_DOMAIN, SERVICE_START, "start_cleaning", None), + (VACUUM_DOMAIN, SERVICE_STOP, "set_power_status", None), ( + DOMAIN, SERVICE_SET_SLEEP_MODE, "set_sleep_mode", {"data": {"enabled": True, "start_time": "22:30"}}, ), - (SERVICE_SET_SLEEP_MODE, "set_sleep_mode", {"data": {"enabled": True}}), - (SERVICE_SET_SLEEP_MODE, "set_sleep_mode", {"data": {"enabled": False}}), + ( + DOMAIN, + SERVICE_SET_SLEEP_MODE, + "set_sleep_mode", + {"data": {"enabled": True}}, + ), + ( + DOMAIN, + SERVICE_SET_SLEEP_MODE, + "set_sleep_mode", + {"data": {"enabled": False}}, + ), ], ) async def test_commands( hass: HomeAssistant, mock_account: MagicMock, caplog: pytest.LogCaptureFixture, + service_domain: str, service: str, command: str, extra: dict[str, Any], @@ -140,7 +148,7 @@ async def test_commands( issues = extra.get("issues", set()) await hass.services.async_call( - COMPONENT_SERVICE_DOMAIN.get(service, VACUUM_DOMAIN), + service_domain, service, data, blocking=True, From 3a0bde5d3e89aa03d362e4894d41a681afc5af19 Mon Sep 17 00:00:00 2001 From: AlexSp <36576348+AlexSperka@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:29:49 +0100 Subject: [PATCH 21/31] Add dependabot cooldown (#163082) Co-authored-by: Franck Nijhof --- .github/dependabot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f9bfa9b406dd10..3f9af2b699fb39 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,8 @@ updates: labels: - dependency - github_actions + cooldown: + default-days: 7 + semver-major-days: 7 + semver-minor-days: 3 + semver-patch-days: 1 From e88be6bdeb4772e22b5388b7af421da6d621ba50 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Feb 2026 14:56:33 +0100 Subject: [PATCH 22/31] Fix dependabot cooldown config for github-actions ecosystem (#163166) --- .github/dependabot.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3f9af2b699fb39..e04aba50e62025 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,3 @@ updates: - github_actions cooldown: default-days: 7 - semver-major-days: 7 - semver-minor-days: 3 - semver-patch-days: 1 From c5b1b4482dd372c01f1cd7a6dd299ad640db5d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Feb 2026 15:00:52 +0100 Subject: [PATCH 23/31] Fix device class for Matter Nitrogen Dioxide Sensor (#162965) --- homeassistant/components/matter/sensor.py | 2 +- tests/components/matter/snapshots/test_sensor.ambr | 10 ++++++---- tests/components/matter/test_sensor.py | 13 +++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 1f9d2742325c72..ac24ab76724624 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -722,8 +722,8 @@ def _update_from_device(self) -> None: platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="NitrogenDioxideSensor", - translation_key="nitrogen_dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 054ac6aaae511c..c1a47bf30a1695 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -200,14 +200,14 @@ 'object_id_base': 'Nitrogen dioxide', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'nitrogen_dioxide', + 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-NitrogenDioxideSensor-1043-0', 'unit_of_measurement': 'ppm', }) @@ -215,6 +215,7 @@ # name: test_sensors[air_quality_sensor][sensor.lightfi_aq1_air_quality_sensor_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'nitrogen_dioxide', 'friendly_name': 'lightfi-aq1-air-quality-sensor Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'ppm', @@ -7544,14 +7545,14 @@ 'object_id_base': 'Nitrogen dioxide', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Nitrogen dioxide', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'nitrogen_dioxide', + 'translation_key': None, 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-NitrogenDioxideSensor-1043-0', 'unit_of_measurement': 'ppm', }) @@ -7559,6 +7560,7 @@ # name: test_sensors[mock_air_purifier][sensor.mock_air_purifier_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'nitrogen_dioxide', 'friendly_name': 'Mock Air Purifier Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'ppm', diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 38ae3749fddc7b..1b9768e54c5f25 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -310,6 +310,19 @@ async def test_air_quality_sensor( assert state assert state.state == "789.0" + # Nitrogen Dioxide + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_nitrogen_dioxide") + assert state + assert state.state == "0.0" + assert state.attributes["device_class"] == "nitrogen_dioxide" + + set_node_attribute(matter_node, 1, 1043, 0, 12.5) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_nitrogen_dioxide") + assert state + assert state.state == "12.5" + # PM1 state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm1") assert state From 726870b8292bd45de09f12c9f49495c06708b558 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:09:07 +0100 Subject: [PATCH 24/31] Add py_vapid to requirements in HTML5 integration (#163165) --- homeassistant/components/html5/manifest.json | 2 +- requirements_all.txt | 3 +++ requirements_test_all.txt | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index 59a755cbf06918..1ef261d201d860 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/html5", "iot_class": "cloud_push", "loggers": ["http_ece", "py_vapid", "pywebpush"], - "requirements": ["pywebpush==2.3.0"], + "requirements": ["pywebpush==2.3.0", "py_vapid==1.9.4"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 1c887daba6565e..068635d57f4f1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1902,6 +1902,9 @@ pyW800rf32==0.4 # homeassistant.components.ccm15 py_ccm15==0.1.2 +# homeassistant.components.html5 +py_vapid==1.9.4 + # homeassistant.components.ads pyads==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6dec065ebda1e9..f79d9c975eeb0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1636,6 +1636,9 @@ pyW215==0.8.0 # homeassistant.components.ccm15 py_ccm15==0.1.2 +# homeassistant.components.html5 +py_vapid==1.9.4 + # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 From 1e6196c6e826bd393be77e1c1558f53c3e8cce7a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Feb 2026 15:18:55 +0100 Subject: [PATCH 25/31] Add zizmor as a CI check for GitHub Actions workflows (#163161) --- .github/workflows/ci.yaml | 22 +++++++++++++++++++++- .pre-commit-config.yaml | 6 ++++++ requirements_test_pre_commit.txt | 1 + 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9108af65115e52..78dd9472af02fd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -280,9 +280,29 @@ jobs: - name: Run prek uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 env: - PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config + PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github + zizmor: + name: Check GitHub Actions workflows + runs-on: ubuntu-24.04 + permissions: + contents: read # To check out the repository + needs: [info] + if: | + github.event.inputs.pylint-only != 'true' + && github.event.inputs.mypy-only != 'true' + && github.event.inputs.audit-licenses-only != 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Run zizmor + uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 + with: + extra-args: --all-files zizmor + lint-hadolint: name: Check ${{ matrix.file }} runs-on: ubuntu-24.04 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 17dd38d51c0e89..79f9f5fd540919 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,12 @@ repos: - --quiet-level=2 exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.22.0 + hooks: + - id: zizmor + args: + - --pedantic - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 37b16e39fd1132..4ea4d8f07f26cb 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -3,3 +3,4 @@ codespell==2.4.1 ruff==0.15.1 yamllint==1.37.1 +zizmor==1.22.0 From 6e48172654b3d872a251a50683b35b595c918f68 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:21:25 +0100 Subject: [PATCH 26/31] Improve typing in HTML5 webpush integration (#163162) --- homeassistant/components/html5/notify.py | 144 +++++++++++++++-------- 1 file changed, 95 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index f38ff0dce1d779..ff3354e5c77762 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -9,10 +9,11 @@ import json import logging import time -from typing import Any +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast from urllib.parse import urlparse import uuid +from aiohttp import web from aiohttp.hdrs import AUTHORIZATION import jwt from py_vapid import Vapid @@ -29,14 +30,15 @@ ATTR_TITLE_DEFAULT, BaseNotificationService, ) +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.const import ATTR_NAME, URL_ROOT -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string -from homeassistant.util.json import JsonObjectType, load_json_object +from homeassistant.util.json import load_json_object from .const import ( ATTR_VAPID_EMAIL, @@ -146,6 +148,29 @@ ) +class Keys(TypedDict): + """Types for keys.""" + + p256dh: str + auth: str + + +class Subscription(TypedDict): + """Types for subscription.""" + + endpoint: str + expirationTime: int | None + keys: Keys + + +class Registration(TypedDict): + """Types for registration.""" + + subscription: Subscription + browser: str + name: NotRequired[str] + + async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -161,11 +186,14 @@ async def async_get_service( registrations = await hass.async_add_executor_job(_load_config, json_path) - vapid_pub_key = discovery_info[ATTR_VAPID_PUB_KEY] - vapid_prv_key = discovery_info[ATTR_VAPID_PRV_KEY] - vapid_email = discovery_info[ATTR_VAPID_EMAIL] + vapid_pub_key: str = discovery_info[ATTR_VAPID_PUB_KEY] + vapid_prv_key: str = discovery_info[ATTR_VAPID_PRV_KEY] + vapid_email: str = discovery_info[ATTR_VAPID_EMAIL] - def websocket_appkey(_hass, connection, msg): + @callback + def websocket_appkey( + _hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ) -> None: connection.send_message(websocket_api.result_message(msg["id"], vapid_pub_key)) websocket_api.async_register_command( @@ -180,10 +208,10 @@ def websocket_appkey(_hass, connection, msg): ) -def _load_config(filename: str) -> JsonObjectType: +def _load_config(filename: str) -> dict[str, Registration]: """Load configuration.""" with suppress(HomeAssistantError): - return load_json_object(filename) + return cast(dict[str, Registration], load_json_object(filename)) return {} @@ -193,19 +221,20 @@ class HTML5PushRegistrationView(HomeAssistantView): url = "/api/notify.html5" name = "api:notify.html5" - def __init__(self, registrations, json_path): + def __init__(self, registrations: dict[str, Registration], json_path: str) -> None: """Init HTML5PushRegistrationView.""" self.registrations = registrations self.json_path = json_path - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Accept the POST request for push registrations from a browser.""" + try: - data = await request.json() + data: Registration = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) try: - data = REGISTER_SCHEMA(data) + data = cast(Registration, REGISTER_SCHEMA(data)) except vol.Invalid as ex: return self.json_message(humanize_error(data, ex), HTTPStatus.BAD_REQUEST) @@ -234,28 +263,32 @@ async def post(self, request): "Error saving registration.", HTTPStatus.INTERNAL_SERVER_ERROR ) - def find_registration_name(self, data, suggested=None): + def find_registration_name( + self, + data: Registration, + suggested: str | None = None, + ): """Find a registration name matching data or generate a unique one.""" - endpoint = data.get(ATTR_SUBSCRIPTION).get(ATTR_ENDPOINT) + endpoint = data["subscription"]["endpoint"] for key, registration in self.registrations.items(): - subscription = registration.get(ATTR_SUBSCRIPTION) + subscription = registration["subscription"] if subscription.get(ATTR_ENDPOINT) == endpoint: return key return ensure_unique_string(suggested or "unnamed device", self.registrations) - async def delete(self, request): + async def delete(self, request: web.Request): """Delete a registration.""" try: - data = await request.json() + data: dict[str, Any] = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) - subscription = data.get(ATTR_SUBSCRIPTION) + subscription: dict[str, Any] = data[ATTR_SUBSCRIPTION] found = None for key, registration in self.registrations.items(): - if registration.get(ATTR_SUBSCRIPTION) == subscription: + if registration["subscription"] == subscription: found = key break @@ -287,11 +320,11 @@ class HTML5PushCallbackView(HomeAssistantView): url = "/api/notify.html5/callback" name = "api:notify.html5/callback" - def __init__(self, registrations): + def __init__(self, registrations: dict[str, Registration]) -> None: """Init HTML5PushCallbackView.""" self.registrations = registrations - def decode_jwt(self, token): + def decode_jwt(self, token: str) -> web.Response | dict[str, Any]: """Find the registration that signed this JWT and return it.""" # 1. Check claims w/o verifying to see if a target is in there. @@ -299,12 +332,12 @@ def decode_jwt(self, token): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode( + target_check: dict[str, Any] = jwt.decode( token, algorithms=["ES256", "HS256"], options={"verify_signature": False} ) if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] - key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] + key = possible_target["subscription"]["keys"]["auth"] with suppress(jwt.exceptions.DecodeError): return jwt.decode(token, key, algorithms=["ES256", "HS256"]) @@ -314,7 +347,9 @@ def decode_jwt(self, token): # The following is based on code from Auth0 # https://auth0.com/docs/quickstart/backend/python - def check_authorization_header(self, request): + def check_authorization_header( + self, request: web.Request + ) -> web.Response | dict[str, Any]: """Check the authorization header.""" if not (auth := request.headers.get(AUTHORIZATION)): return self.json_message( @@ -343,18 +378,18 @@ def check_authorization_header(self, request): ) return payload - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Accept the POST request for push registrations event callback.""" auth_check = self.check_authorization_header(request) if not isinstance(auth_check, dict): return auth_check try: - data = await request.json() + data: dict[str, str] = await request.json() except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) - event_payload = { + event_payload: dict[str, Any] = { ATTR_TAG: data.get(ATTR_TAG), ATTR_TYPE: data[ATTR_TYPE], ATTR_TARGET: auth_check[ATTR_TARGET], @@ -382,7 +417,14 @@ async def post(self, request): class HTML5NotificationService(BaseNotificationService): """Implement the notification service for HTML5.""" - def __init__(self, hass, vapid_prv, vapid_email, registrations, json_path): + def __init__( + self, + hass: HomeAssistant, + vapid_prv: str, + vapid_email: str, + registrations: dict[str, Registration], + json_path: str, + ) -> None: """Initialize the service.""" self._vapid_prv = vapid_prv self._vapid_email = vapid_email @@ -391,7 +433,7 @@ def __init__(self, hass, vapid_prv, vapid_email, registrations, json_path): async def async_dismiss_message(service: ServiceCall) -> None: """Handle dismissing notification message service calls.""" - kwargs = {} + kwargs: dict[str, Any] = {} if self.targets is not None: kwargs[ATTR_TARGET] = self.targets @@ -410,19 +452,19 @@ async def async_dismiss_message(service: ServiceCall) -> None: ) @property - def targets(self): + def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" return {registration: registration for registration in self.registrations} - def dismiss(self, **kwargs): + def dismiss(self, **kwargs: Any) -> None: """Dismisses a notification.""" - data = kwargs.get(ATTR_DATA) - tag = data.get(ATTR_TAG) if data else "" + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) + tag: str = data.get(ATTR_TAG, "") if data else "" payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} self._push_message(payload, **kwargs) - async def async_dismiss(self, **kwargs): + async def async_dismiss(self, **kwargs) -> None: """Dismisses a notification. This method must be run in the event loop. @@ -432,7 +474,7 @@ async def async_dismiss(self, **kwargs): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" tag = str(uuid.uuid4()) - payload = { + payload: dict[str, Any] = { "badge": "/static/images/notification-badge.png", "body": message, ATTR_DATA: {}, @@ -440,12 +482,12 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: ATTR_TAG: tag, ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), } - - if data := kwargs.get(ATTR_DATA): + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) + if data: # Pick out fields that should go into the notification directly vs # into the notification data dictionary. - data_tmp = {} + data_tmp: dict[str, Any] = {} for key, val in data.items(): if key in HTML5_SHOWNOTIFICATION_PARAMETERS: @@ -463,12 +505,12 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: self._push_message(payload, **kwargs) - def _push_message(self, payload, **kwargs): + def _push_message(self, payload: dict[str, Any], **kwargs: Any) -> None: """Send the message.""" timestamp = int(time.time()) ttl = int(kwargs.get(ATTR_TTL, DEFAULT_TTL)) - priority = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY) + priority: str = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY) if priority not in ["normal", "high"]: priority = DEFAULT_PRIORITY payload["timestamp"] = timestamp * 1000 # Javascript ms since epoch @@ -479,22 +521,23 @@ def _push_message(self, payload, **kwargs): for target in list(targets): info = self.registrations.get(target) try: - info = REGISTER_SCHEMA(info) + info = cast(Registration, REGISTER_SCHEMA(info)) except vol.Invalid: _LOGGER.error( "%s is not a valid HTML5 push notification target", target ) continue - subscription = info[ATTR_SUBSCRIPTION] + subscription = info["subscription"] payload[ATTR_DATA][ATTR_JWT] = add_jwt( timestamp, target, payload[ATTR_TAG], - subscription[ATTR_KEYS][ATTR_AUTH], + subscription["keys"]["auth"], ) - webpusher = WebPusher(info[ATTR_SUBSCRIPTION]) - endpoint = urlparse(subscription[ATTR_ENDPOINT]) + webpusher = WebPusher(cast(dict[str, Any], info["subscription"])) + + endpoint = urlparse(subscription["endpoint"]) vapid_claims = { "sub": f"mailto:{self._vapid_email}", "aud": f"{endpoint.scheme}://{endpoint.netloc}", @@ -506,7 +549,10 @@ def _push_message(self, payload, **kwargs): data=json.dumps(payload), headers=vapid_headers, ttl=ttl ) - if response.status_code == 410: + if TYPE_CHECKING: + assert not isinstance(response, str) + + if response.status_code == HTTPStatus.GONE: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) try: @@ -516,7 +562,7 @@ def _push_message(self, payload, **kwargs): _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") - elif response.status_code > 399: + elif response.status_code >= HTTPStatus.BAD_REQUEST: _LOGGER.error( "There was an issue sending the notification %s: %s", response.status_code, @@ -524,7 +570,7 @@ def _push_message(self, payload, **kwargs): ) -def add_jwt(timestamp, target, tag, jwt_secret): +def add_jwt(timestamp: int, target: str, tag: str, jwt_secret: str) -> str: """Create JWT json to put into payload.""" jwt_exp = datetime.fromtimestamp(timestamp) + timedelta(days=JWT_VALID_DAYS) From 8a5d5a84681d06acf1cfab31d25388ece2c3c365 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Feb 2026 15:25:08 +0100 Subject: [PATCH 27/31] Fix flaky fritz update tests caused by class attribute pollution in test fixtures (#163169) --- tests/components/fritz/conftest.py | 64 ++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index cf3341acc4e68b..211da1cf605270 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -105,32 +105,52 @@ def fc_class_mock(fc_data): @pytest.fixture def fh_class_mock(): """Fixture that sets up a mocked FritzHosts class.""" - with patch( - "homeassistant.components.fritz.coordinator.FritzHosts", - new=FritzHosts, - ) as result: - result.get_mesh_topology = MagicMock(return_value=MOCK_MESH_DATA) - result.get_hosts_attributes = MagicMock(return_value=MOCK_HOST_ATTRIBUTES_DATA) + with ( + patch( + "homeassistant.components.fritz.coordinator.FritzHosts", + new=FritzHosts, + ) as result, + patch.object( + FritzHosts, + "get_mesh_topology", + MagicMock(return_value=MOCK_MESH_DATA), + ), + patch.object( + FritzHosts, + "get_hosts_attributes", + MagicMock(return_value=MOCK_HOST_ATTRIBUTES_DATA), + ), + ): yield result @pytest.fixture def fs_class_mock(): """Fixture that sets up a mocked FritzStatus class.""" - with patch( - "homeassistant.components.fritz.coordinator.FritzStatus", - new=FritzStatus, - ) as result: - result.get_default_connection_service = MagicMock( - return_value=MOCK_STATUS_CONNECTION_DATA - ) - result.get_device_info = MagicMock( - return_value=ArgumentNamespace(MOCK_STATUS_DEVICE_INFO_DATA) - ) - result.get_monitor_data = MagicMock(return_value={}) - result.get_cpu_temperatures = MagicMock(return_value=[42, 38]) - result.get_avm_device_log = MagicMock( - return_value=MOCK_STATUS_AVM_DEVICE_LOG_DATA - ) - result.has_wan_enabled = True + with ( + patch( + "homeassistant.components.fritz.coordinator.FritzStatus", + new=FritzStatus, + ) as result, + patch.object( + FritzStatus, + "get_default_connection_service", + MagicMock(return_value=MOCK_STATUS_CONNECTION_DATA), + ), + patch.object( + FritzStatus, + "get_device_info", + MagicMock(return_value=ArgumentNamespace(MOCK_STATUS_DEVICE_INFO_DATA)), + ), + patch.object(FritzStatus, "get_monitor_data", MagicMock(return_value={})), + patch.object( + FritzStatus, "get_cpu_temperatures", MagicMock(return_value=[42, 38]) + ), + patch.object( + FritzStatus, + "get_avm_device_log", + MagicMock(return_value=MOCK_STATUS_AVM_DEVICE_LOG_DATA), + ), + patch.object(FritzStatus, "has_wan_enabled", True), + ): yield result From 46a1dda8d86eb4f02629e27c2d0fd87af0d15ba0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 16 Feb 2026 15:50:50 +0100 Subject: [PATCH 28/31] Fix CI partial run glob expansion without reintroducing template injection (#163170) --- .github/workflows/ci.yaml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 78dd9472af02fd..f647dd0460241c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -169,9 +169,8 @@ jobs: if [[ "${INTEGRATION_CHANGES}" != "[]" ]]; then - # Create a file glob for the integrations - integrations_glob=$(echo "${INTEGRATION_CHANGES}" | jq -cSr '. | join(",")') - [[ "${integrations_glob}" == *","* ]] && integrations_glob="{${integrations_glob}}" + # Create a space-separated list of integrations + integrations_glob=$(echo "${INTEGRATION_CHANGES}" | jq -r '. | join(" ")') # Create list of testable integrations possible_integrations=$(echo "${INTEGRATION_CHANGES}" | jq -cSr '.[]') @@ -190,9 +189,8 @@ jobs: # Test group count should be 1, we don't split partial tests test_group_count=1 - # Create a file glob for the integrations tests - tests_glob=$(echo "${tests}" | jq -cSr '. | join(",")') - [[ "${tests_glob}" == *","* ]] && tests_glob="{${tests_glob}}" + # Create a space-separated list of test integrations + tests_glob=$(echo "${tests}" | jq -r '. | join(" ")') mariadb_groups="[]" postgresql_groups="[]" @@ -716,7 +714,7 @@ jobs: run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y homeassistant/components/${INTEGRATIONS_GLOB} + pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB}) pylint-tests: name: Check pylint on tests @@ -769,7 +767,7 @@ jobs: run: | . venv/bin/activate python --version - pylint tests/components/${TESTS_GLOB} + pylint $(printf "tests/components/%s " ${TESTS_GLOB}) mypy: name: Check mypy @@ -837,7 +835,7 @@ jobs: run: | . venv/bin/activate python --version - mypy homeassistant/components/${INTEGRATIONS_GLOB} + mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB}) prepare-pytest-full: name: Split tests for full run From 8d228b6e6a3c026d092c05172f4e362523ce105e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 17 Feb 2026 00:57:05 +1000 Subject: [PATCH 29/31] Add battery health sensors to Tessie (#162908) Co-authored-by: Claude Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/tessie/__init__.py | 49 ++- .../components/tessie/coordinator.py | 44 +- .../components/tessie/diagnostics.py | 2 +- homeassistant/components/tessie/entity.py | 19 + homeassistant/components/tessie/icons.json | 21 + homeassistant/components/tessie/models.py | 2 + homeassistant/components/tessie/sensor.py | 84 ++++ homeassistant/components/tessie/strings.json | 21 + tests/components/tessie/common.py | 1 + tests/components/tessie/conftest.py | 17 + tests/components/tessie/fixtures/battery.json | 13 + .../tessie/snapshots/test_diagnostics.ambr | 13 + .../tessie/snapshots/test_sensor.ambr | 398 ++++++++++++++++++ tests/components/tessie/test_coordinator.py | 45 ++ tests/components/tessie/test_init.py | 22 + 15 files changed, 743 insertions(+), 8 deletions(-) create mode 100644 tests/components/tessie/fixtures/battery.json diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 41096ad167e4ff..d2349fc4572f42 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -13,17 +13,22 @@ TeslaFleetError, ) from tesla_fleet_api.tessie import Tessie -from tessie_api import get_state_of_all_vehicles +from tessie_api import get_battery, get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, MODELS from .coordinator import ( + TessieBatteryHealthCoordinator, TessieEnergyHistoryCoordinator, TessieEnergySiteInfoCoordinator, TessieEnergySiteLiveCoordinator, @@ -65,8 +70,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from e - _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) - return False + raise ConfigEntryError("Setup failed, unable to connect to Tessie") from e + except ClientError as e: + raise ConfigEntryNotReady from e + + try: + batteries = await asyncio.gather( + *( + get_battery( + session=session, + api_key=api_key, + vin=vehicle["vin"], + ) + for vehicle in state_of_all_vehicles["results"] + if vehicle["last_state"] is not None + ) + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + raise ConfigEntryError("Setup failed, unable to get battery data") from e except ClientError as e: raise ConfigEntryNotReady from e @@ -80,6 +103,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo vin=vehicle["vin"], data=vehicle["last_state"], ), + battery_coordinator=TessieBatteryHealthCoordinator( + hass, + entry, + api_key=api_key, + vin=vehicle["vin"], + data=battery, + ), device=DeviceInfo( identifiers={(DOMAIN, vehicle["vin"])}, manufacturer="Tesla", @@ -96,8 +126,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo serial_number=vehicle["vin"], ), ) - for vehicle in state_of_all_vehicles["results"] - if vehicle["last_state"] is not None + for vehicle, battery in zip( + ( + v + for v in state_of_all_vehicles["results"] + if v["last_state"] is not None + ), + batteries, + strict=True, + ) ] # Energy Sites diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index ff2b7ff78d76cd..bb9f2a6373444e 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -11,7 +11,7 @@ from tesla_fleet_api.const import TeslaEnergyPeriod from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError from tesla_fleet_api.tessie import EnergySite -from tessie_api import get_state, get_status +from tessie_api import get_battery, get_state, get_status from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -99,6 +99,48 @@ async def _async_update_data(self) -> dict[str, Any]: return flatten(vehicle) +class TessieBatteryHealthCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching battery health data from the Tessie API.""" + + config_entry: TessieConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TessieConfigEntry, + api_key: str, + vin: str, + data: dict[str, Any], + ) -> None: + """Initialize Tessie Battery Health coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Tessie Battery Health", + update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), + ) + self.api_key = api_key + self.vin = vin + self.session = async_get_clientsession(hass) + self.data = data + + async def _async_update_data(self) -> dict[str, Any]: + """Update battery health data using Tessie API.""" + try: + data = await get_battery( + session=self.session, + api_key=self.api_key, + vin=self.vin, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + raise UpdateFailed from e + + return data + + class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Tessie API.""" diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py index 21fc208612d779..6d2daaccf78acc 100644 --- a/homeassistant/components/tessie/diagnostics.py +++ b/homeassistant/components/tessie/diagnostics.py @@ -35,7 +35,7 @@ async def async_get_config_entry_diagnostics( vehicles = [ { "data": async_redact_data(x.data_coordinator.data, VEHICLE_REDACT), - # Battery diag will go here when implemented + "battery": x.battery_coordinator.data, } for x in entry.runtime_data.vehicles ] diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index d4dec969f1cf4e..a717fa5f06a4c4 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -12,6 +12,7 @@ from .const import DOMAIN, TRANSLATED_ERRORS from .coordinator import ( + TessieBatteryHealthCoordinator, TessieEnergyHistoryCoordinator, TessieEnergySiteInfoCoordinator, TessieEnergySiteLiveCoordinator, @@ -23,6 +24,7 @@ class TessieBaseEntity( CoordinatorEntity[ TessieStateUpdateCoordinator + | TessieBatteryHealthCoordinator | TessieEnergySiteInfoCoordinator | TessieEnergySiteLiveCoordinator | TessieEnergyHistoryCoordinator @@ -35,6 +37,7 @@ class TessieBaseEntity( def __init__( self, coordinator: TessieStateUpdateCoordinator + | TessieBatteryHealthCoordinator | TessieEnergySiteInfoCoordinator | TessieEnergySiteLiveCoordinator | TessieEnergyHistoryCoordinator, @@ -139,6 +142,22 @@ def __init__( super().__init__(coordinator, key) +class TessieBatteryEntity(TessieBaseEntity): + """Parent class for Tessie battery health entities.""" + + def __init__( + self, + vehicle: TessieVehicleData, + key: str, + ) -> None: + """Initialize common aspects of a Tessie battery health entity.""" + self.vin = vehicle.vin + self._attr_unique_id = f"{vehicle.vin}-{key}" + self._attr_device_info = vehicle.device + + super().__init__(vehicle.battery_coordinator, key) + + class TessieEnergyHistoryEntity(TessieBaseEntity): """Parent class for Tessie energy site history entities.""" diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json index 5a67cdffb5fb2b..917cb258fd90cd 100644 --- a/homeassistant/components/tessie/icons.json +++ b/homeassistant/components/tessie/icons.json @@ -211,6 +211,9 @@ "energy_left": { "default": "mdi:battery" }, + "energy_remaining": { + "default": "mdi:battery-medium" + }, "generator_power": { "default": "mdi:generator-stationary" }, @@ -220,9 +223,27 @@ "grid_services_power": { "default": "mdi:transmission-tower" }, + "lifetime_energy_used": { + "default": "mdi:battery-heart-variant" + }, "load_power": { "default": "mdi:power-plug" }, + "module_temp_max": { + "default": "mdi:thermometer-high" + }, + "module_temp_min": { + "default": "mdi:thermometer-low" + }, + "pack_current": { + "default": "mdi:current-dc" + }, + "pack_voltage": { + "default": "mdi:lightning-bolt" + }, + "phantom_drain_percent": { + "default": "mdi:battery-minus-outline" + }, "solar_power": { "default": "mdi:solar-power" }, diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index e4e4bb34e81a46..7302071693b888 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -9,6 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( + TessieBatteryHealthCoordinator, TessieEnergyHistoryCoordinator, TessieEnergySiteInfoCoordinator, TessieEnergySiteLiveCoordinator, @@ -41,5 +42,6 @@ class TessieVehicleData: """Data for a Tessie vehicle.""" data_coordinator: TessieStateUpdateCoordinator + battery_coordinator: TessieBatteryHealthCoordinator device: DeviceInfo vin: str diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 54b8031197df04..f512c1eeaaf628 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -36,6 +36,7 @@ from . import TessieConfigEntry from .const import ENERGY_HISTORY_FIELDS, TessieChargeStates, TessieWallConnectorStates from .entity import ( + TessieBatteryEntity, TessieEnergyEntity, TessieEnergyHistoryEntity, TessieEntity, @@ -272,6 +273,64 @@ class TessieSensorEntityDescription(SensorEntityDescription): ) +BATTERY_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="phantom_drain_percent", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + ), + TessieSensorEntityDescription( + key="energy_remaining", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="lifetime_energy_used", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="pack_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="pack_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="module_temp_min", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="module_temp_max", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + ), +) + ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -425,6 +484,12 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in DESCRIPTIONS ), + ( # Add vehicle battery health + TessieBatteryHealthSensorEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in BATTERY_DESCRIPTIONS + if description.key in vehicle.battery_coordinator.data + ), ( # Add energy site info TessieEnergyInfoSensorEntity(energysite, description) for energysite in entry.runtime_data.energysites @@ -483,6 +548,25 @@ def available(self) -> bool: return super().available and self.entity_description.available_fn(self.get()) +class TessieBatteryHealthSensorEntity(TessieBatteryEntity, SensorEntity): + """Sensor entity for Tessie battery health data.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + vehicle: TessieVehicleData, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(vehicle, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_native_value = self.entity_description.value_fn(self._value) + + class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): """Base class for Tessie energy site sensor entity.""" diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index c2f2a719397136..35f22ac301ae4b 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -447,6 +447,9 @@ "energy_left": { "name": "Energy left" }, + "energy_remaining": { + "name": "Energy remaining" + }, "generator_energy_exported": { "name": "Generator exported" }, @@ -487,12 +490,30 @@ "on_grid": "On-grid" } }, + "lifetime_energy_used": { + "name": "Lifetime energy used" + }, "load_power": { "name": "Load power" }, + "module_temp_max": { + "name": "Battery module temperature max" + }, + "module_temp_min": { + "name": "Battery module temperature min" + }, + "pack_current": { + "name": "Battery pack current" + }, + "pack_voltage": { + "name": "Battery pack voltage" + }, "percentage_charged": { "name": "Percentage charged" }, + "phantom_drain_percent": { + "name": "Phantom drain" + }, "solar_energy_exported": { "name": "Solar exported" }, diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index f1b1d8c1ba0558..81f9bb97d9f635 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -19,6 +19,7 @@ # Tessie library TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) +TEST_VEHICLE_BATTERY = load_json_object_fixture("battery.json", DOMAIN) TEST_VEHICLE_STATUS_AWAKE = {"status": TessieStatus.AWAKE} TEST_VEHICLE_STATUS_ASLEEP = {"status": TessieStatus.ASLEEP} diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index 47a86c8b11f5e9..217b4d1215c5cf 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -15,6 +15,7 @@ SCOPES, SITE_INFO, TEST_STATE_OF_ALL_VEHICLES, + TEST_VEHICLE_BATTERY, TEST_VEHICLE_STATE_ONLINE, TEST_VEHICLE_STATUS_AWAKE, ) @@ -42,6 +43,22 @@ def mock_get_status(): yield mock_get_status +@pytest.fixture(autouse=True) +def mock_get_battery(): + """Mock get_battery function.""" + with ( + patch( + "homeassistant.components.tessie.get_battery", + return_value=TEST_VEHICLE_BATTERY, + ) as mock_get_battery, + patch( + "homeassistant.components.tessie.coordinator.get_battery", + new=mock_get_battery, + ), + ): + yield mock_get_battery + + @pytest.fixture(autouse=True) def mock_get_state_of_all_vehicles(): """Mock get_state_of_all_vehicles function.""" diff --git a/tests/components/tessie/fixtures/battery.json b/tests/components/tessie/fixtures/battery.json new file mode 100644 index 00000000000000..6acec073c7e140 --- /dev/null +++ b/tests/components/tessie/fixtures/battery.json @@ -0,0 +1,13 @@ +{ + "timestamp": 1704067200, + "battery_level": 73, + "battery_range": 250.5, + "ideal_battery_range": 280.2, + "phantom_drain_percent": 0.5, + "energy_remaining": 55.2, + "lifetime_energy_used": 12345.6, + "pack_current": -0.6, + "pack_voltage": 390.1, + "module_temp_min": 22.5, + "module_temp_max": 24 +} diff --git a/tests/components/tessie/snapshots/test_diagnostics.ambr b/tests/components/tessie/snapshots/test_diagnostics.ambr index d89f035e3d7ffc..9411e86007cc92 100644 --- a/tests/components/tessie/snapshots/test_diagnostics.ambr +++ b/tests/components/tessie/snapshots/test_diagnostics.ambr @@ -161,6 +161,19 @@ ]), 'vehicles': list([ dict({ + 'battery': dict({ + 'battery_level': 73, + 'battery_range': 250.5, + 'energy_remaining': 55.2, + 'ideal_battery_range': 280.2, + 'lifetime_energy_used': 12345.6, + 'module_temp_max': 24, + 'module_temp_min': 22.5, + 'pack_current': -0.6, + 'pack_voltage': 390.1, + 'phantom_drain_percent': 0.5, + 'timestamp': 1704067200, + }), 'data': dict({ 'access_type': 'OWNER', 'api_version': 67, diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index df39cde725301b..1bac7c86372aee 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1986,6 +1986,234 @@ 'state': '75', }) # --- +# name: test_sensors[sensor.test_battery_module_temperature_max-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_battery_module_temperature_max', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery module temperature max', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery module temperature max', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'module_temp_max', + 'unique_id': 'VINVINVIN-module_temp_max', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_battery_module_temperature_max-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Battery module temperature max', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_battery_module_temperature_max', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensors[sensor.test_battery_module_temperature_min-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_battery_module_temperature_min', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery module temperature min', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery module temperature min', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'module_temp_min', + 'unique_id': 'VINVINVIN-module_temp_min', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_battery_module_temperature_min-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Battery module temperature min', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_battery_module_temperature_min', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensors[sensor.test_battery_pack_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_battery_pack_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery pack current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery pack current', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pack_current', + 'unique_id': 'VINVINVIN-pack_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_battery_pack_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Battery pack current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_battery_pack_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.6', + }) +# --- +# name: test_sensors[sensor.test_battery_pack_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_battery_pack_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery pack voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery pack voltage', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pack_voltage', + 'unique_id': 'VINVINVIN-pack_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_battery_pack_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test Battery pack voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_battery_pack_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '390.1', + }) +# --- # name: test_sensors[sensor.test_battery_range-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2744,6 +2972,63 @@ 'state': '46.92', }) # --- +# name: test_sensors[sensor.test_energy_remaining_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_energy_remaining_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy remaining', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy remaining', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_remaining', + 'unique_id': 'VINVINVIN-energy_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_energy_remaining_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Test Energy remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_energy_remaining_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.2', + }) +# --- # name: test_sensors[sensor.test_inside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2801,6 +3086,63 @@ 'state': '30.4', }) # --- +# name: test_sensors[sensor.test_lifetime_energy_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_lifetime_energy_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy used', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy used', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy_used', + 'unique_id': 'VINVINVIN-lifetime_energy_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_lifetime_energy_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Lifetime energy used', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_lifetime_energy_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12345.6', + }) +# --- # name: test_sensors[sensor.test_odometer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2975,6 +3317,62 @@ 'state': '22.5', }) # --- +# name: test_sensors[sensor.test_phantom_drain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_phantom_drain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Phantom drain', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Phantom drain', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phantom_drain_percent', + 'unique_id': 'VINVINVIN-phantom_drain_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_phantom_drain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Phantom drain', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_phantom_drain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- # name: test_sensors[sensor.test_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py index 44f82a7fb8e521..414de14753ef75 100644 --- a/tests/components/tessie/test_coordinator.py +++ b/tests/components/tessie/test_coordinator.py @@ -102,6 +102,51 @@ async def test_coordinator_connection( assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE +async def test_coordinator_battery_update( + hass: HomeAssistant, mock_get_battery, freezer: FrozenDateTimeFactory +) -> None: + """Tests that the battery coordinator handles updates.""" + + await setup_platform(hass, [Platform.SENSOR]) + + mock_get_battery.reset_mock() + freezer.tick(WAIT) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_get_battery.assert_called_once() + + +async def test_coordinator_battery_auth( + hass: HomeAssistant, mock_get_battery, freezer: FrozenDateTimeFactory +) -> None: + """Tests that the battery coordinator handles auth errors.""" + + await setup_platform(hass, [Platform.SENSOR]) + + mock_get_battery.reset_mock() + mock_get_battery.side_effect = ERROR_AUTH + freezer.tick(WAIT) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_get_battery.assert_called_once() + + +async def test_coordinator_battery_error( + hass: HomeAssistant, mock_get_battery, freezer: FrozenDateTimeFactory +) -> None: + """Tests that the battery coordinator handles client errors.""" + + await setup_platform(hass, [Platform.SENSOR]) + + mock_get_battery.reset_mock() + mock_get_battery.side_effect = ERROR_UNKNOWN + freezer.tick(WAIT) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_get_battery.assert_called_once() + assert hass.states.get("sensor.test_phantom_drain").state == STATE_UNAVAILABLE + + async def test_coordinator_live_error( hass: HomeAssistant, mock_live_status, freezer: FrozenDateTimeFactory ) -> None: diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 921ef93b1ae241..3e546bd63affa8 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from tesla_fleet_api.exceptions import TeslaFleetError from homeassistant.config_entries import ConfigEntryState @@ -50,6 +51,27 @@ async def test_connection_failure( assert entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (ERROR_AUTH, ConfigEntryState.SETUP_ERROR), + (ERROR_UNKNOWN, ConfigEntryState.SETUP_ERROR), + (ERROR_CONNECTION, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_battery_setup_failure( + hass: HomeAssistant, + mock_get_battery, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test init with a battery API error.""" + + mock_get_battery.side_effect = side_effect + entry = await setup_platform(hass) + assert entry.state is expected_state + + async def test_products_error(hass: HomeAssistant) -> None: """Test init with a fleet error on products.""" From be31f01fc2abe63d96e9935f9679607e68222c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 16 Feb 2026 16:21:15 +0100 Subject: [PATCH 30/31] Homevolt quality scale (#163038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/homevolt/manifest.json | 2 +- homeassistant/components/homevolt/quality_scale.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homevolt/manifest.json b/homeassistant/components/homevolt/manifest.json index 93e0ad3f56d789..c12fc9c69ed2b8 100644 --- a/homeassistant/components/homevolt/manifest.json +++ b/homeassistant/components/homevolt/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/homevolt", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["homevolt==0.4.4"], "zeroconf": [ { diff --git a/homeassistant/components/homevolt/quality_scale.yaml b/homeassistant/components/homevolt/quality_scale.yaml index 3e59352ce66c2f..a924f0a8a86a8a 100644 --- a/homeassistant/components/homevolt/quality_scale.yaml +++ b/homeassistant/components/homevolt/quality_scale.yaml @@ -33,13 +33,13 @@ rules: docs-configuration-parameters: status: exempt comment: Integration does not have an options flow. - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done From 7ab4f2f4310c0d9806e98bc87cff46877fa76bcd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:21:29 +0100 Subject: [PATCH 31/31] Use HassKey in usb (#163138) --- homeassistant/components/usb/__init__.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 3c154e2887bee0..ec726bba460667 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -29,6 +29,7 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .models import USBDevice @@ -42,6 +43,7 @@ ) _LOGGER = logging.getLogger(__name__) +_USB_DATA: HassKey[USBDiscovery] = HassKey(DOMAIN) PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] @@ -67,8 +69,7 @@ def async_register_scan_request_callback( hass: HomeAssistant, callback: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Register to receive a callback when a scan should be initiated.""" - discovery: USBDiscovery = hass.data[DOMAIN] - return discovery.async_register_scan_request_callback(callback) + return hass.data[_USB_DATA].async_register_scan_request_callback(callback) @hass_callback @@ -79,8 +80,7 @@ def async_register_initial_scan_callback( If the initial scan is already done, the callback is called immediately. """ - discovery: USBDiscovery = hass.data[DOMAIN] - return discovery.async_register_initial_scan_callback(callback) + return hass.data[_USB_DATA].async_register_initial_scan_callback(callback) @hass_callback @@ -88,8 +88,7 @@ def async_register_port_event_callback( hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE ) -> CALLBACK_TYPE: """Register to receive a callback when a USB device is connected or disconnected.""" - discovery: USBDiscovery = hass.data[DOMAIN] - return discovery.async_register_port_event_callback(callback) + return hass.data[_USB_DATA].async_register_port_event_callback(callback) @hass_callback @@ -97,8 +96,7 @@ def async_get_usb_matchers_for_device( hass: HomeAssistant, device: USBDevice ) -> list[USBMatcher]: """Return a list of matchers that match the given device.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] - return usb_discovery.async_get_usb_matchers_for_device(device) + return hass.data[_USB_DATA].async_get_usb_matchers_for_device(device) @overload @@ -159,7 +157,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: usb = await async_get_usb(hass) usb_discovery = USBDiscovery(hass, usb) await usb_discovery.async_setup() - hass.data[DOMAIN] = usb_discovery + hass.data[_USB_DATA] = usb_discovery websocket_api.async_register_command(hass, websocket_usb_scan) return True @@ -167,7 +165,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_request_scan(hass: HomeAssistant) -> None: """Request a USB scan.""" - usb_discovery: USBDiscovery = hass.data[DOMAIN] + usb_discovery = hass.data[_USB_DATA] if not usb_discovery.observer_active: await usb_discovery.async_request_scan()