diff --git a/.claude/skills/ha-merge-queue/SKILL.md b/.claude/skills/ha-merge-queue/SKILL.md index 6866d43fa924f..3df4ee7965f83 100644 --- a/.claude/skills/ha-merge-queue/SKILL.md +++ b/.claude/skills/ha-merge-queue/SKILL.md @@ -27,7 +27,7 @@ verify every finalist with the per-PR checks below. ## Verify each finalist -Check all five. A PR fails the shortlist if any one fails. +Check all six conditions. A PR fails the shortlist if any of checks 1 through 5 fail. Paginate every list response before deciding. Check runs, reviews and review threads are all paged, typically 30 per page, and a full-suite Home Assistant PR runs to 40-odd check runs — @@ -90,12 +90,27 @@ Compare the returned count against the reported total and keep fetching until th discount one for the category it appears to fall into; an unaddressed defect is a blocker whether or not anyone is arguing about it. +6. **Peer-Review Checklist Verification (Priority Boost)** — inspect the PR body text for + the template checkbox: `- [x] I have reviewed two other [open pull requests][prs] in this repository.` + - **Checked (`[x]`):** Give this PR higher priority in ranking to reward contributors helping + clear the review backlog. + - **Unchecked (`[ ]` or missing):** Keep in queue with standard priority (do not disqualify). + ## Report -Rank by how little work each PR needs: `clean` first, then `blocked` with everything else -green. For each PR give the number as a full markdown link, the integration or core area, -one line on what it does, and its blocking state. Plenty of `home-assistant/core` PRs touch -helpers, the framework, the recorder or repo tooling and have no integration at all — name +Rank and order candidates primarily by their status readiness (`clean` first, then `blocked` +with everything else green), and **secondarily by peer-review participation**: + +1. **Clean PRs** with the `[x] I have reviewed two other open pull requests...` checkbox checked. +2. **Clean PRs** without the checkbox checked. +3. **Blocked/Near-miss PRs** with the `[x] I have reviewed two other open pull requests...` checkbox checked. +4. **Blocked/Near-miss PRs** without the checkbox checked. + +For each PR give the number as a full markdown link, the integration or core area, +one line on what it does, its blocking state, and explicitly note if the author checked the +peer-review box (e.g., "⭐ *Contributor reviewed 2 PRs*"). + +Plenty of `home-assistant/core` PRs touch helpers, the framework, the recorder or repo tooling and have no integration at all — name what they touch instead, rather than dropping them or inventing one. Then list the near-misses separately, each with the one action that unblocks it. The @@ -115,4 +130,4 @@ change but carries no `breaking-change` label will silently miss the release not - Only report in the CONSOLE. DO NOT ACT ON GITHUB — no comments, no reviews, no merges, no pushes to contributor branches. Per `AI_POLICY.md`, a human decides and acts. - Never call a PR ready on green CI alone. Read the diff of every PR you shortlist; CI - cannot tell you whether the change is correct or wanted. + cannot tell you whether the change is correct or wanted. \ No newline at end of file diff --git a/homeassistant/components/centriconnect/config_flow.py b/homeassistant/components/centriconnect/config_flow.py index 2821f5d594ea8..40ed34f56ef81 100644 --- a/homeassistant/components/centriconnect/config_flow.py +++ b/homeassistant/components/centriconnect/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the CentriConnect/MyPropane API integration.""" +from collections.abc import Callable, Mapping import logging from typing import Any, override @@ -11,7 +12,7 @@ CentriConnectNotFoundError, CentriConnectTooManyRequestsError, ) -import voluptuous as vol +import probatio from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME @@ -22,11 +23,25 @@ _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_RECONFIGURE_DATA_SCHEMA = probatio.Schema( { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_DEVICE_ID): str, - vol.Required(CONF_PASSWORD): str, + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_PASSWORD): str, + } +) + +STEP_REAUTHENTICATE_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_PASSWORD): str, + } +) + +STEP_USER_DATA_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_USERNAME): str, + probatio.Required(CONF_DEVICE_ID): str, + probatio.Required(CONF_PASSWORD): str, } ) @@ -57,34 +72,117 @@ class CentriConnectConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for CentriConnect/MyPropane API.""" VERSION = 1 - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None + _device_id: str | None = None + + async def _handle_flow( + self, + step_id: str, + data_schema: probatio.Schema, + user_input: dict[str, Any] | None, + update_user_input: Callable[[dict[str, Any]], dict[str, Any]], + on_success: Callable[[dict[str, Any], dict[str, Any]], ConfigFlowResult], ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the flow for both user and reconfigure steps.""" errors: dict[str, str] = {} if user_input is not None: try: - info = await validate_input(self.hass, user_input) - except CentriConnectConnectionError, CentriConnectTooManyRequestsError: + info = await validate_input(self.hass, update_user_input(user_input)) + except ( + CentriConnectConnectionError, + CentriConnectTooManyRequestsError, + ): errors["base"] = "cannot_connect" except CentriConnectNotFoundError: errors["base"] = "invalid_auth" except CentriConnectEmptyResponseError, CentriConnectDecodeError: errors["base"] = "unknown" - except Exception: + except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( unique_id=info[CENTRICONNECT_DEVICE_ID], raise_on_progress=True ) - self._abort_if_unique_id_configured( - updates=user_input, reload_on_update=True - ) - return self.async_create_entry(title=info["title"], data=user_input) + return on_success(info, user_input) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id=step_id, data_schema=data_schema, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + old_entry = self._get_reconfigure_entry() + + def _on_success( + info: dict[str, Any], user_input: dict[str, Any] + ) -> ConfigFlowResult: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + old_entry, data_updates=user_input + ) + + return await self._handle_flow( + step_id="reconfigure", + data_schema=STEP_RECONFIGURE_DATA_SCHEMA, + user_input=user_input, + update_user_input=lambda user_input: { + **user_input, + CONF_DEVICE_ID: old_entry.data[CONF_DEVICE_ID], + }, + on_success=_on_success, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self._device_id = entry_data[CONF_DEVICE_ID] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + + def _on_success( + info: dict[str, Any], user_input: dict[str, Any] + ) -> ConfigFlowResult: + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return await self._handle_flow( + step_id="reauth_confirm", + data_schema=STEP_REAUTHENTICATE_DATA_SCHEMA, + user_input=user_input, + update_user_input=lambda user_input: { + **user_input, + CONF_DEVICE_ID: self._device_id, + }, + on_success=_on_success, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + def _on_success( + info: dict[str, Any], user_input: dict[str, Any] + ) -> ConfigFlowResult: + self._abort_if_unique_id_configured( + updates=user_input, reload_on_update=True + ) + return self.async_create_entry(title=info["title"], data=user_input) + + return await self._handle_flow( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + user_input=user_input, + update_user_input=lambda user_input: user_input, + on_success=_on_success, ) diff --git a/homeassistant/components/centriconnect/quality_scale.yaml b/homeassistant/components/centriconnect/quality_scale.yaml index d0bc918ebcd6b..26f5ba53ffa22 100644 --- a/homeassistant/components/centriconnect/quality_scale.yaml +++ b/homeassistant/components/centriconnect/quality_scale.yaml @@ -42,7 +42,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold @@ -70,7 +70,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No user-actionable repair scenarios identified for this integration. diff --git a/homeassistant/components/centriconnect/strings.json b/homeassistant/components/centriconnect/strings.json index 4f9c6cc8943ee..754582ee035f1 100644 --- a/homeassistant/components/centriconnect/strings.json +++ b/homeassistant/components/centriconnect/strings.json @@ -1,7 +1,10 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "This CentriConnect/MyPropane device does not match the existing device ID. Please make sure you entered the credentials correctly." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +12,28 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:component::centriconnect::config::step::user::data::password%]", + "username": "[%key:component::centriconnect::config::step::user::data::username%]" + }, + "data_description": { + "password": "[%key:component::centriconnect::config::step::user::data_description::password%]", + "username": "[%key:component::centriconnect::config::step::user::data_description::username%]" + }, + "description": "[%key:component::centriconnect::config::step::user::description%]" + }, + "reconfigure": { + "data": { + "password": "[%key:component::centriconnect::config::step::user::data::password%]", + "username": "[%key:component::centriconnect::config::step::user::data::username%]" + }, + "data_description": { + "password": "[%key:component::centriconnect::config::step::user::data_description::password%]", + "username": "[%key:component::centriconnect::config::step::user::data_description::username%]" + }, + "description": "[%key:component::centriconnect::config::step::user::description%]" + }, "user": { "data": { "device_id": "Device ID", diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 3e6371cbe239d..980c9ab9037a2 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -8,7 +8,7 @@ "integration_type": "service", "iot_class": "cloud_push", "requirements": [ - "google-cloud-texttospeech==2.25.1", - "google-cloud-speech==2.31.1" + "google-cloud-texttospeech==2.37.0", + "google-cloud-speech==2.40.0" ] } diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 11757792aad2a..7c86a2ef92400 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -27,7 +27,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import CONF_BASE_PATH, CONF_SERIAL, DOMAIN +from .const import CONF_BASE_PATH, CONF_SERIAL, DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -45,6 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: tls=data[CONF_SSL], verify_ssl=data[CONF_VERIFY_SSL], session=session, + request_timeout=REQUEST_TIMEOUT, ) printer = await ipp.printer() diff --git a/homeassistant/components/ipp/const.py b/homeassistant/components/ipp/const.py index 642898385430a..d1ad3e73fc8f9 100644 --- a/homeassistant/components/ipp/const.py +++ b/homeassistant/components/ipp/const.py @@ -14,6 +14,9 @@ ATTR_STATE_REASON = "state_reason" ATTR_URI_SUPPORTED = "uri_supported" +# Printers waking from sleep can take well over pyipp's own default +REQUEST_TIMEOUT = 30 + # Config Keys CONF_BASE_PATH = "base_path" CONF_SERIAL = "serial" diff --git a/homeassistant/components/ipp/coordinator.py b/homeassistant/components/ipp/coordinator.py index 84d54e7b8a5cf..c9c56bd5464f8 100644 --- a/homeassistant/components/ipp/coordinator.py +++ b/homeassistant/components/ipp/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_BASE_PATH, DOMAIN +from .const import CONF_BASE_PATH, DOMAIN, REQUEST_TIMEOUT SCAN_INTERVAL = timedelta(seconds=60) @@ -36,6 +36,7 @@ def __init__(self, hass: HomeAssistant, config_entry: IPPConfigEntry) -> None: tls=config_entry.data[CONF_SSL], verify_ssl=config_entry.data[CONF_VERIFY_SSL], session=async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]), + request_timeout=REQUEST_TIMEOUT, ) super().__init__( diff --git a/homeassistant/components/lunatone/icons.json b/homeassistant/components/lunatone/icons.json new file mode 100644 index 0000000000000..099c8ac271894 --- /dev/null +++ b/homeassistant/components/lunatone/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "dali_line_status": { + "default": "mdi:current-ac" + } + } + } +} diff --git a/homeassistant/components/lunatone/sensor.py b/homeassistant/components/lunatone/sensor.py index 7ec5903e52af5..5ddc6492484fc 100644 --- a/homeassistant/components/lunatone/sensor.py +++ b/homeassistant/components/lunatone/sensor.py @@ -3,7 +3,7 @@ from typing import Final, override from lunatone_rest_api_client import Sensor -from lunatone_rest_api_client.models import SensorAddressType, SensorType +from lunatone_rest_api_client.models import LineStatus, SensorAddressType, SensorType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,6 +13,7 @@ ) from homeassistant.const import ( LIGHT_LUX, + EntityCategory, UnitOfPressure, UnitOfRatio, UnitOfTemperature, @@ -24,8 +25,18 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LunatoneConfigEntry, LunatoneSensorsDataUpdateCoordinator +from .coordinator import ( + LunatoneConfigEntry, + LunatoneInfoDataUpdateCoordinator, + LunatoneSensorsDataUpdateCoordinator, +) +DALI_LINE_STATUS_SENSOR_MAPPING: dict[str, str] = { + LineStatus.LOW_POWER: "low_power", + LineStatus.NO_POWER: "no_power", + LineStatus.NOT_REACHABLE: "not_reachable", + LineStatus.OK: "ok", +} PARALLEL_UPDATES = 0 SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { SensorType.AIR_HUMIDITY: SensorEntityDescription( @@ -78,18 +89,25 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lunatone sensors from the config entry.""" + coordinator_info = config_entry.runtime_data.coordinator_info coordinator_sensors = config_entry.runtime_data.coordinator_sensors assert config_entry.unique_id is not None - async_add_entities( + entities: list[SensorEntity] = [ LunatoneSensor( coordinator_sensors, description, sensor_id, config_entry.unique_id ) for sensor_id, sensor_data in coordinator_sensors.data.items() if (description := SENSOR_TYPES.get(sensor_data.data.type)) + ] + entities.extend( + LunatoneDALILineStatusSensor(coordinator_info, line_id, config_entry.unique_id) + for line_id in coordinator_info.data.lines ) + async_add_entities(entities) + class LunatoneSensor( CoordinatorEntity[LunatoneSensorsDataUpdateCoordinator], SensorEntity @@ -160,3 +178,42 @@ def available(self) -> bool: def native_value(self) -> float | None: """Return the measurement value of the sensor.""" return self.sensor.data.value + + +class LunatoneDALILineStatusSensor( + CoordinatorEntity[LunatoneInfoDataUpdateCoordinator], SensorEntity +): + """Representation of a Lunatone DALI line status sensor.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_has_entity_name = True + _attr_options = list(DALI_LINE_STATUS_SENSOR_MAPPING.values()) + _attr_state_class = None + _attr_translation_key = "dali_line_status" + + def __init__( + self, + coordinator: LunatoneInfoDataUpdateCoordinator, + line_id: str, + config_entry_unique_id: str, + ) -> None: + """Initialize a Lunatone DALI line status sensor.""" + super().__init__(coordinator) + + self._config_entry_unique_id = config_entry_unique_id + self._line_id = line_id + + line_unique_id = f"{config_entry_unique_id}-line{line_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, line_unique_id)}, + ) + self._attr_unique_id = f"{line_unique_id}-status" + + @property + @override + def native_value(self) -> str: + """Return the value of the sensor.""" + return DALI_LINE_STATUS_SENSOR_MAPPING[ + self.coordinator.data.lines[self._line_id].line_status + ] diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index 2d2b6b20800a0..70d5ddde2ef30 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -42,6 +42,17 @@ "scan_status": { "name": "DALI scan" } + }, + "sensor": { + "dali_line_status": { + "name": "Status", + "state": { + "low_power": "Low bus power", + "no_power": "Bus power failure", + "not_reachable": "Not reachable", + "ok": "OK" + } + } } }, "exceptions": { diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 68032fae8a2a6..4f04db404bb5d 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -38,6 +38,14 @@ PRESET_VACATION_HOLD: "Holiday", } +PRIORITY_STATUS_OPTIONS = { + PRESET_NO_HOLD: "no_hold", + PRESET_TEMPORARY_HOLD: "temporary_hold", + PRESET_HOLD_UNTIL: "hold_until", + PRESET_PERMANENT_HOLD: "permanent_hold", + PRESET_VACATION_HOLD: "vacation_hold", +} + @dataclass(frozen=True, kw_only=True) class LyricSensorEntityDescription(SensorEntityDescription): @@ -204,6 +212,14 @@ async def async_setup_entry( if accessory_sensor.suitable_fn(room, accessory) ) + async_add_entities( + LyricPriorityStatusSensor(coordinator, location, device) + for location in coordinator.data.locations + for device in location.devices + if device.device_class == "Thermostat" + and coordinator.data.rooms_dict.get(device.mac_id) + ) + class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" @@ -273,3 +289,35 @@ def __init__( def native_value(self) -> StateType | datetime: """Return the state.""" return self.entity_description.value_fn(self.room, self.accessory) + + +class LyricPriorityStatusSensor(LyricDeviceEntity, SensorEntity): + """Define a Honeywell Lyric room priority hold status sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "priority_status" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = list(PRIORITY_STATUS_OPTIONS.values()) + + def __init__( + self, + coordinator: LyricDataUpdateCoordinator, + location: LyricLocation, + device: LyricDevice, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + location, + device, + f"{device.mac_id}_priority_status", + ) + + @property + @override + def native_value(self) -> str | None: + """Return the state.""" + priority = self.coordinator.data.priorities_dict.get(self._mac_id) + if priority is None: + return None + return PRIORITY_STATUS_OPTIONS.get(priority.status) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 3bcacdc7f0d3c..595ba439e7ad7 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -59,6 +59,16 @@ "outdoor_temperature": { "name": "Outdoor temperature" }, + "priority_status": { + "name": "Priority status", + "state": { + "hold_until": "Hold until", + "no_hold": "No hold", + "permanent_hold": "Permanent hold", + "temporary_hold": "Temporary hold", + "vacation_hold": "Vacation hold" + } + }, "room_average_temperature": { "name": "Room average temperature" }, diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index f85d2b702c057..e4f525e2aab82 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["midealocal"], "quality_scale": "bronze", - "requirements": ["midea-local==10.0.1"] + "requirements": ["midea-local==10.1.0"] } diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 90edbabfb4ec6..0f7a97cc1633c 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -239,18 +239,22 @@ def update_devices(self) -> None: with mikrotik_config_entry_errors(): # Retrieve data self.all_devices = self.get_list_from_interface(DHCP) - if self.support_capsman: - LOGGER.debug("Hub is a CAPSman manager") - device_list = wireless_devices = self.get_list_from_interface(CAPSMAN) - elif self.support_wireless: - LOGGER.debug("Hub supports wireless Interface") - device_list = wireless_devices = self.get_list_from_interface(WIRELESS) - elif self.support_wifiwave2: - LOGGER.debug("Hub supports wifiwave2 Interface") - device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) - elif self.support_wifi: - LOGGER.debug("Hub supports wifi Interface") - device_list = wireless_devices = self.get_list_from_interface(WIFI) + + # A hub can expose more than one wireless stack at once (e.g. the + # legacy "wireless" package kept for CAPsMAN alongside the newer + # "wifi" registration table), so merge every supported interface + # instead of picking only the first match. + for supported, interface, message in ( + (self.support_capsman, CAPSMAN, "Hub is a CAPSman manager"), + (self.support_wireless, WIRELESS, "Hub supports wireless Interface"), + (self.support_wifiwave2, WIFIWAVE2, "Hub supports wifiwave2 Interface"), + (self.support_wifi, WIFI, "Hub supports wifi Interface"), + ): + if supported: + LOGGER.debug(message) + wireless_devices.update(self.get_list_from_interface(interface)) + + device_list = wireless_devices if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index c8ffc3db7f9d8..e6dabffae317b 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -33,6 +33,7 @@ def get_device_info(device: RoborockDevice) -> DeviceInfo: model=device.product.model, model_id=device.product.model, sw_version=device.device_info.fv, + serial_number=device.device_info.sn, ) diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index e32533594ca81..48155d6a55958 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -6,6 +6,7 @@ the ``solaredged`` library. """ +from collections.abc import Set as AbstractSet from typing import TYPE_CHECKING from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError @@ -24,6 +25,9 @@ CONF_UNIT_ID, DOMAIN, LOGGER, + SCAN_INTERVAL, + SETTINGS_SCAN_INTERVAL, + SUBSYSTEM_BATTERIES, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER, SUBSYSTEM_METERS, @@ -33,10 +37,10 @@ SolarEdgeModbusDataUpdateCoordinator, SolarEdgeModbusRuntimeData, ) -from .entity import inverter_device_info, meter_identity +from .entity import attachment_identity, inverter_device_info from .helpers import create_modbus_params -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry( @@ -74,7 +78,23 @@ async def async_setup_entry( translation_key="no_solaredge_device", ) from err - readings = SolarEdgeModbusDataUpdateCoordinator(hass, entry, solaredge) + readings = SolarEdgeModbusDataUpdateCoordinator( + hass, + entry, + solaredge, + poll=solaredge.async_update_readings, + interval=SCAN_INTERVAL, + label="readings", + ) + settings = SolarEdgeModbusDataUpdateCoordinator( + hass, + entry, + solaredge, + poll=solaredge.async_update_settings, + interval=SETTINGS_SCAN_INTERVAL, + label="settings", + ) + await readings.async_config_entry_first_refresh() # Identity arrives with that first read, and a poll can come back without @@ -92,6 +112,7 @@ async def async_setup_entry( # entities would stay missing until a reload. measuring = {SUBSYSTEM_INVERTER} measuring.update(f"meters[{index}]" for index in range(len(solaredge.meters))) + measuring.update(f"batteries[{index}]" for index in range(len(solaredge.batteries))) if measuring & readings.data.failed.keys(): raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -104,21 +125,29 @@ async def async_setup_entry( inverter = dr.async_get(hass).async_get_or_create( config_entry_id=entry.entry_id, **device_info ) + # The readings poll already proved the link; a control block that refuses + # one read leaves its own entities unavailable instead of failing setup. + await settings.async_refresh() + entry.runtime_data = SolarEdgeModbusRuntimeData( - readings=readings, device_info=device_info, inverter_device_id=inverter.id + readings=readings, + settings=settings, + device_info=device_info, + inverter_device_id=inverter.id, ) - # A block that stayed silent while probing is taken for absent, so a meter - # that timed out cannot be told from one that was unwired. Its device stays - # where it is until the device says for itself that it is gone. - if SUBSYSTEM_METERS in solaredge.unresponsive_blocks: + if silent := solaredge.unresponsive_blocks & { + SUBSYSTEM_BATTERIES, + SUBSYSTEM_METERS, + }: LOGGER.warning( - "%s did not answer for its meters while probing, so their entities" - " are missing until it does; reloading probes again", + "%s did not answer for its %s while probing, so their entities are" + " missing until it does; reloading probes again", entry.title, + " and ".join(sorted(silent)), ) - else: - _async_remove_stale_devices(hass, entry, solaredge, serial_number) + + _async_remove_stale_devices(hass, entry, solaredge, serial_number, silent=silent) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -130,18 +159,41 @@ def _async_remove_stale_devices( entry: SolarEdgeModbusConfigEntry, solaredge: SolarEdge, serial_number: str, + *, + silent: AbstractSet[str], ) -> None: - """Remove devices for meters no longer attached to the inverter.""" + """Remove devices for meters and batteries no longer attached. + + A block that stayed silent while probing is taken for absent, and silence + is not the inverter saying its hardware is gone. Devices of that kind stay + where they are; the kind that did answer is still cleaned up. + """ current = {(DOMAIN, serial_number)} current.update( - (DOMAIN, f"{serial_number}_meter_{meter_identity(meter, index)}") + (DOMAIN, f"{serial_number}_meter_{attachment_identity(meter, index)}") for index, meter in enumerate(solaredge.meters, 1) ) + current.update( + (DOMAIN, f"{serial_number}_battery_{attachment_identity(battery, index)}") + for index, battery in enumerate(solaredge.batteries, 1) + ) + + unproven = tuple( + f"{serial_number}_{kind}_" + for block, kind in ( + (SUBSYSTEM_BATTERIES, "battery"), + (SUBSYSTEM_METERS, "meter"), + ) + if block in silent + ) device_registry = dr.async_get(hass) for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): - if not current.intersection(device.identifiers): - device_registry.async_remove_device(device.id) + if current.intersection(device.identifiers): + continue + if any(identifier.startswith(unproven) for _, identifier in device.identifiers): + continue + device_registry.async_remove_device(device.id) async def async_unload_entry( diff --git a/homeassistant/components/solaredge_modbus/binary_sensor.py b/homeassistant/components/solaredge_modbus/binary_sensor.py new file mode 100644 index 0000000000000..29de704508825 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/binary_sensor.py @@ -0,0 +1,136 @@ +"""Support for SolarEdge Modbus binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from solaredged import ( + Battery, + BatteryStatus, + Inverter, + InverterExtended, + InverterStatus, +) + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import SolarEdgeModbusBatteryEntity, SolarEdgeModbusInverterEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusBinarySensorEntityDescription[ComponentT]( + BinarySensorEntityDescription +): + """Describes a SolarEdge Modbus binary sensor entity.""" + + exists_fn: Callable[[ComponentT], bool] = lambda _: True + is_on_fn: Callable[[ComponentT], bool | None] + + +def _faulted(inverter: Inverter) -> bool | None: + """Whether the inverter reports a fault, unknown while its status is.""" + if inverter.status is None: + return None + return inverter.status is InverterStatus.FAULT + + +def _charging(battery: Battery) -> bool | None: + """Whether the battery is taking charge, unknown while its status is.""" + if battery.status is None: + return None + return battery.status is BatteryStatus.CHARGE + + +INVERTER_BINARY_SENSORS: tuple[ + SolarEdgeModbusBinarySensorEntityDescription[Inverter], ... +] = ( + SolarEdgeModbusBinarySensorEntityDescription( + key="problem", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=_faulted, + ), + SolarEdgeModbusBinarySensorEntityDescription( + key="on_grid", + translation_key="on_grid", + # Grid status is a firmware extension; without it there is nothing to + # show, and the library only carries the field where it answered. + exists_fn=lambda inverter: isinstance(inverter, InverterExtended), + is_on_fn=lambda inverter: inverter.on_grid, + ), +) + +BATTERY_BINARY_SENSORS: tuple[ + SolarEdgeModbusBinarySensorEntityDescription[Battery], ... +] = ( + # The status sensor carries the whole story, but Home Assistant's + # battery-charging triggers and conditions only look at this device class. + SolarEdgeModbusBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on_fn=_charging, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus binary sensor entities based on a config entry.""" + solaredge = entry.runtime_data.solaredge + + entities: list[BinarySensorEntity] = [ + SolarEdgeModbusInverterBinarySensorEntity(entry=entry, description=description) + for description in INVERTER_BINARY_SENSORS + if description.exists_fn(solaredge.inverter) + ] + entities.extend( + SolarEdgeModbusBatteryBinarySensorEntity( + entry=entry, description=description, index=index + ) + for index in range(1, len(solaredge.batteries) + 1) + for description in BATTERY_BINARY_SENSORS + if description.exists_fn(solaredge.batteries[index - 1]) + ) + + async_add_entities(entities) + + +class SolarEdgeModbusInverterBinarySensorEntity( + SolarEdgeModbusInverterEntity, BinarySensorEntity +): + """Defines a SolarEdge Modbus inverter binary sensor entity.""" + + entity_description: SolarEdgeModbusBinarySensorEntityDescription[Inverter] + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.solaredge.inverter) + + +class SolarEdgeModbusBatteryBinarySensorEntity( + SolarEdgeModbusBatteryEntity, BinarySensorEntity +): + """Defines a SolarEdge Modbus battery binary sensor entity.""" + + entity_description: SolarEdgeModbusBinarySensorEntityDescription[Battery] + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn( + self.coordinator.solaredge.batteries[self._index - 1] + ) diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 8339aa22eb38e..5453110499cfe 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -23,8 +23,19 @@ SUBSYSTEM_COMMON: Final = "common" SUBSYSTEM_INVERTER: Final = "inverter" -# How the library names the meter block it probes for. +# How the library names the blocks it probes for. +SUBSYSTEM_BATTERIES: Final = "batteries" SUBSYSTEM_METERS: Final = "meters" +# The writable control blocks, as an UpdateReport names them. Export control's +# read spans storage control, so the library reads and reports the two as one. +SUBSYSTEM_ADVANCED_POWER_CONTROL: Final = "advanced_power_control" +SUBSYSTEM_POWER_CONTROL: Final = "power_control" +SUBSYSTEM_SITE_CONTROL: Final = "site_control" + # Local Modbus is cheap to read and PV production moves fast. SCAN_INTERVAL: Final = timedelta(seconds=10) + +# The control blocks hold what the site was told to do; they only move when +# something writes them, so they do not need a live measurement's cadence. +SETTINGS_SCAN_INTERVAL: Final = timedelta(minutes=5) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py index 7a19ea96bd167..98f08e2a440c1 100644 --- a/homeassistant/components/solaredge_modbus/coordinator.py +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -1,7 +1,9 @@ -"""DataUpdateCoordinator for the SolarEdge Modbus integration.""" +"""DataUpdateCoordinators for the SolarEdge Modbus integration.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import override +from datetime import timedelta +from typing import Final, override from solaredged import SolarEdge, SolarEdgeConnectionError, UpdateReport @@ -11,10 +13,25 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL, SUBSYSTEM_COMMON +from .const import ( + DOMAIN, + LOGGER, + SUBSYSTEM_ADVANCED_POWER_CONTROL, + SUBSYSTEM_COMMON, + SUBSYSTEM_POWER_CONTROL, + SUBSYSTEM_SITE_CONTROL, +) type SolarEdgeModbusConfigEntry = ConfigEntry[SolarEdgeModbusRuntimeData] +SETTINGS_SUBSYSTEMS: Final = frozenset( + { + SUBSYSTEM_ADVANCED_POWER_CONTROL, + SUBSYSTEM_POWER_CONTROL, + SUBSYSTEM_SITE_CONTROL, + } +) + def _merge(first: UpdateReport, second: UpdateReport) -> UpdateReport: """Fold a retried poll into the one it followed. @@ -33,7 +50,7 @@ def _merge(first: UpdateReport, second: UpdateReport) -> UpdateReport: class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): - """Polls the inverter's sub-systems over Modbus. + """Polls one set of the inverter's sub-systems over Modbus. A poll can come back partial: the library reads every sub-system on its own, so one that falls silent no longer takes the others down with it. The @@ -48,9 +65,14 @@ def __init__( hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry, solaredge: SolarEdge, + *, + poll: Callable[[], Awaitable[UpdateReport]], + interval: timedelta, + label: str, ) -> None: """Initialize the coordinator.""" self.solaredge = solaredge + self._poll = poll self._silent: set[str] = set() super().__init__( hass, @@ -58,8 +80,8 @@ def __init__( config_entry=entry, # The serial number identifies this inverter, but it would also end # up in every log line a name is written to, so the title stands in. - name=f"{entry.title} readings", - update_interval=SCAN_INTERVAL, + name=f"{entry.title} {label}", + update_interval=interval, ) @override @@ -99,7 +121,7 @@ async def _async_retry(self, report: UpdateReport) -> UpdateReport: enough. """ try: - retried = await self.solaredge.async_update_readings() + retried = await self._poll() except SolarEdgeConnectionError as err: LOGGER.debug( "%s: nothing answered the retry (%s); keeping the first poll", @@ -113,7 +135,7 @@ async def _async_retry(self, report: UpdateReport) -> UpdateReport: async def _async_poll(self) -> UpdateReport: """Poll the inverter's sub-systems, translating a dead link.""" try: - return await self.solaredge.async_update_readings() + return await self._poll() except SolarEdgeConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -143,10 +165,17 @@ class SolarEdgeModbusRuntimeData: """Runtime data for a SolarEdge Modbus config entry.""" readings: SolarEdgeModbusDataUpdateCoordinator + settings: SolarEdgeModbusDataUpdateCoordinator device_info: DeviceInfo inverter_device_id: str @property def solaredge(self) -> SolarEdge: - """Return the polled device.""" + """Return the polled device, which both coordinators share.""" return self.readings.solaredge + + def coordinator_for(self, subsystem: str) -> SolarEdgeModbusDataUpdateCoordinator: + """Return the coordinator that refreshes a given sub-system.""" + if subsystem in SETTINGS_SUBSYSTEMS: + return self.settings + return self.readings diff --git a/homeassistant/components/solaredge_modbus/entity.py b/homeassistant/components/solaredge_modbus/entity.py index 132306d6b33ff..20805cede2a23 100644 --- a/homeassistant/components/solaredge_modbus/entity.py +++ b/homeassistant/components/solaredge_modbus/entity.py @@ -1,20 +1,32 @@ """Base entities for the SolarEdge Modbus integration. -Each meter attached to the inverter is its own sub-device, linked to the -inverter as its parent; everything else belongs to the inverter. All +Each meter and battery attached to the inverter is its own sub-device, linked +to the inverter as its parent; everything else belongs to the inverter. All identities derive from the inverter's serial number, which the config flow stores as the config entry unique ID. """ from typing import TYPE_CHECKING, override -from solaredged import Meter, SolarEdge +from solaredged import ( + Battery, + ExportControl, + Meter, + PowerControl, + SolarEdge, + StorageControl, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SUBSYSTEM_INVERTER +from .const import ( + DOMAIN, + SUBSYSTEM_INVERTER, + SUBSYSTEM_POWER_CONTROL, + SUBSYSTEM_SITE_CONTROL, +) from .coordinator import ( SolarEdgeModbusConfigEntry, SolarEdgeModbusDataUpdateCoordinator, @@ -41,15 +53,31 @@ def inverter_name(model: str | None) -> str: return f"SolarEdge {commercial}" -def meter_identity(meter: Meter, index: int) -> str: - """Return what tells a meter apart from the next one in its place. +# The control blocks that host entities. Advanced power control is polled but +# has none, so adding entities for it means widening this and the sub-system it +# maps to below. +type ControlComponent = ExportControl | PowerControl | StorageControl + + +def _control_subsystem(component: ControlComponent) -> str: + """Return the sub-system a control block's poll is reported under.""" + if isinstance(component, PowerControl): + return SUBSYSTEM_POWER_CONTROL + # Export control's read spans storage control, so the library reads the two + # as one pooled block and reports them together. + return SUBSYSTEM_SITE_CONTROL + + +def attachment_identity(component: Battery | Meter, index: int) -> str: + """Return what tells an attached device apart from the next in its place. - A meter that reports a serial number is known by it, so replacing one is a + One that reports a serial number is known by it, so replacing it is a different device rather than the same slot with other numbers in it. Not - every meter reports one, and then the slot it is wired to is all there is. - That fallback says so, since a bare number could be a serial itself. + every meter or battery reports one, and then the slot it is wired to is all + there is. That fallback says so, since a bare number could be a serial + itself. """ - return meter.serial_number or f"slot_{index}" + return component.serial_number or f"slot_{index}" def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo: @@ -80,7 +108,7 @@ def __init__( key_prefix: str = "", ) -> None: """Initialize a SolarEdge Modbus entity.""" - super().__init__(coordinator=entry.runtime_data.readings) + super().__init__(coordinator=entry.runtime_data.coordinator_for(subsystem)) self.entity_description = description self._subsystem = subsystem @@ -109,11 +137,10 @@ def __init__( *, entry: SolarEdgeModbusConfigEntry, description: EntityDescription, + subsystem: str = SUBSYSTEM_INVERTER, ) -> None: """Initialize a SolarEdge Modbus inverter entity.""" - super().__init__( - entry=entry, subsystem=SUBSYSTEM_INVERTER, description=description - ) + super().__init__(entry=entry, subsystem=subsystem, description=description) self._attr_device_info = entry.runtime_data.device_info @@ -129,7 +156,7 @@ def __init__( ) -> None: """Initialize a SolarEdge Modbus meter entity.""" meter = entry.runtime_data.solaredge.meters[index - 1] - identity = meter_identity(meter, index) + identity = attachment_identity(meter, index) super().__init__( entry=entry, subsystem=f"meters[{index - 1}]", @@ -148,3 +175,62 @@ def __init__( serial_number=meter.serial_number or None, via_device_id=entry.runtime_data.inverter_device_id, ) + + +class SolarEdgeModbusBatteryEntity(SolarEdgeModbusEntity): + """Defines a SolarEdge Modbus entity on a battery sub-device.""" + + def __init__( + self, + *, + entry: SolarEdgeModbusConfigEntry, + description: EntityDescription, + index: int, + ) -> None: + """Initialize a SolarEdge Modbus battery entity.""" + battery = entry.runtime_data.solaredge.batteries[index - 1] + identity = attachment_identity(battery, index) + super().__init__( + entry=entry, + subsystem=f"batteries[{index - 1}]", + description=description, + key_prefix=f"battery_{identity}_", + ) + self._index = index + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._serial_number}_battery_{identity}")}, + manufacturer=battery.manufacturer or "SolarEdge", + # A battery names itself the same way a meter does, with a part + # number rather than something it is sold under. + model_id=battery.model or None, + name=f"Battery {index}", + sw_version=battery.version or None, + serial_number=battery.serial_number or None, + via_device_id=entry.runtime_data.inverter_device_id, + ) + + +class SolarEdgeModbusControlEntity[ComponentT: ControlComponent]( + SolarEdgeModbusInverterEntity +): + """Defines a SolarEdge Modbus entity for a writable control block. + + The library refreshes the component instances in place on every poll, so + the entity holds on to its control component directly. + """ + + def __init__( + self, + *, + entry: SolarEdgeModbusConfigEntry, + description: EntityDescription, + component: ComponentT, + ) -> None: + """Initialize a SolarEdge Modbus control entity.""" + super().__init__( + entry=entry, + subsystem=_control_subsystem(component), + description=description, + ) + self._component = component diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py index 245b8834f0dec..7ffa0764ae3e9 100644 --- a/homeassistant/components/solaredge_modbus/helpers.py +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -1,13 +1,16 @@ """Helpers for the SolarEdge Modbus integration.""" -from collections.abc import Mapping -from typing import Any +from collections.abc import Callable, Coroutine, Mapping +from typing import Any, Concatenate from modbus_connection import ModbusSerialParams, ModbusTcpParams +from solaredged import SolarEdgeConnectionError, SolarEdgeError from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.exceptions import HomeAssistantError -from .const import CONF_BAUDRATE, TYPE_SERIAL +from .const import CONF_BAUDRATE, DOMAIN, TYPE_SERIAL +from .entity import SolarEdgeModbusEntity def create_modbus_params( @@ -23,3 +26,34 @@ def create_modbus_params( device=data[CONF_DEVICE], baudrate=data[CONF_BAUDRATE] ) return ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT]) + + +def solaredge_exception_handler[_EntityT: SolarEdgeModbusEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate SolarEdge writes to translate what the library raises. + + A successful write updates the library's decoded cache, so listeners are + nudged to re-read entity state without waiting for the next poll. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + self.coordinator.async_update_listeners() + + except SolarEdgeConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + except SolarEdgeError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rejected_value", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index 49cff864afa28..33959b00a866f 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -1,8 +1,45 @@ { "entity": { + "binary_sensor": { + "on_grid": { + "default": "mdi:transmission-tower", + "state": { + "off": "mdi:transmission-tower-off" + } + } + }, + "number": { + "active_power_limit": { + "default": "mdi:speedometer" + }, + "backup_reserve": { + "default": "mdi:battery-lock" + }, + "charge_limit": { + "default": "mdi:battery-plus" + }, + "cos_phi": { + "default": "mdi:sine-wave" + }, + "discharge_limit": { + "default": "mdi:battery-minus" + }, + "external_production_max": { + "default": "mdi:solar-power" + }, + "site_limit": { + "default": "mdi:transmission-tower-export" + } + }, "sensor": { + "battery_status": { + "default": "mdi:home-battery" + }, "inverter_status": { "default": "mdi:solar-power" + }, + "state_of_health": { + "default": "mdi:battery-heart-variant" } } } diff --git a/homeassistant/components/solaredge_modbus/number.py b/homeassistant/components/solaredge_modbus/number.py new file mode 100644 index 0000000000000..f9b2935648e00 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/number.py @@ -0,0 +1,186 @@ +"""Support for SolarEdge Modbus number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from solaredged import ExportControl, PowerControl, StorageControl + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import ControlComponent, SolarEdgeModbusControlEntity +from .helpers import solaredge_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusNumberEntityDescription[ComponentT](NumberEntityDescription): + """Describes a SolarEdge Modbus number entity.""" + + value_fn: Callable[[ComponentT], float | None] + set_fn: Callable[[ComponentT, float], Awaitable[Any]] + + +STORAGE_NUMBERS: tuple[SolarEdgeModbusNumberEntityDescription[StorageControl], ...] = ( + SolarEdgeModbusNumberEntityDescription( + key="backup_reserve", + translation_key="backup_reserve", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + native_min_value=0, + native_max_value=100, + native_step=1, + value_fn=lambda storage: storage.backup_reserve, + set_fn=lambda storage, value: storage.set_backup_reserve(value), + ), + SolarEdgeModbusNumberEntityDescription( + key="charge_limit", + translation_key="charge_limit", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda storage: storage.charge_limit, + set_fn=lambda storage, value: storage.set_charge_limit(value), + ), + SolarEdgeModbusNumberEntityDescription( + key="discharge_limit", + translation_key="discharge_limit", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda storage: storage.discharge_limit, + set_fn=lambda storage, value: storage.set_discharge_limit(value), + ), +) + +EXPORT_NUMBERS: tuple[SolarEdgeModbusNumberEntityDescription[ExportControl], ...] = ( + SolarEdgeModbusNumberEntityDescription( + key="site_limit", + translation_key="site_limit", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda export: export.site_limit, + set_fn=lambda export, value: export.set_site_limit(value), + ), + SolarEdgeModbusNumberEntityDescription( + key="external_production_max", + translation_key="external_production_max", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda export: export.external_production_max, + set_fn=lambda export, value: export.set_external_production_max(value), + ), +) + +POWER_NUMBERS: tuple[SolarEdgeModbusNumberEntityDescription[PowerControl], ...] = ( + SolarEdgeModbusNumberEntityDescription( + key="active_power_limit", + translation_key="active_power_limit", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + native_min_value=0, + native_max_value=100, + native_step=1, + value_fn=lambda power: power.active_power_limit, + set_fn=lambda power, value: power.set_active_power_limit(int(value)), + ), + SolarEdgeModbusNumberEntityDescription( + key="cos_phi", + translation_key="cos_phi", + entity_category=EntityCategory.CONFIG, + # Reactive power is grid-code territory, set by the installer or the + # network operator. Almost nobody should be moving it from Home + # Assistant, so it has to be asked for. + entity_registry_enabled_default=False, + mode=NumberMode.BOX, + native_min_value=-1.0, + native_max_value=1.0, + native_step=0.01, + value_fn=lambda power: power.cos_phi, + set_fn=lambda power, value: power.set_cos_phi(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus number entities based on a config entry.""" + solaredge = entry.runtime_data.solaredge + + entities: list[NumberEntity] = [] + # The storage control block answers on inverters without storage too; the + # settings only mean something when a battery is actually attached. + if (storage := solaredge.storage_control) is not None and solaredge.batteries: + entities.extend( + SolarEdgeModbusNumberEntity( + entry=entry, description=description, component=storage + ) + for description in STORAGE_NUMBERS + ) + if (export := solaredge.export_control) is not None: + entities.extend( + SolarEdgeModbusNumberEntity( + entry=entry, description=description, component=export + ) + for description in EXPORT_NUMBERS + ) + if (power := solaredge.power_control) is not None: + entities.extend( + SolarEdgeModbusNumberEntity( + entry=entry, description=description, component=power + ) + for description in POWER_NUMBERS + ) + + async_add_entities(entities) + + +class SolarEdgeModbusNumberEntity[ComponentT: ControlComponent]( + SolarEdgeModbusControlEntity[ComponentT], NumberEntity +): + """Defines a SolarEdge Modbus number entity.""" + + entity_description: SolarEdgeModbusNumberEntityDescription[ComponentT] + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self._component) + + @solaredge_exception_handler + @override + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + await self.entity_description.set_fn(self._component, value) diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py index 0f9ca9e4b974b..727ecc8d6e7fb 100644 --- a/homeassistant/components/solaredge_modbus/sensor.py +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -4,7 +4,14 @@ from dataclasses import dataclass from typing import override -from solaredged import Inverter, InverterStatus, Meter, SunSpecDID +from solaredged import ( + Battery, + BatteryStatus, + Inverter, + InverterStatus, + Meter, + SunSpecDID, +) from homeassistant.components.sensor import ( RestoreSensor, @@ -31,7 +38,11 @@ from .const import LOGGER from .coordinator import SolarEdgeModbusConfigEntry -from .entity import SolarEdgeModbusInverterEntity, SolarEdgeModbusMeterEntity +from .entity import ( + SolarEdgeModbusBatteryEntity, + SolarEdgeModbusInverterEntity, + SolarEdgeModbusMeterEntity, +) PARALLEL_UPDATES = 0 @@ -292,7 +303,7 @@ class SolarEdgeModbusSensorEntityDescription[ComponentT](SensorEntityDescription device_class=SensorDeviceClass.ENUM, options=[status.name.lower() for status in InverterStatus], value_fn=lambda inverter: ( - inverter.status.name.lower() if inverter.status else None + inverter.status.name.lower() if inverter.status is not None else None ), ), ) @@ -591,6 +602,178 @@ class SolarEdgeModbusSensorEntityDescription[ComponentT](SensorEntityDescription ) +BATTERY_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Battery], ...] = ( + SolarEdgeModbusSensorEntityDescription( + key="dc_power", + translation_key="dc_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery: battery.dc_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_exported", + translation_key="energy_exported", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda battery: battery.energy_exported, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_imported", + translation_key="energy_imported", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda battery: battery.energy_imported, + ), + SolarEdgeModbusSensorEntityDescription( + key="state_of_energy", + translation_key="state_of_energy", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda battery: battery.state_of_energy, + ), + SolarEdgeModbusSensorEntityDescription( + key="state_of_health", + translation_key="state_of_health", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda battery: battery.state_of_health, + ), + SolarEdgeModbusSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + value_fn=lambda battery: battery.temperature_average, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_available", + translation_key="energy_available", + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda battery: battery.energy_available, + ), + SolarEdgeModbusSensorEntityDescription( + key="energy_max", + translation_key="energy_max", + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda battery: battery.energy_max, + ), + SolarEdgeModbusSensorEntityDescription( + key="rated_energy", + translation_key="rated_energy", + device_class=SensorDeviceClass.ENERGY_STORAGE, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda battery: battery.rated_energy, + ), + SolarEdgeModbusSensorEntityDescription( + key="dc_voltage", + translation_key="dc_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + value_fn=lambda battery: battery.dc_voltage, + ), + SolarEdgeModbusSensorEntityDescription( + key="dc_current", + translation_key="dc_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + value_fn=lambda battery: battery.dc_current, + ), + SolarEdgeModbusSensorEntityDescription( + key="max_charge_power", + translation_key="max_charge_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda battery: battery.max_charge_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="max_discharge_power", + translation_key="max_discharge_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda battery: battery.max_discharge_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="max_charge_peak_power", + translation_key="max_charge_peak_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda battery: battery.max_charge_peak_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="max_discharge_peak_power", + translation_key="max_discharge_peak_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda battery: battery.max_discharge_peak_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="temperature_max", + translation_key="temperature_max", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + value_fn=lambda battery: battery.temperature_max, + ), + SolarEdgeModbusSensorEntityDescription( + key="status", + translation_key="battery_status", + device_class=SensorDeviceClass.ENUM, + options=[status.name.lower() for status in BatteryStatus], + value_fn=lambda battery: ( + battery.status.name.lower() if battery.status is not None else None + ), + ), +) + + def _inverter_sensor( entry: SolarEdgeModbusConfigEntry, description: SolarEdgeModbusSensorEntityDescription[Inverter], @@ -618,6 +801,21 @@ def _meter_sensor( ) +def _battery_sensor( + entry: SolarEdgeModbusConfigEntry, + description: SolarEdgeModbusSensorEntityDescription[Battery], + index: int, +) -> SensorEntity: + """Build a battery sensor, monotonic where its state class asks for it.""" + if description.state_class is SensorStateClass.TOTAL_INCREASING: + return SolarEdgeModbusBatteryEnergySensorEntity( + entry=entry, description=description, index=index + ) + return SolarEdgeModbusBatterySensorEntity( + entry=entry, description=description, index=index + ) + + async def async_setup_entry( hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry, @@ -637,6 +835,12 @@ async def async_setup_entry( for description in METER_SENSORS if description.exists_fn(meter) ) + entities.extend( + _battery_sensor(entry, description, index) + for index, battery in enumerate(solaredge.batteries, 1) + for description in BATTERY_SENSORS + if description.exists_fn(battery) + ) async_add_entities(entities) @@ -728,3 +932,23 @@ class SolarEdgeModbusMeterEnergySensorEntity( SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusMeterSensorEntity ): """Defines a monotonic SolarEdge Modbus meter energy sensor entity.""" + + +class SolarEdgeModbusBatterySensorEntity(SolarEdgeModbusBatteryEntity, SensorEntity): + """Defines a SolarEdge Modbus battery sensor entity.""" + + entity_description: SolarEdgeModbusSensorEntityDescription[Battery] + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn( + self.coordinator.solaredge.batteries[self._index - 1] + ) + + +class SolarEdgeModbusBatteryEnergySensorEntity( + SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusBatterySensorEntity +): + """Defines a monotonic SolarEdge Modbus battery energy sensor entity.""" diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 246f7afc4f77e..2c1ee90fcec1a 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -100,7 +100,53 @@ } }, "entity": { + "binary_sensor": { + "on_grid": { + "name": "On grid", + "state": { + "off": "[%key:common::state::no%]", + "on": "[%key:common::state::yes%]" + } + } + }, + "number": { + "active_power_limit": { + "name": "Active power limit" + }, + "backup_reserve": { + "name": "Backup reserve" + }, + "charge_limit": { + "name": "Storage charge limit" + }, + "cos_phi": { + "name": "Power factor setpoint" + }, + "discharge_limit": { + "name": "Storage discharge limit" + }, + "external_production_max": { + "name": "External production maximum" + }, + "site_limit": { + "name": "Site export limit" + } + }, "sensor": { + "battery_status": { + "name": "Status", + "state": { + "charge": "Charging", + "discharge": "Discharging", + "fault": "Fault", + "idle": "[%key:common::state::idle%]", + "init": "Initializing", + "off": "[%key:common::state::off%]", + "power_saving": "Power saving", + "preserve_charge": "Preserving charge", + "standby": "[%key:common::state::standby%]" + } + }, "current_phase_a": { "name": "Current phase A" }, @@ -119,6 +165,9 @@ "dc_voltage": { "name": "DC voltage" }, + "energy_available": { + "name": "Available energy" + }, "energy_exported": { "name": "Energy exported" }, @@ -143,6 +192,9 @@ "energy_imported_phase_c": { "name": "Energy imported phase C" }, + "energy_max": { + "name": "Usable capacity" + }, "inverter_status": { "name": "Status", "state": { @@ -156,6 +208,18 @@ "throttled": "Throttled" } }, + "max_charge_peak_power": { + "name": "Maximum charge peak power" + }, + "max_charge_power": { + "name": "Maximum charge power" + }, + "max_discharge_peak_power": { + "name": "Maximum discharge peak power" + }, + "max_discharge_power": { + "name": "Maximum discharge power" + }, "power_phase_a": { "name": "Power phase A" }, @@ -165,6 +229,18 @@ "power_phase_c": { "name": "Power phase C" }, + "rated_energy": { + "name": "Rated energy" + }, + "state_of_energy": { + "name": "State of energy" + }, + "state_of_health": { + "name": "State of health" + }, + "temperature_max": { + "name": "Maximum temperature" + }, "voltage_phase_ab": { "name": "Voltage phase A-B" }, @@ -201,6 +277,9 @@ "no_solaredge_device": { "message": "The configured Modbus device does not answer as a SolarEdge inverter." }, + "rejected_value": { + "message": "The value was rejected for the SolarEdge inverter: {error}" + }, "wrong_inverter": { "message": "A different inverter is answering than the one this entry was set up for. Reconfigure the entry to point at the right device." } diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 986571917ff6f..71d9e25571b3a 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -29,6 +29,13 @@ class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( + TailscaleBinarySensorEntityDescription( + key="connected_to_control", + translation_key="connected_to_control", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.connected_to_control, + ), TailscaleBinarySensorEntityDescription( key="update_available", translation_key="client", diff --git a/homeassistant/components/tailscale/strings.json b/homeassistant/components/tailscale/strings.json index 12b910639fc31..f90e00489e1a9 100644 --- a/homeassistant/components/tailscale/strings.json +++ b/homeassistant/components/tailscale/strings.json @@ -44,6 +44,9 @@ "client_supports_upnp": { "name": "Supports UPnP" }, + "connected_to_control": { + "name": "Connected to control" + }, "key_expiry_disabled": { "name": "Key expiry disabled" } diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index cf10b61d05787..d06d50b286e8b 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -642,6 +642,7 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 0318fca1f2514..f8718b594763b 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==16.1.0"] + "requirements": ["uiprotect==16.6.1"] } diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index bf147950b9282..8d747c0374b34 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -12,6 +12,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 036b3fdc5dc59..a528e2ca33b64 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -659,6 +659,20 @@ "name": "[%key:component::sensor::entity_component::signal_strength::name%]" } }, + "switch": { + "quickmode_comfort": { + "name": "Intensive" + }, + "quickmode_eco": { + "name": "Eco" + }, + "quickmode_forced_level_four": { + "name": "Boost" + }, + "quickmode_silent": { + "name": "Silent" + } + }, "water_heater": { "domestic_hot_water": { "name": "Domestic hot water" @@ -674,6 +688,9 @@ }, "program_unknown": { "message": "Cannot translate preset {preset} into a valid ViCare program" + }, + "quickmode_not_activated": { + "message": "Unable to activate ViCare quickmode {quickmode}. Only one quickmode can be active at a time; another one may already be running." } }, "issues": { diff --git a/homeassistant/components/vicare/switch.py b/homeassistant/components/vicare/switch.py new file mode 100644 index 0000000000000..bb972728af057 --- /dev/null +++ b/homeassistant/components/vicare/switch.py @@ -0,0 +1,123 @@ +"""Viessmann ViCare switch device.""" + +from contextlib import suppress +import enum +from typing import Any, override + +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareUtils import ( + PyViCareCommandError, + PyViCareNotSupportedFeatureError, +) +from PyViCare.PyViCareVentilationDevice import ( + VentilationDevice as PyViCareVentilationDevice, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .entity import ViCareEntity +from .types import ViCareConfigEntry, ViCareDevice + + +class VentilationQuickmode(enum.StrEnum): + """ViCare ventilation quickmodes that can be switched on and off. + + `standby` is used by the fan entity, `holiday` is scheduled instead of + activated. + """ + + COMFORT = "comfort" + ECO = "eco" + FORCED_LEVEL_FOUR = "forcedLevelFour" + SILENT = "silent" + + +# Also used as unique id suffix, so the quickmodes cannot collide with other +# switches that may be added for the same device later on. +ENTITY_KEYS = { + VentilationQuickmode.COMFORT: "quickmode_comfort", + VentilationQuickmode.ECO: "quickmode_eco", + VentilationQuickmode.FORCED_LEVEL_FOUR: "quickmode_forced_level_four", + VentilationQuickmode.SILENT: "quickmode_silent", +} + + +def _build_entities( + device_list: list[ViCareDevice], +) -> list[ViCareQuickmodeSwitch]: + """Create ViCare switch entities for a device.""" + entities: list[ViCareQuickmodeSwitch] = [] + for device in device_list: + if not device.api.isVentilationDevice(): + continue + available: list[str] = [] + with suppress(PyViCareNotSupportedFeatureError): + available = device.api.getVentilationQuickmodes() + entities.extend( + ViCareQuickmodeSwitch(quickmode, device.serial, device.config, device.api) + for quickmode in VentilationQuickmode + if quickmode in available + ) + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ViCareConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create the ViCare switch entities.""" + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + config_entry.runtime_data.devices, + ), + # run update to have the current quickmode state on startup + True, + ) + + +class ViCareQuickmodeSwitch(ViCareEntity, SwitchEntity): + """Representation of a ViCare ventilation quickmode.""" + + _api: PyViCareVentilationDevice + + def __init__( + self, + quickmode: VentilationQuickmode, + device_serial: str | None, + device_config: PyViCareDeviceConfig, + device: PyViCareVentilationDevice, + ) -> None: + """Initialize the switch.""" + super().__init__(ENTITY_KEYS[quickmode], device_serial, device_config, device) + self._quickmode = quickmode + self._attr_translation_key = ENTITY_KEYS[quickmode] + + def update(self) -> None: + """Update state of the switch.""" + with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError): + self._attr_is_on = self._api.getVentilationQuickmode(self._quickmode) + + @override + def turn_on(self, **kwargs: Any) -> None: + """Activate the quickmode.""" + try: + self._api.activateVentilationQuickmode(self._quickmode) + except PyViCareCommandError as err: + # Any failed command lands here, but the one users hit is the + # device refusing a second quickmode instead of switching over. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="quickmode_not_activated", + translation_placeholders={"quickmode": self._quickmode}, + ) from err + + @override + def turn_off(self, **kwargs: Any) -> None: + """Deactivate the quickmode.""" + self._api.deactivateVentilationQuickmode(self._quickmode) diff --git a/homeassistant/components/vistapool/coordinator.py b/homeassistant/components/vistapool/coordinator.py index 5765fa5553266..ad24ebf636bf0 100644 --- a/homeassistant/components/vistapool/coordinator.py +++ b/homeassistant/components/vistapool/coordinator.py @@ -10,7 +10,7 @@ ResilientPoolSubscription, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -41,6 +41,7 @@ def __init__( self.pool_id: str = pool_id self.pool_name: str = pool_name self.subscription: ResilientPoolSubscription | None = None + self._push_connected = True super().__init__( hass, @@ -61,16 +62,48 @@ async def _async_update_data(self) -> dict[str, Any]: translation_key="update_failed", ) from err + @property + def push_connected(self) -> bool: + """Whether pool data is still flowing in from the subscription.""" + return self._push_connected + async def subscribe(self) -> None: """Subscribe to Firestore real-time updates via the library.""" def _on_data(data: dict[str, Any]) -> None: """Callback from the Firestore thread; push data to the HA loop.""" - self.hass.loop.call_soon_threadsafe(self.async_set_updated_data, data) + self.hass.loop.call_soon_threadsafe(self._async_handle_push, data) self.subscription = await self.api.subscribe_pool_resilient( - self.pool_id, _on_data + self.pool_id, _on_data, on_health=self._async_on_subscription_health + ) + + @callback + def _async_handle_push(self, data: dict[str, Any]) -> None: + """Apply a snapshot; its arrival is what proves the connection is up.""" + if not self._push_connected: + self._push_connected = True + _LOGGER.info("Reconnected to %s, entities are available again", self.name) + self.async_set_updated_data(data) + + @callback + def _async_on_subscription_health(self, healthy: bool) -> None: + """Mark entities unavailable while the push connection is down. + + Tracked separately from last_update_success: an optimistic update + or a manual refresh sets that flag back to True while the + subscription is still down, and the health callback only fires on + transitions, so it would not correct it. Only an incoming snapshot + clears this. + """ + if healthy or not self._push_connected: + return + self._push_connected = False + _LOGGER.warning( + "Lost the connection to %s, entities are unavailable until it recovers", + self.name, ) + self.async_update_listeners() @override async def async_shutdown(self) -> None: diff --git a/homeassistant/components/vistapool/entity.py b/homeassistant/components/vistapool/entity.py index 175019c901da6..9b47f6e524d73 100644 --- a/homeassistant/components/vistapool/entity.py +++ b/homeassistant/components/vistapool/entity.py @@ -1,5 +1,7 @@ """Shared base entity helpers for Vistapool.""" +from typing import override + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -24,6 +26,12 @@ def __init__(self, coordinator: VistapoolDataUpdateCoordinator) -> None: sw_version=str(sw_version) if sw_version is not None else None, ) + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.push_connected + @property def pool_id(self) -> str: """Return the pool ID for the entity.""" diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 575914c353491..96224b9b8f9dd 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -39,7 +39,7 @@ rules: docs-troubleshooting: done entity-category: done entity-disabled-by-default: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 811097703c662..c02056a0e1112 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ orjson==3.12.0 packaging>=23.1 paho-mqtt==2.1.0 Pillow==12.3.0 -probatio==0.11.3 +probatio==0.11.4 propcache==0.5.2 psutil-home-assistant==0.0.1 PyJWT==2.13.0 diff --git a/pyproject.toml b/pyproject.toml index c6e75cd5bf6eb..c9c2e46164d28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ dependencies = [ "ulid-transform==2.2.9", "urllib3>=2.0", "uv==0.12.5", - "probatio==0.11.3", + "probatio==0.11.4", "yarl==1.24.5", "webrtc-models==0.3.0", "zeroconf==0.151.1", diff --git a/requirements.txt b/requirements.txt index 554f70c761207..6731b5d5b6ec5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ mutagen==1.48.1 orjson==3.12.0 packaging>=23.1 Pillow==12.3.0 -probatio==0.11.3 +probatio==0.11.4 propcache==0.5.2 psutil-home-assistant==0.0.1 PyJWT==2.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 51a767f3c6159..b287137d98424 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1156,10 +1156,10 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud -google-cloud-speech==2.31.1 +google-cloud-speech==2.40.0 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.25.1 +google-cloud-texttospeech==2.37.0 # homeassistant.components.google_generative_ai_conversation google-genai==2.16.0 @@ -1611,7 +1611,7 @@ micloud==0.5 microBeesPy==0.3.5 # homeassistant.components.midea -midea-local==10.0.1 +midea-local==10.1.0 # homeassistant.components.mill mill-local==0.5.0 @@ -3314,7 +3314,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==16.1.0 +uiprotect==16.6.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index bd0f1b0a08f68..0860eb4f36d5f 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -55,10 +55,16 @@ async def test_connection_error( ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=FIXTURE_USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=FIXTURE_USER_INPUT ) - assert result assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -109,15 +115,21 @@ async def test_full_flow_implementation( async def test_integration_already_exists(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" MockConfigEntry( - domain=DOMAIN, data={"host": "mock-adguard", "port": "3000"} + domain=DOMAIN, data={CONF_HOST: "mock-adguard", CONF_PORT: 3000} ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - data={"host": "mock-adguard", "port": "3000"}, - context={"source": config_entries.SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - assert result + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_HOST: "mock-adguard"}, + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/alexa_devices/test_todo.py b/tests/components/alexa_devices/test_todo.py index c3d496d9833ca..64663d74ab8f1 100644 --- a/tests/components/alexa_devices/test_todo.py +++ b/tests/components/alexa_devices/test_todo.py @@ -452,6 +452,23 @@ async def test_update_todo_item( "todo_list_id", "item_2", "Both Changed", 2 ) + # Neither status nor name changed -> no API calls + mock_amazon_devices_client.set_todo_list_item_checked_status.reset_mock() + mock_amazon_devices_client.rename_todo_list_item.reset_mock() + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + { + ATTR_ENTITY_ID: entity_id, + "item": "item_2", + "rename": "Task 1", + "status": TodoItemStatus.NEEDS_ACTION, + }, + blocking=True, + ) + mock_amazon_devices_client.set_todo_list_item_checked_status.assert_not_called() + mock_amazon_devices_client.rename_todo_list_item.assert_not_called() + async def test_update_todo_item_refreshes_state( hass: HomeAssistant, diff --git a/tests/components/centriconnect/snapshots/test_init.ambr b/tests/components/centriconnect/snapshots/test_init.ambr new file mode 100644 index 0000000000000..4aef683b709be --- /dev/null +++ b/tests/components/centriconnect/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.1', + 'id': , + 'identifiers': set({ + tuple( + 'centriconnect', + '123a4b5c-678d-9e0f-a123-4b567c8d901e', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'CentriConnect', + 'model': None, + 'model_id': None, + 'name': 'My Tank', + 'name_by_user': None, + 'serial_number': '123a4b5c-678d-9e0f-a123-4b567c8d901e', + 'sw_version': '1.1.2', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/centriconnect/test_config_flow.py b/tests/components/centriconnect/test_config_flow.py index 73c2eed7ef22d..6257955d99298 100644 --- a/tests/components/centriconnect/test_config_flow.py +++ b/tests/components/centriconnect/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from aiocentriconnect import Tank from aiocentriconnect.exceptions import ( CentriConnectConnectionError, CentriConnectConnectionTimeoutError, @@ -13,7 +14,7 @@ import pytest from homeassistant.components.centriconnect.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -151,3 +152,163 @@ async def test_user_flow_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +RECONFIGURED_USERNAME = "87654321-2109-6543-98a7-f6edc543210b" +RECONFIGURED_PASSWORD = "654321" + + +async def _start_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + { + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + }, + ) + + +async def _start_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reauthenticate flow.""" + mock_config_entry.add_to_hass(hass) + + reauthenticate_result = await mock_config_entry.start_reauth_flow(hass) + + assert reauthenticate_result["type"] is FlowResultType.FORM + assert reauthenticate_result["step_id"] == "reauth_confirm" + + return await hass.config_entries.flow.async_configure( + reauthenticate_result["flow_id"], + { + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + }, + ) + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reconfigure flow aborts if the device ID changes.""" + mock_centriconnect_client.async_get_tank_data.return_value = Tank( + { + "AlertStatus": "No Alert", + "Altitude": 123.456, + "BatteryVolts": 4.19, + "DeviceID": "different_device_id", + "DeviceName": TEST_TANK_NAME, + "DeviceTempCelsius": 17.0, + "DeviceTempFahrenheit": 63.0, + "LastPostTimeIso": "2026-02-27 22:00:31.000", + "Latitude": 40.7128, + "Longitude": -74.0060, + "NextPostTimeIso": "2026-02-28 10:00:00.000", + "SignalQualLTE": -107.0, + "SolarVolts": 2.46, + "TankLevel": 75.0, + "TankSize": 1000, + "TankSizeUnit": "Gallons", + "VersionHW": "4.1", + "VersionLTE": "1.1.2", + } + ) + + result = await _start_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauthenticate_flow( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauthenticate flow.""" + + result = await _start_reauth_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: RECONFIGURED_USERNAME, + CONF_PASSWORD: RECONFIGURED_PASSWORD, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauthenticate_unique_id_mismatch( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure reauthenticate flow aborts if the device ID changes.""" + mock_centriconnect_client.async_get_tank_data.return_value = Tank( + { + "AlertStatus": "No Alert", + "Altitude": 123.456, + "BatteryVolts": 4.19, + "DeviceID": "different_device_id", + "DeviceName": TEST_TANK_NAME, + "DeviceTempCelsius": 17.0, + "DeviceTempFahrenheit": 63.0, + "LastPostTimeIso": "2026-02-27 22:00:31.000", + "Latitude": 40.7128, + "Longitude": -74.0060, + "NextPostTimeIso": "2026-02-28 10:00:00.000", + "SignalQualLTE": -107.0, + "SolarVolts": 2.46, + "TankLevel": 75.0, + "TankSize": 1000, + "TankSizeUnit": "Gallons", + "VersionHW": "4.1", + "VersionLTE": "1.1.2", + } + ) + + result = await _start_reauth_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" diff --git a/tests/components/centriconnect/test_init.py b/tests/components/centriconnect/test_init.py index 02b413bd967b4..4053bdd906c96 100644 --- a/tests/components/centriconnect/test_init.py +++ b/tests/components/centriconnect/test_init.py @@ -3,15 +3,34 @@ from unittest.mock import AsyncMock from aiocentriconnect.exceptions import CentriConnectConnectionError +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.centriconnect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from tests.common import MockConfigEntry +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, mock_config_entry.unique_id), mock_config_entry.entry_id + ) + assert device_entry is not None + assert device_entry == snapshot + + async def test_config_entry_not_ready( hass: HomeAssistant, mock_centriconnect_client: AsyncMock, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 1e2d77e4fb07b..b5ad2e3635f6a 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -104,9 +104,14 @@ async def test_connection_error( """Test we show user form on Elgato Key Light connection error.""" mock_elgato.info.side_effect = ElgatoConnectionError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.FORM @@ -116,10 +121,8 @@ async def test_connection_error( # Recover from error mock_elgato.info.side_effect = None - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.2"}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2"} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -164,9 +167,14 @@ async def test_user_device_exists_abort( """Test we abort zeroconf flow if Elgato Key Light device already configured.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/fritz/test_coordinator.py b/tests/components/fritz/test_coordinator.py index 39eaf06a2a5ed..ddeb752cf121c 100644 --- a/tests/components/fritz/test_coordinator.py +++ b/tests/components/fritz/test_coordinator.py @@ -527,6 +527,17 @@ async def test_trigger_methods( fritz_tools.fritz_call.hangup.assert_called_once() +async def test_trigger_reconnect_reraises_unexpected_error( + fritz_tools, +) -> None: + """Test async_trigger_reconnect re-raises errors other than DisconnectInProgress.""" + fritz_tools.connection.call_action = MagicMock( + side_effect=FritzConnectionException("some other error") + ) + with pytest.raises(FritzConnectionException): + await fritz_tools.async_trigger_reconnect() + + async def test_avmwrapper_service_call_branches( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index e574faf346d36..f9798c3b60bdf 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -5,13 +5,14 @@ from freezegun.api import FrozenDateTimeFactory from fritzconnection.core.exceptions import FritzConnectionException +from fritzconnection.lib.fritzstatus import FritzStatus import pytest from requests.exceptions import RequestException from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -154,3 +155,34 @@ async def test_sensor_cpu_temp_not_supported( assert not entity_registry.async_is_registered( "sensor.mock_title_cpu_temperature" ) + + +@pytest.mark.freeze_time(datetime(2024, 9, 1, 20, tzinfo=UTC)) +async def test_sensor_cpu_temp_unknown_on_request_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + fc_class_mock, + fh_class_mock, + fs_class_mock, +) -> None: + """Test the CPU temperature sensor turns unknown when reading it raises.""" + entity_id = "sensor.mock_title_cpu_temperature" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == "42" + + with patch.object( + FritzStatus, "get_cpu_temperatures", side_effect=RequestException("boom") + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/ipp/test_init.py b/tests/components/ipp/test_init.py index e1050bc5c2188..7edd0f8494e1d 100644 --- a/tests/components/ipp/test_init.py +++ b/tests/components/ipp/test_init.py @@ -4,6 +4,7 @@ from pyipp import IPPConnectionError +from homeassistant.components.ipp.const import REQUEST_TIMEOUT from homeassistant.components.ipp.coordinator import IPPDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -43,3 +44,12 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_request_timeout( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test a printer gets time to wake up before we give up on it.""" + coordinator = IPPDataUpdateCoordinator(hass, mock_config_entry) + + assert coordinator.ipp.request_timeout == REQUEST_TIMEOUT diff --git a/tests/components/lunatone/snapshots/test_sensor.ambr b/tests/components/lunatone/snapshots/test_sensor.ambr index d7cf185cadca5..2b5db2c564ee1 100644 --- a/tests/components/lunatone/snapshots/test_sensor.ambr +++ b/tests/components/lunatone/snapshots/test_sensor.ambr @@ -57,6 +57,134 @@ 'state': 'unknown', }) # --- +# name: test_setup[sensor.dali_line_0_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'low_power', + 'no_power', + 'not_reachable', + 'ok', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dali_line_0_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dali_line_status', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-line0-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.dali_line_0_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'DALI Line 0 Status', + : list([ + 'low_power', + 'no_power', + 'not_reachable', + 'ok', + ]), + }), + 'context': , + 'entity_id': 'sensor.dali_line_0_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_setup[sensor.dali_line_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'low_power', + 'no_power', + 'not_reachable', + 'ok', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dali_line_1_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dali_line_status', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-line1-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.dali_line_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'DALI Line 1 Status', + : list([ + 'low_power', + 'no_power', + 'not_reachable', + 'ok', + ]), + }), + 'context': , + 'entity_id': 'sensor.dali_line_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- # name: test_setup[sensor.test_sensor_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/lunatone/test_sensor.py b/tests/components/lunatone/test_sensor.py index cccfb2f58fa49..774ecde829f9c 100644 --- a/tests/components/lunatone/test_sensor.py +++ b/tests/components/lunatone/test_sensor.py @@ -1,10 +1,11 @@ """Tests for the lights provided by the Lunatone integration.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from lunatone_rest_api_client.models import SensorData +from lunatone_rest_api_client.models import LineStatus, SensorData from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -71,3 +72,40 @@ async def fake_update(): assert entities[0].state == "22" assert entities[1].state == "55" assert entities[2].state == "20" + + +async def test_dali_line_status_value_update( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Lunatone sensor value update.""" + line_id = 0 + entity_id = f"sensor.dali_line_{line_id}_status" + + line_statuses = iter((LineStatus.NO_POWER, LineStatus.OK)) + + async def fake_update() -> None: + info_data = deepcopy(mock_lunatone_info.data) + info_data.lines[str(line_id)].line_status = next(line_statuses) + mock_lunatone_info.data = info_data + + mock_lunatone_info.async_update.side_effect = fake_update + + await setup_integration(hass, mock_config_entry) + + entity = hass.states.get(entity_id) + assert entity + assert entity.state == "no_power" + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity + assert entity.state == "ok" diff --git a/tests/components/lyric/conftest.py b/tests/components/lyric/conftest.py index 979a3c79c1f77..f14aa63d41765 100644 --- a/tests/components/lyric/conftest.py +++ b/tests/components/lyric/conftest.py @@ -8,7 +8,7 @@ from aiolyric import Lyric from aiolyric.exceptions import LyricException from aiolyric.objects.location import LyricLocation -from aiolyric.objects.priority import LyricRoom +from aiolyric.objects.priority import LyricPriority, LyricRoom import pytest from homeassistant.components.application_credentials import ( @@ -28,6 +28,11 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" +MAC_ID = "5CFCE1B67035" +# Second device: has room data but no priority data yet, exercising the +# defensive "no priority entry" branch of LyricPriorityStatusSensor. +NO_PRIORITY_DATA_MAC_ID = "5CFCE1B67036" + @pytest.fixture async def setup_credentials(hass: HomeAssistant) -> None: @@ -163,12 +168,7 @@ async def get_thermostat_rooms(location_id: int, device_id: str) -> None: @pytest.fixture def mock_lyric_api() -> Generator[MagicMock]: - """Mock the aiolyric client, backed by a real Location parsed from a live-shaped fixture. - - get_thermostat_rooms is left as an autospec'd no-op: this test only - covers device-level sensors, not the room/priority data it would - otherwise populate. - """ + """Mock the aiolyric client, backed by a real Location and a real LyricPriority.""" with patch("homeassistant.components.lyric.Lyric", autospec=True) as mock_lyric_cls: lyric = mock_lyric_cls.return_value @@ -180,4 +180,11 @@ def mock_lyric_api() -> Generator[MagicMock]: location.location_id: location for location in lyric.locations } + priority_json = load_json_object_fixture("priority.json", DOMAIN) + lyric.priorities_dict = {MAC_ID: LyricPriority(priority_json)} + lyric.rooms_dict = { + MAC_ID: {1: MagicMock()}, + NO_PRIORITY_DATA_MAC_ID: {1: MagicMock()}, + } + yield lyric diff --git a/tests/components/lyric/fixtures/locations.json b/tests/components/lyric/fixtures/locations.json index 0d3f11736e2ab..efc5cdfea5e1c 100644 --- a/tests/components/lyric/fixtures/locations.json +++ b/tests/components/lyric/fixtures/locations.json @@ -15,6 +15,19 @@ "units": "Fahrenheit", "indoorTemperature": 79, "deviceModel": "T9-T10" + }, + { + "vacationHold": { "Enabled": false }, + "scheduleStatus": "Resume", + "settings": { "devicePairingEnabled": true }, + "deviceClass": "Thermostat", + "deviceType": "Thermostat", + "deviceID": "LCC-8f86b153-8480-f111-b78f-6045bdb25007", + "name": "Bedroom", + "macID": "5CFCE1B67036", + "units": "Fahrenheit", + "indoorTemperature": 72, + "deviceModel": "T9-T10" } ], "users": [] diff --git a/tests/components/lyric/fixtures/priority.json b/tests/components/lyric/fixtures/priority.json new file mode 100644 index 0000000000000..203a9c8e836ed --- /dev/null +++ b/tests/components/lyric/fixtures/priority.json @@ -0,0 +1,9 @@ +{ + "deviceId": "LCC-7f86b153-8480-f111-b78f-6045bdb25006", + "priorityStatus": "NoHold", + "priority": { + "priorityType": "WholeHouse", + "selectedRooms": [], + "rooms": [] + } +} diff --git a/tests/components/lyric/snapshots/test_binary_sensor.ambr b/tests/components/lyric/snapshots/test_binary_sensor.ambr index 25051f05620e3..0828414db8e11 100644 --- a/tests/components/lyric/snapshots/test_binary_sensor.ambr +++ b/tests/components/lyric/snapshots/test_binary_sensor.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_binary_sensor[binary_sensor.bedroom_thermostat_device_pairing_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.bedroom_thermostat_device_pairing_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Device pairing enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Device pairing enabled', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_pairing_enabled', + 'unique_id': '5CFCE1B67036_device_pairing_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.bedroom_thermostat_device_pairing_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom Thermostat Device pairing enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.bedroom_thermostat_device_pairing_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[binary_sensor.ocala_thermostat_device_pairing_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/lyric/snapshots/test_sensor.ambr b/tests/components/lyric/snapshots/test_sensor.ambr index ee677d3ed7164..4c19760f00635 100644 --- a/tests/components/lyric/snapshots/test_sensor.ambr +++ b/tests/components/lyric/snapshots/test_sensor.ambr @@ -1,4 +1,178 @@ # serializer version: 1 +# name: test_sensor[sensor.bedroom_thermostat_indoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bedroom_thermostat_indoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indoor temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Indoor temperature', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_temperature', + 'unique_id': '5CFCE1B67036_indoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_indoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Bedroom Thermostat Indoor temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bedroom_thermostat_indoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2222222222222', + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_priority_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'no_hold', + 'temporary_hold', + 'hold_until', + 'permanent_hold', + 'vacation_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_thermostat_priority_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Priority status', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'priority_status', + 'unique_id': '5CFCE1B67036_priority_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_priority_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Bedroom Thermostat Priority status', + : list([ + 'no_hold', + 'temporary_hold', + 'hold_until', + 'permanent_hold', + 'vacation_hold', + ]), + }), + 'context': , + 'entity_id': 'sensor.bedroom_thermostat_priority_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_schedule_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bedroom_thermostat_schedule_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Schedule status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Schedule status', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'schedule_status', + 'unique_id': '5CFCE1B67036_schedule_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.bedroom_thermostat_schedule_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom Thermostat Schedule status', + }), + 'context': , + 'entity_id': 'sensor.bedroom_thermostat_schedule_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Resume', + }) +# --- # name: test_sensor[sensor.ocala_thermostat_indoor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -57,6 +231,72 @@ 'state': '26.1111111111111', }) # --- +# name: test_sensor[sensor.ocala_thermostat_priority_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'no_hold', + 'temporary_hold', + 'hold_until', + 'permanent_hold', + 'vacation_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ocala_thermostat_priority_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Priority status', + 'platform': 'lyric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'priority_status', + 'unique_id': '5CFCE1B67035_priority_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.ocala_thermostat_priority_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Ocala Thermostat Priority status', + : list([ + 'no_hold', + 'temporary_hold', + 'hold_until', + 'permanent_hold', + 'vacation_hold', + ]), + }), + 'context': , + 'entity_id': 'sensor.ocala_thermostat_priority_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_hold', + }) +# --- # name: test_sensor[sensor.ocala_thermostat_schedule_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/midea/test_binary_sensor.py b/tests/components/midea/test_binary_sensor.py index 7d0efb2cc9620..f3a9c27f6d40e 100644 --- a/tests/components/midea/test_binary_sensor.py +++ b/tests/components/midea/test_binary_sensor.py @@ -115,3 +115,29 @@ async def test_binary_sensor_state_update( state = hass.states.get(entity_entry.entity_id) assert state is not None assert state.state == "on" + + +async def test_binary_sensor_unknown_for_non_bool_value( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test binary_sensor is unknown when the device reports a non-bool value.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.full_dust: None, + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_full_dust"] + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == "unknown" diff --git a/tests/components/midea/test_light.py b/tests/components/midea/test_light.py index 4ad0b8443822c..92e89d5185b37 100644 --- a/tests/components/midea/test_light.py +++ b/tests/components/midea/test_light.py @@ -256,6 +256,22 @@ async def test_light_color_mode_fallbacks( assert (ATTR_EFFECT in state.attributes) == expected_has_effect +async def test_light_effect_none_when_device_reports_non_string( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test effect is reported as None when the device value is not a string.""" + device = _x13_device() + device.attributes[X13Attributes.effect] = 0 + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_light"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.attributes[ATTR_EFFECT] is None + + async def test_light_not_created_for_other_device_type( hass: HomeAssistant, mock_config_entry: Callable[[DummyDevice], MockConfigEntry], diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 97a0f63392d18..53cb8c90a2193 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -190,6 +190,33 @@ async def test_hub_wifi(hass: HomeAssistant) -> None: assert device_2.state == "home" +@pytest.mark.usefixtures("mock_device_registry_devices") +async def test_hub_wireless_and_wifi(hass: HomeAssistant) -> None: + """Test a hub exposing both the legacy wireless package and the wifi driver. + + The legacy ``wireless`` package can linger on a hub acting as a CAPsMAN for + ``wifi`` access points, leaving its registration table empty while the + connected clients are only listed on the ``wifi`` interface. + """ + device_2_without_active_address = { + key: value for key, value in DEVICE_2_DHCP.items() if key != "active-address" + } + + await setup_mikrotik_entry( + hass, + dhcp_data=[DEVICE_1_DHCP, device_2_without_active_address], + support_wireless=True, + wireless_data=[], + support_wifi=True, + wifi_data=[DEVICE_2_WIRELESS], + ) + + # device_2 is only present on the wifi interface, not in the wireless list + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 + assert device_2.state == "home" + + @pytest.mark.usefixtures("mock_device_registry_devices") async def test_wired_device_without_active_address_is_not_home( hass: HomeAssistant, diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 6d22f2cfcbec7..67a5a6b665b88 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -10,8 +10,13 @@ import pytest from homeassistant.components.mikrotik.const import ( + ARP, + CONF_ARP_PING, + CONF_FORCE_DHCP, + DHCP, IDENTITY, MIKROTIK_SERVICES, + PING, ROUTERBOARD, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -22,7 +27,7 @@ from . import setup_integration from .conftest import MockConfigEntryFactory -from .const import MOCK_DATA +from .const import ARP_DATA, DHCP_DATA, MOCK_DATA from tests.common import async_fire_time_changed @@ -274,6 +279,50 @@ def flaky_call(cmd: str, **params: Any) -> list[dict[str, Any]]: assert mock_get_api.call_count == 1 +async def test_connection_dropped_during_arp_ping_retries_with_params( + hass: HomeAssistant, + mock_api: MagicMock, + mock_config_entry: MockConfigEntryFactory, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a dropped connection during an arp-ping reconnects and retries with params.""" + entry = mock_config_entry(options={CONF_ARP_PING: True, CONF_FORCE_DHCP: True}) + await setup_integration(hass, entry, command_responses={}) + assert entry.state is ConfigEntryState.LOADED + + ping_cmd = MIKROTIK_SERVICES[PING] + # a single tracked device keeps the arp-ping call count deterministic + responses = { + MIKROTIK_SERVICES[DHCP]: DHCP_DATA[:1], + MIKROTIK_SERVICES[ARP]: ARP_DATA[:1], + } + ping_calls = 0 + + def flaky_call(cmd: str, **params: Any) -> list[dict[str, Any]]: + nonlocal ping_calls + if cmd == ping_cmd: + ping_calls += 1 + if ping_calls == 1: + raise ConnectionClosed + return [{"seq": "0"}] + return responses.get(cmd, []) + + mock_api.side_effect = flaky_call + + with patch( + "homeassistant.components.mikrotik.coordinator.get_api", return_value=mock_api + ) as mock_get_api: + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + assert entry.runtime_data.last_update_success is True + # the arp-ping command carries params, so the reconnect retries it with them + assert mock_get_api.call_count == 1 + assert ping_calls == 2 + + async def test_scheduled_refresh_reuses_persistent_connection( hass: HomeAssistant, mock_config_entry: MockConfigEntryFactory ) -> None: diff --git a/tests/components/peblar/test_config_flow.py b/tests/components/peblar/test_config_flow.py index ea3573511bfe4..cdb8967d2d33e 100644 --- a/tests/components/peblar/test_config_flow.py +++ b/tests/components/peblar/test_config_flow.py @@ -66,9 +66,15 @@ async def test_user_flow_errors( mock_peblar.login.side_effect = side_effect result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "127.0.0.1", CONF_PASSWORD: "OMGCATS!", }, @@ -105,9 +111,15 @@ async def test_user_flow_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "127.0.0.1", CONF_PASSWORD: "OMGSPIDERS", }, diff --git a/tests/components/poolsense/test_config_flow.py b/tests/components/poolsense/test_config_flow.py index 4e160a3cd9fba..7ea37b2f81684 100644 --- a/tests/components/poolsense/test_config_flow.py +++ b/tests/components/poolsense/test_config_flow.py @@ -47,9 +47,15 @@ async def test_invalid_credentials( """Test we handle invalid credentials.""" mock_poolsense_client.test_poolsense_credentials.return_value = False result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, ) assert result["type"] is FlowResultType.FORM @@ -73,9 +79,15 @@ async def test_duplicate_entry( """Test we can't add the same entry twice.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, ) await hass.async_block_till_done() diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py index 8837fbe753bf2..d43966c2972b0 100644 --- a/tests/components/pterodactyl/test_config_flow.py +++ b/tests/components/pterodactyl/test_config_flow.py @@ -102,7 +102,14 @@ async def test_service_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_INPUT + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/pushbullet/test_config_flow.py b/tests/components/pushbullet/test_config_flow.py index 0b2efa1d556fd..cb6e13b610bad 100644 --- a/tests/components/pushbullet/test_config_flow.py +++ b/tests/components/pushbullet/test_config_flow.py @@ -97,9 +97,14 @@ async def test_flow_invalid_key(hass: HomeAssistant) -> None: side_effect=InvalidKeyError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=MOCK_CONFIG, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -114,9 +119,14 @@ async def test_flow_conn_error(hass: HomeAssistant) -> None: side_effect=PushbulletError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=MOCK_CONFIG, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/pushover/test_config_flow.py b/tests/components/pushover/test_config_flow.py index a3c9ac3ccbbb7..a2989d98a1af2 100644 --- a/tests/components/pushover/test_config_flow.py +++ b/tests/components/pushover/test_config_flow.py @@ -102,10 +102,16 @@ async def test_flow_invalid_user_key( mock_pushover.side_effect = BadAPIRequestError("400: user key is invalid") result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=MOCK_CONFIG, + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_USER_KEY: "invalid_user_key"} @@ -118,10 +124,16 @@ async def test_flow_invalid_api_key( mock_pushover.side_effect = BadAPIRequestError("400: application token is invalid") result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=MOCK_CONFIG, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} @@ -132,10 +144,16 @@ async def test_flow_conn_err(hass: HomeAssistant, mock_pushover: MagicMock) -> N mock_pushover.side_effect = BadAPIRequestError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=MOCK_CONFIG, + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py index 9333100634dbf..1871fdea118ea 100644 --- a/tests/components/pvoutput/test_config_flow.py +++ b/tests/components/pvoutput/test_config_flow.py @@ -104,9 +104,15 @@ async def test_connection_error(hass: HomeAssistant, mock_pvoutput: MagicMock) - mock_pvoutput.system.side_effect = PVOutputConnectionError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_SYSTEM_ID: 12345, CONF_API_KEY: "tadaaa", }, diff --git a/tests/components/redgtech/test_config_flow.py b/tests/components/redgtech/test_config_flow.py index 9ca0b9abe7c1b..eca3b63fc8998 100644 --- a/tests/components/redgtech/test_config_flow.py +++ b/tests/components/redgtech/test_config_flow.py @@ -38,7 +38,14 @@ async def test_user_step_errors( mock_redgtech_api.login.return_value = None result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=user_input + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.FORM @@ -57,7 +64,14 @@ async def test_user_step_creates_entry( mock_redgtech_api.login.side_effect = None result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=user_input + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -82,7 +96,14 @@ async def test_user_step_duplicate_entry( user_input = {CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=user_input + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.ABORT @@ -115,7 +136,14 @@ async def test_user_step_error_recovery( # First attempt fails with error mock_redgtech_api.login.side_effect = side_effect result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=user_input + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 6b5b9bbc47c02..0fa7913f032f1 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -108,6 +108,34 @@ async def test_stale_device( } +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) +async def test_device_serial_number( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test the serial number is taken from home data, where the cloud reports one. + + The docks are separate devices without their own home data entry, and the + Dyad Pro is a shared device whose home data carries no serial number. + """ + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + devices = dr.async_entries_for_config_entry( + device_registry, mock_roborock_entry.entry_id + ) + assert {device.name: device.serial_number for device in devices} == { + "Roborock S7 MaxV": "abc123", + "Roborock S7 MaxV Dock": None, + "Roborock S7 2": "abc123", + "Roborock S7 2 Dock": None, + "Dyad Pro": None, + "Zeo One": "zeo_sn", + "Roborock Q7": "q7_sn", + "Roborock Q10 S5+": "9FFC112EQAD843", + } + + @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_no_stale_device( hass: HomeAssistant, diff --git a/tests/components/romy/test_config_flow.py b/tests/components/romy/test_config_flow.py index 5286801ac0ee7..b018d0d4c3e3f 100644 --- a/tests/components/romy/test_config_flow.py +++ b/tests/components/romy/test_config_flow.py @@ -64,9 +64,14 @@ async def test_show_user_form_robot_is_offline_and_locked(hass: HomeAssistant) - return_value=_create_mocked_romy(False, False), ): result1 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=INPUT_CONFIG_HOST, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user" + + result1 = await hass.config_entries.flow.async_configure( + result1["flow_id"], user_input=INPUT_CONFIG_HOST ) assert result1["errors"].get("host") == "cannot_connect" @@ -106,9 +111,14 @@ async def test_show_user_form_robot_unlock_with_password(hass: HomeAssistant) -> return_value=_create_mocked_romy(True, False), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=INPUT_CONFIG_HOST, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=INPUT_CONFIG_HOST ) with patch( @@ -156,9 +166,14 @@ async def test_show_user_form_robot_reachable_again(hass: HomeAssistant) -> None return_value=_create_mocked_romy(False, False), ): result1 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=INPUT_CONFIG_HOST, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user" + + result1 = await hass.config_entries.flow.async_configure( + result1["flow_id"], user_input=INPUT_CONFIG_HOST ) assert result1["errors"].get("host") == "cannot_connect" diff --git a/tests/components/rova/test_config_flow.py b/tests/components/rova/test_config_flow.py index 608f4ec105b95..3f8c456981b91 100644 --- a/tests/components/rova/test_config_flow.py +++ b/tests/components/rova/test_config_flow.py @@ -31,10 +31,9 @@ async def test_user(hass: HomeAssistant, mock_rova: MagicMock) -> None: assert result.get("step_id") == "user" # test with all information provided - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_ZIP_CODE: ZIP_CODE, CONF_HOUSE_NUMBER: HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, @@ -106,9 +105,15 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_ZIP_CODE: ZIP_CODE, CONF_HOUSE_NUMBER: HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, diff --git a/tests/components/season/test_config_flow.py b/tests/components/season/test_config_flow.py index a3e0142fe28a0..6f6be77fc4f0f 100644 --- a/tests/components/season/test_config_flow.py +++ b/tests/components/season/test_config_flow.py @@ -39,8 +39,16 @@ async def test_single_instance_allowed( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_TYPE: TYPE_ASTRONOMICAL} + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TYPE: TYPE_ASTRONOMICAL}, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" diff --git a/tests/components/seventeentrack/test_config_flow.py b/tests/components/seventeentrack/test_config_flow.py index d62126a096d6b..0b34878633eef 100644 --- a/tests/components/seventeentrack/test_config_flow.py +++ b/tests/components/seventeentrack/test_config_flow.py @@ -81,10 +81,16 @@ async def test_flow_fails( """Test that the user step fails.""" mock_seventeentrack.return_value.profile.login.return_value = return_value mock_seventeentrack.return_value.profile.login.side_effect = side_effect - failed_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + failed_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, ) assert failed_result["errors"] == {"base": error} diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index ded9f296460ed..8218f82a92c2a 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -25,7 +25,12 @@ BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_DEVICE_ID, + EVENT_HOMEASSISTANT_STOP, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceRegistry @@ -511,6 +516,21 @@ async def test_rpc_connection_error_during_unload( assert entry.state is ConfigEntryState.NOT_LOADED +async def test_block_shutdown_on_ha_stop( + hass: HomeAssistant, + mock_block_device: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the device is shut down when Home Assistant stops.""" + await init_integration(hass, 1) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert "Stopping RPC device coordinator for Test name" in caplog.text + mock_block_device.shutdown.assert_called() + + async def test_rpc_click_event( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/skybell/test_config_flow.py b/tests/components/skybell/test_config_flow.py index f415fef077e6b..3a10c688b2da6 100644 --- a/tests/components/skybell/test_config_flow.py +++ b/tests/components/skybell/test_config_flow.py @@ -55,7 +55,14 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA ) assert result["type"] is FlowResultType.ABORT @@ -66,7 +73,14 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant, skybell_mock) -> No """Test user initialized flow with unreachable server.""" skybell_mock.async_initialize.side_effect = exceptions.SkybellException(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -79,7 +93,14 @@ async def test_invalid_credentials(hass: HomeAssistant, skybell_mock) -> None: exceptions.SkybellAuthenticationException(hass) ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA ) assert result["type"] is FlowResultType.FORM @@ -91,7 +112,14 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, skybell_mock) -> Non """Test user initialized flow with unreachable server.""" skybell_mock.async_initialize.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_DATA ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/slack/test_config_flow.py b/tests/components/slack/test_config_flow.py index 6d0953da5e975..1c10bf4b575a8 100644 --- a/tests/components/slack/test_config_flow.py +++ b/tests/components/slack/test_config_flow.py @@ -54,9 +54,14 @@ async def test_flow_user_invalid_auth( """Test user initialized flow with invalid token.""" mock_connection(aioclient_mock, "invalid_auth") result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_INPUT ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -69,9 +74,14 @@ async def test_flow_user_cannot_connect( """Test user initialized flow with unreachable server.""" mock_connection(aioclient_mock, "cannot_connect") result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_INPUT ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -85,9 +95,14 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: ) as mock: mock.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_INPUT ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" diff --git a/tests/components/sleepiq/test_config_flow.py b/tests/components/sleepiq/test_config_flow.py index 26007d42e7dad..0b0519bc47780 100644 --- a/tests/components/sleepiq/test_config_flow.py +++ b/tests/components/sleepiq/test_config_flow.py @@ -47,7 +47,7 @@ async def test_show_set_form(hass: HomeAssistant) -> None: """Test that the setup form is served.""" with patch("asyncsleepiq.AsyncSleepIQ.login"): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -68,7 +68,14 @@ async def test_login_failure(hass: HomeAssistant, side_effect, error) -> None: side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SLEEPIQ_CONFIG + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=SLEEPIQ_CONFIG ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py index 7d39adce2e6bc..d144e120a21fb 100644 --- a/tests/components/smarla/test_config_flow.py +++ b/tests/components/smarla/test_config_flow.py @@ -52,9 +52,15 @@ async def test_malformed_token(hass: HomeAssistant) -> None: "homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=MOCK_USER_INPUT, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -87,9 +93,15 @@ async def test_validation_exception( mock_connection.refresh_token.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=MOCK_USER_INPUT, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, ) mock_connection.refresh_token.side_effect = None @@ -114,9 +126,15 @@ async def test_device_exists_abort( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=MOCK_USER_INPUT, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/solaredge/test_config_flow.py b/tests/components/solaredge/test_config_flow.py index 991f9e6d74bb3..034a07d848b2d 100644 --- a/tests/components/solaredge/test_config_flow.py +++ b/tests/components/solaredge/test_config_flow.py @@ -158,9 +158,15 @@ async def test_abort_if_already_setup( # Should fail, same SITE_ID result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_NAME: "test", CONF_SITE_ID: SITE_ID, CONF_SECTION_API_AUTH: {CONF_API_KEY: "test"}, @@ -182,9 +188,15 @@ async def test_ignored_entry_does_not_cause_error( # Should not fail, same SITE_ID result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_NAME: "test", CONF_SITE_ID: SITE_ID, CONF_SECTION_API_AUTH: {CONF_API_KEY: "test"}, @@ -238,9 +250,14 @@ async def test_api_key_errors( CONF_SECTION_API_AUTH: {CONF_API_KEY: API_KEY}, } result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input ) assert result.get("type") is FlowResultType.FORM diff --git a/tests/components/solaredge_modbus/conftest.py b/tests/components/solaredge_modbus/conftest.py index 64071d6d0abef..f0d53108c4704 100644 --- a/tests/components/solaredge_modbus/conftest.py +++ b/tests/components/solaredge_modbus/conftest.py @@ -30,6 +30,7 @@ UNIT_ID = 1 SERIAL_NUMBER = "7E123ABC" METER_SERIAL_NUMBER = "7E4A11C2" +BATTERY_SERIAL_NUMBERS = ("7E7C33E4", "7E8D44F5") # Where a meter's block starts, how far the next one sits, and where in it the # serial number lives, as SunSpec lays them out. @@ -37,6 +38,12 @@ METER_STRIDE = 174 METER_SERIAL_BASE = 40171 +# The same for the batteries, whose blocks sit at fixed offsets rather than a +# stride, with the rated-energy register the probe counts them by. +BATTERY_SERIAL_BASE = 57648 +BATTERY_RATED_ENERGY = 57666 +BATTERY_OFFSETS = (0, 256, 768) + def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]: """Config entry data for an inverter reached over Modbus TCP.""" diff --git a/tests/components/solaredge_modbus/fixtures/se10000h.json b/tests/components/solaredge_modbus/fixtures/se10000h.json index a6e6d9a1afe0b..fb3f72d4151db 100644 --- a/tests/components/solaredge_modbus/fixtures/se10000h.json +++ b/tests/components/solaredge_modbus/fixtures/se10000h.json @@ -177,6 +177,70 @@ "57359": 17970, "57360": 8192, "57361": 17970, + "57600": 21359, + "57601": 27745, + "57602": 29253, + "57603": 25703, + "57604": 25856, + "57605": 0, + "57606": 0, + "57607": 0, + "57608": 0, + "57609": 0, + "57610": 0, + "57611": 0, + "57612": 0, + "57613": 0, + "57614": 0, + "57615": 0, + "57616": 21317, + "57617": 11586, + "57618": 16724, + "57619": 11572, + "57620": 14422, + "57621": 11569, + "57622": 12363, + "57623": 22344, + "57624": 0, + "57625": 0, + "57626": 0, + "57627": 0, + "57628": 0, + "57629": 0, + "57630": 0, + "57631": 0, + "57632": 12590, + "57633": 12846, + "57634": 13056, + "57635": 0, + "57636": 0, + "57637": 0, + "57638": 0, + "57639": 0, + "57640": 0, + "57641": 0, + "57642": 0, + "57643": 0, + "57644": 0, + "57645": 0, + "57646": 0, + "57647": 0, + "57648": 14149, + "57649": 14147, + "57650": 13107, + "57651": 17716, + "57652": 0, + "57653": 0, + "57654": 0, + "57655": 0, + "57656": 0, + "57657": 0, + "57658": 0, + "57659": 0, + "57660": 0, + "57661": 0, + "57662": 0, + "57663": 0, "57666": 36864, "57667": 17943, "57668": 16384, @@ -207,6 +271,70 @@ "57733": 17095, "57734": 6, "57735": 0, + "57856": 21359, + "57857": 27745, + "57858": 29253, + "57859": 25703, + "57860": 25856, + "57861": 0, + "57862": 0, + "57863": 0, + "57864": 0, + "57865": 0, + "57866": 0, + "57867": 0, + "57868": 0, + "57869": 0, + "57870": 0, + "57871": 0, + "57872": 21317, + "57873": 11586, + "57874": 16724, + "57875": 11572, + "57876": 14422, + "57877": 11569, + "57878": 12363, + "57879": 22344, + "57880": 0, + "57881": 0, + "57882": 0, + "57883": 0, + "57884": 0, + "57885": 0, + "57886": 0, + "57887": 0, + "57888": 12590, + "57889": 12846, + "57890": 13056, + "57891": 0, + "57892": 0, + "57893": 0, + "57894": 0, + "57895": 0, + "57896": 0, + "57897": 0, + "57898": 0, + "57899": 0, + "57900": 0, + "57901": 0, + "57902": 0, + "57903": 0, + "57904": 14149, + "57905": 14404, + "57906": 13364, + "57907": 17973, + "57908": 0, + "57909": 0, + "57910": 0, + "57911": 0, + "57912": 0, + "57913": 0, + "57914": 0, + "57915": 0, + "57916": 0, + "57917": 0, + "57918": 0, + "57919": 0, "57922": 36864, "57923": 17943, "57964": 58463, diff --git a/tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..b7428ba43a926 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,204 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.battery_1_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.battery_1_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_battery_7E7C33E4_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.battery_1_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery_charging', + : 'Battery 1 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_1_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.battery_2_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.battery_2_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_battery_7E8D44F5_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.battery_2_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery_charging', + : 'Battery 2 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.battery_2_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_on_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.solaredge_se10000h_on_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'On grid', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'On grid', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'on_grid', + 'unique_id': '7E123ABC_on_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_on_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H On grid', + }), + 'context': , + 'entity_id': 'binary_sensor.solaredge_se10000h_on_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_problem-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.solaredge_se10000h_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Problem', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_problem', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.solaredge_se10000h_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'SolarEdge SE10000H Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.solaredge_se10000h_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr index e086af93533c4..c96d5cbea1b71 100644 --- a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr @@ -10,20 +10,20 @@ 'energy_exported': 0, 'energy_imported': 0, 'energy_max': 9700.0, - 'manufacturer': '', + 'manufacturer': 'SolarEdge', 'max_charge_peak_power': 6000.0, 'max_charge_power': 5000.0, 'max_discharge_peak_power': 6100.0, 'max_discharge_power': 5100.0, - 'model': '', + 'model': 'SE-BAT-48V-10KWH', 'rated_energy': 9700.0, - 'serial_number': '', + 'serial_number': '**REDACTED**', 'state_of_energy': 99.94781494140625, 'state_of_health': 100.0, 'status': 6, 'temperature_average': 23.608806610107422, 'temperature_max': 24.5, - 'version': '', + 'version': '1.2.3', }), dict({ 'dc_current': -0.0, @@ -33,20 +33,20 @@ 'energy_exported': 0, 'energy_imported': 0, 'energy_max': 9700.0, - 'manufacturer': '', + 'manufacturer': 'SolarEdge', 'max_charge_peak_power': 0.0, 'max_charge_power': 0.0, 'max_discharge_peak_power': 0.0, 'max_discharge_power': 0.0, - 'model': '', + 'model': 'SE-BAT-48V-10KWH', 'rated_energy': 9700.0, - 'serial_number': '', + 'serial_number': '**REDACTED**', 'state_of_energy': 99.2170181274414, 'state_of_health': 100.0, 'status': 6, 'temperature_average': 23.861509323120117, 'temperature_max': 0.0, - 'version': '', + 'version': '1.2.3', }), ]), 'common': dict({ diff --git a/tests/components/solaredge_modbus/snapshots/test_number.ambr b/tests/components/solaredge_modbus/snapshots/test_number.ambr new file mode 100644 index 0000000000000..bb9434f1f73f7 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_number.ambr @@ -0,0 +1,424 @@ +# serializer version: 1 +# name: test_numbers[number.solaredge_se10000h_active_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_active_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active power limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_limit', + 'unique_id': '7E123ABC_active_power_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_active_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Active power limit', + : 100, + : 0, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_active_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backup reserve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup reserve', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve', + 'unique_id': '7E123ABC_backup_reserve', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Backup reserve', + : 100, + : 0, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_external_production_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_external_production_maximum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External production maximum', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External production maximum', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_production_max', + 'unique_id': '7E123ABC_external_production_max', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_external_production_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H External production maximum', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_external_production_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_power_factor_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1.0, + : -1.0, + : , + : 0.01, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_power_factor_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power factor setpoint', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power factor setpoint', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cos_phi', + 'unique_id': '7E123ABC_cos_phi', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.solaredge_se10000h_power_factor_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Power factor setpoint', + : 1.0, + : -1.0, + : , + : 0.01, + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_power_factor_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_site_export_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_site_export_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Site export limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Site export limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'site_limit', + 'unique_id': '7E123ABC_site_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_site_export_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Site export limit', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_site_export_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_storage_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage charge limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage charge limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit', + 'unique_id': '7E123ABC_charge_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Storage charge limit', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_storage_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11400.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_discharge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_storage_discharge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage discharge limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage discharge limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge_limit', + 'unique_id': '7E123ABC_discharge_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_discharge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Storage discharge limit', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_storage_discharge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11400.0', + }) +# --- diff --git a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr index a240a8825adc1..25fe61c87e31c 100644 --- a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr @@ -1,4 +1,2036 @@ # serializer version: 1 +# name: test_sensors[sensor.battery_1_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Available energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Available energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_available', + 'unique_id': '7E123ABC_battery_7E7C33E4_energy_available', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy_storage', + : 'Battery 1 Available energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.581759765625', + }) +# --- +# name: test_sensors[sensor.battery_1_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '7E123ABC_battery_7E7C33E4_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Battery 1 DC current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.0', + }) +# --- +# name: test_sensors[sensor.battery_1_dc_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1_dc_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_power', + 'unique_id': '7E123ABC_battery_7E7C33E4_dc_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_dc_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1 DC power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_dc_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_1_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '7E123ABC_battery_7E7C33E4_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Battery 1 DC voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '403.846710205078', + }) +# --- +# name: test_sensors[sensor.battery_1_energy_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1_energy_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported', + 'unique_id': '7E123ABC_battery_7E7C33E4_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_energy_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1 Energy exported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_energy_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_1_energy_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1_energy_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported', + 'unique_id': '7E123ABC_battery_7E7C33E4_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_energy_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1 Energy imported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_energy_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_charge_peak_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_maximum_charge_peak_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum charge peak power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum charge peak power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_charge_peak_power', + 'unique_id': '7E123ABC_battery_7E7C33E4_max_charge_peak_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_charge_peak_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1 Maximum charge peak power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_maximum_charge_peak_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6000.0', + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_charge_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_maximum_charge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum charge power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum charge power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_charge_power', + 'unique_id': '7E123ABC_battery_7E7C33E4_max_charge_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_charge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1 Maximum charge power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_maximum_charge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.0', + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_discharge_peak_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_maximum_discharge_peak_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum discharge peak power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum discharge peak power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_discharge_peak_power', + 'unique_id': '7E123ABC_battery_7E7C33E4_max_discharge_peak_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_discharge_peak_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1 Maximum discharge peak power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_maximum_discharge_peak_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6100.0', + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_discharge_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_maximum_discharge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum discharge power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum discharge power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_discharge_power', + 'unique_id': '7E123ABC_battery_7E7C33E4_max_discharge_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_discharge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1 Maximum discharge power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_maximum_discharge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5100.0', + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum temperature', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_max', + 'unique_id': '7E123ABC_battery_7E7C33E4_temperature_max', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Battery 1 Maximum temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.5', + }) +# --- +# name: test_sensors[sensor.battery_1_rated_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_rated_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rated energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rated energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rated_energy', + 'unique_id': '7E123ABC_battery_7E7C33E4_rated_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_rated_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy_storage', + : 'Battery 1 Rated energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_rated_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.7', + }) +# --- +# name: test_sensors[sensor.battery_1_state_of_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1_state_of_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_energy', + 'unique_id': '7E123ABC_battery_7E7C33E4_state_of_energy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.battery_1_state_of_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Battery 1 State of energy', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.battery_1_state_of_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99.9478149414062', + }) +# --- +# name: test_sensors[sensor.battery_1_state_of_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_state_of_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of health', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'State of health', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_health', + 'unique_id': '7E123ABC_battery_7E7C33E4_state_of_health', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.battery_1_state_of_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Battery 1 State of health', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.battery_1_state_of_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensors[sensor.battery_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'standby', + 'init', + 'charge', + 'discharge', + 'fault', + 'preserve_charge', + 'idle', + 'power_saving', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_status', + 'unique_id': '7E123ABC_battery_7E7C33E4_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.battery_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Battery 1 Status', + : list([ + 'off', + 'standby', + 'init', + 'charge', + 'discharge', + 'fault', + 'preserve_charge', + 'idle', + 'power_saving', + ]), + }), + 'context': , + 'entity_id': 'sensor.battery_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preserve_charge', + }) +# --- +# name: test_sensors[sensor.battery_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_battery_7E7C33E4_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Battery 1 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.6088066101074', + }) +# --- +# name: test_sensors[sensor.battery_1_usable_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1_usable_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Usable capacity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Usable capacity', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_max', + 'unique_id': '7E123ABC_battery_7E7C33E4_energy_max', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1_usable_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy_storage', + : 'Battery 1 Usable capacity', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1_usable_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.7', + }) +# --- +# name: test_sensors[sensor.battery_2_available_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_2_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Available energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Available energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_available', + 'unique_id': '7E123ABC_battery_7E8D44F5_energy_available', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_available_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy_storage', + : 'Battery 2 Available energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_available_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.7065595703125', + }) +# --- +# name: test_sensors[sensor.battery_2_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '7E123ABC_battery_7E8D44F5_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Battery 2 DC current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_dc_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_2_dc_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_power', + 'unique_id': '7E123ABC_battery_7E8D44F5_dc_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_dc_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 2 DC power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_dc_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '7E123ABC_battery_7E8D44F5_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Battery 2 DC voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '404.802947998047', + }) +# --- +# name: test_sensors[sensor.battery_2_energy_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_2_energy_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported', + 'unique_id': '7E123ABC_battery_7E8D44F5_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_energy_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 2 Energy exported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_energy_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_energy_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_2_energy_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported', + 'unique_id': '7E123ABC_battery_7E8D44F5_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_energy_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 2 Energy imported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_energy_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_charge_peak_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_maximum_charge_peak_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum charge peak power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum charge peak power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_charge_peak_power', + 'unique_id': '7E123ABC_battery_7E8D44F5_max_charge_peak_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_charge_peak_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 2 Maximum charge peak power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_maximum_charge_peak_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_charge_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_maximum_charge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum charge power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum charge power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_charge_power', + 'unique_id': '7E123ABC_battery_7E8D44F5_max_charge_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_charge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 2 Maximum charge power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_maximum_charge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_discharge_peak_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_maximum_discharge_peak_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum discharge peak power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum discharge peak power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_discharge_peak_power', + 'unique_id': '7E123ABC_battery_7E8D44F5_max_discharge_peak_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_discharge_peak_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 2 Maximum discharge peak power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_maximum_discharge_peak_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_discharge_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_maximum_discharge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum discharge power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum discharge power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_discharge_power', + 'unique_id': '7E123ABC_battery_7E8D44F5_max_discharge_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_discharge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 2 Maximum discharge power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_maximum_discharge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Maximum temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum temperature', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_max', + 'unique_id': '7E123ABC_battery_7E8D44F5_temperature_max', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Battery 2 Maximum temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.battery_2_rated_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_rated_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Rated energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rated energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rated_energy', + 'unique_id': '7E123ABC_battery_7E8D44F5_rated_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_rated_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy_storage', + : 'Battery 2 Rated energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_rated_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.7', + }) +# --- +# name: test_sensors[sensor.battery_2_state_of_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_2_state_of_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_energy', + 'unique_id': '7E123ABC_battery_7E8D44F5_state_of_energy', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.battery_2_state_of_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Battery 2 State of energy', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.battery_2_state_of_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99.2170181274414', + }) +# --- +# name: test_sensors[sensor.battery_2_state_of_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_state_of_health', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of health', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'State of health', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_health', + 'unique_id': '7E123ABC_battery_7E8D44F5_state_of_health', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.battery_2_state_of_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Battery 2 State of health', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.battery_2_state_of_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensors[sensor.battery_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'standby', + 'init', + 'charge', + 'discharge', + 'fault', + 'preserve_charge', + 'idle', + 'power_saving', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_2_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_status', + 'unique_id': '7E123ABC_battery_7E8D44F5_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.battery_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Battery 2 Status', + : list([ + 'off', + 'standby', + 'init', + 'charge', + 'discharge', + 'fault', + 'preserve_charge', + 'idle', + 'power_saving', + ]), + }), + 'context': , + 'entity_id': 'sensor.battery_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preserve_charge', + }) +# --- +# name: test_sensors[sensor.battery_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_battery_7E8D44F5_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Battery 2 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.8615093231201', + }) +# --- +# name: test_sensors[sensor.battery_2_usable_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_2_usable_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Usable capacity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Usable capacity', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_max', + 'unique_id': '7E123ABC_battery_7E8D44F5_energy_max', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_2_usable_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy_storage', + : 'Battery 2 Usable capacity', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_2_usable_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.7', + }) +# --- # name: test_sensors[sensor.meter_1_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/solaredge_modbus/test_binary_sensor.py b/tests/components/solaredge_modbus/test_binary_sensor.py new file mode 100644 index 0000000000000..0d82a3324b460 --- /dev/null +++ b/tests/components/solaredge_modbus/test_binary_sensor.py @@ -0,0 +1,118 @@ +"""Tests for the SolarEdge Modbus binary sensor entities.""" + +from unittest.mock import patch + +from modbus_connection import IllegalDataAddressError +from modbus_connection.encode import encode_int +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +CHARGING_ENTITY = "binary_sensor.battery_1_charging" +PROBLEM_ENTITY = "binary_sensor.solaredge_se10000h_problem" +ON_GRID_ENTITY = "binary_sensor.solaredge_se10000h_on_grid" +BATTERY_STATUS_REGISTER = 57734 + + +async def _setup_binary_sensor_platform( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All binary sensor entities and their states match the snapshot.""" + await _setup_binary_sensor_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_no_on_grid_sensor_without_grid_status_extension( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Firmware without the grid status extension gets no on-grid sensor.""" + # A real device answers reads of the absent extension with a Modbus + # exception (illegal data address). + mock_modbus_unit.fail_read(40113, IllegalDataAddressError()) + + await _setup_binary_sensor_platform(hass, mock_config_entry) + + assert hass.states.get(ON_GRID_ENTITY) is None + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + pytest.param(3, STATE_ON, id="charging"), + pytest.param(4, STATE_OFF, id="discharging"), + pytest.param(6, STATE_OFF, id="preserving charge"), + pytest.param(0xFFFFFFFF, STATE_UNKNOWN, id="not implemented"), + ], +) +async def test_battery_charging( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, + status: int, + expected: str, +) -> None: + """The charging sensor follows the battery status, and admits ignorance. + + Home Assistant's battery-charging triggers and conditions key on this + device class, so the status enum alone would leave them out of reach. + """ + # The battery block is word-swapped, which the encoder can do itself. + words = encode_int(status, count=2, word_order="little") + for offset, word in enumerate(words): + mock_modbus_unit.holding[BATTERY_STATUS_REGISTER + offset] = word + + await _setup_binary_sensor_platform(hass, mock_config_entry) + + state = hass.states.get(CHARGING_ENTITY) + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + pytest.param(7, STATE_ON, id="fault"), + pytest.param(4, STATE_OFF, id="producing"), + pytest.param(2, STATE_OFF, id="sleeping"), + pytest.param(99, STATE_UNKNOWN, id="not a known status"), + ], +) +async def test_inverter_problem( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, + status: int, + expected: str, +) -> None: + """A faulted inverter is a problem, and an unreadable status is unknown.""" + mock_modbus_unit.holding[40107] = status + + await _setup_binary_sensor_platform(hass, mock_config_entry) + + state = hass.states.get(PROBLEM_ENTITY) + assert state is not None + assert state.state == expected diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py index 3b052d34a8498..64c452aaf289e 100644 --- a/tests/components/solaredge_modbus/test_init.py +++ b/tests/components/solaredge_modbus/test_init.py @@ -10,15 +10,28 @@ ) from modbus_connection.mock import MockModbusConnection, MockModbusUnit import pytest +from solaredged import SolarEdgeConnectionError -from homeassistant.components.solaredge_modbus.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.solaredge_modbus.const import ( + DOMAIN, + SCAN_INTERVAL, + SETTINGS_SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from .conftest import METER_SERIAL_NUMBER, SERIAL_NUMBER, async_seed_unit, tcp_data +from .conftest import ( + BATTERY_RATED_ENERGY, + BATTERY_SERIAL_BASE, + BATTERY_SERIAL_NUMBERS, + METER_SERIAL_NUMBER, + SERIAL_NUMBER, + async_seed_unit, + tcp_data, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -27,6 +40,12 @@ # An address inside the inverter's read, to make that read fail. INVERTER_REGISTER = 40069 +# The register the probe counts meters by. +METER_MODEL_REGISTER = 40188 + +# An address inside the pooled storage and export control read. +SITE_CONTROL_REGISTER = 57348 + async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: entry.add_to_hass(hass) @@ -250,6 +269,157 @@ async def test_replaced_meter_is_a_new_device( ) +async def test_batteries_are_sub_devices_of_the_inverter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Each battery is hardware of its own, hanging off the inverter.""" + await _setup(hass, mock_config_entry) + + inverter = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id + ) + assert inverter is not None + + for index, serial_number in enumerate(BATTERY_SERIAL_NUMBERS, 1): + battery = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_battery_{serial_number}"), + mock_config_entry.entry_id, + ) + assert battery is not None + assert battery.via_device_id == inverter.id + assert battery.name == f"Battery {index}" + assert battery.model_id == "SE-BAT-48V-10KWH" + assert battery.serial_number == serial_number + + +async def test_battery_that_left_the_installation_is_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A battery taken out does not linger as a device. + + The inverter refusing its block is the device saying it is gone, where + silence would only mean it did not answer this time. + """ + await _setup(hass, mock_config_entry) + + identifiers = [ + (DOMAIN, f"{SERIAL_NUMBER}_battery_{serial_number}") + for serial_number in BATTERY_SERIAL_NUMBERS + ] + assert all( + device_registry.async_get_device_by_identifier( + identifier, mock_config_entry.entry_id + ) + is not None + for identifier in identifiers + ) + + mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError()) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert all( + device_registry.async_get_device_by_identifier( + identifier, mock_config_entry.entry_id + ) + is None + for identifier in identifiers + ) + + +async def test_battery_that_did_not_answer_the_probe_is_kept( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Silence while probing is not proof that a battery is gone.""" + await _setup(hass, mock_config_entry) + + identifier = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}") + assert ( + device_registry.async_get_device_by_identifier( + identifier, mock_config_entry.entry_id + ) + is not None + ) + + mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, ModbusTimeoutError("timed out")) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device_by_identifier( + identifier, mock_config_entry.entry_id + ) + is not None + ) + + +async def test_silence_about_one_kind_does_not_shield_the_other( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A meter that is really gone goes, even when the batteries kept quiet. + + Silence about one kind of attached hardware says nothing about the other, + and holding on to everything would leave a removed meter behind for as long + as a battery is slow to answer. + """ + await _setup(hass, mock_config_entry) + + meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}") + battery = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}") + + mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, ModbusTimeoutError("timed out")) + mock_modbus_unit.fail_read(METER_MODEL_REGISTER, IllegalDataAddressError()) + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device_by_identifier( + meter, mock_config_entry.entry_id + ) + is None + ) + assert ( + device_registry.async_get_device_by_identifier( + battery, mock_config_entry.entry_id + ) + is not None + ) + + +async def test_battery_without_a_serial_number_is_known_by_its_slot( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Not every battery names itself, and then its place on the inverter does.""" + mock_modbus_unit.holding.update( + dict.fromkeys(range(BATTERY_SERIAL_BASE, BATTERY_SERIAL_BASE + 16), 0) + ) + + await _setup(hass, mock_config_entry) + + battery = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{SERIAL_NUMBER}_battery_slot_1"), mock_config_entry.entry_id + ) + assert battery is not None + assert battery.serial_number is None + + async def test_single_late_answer_is_retried( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -343,6 +513,47 @@ async def test_another_inverter_on_the_address_fails_the_refresh( assert state.state == STATE_UNAVAILABLE +async def test_silent_control_block_leaves_the_others_alone( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Storage and export controls share one read; power control has its own.""" + await _setup(hass, mock_config_entry) + + mock_modbus_unit.fail_read(SITE_CONTROL_REGISTER, ServerDeviceFailureError()) + freezer.tick(SETTINGS_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("number.solaredge_se10000h_backup_reserve") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("number.solaredge_se10000h_active_power_limit") + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_settings_failure_does_not_block_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Readings carry the entry even when the control blocks stay silent.""" + with patch( + "homeassistant.components.solaredge_modbus.SolarEdge.async_update_settings", + side_effect=SolarEdgeConnectionError("timed out"), + ): + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.states.get(POWER_ENTITY) is not None + + state = hass.states.get("number.solaredge_se10000h_backup_reserve") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + async def test_setup_retry_when_device_unresponsive( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/solaredge_modbus/test_number.py b/tests/components/solaredge_modbus/test_number.py new file mode 100644 index 0000000000000..e71bb385fff0a --- /dev/null +++ b/tests/components/solaredge_modbus/test_number.py @@ -0,0 +1,124 @@ +"""Tests for the SolarEdge Modbus number entities.""" + +from unittest.mock import patch + +from modbus_connection import ModbusTimeoutError +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +BACKUP_RESERVE_ENTITY = "number.solaredge_se10000h_backup_reserve" +BACKUP_RESERVE_REGISTER = 57352 + + +async def _setup_number_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.NUMBER] + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All number entities and their states match the snapshot.""" + await _setup_number_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_power_factor_setpoint_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Reactive power is grid-code territory and stays out of the way.""" + await _setup_number_platform(hass, mock_config_entry) + + entity_id = "number.solaredge_se10000h_power_factor_setpoint" + + assert hass.states.get(entity_id) is None + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Setting a number writes to the device and updates the state.""" + await _setup_number_platform(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: BACKUP_RESERVE_ENTITY, ATTR_VALUE: 25}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(BACKUP_RESERVE_ENTITY) + assert state is not None + assert state.state == "25.0" + + +async def test_set_value_communication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A write that fails on the wire raises a translated error.""" + await _setup_number_platform(hass, mock_config_entry) + + mock_modbus_unit.fail_write(BACKUP_RESERVE_REGISTER, ModbusTimeoutError("timeout")) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: BACKUP_RESERVE_ENTITY, ATTR_VALUE: 25}, + blocking=True, + ) + + assert excinfo.value.translation_key == "communication_error" + + +async def test_set_value_rejected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A value the device rejects raises a translated error.""" + await _setup_number_platform(hass, mock_config_entry) + + mock_modbus_unit.fail_write(BACKUP_RESERVE_REGISTER, ValueError("does not fit")) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: BACKUP_RESERVE_ENTITY, ATTR_VALUE: 25}, + blocking=True, + ) + + assert excinfo.value.translation_key == "rejected_value" diff --git a/tests/components/soma/test_config_flow.py b/tests/components/soma/test_config_flow.py index 67109e37c6db6..9c69eb4db90a5 100644 --- a/tests/components/soma/test_config_flow.py +++ b/tests/components/soma/test_config_flow.py @@ -87,8 +87,14 @@ async def test_full_flow(hass: HomeAssistant) -> None: hass.data[DOMAIN] = {} with patch.object(SomaApi, "list_devices", return_value={"result": "success"}): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"host": MOCK_HOST, "port": MOCK_PORT}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": MOCK_HOST, "port": MOCK_PORT}, ) assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/starline/test_config_flow.py b/tests/components/starline/test_config_flow.py index fd2a4f3161849..273f967725c90 100644 --- a/tests/components/starline/test_config_flow.py +++ b/tests/components/starline/test_config_flow.py @@ -87,9 +87,15 @@ async def test_step_auth_app_code_falls(hass: HomeAssistant) -> None: "https://id.starline.ru/apiV3/application/getCode/", text='{"state": 0}}' ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_app" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ config_flow.CONF_APP_ID: TEST_APP_ID, config_flow.CONF_APP_SECRET: TEST_APP_SECRET, }, @@ -110,9 +116,15 @@ async def test_step_auth_app_token_falls(hass: HomeAssistant) -> None: "https://id.starline.ru/apiV3/application/getToken/", text='{"state": 0}' ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_app" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ config_flow.CONF_APP_ID: TEST_APP_ID, config_flow.CONF_APP_SECRET: TEST_APP_SECRET, }, diff --git a/tests/components/syncthing/test_config_flow.py b/tests/components/syncthing/test_config_flow.py index b3d1c5c0f20e6..9339c13156346 100644 --- a/tests/components/syncthing/test_config_flow.py +++ b/tests/components/syncthing/test_config_flow.py @@ -36,9 +36,14 @@ async def test_flow_successful(hass: HomeAssistant) -> None: ) as mock_setup_entry, ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "user"}, - data=MOCK_ENTRY, + DOMAIN, context={"source": "user"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ENTRY ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == URL @@ -54,9 +59,14 @@ async def test_flow_already_configured( """Test the server ID is already configured.""" with patch("aiosyncthing.system.System.status", return_value={"myID": SERVER_ID}): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "user"}, - data=MOCK_ENTRY, + DOMAIN, context={"source": "user"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ENTRY ) assert result["type"] is FlowResultType.ABORT @@ -68,9 +78,14 @@ async def test_flow_invalid_auth(hass: HomeAssistant) -> None: with patch("aiosyncthing.system.System.status", side_effect=UnauthorizedError): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "user"}, - data=MOCK_ENTRY, + DOMAIN, context={"source": "user"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ENTRY ) assert result["type"] is FlowResultType.FORM @@ -82,9 +97,14 @@ async def test_flow_cannot_connect(hass: HomeAssistant) -> None: with patch("aiosyncthing.system.System.status", side_effect=Exception): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "user"}, - data=MOCK_ENTRY, + DOMAIN, context={"source": "user"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ENTRY ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 434a7ec23aa04..fb229615e5fba 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -62,9 +62,14 @@ async def test_already_configured_by_url( ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=FIXTURE_USER_INPUT ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -79,9 +84,14 @@ async def test_syncthru_not_supported( """Test we show user form on unsupported device.""" mock_syncthru.update.side_effect = SyncThruAPINotSupported result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=FIXTURE_USER_INPUT ) assert result["type"] is FlowResultType.FORM diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py index 2c47512fcb7a1..0f5bcfe07a02b 100644 --- a/tests/components/tailscale/test_binary_sensor.py +++ b/tests/components/tailscale/test_binary_sensor.py @@ -30,6 +30,40 @@ async def test_tailscale_binary_sensors( assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Client" assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE + state = hass.states.get("binary_sensor.frencks_iphone_connected_to_control") + entry = entity_registry.async_get( + "binary_sensor.frencks_iphone_connected_to_control" + ) + assert entry + assert state + assert entry.unique_id == "123456_connected_to_control" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "frencks-iphone Connected to control" + ) + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY + ) + + state = hass.states.get("binary_sensor.host_no_connectivity_connected_to_control") + entry = entity_registry.async_get( + "binary_sensor.host_no_connectivity_connected_to_control" + ) + assert entry + assert state + assert entry.unique_id == "123458_connected_to_control" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "host-no-connectivity Connected to control" + ) + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY + ) + state = hass.states.get("binary_sensor.frencks_iphone_key_expiry_disabled") entry = entity_registry.async_get( "binary_sensor.frencks_iphone_key_expiry_disabled" diff --git a/tests/components/tailscale/test_config_flow.py b/tests/components/tailscale/test_config_flow.py index 3a67f46a49629..657eb90e3d133 100644 --- a/tests/components/tailscale/test_config_flow.py +++ b/tests/components/tailscale/test_config_flow.py @@ -105,9 +105,15 @@ async def test_connection_error( mock_tailscale_config_flow.devices.side_effect = TailscaleConnectionError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_TAILNET: "homeassistant.github", CONF_API_KEY: "tskey-FAKE", }, diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py index 872e278d14504..715d2921b4fd5 100644 --- a/tests/components/tailwind/test_config_flow.py +++ b/tests/components/tailwind/test_config_flow.py @@ -72,9 +72,15 @@ async def test_user_flow_errors( mock_tailwind.status.side_effect = side_effect result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, @@ -109,9 +115,15 @@ async def test_user_flow_unsupported_firmware_version( """Test configuration flow aborts when the firmware version is not supported.""" mock_tailwind.status.side_effect = TailwindUnsupportedFirmwareVersionError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, @@ -133,9 +145,15 @@ async def test_user_flow_already_configured( assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, diff --git a/tests/components/thethingsnetwork/test_config_flow.py b/tests/components/thethingsnetwork/test_config_flow.py index 99c4a080e177a..5525fe3222140 100644 --- a/tests/components/thethingsnetwork/test_config_flow.py +++ b/tests/components/thethingsnetwork/test_config_flow.py @@ -28,10 +28,8 @@ async def test_user(hass: HomeAssistant, mock_ttnclient) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=USER_DATA, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_DATA ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == APP_ID @@ -54,17 +52,21 @@ async def test_user_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=USER_DATA, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_DATA ) assert result["type"] is FlowResultType.FORM assert base_error in result["errors"]["base"] # Recover mock_ttnclient.return_value.fetch_data.side_effect = None - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=USER_DATA, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_DATA ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -84,10 +86,8 @@ async def test_duplicate_entry( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=USER_DATA, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index 8200b0b2192ee..1751044867cd9 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -5,7 +5,6 @@ import pytest from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError -from homeassistant import config_entries from homeassistant.components.twentemilieu.const import ( CONF_HOUSE_LETTER, CONF_HOUSE_NUMBER, @@ -113,9 +112,15 @@ async def test_connection_error( mock_twentemilieu.unique_id.side_effect = TwenteMilieuConnectionError result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_POST_CODE: "1234AB", CONF_HOUSE_NUMBER: "1", CONF_HOUSE_LETTER: "A", @@ -159,9 +164,15 @@ async def test_address_already_set_up( """Test we abort if address has already been set up.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ CONF_POST_CODE: "1234AB", CONF_HOUSE_NUMBER: "1", CONF_HOUSE_LETTER: "A", diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index efc258d9be8a2..b2e1383d50930 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -138,7 +138,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -177,6 +179,7 @@ 'attributes': ReadOnlyDict({ : 'temperature', : 'Device Temperature', + : , : , }), 'context': , diff --git a/tests/components/vicare/snapshots/test_switch.ambr b/tests/components/vicare/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..fbc7cba8ff4f4 --- /dev/null +++ b/tests/components/vicare/snapshots/test_switch.ambr @@ -0,0 +1,301 @@ +# serializer version: 1 +# name: test_all_entities[switch.model0_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model0_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boost', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boost', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quickmode_forced_level_four', + 'unique_id': 'gateway0_deviceSerialViAir300F-quickmode_forced_level_four', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.model0_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'model0 Boost', + }), + 'context': , + 'entity_id': 'switch.model0_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model0_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model0_silent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Silent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silent', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quickmode_silent', + 'unique_id': 'gateway0_deviceSerialViAir300F-quickmode_silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.model0_silent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'model0 Silent', + }), + 'context': , + 'entity_id': 'switch.model0_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model1_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model1_boost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boost', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Boost', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quickmode_forced_level_four', + 'unique_id': 'gateway1_deviceId1-quickmode_forced_level_four', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.model1_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'model1 Boost', + }), + 'context': , + 'entity_id': 'switch.model1_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model1_silent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model1_silent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Silent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silent', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quickmode_silent', + 'unique_id': 'gateway1_deviceId1-quickmode_silent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.model1_silent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'model1 Silent', + }), + 'context': , + 'entity_id': 'switch.model1_silent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model2_eco-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model2_eco', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Eco', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eco', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quickmode_eco', + 'unique_id': 'gateway2_################-quickmode_eco', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.model2_eco-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'model2 Eco', + }), + 'context': , + 'entity_id': 'switch.model2_eco', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.model2_intensive-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.model2_intensive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intensive', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intensive', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'quickmode_comfort', + 'unique_id': 'gateway2_################-quickmode_comfort', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.model2_intensive-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'model2 Intensive', + }), + 'context': , + 'entity_id': 'switch.model2_intensive', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_switch.py b/tests/components/vicare/test_switch.py new file mode 100644 index 0000000000000..efdda6901e055 --- /dev/null +++ b/tests/components/vicare/test_switch.py @@ -0,0 +1,208 @@ +"""Test ViCare switch.""" + +from unittest.mock import patch + +import pytest +from PyViCare.PyViCareUtils import ( + PyViCareCommandError, + PyViCareNotSupportedFeatureError, +) +from syrupy.assertion import SnapshotAssertion + +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, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import MODULE, setup_integration +from .conftest import Fixture, MockPyViCare + +from tests.common import MockConfigEntry, snapshot_platform + +VENTILATION_FIXTURES: list[Fixture] = [ + Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), + Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), +] + + +async def setup_switch_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vicare: MockPyViCare, +) -> None: + """Set up the switch platform with the given mocked devices.""" + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=mock_vicare.as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.SWITCH]), + ): + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_switch_platform( + hass, mock_config_entry, MockPyViCare(VENTILATION_FIXTURES) + ) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_switch_created_per_available_quickmode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that only quickmodes that can be switched get an entity.""" + await setup_switch_platform( + hass, mock_config_entry, MockPyViCare(VENTILATION_FIXTURES) + ) + + assert set(hass.states.async_entity_ids(SWITCH_DOMAIN)) == { + "switch.model0_boost", + "switch.model0_silent", + "switch.model1_boost", + "switch.model1_silent", + "switch.model2_intensive", + "switch.model2_eco", + } + + +async def test_switch_state_follows_quickmode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an active quickmode is reported as on.""" + mock_vicare = MockPyViCare(VENTILATION_FIXTURES) + activate_quickmode(mock_vicare, 2, "comfort") + + await setup_switch_platform(hass, mock_config_entry, mock_vicare) + + assert hass.states.get("switch.model2_intensive").state == STATE_ON + assert hass.states.get("switch.model2_eco").state == STATE_OFF + + +async def test_turn_on_activates_quickmode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that turning the switch on activates the quickmode.""" + mock_vicare = MockPyViCare(VENTILATION_FIXTURES) + await setup_switch_platform(hass, mock_config_entry, mock_vicare) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.model2_intensive"}, + blocking=True, + ) + + device = mock_vicare.devices[2] + device.service.setProperty.assert_called_once_with( + device.accessor, "ventilation.quickmodes.comfort", "activate", {} + ) + + +async def test_turn_off_deactivates_quickmode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that turning the switch off deactivates the quickmode.""" + mock_vicare = MockPyViCare(VENTILATION_FIXTURES) + await setup_switch_platform(hass, mock_config_entry, mock_vicare) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.model0_boost"}, + blocking=True, + ) + + device = mock_vicare.devices[0] + device.service.setProperty.assert_called_once_with( + device.accessor, "ventilation.quickmodes.forcedLevelFour", "deactivate", {} + ) + + +async def test_no_switch_for_non_ventilation_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a device without ventilation does not get quickmode switches.""" + await setup_switch_platform( + hass, + mock_config_entry, + MockPyViCare([Fixture({"type:boiler"}, "vicare/Vitodens300W.json")]), + ) + + assert not hass.states.async_entity_ids(SWITCH_DOMAIN) + + +async def test_turn_on_error_is_raised( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a failed activation is not swallowed.""" + mock_vicare = MockPyViCare(VENTILATION_FIXTURES) + await setup_switch_platform(hass, mock_config_entry, mock_vicare) + mock_vicare.devices[ + 2 + ].service.setProperty.side_effect = PyViCareNotSupportedFeatureError("comfort") + + with pytest.raises(PyViCareNotSupportedFeatureError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.model2_intensive"}, + blocking=True, + ) + + +async def test_turn_on_refused_while_another_quickmode_runs( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the device refusing a second quickmode reads as a clear error.""" + mock_vicare = MockPyViCare(VENTILATION_FIXTURES) + await setup_switch_platform(hass, mock_config_entry, mock_vicare) + mock_vicare.devices[2].service.setProperty.side_effect = PyViCareCommandError( + {"statusCode": 400, "extendedPayload": "COMMAND_NOT_EXECUTABLE"} + ) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.model2_intensive"}, + blocking=True, + ) + + assert err.value.translation_key == "quickmode_not_activated" + + +def activate_quickmode(mock_vicare: MockPyViCare, device: int, quickmode: str) -> None: + """Mark a quickmode as active in the fixture data of a mocked device.""" + for feature in mock_vicare.devices[device].service._test_data["data"]: + if feature["feature"] == f"ventilation.quickmodes.{quickmode}": + feature["properties"]["active"]["value"] = True + return + pytest.fail(f"quickmode {quickmode} not found in fixture") diff --git a/tests/components/vistapool/test_init.py b/tests/components/vistapool/test_init.py index 32a6753337f23..31bd531a833df 100644 --- a/tests/components/vistapool/test_init.py +++ b/tests/components/vistapool/test_init.py @@ -6,10 +6,13 @@ from aioaquarite import AquariteError, AuthenticationError +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.vistapool.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from .conftest import MOCK_POOL_ID, MOCK_POOL_NAME @@ -18,6 +21,8 @@ _SECOND_POOL_ID = "ZYXWVU9876543210" _SECOND_POOL_NAME = "Spa" _THIRD_POOL_ID = "QQQQQQ1111111111" +_TEMPERATURE_ENTITY = "sensor.my_pool_temperature" +_LIGHT_ENTITY = "light.my_pool_light" async def test_setup_entry( @@ -312,6 +317,82 @@ async def test_apply_optimistic_creates_missing_intermediate_dicts( assert coordinator.data["existing"] == {"nested": {"key": 1}} +async def test_entities_unavailable_while_push_connection_is_down( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test entities go unavailable when the Firestore subscription drops. + + The integration has no polling interval, so without this the last + snapshot would stay on display as if it were still current. + """ + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(_TEMPERATURE_ENTITY).state != STATE_UNAVAILABLE + + call = mock_vistapool_client.subscribe_pool_resilient.call_args + on_data = call.args[1] + on_health = call.kwargs["on_health"] + + on_health(False) + await hass.async_block_till_done() + + assert hass.states.get(_TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE + + # Only an incoming snapshot proves the connection is back. + on_data({"main": {"temperature": 25}}) + await hass.async_block_till_done() + + assert hass.states.get(_TEMPERATURE_ENTITY).state == "25.0" + + +async def test_entities_stay_unavailable_on_local_updates_during_outage( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test updates that are not push snapshots do not fake availability. + + Both an optimistic write and a manual refresh set the coordinator's + success flag, so availability cannot ride on that flag alone. + """ + mock_vistapool_client.fetch_pool_data.return_value = {"light": {"status": 0}} + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert await async_setup_component(hass, "homeassistant", {}) + + on_health = mock_vistapool_client.subscribe_pool_resilient.call_args.kwargs[ + "on_health" + ] + on_health(False) + await hass.async_block_till_done() + assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE + + # An optimistic write updates coordinator data while the push is down. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _LIGHT_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE + + # So does a successful manual refresh. + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: _LIGHT_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(_LIGHT_ENTITY).state == STATE_UNAVAILABLE + + async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index e6b46bd07bec7..5fb67728f3c7e 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -141,12 +141,18 @@ async def test_already_configured( mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_DOMAIN: "HOME-Assistant.io"}, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DOMAIN: "HOME-Assistant.io"}, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index f4d3da1b571ef..e5fc5d235ffb7 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -229,7 +229,13 @@ async def test_form_submission_errors( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data=CONFIG, + ) + + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG ) assert result.get("type") is FlowResultType.FORM @@ -299,7 +305,13 @@ async def test_user_device_exists_abort( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "192.168.1.123"}, + ) + + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} ) assert result.get("type") is FlowResultType.ABORT