diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 17898f85ba54c7..9779e09f479727 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,6 +6,7 @@ - Start review comments with a short, one-sentence summary of the suggested fix. - Do not comment on code style, formatting or linting issues. +- When reviewing changes under `homeassistant/components/` or `tests/components/`, use the `ha-integration-knowledge` skill as the primary reference. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. - Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments and Markdown link reference definitions (lines of the form `[name]: url`), which do not render and cannot be verified from the description. Even unchecked checkboxes or empty sections must be present. This is a hard requirement. diff --git a/.github/instructions/integrations.instructions.md b/.github/instructions/integrations.instructions.md deleted file mode 100644 index 420ca7d65b1a5a..00000000000000 --- a/.github/instructions/integrations.instructions.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -applyTo: "homeassistant/components/**, tests/components/**" -excludeAgent: "cloud-agent" ---- - - - - -## File Locations -- **Integration code**: `./homeassistant/components//` -- **Integration tests**: `./tests/components//` - -## General guidelines - -- When looking for examples, prefer integrations with the platinum or gold quality scale level first. -- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries. -- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names. -- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely. -- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast. -- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining. -- Integrations should not implement fixes or workarounds for limitations in libraries. Instead, the library should be updated to fix the issue. - -The following platforms have extra guidelines: -- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection -- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues - -## Entity platforms - -- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`. -- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead. - -## Integration Quality Scale - -- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules -- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices. - -Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml` - -### How Rules Apply -1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level -2. **Bronze Rules**: Always required for any integration with quality scale -3. **Higher Tier Rules**: Only apply if integration targets that tier or higher -4. **Rule Status**: Check `quality_scale.yaml` in integration folder for: - - `done`: Rule implemented - - `exempt`: Rule doesn't apply (with reason in comment) - - `todo`: Rule needs implementation - - -## Testing Requirements - -- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7496fcaffcdfbc..1d9a093a7e652c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,7 +95,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$ + files: ^(AGENTS\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$ - id: hassfest name: hassfest entry: script/run-in-env.sh python3 -m script.hassfest diff --git a/CODEOWNERS b/CODEOWNERS index b1bef3c7924466..955895265893bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1742,8 +1742,8 @@ CLAUDE.md @home-assistant/core /tests/components/smartthings/ @joostlek /homeassistant/components/smarttub/ @mdz /tests/components/smarttub/ @mdz -/homeassistant/components/smarty/ @z0mbieprocess -/tests/components/smarty/ @z0mbieprocess +/homeassistant/components/smarty/ @z0mbieprocess @marcodutto +/tests/components/smarty/ @z0mbieprocess @marcodutto /homeassistant/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST /homeassistant/components/smlight/ @tl-sl diff --git a/homeassistant/components/axle_energy/manifest.json b/homeassistant/components/axle_energy/manifest.json index 0f180300da7f7b..e84c39a084d3ec 100644 --- a/homeassistant/components/axle_energy/manifest.json +++ b/homeassistant/components/axle_energy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["aioaxlevpp==0.1.0"] + "requirements": ["aioaxlevpp==0.1.1"] } diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 6215c88574ffff..b8c350f5aa1b9d 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -14,7 +14,7 @@ STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 7 +STORAGE_VERSION_MINOR = 8 class StoredBackupData(TypedDict): @@ -39,6 +39,8 @@ def __init__(self, hass: HomeAssistant) -> None: STORAGE_KEY, max_readable_version=self._MAX_READABLE_VERSION, minor_version=STORAGE_VERSION_MINOR, + # The store persists the automatic backup password in plaintext. + private=True, ) @override @@ -85,6 +87,9 @@ async def _async_migrate_func( for backup in data["backups"]: backup["failed_addons"] = [] backup["failed_folders"] = [] + # Version 1.8 does not change the data. The minor version was bumped + # so that stores written before the store was made private are + # rewritten once, with mode 0600, on the first load. # Note: We allow reading data with major version 2 in which the unused key # data["config"]["schedule"]["state"] will be removed. The bump to 2 is diff --git a/homeassistant/components/egauge/coordinator.py b/homeassistant/components/egauge/coordinator.py index e6077a692f2be8..956f4335113df4 100644 --- a/homeassistant/components/egauge/coordinator.py +++ b/homeassistant/components/egauge/coordinator.py @@ -98,7 +98,7 @@ async def _async_update_data(self) -> EgaugeData: EgaugeException, ) as err: # will raise ConfigEntryAuthFailed once reauth is implemented - raise ConfigEntryError("Error fetching device info: {err}") from err + raise ConfigEntryError(f"Error fetching device info: {err}") from err except ConnectError as err: raise UpdateFailed(f"Error fetching device info: {err}") from err diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index c1afe9e3b7f4ce..7a1ec4a347e8bf 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -87,28 +87,24 @@ def setup_platform( for account in balance_accounts: if config[CONF_ACCOUNTS] and account.iban not in account_config: - _LOGGER.debug("Skipping account %s for bank %s", account.iban, fints_name) + _LOGGER.debug("Skipping account for bank %s", fints_name) continue if not (account_name := account_config.get(account.iban)): account_name = f"{fints_name} - {account.iban}" accounts.append(FinTsAccount(client, account, account_name)) - _LOGGER.debug("Creating account %s for bank %s", account.iban, fints_name) + _LOGGER.debug("Creating account for bank %s", fints_name) for account in holdings_accounts: if config[CONF_HOLDINGS] and account.accountnumber not in holdings_config: - _LOGGER.debug( - "Skipping holdings %s for bank %s", account.accountnumber, fints_name - ) + _LOGGER.debug("Skipping holdings for bank %s", fints_name) continue account_name = holdings_config.get(account.accountnumber) if not account_name: account_name = f"{fints_name} - {account.accountnumber}" accounts.append(FinTsHoldingsAccount(client, account, account_name)) - _LOGGER.debug( - "Creating holdings %s for bank %s", account.accountnumber, fints_name - ) + _LOGGER.debug("Creating holdings for bank %s", fints_name) add_entities(accounts, True) @@ -216,9 +212,7 @@ def detect_accounts(self) -> tuple[list, list]: else: _LOGGER.warning( - "Could not determine type of account %s from %s", - account.iban, - self.client.user_id, + "Could not determine type of account for bank %s", self.name ) return balance_accounts, holdings_accounts diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 6d555d1f999aa3..70a647227e73d9 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -78,7 +78,7 @@ async def async_step_user( _LOGGER.debug("IPP Connection/Response Error", exc_info=True) return self._show_setup_form({"base": "cannot_connect"}) except IPPParseError: - _LOGGER.debug("IPP Parse Error", exc_info=True) + _LOGGER.warning("IPP Parse Error", exc_info=True) return self.async_abort(reason="parse_error") except IPPVersionNotSupportedError: return self.async_abort(reason="ipp_version_error") @@ -147,7 +147,7 @@ async def async_step_zeroconf( _LOGGER.debug("IPP Connection/Response Error", exc_info=True) return self.async_abort(reason="cannot_connect") except IPPParseError: - _LOGGER.debug("IPP Parse Error", exc_info=True) + _LOGGER.warning("IPP Parse Error", exc_info=True) return self.async_abort(reason="parse_error") except IPPVersionNotSupportedError: return self.async_abort(reason="ipp_version_error") diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index bc69d43c3bc354..4294b20490e75f 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], - "requirements": ["aioipp==0.19.0"], + "requirements": ["aioipp==0.19.3"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 5f89df8c228522..5a690b5af5afdb 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -198,6 +198,11 @@ class FanZeroMode(StrEnum): Platform.WEATHER, } +# read-only platforms raising when added with `EntityCategory.CONFIG` +PLATFORMS_WITHOUT_CONFIG_CATEGORY: Final = frozenset( + {Platform.BINARY_SENSOR, Platform.SENSOR} +) + SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 5a80850e175f38..31e50c7eb9a449 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -48,7 +48,6 @@ Platform, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.util import slugify from .const import ( @@ -78,6 +77,7 @@ from .validation import ( backwards_compatible_xknx_climate_enum_member, dpt_base_type_validator, + entity_category_validator, ga_list_validator, ga_validator, numeric_type_validator, @@ -260,7 +260,9 @@ def _entity_base_schema(platform: Platform) -> probatio.Schema: probatio.Optional(CONF_DEFAULT_ENTITY_ID): probatio.All( cv.entity_id, cv.entity_domain(platform) ), - probatio.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + probatio.Optional(CONF_ENTITY_CATEGORY): entity_category_validator( + platform + ), probatio.Optional(CONF_UNIQUE_ID): probatio.All( cv.string, probatio.Length(min=1) ), diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2b1da794031326..35d4c422a8e705 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION: Final = 2 -STORAGE_VERSION_MINOR: Final = 4 +STORAGE_VERSION_MINOR: Final = 5 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -80,6 +80,10 @@ async def _async_migrate_func( # version 2.4 introduced in 2026.5 migration.migrate_2_3_to_2_4(old_data) + if old_major_version <= 2 and old_minor_version < 5: + # version 2.5 introduced in 2026.10 + migration.migrate_2_4_to_2_5(old_data) + return old_data diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 2616d0a795f811..933faf90375169 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -30,7 +30,6 @@ Platform, ) from homeassistant.helpers import selector -from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.helpers.typing import VolDictType from ..const import ( @@ -54,7 +53,11 @@ SelectConf, ) from ..dpt import get_supported_dpts, raw_payload_length -from ..validation import validate_number_attributes, validate_sensor_attributes +from ..validation import ( + entity_category_validator, + validate_number_attributes, + validate_sensor_attributes, +) from .const import ( CONF_ALWAYS_CALLBACK, CONF_COLOR, @@ -140,30 +143,33 @@ SyncStateSelector, ) -BASE_ENTITY_SCHEMA = probatio.All( - { - probatio.Optional(CONF_NAME, default=None): probatio.Maybe(str), - probatio.Optional(CONF_DEVICE_INFO, default=None): probatio.Maybe(str), - probatio.Optional(CONF_ENTITY_CATEGORY, default=None): probatio.Any( - ENTITY_CATEGORIES_SCHEMA, probatio.SetTo(None) - ), - }, - probatio.Any( - probatio.Schema( - { - probatio.Required(CONF_NAME): probatio.All(str, probatio.IsTrue()), - }, - extra=probatio.ALLOW_EXTRA, - ), - probatio.Schema( - { - probatio.Required(CONF_DEVICE_INFO): str, - }, - extra=probatio.ALLOW_EXTRA, + +def base_entity_schema(platform: Platform) -> probatio.All: + """Return the base entity schema for a platform.""" + return probatio.All( + { + probatio.Optional(CONF_NAME, default=None): probatio.Maybe(str), + probatio.Optional(CONF_DEVICE_INFO, default=None): probatio.Maybe(str), + probatio.Optional( + CONF_ENTITY_CATEGORY, default=None + ): entity_category_validator(platform), + }, + probatio.Any( + probatio.Schema( + { + probatio.Required(CONF_NAME): probatio.All(str, probatio.IsTrue()), + }, + extra=probatio.ALLOW_EXTRA, + ), + probatio.Schema( + { + probatio.Required(CONF_DEVICE_INFO): str, + }, + extra=probatio.ALLOW_EXTRA, + ), + msg="One of `Device` or `Name` is required", ), - msg="One of `Device` or `Name` is required", - ), -) + ) BINARY_SENSOR_KNX_SCHEMA = probatio.Schema( @@ -1045,7 +1051,9 @@ def _sensor_attribute_sub_validator(config: dict) -> dict: { probatio.Required(CONF_DATA): probatio.Schema( { - probatio.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + probatio.Required(CONF_ENTITY): base_entity_schema( + platform + ), probatio.Required(DOMAIN): knx_schema, }, extra=probatio.PREVENT_EXTRA, # restrict in data key for yaml edit diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py index e4c33e319d1630..eaf5b1c071f459 100644 --- a/homeassistant/components/knx/storage/migration.py +++ b/homeassistant/components/knx/storage/migration.py @@ -2,9 +2,9 @@ from typing import Any -from homeassistant.const import Platform +from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory, Platform -from ..const import CONF_RESPOND_TO_READ +from ..const import CONF_RESPOND_TO_READ, PLATFORMS_WITHOUT_CONFIG_CATEGORY from . import const as store_const @@ -60,3 +60,14 @@ def migrate_2_2_to_2_3(data: dict[str, Any]) -> None: def migrate_2_3_to_2_4(data: dict[str, Any]) -> None: """Migrate from schema 2.3 to schema 2.4.""" data.setdefault("expose", {}) + + +def migrate_2_4_to_2_5(data: dict[str, Any]) -> None: + """Migrate from schema 2.4 to schema 2.5.""" + for platform in PLATFORMS_WITHOUT_CONFIG_CATEGORY: + for entity in data.get("entities", {}).get(platform, {}).values(): + # `EntityCategory.CONFIG` is not valid for these platforms + # so these entities were never set up + entity_data = entity[store_const.CONF_ENTITY] + if entity_data.get(CONF_ENTITY_CATEGORY) == EntityCategory.CONFIG: + entity_data[CONF_ENTITY_CATEGORY] = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index dd76188646b398..a7221c05b782a4 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -20,10 +20,15 @@ DEVICE_CLASS_UNITS, STATE_CLASS_UNITS, ) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + EntityCategory, + Platform, +) from homeassistant.helpers import config_validation as cv -from .const import NumberConf +from .const import PLATFORMS_WITHOUT_CONFIG_CATEGORY, NumberConf from .dpt import DPTInfo, get_supported_dpts @@ -51,6 +56,38 @@ def dpt_value_validator(value: Any) -> str | int: sensor_type_validator = probatio.Any(numeric_type_validator, string_type_validator) +def entity_category_validator( + platform: Platform, +) -> Callable[[Any], EntityCategory | None]: + """Validate the entity category is supported by the platform. + + Works for both, UI and YAML configuration schema. + """ + valid_categories = set(EntityCategory) + if platform in PLATFORMS_WITHOUT_CONFIG_CATEGORY: + valid_categories -= {EntityCategory.CONFIG} + + def validate(value: Any) -> EntityCategory | None: + """Validate the entity category.""" + if value is None or value == "": # UI sends an empty value to clear it + return None + try: + entity_category = EntityCategory(value) + except ValueError: + raise probatio.Invalid( + f"'{value}' is not a valid entity category" + ) from None + if entity_category not in valid_categories: + _options = ", ".join(sorted(valid_categories)) + raise probatio.Invalid( + f"Entity category '{entity_category}' is not supported by the" + f" {platform} platform. Valid options are: {_options}" + ) + return entity_category + + return validate + + def ga_validator(value: Any) -> str | int: """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" if not isinstance(value, (str, int)): diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 23c0c6a62d1fa1..3d05a806fd6286 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -34,7 +34,7 @@ async def async_setup_entry( try: await store.async_load() except OSError as err: - raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + raise ConfigEntryNotReady(f"Failed to load file {path}: {err}") from err entry.runtime_data = store diff --git a/homeassistant/components/local_todo/__init__.py b/homeassistant/components/local_todo/__init__.py index d4eaa0de3fc823..c1c87e70f72f74 100644 --- a/homeassistant/components/local_todo/__init__.py +++ b/homeassistant/components/local_todo/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LocalTodoConfigEntry) -> try: await store.async_load() except OSError as err: - raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + raise ConfigEntryNotReady(f"Failed to load file {path}: {err}") from err entry.runtime_data = store diff --git a/homeassistant/components/my_pv/config_flow.py b/homeassistant/components/my_pv/config_flow.py index 78811ab00af3a7..966bfdff2ff1d6 100644 --- a/homeassistant/components/my_pv/config_flow.py +++ b/homeassistant/components/my_pv/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the my-PV integration.""" +from collections.abc import Mapping import logging from typing import Any, Final, override @@ -213,3 +214,49 @@ async def async_step_auth( errors=errors, description_placeholders=self.context["title_placeholders"], ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + if user_input is not None: + user_input = {**reauth_entry.data, **user_input} + host = user_input[CONF_HOST] + password = user_input[CONF_PASSWORD] + + device = MyPVLocalDevice(host, password) + try: + if not await device.connect(): + errors[CONF_BASE] = "cannot_connect" + except MyPVAuthenticationError: + errors[CONF_PASSWORD] = "invalid_password" + finally: + await device.disconnect() + + if not errors: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_mismatch() + data = { + CONF_PASSWORD: password, + } + return self.async_update_reload_and_abort( + reauth_entry, data_updates=data + ) + + data_schema = self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input or {}) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + description_placeholders=self.context["title_placeholders"], + ) diff --git a/homeassistant/components/my_pv/quality_scale.yaml b/homeassistant/components/my_pv/quality_scale.yaml index 4c73587b9000d9..87a42b5e1a2c27 100644 --- a/homeassistant/components/my_pv/quality_scale.yaml +++ b/homeassistant/components/my_pv/quality_scale.yaml @@ -42,7 +42,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: todo - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold tier rules diff --git a/homeassistant/components/my_pv/strings.json b/homeassistant/components/my_pv/strings.json index 26662bfd30604c..28a5a13ba383ff 100644 --- a/homeassistant/components/my_pv/strings.json +++ b/homeassistant/components/my_pv/strings.json @@ -6,7 +6,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unique_id_mismatch": "The device serial number does not match the original device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -36,6 +37,15 @@ "description": "[%key:component::my_pv::common::discovery_description%]", "title": "{name}" }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::my_pv::common::password_description%]" + }, + "title": "{name} password" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -49,7 +59,7 @@ }, "exceptions": { "auth_error": { - "message": "Authentication failed. Remove and add the integration again with the current device password." + "message": "Authentication failed, please reauthenticate." }, "cannot_connect": { "message": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json index 236112f836aaa4..e9c76351fab248 100644 --- a/homeassistant/components/neopool/manifest.json +++ b/homeassistant/components/neopool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["neopool_modbus"], "quality_scale": "platinum", - "requirements": ["neopool-modbus==4.7.0"] + "requirements": ["neopool-modbus==4.8.0"] } diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py index 36b69ef224f5aa..17aeb04a447d4e 100644 --- a/homeassistant/components/nws/coordinator.py +++ b/homeassistant/components/nws/coordinator.py @@ -115,12 +115,7 @@ async def _async_check_location_change(self) -> None: self._location_entity_id, ) return - _LOGGER.info( - "NWS API updated: station %s at (%.4f, %.4f)", - new_nws.station, - coordinates.latitude, - coordinates.longitude, - ) + _LOGGER.info("NWS API updated: station %s", new_nws.station) self.nws = new_nws self.name = f"NWS observation station {new_nws.station}" runtime_data = self.config_entry.runtime_data diff --git a/homeassistant/components/prowl/__init__.py b/homeassistant/components/prowl/__init__.py index 4f744e25bf7de5..7bfe8bafdb43d3 100644 --- a/homeassistant/components/prowl/__init__.py +++ b/homeassistant/components/prowl/__init__.py @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except prowlpy.APIError as ex: if str(ex).startswith("Not accepted: exceeded rate limit"): raise ConfigEntryNotReady("Prowl API rate limit exceeded") from ex - raise ConfigEntryError("Failed to validate Prowl API key ({ex})") from ex + raise ConfigEntryError(f"Failed to validate Prowl API key ({ex})") from ex await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index fb102a8f9e9f8e..00788a116a7359 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -1,7 +1,7 @@ { "domain": "smarty", "name": "Salda Smarty", - "codeowners": ["@z0mbieprocess"], + "codeowners": ["@z0mbieprocess", "@marcodutto"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smarty", "integration_type": "hub", diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index afab0e2203ab06..bf11dc84b50adf 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -152,7 +152,7 @@ async def async_setup_platform( ts_data = StartcaData(websession, apikey, bandwidthcap) ret = await ts_data.async_update() if ret is False: - _LOGGER.error("Invalid Start.ca API key: %s", apikey) + _LOGGER.error("Invalid Start.ca API key") return name = config[CONF_NAME] diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 4b3f9e5a40f94f..77ec1881f49a83 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -12,6 +12,6 @@ "requirements": [ "aiopowerwall==0.3.2", "tesla-fleet-api==1.13.0", - "teslemetry-stream==0.13.1" + "teslemetry-stream==0.13.2" ] } diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index a191b2289b4d08..5a782a367ef0a0 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.4.3"] + "requirements": ["voip-utils==0.5.0"] } diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 52b6996318587b..1bc3f1ea6a3acd 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -76,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: # since its the wrong device. As soon as the device comes back # online the ip will get updated and setup will proceed. raise ConfigEntryNotReady( - "Found bulb {bulb.mac} at {ip_address}, expected {entry.unique_id}" + f"Found bulb {bulb.mac} at {ip_address}, expected {entry.unique_id}" ) coordinator = WizCoordinator(hass, entry, bulb) diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 0877a702b12a2c..f6b5448d00a5f9 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -168,11 +168,10 @@ def _get_token(host, username, password): return result["token"] except KeyError: error_message = ( - "Xiaomi token cannot be refreshed, response from " - "url: [%s] \nwith parameter: [%s] \nwas: [%s]" + "Xiaomi token cannot be refreshed, response from url: [%s] was: [%s]" ) - _LOGGER.exception(error_message, url, data, result) + _LOGGER.exception(error_message, url, result) return None - _LOGGER.error("Invalid response: [%s] at url: [%s] with data [%s]", res, url, data) + _LOGGER.error("Invalid response: [%s] at url: [%s]", res, url) return None diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 14ba8062807333..9d8a32e63845dd 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -101,6 +101,9 @@ key=ExtendedBinarySensorDeviceClass.PRY_THE_DOOR, device_class=BinarySensorDeviceClass.TAMPER, ), + ExtendedBinarySensorDeviceClass.STABILIZED: BinarySensorEntityDescription( + key=ExtendedBinarySensorDeviceClass.STABILIZED, + ), ExtendedBinarySensorDeviceClass.TOOTHBRUSH: BinarySensorEntityDescription( key=ExtendedBinarySensorDeviceClass.TOOTHBRUSH, ), diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 94645ea1f16973..80c4792156a93d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2527,6 +2527,30 @@ async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: entry.state is ConfigEntryState.LOADED # type: ignore[comparison-overlap] ) + async def async_retry_migration(self, entry_id: str) -> None: + """Retry migration for a config entry. + + This is only intended for repair flows created to handle + non-recoverable migration errors. + """ + entry = self.async_get_known_entry(entry_id) + if entry.state is not ConfigEntryState.MIGRATION_ERROR: + raise OperationNotAllowed( + f"The config entry '{entry.title}' ({entry.domain}) with entry_id" + f" '{entry.entry_id}' cannot retry the migration as it is not in the" + f" state {ConfigEntryState.MIGRATION_ERROR} but is in the state {entry.state}" + ) + if entry.disabled_by: + raise OperationNotAllowed( + f"The config entry '{entry.title}' ({entry.domain}) with entry_id" + f" '{entry.entry_id}' cannot retry the migration as it is disabled by" + f" {entry.disabled_by}. Please enable the config entry and retry." + ) + + # Config entry was never loaded so we can set state and start setup to try again + entry._async_set_state(self.hass, ConfigEntryState.NOT_LOADED, None) # noqa: SLF001 + await self.async_setup(entry_id) + async def async_unload(self, entry_id: str, _lock: bool = True) -> bool: """Unload a config entry.""" entry = self.async_get_known_entry(entry_id) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e43d5573226b55..3eb7b74ba0d3f3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -72,9 +72,9 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.10 +uv==0.12.11 webrtc-models==0.3.0 -yarl==1.24.5 +yarl==1.25.1 zeroconf==0.151.3 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index be9900f7f7c55a..fd1ebb770134b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,9 +77,9 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.10", + "uv==0.12.11", "probatio==0.12.1", - "yarl==1.24.5", + "yarl==1.25.1", "webrtc-models==0.3.0", "zeroconf==0.151.3", ] diff --git a/requirements.txt b/requirements.txt index 9438564e1673bf..9cae7538b02e05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.10 +uv==0.12.11 webrtc-models==0.3.0 -yarl==1.24.5 +yarl==1.25.1 zeroconf==0.151.3 diff --git a/requirements_all.txt b/requirements_all.txt index 03937333354dcb..fd9804e582bc39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -218,7 +218,7 @@ aioasuswrt==1.5.4 aioautomower==3.0.0 # homeassistant.components.axle_energy -aioaxlevpp==0.1.0 +aioaxlevpp==0.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -318,7 +318,7 @@ aioimmich==0.17.0 aioindiallsky==0.1.1 # homeassistant.components.ipp -aioipp==0.19.0 +aioipp==0.19.3 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -1710,7 +1710,7 @@ nad-receiver==0.4.0 ndms2-client==0.1.2 # homeassistant.components.neopool -neopool-modbus==4.7.0 +neopool-modbus==4.8.0 # homeassistant.components.ness_alarm nessclient==1.3.1 @@ -3260,7 +3260,7 @@ tesla-powerwall==0.5.3 tesla-wall-connector==1.2.0 # homeassistant.components.teslemetry -teslemetry-stream==0.13.1 +teslemetry-stream==0.13.2 # homeassistant.components.tessie tessie-api==0.1.3 @@ -3418,7 +3418,7 @@ vizaio==0.7.0 vobject==0.9.9 # homeassistant.components.voip -voip-utils==0.4.3 +voip-utils==0.5.0 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index 020d58252f8c13..0383253c94b9a1 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -"""Generate .github/copilot-instructions.md from AGENTS.md and skills. - -Necessary until copilot can handle skills. -""" +"""Generate .github/copilot-instructions.md from AGENTS.md and the PR template.""" from pathlib import Path import sys @@ -13,10 +10,6 @@ AGENTS_FILE = Path("AGENTS.md") OUTPUT_FILE = Path(".github/copilot-instructions.md") -INTEGRATION_SKILL_FILE = Path(".claude/skills/ha-integration-knowledge/SKILL.md") -INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE = Path( - ".github/instructions/integrations.instructions.md" -) PR_TEMPLATE_FILE = Path(".github/PULL_REQUEST_TEMPLATE.md") COPILOT_SPECIFIC_INSTRUCTIONS = """ @@ -24,6 +17,7 @@ - Start review comments with a short, one-sentence summary of the suggested fix. - Do not comment on code style, formatting or linting issues. +- When reviewing changes under `homeassistant/components/` or `tests/components/`, use the `ha-integration-knowledge` skill as the primary reference. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. - Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments and Markdown link reference definitions (lines of the form `[name]: url`), which do not render and cannot be verified from the description. Even unchecked checkboxes or empty sections must be present. This is a hard requirement. @@ -37,41 +31,6 @@ ``` """ -INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """--- -applyTo: "homeassistant/components/**, tests/components/**" -excludeAgent: "cloud-agent" ---- -""" - - -def _strip_frontmatter(text: str) -> str: - """Strip YAML frontmatter from the start of a markdown document.""" - if not text.startswith("---\n"): - return text - - end = text.find("\n---\n", 4) - if end == -1: - return text - - return text[end + len("\n---\n") :].lstrip("\n") - - -def generate_integration_path_specific_instructions() -> str: - """Generate instructions for integration paths.""" - if not INTEGRATION_SKILL_FILE.exists(): - print(f"Error: {INTEGRATION_SKILL_FILE} not found") - sys.exit(1) - - skill_content = _strip_frontmatter(INTEGRATION_SKILL_FILE.read_text()) - - return ( - INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS - + "\n" - + GENERATED_MESSAGE - + "\n" - + skill_content - ) - def generate_output() -> str: """Generate the copilot-instructions.md content.""" @@ -119,22 +78,13 @@ def main(validate: bool = False) -> int: return 1 main_content = generate_output() - integration_path_specific_content = ( - generate_integration_path_specific_instructions() - ) if validate: check_file(OUTPUT_FILE, main_content) - check_file( - INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE, integration_path_specific_content - ) return 0 OUTPUT_FILE.write_text(main_content) print(f"Generated {OUTPUT_FILE}") - - INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE.write_text(integration_path_specific_content) - print(f"Generated {INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE}") return 0 diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index b82bb7c650fa58..d2f1b45934be12 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -44,7 +44,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -93,7 +93,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -142,7 +142,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -191,7 +191,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -250,7 +250,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -309,7 +309,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -362,7 +362,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -415,7 +415,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -468,7 +468,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -521,7 +521,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -577,7 +577,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -633,7 +633,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -695,7 +695,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -757,7 +757,131 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data7] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'failed_folders': list([ + 'ssl', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 8, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data7].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_addons': list([ + dict({ + 'name': 'Test add-on', + 'slug': 'test_addon', + 'version': '1.0.0', + }), + ]), + 'failed_agent_ids': list([ + 'test.remote', + ]), + 'failed_folders': list([ + 'ssl', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 8, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2b1d7399a7fd6a..2fd9453aef290b 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1330,7 +1330,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -1446,7 +1446,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -1562,7 +1562,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -1692,7 +1692,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -1969,7 +1969,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2083,7 +2083,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2197,7 +2197,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2313,7 +2313,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2431,7 +2431,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2547,7 +2547,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2667,7 +2667,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2791,7 +2791,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -2907,7 +2907,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -3023,7 +3023,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -3139,7 +3139,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- @@ -3255,7 +3255,7 @@ }), }), 'key': 'backup', - 'minor_version': 7, + 'minor_version': 8, 'version': 1, }) # --- diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index a016ab36f3d730..63e7cbb55045e3 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -302,6 +302,57 @@ def mock_delay_save() -> Generator[None]: "minor_version": 7, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_addons": [ + { + "name": "Test add-on", + "slug": "test_addon", + "version": "1.0.0", + } + ], + "failed_agent_ids": ["test.remote"], + "failed_folders": ["ssl"], + } + ], + "config": { + "agents": { + "test.remote": { + "protected": True, + "retention": {"copies": None, "days": None}, + } + }, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 8, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/fints/test_sensor.py b/tests/components/fints/test_sensor.py new file mode 100644 index 00000000000000..26cbce6aef01d3 --- /dev/null +++ b/tests/components/fints/test_sensor.py @@ -0,0 +1,110 @@ +"""Tests for the FinTS sensor platform.""" + +import logging +from unittest.mock import MagicMock, patch + +from fints.client import BankIdentifier, FinTSOperations +import pytest + +from homeassistant.components.fints.sensor import SEPAAccount +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +BANK_INFORMATION = { + "bank_identifier": BankIdentifier(country_identifier="280", bank_code="50010517"), + "currency": "EUR", + "customer_id": "0815", + "owner_name": ["SURNAME, FIRSTNAME"], + "subaccount_number": None, + "supported_operations": { + FinTSOperations.GET_BALANCE: True, + FinTSOperations.GET_HOLDINGS: True, + FinTSOperations.GET_SEPA_ACCOUNTS: True, + }, +} + +# GIRO2 and DEPOT2 are deliberately left out of the configuration below, so the +# platform skips them. UNKNOWN has no type and matches neither config. +ACCOUNT_TYPES = {"GIRO1": 5, "GIRO2": 5, "DEPOT1": 33, "DEPOT2": 33, "UNKNOWN": None} + +CONFIG = { + "sensor": { + "platform": "fints", + "bank_identification_number": "12345678", + "username": "user", + "pin": "1234", + "url": "https://example.com", + "name": "Test Bank", + "accounts": [{"account": "GIRO1", "name": "Checking"}], + "holdings": [{"account": "DEPOT1", "name": "Depot"}], + } +} + + +def _sepa_account(identifier: str) -> SEPAAccount: + """Build a SEPA account with the same IBAN and account number.""" + return SEPAAccount( + iban=identifier, + bic="BANCODELTEST", + accountnumber=identifier, + subaccount=None, + blz="12345", + ) + + +@pytest.fixture +def mock_bank() -> MagicMock: + """Return a bank that serves one of every account we care about.""" + bank = MagicMock() + bank.get_sepa_accounts.return_value = [ + _sepa_account(identifier) for identifier in ACCOUNT_TYPES + ] + bank.get_information.return_value = { + "accounts": [ + BANK_INFORMATION + | {"account_number": identifier, "iban": identifier, "type": account_type} + for identifier, account_type in ACCOUNT_TYPES.items() + ] + } + bank.get_balance.return_value.amount.amount = 1234.56 + bank.get_balance.return_value.amount.currency = "EUR" + bank.get_holdings.return_value = [] + return bank + + +async def test_setup_platform( + hass: HomeAssistant, mock_bank: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Only configured accounts get a sensor, and the rest are skipped.""" + caplog.set_level(logging.DEBUG) + + with patch( + "homeassistant.components.fints.sensor.FinTS3PinTanClient", + return_value=mock_bank, + ): + assert await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() + + assert hass.states.get("sensor.checking").state == "1234.56" + assert hass.states.get("sensor.depot").state == "0" + + assert "Skipping account for bank Test Bank" in caplog.text + assert "Skipping holdings for bank Test Bank" in caplog.text + assert "Could not determine type of account for bank Test Bank" in caplog.text + + +async def test_account_identifiers_are_not_logged( + hass: HomeAssistant, mock_bank: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """No IBAN or account number ends up in the log.""" + caplog.set_level(logging.DEBUG) + + with patch( + "homeassistant.components.fints.sensor.FinTS3PinTanClient", + return_value=mock_bank, + ): + assert await async_setup_component(hass, "sensor", CONFIG) + await hass.async_block_till_done() + + for identifier in ACCOUNT_TYPES: + assert identifier not in caplog.text diff --git a/tests/components/ipp/snapshots/test_diagnostics.ambr b/tests/components/ipp/snapshots/test_diagnostics.ambr index e7817211f9f1c5..29966d46fabdf5 100644 --- a/tests/components/ipp/snapshots/test_diagnostics.ambr +++ b/tests/components/ipp/snapshots/test_diagnostics.ambr @@ -14,11 +14,15 @@ }), 'info': dict({ 'command_set': 'ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF', + 'icons': list([ + ]), 'location': None, 'manufacturer': 'TEST', 'model': 'HA-1000 Series', 'more_info': 'http://192.168.1.31:80/PRESENTATION/BONJOUR', 'name': 'Test HA-1000 Series', + 'pages_per_minute': None, + 'pages_per_minute_color': None, 'printer_info': 'Test HA-1000 Series', 'printer_name': 'Test Printer', 'printer_uri_supported': list([ @@ -30,6 +34,8 @@ 'uuid': 'cfe92100-67c4-11d4-a45f-f8d027761251', 'version': '20.23.06HA', }), + 'input_trays': list([ + ]), 'markers': list([ dict({ 'color': '#000000', @@ -77,11 +83,23 @@ 'name': 'Yellow ink', }), ]), + 'output_trays': list([ + ]), 'state': dict({ 'message': None, 'printer_state': 'idle', 'reasons': None, }), + 'status': dict({ + 'accepting_jobs': None, + 'alerts': list([ + ]), + 'media_ready': list([ + ]), + 'queued_jobs': None, + 'supported': list([ + ]), + }), 'uris': list([ dict({ 'authentication': None, diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 03d5446ace115d..250283920a1712 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 4, + "minor_version": 5, "key": "knx/config_store.json", "data": { "entities": { diff --git a/tests/components/knx/fixtures/config_store_entity_category.json b/tests/components/knx/fixtures/config_store_entity_category.json new file mode 100644 index 00000000000000..1b67d06633008e --- /dev/null +++ b/tests/components/knx/fixtures/config_store_entity_category.json @@ -0,0 +1,69 @@ +{ + "version": 2, + "minor_version": 5, + "key": "knx/config_store.json", + "data": { + "entities": { + "sensor": { + "knx_es_01KC2F5CP5S4QCE3FZ49EF7CSJ": { + "entity": { + "name": "Test sensor", + "device_info": null, + "entity_category": "diagnostic" + }, + "knx": { + "ga_sensor": { + "state": "1/1/1", + "dpt": "7.600", + "passive": [] + }, + "always_callback": false, + "sync_state": true + } + } + }, + "binary_sensor": { + "knx_es_01KC2F5CP5S4QCE3FZ49EF7CSK": { + "entity": { + "name": "Test binary sensor", + "device_info": null, + "entity_category": "diagnostic" + }, + "knx": { + "ga_sensor": { + "state": "2/2/2", + "dpt": "1", + "passive": [] + }, + "invert": false, + "ignore_internal_state": false, + "context_timeout": 0.0, + "reset_after": 0.0, + "sync_state": true + } + } + }, + "switch": { + "knx_es_01KC2F5CP5S4QCE3FZ49EF7CSL": { + "entity": { + "name": "Test switch", + "device_info": null, + "entity_category": "config" + }, + "knx": { + "ga_switch": { + "write": "3/3/3", + "state": null, + "passive": [] + }, + "invert": false, + "respond_to_read": false, + "sync_state": true + } + } + } + }, + "expose": {}, + "time_server": {} + } +} diff --git a/tests/components/knx/fixtures/config_store_entity_category_v2_4.json b/tests/components/knx/fixtures/config_store_entity_category_v2_4.json new file mode 100644 index 00000000000000..060ee4ed278724 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_entity_category_v2_4.json @@ -0,0 +1,69 @@ +{ + "version": 2, + "minor_version": 4, + "key": "knx/config_store.json", + "data": { + "entities": { + "sensor": { + "knx_es_01KC2F5CP5S4QCE3FZ49EF7CSJ": { + "entity": { + "name": "Test sensor", + "device_info": null, + "entity_category": "config" + }, + "knx": { + "ga_sensor": { + "state": "1/1/1", + "dpt": "7.600", + "passive": [] + }, + "always_callback": false, + "sync_state": true + } + } + }, + "binary_sensor": { + "knx_es_01KC2F5CP5S4QCE3FZ49EF7CSK": { + "entity": { + "name": "Test binary sensor", + "device_info": null, + "entity_category": "config" + }, + "knx": { + "ga_sensor": { + "state": "2/2/2", + "dpt": "1", + "passive": [] + }, + "invert": false, + "ignore_internal_state": false, + "context_timeout": 0.0, + "reset_after": 0.0, + "sync_state": true + } + } + }, + "switch": { + "knx_es_01KC2F5CP5S4QCE3FZ49EF7CSL": { + "entity": { + "name": "Test switch", + "device_info": null, + "entity_category": "config" + }, + "knx": { + "ga_switch": { + "write": "3/3/3", + "state": null, + "passive": [] + }, + "invert": false, + "respond_to_read": false, + "sync_state": true + } + } + } + }, + "expose": {}, + "time_server": {} + } +} diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json index 634ac9290afe35..d82c57983653bd 100644 --- a/tests/components/knx/fixtures/config_store_light.json +++ b/tests/components/knx/fixtures/config_store_light.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 4, + "minor_version": 5, "key": "knx/config_store.json", "data": { "entities": { diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 04f7618c8e3caa..4036571d2d479e 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -120,6 +120,73 @@ async def test_create_entity_error( assert res["result"]["error_base"].startswith("value must be one of") +@pytest.mark.parametrize( + ("platform", "knx_data", "read_response"), + [ + pytest.param( + Platform.SENSOR, + {"ga_sensor": {"state": "1/2/3", "dpt": "5.001"}}, + (0,), + id="sensor", + ), + pytest.param( + Platform.BINARY_SENSOR, + {"ga_sensor": {"state": "1/2/3", "dpt": "1"}}, + 0, + id="binary_sensor", + ), + ], +) +async def test_create_entity_unsupported_entity_category( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + entity_registry: er.EntityRegistry, + create_ui_entity: KnxEntityGenerator, + platform: Platform, + knx_data: dict[str, Any], + read_response: int | tuple[int, ...], +) -> None: + """Test read-only platforms reject `EntityCategory.CONFIG`.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": platform, + "data": { + "entity": { + "name": "Test config category", + "entity_category": EntityCategory.CONFIG, + }, + "knx": knx_data, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert not res["result"]["success"] + assert res["result"]["errors"][0]["path"] == ["data", "entity", "entity_category"] + assert "is not supported by the" in res["result"]["error_base"] + assert KNX_CONFIG_STORAGE_KEY not in hass_storage + + entity_entry = await create_ui_entity( + platform=platform, + entity_data={ + "name": "Test diagnostic category", + "entity_category": EntityCategory.DIAGNOSTIC, + }, + knx_data=knx_data, + ) + await knx.assert_read("1/2/3", response=read_response) + assert ( + entity_registry.async_get(entity_entry.entity_id).entity_category + is EntityCategory.DIAGNOSTIC + ) + + async def test_update_entity( hass: HomeAssistant, knx: KNXTestKit, @@ -710,12 +777,12 @@ async def test_migration_1_to_2( assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data -async def test_migration_2_1_to_2_4( +async def test_migration_2_1_to_2_5( hass: HomeAssistant, knx: KNXTestKit, hass_storage: dict[str, Any], ) -> None: - """Test migration from schema 2.1 to schema 2.4.""" + """Test migration from schema 2.1 to schema 2.5.""" await knx.setup_integration( config_store_fixture="config_store_binarysensor_v2_1.json", state_updater=False, @@ -724,3 +791,23 @@ async def test_migration_2_1_to_2_4( hass, "config_store_binarysensor.json", "knx" ) assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data + + +async def test_migration_2_4_to_2_5( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 2.4 to schema 2.5.""" + await knx.setup_integration( + config_store_fixture="config_store_entity_category_v2_4.json", + state_updater=False, + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_entity_category.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data + + # entities that could not be set up before are now created + assert hass.states.get("sensor.test_sensor") + assert hass.states.get("binary_sensor.test_binary_sensor") diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 7a18bce7a19b33..bf280e56e84747 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -247,6 +247,7 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: {"state_class": "total_increasing"}, # invalid for temperature DPT {"unit_of_measurement": "invalid"}, {"device_class": "energy", "unit_of_measurement": "invalid"}, + {"entity_category": "config"}, # sensors can not be added as config entities ], ) async def test_sensor_yaml_attribute_validation( @@ -255,7 +256,7 @@ async def test_sensor_yaml_attribute_validation( knx: KNXTestKit, attribute_config: dict[str, Any], ) -> None: - """Test creating a sensor with invalid unit, state_class or device_class.""" + """Test creating a sensor with invalid attributes.""" with caplog.at_level(logging.ERROR): await knx.setup_integration( { diff --git a/tests/components/my_pv/test_config_flow.py b/tests/components/my_pv/test_config_flow.py index 88d88da1ff011f..c5531c3474d05a 100644 --- a/tests/components/my_pv/test_config_flow.py +++ b/tests/components/my_pv/test_config_flow.py @@ -442,3 +442,107 @@ async def test_step_discovery_auth_wrong_password( CONF_PASSWORD: "test-password", } assert result["result"].unique_id == ELWA2_SERIAL_NUMBER + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reauth( + hass: HomeAssistant, + mock_my_pv_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test for reauth.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reauth_wrong_password( + hass: HomeAssistant, + mock_my_pv_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test for reauth with an incorrect password.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] + + mock_my_pv_client.connect.side_effect = MyPVAuthenticationError() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "wrong-password"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["password"] == "invalid_password" + + mock_my_pv_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_reauth_cannot_connect( + hass: HomeAssistant, + mock_my_pv_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test for reauth if we can not connect to device.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] + + mock_my_pv_client.connect.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == "cannot_connect" + + mock_my_pv_client.connect.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert updated_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/nws/test_init.py b/tests/components/nws/test_init.py index 6d2f46ef317fbd..b2c0ef5590340c 100644 --- a/tests/components/nws/test_init.py +++ b/tests/components/nws/test_init.py @@ -1,5 +1,7 @@ """Tests for init module.""" +import logging + from pynws import NwsNoDataError import pytest @@ -195,6 +197,40 @@ async def test_location_change_updates_coordinates( assert coordinator.name == f"NWS observation station {new_station}" +async def test_location_change_does_not_log_coordinates( + hass: HomeAssistant, + mock_simple_nws, + location_entity_config: dict, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the tracked location is not written to the log.""" + caplog.set_level(logging.INFO) + entity = location_entity_config["entry"] + hass.states.async_set( + entity.entity_id, + "home", + {ATTR_LATITUDE: 40.0, ATTR_LONGITUDE: -80.0}, + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=location_entity_config["config"]) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + caplog.clear() + hass.states.async_set( + entity.entity_id, + "away", + {ATTR_LATITUDE: 41.0, ATTR_LONGITUDE: -81.0}, + ) + await hass.async_block_till_done() + + assert "NWS API updated: station" in caplog.text + assert "41.0000" not in caplog.text + assert "-81.0000" not in caplog.text + + async def test_location_change_resets_api_success_time( hass: HomeAssistant, mock_simple_nws, location_entity_config: dict ) -> None: diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py index ef7b704a718841..6630e9b03e19f8 100644 --- a/tests/components/startca/test_sensor.py +++ b/tests/components/startca/test_sensor.py @@ -2,6 +2,8 @@ from http import HTTPStatus +import pytest + from homeassistant.components.startca.sensor import StartcaData from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant @@ -227,3 +229,27 @@ async def test_bad_json_decode( result = await scd.async_update() assert result is False + + +async def test_invalid_api_key( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that setup stops when Start.ca rejects the key.""" + config = { + "platform": "startca", + "api_key": "NOTAKEY", + "total_bandwidth": 400, + "monitored_variables": ["usage"], + } + aioclient_mock.get( + "https://www.start.ca/support/usage/api?key=NOTAKEY", + status=HTTPStatus.NOT_FOUND, + ) + + await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() + + assert hass.states.get("sensor.start_ca_usage") is None + assert "Invalid Start.ca API key" in caplog.text diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index e3cc1898ce903c..4fb0d9dd540fe6 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -4,6 +4,7 @@ import logging from unittest.mock import MagicMock, call, patch +import pytest import requests from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN @@ -16,6 +17,8 @@ INVALID_USERNAME = "bob" TOKEN_TIMEOUT_USERNAME = "tok" +ERROR_STATUS_USERNAME = "err" +PASSWORD = "passwordTest" URL_AUTHORIZE = "http://192.168.0.1/cgi-bin/luci/api/xqsystem/login" URL_LIST_END = "api/misystem/devicelist" @@ -51,6 +54,9 @@ def raise_for_status(self): # pylint: disable-next=global-statement global FIRST_CALL # noqa: PLW0603 + if data and data.get("username", None) == ERROR_STATUS_USERNAME: + # deliver a response the router could not serve + return MockResponse({}, HTTPStatus.INTERNAL_SERVER_ERROR) if data and data.get("username", None) == INVALID_USERNAME: # deliver an invalid token return MockResponse({"code": "401", "msg": "Invalid token"}, 200) @@ -258,3 +264,52 @@ async def test_token_timed_out(mock_get, mock_post, hass: HomeAssistant) -> None assert len(scanner.scan_devices()) == 2 assert scanner.get_device_name("23:83:BF:F6:38:A0") == "Device1" assert scanner.get_device_name("1D:98:EC:5E:D5:A6") == "Device2" + + +@patch("requests.get", side_effect=mocked_requests) +@patch("requests.post", side_effect=mocked_requests) +async def test_rejected_login_does_not_log_password( + mock_get: MagicMock, + mock_post: MagicMock, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a router refusing the login keeps the password out of the log.""" + config = { + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( + { + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, + CONF_HOST: "192.168.0.1", + CONF_USERNAME: INVALID_USERNAME, + CONF_PASSWORD: PASSWORD, + } + ) + } + + assert get_scanner(hass, config) is None + + assert "Xiaomi token cannot be refreshed" in caplog.text + assert PASSWORD not in caplog.text + + +@patch("requests.get", side_effect=mocked_requests) +@patch("requests.post", side_effect=mocked_requests) +async def test_error_response_does_not_log_password( + mock_get, mock_post, hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that a non-OK response keeps the password out of the log.""" + config = { + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( + { + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, + CONF_HOST: "192.168.0.1", + CONF_USERNAME: ERROR_STATUS_USERNAME, + CONF_PASSWORD: PASSWORD, + } + ) + } + + assert get_scanner(hass, config) is None + + assert "Invalid response" in caplog.text + assert PASSWORD not in caplog.text diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index e48679438a54d7..4c5ab407d4eeef 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import make_advertisement +from . import MISCALE_V2_SERVICE_INFO, make_advertisement from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( @@ -175,6 +175,35 @@ async def test_opening(hass: HomeAssistant) -> None: await hass.async_block_till_done() +async def test_miscale_v2_stabilized_binary_sensor(hass: HomeAssistant) -> None: + """Test MiScale V2 stabilized binary sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="50:FB:19:1B:B5:DC", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info_bleak(hass, MISCALE_V2_SERVICE_INFO) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + + stabilized_sensor = hass.states.get( + "binary_sensor.mi_body_composition_scale_b5dc_stabilized" + ) + assert stabilized_sensor.state == STATE_ON + assert ( + stabilized_sensor.attributes[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Stabilized" + ) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_opening_problem_sensors(hass: HomeAssistant) -> None: """Test setting up a opening binary sensor with additional problem sensors.""" entry = MockConfigEntry( diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index d382dbaf8d00ab..b07c07800d2891 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -692,7 +692,7 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak(hass, MISCALE_V1_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( "sensor.mi_smart_scale_b5dc_weight_non_stabilized" @@ -734,7 +734,7 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak(hass, MISCALE_V2_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 mass_non_stabilized_sensor = hass.states.get( "sensor.mi_body_composition_scale_b5dc_weight_non_stabilized" @@ -844,7 +844,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak(hass, MISCALE_V1_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( "sensor.mi_smart_scale_b5dc_weight_non_stabilized" @@ -895,7 +895,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: inject_bluetooth_service_info_bleak(hass, MISCALE_V1_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 mass_non_stabilized_sensor = hass.states.get( "sensor.mi_smart_scale_b5dc_weight_non_stabilized" diff --git a/tests/e2e/package.json b/tests/e2e/package.json index d739c15e5267de..19b919b61ea4d8 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@12.3.4", + "packageManager": "pnpm@12.4.0", "scripts": { "test": "playwright test" }, diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml index c52be4c7bff088..e27ae8fce77b8d 100644 --- a/tests/e2e/pnpm-lock.yaml +++ b/tests/e2e/pnpm-lock.yaml @@ -7,96 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.3.4 - version: 12.3.4 + specifier: 12.4.0 + version: 12.4.0 packages: - '@pnpm/exe.darwin-arm64@12.3.4': - resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} + '@pnpm/exe.android-arm64@12.4.0': + resolution: {integrity: sha512-sAwslzCw74OpqK2P9l39cgdrRWHSqf5wEjB58JEzeVX6wdLvaHyg9i/GeRK5Ou6PZNA3AkD4yLO3c/y7UqOf8w==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.4.0': + resolution: {integrity: sha512-Ps3Gz0OrqYjuRfhzeNqcvIow6QmNrU3BSzMxJu5rUCSl7inVUUFX2gZbNL9naQsNLoorKCdxUf4N+WI9Mr1WBg==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.4.0': + resolution: {integrity: sha512-75EYiF8GuiTsnvP4cbZsviMBjohXUYtg2zjD4gdfhqxlU1y9Nj+KdEsbH4jfgB/FgyJSYPB2rqfmvvgLnRlVug==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.3.4': - resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} + '@pnpm/exe.darwin-x64@12.4.0': + resolution: {integrity: sha512-b74TzBg8lxl0lqZImGOXpRbAGcqVKX+UMckl3wG+KH52yYHI86VBJP86HbJX30HJ/EFeC6Tgbz2E4b5hhKRvdA==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.3.4': - resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} + '@pnpm/exe.freebsd-x64@12.4.0': + resolution: {integrity: sha512-A45d6axdQIFNyNTYZAC64wh5+6LJ6dNKhNAoNMNi/EC5y9CRfv0GL3ZYDBqpkmsN9KMAkVsFWizq/Ng6xtjnhw==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.4.0': + resolution: {integrity: sha512-yS6DNel7twfEUoW1yka1hpQrnhRe/s1JZ1MThVfgrmNQrNaPyHxQvAihm/GtL2CM6OeMV6z5532H/W/yDjQp5g==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.3.4': - resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} + '@pnpm/exe.linux-arm64@12.4.0': + resolution: {integrity: sha512-6npQUwq3D/WXbTgR4evApEKQ9ITznZ6Iz/dptcjBzA7+wSLTaYkK98Od2wsHCoPmEFb9tGtM+cvG82+wy0o9uA==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.3.4': - resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} + '@pnpm/exe.linux-ppc64@12.4.0': + resolution: {integrity: sha512-7shJ4WytyvBEdjrbIDqx2gDAH7sr0HBDooTmnNQ7DlzcLeeGi7Yqir7gJjOTm6s9S1cVnT56IwmqnnwQMP1HTQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.4.0': + resolution: {integrity: sha512-q03EGUFoe/oOU/67E3sEAePr3qjFu8xrsX+WOXTodcFXfO5FondC6TPs7BSwhTiV27j3jeDP56qhJP05pXn+vw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.4.0': + resolution: {integrity: sha512-GZ5YellCtNnaLe9zVj9l3avlw9CbSzExUFDh7v+tVbLfOOfkkRLpx2lsw1ytJ/nFWvxF2TO6M6Q9JDT7q8svRg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.4.0': + resolution: {integrity: sha512-c8YyjVL39L48tRg9i3iw3d90fN6q84UjxlgWBhplcBOpzCgmglDVkPMtEAVDC+t9iobvYzs2hJnmTqLaA87MVA==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.3.4': - resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} + '@pnpm/exe.linux-x64@12.4.0': + resolution: {integrity: sha512-SQVgRkcR4Xyqf8+VNbtY0rtcEnfDq48RhH30HWo2/UfqKEfle2rOMyGZOmN1DbMw4ZzG5mWYoC81O7ZqHFZcPw==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.3.4': - resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} + '@pnpm/exe.win32-arm64@12.4.0': + resolution: {integrity: sha512-ZamXPhx0X6APZJAIkcH+K9KAsKcTYwxEgOyTQ/NXE+2UHBT9bRnSQ91sBeY6ELNd58V5cmMAkXSW5xmU+2ry+w==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.3.4': - resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} + '@pnpm/exe.win32-x64@12.4.0': + resolution: {integrity: sha512-b8bLaprnpYi/2zN0M3H9RDAHuxCP3fmV5agPPwdp9fIdjNSUkV7V/RC3L4oWwbZvVgYEjjFLx1RuJOdltoKP6g==} cpu: [x64] os: [win32] - pnpm@12.3.4: - resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} + pnpm@12.4.0: + resolution: {integrity: sha512-N1NsJu1Aq0E0tlEeCfayfz67RWh0aPJAbKOAUnmk5coVjBkxNQrZd01qshCNcbPbrrOZQxWSlDdeTQU+jgVoXA==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.3.4': + '@pnpm/exe.android-arm64@12.4.0': + optional: true + + '@pnpm/exe.android-x64@12.4.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.4.0': + optional: true + + '@pnpm/exe.darwin-x64@12.4.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.4.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.4.0': + optional: true + + '@pnpm/exe.linux-arm64@12.4.0': optional: true - '@pnpm/exe.darwin-x64@12.3.4': + '@pnpm/exe.linux-ppc64@12.4.0': optional: true - '@pnpm/exe.linux-arm64-musl@12.3.4': + '@pnpm/exe.linux-riscv64@12.4.0': optional: true - '@pnpm/exe.linux-arm64@12.3.4': + '@pnpm/exe.linux-s390x@12.4.0': optional: true - '@pnpm/exe.linux-x64-musl@12.3.4': + '@pnpm/exe.linux-x64-musl@12.4.0': optional: true - '@pnpm/exe.linux-x64@12.3.4': + '@pnpm/exe.linux-x64@12.4.0': optional: true - '@pnpm/exe.win32-arm64@12.3.4': + '@pnpm/exe.win32-arm64@12.4.0': optional: true - '@pnpm/exe.win32-x64@12.3.4': + '@pnpm/exe.win32-x64@12.4.0': optional: true - pnpm@12.3.4: + pnpm@12.4.0: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.3.4 - '@pnpm/exe.darwin-x64': 12.3.4 - '@pnpm/exe.linux-arm64': 12.3.4 - '@pnpm/exe.linux-arm64-musl': 12.3.4 - '@pnpm/exe.linux-x64': 12.3.4 - '@pnpm/exe.linux-x64-musl': 12.3.4 - '@pnpm/exe.win32-arm64': 12.3.4 - '@pnpm/exe.win32-x64': 12.3.4 + '@pnpm/exe.android-arm64': 12.4.0 + '@pnpm/exe.android-x64': 12.4.0 + '@pnpm/exe.darwin-arm64': 12.4.0 + '@pnpm/exe.darwin-x64': 12.4.0 + '@pnpm/exe.freebsd-x64': 12.4.0 + '@pnpm/exe.linux-arm64': 12.4.0 + '@pnpm/exe.linux-arm64-musl': 12.4.0 + '@pnpm/exe.linux-ppc64': 12.4.0 + '@pnpm/exe.linux-riscv64': 12.4.0 + '@pnpm/exe.linux-s390x': 12.4.0 + '@pnpm/exe.linux-x64': 12.4.0 + '@pnpm/exe.linux-x64-musl': 12.4.0 + '@pnpm/exe.win32-arm64': 12.4.0 + '@pnpm/exe.win32-x64': 12.4.0 --- lockfileVersion: '9.0' diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 32adc595682cf3..afe1735c4f1488 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3729,6 +3729,170 @@ async def test_entry_reload_not_loaded( assert entry.state is config_entries.ConfigEntryState.LOADED +async def test_async_retry_migration( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, +) -> None: + """Test that we can recover from a migration error.""" + entry = MockConfigEntry(domain="comp") + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + async_migrate_entry = AsyncMock(side_effect=[False, False, True]) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + async_migrate_entry=async_migrate_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 2 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with mock_config_flow("comp", TestFlow): + result = await async_setup_component(hass, "comp", {}) + await hass.async_block_till_done() + + assert result is True + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + + with mock_config_flow("comp", TestFlow): + await manager.async_retry_migration(entry.entry_id) + + assert len(async_unload_entry.mock_calls) == 0 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 0 + assert len(async_migrate_entry.mock_calls) == 2 + assert entry.version == 1 + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + + with mock_config_flow("comp", TestFlow): + await manager.async_retry_migration(entry.entry_id) + + assert len(async_unload_entry.mock_calls) == 0 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert len(async_migrate_entry.mock_calls) == 3 + assert entry.state is config_entries.ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "state", + [ + config_entries.ConfigEntryState.NOT_LOADED, + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.SETUP_ERROR, + config_entries.ConfigEntryState.FAILED_UNLOAD, + ], +) +async def test_async_retry_migration_fails( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: + """Test we can't use async_retry_migration. + + With other states than `MIGRATION_ERROR`. + """ + entry = MockConfigEntry(domain="comp", state=state) + entry.add_to_hass(hass) + + with pytest.raises(config_entries.OperationNotAllowed, match=str(state)): + await manager.async_retry_migration(entry.entry_id) + + assert entry.state is state + + +async def test_async_retry_migration_on_disabled_entry( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, +) -> None: + """Test we can't use async_retry_migration. + + On a disabled entry that is in `MIGRATION_ERROR` state. + """ + entry = MockConfigEntry(domain="comp") + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + async_migrate_entry = AsyncMock(side_effect=[False, True]) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + async_migrate_entry=async_migrate_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 2 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Test user step.""" + return self.async_create_entry(title="title", data={}) + + with mock_config_flow("comp", TestFlow): + result = await async_setup_component(hass, "comp", {}) + await hass.async_block_till_done() + + assert result is True + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + + with pytest.raises(config_entries.OperationNotAllowed): + await manager.async_set_disabled_by( + entry.entry_id, disabled_by=config_entries.ConfigEntryDisabler.USER + ) + # Disabled by user is set even if it's not allowed to reload after + assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER + + with pytest.raises( + config_entries.OperationNotAllowed, + match=( + " cannot retry the migration as it is disabled by user." + " Please enable the config entry and retry." + ), + ): + await manager.async_retry_migration(entry.entry_id) + + with pytest.raises(config_entries.OperationNotAllowed): + await manager.async_set_disabled_by(entry.entry_id, disabled_by=None) + assert entry.disabled_by is None + + with mock_config_flow("comp", TestFlow): + await manager.async_retry_migration(entry.entry_id) + + assert entry.state is config_entries.ConfigEntryState.LOADED + + @pytest.mark.parametrize( "state", [