diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f128cafbee199..b52cced901b3e8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -625,7 +625,7 @@ jobs: steps: - *checkout - name: Dependency review - uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0 + uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 with: license-check: false # We use our own license audit checks diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 80ba6a0c8d38b1..e0341e4ed38a39 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 + uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 + uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 with: category: "/language:python" diff --git a/.gitignore b/.gitignore index bcd3e3d95d08e6..a8bafbc343e83e 100644 --- a/.gitignore +++ b/.gitignore @@ -79,7 +79,6 @@ junit.xml .project .pydevproject -.python-version .tool-versions # emacs auto backups diff --git a/.python-version b/.python-version new file mode 100644 index 00000000000000..24ee5b1be9961e --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CODEOWNERS b/CODEOWNERS index e29f2712032ad3..6e3c0928390576 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -46,6 +46,8 @@ build.json @home-assistant/supervisor /tests/components/accuweather/ @bieniu /homeassistant/components/acmeda/ @atmurray /tests/components/acmeda/ @atmurray +/homeassistant/components/actron_air/ @kclif9 @JagadishDhanamjayam +/tests/components/actron_air/ @kclif9 @JagadishDhanamjayam /homeassistant/components/adax/ @danielhiversen @lazytarget /tests/components/adax/ @danielhiversen @lazytarget /homeassistant/components/adguard/ @frenck diff --git a/Dockerfile.dev b/Dockerfile.dev index d81daaa47eb594..e57fae2a0057d3 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -36,7 +36,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv USER vscode -ENV UV_PYTHON=3.13.2 +COPY .python-version ./ RUN uv python install ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py new file mode 100644 index 00000000000000..39d4f321fe4b59 --- /dev/null +++ b/homeassistant/components/actron_air/__init__.py @@ -0,0 +1,57 @@ +"""The Actron Air integration.""" + +from actron_neo_api import ( + ActronAirNeoACSystem, + ActronNeoAPI, + ActronNeoAPIError, + ActronNeoAuthError, +) + +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import _LOGGER +from .coordinator import ( + ActronAirConfigEntry, + ActronAirRuntimeData, + ActronAirSystemCoordinator, +) + +PLATFORM = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: + """Set up Actron Air integration from a config entry.""" + + api = ActronNeoAPI(refresh_token=entry.data[CONF_API_TOKEN]) + systems: list[ActronAirNeoACSystem] = [] + + try: + systems = await api.get_ac_systems() + await api.update_status() + except ActronNeoAuthError: + _LOGGER.error("Authentication error while setting up Actron Air integration") + raise + except ActronNeoAPIError as err: + _LOGGER.error("API error while setting up Actron Air integration: %s", err) + raise + + system_coordinators: dict[str, ActronAirSystemCoordinator] = {} + for system in systems: + coordinator = ActronAirSystemCoordinator(hass, entry, api, system) + _LOGGER.debug("Setting up coordinator for system: %s", system["serial"]) + await coordinator.async_config_entry_first_refresh() + system_coordinators[system["serial"]] = coordinator + + entry.runtime_data = ActronAirRuntimeData( + api=api, + system_coordinators=system_coordinators, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORM) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORM) diff --git a/homeassistant/components/actron_air/climate.py b/homeassistant/components/actron_air/climate.py new file mode 100644 index 00000000000000..62db76073f8b60 --- /dev/null +++ b/homeassistant/components/actron_air/climate.py @@ -0,0 +1,259 @@ +"""Climate platform for Actron Air integration.""" + +from typing import Any + +from actron_neo_api import ActronAirNeoStatus, ActronAirNeoZone + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator + +PARALLEL_UPDATES = 0 + +FAN_MODE_MAPPING_ACTRONAIR_TO_HA = { + "AUTO": FAN_AUTO, + "LOW": FAN_LOW, + "MED": FAN_MEDIUM, + "HIGH": FAN_HIGH, +} +FAN_MODE_MAPPING_HA_TO_ACTRONAIR = { + v: k for k, v in FAN_MODE_MAPPING_ACTRONAIR_TO_HA.items() +} +HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = { + "COOL": HVACMode.COOL, + "HEAT": HVACMode.HEAT, + "FAN": HVACMode.FAN_ONLY, + "AUTO": HVACMode.AUTO, + "OFF": HVACMode.OFF, +} +HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = { + v: k for k, v in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.items() +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ActronAirConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Actron Air climate entities.""" + system_coordinators = entry.runtime_data.system_coordinators + entities: list[ClimateEntity] = [] + + for coordinator in system_coordinators.values(): + status = coordinator.data + name = status.ac_system.system_name + entities.append(ActronSystemClimate(coordinator, name)) + + entities.extend( + ActronZoneClimate(coordinator, zone) + for zone in status.remote_zone_info + if zone.exists + ) + + async_add_entities(entities) + + +class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity): + """Base class for Actron Air climate entities.""" + + _attr_has_entity_name = True + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + _attr_name = None + _attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values()) + _attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values()) + + def __init__( + self, + coordinator: ActronAirSystemCoordinator, + name: str, + ) -> None: + """Initialize an Actron Air unit.""" + super().__init__(coordinator) + self._serial_number = coordinator.serial_number + + +class ActronSystemClimate(BaseClimateEntity): + """Representation of the Actron Air system.""" + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + coordinator: ActronAirSystemCoordinator, + name: str, + ) -> None: + """Initialize an Actron Air unit.""" + super().__init__(coordinator, name) + serial_number = coordinator.serial_number + self._attr_unique_id = serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + name=self._status.ac_system.system_name, + manufacturer="Actron Air", + model_id=self._status.ac_system.master_wc_model, + sw_version=self._status.ac_system.master_wc_firmware_version, + serial_number=serial_number, + ) + + @property + def min_temp(self) -> float: + """Return the minimum temperature that can be set.""" + return self._status.min_temp + + @property + def max_temp(self) -> float: + """Return the maximum temperature that can be set.""" + return self._status.max_temp + + @property + def _status(self) -> ActronAirNeoStatus: + """Get the current status from the coordinator.""" + return self.coordinator.data + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + if not self._status.user_aircon_settings.is_on: + return HVACMode.OFF + + mode = self._status.user_aircon_settings.mode + return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode) + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + fan_mode = self._status.user_aircon_settings.fan_mode + return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode) + + @property + def current_humidity(self) -> float: + """Return the current humidity.""" + return self._status.master_info.live_humidity_pc + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._status.master_info.live_temp_c + + @property + def target_temperature(self) -> float: + """Return the target temperature.""" + return self._status.user_aircon_settings.temperature_setpoint_cool_c + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set a new fan mode.""" + api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower()) + await self._status.user_aircon_settings.set_fan_mode(api_fan_mode) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode) + await self._status.ac_system.set_system_mode(ac_mode) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + await self._status.user_aircon_settings.set_temperature(temperature=temp) + + +class ActronZoneClimate(BaseClimateEntity): + """Representation of a zone within the Actron Air system.""" + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + coordinator: ActronAirSystemCoordinator, + zone: ActronAirNeoZone, + ) -> None: + """Initialize an Actron Air unit.""" + super().__init__(coordinator, zone.title) + serial_number = coordinator.serial_number + self._zone_id: int = zone.zone_id + self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}" + self._attr_device_info: DeviceInfo = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=zone.title, + manufacturer="Actron Air", + model="Zone", + suggested_area=zone.title, + via_device=(DOMAIN, serial_number), + ) + + @property + def min_temp(self) -> float: + """Return the minimum temperature that can be set.""" + return self._zone.min_temp + + @property + def max_temp(self) -> float: + """Return the maximum temperature that can be set.""" + return self._zone.max_temp + + @property + def _zone(self) -> ActronAirNeoZone: + """Get the current zone data from the coordinator.""" + status = self.coordinator.data + return status.zones[self._zone_id] + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + if self._zone.is_active: + mode = self._zone.hvac_mode + return HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.get(mode) + return HVACMode.OFF + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + return self._zone.humidity + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._zone.live_temp_c + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + return self._zone.temperature_setpoint_cool_c + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + is_enabled = hvac_mode != HVACMode.OFF + await self._zone.enable(is_enabled) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the temperature.""" + await self._zone.set_temperature(temperature=kwargs["temperature"]) diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py new file mode 100644 index 00000000000000..7eb77b53327ad9 --- /dev/null +++ b/homeassistant/components/actron_air/config_flow.py @@ -0,0 +1,132 @@ +"""Setup config flow for Actron Air integration.""" + +import asyncio +from typing import Any + +from actron_neo_api import ActronNeoAPI, ActronNeoAuthError + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.exceptions import HomeAssistantError + +from .const import _LOGGER, DOMAIN + + +class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Actron Air.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._api: ActronNeoAPI | None = None + self._device_code: str | None = None + self._user_code: str = "" + self._verification_uri: str = "" + self._expires_minutes: str = "30" + self.login_task: asyncio.Task | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if self._api is None: + _LOGGER.debug("Initiating device authorization") + self._api = ActronNeoAPI() + try: + device_code_response = await self._api.request_device_code() + except ActronNeoAuthError as err: + _LOGGER.error("OAuth2 flow failed: %s", err) + return self.async_abort(reason="oauth2_error") + + self._device_code = device_code_response["device_code"] + self._user_code = device_code_response["user_code"] + self._verification_uri = device_code_response["verification_uri_complete"] + self._expires_minutes = str(device_code_response["expires_in"] // 60) + + async def _wait_for_authorization() -> None: + """Wait for the user to authorize the device.""" + assert self._api is not None + assert self._device_code is not None + _LOGGER.debug("Waiting for device authorization") + try: + await self._api.poll_for_token(self._device_code) + _LOGGER.debug("Authorization successful") + except ActronNeoAuthError as ex: + _LOGGER.exception("Error while waiting for device authorization") + raise CannotConnect from ex + + _LOGGER.debug("Checking login task") + if self.login_task is None: + _LOGGER.debug("Creating task for device authorization") + self.login_task = self.hass.async_create_task(_wait_for_authorization()) + + if self.login_task.done(): + _LOGGER.debug("Login task is done, checking results") + if exception := self.login_task.exception(): + if isinstance(exception, CannotConnect): + return self.async_show_progress_done( + next_step_id="connection_error" + ) + return self.async_show_progress_done(next_step_id="timeout") + return self.async_show_progress_done(next_step_id="finish_login") + + return self.async_show_progress( + step_id="user", + progress_action="wait_for_authorization", + description_placeholders={ + "user_code": self._user_code, + "verification_uri": self._verification_uri, + "expires_minutes": self._expires_minutes, + }, + progress_task=self.login_task, + ) + + async def async_step_finish_login( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the finalization of login.""" + _LOGGER.debug("Finalizing authorization") + assert self._api is not None + + try: + user_data = await self._api.get_user_info() + except ActronNeoAuthError as err: + _LOGGER.error("Error getting user info: %s", err) + return self.async_abort(reason="oauth2_error") + + unique_id = str(user_data["id"]) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_data["email"], + data={CONF_API_TOKEN: self._api.refresh_token_value}, + ) + + async def async_step_timeout( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle issues that need transition await from progress step.""" + if user_input is None: + return self.async_show_form( + step_id="timeout", + ) + del self.login_task + return await self.async_step_user() + + async def async_step_connection_error( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle connection error from progress step.""" + if user_input is None: + return self.async_show_form(step_id="connection_error") + + # Reset state and try again + self._api = None + self._device_code = None + self.login_task = None + return await self.async_step_user() + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/actron_air/const.py b/homeassistant/components/actron_air/const.py new file mode 100644 index 00000000000000..a3f106acaa3966 --- /dev/null +++ b/homeassistant/components/actron_air/const.py @@ -0,0 +1,6 @@ +"""Constants used by Actron Air integration.""" + +import logging + +_LOGGER = logging.getLogger(__package__) +DOMAIN = "actron_air" diff --git a/homeassistant/components/actron_air/coordinator.py b/homeassistant/components/actron_air/coordinator.py new file mode 100644 index 00000000000000..b9d074bf9a1421 --- /dev/null +++ b/homeassistant/components/actron_air/coordinator.py @@ -0,0 +1,69 @@ +"""Coordinator for Actron Air integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from actron_neo_api import ActronAirNeoACSystem, ActronAirNeoStatus, ActronNeoAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .const import _LOGGER + +STALE_DEVICE_TIMEOUT = timedelta(hours=24) +ERROR_NO_SYSTEMS_FOUND = "no_systems_found" +ERROR_UNKNOWN = "unknown_error" + + +@dataclass +class ActronAirRuntimeData: + """Runtime data for the Actron Air integration.""" + + api: ActronNeoAPI + system_coordinators: dict[str, ActronAirSystemCoordinator] + + +type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData] + +AUTH_ERROR_THRESHOLD = 3 +SCAN_INTERVAL = timedelta(seconds=30) + + +class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirNeoACSystem]): + """System coordinator for Actron Air integration.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ActronAirConfigEntry, + api: ActronNeoAPI, + system: ActronAirNeoACSystem, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Actron Air Status", + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + self.system = system + self.serial_number = system["serial"] + self.api = api + self.status = self.api.state_manager.get_status(self.serial_number) + self.last_seen = dt_util.utcnow() + + async def _async_update_data(self) -> ActronAirNeoStatus: + """Fetch updates and merge incremental changes into the full state.""" + await self.api.update_status() + self.status = self.api.state_manager.get_status(self.serial_number) + self.last_seen = dt_util.utcnow() + return self.status + + def is_device_stale(self) -> bool: + """Check if a device is stale (not seen for a while).""" + return (dt_util.utcnow() - self.last_seen) > STALE_DEVICE_TIMEOUT diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json new file mode 100644 index 00000000000000..a325f585daa3da --- /dev/null +++ b/homeassistant/components/actron_air/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "actron_air", + "name": "Actron Air", + "codeowners": ["@kclif9", "@JagadishDhanamjayam"], + "config_flow": true, + "dhcp": [ + { + "hostname": "neo-*", + "macaddress": "FC0FE7*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/actron_air", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["actron-neo-api==0.1.84"] +} diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml new file mode 100644 index 00000000000000..06010dd3ed0233 --- /dev/null +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not have custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not have custom service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: This integration does not subscribe to external events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update. + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: + status: exempt + comment: This integration does not use entity categories. + entity-device-class: + status: exempt + comment: This integration does not use entity device classes. + entity-disabled-by-default: + status: exempt + comment: Not required for this integration at this stage. + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not have any known issues that require repair. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/actron_air/strings.json b/homeassistant/components/actron_air/strings.json new file mode 100644 index 00000000000000..989bcfb8054803 --- /dev/null +++ b/homeassistant/components/actron_air/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "Actron Air OAuth2 Authorization" + }, + "timeout": { + "title": "Authorization timeout", + "description": "The authorization process timed out. Please try again.", + "data": {} + }, + "connection_error": { + "title": "Connection error", + "description": "Failed to connect to Actron Air. Please check your internet connection and try again.", + "data": {} + } + }, + "progress": { + "wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes." + }, + "error": { + "oauth2_error": "Failed to start OAuth2 flow. Please try again later." + }, + "abort": { + "oauth2_error": "Failed to start OAuth2 flow", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py index 0b1245694c1ec1..baa1695d08e073 100644 --- a/homeassistant/components/airos/entity.py +++ b/homeassistant/components/airos/entity.py @@ -36,6 +36,11 @@ def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: identifiers={(DOMAIN, str(airos_data.host.device_id))}, manufacturer=MANUFACTURER, model=airos_data.host.devmodel, + model_id=( + sku + if (sku := airos_data.derived.sku) not in ["UNKNOWN", "AMBIGUOUS"] + else None + ), name=airos_data.host.hostname, sw_version=airos_data.host.fwversion, ) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 02a1ca997fb2f2..5113aaf939e5df 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -4,7 +4,8 @@ "codeowners": ["@CoMPaTech"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airos", + "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.5.5"] + "requirements": ["airos==0.5.6"] } diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 5bce846d3cbe02..3610688a113594 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.4.6"] + "requirements": ["aioairq==0.4.7"] } diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index ae6cbc1c82a46f..53c19ede366a88 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -7,13 +7,13 @@ from collections.abc import Awaitable, Callable, Coroutine import functools import logging -from typing import Any, cast +from typing import Any from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession from asusrouter import AsusRouter, AsusRouterError from asusrouter.config import ARConfigKey -from asusrouter.modules.client import AsusClient +from asusrouter.modules.client import AsusClient, ConnectionState from asusrouter.modules.data import AsusData from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors from asusrouter.tools.connection import get_cookie_jar @@ -219,7 +219,7 @@ def _get_api( @property def is_connected(self) -> bool: """Get connected status.""" - return cast(bool, self._api.is_connected) + return self._api.is_connected async def async_connect(self) -> None: """Connect to the device.""" @@ -235,8 +235,7 @@ async def async_connect(self) -> None: async def async_disconnect(self) -> None: """Disconnect to the device.""" - if self._api is not None and self._protocol == PROTOCOL_TELNET: - self._api.connection.disconnect() + await self._api.async_disconnect() async def async_get_connected_devices(self) -> dict[str, WrtDevice]: """Get list of connected devices.""" @@ -437,6 +436,7 @@ async def async_get_connected_devices(self) -> dict[str, WrtDevice]: if dev.connection is not None and dev.description is not None and dev.connection.ip_address is not None + and dev.state is ConnectionState.CONNECTED } async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 6273c77ca783ac..3f2872bf25868a 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asusrouter", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "asusrouter==1.21.0"] + "requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.0"] } diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 3559adfd976912..d4edd0d34f21ca 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -57,6 +57,7 @@ _get_manager, async_address_present, async_ble_device_from_address, + async_clear_address_from_match_history, async_current_scanners, async_discovered_service_info, async_get_advertisement_callback, @@ -115,6 +116,7 @@ "HomeAssistantRemoteScanner", "async_address_present", "async_ble_device_from_address", + "async_clear_address_from_match_history", "async_current_scanners", "async_discovered_service_info", "async_get_advertisement_callback", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 556ae2ac9fd2a2..c0ec6acf0a5233 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -193,6 +193,20 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None: _get_manager(hass).async_rediscover_address(address) +@hass_callback +def async_clear_address_from_match_history(hass: HomeAssistant, address: str) -> None: + """Clear an address from the integration matcher history. + + This allows future advertisements from this address to trigger discovery + even if the advertisement content has changed but the service data UUIDs + remain the same. + + Unlike async_rediscover_address, this does not immediately re-trigger + discovery with the current advertisement in history. + """ + _get_manager(hass).async_clear_address_from_match_history(address) + + @hass_callback def async_register_scanner( hass: HomeAssistant, diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index c43f7dd5fd7ffa..88f486fcc3519f 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -120,6 +120,19 @@ def async_rediscover_address(self, address: str) -> None: if service_info := self._all_history.get(address): self._async_trigger_matching_discovery(service_info) + @hass_callback + def async_clear_address_from_match_history(self, address: str) -> None: + """Clear an address from the integration matcher history. + + This allows future advertisements from this address to trigger discovery + even if the advertisement content has changed but the service data UUIDs + remain the same. + + Unlike async_rediscover_address, this does not immediately re-trigger + discovery with the current advertisement in history. + """ + self._integration_matcher.async_clear_address(address) + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: matched_domains = self._integration_matcher.match_domains(service_info) if self._debug: diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index c37fa4615f6388..c755f9dcd1fbe8 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -68,12 +68,17 @@ class IntegrationMatchHistory: manufacturer_data: bool service_data: set[str] service_uuids: set[str] + name: str def seen_all_fields( - previous_match: IntegrationMatchHistory, advertisement_data: AdvertisementData + previous_match: IntegrationMatchHistory, + advertisement_data: AdvertisementData, + name: str, ) -> bool: """Return if we have seen all fields.""" + if previous_match.name != name: + return False if not previous_match.manufacturer_data and advertisement_data.manufacturer_data: return False if advertisement_data.service_data and ( @@ -122,10 +127,11 @@ def match_domains(self, service_info: BluetoothServiceInfoBleak) -> set[str]: device = service_info.device advertisement_data = service_info.advertisement connectable = service_info.connectable + name = service_info.name matched = self._matched_connectable if connectable else self._matched matched_domains: set[str] = set() if (previous_match := matched.get(device.address)) and seen_all_fields( - previous_match, advertisement_data + previous_match, advertisement_data, name ): # We have seen all fields so we can skip the rest of the matchers return matched_domains @@ -140,11 +146,13 @@ def match_domains(self, service_info: BluetoothServiceInfoBleak) -> set[str]: ) previous_match.service_data |= set(advertisement_data.service_data) previous_match.service_uuids |= set(advertisement_data.service_uuids) + previous_match.name = name else: matched[device.address] = IntegrationMatchHistory( manufacturer_data=bool(advertisement_data.manufacturer_data), service_data=set(advertisement_data.service_data), service_uuids=set(advertisement_data.service_uuids), + name=name, ) return matched_domains diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 134c912751238e..e9d24d453e565c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.2.0"], + "requirements": ["hass-nabucasa==1.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index e276450f4fc05f..3bd3e2aa57a9e3 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==1.1.1"] + "requirements": ["aiocomelit==1.1.2"] } diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index 577aa4e2cf16de..91d4916809eff1 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -138,7 +138,7 @@ def new_device_listener( data_type: str, ) -> Callable[[], None]: """Subscribe to coordinator updates to check for new devices.""" - known_devices: set[int] = set() + known_devices: dict[str, list[int]] = {} def _check_devices() -> None: """Check for new devices and call callback with any new monitors.""" @@ -147,8 +147,8 @@ def _check_devices() -> None: new_devices: list[DeviceType] = [] for _id in coordinator.data[data_type]: - if _id not in known_devices: - known_devices.add(_id) + if _id not in (id_list := known_devices.get(data_type, [])): + known_devices.update({data_type: [*id_list, _id]}) new_devices.append(coordinator.data[data_type][_id]) if new_devices: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 0e18b416db9d80..32f9d0ff5a5926 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==41.14.0", + "aioesphomeapi==41.15.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.4.0" ], diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 80b6ec96e835a2..c4380ffb09788b 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -35,9 +35,16 @@ class FoscamDeviceInfo: is_turn_off_volume: bool is_turn_off_light: bool supports_speak_volume_adjustment: bool + supports_pet_adjustment: bool + supports_car_adjustment: bool + supports_wdr_adjustment: bool + supports_hdr_adjustment: bool is_open_wdr: bool | None = None is_open_hdr: bool | None = None + is_pet_detection_on: bool | None = None + is_car_detection_on: bool | None = None + is_human_detection_on: bool | None = None class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]): @@ -107,14 +114,15 @@ def gather_all_configs(self) -> FoscamDeviceInfo: is_open_wdr = None is_open_hdr = None - reserve3 = product_info.get("reserve3") + reserve3 = product_info.get("reserve4") reserve3_int = int(reserve3) if reserve3 is not None else 0 - - if (reserve3_int & (1 << 8)) != 0: + supports_wdr_adjustment_val = bool(int(reserve3_int & 256)) + supports_hdr_adjustment_val = bool(int(reserve3_int & 128)) + if supports_wdr_adjustment_val: ret_wdr, is_open_wdr_data = self.session.getWdrMode() mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 is_open_wdr = bool(int(mode)) - else: + elif supports_hdr_adjustment_val: ret_hdr, is_open_hdr_data = self.session.getHdrMode() mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 is_open_hdr = bool(int(mode)) @@ -126,6 +134,34 @@ def gather_all_configs(self) -> FoscamDeviceInfo: if ret_sw == 0 else False ) + pet_adjustment_val = ( + bool(int(software_capabilities.get("swCapabilities2")) & 512) + if ret_sw == 0 + else False + ) + car_adjustment_val = ( + bool(int(software_capabilities.get("swCapabilities2")) & 256) + if ret_sw == 0 + else False + ) + ret_md, mothion_config_val = self.session.get_motion_detect_config() + if pet_adjustment_val: + is_pet_detection_on_val = ( + mothion_config_val["petEnable"] == "1" if ret_md == 0 else False + ) + else: + is_pet_detection_on_val = False + + if car_adjustment_val: + is_car_detection_on_val = ( + mothion_config_val["carEnable"] == "1" if ret_md == 0 else False + ) + else: + is_car_detection_on_val = False + + is_human_detection_on_val = ( + mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False + ) return FoscamDeviceInfo( dev_info=dev_info, @@ -141,8 +177,15 @@ def gather_all_configs(self) -> FoscamDeviceInfo: is_turn_off_volume=is_turn_off_volume_val, is_turn_off_light=is_turn_off_light_val, supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, + supports_pet_adjustment=pet_adjustment_val, + supports_car_adjustment=car_adjustment_val, + supports_hdr_adjustment=supports_hdr_adjustment_val, + supports_wdr_adjustment=supports_wdr_adjustment_val, is_open_wdr=is_open_wdr, is_open_hdr=is_open_hdr, + is_pet_detection_on=is_pet_detection_on_val, + is_car_detection_on=is_car_detection_on_val, + is_human_detection_on=is_human_detection_on_val, ) async def _async_update_data(self) -> FoscamDeviceInfo: diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json index 7dbd874b2f6840..de9e13faa0002a 100644 --- a/homeassistant/components/foscam/icons.json +++ b/homeassistant/components/foscam/icons.json @@ -38,6 +38,15 @@ }, "wdr_switch": { "default": "mdi:alpha-w-box" + }, + "pet_detection": { + "default": "mdi:paw" + }, + "car_detection": { + "default": "mdi:car-hatchback" + }, + "human_detection": { + "default": "mdi:human" } }, "number": { diff --git a/homeassistant/components/foscam/number.py b/homeassistant/components/foscam/number.py index e828955870d07b..a693685c67e1d3 100644 --- a/homeassistant/components/foscam/number.py +++ b/homeassistant/components/foscam/number.py @@ -22,7 +22,7 @@ class FoscamNumberEntityDescription(NumberEntityDescription): native_value_fn: Callable[[FoscamCoordinator], int] set_value_fn: Callable[[FoscamCamera, float], Any] - exists_fn: Callable[[FoscamCoordinator], bool] + exists_fn: Callable[[FoscamCoordinator], bool] = lambda _: True NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [ @@ -34,7 +34,6 @@ class FoscamNumberEntityDescription(NumberEntityDescription): native_step=1, native_value_fn=lambda coordinator: coordinator.data.device_volume, set_value_fn=lambda session, value: session.setAudioVolume(value), - exists_fn=lambda _: True, ), FoscamNumberEntityDescription( key="speak_volume", diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 86a5ba59c0a846..e42a74a2683acd 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -61,6 +61,15 @@ }, "wdr_switch": { "name": "WDR" + }, + "pet_detection": { + "name": "Pet detection" + }, + "car_detection": { + "name": "Car detection" + }, + "human_detection": { + "name": "Human detection" } }, "number": { diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 8407da8edd3a51..dbfdad2e0e0d30 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -30,6 +30,14 @@ def handle_ir_turn_off(session: FoscamCamera) -> None: session.close_infra_led() +def set_motion_detection(session: FoscamCamera, field: str, enabled: bool) -> None: + """Turns on pet detection.""" + ret, config = session.get_motion_detect_config() + if not ret: + config[field] = int(enabled) + session.set_motion_detect_config(config) + + @dataclass(frozen=True, kw_only=True) class FoscamSwitchEntityDescription(SwitchEntityDescription): """A custom entity description that supports a turn_off function.""" @@ -37,6 +45,7 @@ class FoscamSwitchEntityDescription(SwitchEntityDescription): native_value_fn: Callable[..., bool] turn_off_fn: Callable[[FoscamCamera], None] turn_on_fn: Callable[[FoscamCamera], None] + exists_fn: Callable[[FoscamCoordinator], bool] = lambda _: True SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [ @@ -102,6 +111,7 @@ class FoscamSwitchEntityDescription(SwitchEntityDescription): native_value_fn=lambda data: data.is_open_hdr, turn_off_fn=lambda session: session.setHdrMode(0), turn_on_fn=lambda session: session.setHdrMode(1), + exists_fn=lambda coordinator: coordinator.data.supports_hdr_adjustment, ), FoscamSwitchEntityDescription( key="is_open_wdr", @@ -109,6 +119,30 @@ class FoscamSwitchEntityDescription(SwitchEntityDescription): native_value_fn=lambda data: data.is_open_wdr, turn_off_fn=lambda session: session.setWdrMode(0), turn_on_fn=lambda session: session.setWdrMode(1), + exists_fn=lambda coordinator: coordinator.data.supports_wdr_adjustment, + ), + FoscamSwitchEntityDescription( + key="pet_detection", + translation_key="pet_detection", + native_value_fn=lambda data: data.is_pet_detection_on, + turn_off_fn=lambda session: set_motion_detection(session, "petEnable", False), + turn_on_fn=lambda session: set_motion_detection(session, "petEnable", True), + exists_fn=lambda coordinator: coordinator.data.supports_pet_adjustment, + ), + FoscamSwitchEntityDescription( + key="car_detection", + translation_key="car_detection", + native_value_fn=lambda data: data.is_car_detection_on, + turn_off_fn=lambda session: set_motion_detection(session, "carEnable", False), + turn_on_fn=lambda session: set_motion_detection(session, "carEnable", True), + exists_fn=lambda coordinator: coordinator.data.supports_car_adjustment, + ), + FoscamSwitchEntityDescription( + key="human_detection", + translation_key="human_detection", + native_value_fn=lambda data: data.is_human_detection_on, + turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False), + turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True), ), ] @@ -122,24 +156,11 @@ async def async_setup_entry( coordinator = config_entry.runtime_data - entities = [] - - product_info = coordinator.data.product_info - reserve3 = product_info.get("reserve3", "0") - - for description in SWITCH_DESCRIPTIONS: - if description.key == "is_asleep": - if not coordinator.data.is_asleep["supported"]: - continue - elif description.key == "is_open_hdr": - if ((1 << 8) & int(reserve3)) != 0 or ((1 << 7) & int(reserve3)) == 0: - continue - elif description.key == "is_open_wdr": - if ((1 << 8) & int(reserve3)) == 0: - continue - - entities.append(FoscamGenericSwitch(coordinator, description)) - async_add_entities(entities) + async_add_entities( + FoscamGenericSwitch(coordinator, description) + for description in SWITCH_DESCRIPTIONS + if description.exists_fn(coordinator) + ) class FoscamGenericSwitch(FoscamEntity, SwitchEntity): diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 0621ca369db8b8..aacf017eeddc01 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -191,7 +191,9 @@ async def async_test_still( try: async_client = get_async_client(hass, verify_ssl=verify_ssl) async with asyncio.timeout(GET_IMAGE_TIMEOUT): - response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT) + response = await async_client.get( + url, auth=auth, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True + ) response.raise_for_status() image = response.content except ( diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 8689421b2ce311..9d8bf4b6e3a3df 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -36,7 +36,7 @@ DOMAIN = "growatt_server" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] LOGIN_INVALID_AUTH_CODE = "502" diff --git a/homeassistant/components/growatt_server/sensor/tlx.py b/homeassistant/components/growatt_server/sensor/tlx.py index bf8746e08aca8a..298170531de301 100644 --- a/homeassistant/components/growatt_server/sensor/tlx.py +++ b/homeassistant/components/growatt_server/sensor/tlx.py @@ -210,6 +210,15 @@ device_class=SensorDeviceClass.POWER, precision=1, ), + GrowattSensorEntityDescription( + key="tlx_solar_generation_today", + translation_key="tlx_solar_generation_today", + api_key="epvToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), GrowattSensorEntityDescription( key="tlx_solar_generation_total", translation_key="tlx_solar_generation_total", @@ -430,4 +439,120 @@ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + GrowattSensorEntityDescription( + key="tlx_pac_to_local_load", + translation_key="tlx_pac_to_local_load", + api_key="pacToLocalLoad", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_pac_to_user_total", + translation_key="tlx_pac_to_user_total", + api_key="pacToUserTotal", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_pac_to_grid_total", + translation_key="tlx_pac_to_grid_total", + api_key="pacToGridTotal", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_system_production_today", + translation_key="tlx_system_production_today", + api_key="esystemToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_system_production_total", + translation_key="tlx_system_production_total", + api_key="esystemTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + never_resets=True, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_self_consumption_today", + translation_key="tlx_self_consumption_today", + api_key="eselfToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_self_consumption_total", + translation_key="tlx_self_consumption_total", + api_key="eselfTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + never_resets=True, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_import_from_grid_today", + translation_key="tlx_import_from_grid_today", + api_key="etoUserToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_import_from_grid_total", + translation_key="tlx_import_from_grid_total", + api_key="etoUserTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + never_resets=True, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_batteries_charged_from_grid_today", + translation_key="tlx_batteries_charged_from_grid_today", + api_key="eacChargeToday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_batteries_charged_from_grid_total", + translation_key="tlx_batteries_charged_from_grid_total", + api_key="eacChargeTotal", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + never_resets=True, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_p_system", + translation_key="tlx_p_system", + api_key="psystem", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), + GrowattSensorEntityDescription( + key="tlx_p_self", + translation_key="tlx_p_self", + api_key="pself", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + precision=1, + ), ) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index fdede7fe115ede..d353350c968a68 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -362,6 +362,9 @@ "tlx_wattage_input_4": { "name": "Input 4 wattage" }, + "tlx_solar_generation_today": { + "name": "Solar energy today" + }, "tlx_solar_generation_total": { "name": "Lifetime total solar energy" }, @@ -443,6 +446,45 @@ "tlx_statement_of_charge": { "name": "State of charge (SoC)" }, + "tlx_pac_to_local_load": { + "name": "Local load power" + }, + "tlx_pac_to_user_total": { + "name": "Import power" + }, + "tlx_pac_to_grid_total": { + "name": "Export power" + }, + "tlx_system_production_today": { + "name": "System production today" + }, + "tlx_system_production_total": { + "name": "Lifetime system production" + }, + "tlx_self_consumption_today": { + "name": "Self consumption today" + }, + "tlx_self_consumption_total": { + "name": "Lifetime self consumption" + }, + "tlx_import_from_grid_today": { + "name": "Import from grid today" + }, + "tlx_import_from_grid_total": { + "name": "Lifetime import from grid" + }, + "tlx_batteries_charged_from_grid_today": { + "name": "Batteries charged from grid today" + }, + "tlx_batteries_charged_from_grid_total": { + "name": "Lifetime batteries charged from grid" + }, + "tlx_p_system": { + "name": "System power" + }, + "tlx_p_self": { + "name": "Self power" + }, "total_money_today": { "name": "Total money today" }, @@ -461,6 +503,11 @@ "total_maximum_output": { "name": "Maximum power" } + }, + "switch": { + "ac_charge": { + "name": "Charge from grid" + } } } } diff --git a/homeassistant/components/growatt_server/switch.py b/homeassistant/components/growatt_server/switch.py new file mode 100644 index 00000000000000..59cc2535da38e7 --- /dev/null +++ b/homeassistant/components/growatt_server/switch.py @@ -0,0 +1,138 @@ +"""Switch platform for Growatt.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Any + +from growattServer import GrowattV1ApiError + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GrowattConfigEntry, GrowattCoordinator +from .sensor.sensor_entity_description import GrowattRequiredKeysMixin + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = ( + 1 # Serialize updates as inverter does not handle concurrent requests +) + + +@dataclass(frozen=True, kw_only=True) +class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin): + """Describes Growatt switch entity.""" + + write_key: str | None = None # Parameter ID for writing (if different from api_key) + + +# Note that the Growatt V1 API uses different keys for reading and writing parameters. +# Reading values returns camelCase keys, while writing requires snake_case keys. + +MIN_SWITCH_TYPES: tuple[GrowattSwitchEntityDescription, ...] = ( + GrowattSwitchEntityDescription( + key="ac_charge", + translation_key="ac_charge", + api_key="acChargeEnable", # Key returned by V1 API + write_key="ac_charge", # Key used to write parameter + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GrowattConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Growatt switch entities.""" + runtime_data = entry.runtime_data + + # Add switch entities for each MIN device (only supported with V1 API) + async_add_entities( + GrowattSwitch(device_coordinator, description) + for device_coordinator in runtime_data.devices.values() + if ( + device_coordinator.device_type == "min" + and device_coordinator.api_version == "v1" + ) + for description in MIN_SWITCH_TYPES + ) + + +class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity): + """Representation of a Growatt switch.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + entity_description: GrowattSwitchEntityDescription + + def __init__( + self, + coordinator: GrowattCoordinator, + description: GrowattSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + @property + def is_on(self) -> bool | None: + """Return true if the switch is on.""" + value = self.coordinator.data.get(self.entity_description.api_key) + if value is None: + return None + + # API returns integer 1 for enabled, 0 for disabled + return bool(value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_state(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_set_state(False) + + async def _async_set_state(self, state: bool) -> None: + """Set the switch state.""" + # Use write_key if specified, otherwise fall back to api_key + parameter_id = ( + self.entity_description.write_key or self.entity_description.api_key + ) + api_value = int(state) + + try: + # Use V1 API to write parameter + await self.hass.async_add_executor_job( + self.coordinator.api.min_write_parameter, + self.coordinator.device_id, + parameter_id, + api_value, + ) + except GrowattV1ApiError as e: + raise HomeAssistantError(f"Error while setting switch state: {e}") from e + + # If no exception was raised, the write was successful + _LOGGER.debug( + "Set switch %s to %s", + parameter_id, + api_value, + ) + + # Update the value in coordinator data (keep as integer like API returns) + self.coordinator.data[self.entity_description.api_key] = api_value + self.async_write_ha_state() diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 71ba8e60e067cd..b8641deb9c2f45 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -4,6 +4,7 @@ from enum import StrEnum import logging +import math from typing import TYPE_CHECKING from uuid import UUID @@ -281,7 +282,7 @@ def todo_items(self) -> list[TodoItem]: return sorted( tasks, key=lambda task: ( - float("inf") + math.inf if (uid := UUID(task.uid)) not in (tasks_order := self.coordinator.data.user.tasksOrder.todos) else tasks_order.index(uid) @@ -367,7 +368,7 @@ def todo_items(self) -> list[TodoItem]: return sorted( tasks, key=lambda task: ( - float("inf") + math.inf if (uid := UUID(task.uid)) not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys) else tasks_order.index(uid) diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py index 1d95601211e445..ca5a4ae307e607 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py +++ b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py @@ -7,11 +7,18 @@ from homeassistant.components import usb from homeassistant.components.homeassistant_hardware import firmware_config_flow +from homeassistant.components.homeassistant_hardware.helpers import ( + HardwareFirmwareDiscoveryInfo, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ResetTarget, ) +from homeassistant.components.usb import ( + usb_service_info_from_device, + usb_unique_id_from_service_info, +) from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -123,22 +130,16 @@ def async_get_options_flow( async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" - device = discovery_info.device - vid = discovery_info.vid - pid = discovery_info.pid - serial_number = discovery_info.serial_number - manufacturer = discovery_info.manufacturer - description = discovery_info.description - unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" - - device = discovery_info.device = await self.hass.async_add_executor_job( + unique_id = usb_unique_id_from_service_info(discovery_info) + + discovery_info.device = await self.hass.async_add_executor_job( usb.get_serial_by_id, discovery_info.device ) try: await self.async_set_unique_id(unique_id) finally: - self._abort_if_unique_id_configured(updates={DEVICE: device}) + self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device}) self._usb_info = discovery_info @@ -148,6 +149,24 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu return await self.async_step_confirm() + async def async_step_import( + self, fw_discovery_info: HardwareFirmwareDiscoveryInfo + ) -> ConfigFlowResult: + """Handle import from ZHA/OTBR firmware notification.""" + assert fw_discovery_info["usb_device"] is not None + usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"]) + unique_id = usb_unique_id_from_service_info(usb_info) + + if await self.async_set_unique_id(unique_id, raise_on_progress=False): + self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device}) + + self._usb_info = usb_info + self._device = usb_info.device + self._hardware_name = HARDWARE_NAME + self._probed_firmware_info = fw_discovery_info["firmware_info"] + + return self._async_flow_finished() + def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" assert self._usb_info is not None diff --git a/homeassistant/components/homeassistant_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index a3c091ff7eed51..5b5b509ae1fa6e 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -19,6 +19,12 @@ ZHA_DOMAIN = "zha" OTBR_DOMAIN = "otbr" +HARDWARE_INTEGRATION_DOMAINS = { + "homeassistant_sky_connect", + "homeassistant_connect_zbt2", + "homeassistant_yellow", +} + OTBR_ADDON_NAME = "OpenThread Border Router" OTBR_ADDON_MANAGER_DATA = "openthread_border_router" OTBR_ADDON_SLUG = "core_openthread_border_router" diff --git a/homeassistant/components/homeassistant_hardware/helpers.py b/homeassistant/components/homeassistant_hardware/helpers.py index 57558a1b0e7205..51932ee3ee6110 100644 --- a/homeassistant/components/homeassistant_hardware/helpers.py +++ b/homeassistant/components/homeassistant_hardware/helpers.py @@ -6,19 +6,33 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager import logging -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, TypedDict -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.usb import ( + USBDevice, + async_get_usb_matchers_for_device, + usb_device_from_path, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from . import DATA_COMPONENT +from .const import HARDWARE_INTEGRATION_DOMAINS if TYPE_CHECKING: from .util import FirmwareInfo + _LOGGER = logging.getLogger(__name__) +class HardwareFirmwareDiscoveryInfo(TypedDict): + """Data for triggering hardware integration discovery via firmware notification.""" + + usb_device: USBDevice | None + firmware_info: FirmwareInfo + + class SyncHardwareFirmwareInfoModule(Protocol): """Protocol type for Home Assistant Hardware firmware info platform modules.""" @@ -46,6 +60,23 @@ async def async_get_firmware_info( ) +@hass_callback +def async_get_hardware_domain_for_usb_device( + hass: HomeAssistant, usb_device: USBDevice +) -> str | None: + """Identify which hardware domain should handle a USB device.""" + matched = async_get_usb_matchers_for_device(hass, usb_device) + hw_domains = {match["domain"] for match in matched} & HARDWARE_INTEGRATION_DOMAINS + + if not hw_domains: + return None + + # We can never have two hardware integrations overlap in discovery + assert len(hw_domains) == 1 + + return list(hw_domains)[0] + + class HardwareInfoDispatcher: """Central dispatcher for hardware/firmware information.""" @@ -94,7 +125,7 @@ async def notify_firmware_info( "Received firmware info notification from %r: %s", domain, firmware_info ) - for callback in self._notification_callbacks.get(firmware_info.device, []): + for callback in list(self._notification_callbacks[firmware_info.device]): try: callback(firmware_info) except Exception: @@ -102,6 +133,48 @@ async def notify_firmware_info( "Error while notifying firmware info listener %s", callback ) + await self._async_trigger_hardware_discovery(firmware_info) + + async def _async_trigger_hardware_discovery( + self, firmware_info: FirmwareInfo + ) -> None: + """Trigger hardware integration config flows from firmware info. + + Identifies which hardware integration should handle the device based on + USB matchers, then triggers an import flow for only that integration. + """ + + usb_device = await self.hass.async_add_executor_job( + usb_device_from_path, firmware_info.device + ) + + if usb_device is None: + _LOGGER.debug("Cannot find USB for path %s", firmware_info.device) + return + + hardware_domain = async_get_hardware_domain_for_usb_device( + self.hass, usb_device + ) + + if hardware_domain is None: + _LOGGER.debug("No hardware integration found for device %s", usb_device) + return + + _LOGGER.debug( + "Triggering %s import flow for device %s", + hardware_domain, + firmware_info.device, + ) + + await self.hass.config_entries.flow.async_init( + hardware_domain, + context={"source": SOURCE_IMPORT}, + data=HardwareFirmwareDiscoveryInfo( + usb_device=usb_device, + firmware_info=firmware_info, + ), + ) + async def iter_firmware_info(self) -> AsyncIterator[FirmwareInfo]: """Iterate over all firmware information for all hardware.""" for domain, fw_info_module in self._providers.items(): diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 192aecc93bf985..01c27300d712fd 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -3,6 +3,7 @@ "name": "Home Assistant Hardware", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 7a9eff0b741396..1d8095d80a9afa 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -10,10 +10,17 @@ firmware_config_flow, silabs_multiprotocol_addon, ) +from homeassistant.components.homeassistant_hardware.helpers import ( + HardwareFirmwareDiscoveryInfo, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) +from homeassistant.components.usb import ( + usb_service_info_from_device, + usb_unique_id_from_service_info, +) from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -142,16 +149,10 @@ def async_get_options_flow( async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle usb discovery.""" - device = discovery_info.device - vid = discovery_info.vid - pid = discovery_info.pid - serial_number = discovery_info.serial_number - manufacturer = discovery_info.manufacturer - description = discovery_info.description - unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" + unique_id = usb_unique_id_from_service_info(discovery_info) if await self.async_set_unique_id(unique_id): - self._abort_if_unique_id_configured(updates={DEVICE: device}) + self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device}) discovery_info.device = await self.hass.async_add_executor_job( usb.get_serial_by_id, discovery_info.device @@ -159,8 +160,10 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu self._usb_info = discovery_info - assert description is not None - self._hw_variant = HardwareVariant.from_usb_product_name(description) + assert discovery_info.description is not None + self._hw_variant = HardwareVariant.from_usb_product_name( + discovery_info.description + ) # Set parent class attributes self._device = self._usb_info.device @@ -168,6 +171,26 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu return await self.async_step_confirm() + async def async_step_import( + self, fw_discovery_info: HardwareFirmwareDiscoveryInfo + ) -> ConfigFlowResult: + """Handle import from ZHA/OTBR firmware notification.""" + assert fw_discovery_info["usb_device"] is not None + usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"]) + unique_id = usb_unique_id_from_service_info(usb_info) + + if await self.async_set_unique_id(unique_id, raise_on_progress=False): + self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device}) + + self._usb_info = usb_info + assert usb_info.description is not None + self._hw_variant = HardwareVariant.from_usb_product_name(usb_info.description) + self._device = usb_info.device + self._hardware_name = self._hw_variant.full_name + self._probed_firmware_info = fw_discovery_info["firmware_info"] + + return self._async_flow_finished() + def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" assert self._usb_info is not None diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 27c40e35946b8b..07fe496b049e31 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -41,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: firmware = ApplicationType(entry.data[FIRMWARE]) + # Auto start the multiprotocol addon if it is in use if firmware is ApplicationType.CPC: try: await check_multi_pan_addon(hass) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 0dcefba64284df..66e95f6d028b4c 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -138,11 +138,23 @@ def _abort_if_provisioned(self) -> None: _LOGGER.debug( ( "Aborting improv flow, device with bluetooth address '%s' is " - "already provisioned: %s" + "already provisioned: %s; clearing match history to allow " + "rediscovery if device is factory reset" ), self._discovery_info.address, improv_service_data.state, ) + # Clear match history so device can be rediscovered if factory reset. + # This is safe to do on every abort because: + # 1. While device stays provisioned, the Bluetooth matcher won't trigger + # new discoveries since the advertisement content hasn't changed + # 2. If device is factory reset (state changes to authorized), the + # matcher will see new content and trigger discovery since we cleared + # the history + # 3. No ongoing monitoring or callbacks - zero performance overhead + bluetooth.async_clear_address_from_match_history( + self.hass, self._discovery_info.address + ) raise AbortFlow("already_provisioned") @callback @@ -158,6 +170,12 @@ def _async_update_ble( ) self._discovery_info = service_info + + # Update title placeholders if name changed + name = service_info.name or service_info.address + if self.context.get("title_placeholders", {}).get("name") != name: + self.async_update_title_placeholders({"name": name}) + try: self._abort_if_provisioned() except AbortFlow: @@ -180,6 +198,14 @@ async def async_step_bluetooth( self._abort_if_unique_id_configured() self._abort_if_provisioned() + # Clear match history at the start of discovery flow. + # This ensures that if the user never provisions the device and it + # disappears (powers down), the discovery flow gets cleaned up, + # and then the device comes back later, it can be rediscovered. + bluetooth.async_clear_address_from_match_history( + self.hass, discovery_info.address + ) + self._remove_bluetooth_callback = bluetooth.async_register_callback( self.hass, self._async_update_ble, @@ -317,6 +343,13 @@ async def _do_provision() -> None: return else: _LOGGER.debug("Provision successful, redirect URL: %s", redirect_url) + # Clear match history so device can be rediscovered if factory reset. + # This ensures that if the device is factory reset in the future, + # it will trigger a new discovery flow. + assert self._discovery_info is not None + bluetooth.async_clear_address_from_match_history( + self.hass, self._discovery_info.address + ) # Abort all flows in progress with same unique ID for flow in self._async_in_progress(include_uninitialized=True): flow_unique_id = flow["context"].get("unique_id") diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 947d382a12c5d3..d6f8fa62915b8f 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -125,6 +125,7 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE], context_timeout=config.get(CONF_CONTEXT_TIMEOUT), reset_after=config.get(CONF_RESET_AFTER), + always_callback=True, ), ) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) @@ -159,5 +160,6 @@ def __init__( ), context_timeout=knx_conf.get(CONF_CONTEXT_TIMEOUT), reset_after=knx_conf.get(CONF_RESET_AFTER), + always_callback=True, ) self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 4299b8b9695936..bccc00250048ba 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "silver", "requirements": [ - "xknx==3.9.1", + "xknx==3.10.0", "xknxproject==3.8.2", "knx-frontend==2025.10.9.185845" ], diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index e6dc0c1bb3e361..794d875132723a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -4,6 +4,7 @@ from abc import ABC from collections import OrderedDict +import math from typing import ClassVar, Final import voluptuous as vol @@ -86,7 +87,7 @@ def number_limit_sub_validator(entity_config: OrderedDict) -> OrderedDict: raise vol.Invalid(f"'type: {value_type}' is not a valid numeric sensor type.") # Infinity is not supported by Home Assistant frontend so user defined # config is required if if xknx DPTNumeric subclass defines it as limit. - if min_config is None and dpt_class.value_min == float("-inf"): + if min_config is None and dpt_class.value_min == -math.inf: raise vol.Invalid(f"'min' key required for value type '{value_type}'") if min_config is not None and min_config < dpt_class.value_min: raise vol.Invalid( @@ -94,7 +95,7 @@ def number_limit_sub_validator(entity_config: OrderedDict) -> OrderedDict: f" of value type '{value_type}': {dpt_class.value_min}" ) - if max_config is None and dpt_class.value_max == float("inf"): + if max_config is None and dpt_class.value_max == math.inf: raise vol.Invalid(f"'max' key required for value type '{value_type}'") if max_config is not None and max_config > dpt_class.value_max: raise vol.Invalid( diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 13556e8293c1b2..8e7086eef406d1 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -35,7 +35,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterBinarySensorEntityDescription( BinarySensorEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index f75c5063c064a9..73cbf6a24c9f4a 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -33,7 +33,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BUTTON, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterButtonEntityDescription(ButtonEntityDescription, MatterEntityDescription): """Describe Matter Button entities.""" diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index ec07a564621e07..d9fd62fa210f63 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -183,7 +183,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.CLIMATE, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterClimateEntityDescription(ClimateEntityDescription, MatterEntityDescription): """Describe Matter Climate entities.""" diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index ac182c798d4be7..2d81577772a9dc 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -62,7 +62,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.COVER, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterCoverEntityDescription(CoverEntityDescription, MatterEntityDescription): """Describe Matter Cover entities.""" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 028feab9c883e9..c349a6b1fda723 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -54,7 +54,7 @@ async def wrapper(self: MatterEntity, *args: P.args, **kwargs: P.kwargs) -> _R: return wrapper -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 6f4205772a47eb..d840daad8ba9b4 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -47,7 +47,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.EVENT, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterEventEntityDescription(EventEntityDescription, MatterEntityDescription): """Describe Matter Event entities.""" diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index a0e85a2df2f4de..823451113e0edd 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -53,7 +53,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.FAN, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterFanEntityDescription(FanEntityDescription, MatterEntityDescription): """Describe Matter Fan entities.""" diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 0a870167d465bc..89014e48a17d62 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -86,7 +86,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.LIGHT, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterLightEntityDescription(LightEntityDescription, MatterEntityDescription): """Describe Matter Light entities.""" diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 230714accbd7af..330735f338b066 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -53,7 +53,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.LOCK, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterLockEntityDescription(LockEntityDescription, MatterEntityDescription): """Describe Matter Lock entities.""" diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4162d406e7cb83..733779e19f8f3a 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -44,7 +44,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.NUMBER, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescription): """Describe Matter Number Input entities.""" diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 665e9041be72e1..12e228dfd0a1ba 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -62,7 +62,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SELECT, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescription): """Describe Matter select entities.""" diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index b3b81583b19f98..8784a1e0696002 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -166,7 +166,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): """Describe Matter sensor entities.""" diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index d8ae8d66f2bff8..ee906662de50a7 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -42,7 +42,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SWITCH, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterSwitchEntityDescription(SwitchEntityDescription, MatterEntityDescription): """Describe Matter Switch entities.""" @@ -120,7 +120,7 @@ async def send_device_command( ) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterGenericCommandSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription ): @@ -132,7 +132,7 @@ class MatterGenericCommandSwitchEntityDescription( command_timeout: int | None = None -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterNumericSwitchEntityDescription( SwitchEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 17506fe4d4cb5c..e100bf444935b1 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -67,7 +67,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.UPDATE, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterUpdateEntityDescription(UpdateEntityDescription, MatterEntityDescription): """Describe Matter Update entities.""" diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 2e4ee41b7ec3a2..93922fde0f6f35 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -59,7 +59,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.VACUUM, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterStateVacuumEntityDescription( StateVacuumEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index be7a382307bd92..ce9f16921de47c 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -36,7 +36,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.VALVE, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterValveEntityDescription(ValveEntityDescription, MatterEntityDescription): """Describe Matter Valve entities.""" diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py index f761af9427d195..bd83195c2be95b 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -51,7 +51,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.WATER_HEATER, async_add_entities) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class MatterWaterHeaterEntityDescription( WaterHeaterEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py index eb0a1fe49c3c45..debb1334706e90 100644 --- a/homeassistant/components/miele/diagnostics.py +++ b/homeassistant/components/miele/diagnostics.py @@ -65,6 +65,7 @@ async def async_get_device_diagnostics( info = { "manufacturer": device.manufacturer, "model": device.model, + "model_id": device.model_id, } coordinator = config_entry.runtime_data diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index 8cb4db6bbe0be6..ff2207fd0aa315 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -40,7 +40,13 @@ def __init__( name=device.device_name or appliance_type or device.tech_type, translation_key=None if device.device_name else appliance_type, manufacturer=MANUFACTURER, - model=device.tech_type, + model=( + appliance_type.capitalize().replace("_", " ") + if appliance_type + else None + ) + or device.tech_type, + model_id=device.tech_type, hw_version=device.xkm_tech_type, sw_version=device.xkm_release_version, ) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 64601433e7ee25..62105459b89130 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -470,6 +470,26 @@ EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} PWD_NOT_CHANGED = "__**password_not_changed**__" +DEVELOPER_DOCUMENTATION_URL = "https://developers.home-assistant.io/" +USER_DOCUMENTATION_URL = "https://www.home-assistant.io/" + +INTEGRATION_URL = f"{USER_DOCUMENTATION_URL}integrations/{DOMAIN}/" +TEMPLATING_URL = f"{USER_DOCUMENTATION_URL}docs/configuration/templating/" +AVAILABLE_STATE_CLASSES_URL = ( + f"{DEVELOPER_DOCUMENTATION_URL}docs/core/entity/sensor/#available-state-classes" +) +NAMING_ENTITIES_URL = f"{INTEGRATION_URL}#naming-of-mqtt-entities" +REGISTRY_PROPERTIES_URL = ( + f"{DEVELOPER_DOCUMENTATION_URL}docs/core/entity/#registry-properties" +) + +TRANSLATION_DESCRIPTION_PLACEHOLDERS = { + "templating_url": TEMPLATING_URL, + "available_state_classes_url": AVAILABLE_STATE_CLASSES_URL, + "naming_entities_url": NAMING_ENTITIES_URL, + "registry_properties_url": REGISTRY_PROPERTIES_URL, +} + # Common selectors BOOLEAN_SELECTOR = BooleanSelector() TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) @@ -4248,7 +4268,8 @@ async def async_step_entity_platform_config( return self.async_show_form( step_id="entity_platform_config", data_schema=data_schema, - description_placeholders={ + description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS + | { "mqtt_device": device_name, CONF_PLATFORM: platform, "entity": full_entity_name, @@ -4301,7 +4322,8 @@ async def async_step_mqtt_platform_config( return self.async_show_form( step_id="mqtt_platform_config", data_schema=data_schema, - description_placeholders={ + description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS + | { "mqtt_device": device_name, CONF_PLATFORM: platform, "entity": full_entity_name, @@ -4517,9 +4539,7 @@ async def async_step_export_yaml( step_id="export_yaml", last_step=False, data_schema=data_schema, - description_placeholders={ - "url": "https://www.home-assistant.io/integrations/mqtt/" - }, + description_placeholders={"url": INTEGRATION_URL}, ) async def async_step_export_discovery( @@ -4571,9 +4591,7 @@ async def async_step_export_discovery( step_id="export_discovery", last_step=False, data_schema=data_schema, - description_placeholders={ - "url": "https://www.home-assistant.io/integrations/mqtt/" - }, + description_placeholders={"url": INTEGRATION_URL}, ) diff --git a/homeassistant/components/nintendo_parental_controls/__init__.py b/homeassistant/components/nintendo_parental_controls/__init__.py index 974bd15ea49dc9..54ed6e2f28f6ff 100644 --- a/homeassistant/components/nintendo_parental_controls/__init__.py +++ b/homeassistant/components/nintendo_parental_controls/__init__.py @@ -16,7 +16,7 @@ from .const import CONF_SESSION_TOKEN, DOMAIN from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TIME] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TIME, Platform.SWITCH] async def async_setup_entry( diff --git a/homeassistant/components/nintendo_parental_controls/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json index 9416a31c5adb95..2617a6464bd55d 100644 --- a/homeassistant/components/nintendo_parental_controls/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -43,6 +43,11 @@ "bedtime_alarm": { "name": "Bedtime alarm" } + }, + "switch": { + "suspend_software": { + "name": "Suspend software" + } } }, "exceptions": { diff --git a/homeassistant/components/nintendo_parental_controls/switch.py b/homeassistant/components/nintendo_parental_controls/switch.py new file mode 100644 index 00000000000000..c9bd10518735cf --- /dev/null +++ b/homeassistant/components/nintendo_parental_controls/switch.py @@ -0,0 +1,94 @@ +"""Switch platform for Nintendo Parental.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pynintendoparental.enum import RestrictionMode + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoordinator +from .entity import Device, NintendoDevice + +PARALLEL_UPDATES = 0 + + +class NintendoParentalSwitch(StrEnum): + """Store keys for Nintendo Parental Controls switches.""" + + SUSPEND_SOFTWARE = "suspend_software" + + +@dataclass(kw_only=True, frozen=True) +class NintendoParentalControlsSwitchEntityDescription(SwitchEntityDescription): + """Description for Nintendo Parental Controls switch entities.""" + + is_on: Callable[[Device], bool | None] + turn_on_fn: Callable[[Device], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[Device], Coroutine[Any, Any, None]] + + +SWITCH_DESCRIPTIONS: tuple[NintendoParentalControlsSwitchEntityDescription, ...] = ( + NintendoParentalControlsSwitchEntityDescription( + key=NintendoParentalSwitch.SUSPEND_SOFTWARE, + translation_key=NintendoParentalSwitch.SUSPEND_SOFTWARE, + device_class=SwitchDeviceClass.SWITCH, + is_on=lambda device: device.forced_termination_mode, + turn_off_fn=lambda device: device.set_restriction_mode(RestrictionMode.ALARM), + turn_on_fn=lambda device: device.set_restriction_mode( + RestrictionMode.FORCED_TERMINATION + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NintendoParentalControlsConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform.""" + async_add_devices( + NintendoParentalControlsSwitchEntity(entry.runtime_data, device, switch) + for device in entry.runtime_data.api.devices.values() + for switch in SWITCH_DESCRIPTIONS + ) + + +class NintendoParentalControlsSwitchEntity(NintendoDevice, SwitchEntity): + """Represent a single switch.""" + + entity_description: NintendoParentalControlsSwitchEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + description: NintendoParentalControlsSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + + @property + def is_on(self) -> bool | None: + """Return entity state.""" + return self.entity_description.is_on(self._device) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.turn_on_fn(self._device) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.turn_off_fn(self._device) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 2aa77e09d16cb4..8bd534d57168d6 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -29,6 +29,9 @@ from .const import ( CONF_HOME_INTERVAL, + CONF_HOSTS_EXCLUDE, + CONF_HOSTS_LIST, + CONF_MAC_EXCLUDE, CONF_OPTIONS, DOMAIN, NMAP_TRACKED_DEVICES, @@ -103,6 +106,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1: + new_options = {**entry.options} + if entry.minor_version < 2: + new_options[CONF_HOSTS_LIST] = cv.ensure_list_csv( + new_options.get(CONF_HOSTS, []) + ) + new_options[CONF_HOSTS_EXCLUDE] = cv.ensure_list_csv( + new_options.get(CONF_EXCLUDE, []) + ) + new_options[CONF_MAC_EXCLUDE] = [] + + hass.config_entries.async_update_entry( + entry, options=new_options, minor_version=2, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True + + @callback def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove tracking for devices owned by this config entry.""" @@ -145,6 +182,7 @@ def __init__( self._hosts = None self._options = None self._exclude = None + self._mac_exclude = None self._scan_interval = None self._known_mac_addresses: dict[str, str] = {} @@ -157,10 +195,9 @@ async def async_setup(self): self._scan_interval = timedelta( seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) ) - hosts_list = cv.ensure_list_csv(config[CONF_HOSTS]) - self._hosts = [host for host in hosts_list if host != ""] - excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE]) - self._exclude = [exclude for exclude in excludes_list if exclude != ""] + self._hosts = config.get(CONF_HOSTS_LIST, []) + self._exclude = config.get(CONF_HOSTS_EXCLUDE, []) + self._mac_exclude = config.get(CONF_MAC_EXCLUDE, []) self._options = config[CONF_OPTIONS] self.home_interval = timedelta( minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) @@ -377,6 +414,11 @@ async def _async_run_nmap_scan(self): continue formatted_mac = format_mac(mac) + + if formatted_mac in self._mac_exclude: + _LOGGER.debug("MAC address %s is excluded from tracking", formatted_mac) + continue + if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index e3d1ecbdb14fd2..7bde59b768ee96 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from ipaddress import ip_address, ip_network, summarize_address_range +import re from typing import Any import voluptuous as vol @@ -20,13 +21,16 @@ ConfigFlowResult, OptionsFlowWithReload, ) -from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import TextSelector, TextSelectorConfig from homeassistant.helpers.typing import VolDictType from .const import ( CONF_HOME_INTERVAL, + CONF_HOSTS_EXCLUDE, + CONF_HOSTS_LIST, + CONF_MAC_EXCLUDE, CONF_OPTIONS, DEFAULT_OPTIONS, DOMAIN, @@ -52,11 +56,9 @@ async def async_get_network(hass: HomeAssistant) -> str: return str(ip_network(f"{local_ip}/{network_prefix}", False)) -def _normalize_ips_and_network(hosts_str: str) -> list[str] | None: +def _normalize_ips_and_network(hosts: list[str]) -> list[str] | None: """Check if a list of hosts are all ips or ip networks.""" - normalized_hosts = [] - hosts = [host for host in cv.ensure_list_csv(hosts_str) if host != ""] for host in sorted(hosts): try: @@ -86,20 +88,51 @@ def _normalize_ips_and_network(hosts_str: str) -> list[str] | None: return normalized_hosts +def _is_valid_mac(mac_address: str) -> bool: + """Check if a mac address is valid.""" + is_valid_mac = re.fullmatch( + r"[0-9A-F]{12}", string=mac_address, flags=re.IGNORECASE + ) + if is_valid_mac is not None: + return True + return False + + +def _normalize_mac_addresses(mac_addresses: list[str]) -> list[str] | None: + """Check if a list of mac addresses are all valid.""" + normalized_mac_addresses = [] + + for mac_address in sorted(mac_addresses): + mac_address = mac_address.replace(":", "").replace("-", "").upper().strip() + if not _is_valid_mac(mac_address): + return None + + formatted_mac_address = format_mac(mac_address) + normalized_mac_addresses.append(formatted_mac_address) + + return normalized_mac_addresses + + def normalize_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate hosts and exclude are valid.""" errors = {} - normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) + normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS_LIST]) if not normalized_hosts: - errors[CONF_HOSTS] = "invalid_hosts" + errors[CONF_HOSTS_LIST] = "invalid_hosts" else: - user_input[CONF_HOSTS] = ",".join(normalized_hosts) + user_input[CONF_HOSTS_LIST] = normalized_hosts - normalized_exclude = _normalize_ips_and_network(user_input[CONF_EXCLUDE]) + normalized_exclude = _normalize_ips_and_network(user_input[CONF_HOSTS_EXCLUDE]) if normalized_exclude is None: - errors[CONF_EXCLUDE] = "invalid_hosts" + errors[CONF_HOSTS_EXCLUDE] = "invalid_hosts" else: - user_input[CONF_EXCLUDE] = ",".join(normalized_exclude) + user_input[CONF_HOSTS_EXCLUDE] = normalized_exclude + + normalized_mac_exclude = _normalize_mac_addresses(user_input[CONF_MAC_EXCLUDE]) + if normalized_mac_exclude is None: + errors[CONF_MAC_EXCLUDE] = "invalid_hosts" + else: + user_input[CONF_MAC_EXCLUDE] = normalized_mac_exclude return errors @@ -107,16 +140,26 @@ def normalize_input(user_input: dict[str, Any]) -> dict[str, str]: async def _async_build_schema_with_user_input( hass: HomeAssistant, user_input: dict[str, Any], include_options: bool ) -> vol.Schema: - hosts = user_input.get(CONF_HOSTS, await async_get_network(hass)) - exclude = user_input.get( - CONF_EXCLUDE, await network.async_get_source_ip(hass, MDNS_TARGET_IP) + hosts = user_input.get(CONF_HOSTS_LIST, [await async_get_network(hass)]) + ip_exclude = user_input.get( + CONF_HOSTS_EXCLUDE, [await network.async_get_source_ip(hass, MDNS_TARGET_IP)] ) + + mac_exclude = user_input.get(CONF_MAC_EXCLUDE, []) + schema: VolDictType = { - vol.Required(CONF_HOSTS, default=hosts): str, + vol.Required(CONF_HOSTS_LIST, default=hosts): TextSelector( + TextSelectorConfig(multiple=True) + ), vol.Required( CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) ): int, - vol.Optional(CONF_EXCLUDE, default=exclude): str, + vol.Optional(CONF_HOSTS_EXCLUDE, default=ip_exclude): TextSelector( + TextSelectorConfig(multiple=True) + ), + vol.Optional(CONF_MAC_EXCLUDE, default=mac_exclude): TextSelector( + TextSelectorConfig(multiple=True) + ), vol.Optional( CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) ): str, @@ -139,7 +182,7 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlowWithReload): - """Handle a option flow for homekit.""" + """Handle an option flow for nmap tracker.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" @@ -155,8 +198,9 @@ async def async_step_init( self.options.update(user_input) if not errors: + title_hosts = ", ".join(self.options[CONF_HOSTS_LIST]) return self.async_create_entry( - title=f"Nmap Tracker {self.options[CONF_HOSTS]}", data=self.options + title=f"Nmap Tracker {title_hosts}", data=self.options ) return self.async_show_form( @@ -172,6 +216,7 @@ class NmapTrackerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Nmap Tracker.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" @@ -190,8 +235,9 @@ async def async_step_user( self.options.update(user_input) if not errors: + title_hosts = ", ".join(user_input[CONF_HOSTS_LIST]) return self.async_create_entry( - title=f"Nmap Tracker {user_input[CONF_HOSTS]}", + title=f"Nmap Tracker {title_hosts}", data={}, options=user_input, ) @@ -205,9 +251,9 @@ async def async_step_user( ) def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: - hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) + hosts = _normalize_ips_and_network(user_input[CONF_HOSTS_LIST]) for entry in self._async_current_entries(): - if _normalize_ips_and_network(entry.options[CONF_HOSTS]) == hosts: + if _normalize_ips_and_network(entry.options[CONF_HOSTS_LIST]) == hosts: return False return True diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py index a46cbf46443dc8..e6d122a626197b 100644 --- a/homeassistant/components/nmap_tracker/const.py +++ b/homeassistant/components/nmap_tracker/const.py @@ -13,6 +13,9 @@ # Interval in minutes to exclude devices from a scan while they are home CONF_HOME_INTERVAL: Final = "home_interval" CONF_OPTIONS: Final = "scan_options" +CONF_HOSTS_LIST: Final = "hosts_list" +CONF_HOSTS_EXCLUDE: Final = "hosts_exclude" +CONF_MAC_EXCLUDE: Final = "mac_exclude" DEFAULT_OPTIONS: Final = "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s" TRACKER_SCAN_INTERVAL: Final = 120 diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index 5605ce82ac3fae..7e87d9f73dd155 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -9,6 +9,7 @@ "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen.", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", + "mac_exclude": "[%key:component::nmap_tracker::config::step::user::data::mac_exclude%]", "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", "interval_seconds": "Scan interval" } @@ -21,11 +22,12 @@ "config": { "step": { "user": { - "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP addresses (192.168.1.1), IP networks (192.168.0.0/24) or IP ranges (192.168.1.0-32).", + "description": "Configure hosts to be scanned by Nmap. IP address and excludes can be addresses (192.168.1.1), networks (192.168.0.0/24) or ranges (192.168.1.0-32).", "data": { - "hosts": "Network addresses (comma-separated) to scan", + "hosts": "IP address or range to scan", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", - "exclude": "Network addresses (comma-separated) to exclude from scanning", + "exclude": "IP address to exclude from tracking", + "mac_exclude": "MAC address to exclude from tracking", "scan_options": "Raw configurable scan options for Nmap" } } diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index f29206df7c5544..9824d736fe9621 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -124,7 +124,7 @@ class NumberDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppm` (parts per million), `mg/m³` + Unit of measurement: `ppm` (parts per million), `mg/m³`, `μg/m³` """ CO2 = "carbon_dioxide" @@ -478,6 +478,7 @@ class NumberDeviceClass(StrEnum): NumberDeviceClass.CO: { CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 5d95acaa7792e5..7295a41da0f451 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "bronze", - "requirements": ["opower==0.15.6"] + "requirements": ["opower==0.15.7"] } diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 82b6f82867d17f..276d72012f1b5a 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.4.1"] + "requirements": ["renault-api==0.4.4"] } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 396d26421cee54..e4b8644372cfde 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -57,6 +57,17 @@ class ReolinkSmartAIBinarySensorEntityDescription( supported: Callable[[Host, int, int], bool] = lambda api, ch, loc: True +@dataclass(frozen=True, kw_only=True) +class ReolinkIndexBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkEntityDescription, +): + """A class that describes binary sensor entities with an extra index.""" + + value: Callable[[Host, int, int], bool | None] + supported: Callable[[Host, int, int], bool] = lambda api, ch, idx: True + + BINARY_PUSH_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", @@ -282,6 +293,13 @@ class ReolinkSmartAIBinarySensorEntityDescription( ), ) +BINARY_IO_INPUT_SENSOR = ReolinkIndexBinarySensorEntityDescription( + key="io_input", + cmd_id=677, + translation_key="io_input", + value=lambda api, ch, idx: api.baichuan.io_input_state(ch, idx), +) + async def async_setup_entry( hass: HomeAssistant, @@ -292,7 +310,7 @@ async def async_setup_entry( reolink_data: ReolinkData = config_entry.runtime_data api = reolink_data.host.api - entities: list[ReolinkBinarySensorEntity | ReolinkSmartAIBinarySensorEntity] = [] + entities: list[BinarySensorEntity] = [] for channel in api.channels: entities.extend( ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) @@ -314,6 +332,12 @@ async def async_setup_entry( ) if entity_description.supported(api, channel, location) ) + entities.extend( + ReolinkIndexBinarySensorEntity( + reolink_data, channel, index, BINARY_IO_INPUT_SENSOR + ) + for index in api.baichuan.io_inputs(channel) + ) async_add_entities(entities) @@ -407,3 +431,31 @@ def is_on(self) -> bool: return self.entity_description.value( self._host.api, self._channel, self._location ) + + +class ReolinkIndexBinarySensorEntity( + ReolinkChannelCoordinatorEntity, BinarySensorEntity +): + """Binary-sensor class for Reolink IP camera with an extra index.""" + + entity_description: ReolinkIndexBinarySensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + index: int, + entity_description: ReolinkIndexBinarySensorEntityDescription, + ) -> None: + """Initialize Reolink binary sensor.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + self._attr_unique_id = f"{self._attr_unique_id}_{index}" + + self._index = index + self._attr_translation_placeholders = {"index": str(index)} + + @property + def is_on(self) -> bool | None: + """State of the sensor.""" + return self.entity_description.value(self._host.api, self._channel, self._index) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 0894075f4dbf8a..dd2c1513db19a4 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -126,6 +126,12 @@ "state": { "on": "mdi:package-variant-closed-check" } + }, + "io_input": { + "default": "mdi:electric-switch", + "state": { + "on": "mdi:electric-switch-closed" + } } }, "button": { diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 54ee0d54179c19..598775fe63ccc2 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -427,6 +427,13 @@ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } + }, + "io_input": { + "name": "IO input {index}", + "state": { + "off": "[%key:common::state::disconnected%]", + "on": "[%key:common::state::connected%]" + } } }, "button": { diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json index d1189d0a542024..3051aa5ac6336f 100644 --- a/homeassistant/components/route_b_smart_meter/manifest.json +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -13,5 +13,5 @@ "momonga.sk_wrapper_logger" ], "quality_scale": "bronze", - "requirements": ["pyserial==3.5", "momonga==0.1.5"] + "requirements": ["pyserial==3.5", "momonga==0.2.0"] } diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 0e5e9edbb2c47f..c8ba4c95ad4ac9 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@Tommatheussen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/satel_integra", + "integration_type": "device", "iot_class": "local_push", "loggers": ["satel_integra"], "requirements": ["satel-integra==0.3.7"], diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index b91bd26d410cfc..356d2313c31002 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -157,7 +157,7 @@ class SensorDeviceClass(StrEnum): CO = "carbon_monoxide" """Carbon Monoxide gas concentration. - Unit of measurement: `ppm` (parts per million), `mg/m³` + Unit of measurement: `ppm` (parts per million), `mg/m³`, `μg/m³` """ CO2 = "carbon_dioxide" @@ -589,6 +589,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.CO: { CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 45ff44de0539e1..44d59cdfea0eda 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -280,19 +280,27 @@ def _normalize_states( state_unit: str | None = None statistics_unit: str | None state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + device_class = fstates[0][1].attributes.get(ATTR_DEVICE_CLASS) old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None if not old_metadata: # We've not seen this sensor before, the first valid state determines the unit # used for statistics statistics_unit = state_unit - unit_class = _get_unit_class( - fstates[0][1].attributes.get(ATTR_DEVICE_CLASS), - state_unit, - ) + unit_class = _get_unit_class(device_class, state_unit) else: # We have seen this sensor before, use the unit from metadata statistics_unit = old_metadata["unit_of_measurement"] unit_class = old_metadata["unit_class"] + # Check if the unit class has changed + if ( + (new_unit_class := _get_unit_class(device_class, state_unit)) != unit_class + and (new_converter := _get_unit_converter(new_unit_class)) + and state_unit in new_converter.VALID_UNITS + and statistics_unit in new_converter.VALID_UNITS + ): + # The new unit class supports conversion between the units in metadata + # and the unit in the state, so we can use the new unit class + unit_class = new_unit_class if not (converter := _get_unit_converter(unit_class)): # The unit used by this sensor doesn't support unit conversion diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 49e1da860dff80..0336ede3eeac96 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.12.1"] + "requirements": ["pysqueezebox==0.13.0"] } diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index a24440e6d19c05..1a7cc199a3ccdf 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -5,6 +5,7 @@ from collections import deque from io import DEFAULT_BUFFER_SIZE, BytesIO import logging +import math import os from typing import TYPE_CHECKING @@ -76,7 +77,7 @@ async def async_record(self) -> None: # units which seem to be defined inversely to how stream time_bases are defined running_duration = 0 - last_sequence = float("-inf") + last_sequence = -math.inf def write_segment(segment: Segment) -> None: """Write a segment to output.""" diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index c196e57baa4b74..521457f428cb5d 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -9,6 +9,7 @@ import datetime from io import SEEK_END, BytesIO import logging +import math from threading import Event from typing import Any, Self, cast @@ -46,7 +47,7 @@ from .hls import HlsStreamOutput _LOGGER = logging.getLogger(__name__) -NEGATIVE_INF = float("-inf") +NEGATIVE_INF = -math.inf def redact_av_error_string(err: av.FFmpegError) -> str: diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 90433b0f728f79..104ba82c6f0ce6 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -6,7 +6,6 @@ from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import datetime, timedelta -import fnmatch from functools import partial import logging import os @@ -43,7 +42,11 @@ from .models import USBDevice from .utils import ( scan_serial_ports, + usb_device_from_path, # noqa: F401 usb_device_from_port, # noqa: F401 + usb_device_matches_matcher, + usb_service_info_from_device, # noqa: F401 + usb_unique_id_from_service_info, # noqa: F401 ) _LOGGER = logging.getLogger(__name__) @@ -121,7 +124,7 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo usb_discovery: USBDiscovery = hass.data[DOMAIN] return any( - _is_matching( + usb_device_matches_matcher( USBDevice( device=device, vid=vid, @@ -143,6 +146,15 @@ def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> boo ) +@hass_callback +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) + + _DEPRECATED_UsbServiceInfo = DeprecatedConstant( _UsbServiceInfo, "homeassistant.helpers.service_info.usb.UsbServiceInfo", @@ -214,34 +226,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def _fnmatch_lower(name: str | None, pattern: str) -> bool: - """Match a lowercase version of the name.""" - if name is None: - return False - return fnmatch.fnmatch(name.lower(), pattern) - - -def _is_matching(device: USBDevice, matcher: USBMatcher | USBCallbackMatcher) -> bool: - """Return True if a device matches.""" - if "vid" in matcher and device.vid != matcher["vid"]: - return False - if "pid" in matcher and device.pid != matcher["pid"]: - return False - if "serial_number" in matcher and not _fnmatch_lower( - device.serial_number, matcher["serial_number"] - ): - return False - if "manufacturer" in matcher and not _fnmatch_lower( - device.manufacturer, matcher["manufacturer"] - ): - return False - if "description" in matcher and not _fnmatch_lower( - device.description, matcher["description"] - ): - return False - return True - - async def async_request_scan(hass: HomeAssistant) -> None: """Request a USB scan.""" usb_discovery: USBDiscovery = hass.data[DOMAIN] @@ -383,6 +367,29 @@ def _async_remove_callback() -> None: return _async_remove_callback + @hass_callback + def async_get_usb_matchers_for_device(self, device: USBDevice) -> list[USBMatcher]: + """Return a list of matchers that match the given device.""" + matched = [ + matcher + for matcher in self.usb + if usb_device_matches_matcher(device, matcher) + ] + + if not matched: + return [] + + # Sort by specificity (most fields matched first) + sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) + + # Only return matchers with the same specificity as the most specific one + most_matched_fields = len(sorted_by_most_targeted[0]) + return [ + matcher + for matcher in sorted_by_most_targeted + if len(matcher) == most_matched_fields + ] + async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: """Process a USB discovery.""" _LOGGER.debug("Discovered USB Device: %s", device) @@ -391,21 +398,13 @@ async def _async_process_discovered_usb_device(self, device: USBDevice) -> None: return self.seen.add(device_tuple) - matched = [matcher for matcher in self.usb if _is_matching(device, matcher)] + matched = self.async_get_usb_matchers_for_device(device) if not matched: return service_info: _UsbServiceInfo | None = None - sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item)) - most_matched_fields = len(sorted_by_most_targeted[0]) - - for matcher in sorted_by_most_targeted: - # If there is a less targeted match, we only - # want the most targeted match - if len(matcher) < most_matched_fields: - break - + for matcher in matched: if service_info is None: service_info = _UsbServiceInfo( device=await self.hass.async_add_executor_job( diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py index 1bb620ec5f74a5..b5c78fa5f981b2 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -3,10 +3,15 @@ from __future__ import annotations from collections.abc import Sequence +import fnmatch +import os from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo +from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.loader import USBMatcher + from .models import USBDevice @@ -29,3 +34,89 @@ def scan_serial_ports() -> Sequence[USBDevice]: for port in comports() if port.vid is not None or port.pid is not None ] + + +def usb_device_from_path(device_path: str) -> USBDevice | None: + """Get USB device info from a device path.""" + + # Scan all symlinks first + by_id = "/dev/serial/by-id" + realpath_to_by_id: dict[str, str] = {} + if os.path.isdir(by_id): + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + realpath_to_by_id[os.path.realpath(path)] = path + + # Then compare the actual path to each serial port's + device_path_real = os.path.realpath(device_path) + + for device in scan_serial_ports(): + normalized_path = realpath_to_by_id.get(device.device, device.device) + if ( + normalized_path == device_path + or os.path.realpath(device.device) == device_path_real + ): + return USBDevice( + device=normalized_path, + vid=device.vid, + pid=device.pid, + serial_number=device.serial_number, + manufacturer=device.manufacturer, + description=device.description, + ) + + return None + + +def _fnmatch_lower(name: str | None, pattern: str) -> bool: + """Match a lowercase version of the name.""" + if name is None: + return False + return fnmatch.fnmatch(name.lower(), pattern) + + +def usb_device_matches_matcher(device: USBDevice, matcher: USBMatcher) -> bool: + """Check if a USB device matches a USB matcher.""" + if "vid" in matcher and device.vid != matcher["vid"]: + return False + + if "pid" in matcher and device.pid != matcher["pid"]: + return False + + if "serial_number" in matcher and not _fnmatch_lower( + device.serial_number, matcher["serial_number"] + ): + return False + + if "manufacturer" in matcher and not _fnmatch_lower( + device.manufacturer, matcher["manufacturer"] + ): + return False + + if "description" in matcher and not _fnmatch_lower( + device.description, matcher["description"] + ): + return False + + return True + + +def usb_unique_id_from_service_info(usb_info: UsbServiceInfo) -> str: + """Generate a unique ID from USB service info.""" + return ( + f"{usb_info.vid}:{usb_info.pid}_" + f"{usb_info.serial_number}_" + f"{usb_info.manufacturer}_" + f"{usb_info.description}" + ) + + +def usb_service_info_from_device(usb_device: USBDevice) -> UsbServiceInfo: + """Convert a USBDevice to UsbServiceInfo.""" + return UsbServiceInfo( + device=usb_device.device, + vid=usb_device.vid, + pid=usb_device.pid, + serial_number=usb_device.serial_number, + manufacturer=usb_device.manufacturer, + description=usb_device.description, + ) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 7b73d2e5ba313c..f6a8fc3afafeba 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -31,7 +31,7 @@ def __init__( model = device_config.getModel().replace("_", " ") identifier = ( - f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}" + f"{gateway_serial}_{device_serial.replace('-', '_')}" if device_serial is not None else f"{gateway_serial}_{device_id}" ) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 11ba2a31b2a1a1..f2576a05d5193b 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.52.0"] + "requirements": ["PyViCare==2.54.0"] } diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py index a28c09009382c4..685bfc41c7d0be 100644 --- a/homeassistant/components/volvo/entity.py +++ b/homeassistant/components/volvo/entity.py @@ -62,6 +62,7 @@ def __init__( identifiers={(DOMAIN, vehicle.vin)}, manufacturer=MANUFACTURER, model=model, + model_id=f"{vehicle.description.model} ({vehicle.model_year})", name=f"{MANUFACTURER} {vehicle.description.model}", serial_number=vehicle.vin, ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9612868383ea9d..3b18cdb3446b8d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2859,6 +2859,29 @@ def async_get_supported_subentry_types( """Return subentries supported by this handler.""" return {} + @callback + def async_update_title_placeholders( + self, title_placeholders: Mapping[str, str] + ) -> None: + """Update title placeholders for the discovery notification and notify listeners. + + This updates the flow context title_placeholders and notifies listeners + (such as the frontend) to reload the flow state, updating the discovery + notification title. + + Only call this method when the flow is not progressing to a new step + (e.g., from a callback that receives updated data). If the flow is + progressing to a new step, set title_placeholders directly in context + before returning the step result, as the step change will trigger + listener notification automatically. + """ + # Context is typed as TypedDict but is mutable dict at runtime + current_placeholders = cast( + dict[str, str], self.context.setdefault("title_placeholders", {}) + ) + current_placeholders.update(title_placeholders) + self.async_notify_flow_changed() + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -3187,6 +3210,76 @@ def async_create_entry( # type: ignore[override] return result + @callback + def __async_update( + self, + entry: ConfigEntry, + *, + unique_id: str | None | UndefinedType, + title: str | UndefinedType, + data: Mapping[str, Any] | UndefinedType, + data_updates: Mapping[str, Any] | UndefinedType, + options: Mapping[str, Any] | UndefinedType, + ) -> bool: + """Update config entry and return result. + + Internal to be used by update_and_abort and update_reload_and_abort methods only. + """ + + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = entry.data | data_updates + return self.hass.config_entries.async_update_entry( + entry=entry, + unique_id=unique_id, + title=title, + data=data, + options=options, + ) + + @callback + def async_update_and_abort( + self, + entry: ConfigEntry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + reason: str | UndefinedType = UNDEFINED, + ) -> ConfigFlowResult: + """Update config entry and finish config flow. + + Args: + entry: config entry to update + unique_id: replace the unique_id of the entry + title: replace the title of the entry + data: replace the entry data with new data + data_updates: add items from data_updates to entry data - existing keys + are overridden + options: replace the entry options with new options + reason: set the reason for the abort, defaults to + `reauth_successful` or `reconfigure_successful` based on flow source + + Returns: + ConfigFlowResult: The result of the config flow. + """ + self.__async_update( + entry=entry, + unique_id=unique_id, + title=title, + data=data, + data_updates=data_updates, + options=options, + ) + if reason is UNDEFINED: + reason = "reauth_successful" + if self.source == SOURCE_RECONFIGURE: + reason = "reconfigure_successful" + return self.async_abort(reason=reason) + @callback def async_update_reload_and_abort( self, @@ -3202,28 +3295,28 @@ def async_update_reload_and_abort( ) -> ConfigFlowResult: """Update config entry, reload config entry and finish config flow. - :param data: replace the entry data with new data - :param data_updates: add items from data_updates to entry data - existing keys - are overridden - :param options: replace the entry options with new options - :param title: replace the title of the entry - :param unique_id: replace the unique_id of the entry - - :param reason: set the reason for the abort, defaults to - `reauth_successful` or `reconfigure_successful` based on flow source - - :param reload_even_if_entry_is_unchanged: set this to `False` if the entry - should not be reloaded if it is unchanged + Args: + entry: config entry to update and reload + unique_id: replace the unique_id of the entry + title: replace the title of the entry + data: replace the entry data with new data + data_updates: add items from data_updates to entry data - existing keys + are overridden + options: replace the entry options with new options + reason: set the reason for the abort, defaults to + `reauth_successful` or `reconfigure_successful` based on flow source + reload_even_if_entry_is_unchanged: set this to `False` if the entry + should not be reloaded if it is unchanged + + Returns: + ConfigFlowResult: The result of the config flow. """ - if data_updates is not UNDEFINED: - if data is not UNDEFINED: - raise ValueError("Cannot set both data and data_updates") - data = entry.data | data_updates - result = self.hass.config_entries.async_update_entry( + result = self.__async_update( entry=entry, unique_id=unique_id, title=title, data=data, + data_updates=data_updates, options=options, ) if reload_even_if_entry_is_unchanged or result: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index d9e58a8dda8bce..51dcd3fa13e292 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -432,11 +432,7 @@ async def _async_configure( != result.get("description_placeholders") ) ): - # Tell frontend to reload the flow state. - self.hass.bus.async_fire_internal( - EVENT_DATA_ENTRY_FLOW_PROGRESSED, - {"handler": flow.handler, "flow_id": flow_id, "refresh": True}, - ) + flow.async_notify_flow_changed() return result @@ -886,6 +882,17 @@ def async_update_progress(self, progress: float) -> None: {"handler": self.handler, "flow_id": self.flow_id, "progress": progress}, ) + @callback + def async_notify_flow_changed(self) -> None: + """Notify listeners that the flow has changed. + + This notifies listeners (such as the frontend) to reload the flow state. + """ + self.hass.bus.async_fire_internal( + EVENT_DATA_ENTRY_FLOW_PROGRESSED, + {"handler": self.handler, "flow_id": self.flow_id, "refresh": True}, + ) + @callback def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT: """Mark the progress done.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4c5bcb21143547..2a2658c535b44d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -28,6 +28,7 @@ "acaia", "accuweather", "acmeda", + "actron_air", "adax", "adguard", "advantage_air", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index e744f42b541254..0ae6875702cfc0 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -8,6 +8,11 @@ from typing import Final DHCP: Final[list[dict[str, str | bool]]] = [ + { + "domain": "actron_air", + "hostname": "neo-*", + "macaddress": "FC0FE7*", + }, { "domain": "airthings", "hostname": "airthings-view", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6bae0268e7a588..7f11b65001aa19 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -47,6 +47,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "actron_air": { + "name": "Actron Air", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "adax": { "name": "Adax", "integration_type": "hub", @@ -5786,7 +5792,7 @@ }, "satel_integra": { "name": "Satel Integra", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push", "single_config_entry": true @@ -7107,7 +7113,7 @@ "name": "Ubiquiti", "integrations": { "airos": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling", "name": "Ubiquiti airOS" diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 893ca7a3586bf2..632b42c735b935 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -30,6 +30,7 @@ async def async_check_significant_change( from __future__ import annotations from collections.abc import Callable, Mapping +import math from types import MappingProxyType from typing import Any, Protocol @@ -161,7 +162,7 @@ def percentage_change(old_state: float, new_state: float) -> float: try: return (abs(new_state - old_state) / old_state) * 100.0 except ZeroDivisionError: - return float("inf") + return math.inf return _check_numeric_change(old_state, new_state, change, percentage_change) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 16f3b9b696421e..85da7eaf87f899 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -300,12 +300,11 @@ async def async_config_entry_first_refresh(self) -> None: to ensure that multiple retries do not cause log spam. """ if self.config_entry is None: - report_usage( - "uses `async_config_entry_first_refresh`, which is only supported " - "for coordinators with a config entry", - breaks_in_ha_version="2025.11", + raise ConfigEntryError( + "Detected code that uses `async_config_entry_first_refresh`," + " which is only supported for coordinators with a config entry" ) - elif ( + if ( self.config_entry.state is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS ): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1fa7a280d4d014..5693b6614edede 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==1.6.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==5.7.0 -hass-nabucasa==1.2.0 +hass-nabucasa==1.3.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20251001.4 diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 322410f61eccfc..d57913ee39794b 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -191,10 +191,14 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter): CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: ( _CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3 ), + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: ( + _CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6 + ), } VALID_UNITS = { CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, } diff --git a/pyproject.toml b/pyproject.toml index 6f4a8db8b00ea3..99c4f3a7a53118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.6.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.2.0", + "hass-nabucasa==1.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", @@ -161,7 +161,6 @@ class-const-naming-style = "any" # possibly-used-before-assignment - too many errors / not necessarily issues # --- # Pylint CodeStyle plugin -# consider-math-not-float # consider-using-namedtuple-or-dataclass - too opinionated # consider-using-assignment-expr - decision to use := better left to devs disable = [ @@ -182,7 +181,6 @@ disable = [ "too-many-boolean-expressions", "too-many-positional-arguments", "wrong-import-order", - "consider-math-not-float", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", "possibly-used-before-assignment", diff --git a/requirements.txt b/requirements.txt index c258062aa03662..93e63f6e3bdc33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.6 fnv-hash-fast==1.6.0 -hass-nabucasa==1.2.0 +hass-nabucasa==1.3.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f2af0e9741f76d..b3a130c61545f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.52.0 +PyViCare==2.54.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -133,6 +133,9 @@ WSDiscovery==2.1.2 # homeassistant.components.accuweather accuweather==4.2.2 +# homeassistant.components.actron_air +actron-neo-api==0.1.84 + # homeassistant.components.adax adax==0.4.0 @@ -176,7 +179,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.17 # homeassistant.components.airq -aioairq==0.4.6 +aioairq==0.4.7 # homeassistant.components.airzone_cloud aioairzone-cloud==0.7.2 @@ -201,7 +204,7 @@ aioaquacell==0.2.0 aioaseko==1.0.0 # homeassistant.components.asuswrt -aioasuswrt==1.4.0 +aioasuswrt==1.5.1 # homeassistant.components.husqvarna_automower aioautomower==2.2.1 @@ -217,7 +220,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==1.1.1 +aiocomelit==1.1.2 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 @@ -247,7 +250,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.14.0 +aioesphomeapi==41.15.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -453,7 +456,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.5.5 +airos==0.5.6 # homeassistant.components.airthings_ble airthings-ble==1.1.1 @@ -1145,7 +1148,7 @@ habiticalib==0.4.5 habluetooth==5.7.0 # homeassistant.components.cloud -hass-nabucasa==1.2.0 +hass-nabucasa==1.3.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1470,7 +1473,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter -momonga==0.1.5 +momonga==0.2.0 # homeassistant.components.monzo monzopy==1.5.1 @@ -1652,7 +1655,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.15.6 +opower==0.15.7 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -2420,7 +2423,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.12.1 +pysqueezebox==0.13.0 # homeassistant.components.stiebel_eltron pystiebeleltron==0.2.3 @@ -2704,7 +2707,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.4.1 +renault-api==0.4.4 # homeassistant.components.renson renson-endura-delta==1.7.2 @@ -3183,7 +3186,7 @@ xbox-webapi==2.1.0 xiaomi-ble==1.2.0 # homeassistant.components.knx -xknx==3.9.1 +xknx==3.10.0 # homeassistant.components.knx xknxproject==3.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 689760352fad2b..9827d6a562579b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.52.0 +PyViCare==2.54.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -121,6 +121,9 @@ WSDiscovery==2.1.2 # homeassistant.components.accuweather accuweather==4.2.2 +# homeassistant.components.actron_air +actron-neo-api==0.1.84 + # homeassistant.components.adax adax==0.4.0 @@ -164,7 +167,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.17 # homeassistant.components.airq -aioairq==0.4.6 +aioairq==0.4.7 # homeassistant.components.airzone_cloud aioairzone-cloud==0.7.2 @@ -189,7 +192,7 @@ aioaquacell==0.2.0 aioaseko==1.0.0 # homeassistant.components.asuswrt -aioasuswrt==1.4.0 +aioasuswrt==1.5.1 # homeassistant.components.husqvarna_automower aioautomower==2.2.1 @@ -205,7 +208,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==1.1.1 +aiocomelit==1.1.2 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 @@ -235,7 +238,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.14.0 +aioesphomeapi==41.15.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -435,7 +438,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.5.5 +airos==0.5.6 # homeassistant.components.airthings_ble airthings-ble==1.1.1 @@ -1006,7 +1009,7 @@ habiticalib==0.4.5 habluetooth==5.7.0 # homeassistant.components.cloud -hass-nabucasa==1.2.0 +hass-nabucasa==1.3.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation @@ -1265,7 +1268,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter -momonga==0.1.5 +momonga==0.2.0 # homeassistant.components.monzo monzopy==1.5.1 @@ -1411,7 +1414,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.15.6 +opower==0.15.7 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -2026,7 +2029,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.12.1 +pysqueezebox==0.13.0 # homeassistant.components.stiebel_eltron pystiebeleltron==0.2.3 @@ -2250,7 +2253,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.4.1 +renault-api==0.4.4 # homeassistant.components.renson renson-endura-delta==1.7.2 @@ -2639,7 +2642,7 @@ xbox-webapi==2.1.0 xiaomi-ble==1.2.0 # homeassistant.components.knx -xknx==3.9.1 +xknx==3.10.0 # homeassistant.components.knx xknxproject==3.8.2 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 93859fa301fa4b..d741fe98e24e52 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -263,7 +263,6 @@ "sense": {"sense-energy": {"async-timeout"}}, "slimproto": {"aioslimproto": {"async-timeout"}}, "songpal": {"async-upnp-client": {"async-timeout"}}, - "squeezebox": {"pysqueezebox": {"async-timeout"}}, "ssdp": {"async-upnp-client": {"async-timeout"}}, "surepetcare": {"surepy": {"async-timeout"}}, "travisci": { diff --git a/tests/components/actron_air/__init__.py b/tests/components/actron_air/__init__.py new file mode 100644 index 00000000000000..c2f40057ab772a --- /dev/null +++ b/tests/components/actron_air/__init__.py @@ -0,0 +1 @@ +"""Tests for the Actron Air integration.""" diff --git a/tests/components/actron_air/conftest.py b/tests/components/actron_air/conftest.py new file mode 100644 index 00000000000000..a4239d6bd6c7ba --- /dev/null +++ b/tests/components/actron_air/conftest.py @@ -0,0 +1,55 @@ +"""Test fixtures for the Actron Air Integration.""" + +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_actron_api() -> Generator[AsyncMock]: + """Mock the Actron Air API class.""" + with ( + patch( + "homeassistant.components.actron_air.ActronNeoAPI", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.actron_air.config_flow.ActronNeoAPI", + new=mock_api, + ), + ): + api = mock_api.return_value + + # Mock device code request + api.request_device_code.return_value = { + "device_code": "test_device_code", + "user_code": "ABC123", + "verification_uri_complete": "https://example.com/device", + "expires_in": 1800, + } + + # Mock successful token polling (with a small delay to test progress) + async def slow_poll_for_token(device_code): + await asyncio.sleep(0.1) # Small delay to allow progress state to be tested + return { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + } + + api.poll_for_token = slow_poll_for_token + + # Mock user info + api.get_user_info = AsyncMock( + return_value={"id": "test_user_id", "email": "test@example.com"} + ) + + # Mock refresh token property + api.refresh_token_value = "test_refresh_token" + + # Mock other API methods that might be used + api.get_systems = AsyncMock(return_value=[]) + api.get_status = AsyncMock(return_value=None) + + yield api diff --git a/tests/components/actron_air/test_config_flow.py b/tests/components/actron_air/test_config_flow.py new file mode 100644 index 00000000000000..459cd4e37e5e6d --- /dev/null +++ b/tests/components/actron_air/test_config_flow.py @@ -0,0 +1,184 @@ +"""Config flow tests for the Actron Air Integration.""" + +import asyncio +from unittest.mock import AsyncMock + +from actron_neo_api import ActronNeoAuthError + +from homeassistant import config_entries +from homeassistant.components.actron_air.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_oauth2_success( + hass: HomeAssistant, mock_actron_api: AsyncMock +) -> None: + """Test successful OAuth2 device code flow.""" + # Start the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Should start with a progress step + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + assert result["description_placeholders"] is not None + assert "user_code" in result["description_placeholders"] + assert result["description_placeholders"]["user_code"] == "ABC123" + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Continue the flow after progress is done - this should complete the entire flow + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should create entry on successful token exchange + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_API_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == "test_user_id" + + +async def test_user_flow_oauth2_pending(hass: HomeAssistant, mock_actron_api) -> None: + """Test OAuth2 flow when authorization is still pending.""" + + # Make poll_for_token hang indefinitely to simulate pending state + async def hang_forever(device_code): + await asyncio.Event().wait() # This will never complete + + mock_actron_api.poll_for_token = hang_forever + + # Start the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Should start with a progress step since the task will never complete + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + assert result["description_placeholders"] is not None + assert "user_code" in result["description_placeholders"] + assert result["description_placeholders"]["user_code"] == "ABC123" + + # The background task should be running but not completed + # In a real scenario, the user would wait for authorization on their device + + +async def test_user_flow_oauth2_error(hass: HomeAssistant, mock_actron_api) -> None: + """Test OAuth2 flow with authentication error during device code request.""" + # Override the default mock to raise an error + mock_actron_api.request_device_code = AsyncMock( + side_effect=ActronNeoAuthError("OAuth2 error") + ) + + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Should abort with oauth2_error + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth2_error" + + +async def test_user_flow_token_polling_error( + hass: HomeAssistant, mock_actron_api +) -> None: + """Test OAuth2 flow with error during token polling.""" + # Override the default mock to raise an error during token polling + mock_actron_api.poll_for_token = AsyncMock( + side_effect=ActronNeoAuthError("Token polling error") + ) + + # Start the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Since the error occurs immediately, the task completes and we get progress_done + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "connection_error" + + # Continue to the connection_error step + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should show the connection error form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "connection_error" + + # Now fix the mock to allow successful token polling for recovery + async def successful_poll_for_token(device_code): + await asyncio.sleep(0.1) # Small delay to allow progress state + return { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + } + + mock_actron_api.poll_for_token = successful_poll_for_token + + # User clicks retry button - this should restart the flow and succeed + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Should start progress again + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Continue the flow after progress is done - this should complete successfully + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should create entry on successful recovery + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_API_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == "test_user_id" + + +async def test_user_flow_duplicate_account( + hass: HomeAssistant, mock_actron_api: AsyncMock +) -> None: + """Test duplicate account handling - should abort when same account is already configured.""" + # Create an existing config entry for the same user account + existing_entry = MockConfigEntry( + domain=DOMAIN, + title="test@example.com", + data={CONF_API_TOKEN: "existing_refresh_token"}, + unique_id="test_user_id", + ) + existing_entry.add_to_hass(hass) + + # Start the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Should start with a progress step + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + assert result["progress_action"] == "wait_for_authorization" + assert result["description_placeholders"] is not None + assert "user_code" in result["description_placeholders"] + assert result["description_placeholders"]["user_code"] == "ABC123" + + # Wait for the progress to complete + await hass.async_block_till_done() + + # Continue the flow after progress is done + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Should abort because the account is already configured + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json index 06feb3d0a559cf..7864647173e21c 100644 --- a/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json +++ b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json @@ -17,6 +17,7 @@ "ptmp": false, "ptp": true, "role": "access_point", + "sku": "Loco5AC", "station": false }, "firewall": { diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index 4e94beae4733a8..5977f9024346d0 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'ptmp': False, 'ptp': True, 'role': 'access_point', + 'sku': 'Loco5AC', 'station': False, }), 'firewall': dict({ diff --git a/tests/components/asuswrt/common.py b/tests/components/asuswrt/common.py index 541e74e5b39338..1f3179527550f6 100644 --- a/tests/components/asuswrt/common.py +++ b/tests/components/asuswrt/common.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from aioasuswrt.asuswrt import Device as LegacyDevice +from asusrouter.modules.client import ConnectionState from homeassistant.components.asuswrt.const import ( CONF_SSH_KEY, @@ -71,6 +72,7 @@ def make_client(mac, ip, name, node): client = MagicMock() client.connection = connection client.description = description + client.state = ConnectionState.CONNECTED return client diff --git a/tests/components/asuswrt/test_init.py b/tests/components/asuswrt/test_init.py index 72897b737e592a..9c4b2c0d9a2c7f 100644 --- a/tests/components/asuswrt/test_init.py +++ b/tests/components/asuswrt/test_init.py @@ -26,5 +26,5 @@ async def test_disconnect_on_stop(hass: HomeAssistant, connect_legacy) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - assert connect_legacy.return_value.connection.disconnect.call_count == 1 + assert connect_legacy.return_value.async_disconnect.await_count == 1 assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index d896cd83e76982..0e461503fc85a7 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -19,6 +19,7 @@ BluetoothScanningMode, BluetoothServiceInfo, HaBluetoothConnector, + async_clear_address_from_match_history, async_process_advertisements, async_rediscover_address, async_track_unavailable, @@ -703,6 +704,67 @@ async def test_discovery_match_by_local_name( assert mock_config_flow.mock_calls[0][1][0] == "switchbot" +@pytest.mark.usefixtures("enable_bluetooth") +async def test_discovery_match_by_service_uuid_when_name_changes_from_mac( + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock +) -> None: + """Test bluetooth discovery still matches when name changes from MAC address to real name.""" + mock_bt = [ + { + "domain": "improv_ble", + "service_uuid": "00467768-6228-2272-4663-277478268000", + } + ] + with ( + patch( + "homeassistant.components.bluetooth.async_get_bluetooth", + return_value=mock_bt, + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + await async_setup_with_default_adapter(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + # First advertisement: name is MAC address, with service UUID + # This should trigger discovery + device_mac_name = generate_ble_device("64:E8:33:7E:0D:9E", "64:E8:33:7E:0D:9E") + adv_mac_name = generate_advertisement_data( + local_name="64:E8:33:7E:0D:9E", + service_uuids=["00467768-6228-2272-4663-277478268000"], + service_data={ + "00004677-0000-1000-8000-00805f9b34fb": b"\x02\x00\x00\x00\x00\x00" + }, + ) + + inject_advertisement(hass, device_mac_name, adv_mac_name) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "improv_ble" + mock_config_flow.reset_mock() + + # Second advertisement: name changes to real name, same service UUID + # This should trigger discovery again because the name changed + device_real_name = generate_ble_device("64:E8:33:7E:0D:9E", "improvtest") + adv_real_name = generate_advertisement_data( + local_name="improvtest", + service_uuids=["00467768-6228-2272-4663-277478268000"], + service_data={ + "00004677-0000-1000-8000-00805f9b34fb": b"\x02\x00\x00\x00\x00\x00" + }, + ) + + inject_advertisement(hass, device_real_name, adv_real_name) + await hass.async_block_till_done() + + # Should still match improv_ble even though name changed + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "improv_ble" + + @pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock @@ -1175,6 +1237,61 @@ async def test_rediscovery( assert mock_config_flow.mock_calls[1][1][0] == "switchbot" +@pytest.mark.usefixtures("enable_bluetooth") +async def test_clear_address_from_match_history( + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock +) -> None: + """Test clearing match history without re-triggering discovery.""" + mock_bt = [ + {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} + ] + with ( + patch( + "homeassistant.components.bluetooth.async_get_bluetooth", + return_value=mock_bt, + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + await async_setup_with_default_adapter(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_bleak_scanner_start.mock_calls) == 1 + + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_adv_2 = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={1: b"\x01"}, + ) + inject_advertisement(hass, switchbot_device, switchbot_adv) + await hass.async_block_till_done() + + inject_advertisement(hass, switchbot_device, switchbot_adv) + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + + # Clear match history - should NOT trigger immediate rediscovery + async_clear_address_from_match_history(hass, "44:44:33:11:23:45") + await hass.async_block_till_done() + + # No new discovery should have been triggered + assert len(mock_config_flow.mock_calls) == 1 + + # But when we inject new advertisement with different data, it should be discovered + inject_advertisement(hass, switchbot_device, switchbot_adv_2) + await hass.async_block_till_done() + + # Now discovery should happen because history was cleared and data changed + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "switchbot" + + @pytest.mark.usefixtures("macos_adapter") async def test_async_discovered_device_api( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index a7a5b1abe482c9..f18f010f59c512 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -79,11 +79,16 @@ def configure_mock_on_init(host, port, user, passwd, verbose=False): 0, { "swCapabilities1": "100", - "swCapbilities2": "100", - "swCapbilities3": "100", - "swCapbilities4": "100", + "swCapabilities2": "768", + "swCapabilities3": "100", + "swCapabilities4": "100", }, ) + mock_foscam_camera.get_motion_detect_config.return_value = ( + 0, + {"petEnable": "1", "carEnable": "1", "humanEnable": "1"}, + ) + return mock_foscam_camera mock_foscam_camera.side_effect = configure_mock_on_init diff --git a/tests/components/foscam/snapshots/test_switch.ambr b/tests/components/foscam/snapshots/test_switch.ambr index f48df6b65e65e7..0945b65746813d 100644 --- a/tests/components/foscam/snapshots/test_switch.ambr +++ b/tests/components/foscam/snapshots/test_switch.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_entities[switch.mock_title_car_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_car_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Car detection', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'car_detection', + 'unique_id': '123ABC_car_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_car_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Car detection', + }), + 'context': , + 'entity_id': 'switch.mock_title_car_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch.mock_title_flip-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -47,6 +95,54 @@ 'state': 'off', }) # --- +# name: test_entities[switch.mock_title_human_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_human_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Human detection', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'human_detection', + 'unique_id': '123ABC_human_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_human_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Human detection', + }), + 'context': , + 'entity_id': 'switch.mock_title_human_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch.mock_title_infrared_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -191,6 +287,54 @@ 'state': 'off', }) # --- +# name: test_entities[switch.mock_title_pet_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_pet_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pet detection', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pet_detection', + 'unique_id': '123ABC_pet_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_pet_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Pet detection', + }), + 'context': , + 'entity_id': 'switch.mock_title_pet_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch.mock_title_siren_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 30b1cced57df9c..bf05676fc676e0 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -505,6 +505,31 @@ async def test_form_image_http_exceptions( assert result2["errors"] == expected_message +@respx.mock +async def test_form_image_http_302( + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_create_stream: _patch[MagicMock], + fakeimgbytes_png: bytes, +) -> None: + """Test we handle image http 302 (temporary redirect).""" + respx.get("http://127.0.0.1/testurl/1").side_effect = [ + httpx.Response( + status_code=302, headers={"Location": "http://127.0.0.1/testurl2/1"} + ) + ] + respx.get("http://127.0.0.1/testurl2/1", name="fake_img2").respond( + stream=fakeimgbytes_png + ) + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + TESTDATA, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_confirm" + + @respx.mock async def test_form_stream_invalidimage( hass: HomeAssistant, diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py new file mode 100644 index 00000000000000..adf6085433aa8a --- /dev/null +++ b/tests/components/growatt_server/conftest.py @@ -0,0 +1,202 @@ +"""Common fixtures for the Growatt server tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.growatt_server.const import ( + AUTH_API_TOKEN, + AUTH_PASSWORD, + CONF_AUTH_TYPE, + CONF_PLANT_ID, + DEFAULT_URL, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_growatt_v1_api(): + """Return a mocked Growatt V1 API. + + This fixture provides the happy path for integration setup and basic operations. + Individual tests can override specific return values to test error conditions. + + Methods mocked for integration setup: + - device_list: Called during async_setup_entry to discover devices + - plant_energy_overview: Called by total coordinator during first refresh + + Methods mocked for MIN device coordinator refresh: + - min_detail: Provides device state (e.g., acChargeEnable for switches) + - min_settings: Provides settings (e.g. TOU periods) + - min_energy: Provides energy data (empty for switch tests, sensors need real data) + + Methods mocked for switch operations: + - min_write_parameter: Called by switch entities to change settings + """ + with patch("growattServer.OpenApiV1", autospec=True) as mock_v1_api_class: + mock_v1_api = mock_v1_api_class.return_value + + # Called during setup to discover devices + mock_v1_api.device_list.return_value = { + "devices": [ + { + "device_sn": "MIN123456", + "type": 7, # MIN device type + } + ] + } + + # Called by MIN device coordinator during refresh + mock_v1_api.min_detail.return_value = { + "deviceSn": "MIN123456", + "acChargeEnable": 1, # AC charge enabled - read by switch entity + } + + # Called by MIN device coordinator during refresh + mock_v1_api.min_settings.return_value = { + # Forced charge time segments (not used by switch, but coordinator fetches it) + "forcedTimeStart1": "06:00", + "forcedTimeStop1": "08:00", + "forcedChargeBatMode1": 1, + "forcedChargeFlag1": 1, + "forcedTimeStart2": "22:00", + "forcedTimeStop2": "24:00", + "forcedChargeBatMode2": 0, + "forcedChargeFlag2": 0, + } + + # Called by MIN device coordinator during refresh + # Empty dict is sufficient for switch tests (sensor tests would need real energy data) + mock_v1_api.min_energy.return_value = {} + + # Called by total coordinator during refresh + mock_v1_api.plant_energy_overview.return_value = { + "today_energy": 12.5, + "total_energy": 1250.0, + "current_power": 2500, + } + + # Called by switch entities during turn_on/turn_off + mock_v1_api.min_write_parameter.return_value = None + + yield mock_v1_api + + +@pytest.fixture +def mock_growatt_classic_api(): + """Return a mocked Growatt Classic API. + + This fixture provides the happy path for Classic API integration setup. + Individual tests can override specific return values to test error conditions. + + Methods mocked for integration setup: + - login: Called during get_device_list_classic to authenticate + - plant_list: Called during setup if plant_id is default (to auto-select plant) + - device_list: Called during async_setup_entry to discover devices + + Methods mocked for total coordinator refresh: + - plant_info: Provides plant totals (energy, power, money) for Classic API + + Methods mocked for device-specific tests: + - tlx_detail: Provides TLX device data (kept for potential future tests) + """ + with patch("growattServer.GrowattApi", autospec=True) as mock_classic_api_class: + # Use the autospec'd mock instance instead of creating a new Mock() + mock_classic_api = mock_classic_api_class.return_value + + # Called during setup to authenticate with Classic API + mock_classic_api.login.return_value = {"success": True, "user": {"id": 12345}} + + # Called during setup if plant_id is default (auto-select first plant) + mock_classic_api.plant_list.return_value = {"data": [{"plantId": "12345"}]} + + # Called during setup to discover devices + mock_classic_api.device_list.return_value = [ + {"deviceSn": "MIN123456", "deviceType": "min"} + ] + + # Called by total coordinator during refresh for Classic API + mock_classic_api.plant_info.return_value = { + "deviceList": [], + "totalEnergy": 1250.0, + "todayEnergy": 12.5, + "invTodayPpv": 2500, + "plantMoneyText": "123.45/USD", + } + + # Called for TLX device coordinator (kept for potential future tests) + mock_classic_api.tlx_detail.return_value = { + "data": { + "deviceSn": "TLX123456", + } + } + + yield mock_classic_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry (V1 API with token auth). + + This is the primary config entry used by most tests. For Classic API tests, + use mock_config_entry_classic instead. + """ + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_API_TOKEN, + CONF_TOKEN: "test_token_123", + CONF_URL: DEFAULT_URL, + "user_id": "12345", + CONF_PLANT_ID: "plant_123", + "name": "Test Plant", + }, + unique_id="plant_123", + ) + + +@pytest.fixture +def mock_config_entry_classic() -> MockConfigEntry: + """Return a mocked config entry for Classic API (password auth). + + Use this for tests that specifically need to test Classic API behavior. + Most tests use the default mock_config_entry (V1 API) instead. + """ + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_AUTH_TYPE: AUTH_PASSWORD, + CONF_USERNAME: "test_user", + CONF_PASSWORD: "test_password", + CONF_URL: DEFAULT_URL, + CONF_PLANT_ID: "12345", + "name": "Test Plant", + }, + unique_id="12345", + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_growatt_v1_api +) -> MockConfigEntry: + """Set up the Growatt Server integration for testing (V1 API). + + This combines mock_config_entry and mock_growatt_v1_api to provide a fully + initialized integration ready for testing. Use @pytest.mark.usefixtures("init_integration") + to automatically set up the integration before your test runs. + + For Classic API tests, manually set up using mock_config_entry_classic and + mock_growatt_classic_api instead. + """ + # The mock_growatt_v1_api fixture is required for patches to be active + assert mock_growatt_v1_api is not None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/growatt_server/snapshots/test_switch.ambr b/tests/components/growatt_server/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..534d99ee0eb593 --- /dev/null +++ b/tests/components/growatt_server/snapshots/test_switch.ambr @@ -0,0 +1,80 @@ +# serializer version: 1 +# name: test_switch_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'growatt_server', + 'MIN123456', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Growatt', + 'model': None, + 'model_id': None, + 'name': 'MIN123456', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_switch_entities[switch.min123456_charge_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.min123456_charge_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge from grid', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_charge', + 'unique_id': 'MIN123456_ac_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.min123456_charge_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Charge from grid', + }), + 'context': , + 'entity_id': 'switch.min123456_charge_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/growatt_server/test_switch.py b/tests/components/growatt_server/test_switch.py new file mode 100644 index 00000000000000..926edc8874e7a3 --- /dev/null +++ b/tests/components/growatt_server/test_switch.py @@ -0,0 +1,277 @@ +"""Tests for the Growatt Server switch platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from growattServer import GrowattV1ApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.growatt_server.coordinator import SCAN_INTERVAL +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + EntityCategory, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +DOMAIN = "growatt_server" + + +@pytest.fixture(autouse=True) +async def switch_only() -> AsyncGenerator[None]: + """Enable only the switch platform.""" + with patch( + "homeassistant.components.growatt_server.PLATFORMS", + [Platform.SWITCH], + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switch_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that switch entities are created for MIN devices.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_turn_on_switch_success( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test turning on a switch entity successfully.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.min123456_charge_from_grid"}, + blocking=True, + ) + + # Verify API was called with correct parameters + mock_growatt_v1_api.min_write_parameter.assert_called_once_with( + "MIN123456", "ac_charge", 1 + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_turn_off_switch_success( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test turning off a switch entity successfully.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.min123456_charge_from_grid"}, + blocking=True, + ) + + # Verify API was called with correct parameters + mock_growatt_v1_api.min_write_parameter.assert_called_once_with( + "MIN123456", "ac_charge", 0 + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_turn_on_switch_api_error( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test handling API error when turning on switch.""" + # Mock API to raise error + mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error") + + with pytest.raises(HomeAssistantError, match="Error while setting switch state"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.min123456_charge_from_grid"}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_turn_off_switch_api_error( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test handling API error when turning off switch.""" + # Mock API to raise error + mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error") + + with pytest.raises(HomeAssistantError, match="Error while setting switch state"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.min123456_charge_from_grid"}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switch_entity_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entity attributes.""" + # Check entity registry attributes + entity_entry = entity_registry.async_get("switch.min123456_charge_from_grid") + assert entity_entry is not None + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.unique_id == "MIN123456_ac_charge" + + # Check state attributes + state = hass.states.get("switch.min123456_charge_from_grid") + assert state is not None + assert state.attributes["friendly_name"] == "MIN123456 Charge from grid" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_switch_device_registry( + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that switch entities are associated with the correct device.""" + # Get the device from device registry + device = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device is not None + assert device == snapshot + + # Verify switch entity is associated with the device + entity_entry = entity_registry.async_get("switch.min123456_charge_from_grid") + assert entity_entry is not None + assert entity_entry.device_id == device.id + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_state_handling_integer_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_growatt_v1_api, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch state handling with integer values from API.""" + # Set up integration + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should interpret 1 as ON (from default mock data) + state = hass.states.get("switch.min123456_charge_from_grid") + assert state is not None + assert state.state == STATE_ON + + # Test with 0 integer value + mock_growatt_v1_api.min_detail.return_value = { + "deviceSn": "MIN123456", + "acChargeEnable": 0, # Integer value + } + + # Advance time to trigger coordinator refresh + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Should interpret 0 as OFF + state = hass.states.get("switch.min123456_charge_from_grid") + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch_missing_data( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch entity when coordinator data is missing.""" + # Set up API with missing data for switch entity + mock_growatt_v1_api.min_detail.return_value = { + "deviceSn": "MIN123456", + # Missing 'acChargeEnable' key to test None case + } + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Entity should exist but have unknown state due to missing data + state = hass.states.get("switch.min123456_charge_from_grid") + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_switch_entities_for_non_min_devices( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that switch entities are not created for non-MIN devices.""" + # Mock a different device type (not MIN) - type 7 is MIN, type 8 is non-MIN + mock_growatt_v1_api.device_list.return_value = { + "devices": [ + { + "device_sn": "TLX123456", + "type": 8, # Non-MIN device type (MIN is type 7) + } + ] + } + + # Mock TLX API response to prevent coordinator errors + mock_growatt_v1_api.tlx_detail.return_value = {"data": {}} + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should have no switch entities for TLX devices + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + switch_entities = [entry for entry in entity_entries if entry.domain == "switch"] + assert len(switch_entities) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_switch_entities_for_classic_api( + hass: HomeAssistant, + mock_growatt_classic_api, + mock_config_entry_classic: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that switch entities are not created for Classic API.""" + # Mock device list to return no devices + mock_growatt_classic_api.device_list.return_value = [] + + mock_config_entry_classic.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry_classic.entry_id) + await hass.async_block_till_done() + + # Should have no switch entities for classic API (no devices) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_classic.entry_id + ) + switch_entities = [entry for entry in entity_entries if entry.domain == "switch"] + assert len(switch_entities) == 0 diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index dc32741165e50d..8178cac5f60e58 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -10,14 +10,19 @@ STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, ) +from homeassistant.components.usb import USBDevice from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.setup import async_setup_component from .common import USB_DATA_ZBT2 @@ -382,3 +387,122 @@ async def test_duplicate_discovery_updates_usb_path(hass: HomeAssistant) -> None assert result["reason"] == "already_configured" assert config_entry.data["device"] == USB_DATA_ZBT2.device + + +async def test_firmware_callback_auto_creates_entry(hass: HomeAssistant) -> None: + """Test that firmware notification triggers import flow that auto-creates config entry.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, "usb", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=USB_DATA_ZBT2 + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + + usb_device = USBDevice( + device=USB_DATA_ZBT2.device, + vid=USB_DATA_ZBT2.vid, + pid=USB_DATA_ZBT2.pid, + serial_number=USB_DATA_ZBT2.serial_number, + manufacturer=USB_DATA_ZBT2.manufacturer, + description=USB_DATA_ZBT2.description, + ) + + with patch( + "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path", + return_value=usb_device, + ): + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT2.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[], + source="zha", + ), + ) + + await hass.async_block_till_done() + + # The config entry was auto-created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data == { + "device": USB_DATA_ZBT2.device, + "firmware": ApplicationType.EZSP.value, + "firmware_version": "7.4.4.0", + "vid": USB_DATA_ZBT2.vid, + "pid": USB_DATA_ZBT2.pid, + "serial_number": USB_DATA_ZBT2.serial_number, + "manufacturer": USB_DATA_ZBT2.manufacturer, + "product": USB_DATA_ZBT2.description, + } + + # The discovery flow is gone + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +async def test_firmware_callback_updates_existing_entry(hass: HomeAssistant) -> None: + """Test that firmware notification updates existing config entry device path.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, "usb", {}) + + # Create existing config entry with old device path + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "firmware": ApplicationType.EZSP.value, + "firmware_version": "7.4.4.0", + "device": "/dev/oldpath", + "vid": USB_DATA_ZBT2.vid, + "pid": USB_DATA_ZBT2.pid, + "serial_number": USB_DATA_ZBT2.serial_number, + "manufacturer": USB_DATA_ZBT2.manufacturer, + "product": USB_DATA_ZBT2.description, + }, + unique_id=( + f"{USB_DATA_ZBT2.vid}:{USB_DATA_ZBT2.pid}_" + f"{USB_DATA_ZBT2.serial_number}_" + f"{USB_DATA_ZBT2.manufacturer}_" + f"{USB_DATA_ZBT2.description}" + ), + ) + config_entry.add_to_hass(hass) + + usb_device = USBDevice( + device=USB_DATA_ZBT2.device, + vid=USB_DATA_ZBT2.vid, + pid=USB_DATA_ZBT2.pid, + serial_number=USB_DATA_ZBT2.serial_number, + manufacturer=USB_DATA_ZBT2.manufacturer, + description=USB_DATA_ZBT2.description, + ) + + with patch( + "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path", + return_value=usb_device, + ): + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT2.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[], + source="zha", + ), + ) + + await hass.async_block_till_done() + + # The config entry device path should be updated + assert config_entry.data["device"] == USB_DATA_ZBT2.device + + # No new config entry was created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 diff --git a/tests/components/homeassistant_hardware/test_helpers.py b/tests/components/homeassistant_hardware/test_helpers.py index 3c741df2f2391a..540d2ca7afdddc 100644 --- a/tests/components/homeassistant_hardware/test_helpers.py +++ b/tests/components/homeassistant_hardware/test_helpers.py @@ -1,7 +1,8 @@ """Test hardware helpers.""" +from collections.abc import Callable import logging -from unittest.mock import AsyncMock, MagicMock, Mock, call +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch import pytest @@ -19,6 +20,7 @@ ApplicationType, FirmwareInfo, ) +from homeassistant.components.usb import USBDevice from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -257,3 +259,113 @@ async def test_firmware_update_context_manager(hass: HomeAssistant) -> None: # Should be cleaned up after first context assert not async_is_firmware_update_in_progress(hass, device_path) + + +async def test_dispatcher_callback_self_unregister(hass: HomeAssistant) -> None: + """Test callbacks can unregister themselves during notification.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + + called_callbacks = [] + unregister_funcs = {} + + def create_self_unregistering_callback(name: str) -> Callable[[FirmwareInfo], None]: + def callback(firmware_info: FirmwareInfo) -> None: + called_callbacks.append(name) + unregister_funcs[name]() + + return callback + + callback1 = create_self_unregistering_callback("callback1") + callback2 = create_self_unregistering_callback("callback2") + callback3 = create_self_unregistering_callback("callback3") + + # Register all three callbacks and store their unregister functions + unregister_funcs["callback1"] = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device1", callback1 + ) + unregister_funcs["callback2"] = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device1", callback2 + ) + unregister_funcs["callback3"] = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device1", callback3 + ) + + # All callbacks should be called and unregister themselves + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + assert set(called_callbacks) == {"callback1", "callback2", "callback3"} + + # No callbacks should be called since they all unregistered + called_callbacks.clear() + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + assert not called_callbacks + + +async def test_firmware_callback_no_usb_device( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test firmware notification when usb_device_from_path returns None.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, "usb", {}) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path", + return_value=None, + ), + caplog.at_level(logging.DEBUG), + ): + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device="/dev/ttyUSB99", + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + # This isn't a codepath that's expected but we won't fail in this case, just log + assert "Cannot find USB for path /dev/ttyUSB99" in caplog.text + + +async def test_firmware_callback_no_hardware_domain( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test firmware notification when no hardware domain is found for device.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, "usb", {}) + + # Create a USB device that doesn't match any hardware integration + usb_device = USBDevice( + device="/dev/ttyUSB0", + vid="9999", + pid="9999", + serial_number="TEST123", + manufacturer="Test Manufacturer", + description="Test Device", + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path", + return_value=usb_device, + ), + caplog.at_level(logging.DEBUG), + ): + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + assert "No hardware integration found for device" in caplog.text diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 01478900c60fd2..b0d58473a6786f 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -10,6 +10,9 @@ STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( CONF_DISABLE_MULTI_PAN, get_flasher_addon_manager, @@ -20,10 +23,12 @@ FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.components.usb import USBDevice from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo +from homeassistant.setup import async_setup_component from .common import USB_DATA_SKY, USB_DATA_ZBT1 @@ -426,3 +431,187 @@ async def test_options_flow_multipan_uninstall( # We've reverted the firmware back to Zigbee assert config_entry.data["firmware"] == "ezsp" + + +@pytest.mark.parametrize( + ("usb_data", "model"), + [ + (USB_DATA_SKY, "Home Assistant SkyConnect"), + (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), + ], +) +async def test_firmware_callback_auto_creates_entry( + usb_data: UsbServiceInfo, + model: str, + hass: HomeAssistant, +) -> None: + """Test that firmware notification triggers import flow that auto-creates config entry.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, "usb", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=usb_data + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + + usb_device = USBDevice( + device=usb_data.device, + vid=usb_data.vid, + pid=usb_data.pid, + serial_number=usb_data.serial_number, + manufacturer=usb_data.manufacturer, + description=usb_data.description, + ) + + with patch( + "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path", + return_value=usb_device, + ): + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[], + source="zha", + ), + ) + + await hass.async_block_till_done() + + # The config entry was auto-created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data == { + "device": usb_data.device, + "firmware": ApplicationType.EZSP.value, + "firmware_version": "7.4.4.0", + "vid": usb_data.vid, + "pid": usb_data.pid, + "serial_number": usb_data.serial_number, + "manufacturer": usb_data.manufacturer, + "description": usb_data.description, + "product": usb_data.description, + } + + # The discovery flow is gone + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +@pytest.mark.parametrize( + ("usb_data", "model"), + [ + (USB_DATA_SKY, "Home Assistant SkyConnect"), + (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), + ], +) +async def test_duplicate_usb_discovery_aborts_early( + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant +) -> None: + """Test USB discovery aborts early when unique_id exists before serial path resolution.""" + # Create existing config entry + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "firmware": "ezsp", + "device": "/dev/oldpath", + "manufacturer": usb_data.manufacturer, + "pid": usb_data.pid, + "description": usb_data.description, + "product": usb_data.description, + "serial_number": usb_data.serial_number, + "vid": usb_data.vid, + }, + unique_id=( + f"{usb_data.vid}:{usb_data.pid}_" + f"{usb_data.serial_number}_" + f"{usb_data.manufacturer}_" + f"{usb_data.description}" + ), + ) + config_entry.add_to_hass(hass) + + # Try to discover the same device with a different path + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "usb"}, data=usb_data + ) + + # Should abort before get_serial_by_id is called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("usb_data", "model"), + [ + (USB_DATA_SKY, "Home Assistant SkyConnect"), + (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), + ], +) +async def test_firmware_callback_updates_existing_entry( + usb_data: UsbServiceInfo, model: str, hass: HomeAssistant +) -> None: + """Test that firmware notification updates existing config entry device path.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + await async_setup_component(hass, "usb", {}) + + # Create existing config entry with old device path + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "firmware": ApplicationType.EZSP.value, + "firmware_version": "7.4.4.0", + "device": "/dev/oldpath", + "vid": usb_data.vid, + "pid": usb_data.pid, + "serial_number": usb_data.serial_number, + "manufacturer": usb_data.manufacturer, + "description": usb_data.description, + "product": usb_data.description, + }, + unique_id=( + f"{usb_data.vid}:{usb_data.pid}_" + f"{usb_data.serial_number}_" + f"{usb_data.manufacturer}_" + f"{usb_data.description}" + ), + ) + config_entry.add_to_hass(hass) + + usb_device = USBDevice( + device=usb_data.device, + vid=usb_data.vid, + pid=usb_data.pid, + serial_number=usb_data.serial_number, + manufacturer=usb_data.manufacturer, + description=usb_data.description, + ) + + with patch( + "homeassistant.components.homeassistant_hardware.helpers.usb_device_from_path", + return_value=usb_device, + ): + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0", + owners=[], + source="zha", + ), + ) + + await hass.async_block_till_done() + + # The config entry device path should be updated + assert config_entry.data["device"] == usb_data.device + + # No new config entry was created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index e3bb3ac303b9fd..062d3c768a3adf 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -3,6 +3,7 @@ from decimal import Decimal from http import HTTPStatus import json +import math from unittest.mock import AsyncMock, Mock from aiohttp.web_exceptions import ( @@ -46,7 +47,7 @@ async def test_invalid_json(caplog: pytest.LogCaptureFixture) -> None: async def test_nan_serialized_to_null() -> None: """Test nan serialized to null JSON.""" - response = HomeAssistantView.json(float("NaN")) + response = HomeAssistantView.json(math.nan) assert json.loads(response.body.decode("utf-8")) is None diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 9d883502d280cc..140b473cd7d48c 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -8,7 +8,10 @@ import pytest from homeassistant import config_entries -from homeassistant.components.bluetooth import BluetoothChange +from homeassistant.components.bluetooth import ( + BluetoothChange, + BluetoothServiceInfoBleak, +) from homeassistant.components.improv_ble.const import DOMAIN from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS @@ -22,7 +25,12 @@ PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_capture_events +from tests.components.bluetooth import ( + generate_advertisement_data, + generate_ble_device, + inject_bluetooth_service_info_bleak, +) IMPROV_BLE = "homeassistant.components.improv_ble" @@ -179,6 +187,112 @@ async def test_bluetooth_step_provisioned_device_2(hass: HomeAssistant) -> None: assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 0 +async def test_bluetooth_step_provisioned_no_rediscovery(hass: HomeAssistant) -> None: + """Test that provisioned device is not rediscovered while it stays provisioned.""" + # Step 1: Inject provisioned device advertisement (triggers discovery, aborts) + inject_bluetooth_service_info_bleak(hass, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO) + await hass.async_block_till_done() + + # Verify flow was aborted + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 0 + + # Step 2: Inject same provisioned advertisement again + # This should NOT trigger a new discovery because the content hasn't changed + # even though we cleared the match history + inject_bluetooth_service_info_bleak(hass, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO) + await hass.async_block_till_done() + + # Verify no new flow was started + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 0 + + +async def test_bluetooth_step_factory_reset_rediscovery(hass: HomeAssistant) -> None: + """Test that factory reset device can be rediscovered.""" + # Start a flow manually with provisioned device to ensure improv_ble is loaded + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_provisioned" + + # Now the match history has been cleared by the config flow + # Inject authorized device advertisement - should trigger new discovery + inject_bluetooth_service_info_bleak(hass, IMPROV_BLE_DISCOVERY_INFO) + await hass.async_block_till_done() + + # Verify discovery proceeds (new flow started) + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["step_id"] == "bluetooth_confirm" + + +async def test_bluetooth_rediscovery_after_successful_provision( + hass: HomeAssistant, +) -> None: + """Test that device can be rediscovered after successful provisioning.""" + # Start provisioning flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Confirm bluetooth setup + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Start provisioning + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "provision" + + # Complete provisioning successfully + with ( + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), + patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "TestNetwork", "password": "secret"} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "provision_successful" + + # Now inject the same device again (simulating factory reset) + # The match history was cleared after successful provision, so it should be rediscovered + inject_bluetooth_service_info_bleak(hass, IMPROV_BLE_DISCOVERY_INFO) + await hass.async_block_till_done() + + # Verify new discovery flow was created + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["step_id"] == "bluetooth_confirm" + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( @@ -696,3 +810,62 @@ async def test_provision_fails_invalid_data( "Received invalid improv via BLE data '000000000000' from device with bluetooth address 'AA:BB:CC:DD:EE:F0'" in caplog.text ) + + +async def test_bluetooth_name_update(hass: HomeAssistant) -> None: + """Test that discovery notification title updates when device name changes.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_register_callback", + ) as mock_async_register_callback: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Get the flow to check initial title_placeholders + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == {"name": "00123456"} + + # Get the callback that was registered + callback = mock_async_register_callback.call_args.args[1] + + # Create updated discovery info with a new name + updated_discovery_info = BluetoothServiceInfoBleak( + name="improvtest", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[IMPROV_BLE_DISCOVERY_INFO.service_uuids[0]], + service_data=IMPROV_BLE_DISCOVERY_INFO.service_data, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="improvtest"), + advertisement=generate_advertisement_data( + service_uuids=IMPROV_BLE_DISCOVERY_INFO.service_uuids, + service_data=IMPROV_BLE_DISCOVERY_INFO.service_data, + ), + time=0, + connectable=True, + tx_power=-127, + ) + + # Capture events to verify frontend notification + events = async_capture_events(hass, "data_entry_flow_progressed") + + # Simulate receiving updated advertisement with new name + callback(updated_discovery_info, BluetoothChange.ADVERTISEMENT) + await hass.async_block_till_done() + + # Verify title_placeholders were updated + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == {"name": "improvtest"} + + # Verify frontend was notified + assert len(events) == 1 + assert events[0].data == { + "handler": DOMAIN, + "flow_id": result["flow_id"], + "refresh": True, + } diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index b93b7e965dfad8..34382f742c821d 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -109,6 +109,40 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_telegram_count(0) +async def test_last_reported( + hass: HomeAssistant, + knx: KNXTestKit, + freezer: FrozenDateTimeFactory, +) -> None: + """Test KNX binary sensor properly sets last_reported.""" + + await knx.setup_integration( + { + BinarySensorSchema.PLATFORM: [ + { + CONF_NAME: "test", + CONF_STATE_ADDRESS: "1/1/1", + CONF_SYNC_STATE: False, + }, + ] + } + ) + events = async_capture_events(hass, "state_changed") + + # receive initial telegram + await knx.receive_write("1/1/1", True) + first_reported = hass.states.get("binary_sensor.test").last_reported + assert len(events) == 1 + + # receive second telegram with identical payload + freezer.tick(1) + async_fire_time_changed(hass) + await knx.receive_write("1/1/1", True) + + assert first_reported != hass.states.get("binary_sensor.test").last_reported + assert len(events) == 1, events # last_reported shall not fire state_changed + + async def test_binary_sensor_ignore_internal_state( hass: HomeAssistant, knx: KNXTestKit ) -> None: diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 26da92e72c9447..e5a1747962917b 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -48,7 +48,7 @@ async def test_last_reported( knx: KNXTestKit, freezer: FrozenDateTimeFactory, ) -> None: - """Test KNX sensor with last_reported.""" + """Test KNX sensor properly sets last_reported.""" await knx.setup_integration( { diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr index 54f6083a74cc3c..4f5004f25de834 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -849,7 +849,8 @@ }), 'info': dict({ 'manufacturer': 'Miele', - 'model': 'FNS 28463 E ed/', + 'model': 'Freezer', + 'model_id': 'FNS 28463 E ed/', }), 'miele_data': dict({ 'actions': dict({ diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index 81f6c0c3a358f3..b5b830f4e5cb09 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -20,8 +20,8 @@ 'labels': set({ }), 'manufacturer': 'Miele', - 'model': 'FNS 28463 E ed/', - 'model_id': None, + 'model': 'Freezer', + 'model_id': 'FNS 28463 E ed/', 'name': 'Freezer', 'name_by_user': None, 'primary_config_entry': , diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index ef9c6b5b8cd460..e9e56ff6974d09 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,5 +1,6 @@ """The tests for the Modbus sensor component.""" +import math import struct import pytest @@ -738,8 +739,8 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: [ 0x5102, 0x0304, - int.from_bytes(struct.pack(">f", float("nan"))[0:2]), - int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + int.from_bytes(struct.pack(">f", math.nan)[0:2]), + int.from_bytes(struct.pack(">f", math.nan)[2:4]), ], False, ["34899771392.0", STATE_UNKNOWN], @@ -753,8 +754,8 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: [ 0x5102, 0x0304, - int.from_bytes(struct.pack(">f", float("nan"))[0:2]), - int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + int.from_bytes(struct.pack(">f", math.nan)[0:2]), + int.from_bytes(struct.pack(">f", math.nan)[2:4]), ], False, ["34899771392.0", STATE_UNKNOWN], @@ -1160,8 +1161,8 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: CONF_DATA_TYPE: DataType.FLOAT32, }, [ - int.from_bytes(struct.pack(">f", float("nan"))[0:2]), - int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + int.from_bytes(struct.pack(">f", math.nan)[0:2]), + int.from_bytes(struct.pack(">f", math.nan)[2:4]), ], STATE_UNKNOWN, ), @@ -1224,8 +1225,8 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: # floats: nan, 10.600000381469727, # 1.000879611487865e-28, 10.566553115844727 [ - int.from_bytes(struct.pack(">f", float("nan"))[0:2]), - int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + int.from_bytes(struct.pack(">f", math.nan)[0:2]), + int.from_bytes(struct.pack(">f", math.nan)[2:4]), 0x4129, 0x999A, 0x10FD, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f0a6d89fa7a7bb..6185e2ffae18e3 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -17,7 +17,10 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError -from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.config_flow import ( + PWD_NOT_CHANGED, + TRANSLATION_DESCRIPTION_PLACEHOLDERS, +) from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( @@ -3757,12 +3760,16 @@ async def test_subentry_configflow( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - assert result["description_placeholders"] == { - "mqtt_device": device_name, - "platform": component["platform"], - "entity": entity_name, - "url": learn_more_url(component["platform"]), - } + assert ( + result["description_placeholders"] + == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + | TRANSLATION_DESCRIPTION_PLACEHOLDERS + ) # Process entity details step assert result["step_id"] == "entity_platform_config" @@ -3784,12 +3791,16 @@ async def test_subentry_configflow( ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - assert result["description_placeholders"] == { - "mqtt_device": device_name, - "platform": component["platform"], - "entity": entity_name, - "url": learn_more_url(component["platform"]), - } + assert ( + result["description_placeholders"] + == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + | TRANSLATION_DESCRIPTION_PLACEHOLDERS + ) # Process mqtt platform config flow # Test an invalid mqtt user input case @@ -5096,12 +5107,16 @@ async def test_subentry_configflow_section_feature( user_input={"platform": "fan"}, ) assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == { - "mqtt_device": "Bla", - "platform": "fan", - "entity": "Bla", - "url": learn_more_url("fan"), - } + assert ( + result["description_placeholders"] + == { + "mqtt_device": "Bla", + "platform": "fan", + "entity": "Bla", + "url": learn_more_url("fan"), + } + | TRANSLATION_DESCRIPTION_PLACEHOLDERS + ) # Process entity details step assert result["step_id"] == "entity_platform_config" diff --git a/tests/components/nintendo_parental_controls/conftest.py b/tests/components/nintendo_parental_controls/conftest.py index 65ae46603f750b..a75e29d39e724e 100644 --- a/tests/components/nintendo_parental_controls/conftest.py +++ b/tests/components/nintendo_parental_controls/conftest.py @@ -36,6 +36,7 @@ def mock_nintendo_device() -> Device: mock.today_playing_time = 110 mock.bedtime_alarm = time(hour=19) mock.set_bedtime_alarm.return_value = None + mock.forced_termination_mode = True return mock diff --git a/tests/components/nintendo_parental_controls/snapshots/test_switch.ambr b/tests/components/nintendo_parental_controls/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..e93510668be9ac --- /dev/null +++ b/tests/components/nintendo_parental_controls/snapshots/test_switch.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_switch[switch.home_assistant_test_suspend_software-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.home_assistant_test_suspend_software', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Suspend software', + 'platform': 'nintendo_parental_controls', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'testdevid_suspend_software', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.home_assistant_test_suspend_software-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Home Assistant Test Suspend software', + }), + 'context': , + 'entity_id': 'switch.home_assistant_test_suspend_software', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nintendo_parental_controls/test_switch.py b/tests/components/nintendo_parental_controls/test_switch.py new file mode 100644 index 00000000000000..4bef1e2ac28dcf --- /dev/null +++ b/tests/components/nintendo_parental_controls/test_switch.py @@ -0,0 +1,64 @@ +"""Test switch platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switch platform.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_suspend_software( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, +) -> None: + """Test switch platform.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + target={ATTR_ENTITY_ID: "switch.home_assistant_test_suspend_software"}, + blocking=True, + ) + assert len(mock_nintendo_device.set_restriction_mode.mock_calls) == 1 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + target={ATTR_ENTITY_ID: "switch.home_assistant_test_suspend_software"}, + blocking=True, + ) + assert len(mock_nintendo_device.set_restriction_mode.mock_calls) == 2 diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 3fa366216fd65e..7413115c9fcd0f 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -11,11 +11,13 @@ ) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, + CONF_HOSTS_EXCLUDE, + CONF_HOSTS_LIST, + CONF_MAC_EXCLUDE, CONF_OPTIONS, DEFAULT_OPTIONS, DOMAIN, ) -from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import CoreState, HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,7 +25,7 @@ @pytest.mark.parametrize( - "hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"] + "hosts", [["1.1.1.1"], ["192.168.1.0/24"], ["192.168.1.0/24", "192.168.2.0/24"]] ) async def test_form(hass: HomeAssistant, hosts: str) -> None: """Test we get the form.""" @@ -44,22 +46,24 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOSTS: hosts, + CONF_HOSTS_LIST: hosts, CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == f"Nmap Tracker {hosts}" + assert result2["title"] == f"Nmap Tracker {', '.join(hosts)}" assert result2["data"] == {} assert result2["options"] == { - CONF_HOSTS: hosts, + CONF_HOSTS_LIST: hosts, CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], } assert len(mock_setup_entry.mock_calls) == 1 @@ -80,10 +84,11 @@ async def test_form_range(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOSTS: "192.168.0.5-12", + CONF_HOSTS_LIST: ["192.168.0.5-12"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], }, ) await hass.async_block_till_done() @@ -92,10 +97,11 @@ async def test_form_range(hass: HomeAssistant) -> None: assert result2["title"] == "Nmap Tracker 192.168.0.5-12" assert result2["data"] == {} assert result2["options"] == { - CONF_HOSTS: "192.168.0.5-12", + CONF_HOSTS_LIST: ["192.168.0.5-12"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], } assert len(mock_setup_entry.mock_calls) == 1 @@ -112,16 +118,17 @@ async def test_form_invalid_hosts(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOSTS: "not an ip block", + CONF_HOSTS_LIST: ["not an ip block"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "", + CONF_HOSTS_EXCLUDE: [], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} + assert result2["errors"] == {CONF_HOSTS_LIST: "invalid_hosts"} async def test_form_already_configured(hass: HomeAssistant) -> None: @@ -131,10 +138,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: domain=DOMAIN, data={}, options={ - CONF_HOSTS: "192.168.0.0/20", + CONF_HOSTS_LIST: ["192.168.0.0/20"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], }, ) config_entry.add_to_hass(hass) @@ -147,10 +155,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOSTS: "192.168.0.0/20", + CONF_HOSTS_LIST: ["192.168.0.0/20"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "", + CONF_HOSTS_EXCLUDE: [], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], }, ) await hass.async_block_till_done() @@ -159,8 +168,8 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_form_invalid_excludes(hass: HomeAssistant) -> None: - """Test invalid excludes passed in.""" +async def test_form_invalid_ip_excludes(hass: HomeAssistant) -> None: + """Test invalid ip excludes passed in.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -171,16 +180,48 @@ async def test_form_invalid_excludes(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOSTS: "3.3.3.3", + CONF_HOSTS_LIST: ["3.3.3.3"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "not an exclude", + CONF_HOSTS_EXCLUDE: ["not an exclude"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00"], }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} + assert result2["errors"] == {CONF_HOSTS_EXCLUDE: "invalid_hosts"} + + +@pytest.mark.parametrize( + "mac_excludes", + [["1234567890"], ["1234567890", "11:22:33:44:55:66"], ["ABCDEFGHIJK"]], +) +async def test_form_invalid_mac_excludes( + hass: HomeAssistant, mac_excludes: str +) -> None: + """Test invalid mac excludes passed in.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTS_LIST: ["3.3.3.3"], + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: mac_excludes, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_MAC_EXCLUDE: "invalid_hosts"} async def test_options_flow(hass: HomeAssistant) -> None: @@ -190,11 +231,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: domain=DOMAIN, data={}, options={ - CONF_HOSTS: "192.168.1.0/24", + CONF_HOSTS_LIST: ["192.168.1.0/24"], CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"], }, + version=1, + minor_version=2, ) config_entry.add_to_hass(hass) hass.set_state(CoreState.stopped) @@ -208,12 +252,13 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "init" assert result["data_schema"]({}) == { - CONF_EXCLUDE: "4.4.4.4", + CONF_HOSTS_EXCLUDE: ["4.4.4.4"], CONF_HOME_INTERVAL: 3, - CONF_HOSTS: "192.168.1.0/24", + CONF_HOSTS_LIST: ["192.168.1.0/24"], CONF_CONSIDER_HOME: 180, CONF_SCAN_INTERVAL: 120, CONF_OPTIONS: "-n -sn -PR -T4 --min-rate 10 --host-timeout 5s", + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"], } with patch( @@ -223,23 +268,25 @@ async def test_options_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", + CONF_HOSTS_LIST: ["192.168.1.0/24", "192.168.2.0/24"], CONF_HOME_INTERVAL: 5, CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", - CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", + CONF_HOSTS_EXCLUDE: ["4.4.4.4", "5.5.5.5"], CONF_SCAN_INTERVAL: 10, + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"], }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options == { - CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", + CONF_HOSTS_LIST: ["192.168.1.0/24", "192.168.2.0/24"], CONF_HOME_INTERVAL: 5, CONF_CONSIDER_HOME: 500, CONF_OPTIONS: "-sn", - CONF_EXCLUDE: "4.4.4.4,5.5.5.5", + CONF_HOSTS_EXCLUDE: ["4.4.4.4", "5.5.5.5"], CONF_SCAN_INTERVAL: 10, + CONF_MAC_EXCLUDE: ["00:00:00:00:00:00", "11:22:33:44:55:66"], } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nmap_tracker/test_init.py b/tests/components/nmap_tracker/test_init.py new file mode 100644 index 00000000000000..142338921764ff --- /dev/null +++ b/tests/components/nmap_tracker/test_init.py @@ -0,0 +1,93 @@ +"""Tests for the nmap_tracker component.""" + +from unittest.mock import patch + +from homeassistant.components.nmap_tracker.const import ( + CONF_HOME_INTERVAL, + CONF_HOSTS_EXCLUDE, + CONF_HOSTS_LIST, + CONF_MAC_EXCLUDE, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_migrate_entry(hass: HomeAssistant) -> None: + """Test migrating a config entry from version 1 to version 2.""" + mock_entry = MockConfigEntry( + unique_id="test_nmap_tracker", + domain=DOMAIN, + version=1, + options={ + CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: "192.168.1.1,192.168.2.2", + }, + title="Nmap Test Tracker", + ) + + mock_entry.add_to_hass(hass) + # Prevent the scanner from starting + with patch( + "homeassistant.components.nmap_tracker.NmapDeviceScanner._async_start_scanner", + return_value=None, + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Check that it has a source_id now + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry + assert updated_entry.version == 1 + assert updated_entry.minor_version == 2 + assert updated_entry.options == { + CONF_EXCLUDE: "192.168.1.1,192.168.2.2", + CONF_HOME_INTERVAL: 3, + CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", + CONF_HOSTS_EXCLUDE: ["192.168.1.1", "192.168.2.2"], + CONF_HOSTS_LIST: ["192.168.1.0/24", "192.168.2.0/24"], + CONF_MAC_EXCLUDE: [], + CONF_OPTIONS: DEFAULT_OPTIONS, + } + assert updated_entry.state == ConfigEntryState.LOADED + + +async def test_migrate_entry_fails_on_downgrade(hass: HomeAssistant) -> None: + """Test that migration fails when user downgrades from a future version.""" + mock_entry = MockConfigEntry( + unique_id="test_nmap_tracker", + domain=DOMAIN, + version=2, + options={ + CONF_HOSTS: ["192.168.1.0/24"], + CONF_HOME_INTERVAL: 3, + CONF_OPTIONS: DEFAULT_OPTIONS, + CONF_EXCLUDE: ["192.168.1.1"], + }, + title="Nmap Test Tracker", + ) + + mock_entry.add_to_hass(hass) + + # Prevent the scanner from starting + with patch( + "homeassistant.components.nmap_tracker.NmapDeviceScanner._async_start_scanner", + return_value=None, + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Check that entry is in migration error state + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert updated_entry + assert updated_entry.version == 2 + assert updated_entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index cee29a76dca1fb..10537d48064ba4 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -291,7 +291,7 @@ 'state': 'unavailable', }) # --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_driver_door-entry] +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hvac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -304,7 +304,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_fuel_driver_door', + 'entity_id': 'binary_sensor.reg_captur_fuel_hvac', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -314,275 +314,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1capturfuelvin_driver_door_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_driver_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR-FUEL Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_fuel_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hatch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_fuel_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1capturfuelvin_hatch_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hatch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR-FUEL Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_fuel_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_lock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_fuel_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1capturfuelvin_lock_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-CAPTUR-FUEL Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_fuel_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_passenger_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_fuel_passenger_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Passenger door', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1capturfuelvin_passenger_door_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_passenger_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR-FUEL Passenger door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_fuel_passenger_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_left_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_fuel_rear_left_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1capturfuelvin_rear_left_door_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_left_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR-FUEL Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_fuel_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_right_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_fuel_rear_right_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Rear right door', + 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1capturfuelvin_rear_right_door_status', + 'translation_key': 'hvac_status', + 'unique_id': 'vf1capturfuelvin_hvac_status', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_rear_right_door-state] +# name: test_binary_sensors[captur_fuel][binary_sensor.reg_captur_fuel_hvac-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR-FUEL Rear right door', + 'friendly_name': 'REG-CAPTUR-FUEL HVAC', }), 'context': , - 'entity_id': 'binary_sensor.reg_captur_fuel_rear_right_door', + 'entity_id': 'binary_sensor.reg_captur_fuel_hvac', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_charging-entry] @@ -634,7 +388,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_driver_door-entry] +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hvac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -647,7 +401,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_phev_driver_door', + 'entity_id': 'binary_sensor.reg_captur_phev_hvac', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -657,177 +411,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Driver door', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_door_status', - 'unique_id': 'vf1capturphevvin_driver_door_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_driver_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR_PHEV Driver door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_phev_driver_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hatch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_phev_hatch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Hatch', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hatch_status', - 'unique_id': 'vf1capturphevvin_hatch_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hatch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR_PHEV Hatch', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_phev_hatch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_lock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_phev_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lock', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'vf1capturphevvin_lock_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'lock', - 'friendly_name': 'REG-CAPTUR_PHEV Lock', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_phev_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_passenger_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_phev_passenger_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Passenger door', + 'original_name': 'HVAC', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'passenger_door_status', - 'unique_id': 'vf1capturphevvin_passenger_door_status', + 'translation_key': 'hvac_status', + 'unique_id': 'vf1capturphevvin_hvac_status', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_passenger_door-state] +# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_hvac-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR_PHEV Passenger door', + 'friendly_name': 'REG-CAPTUR_PHEV HVAC', }), 'context': , - 'entity_id': 'binary_sensor.reg_captur_phev_passenger_door', + 'entity_id': 'binary_sensor.reg_captur_phev_hvac', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unknown', }) # --- # name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_plug-entry] @@ -879,104 +485,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_left_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_phev_rear_left_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear left door', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_left_door_status', - 'unique_id': 'vf1capturphevvin_rear_left_door_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_left_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR_PHEV Rear left door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_phev_rear_left_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_right_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.reg_captur_phev_rear_right_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Rear right door', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_right_door_status', - 'unique_id': 'vf1capturphevvin_rear_right_door_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensors[captur_phev][binary_sensor.reg_captur_phev_rear_right_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'REG-CAPTUR_PHEV Rear right door', - }), - 'context': , - 'entity_id': 'binary_sensor.reg_captur_phev_rear_right_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_binary_sensors[twingo_3_electric][binary_sensor.reg_twingo_iii_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index e0a1c779fc8b38..ad6e44b3923cef 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -121,67 +121,6 @@ 'state': 'unavailable', }) # --- -# name: test_selects[captur_phev][select.reg_captur_phev_charge_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_captur_phev_charge_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1capturphevvin_charge_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[captur_phev][select.reg_captur_phev_charge_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-CAPTUR_PHEV Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_captur_phev_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'always', - }) -# --- # name: test_selects[twingo_3_electric][select.reg_twingo_iii_charge_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -304,64 +243,3 @@ 'state': 'always', }) # --- -# name: test_selects[zoe_50][select.reg_zoe_50_charge_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.reg_zoe_50_charge_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Charge mode', - 'platform': 'renault', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_mode', - 'unique_id': 'vf1zoe50vin_charge_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[zoe_50][select.reg_zoe_50_charge_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-ZOE-50 Charge mode', - 'options': list([ - 'always', - 'always_charging', - 'schedule_mode', - 'scheduled', - ]), - }), - 'context': , - 'entity_id': 'select.reg_zoe_50_charge_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'schedule_mode', - }) -# --- diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 908b3ab90321e9..3f7c0b637d858b 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -1563,7 +1563,7 @@ 'state': '3', }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-entry] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_hvac_soc_threshold-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1576,7 +1576,56 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', + 'entity_id': 'sensor.reg_captur_fuel_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1capturfuelvin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR-FUEL HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_fuel_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_hvac_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_fuel_last_hvac_activity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1588,38 +1637,36 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Last location activity', + 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1capturfuelvin_location_last_activity', + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1capturfuelvin_hvac_last_activity', 'unit_of_measurement': None, }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-state] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_hvac_activity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'REG-CAPTUR-FUEL Last location activity', + 'friendly_name': 'REG-CAPTUR-FUEL Last HVAC activity', }), 'context': , - 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', + 'entity_id': 'sensor.reg_captur_fuel_last_hvac_activity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', + 'state': 'unknown', }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-entry] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1627,7 +1674,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_fuel_mileage', + 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1636,44 +1683,41 @@ }), 'name': None, 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Mileage', + 'original_name': 'Last location activity', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1capturfuelvin_mileage', - 'unit_of_measurement': , + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1capturfuelvin_location_last_activity', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-state] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_last_location_activity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-CAPTUR-FUEL Mileage', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR-FUEL Last location activity', }), 'context': , - 'entity_id': 'sensor.reg_captur_fuel_mileage', + 'entity_id': 'sensor.reg_captur_fuel_last_location_activity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5567', + 'state': '2020-02-18T16:58:38+00:00', }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start-entry] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1681,7 +1725,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start', + 'entity_id': 'sensor.reg_captur_fuel_mileage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1690,38 +1734,46 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Remote engine start', + 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1capturfuelvin_res_state', - 'unit_of_measurement': None, + 'translation_key': 'mileage', + 'unique_id': 'vf1capturfuelvin_mileage', + 'unit_of_measurement': , }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start-state] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_mileage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-CAPTUR-FUEL Remote engine start', + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR-FUEL Mileage', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start', + 'entity_id': 'sensor.reg_captur_fuel_mileage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Stopped, ready for RES', + 'state': '5567', }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start_code-entry] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1729,7 +1781,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start_code', + 'entity_id': 'sensor.reg_captur_fuel_outside_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1738,30 +1790,36 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Remote engine start code', + 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1capturfuelvin_res_state_code', - 'unit_of_measurement': None, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1capturfuelvin_outside_temperature', + 'unit_of_measurement': , }) # --- -# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_remote_engine_start_code-state] +# name: test_sensors[captur_fuel][sensor.reg_captur_fuel_outside_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-CAPTUR-FUEL Remote engine start code', + 'device_class': 'temperature', + 'friendly_name': 'REG-CAPTUR-FUEL Outside temperature', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.reg_captur_fuel_remote_engine_start_code', + 'entity_id': 'sensor.reg_captur_fuel_outside_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10', + 'state': 'unknown', }) # --- # name: test_sensors[captur_phev][sensor.reg_captur_phev_admissible_charging_power-entry] @@ -2279,6 +2337,55 @@ 'state': '3', }) # --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_hvac_soc_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_captur_phev_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1capturphevvin_hvac_soc_threshold', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[captur_phev][sensor.reg_captur_phev_hvac_soc_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-CAPTUR_PHEV HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_captur_phev_hvac_soc_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[captur_phev][sensor.reg_captur_phev_last_battery_activity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2328,7 +2435,7 @@ 'state': '2020-01-12T21:40:16+00:00', }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-entry] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_hvac_activity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2341,7 +2448,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_phev_last_location_activity', + 'entity_id': 'sensor.reg_captur_phev_last_hvac_activity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2353,38 +2460,36 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Last location activity', + 'original_name': 'Last HVAC activity', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'location_last_activity', - 'unique_id': 'vf1capturphevvin_location_last_activity', + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1capturphevvin_hvac_last_activity', 'unit_of_measurement': None, }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-state] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_hvac_activity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'REG-CAPTUR_PHEV Last location activity', + 'friendly_name': 'REG-CAPTUR_PHEV Last HVAC activity', }), 'context': , - 'entity_id': 'sensor.reg_captur_phev_last_location_activity', + 'entity_id': 'sensor.reg_captur_phev_last_hvac_activity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2020-02-18T16:58:38+00:00', + 'state': 'unknown', }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-entry] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2392,7 +2497,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_phev_mileage', + 'entity_id': 'sensor.reg_captur_phev_last_location_activity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2401,51 +2506,40 @@ }), 'name': None, 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Mileage', + 'original_name': 'Last location activity', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'mileage', - 'unique_id': 'vf1capturphevvin_mileage', - 'unit_of_measurement': , + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1capturphevvin_location_last_activity', + 'unit_of_measurement': None, }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-state] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_last_location_activity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'REG-CAPTUR_PHEV Mileage', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'REG-CAPTUR_PHEV Last location activity', }), 'context': , - 'entity_id': 'sensor.reg_captur_phev_mileage', + 'entity_id': 'sensor.reg_captur_phev_last_location_activity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5567', + 'state': '2020-02-18T16:58:38+00:00', }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-entry] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2454,7 +2548,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_phev_plug_state', + 'entity_id': 'sensor.reg_captur_phev_mileage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2463,46 +2557,46 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Plug state', + 'original_name': 'Mileage', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'plug_state', - 'unique_id': 'vf1capturphevvin_plug_state', - 'unit_of_measurement': None, + 'translation_key': 'mileage', + 'unique_id': 'vf1capturphevvin_mileage', + 'unit_of_measurement': , }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-state] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_mileage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'REG-CAPTUR_PHEV Plug state', - 'options': list([ - 'unplugged', - 'plugged', - 'plugged_waiting_for_charge', - 'plug_error', - 'plug_unknown', - ]), + 'device_class': 'distance', + 'friendly_name': 'REG-CAPTUR_PHEV Mileage', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.reg_captur_phev_plug_state', + 'entity_id': 'sensor.reg_captur_phev_mileage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'plugged', + 'state': '5567', }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start-entry] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2510,7 +2604,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_phev_remote_engine_start', + 'entity_id': 'sensor.reg_captur_phev_outside_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2519,38 +2613,52 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Remote engine start', + 'original_name': 'Outside temperature', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'res_state', - 'unique_id': 'vf1capturphevvin_res_state', - 'unit_of_measurement': None, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1capturphevvin_outside_temperature', + 'unit_of_measurement': , }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start-state] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_outside_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-CAPTUR_PHEV Remote engine start', + 'device_class': 'temperature', + 'friendly_name': 'REG-CAPTUR_PHEV Outside temperature', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.reg_captur_phev_remote_engine_start', + 'entity_id': 'sensor.reg_captur_phev_outside_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Stopped, ready for RES', + 'state': 'unknown', }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start_code-entry] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2558,7 +2666,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_captur_phev_remote_engine_start_code', + 'entity_id': 'sensor.reg_captur_phev_plug_state', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2568,29 +2676,37 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Remote engine start code', + 'original_name': 'Plug state', 'platform': 'renault', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'res_state_code', - 'unique_id': 'vf1capturphevvin_res_state_code', + 'translation_key': 'plug_state', + 'unique_id': 'vf1capturphevvin_plug_state', 'unit_of_measurement': None, }) # --- -# name: test_sensors[captur_phev][sensor.reg_captur_phev_remote_engine_start_code-state] +# name: test_sensors[captur_phev][sensor.reg_captur_phev_plug_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'REG-CAPTUR_PHEV Remote engine start code', + 'device_class': 'enum', + 'friendly_name': 'REG-CAPTUR_PHEV Plug state', + 'options': list([ + 'unplugged', + 'plugged', + 'plugged_waiting_for_charge', + 'plug_error', + 'plug_unknown', + ]), }), 'context': , - 'entity_id': 'sensor.reg_captur_phev_remote_engine_start_code', + 'entity_id': 'sensor.reg_captur_phev_plug_state', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10', + 'state': 'plugged', }) # --- # name: test_sensors[twingo_3_electric][sensor.reg_twingo_iii_admissible_charging_power-entry] diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 73013999e7abe8..cddb06913450e6 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -26,7 +26,10 @@ # Captur (fuel version) does not have a charge mode select -_TEST_VEHICLES = [v for v in MOCK_VEHICLES if v != "captur_fuel"] +# charge mode is also not available for all vehicles +_TEST_VEHICLES = [ + v for v in MOCK_VEHICLES if v not in ("captur_fuel", "captur_phev", "zoe_50") +] @pytest.fixture(autouse=True) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index e75d0558f19074..fe2f63331e0748 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -197,9 +197,9 @@ async def test_sensor_throttling_after_init( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 300), # 5 coordinators => 5 minutes interval - ("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval - ("multi", 2, 480), # 8 coordinators => 8 minutes interval + ("zoe_50", 1, 240), # 4 coordinators => 4 minutes interval + ("captur_fuel", 1, 180), # 3 coordinators => 3 minutes interval + ("multi", 2, 420), # 7 coordinators => 8 minutes interval ], indirect=["vehicle_type"], ) @@ -236,7 +236,7 @@ async def test_dynamic_scan_interval( @pytest.mark.parametrize( ("vehicle_type", "vehicle_count", "scan_interval"), [ - ("zoe_50", 1, 240), # (6-2) coordinators => 4 minutes interval + ("zoe_50", 1, 180), # (6-1) coordinators => 3 minutes interval ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval ("multi", 2, 360), # (8-2) coordinators => 6 minutes interval ], diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 4bbe222fad6d39..b0c387e2cd329d 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -53,7 +53,6 @@ async def test_motion_sensor( async def test_smart_ai_sensor( hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, reolink_host: MagicMock, @@ -77,6 +76,31 @@ async def test_smart_ai_sensor( assert hass.states.get(entity_id).state == STATE_OFF +async def test_index_sensor( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_host: MagicMock, +) -> None: + """Test index binary sensor entity.""" + reolink_host.baichuan.io_inputs.return_value = [0] + reolink_host.baichuan.io_input_state.return_value = True + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_io_input_0" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_host.baichuan.io_input_state.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_tcp_callback( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 05e24d1f9b2cad..4b0ac44c14f85d 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -5,6 +5,7 @@ from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +import math from typing import Any from unittest.mock import patch @@ -2313,9 +2314,11 @@ async def test_state_classes_with_invalid_unit_of_measurement( (datetime(2012, 11, 10, 7, 35, 1), "non-numeric"), (date(2012, 11, 10), "non-numeric"), ("inf", "non-finite"), - (float("inf"), "non-finite"), + (math.inf, "non-finite"), + (float("inf"), "non-finite"), # pylint: disable=consider-math-not-float ("nan", "non-finite"), - (float("nan"), "non-finite"), + (math.nan, "non-finite"), + (float("nan"), "non-finite"), # pylint: disable=consider-math-not-float ], ) async def test_non_numeric_validation_error( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 4d857376efe9b2..9f82b5fe6081d2 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4062,25 +4062,120 @@ async def test_compile_hourly_statistics_equivalent_units_2( @pytest.mark.parametrize( ( "device_class", - "state_unit", - "statistic_unit", - "unit_class", + "unit_1", + "unit_2", + "unit_3", + "unit_class_1", + "unit_class_2", + "factor_2", + "factor_3", "mean1", "mean2", "min", "max", ), [ - ("power", "kW", "kW", "power", 13.050847, 13.333333, -10, 30), + ( + "power", + "kW", + "kW", + "kW", + "power", + "power", + 1, + 1, + 13.050847, + 13.333333, + -10, + 30, + ), + ( + "carbon_monoxide", + "ppm", + "ppm", + "ppm", + "unitless", + "carbon_monoxide", + 1, + 1, + 13.050847, + 13.333333, + -10, + 30, + ), + # Valid change of unit class from unitless to carbon_monoxide + ( + "carbon_monoxide", + "ppm", + "ppm", + "mg/m³", + "unitless", + "carbon_monoxide", + 1, + 1.164409, + 13.050847, + 13.333333, + -10, + 30, + ), + # Valid change of unit class from unitless to carbon_monoxide + ( + "carbon_monoxide", + "ppm", + "mg/m³", + "mg/m³", + "unitless", + "carbon_monoxide", + 1.164409, + 1.164409, + 13.050847, + 13.333333, + -10, + 30, + ), + # Valid change of unit class from concentration to carbon_monoxide + ( + "carbon_monoxide", + "mg/m³", + "mg/m³", + "ppm", + "concentration", + "carbon_monoxide", + 1, + 1 / 1.164409, + 13.050847, + 13.333333, + -10, + 30, + ), + # Valid change of unit class from concentration to carbon_monoxide + ( + "carbon_monoxide", + "mg/m³", + "ppm", + "ppm", + "concentration", + "carbon_monoxide", + 1 / 1.164409, + 1 / 1.164409, + 13.050847, + 13.333333, + -10, + 30, + ), ], ) async def test_compile_hourly_statistics_changing_device_class_1( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_class, - state_unit, - statistic_unit, - unit_class, + unit_1, + unit_2, + unit_3, + unit_class_1, + unit_class_2, + factor_2, + factor_3, mean1, mean2, min, @@ -4088,7 +4183,9 @@ async def test_compile_hourly_statistics_changing_device_class_1( ) -> None: """Test compiling hourly statistics where device class changes from one hour to the next. - Device class is ignored, meaning changing device class should not influence the statistics. + In this test, the device class is first None, then set to a specific device class. + + Changing device class may influence the unit class. """ zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) @@ -4098,7 +4195,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( # Record some states for an initial period, the entity has no device class attributes = { "state_class": "measurement", - "unit_of_measurement": state_unit, + "unit_of_measurement": unit_1, } with freeze_time(zero) as freezer: four, states = await async_record_states( @@ -4113,14 +4210,14 @@ async def test_compile_hourly_statistics_changing_device_class_1( assert statistic_ids == [ { "statistic_id": "sensor.test1", - "display_unit_of_measurement": state_unit, + "display_unit_of_measurement": unit_1, "has_mean": True, "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", - "statistics_unit_of_measurement": state_unit, - "unit_class": unit_class, + "statistics_unit_of_measurement": unit_1, + "unit_class": unit_class_1, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -4139,15 +4236,17 @@ async def test_compile_hourly_statistics_changing_device_class_1( ] } - # Update device class and record additional states in the original UoM + # Update device class and record additional states in a different UoM attributes["device_class"] = device_class + attributes["unit_of_measurement"] = unit_2 + seq = [x * factor_2 for x in (-10, 15, 30)] with freeze_time(zero) as freezer: four, _states = await async_record_states( - hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes, seq ) states["sensor.test1"] += _states["sensor.test1"] four, _states = await async_record_states( - hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) states["sensor.test1"] += _states["sensor.test1"] @@ -4163,14 +4262,14 @@ async def test_compile_hourly_statistics_changing_device_class_1( assert statistic_ids == [ { "statistic_id": "sensor.test1", - "display_unit_of_measurement": state_unit, + "display_unit_of_measurement": unit_2, "has_mean": True, "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", - "statistics_unit_of_measurement": state_unit, - "unit_class": unit_class, + "statistics_unit_of_measurement": unit_1, + "unit_class": unit_class_2, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -4179,9 +4278,9 @@ async def test_compile_hourly_statistics_changing_device_class_1( { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), - "mean": pytest.approx(mean1), - "min": pytest.approx(min), - "max": pytest.approx(max), + "mean": pytest.approx(mean1 * factor_2), + "min": pytest.approx(min * factor_2), + "max": pytest.approx(max * factor_2), "last_reset": None, "state": None, "sum": None, @@ -4189,9 +4288,9 @@ async def test_compile_hourly_statistics_changing_device_class_1( { "start": process_timestamp(zero + timedelta(minutes=10)).timestamp(), "end": process_timestamp(zero + timedelta(minutes=15)).timestamp(), - "mean": pytest.approx(mean2), - "min": pytest.approx(min), - "max": pytest.approx(max), + "mean": pytest.approx(mean2 * factor_2), + "min": pytest.approx(min * factor_2), + "max": pytest.approx(max * factor_2), "last_reset": None, "state": None, "sum": None, @@ -4200,14 +4299,15 @@ async def test_compile_hourly_statistics_changing_device_class_1( } # Update device class and record additional states in a different UoM - attributes["unit_of_measurement"] = statistic_unit + attributes["unit_of_measurement"] = unit_3 + seq = [x * factor_3 for x in (-10, 15, 30)] with freeze_time(zero) as freezer: four, _states = await async_record_states( - hass, freezer, zero + timedelta(minutes=15), "sensor.test1", attributes + hass, freezer, zero + timedelta(minutes=15), "sensor.test1", attributes, seq ) states["sensor.test1"] += _states["sensor.test1"] four, _states = await async_record_states( - hass, freezer, zero + timedelta(minutes=20), "sensor.test1", attributes + hass, freezer, zero + timedelta(minutes=20), "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) states["sensor.test1"] += _states["sensor.test1"] @@ -4223,14 +4323,14 @@ async def test_compile_hourly_statistics_changing_device_class_1( assert statistic_ids == [ { "statistic_id": "sensor.test1", - "display_unit_of_measurement": state_unit, + "display_unit_of_measurement": unit_3, "has_mean": True, "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", - "statistics_unit_of_measurement": state_unit, - "unit_class": unit_class, + "statistics_unit_of_measurement": unit_1, + "unit_class": unit_class_2, }, ] stats = statistics_during_period(hass, zero, period="5minute") @@ -4239,9 +4339,9 @@ async def test_compile_hourly_statistics_changing_device_class_1( { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), - "mean": pytest.approx(mean1), - "min": pytest.approx(min), - "max": pytest.approx(max), + "mean": pytest.approx(mean1 * factor_3), + "min": pytest.approx(min * factor_3), + "max": pytest.approx(max * factor_3), "last_reset": None, "state": None, "sum": None, @@ -4249,9 +4349,9 @@ async def test_compile_hourly_statistics_changing_device_class_1( { "start": process_timestamp(zero + timedelta(minutes=10)).timestamp(), "end": process_timestamp(zero + timedelta(minutes=15)).timestamp(), - "mean": pytest.approx(mean2), - "min": pytest.approx(min), - "max": pytest.approx(max), + "mean": pytest.approx(mean2 * factor_3), + "min": pytest.approx(min * factor_3), + "max": pytest.approx(max * factor_3), "last_reset": None, "state": None, "sum": None, @@ -4259,9 +4359,9 @@ async def test_compile_hourly_statistics_changing_device_class_1( { "start": process_timestamp(zero + timedelta(minutes=20)).timestamp(), "end": process_timestamp(zero + timedelta(minutes=25)).timestamp(), - "mean": pytest.approx(mean2), - "min": pytest.approx(min), - "max": pytest.approx(max), + "mean": pytest.approx(mean2 * factor_3), + "min": pytest.approx(min * factor_3), + "max": pytest.approx(max * factor_3), "last_reset": None, "state": None, "sum": None, @@ -4302,7 +4402,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( ) -> None: """Test compiling hourly statistics where device class changes from one hour to the next. - Device class is ignored, meaning changing device class should not influence the statistics. + In this test, the device class is first set to a specific device class, then set to None. """ zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 3a56e929b226f9..49ebbf42af5d83 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -1,6 +1,7 @@ """Tests for the USB Discovery integration.""" import asyncio +import dataclasses from datetime import timedelta import logging import os @@ -11,6 +12,7 @@ from homeassistant.components import usb from homeassistant.components.usb.models import USBDevice +from homeassistant.components.usb.utils import usb_device_from_path from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo @@ -1383,3 +1385,126 @@ async def test_register_port_event_callback_failure( assert caplog.text.count("Error in USB port event callback") == 2 assert "Failure 1" in caplog.text assert "Failure 2" in caplog.text + + +def test_usb_device_from_path_with_symlinks() -> None: + """Test usb_device_from_path resolves devices using symlink mapping.""" + # Mock /dev/serial/by-id exists and contains symlinks + entry1 = MagicMock(spec_set=os.DirEntry) + entry1.is_symlink.return_value = True + entry1.path = "/dev/serial/by-id/usb-device1" + + entry2 = MagicMock(spec_set=os.DirEntry) + entry2.is_symlink.return_value = True + entry2.path = "/dev/serial/by-id/usb-device2" + + def mock_realpath(path: str) -> str: + realpath_map = { + "/dev/serial/by-id/usb-device1": "/dev/ttyUSB0", + "/dev/serial/by-id/usb-device2": "/dev/ttyUSB1", + "/dev/ttyUSB0": "/dev/ttyUSB0", + "/dev/ttyUSB1": "/dev/ttyUSB1", + } + return realpath_map.get(path, path) + + usb_device = USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number="ABC123", + manufacturer="Test Manufacturer", + description="Test Device", + ) + + with ( + patch("os.path.isdir", return_value=True), + patch("os.scandir", return_value=[entry1, entry2]), + patch("os.path.realpath", side_effect=mock_realpath), + patch( + "homeassistant.components.usb.utils.scan_serial_ports", + return_value=[usb_device], + ), + ): + dev_from_path = usb_device_from_path("/dev/serial/by-id/usb-device1") + + # The USB device for the given path differs from the `scan_serial_ports` only by its + # `device` pointing to a symlink + assert dev_from_path == dataclasses.replace( + usb_device, device="/dev/serial/by-id/usb-device1" + ) + + +def test_usb_device_from_path_with_realpath_match() -> None: + """Test usb_device_from_path falls back to the original path without a symlink.""" + usb_device = USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number="ABC123", + manufacturer="Test Manufacturer", + description="Test Device", + ) + + with ( + patch("os.path.isdir", return_value=True), + patch("os.scandir", return_value=[]), + patch("os.path.realpath", side_effect=lambda x: x), + patch( + "homeassistant.components.usb.utils.scan_serial_ports", + return_value=[usb_device], + ), + ): + dev_from_path = usb_device_from_path("/dev/ttyUSB0") + + # There is no symlink for the device so we must keep using the base `/dev/` path + assert dev_from_path == usb_device + + +def test_usb_device_from_path_no_match() -> None: + """Test usb_device_from_path returns None when device not found.""" + usb_device = USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number="ABC123", + manufacturer="Test Manufacturer", + description="Test Device", + ) + + with ( + patch("os.path.isdir", return_value=True), + patch("os.scandir", return_value=[]), + patch("os.path.realpath", side_effect=lambda x: x), + patch( + "homeassistant.components.usb.utils.scan_serial_ports", + return_value=[usb_device], + ), + ): + dev_from_path = usb_device_from_path("/dev/ttyUSB99") + + assert dev_from_path is None + + +def test_usb_device_from_path_no_by_id_dir() -> None: + """Test usb_device_from_path when /dev/serial/by-id doesn't exist.""" + usb_device = USBDevice( + device="/dev/ttyUSB0", + vid="1234", + pid="5678", + serial_number="ABC123", + manufacturer="Test Manufacturer", + description="Test Device", + ) + + with ( + patch("os.path.isdir", return_value=False), + patch("os.path.realpath", side_effect=lambda x: x), + patch( + "homeassistant.components.usb.utils.scan_serial_ports", + return_value=[usb_device], + ), + ): + dev_from_path = usb_device_from_path("/dev/ttyUSB0") + + # We have no symlinks so we use the base path + assert dev_from_path == usb_device diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 43a4fb0e539311..4a62ae99817f1d 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -4,6 +4,7 @@ from copy import deepcopy import io import logging +import math from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -1061,7 +1062,7 @@ async def test_get_states_not_allows_nan( ) -> None: """Test get_states command converts NaN to None.""" hass.states.async_set("greeting.hello", "world") - hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) + hass.states.async_set("greeting.bad", "data", {"hello": math.nan}) hass.states.async_set("greeting.bye", "universe") await websocket_client.send_json_auto_id({"type": "get_states"}) diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 26ee4c675bb52c..6326f81d651f66 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -306,7 +306,7 @@ def test_find_unserializable_data() -> None: assert find_paths_unserializable_data({("A",): 1}) == {"$": ("A",)} assert math.isnan( find_paths_unserializable_data( - float("nan"), dump=partial(json.dumps, allow_nan=False) + math.nan, dump=partial(json.dumps, allow_nan=False) )["$"] ) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 57e80927e7ee61..c81128ac8bb1fd 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -663,10 +663,9 @@ async def test_async_config_entry_first_refresh_no_entry(hass: HomeAssistant) -> crd = get_crd(hass, DEFAULT_UPDATE_INTERVAL, None) crd.setup_method = AsyncMock() with pytest.raises( - RuntimeError, - match="Detected code that uses `async_config_entry_first_refresh`, " - "which is only supported for coordinators with a config entry. " - "Please report this issue", + ConfigEntryError, + match="Detected code that uses `async_config_entry_first_refresh`," + " which is only supported for coordinators with a config entry", ): await crd.async_config_entry_first_refresh() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 4619d49584a898..12ce4a205814a4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6626,6 +6626,83 @@ async def async_step_reconfigure(self, data): assert len(comp.async_unload_entry.mock_calls) == calls_entry_load_unload[1] +@pytest.mark.parametrize( + ("source", "reason"), + [ + (config_entries.SOURCE_REAUTH, "reauth_successful"), + (config_entries.SOURCE_RECONFIGURE, "reconfigure_successful"), + ], +) +async def test_update_entry_without_reload( + hass: HomeAssistant, + source: str, + reason: str, +) -> None: + """Test updating an entry without reloading.""" + entry = MockConfigEntry( + domain="comp", + unique_id="1234", + title="Test", + data={"vendor": "data"}, + options={"vendor": "options"}, + ) + entry.add_to_hass(hass) + + comp = MockModule( + "comp", + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + await hass.config_entries.async_setup(entry.entry_id) + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_reauth(self, data): + """Mock Reauth.""" + return self.async_update_and_abort( + entry, + unique_id="5678", + title="Updated title", + data={"vendor": "data2"}, + options={"vendor": "options2"}, + ) + + async def async_step_reconfigure(self, data): + """Mock Reconfigure.""" + return self.async_update_and_abort( + entry, + unique_id="5678", + title="Updated title", + data={"vendor": "data2"}, + options={"vendor": "options2"}, + ) + + with mock_config_flow("comp", MockFlowHandler): + if source == config_entries.SOURCE_REAUTH: + result = await entry.start_reauth_flow(hass) + elif source == config_entries.SOURCE_RECONFIGURE: + result = await entry.start_reconfigure_flow(hass) + + await hass.async_block_till_done() + + assert entry.title == "Updated title" + assert entry.unique_id == "5678" + assert entry.data == {"vendor": "data2"} + assert entry.options == {"vendor": "options2"} + assert entry.state == config_entries.ConfigEntryState.LOADED + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + # Assert entry is not reloaded + assert len(comp.async_setup_entry.mock_calls) == 1 + assert len(comp.async_unload_entry.mock_calls) == 0 + + @pytest.mark.parametrize( ( "kwargs", @@ -9434,3 +9511,74 @@ async def async_step_user(self, user_input=None): "working in Home Assistant 2026.3, please create a bug report at https:" ) assert (log_text in caplog.text) == expected_log + + +async def test_async_update_title_placeholders(hass: HomeAssistant) -> None: + """Test async_update_title_placeholders updates context and notifies listeners.""" + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + self.context["title_placeholders"] = {"initial": "value"} + return self.async_show_form(step_id="user") + + mock_integration(hass, MockModule("comp")) + mock_platform(hass, "comp.config_flow", None) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await hass.config_entries.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + # Get the flow to check initial title_placeholders + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == {"initial": "value"} + + # Get the flow instance to call methods + flow_instance = hass.config_entries.flow._progress[result["flow_id"]] + + # Capture events to verify frontend notification + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + # Update title placeholders + flow_instance.async_update_title_placeholders({"name": "updated"}) + await hass.async_block_till_done() + + # Verify placeholders were updated (preserving existing values) + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == { + "initial": "value", + "name": "updated", + } + + # Verify frontend was notified + assert len(events) == 1 + assert events[0].data == { + "handler": "comp", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Update again with overlapping key + flow_instance.async_update_title_placeholders( + {"initial": "new_value", "another": "key"} + ) + await hass.async_block_till_done() + + # Verify placeholders were updated correctly + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == { + "initial": "new_value", + "name": "updated", + "another": "key", + } + + # Verify frontend was notified again + assert len(events) == 2 diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 824d9708f7fa95..ba4926eba6d4e6 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -288,18 +288,45 @@ ), ], CarbonMonoxideConcentrationConverter: [ + # PPM to other units ( 1, CONCENTRATION_PARTS_PER_MILLION, 1.16441, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, ), + ( + 1, + CONCENTRATION_PARTS_PER_MILLION, + 1164.41, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + # MILLIGRAMS_PER_CUBIC_METER to other units + ( + 120, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + 103.05655, + CONCENTRATION_PARTS_PER_MILLION, + ), ( 120, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + 120000, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + # MICROGRAMS_PER_CUBIC_METER to other units + ( + 120000, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 103.05655, CONCENTRATION_PARTS_PER_MILLION, ), + ( + 120000, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + 120, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), ], ConductivityConverter: [ (