diff --git a/.strict-typing b/.strict-typing index ddb4f2adf2232b..8c807501387396 100644 --- a/.strict-typing +++ b/.strict-typing @@ -547,6 +547,7 @@ homeassistant.components.smhi.* homeassistant.components.smlight.* homeassistant.components.smtp.* homeassistant.components.snooz.* +homeassistant.components.solaredge_modbus.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* homeassistant.components.spaceapi.* diff --git a/CODEOWNERS b/CODEOWNERS index 1a5acdc24529eb..0c74cbd05d2364 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1742,6 +1742,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/solaredge/ @frenck @bdraco @tronikos /tests/components/solaredge/ @frenck @bdraco @tronikos /homeassistant/components/solaredge_local/ @drobtravels @scheric +/homeassistant/components/solaredge_modbus/ @frenck +/tests/components/solaredge_modbus/ @frenck /homeassistant/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solarman/ @solarmanpv diff --git a/homeassistant/brands/solaredge.json b/homeassistant/brands/solaredge.json index 90190f9c786155..1138992f1fb51b 100644 --- a/homeassistant/brands/solaredge.json +++ b/homeassistant/brands/solaredge.json @@ -1,5 +1,5 @@ { "domain": "solaredge", "name": "SolarEdge", - "integrations": ["solaredge", "solaredge_local"] + "integrations": ["solaredge", "solaredge_local", "solaredge_modbus"] } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 24a9b3e6f56c06..82be30c0503254 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -538,8 +538,9 @@ def rebuild_bluesound_group(self) -> list[str]: if self.sync_status.leader is None and self.sync_status.followers is None: return [] + # An entry that is not loaded has no runtime data to read a status from config_entries: list[BluesoundConfigEntry] = ( - self.hass.config_entries.async_entries(DOMAIN) + self.hass.config_entries.async_loaded_entries(DOMAIN) ) sync_status_list = [ x.runtime_data.coordinator.data.sync_status for x in config_entries @@ -609,8 +610,9 @@ def _entity_ids_with_sync_status(self) -> dict[str, SyncStatus]: entity_registry = er.async_get(self.hass) + # An entry that is not loaded has no runtime data to read a status from config_entries: list[BluesoundConfigEntry] = ( - self.hass.config_entries.async_entries(DOMAIN) + self.hass.config_entries.async_loaded_entries(DOMAIN) ) for config_entry in config_entries: entity_entries = er.async_entries_for_config_entry( diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index e171a77ba6d8bd..6b414988944567 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -80,11 +80,16 @@ def _update_attrs(self) -> None: @override def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" - # Guard against updates arriving after the controller has been removed + # Guard against updates arriving after what the entity reads on has gone # but before the entity has been unsubscribed from the coordinator. - if self.controller.id not in self.coordinator.data.controllers: + data = self.coordinator.data + if ( + self.controller.id not in data.controllers + or (self.zone_id is not None and self.zone_id not in data.zones) + or (self.sensor_id is not None and self.sensor_id not in data.sensors) + ): return - self.controller = self.coordinator.data.controllers[self.controller.id] + self.controller = data.controllers[self.controller.id] self._update_attrs() super()._handle_coordinator_update() diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index d14033bded36de..573bb7bd758372 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers import config_entry_oauth2_flow, llm from .application_credentials import authorization_server_context -from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .const import CONF_AUTHORIZATION_URL, CONF_SLUG, CONF_TOKEN_URL, DOMAIN from .coordinator import ModelContextProtocolCoordinator, TokenManager from .types import ModelContextProtocolConfigEntry @@ -72,11 +72,12 @@ async def async_setup_entry( coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager) await coordinator.async_config_entry_first_refresh() + api_id = f"{DOMAIN}-{entry.data.get(CONF_SLUG, entry.entry_id)}" unsub = llm.async_register_api( hass, ModelContextProtocolAPI( hass=hass, - id=f"{DOMAIN}-{entry.entry_id}", + id=api_id, name=entry.title, coordinator=coordinator, ), diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 46bc93934490ca..d0b0747494e655 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -20,11 +20,12 @@ AbstractOAuth2FlowHandler, async_get_implementations, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context from .auth import AuthenticateHeader -from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN +from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_SLUG, CONF_TOKEN_URL, DOMAIN from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -153,6 +154,7 @@ def __init__(self) -> None: self.data: dict[str, Any] = {} self.oauth_config: OAuthConfig | None = None self.auth_header: AuthenticateHeader | None = None + self.addon_name: str = "" @override async def async_step_user( @@ -189,6 +191,59 @@ async def async_step_user( description_placeholders={"example_url": EXAMPLE_URL}, ) + @override + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Handle discovery of an MCP server provided by an app.""" + url = discovery_info.config.get(CONF_URL) + try: + # An unparsable URL, such as an unmatched IPv6 bracket, raises ValueError + url = cv.url(url) + except vol.Invalid, ValueError: + _LOGGER.debug( + "Ignoring discovery from app %s with invalid URL: %s", + discovery_info.slug, + url, + ) + return self.async_abort(reason="invalid_discovery_info") + + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + self._async_abort_entries_match({CONF_URL: url}) + self.data[CONF_URL] = url + self.data[CONF_SLUG] = discovery_info.slug + self.addon_name = discovery_info.name + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the MCP server provided by an app.""" + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": self.addon_name}, + ) + + try: + info = await validate_input(self.hass, self.data) + except TimeoutConnectError: + return self.async_abort(reason="timeout_connect") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except InvalidAuth as err: + self.auth_header = err.metadata + return await self.async_step_auth_discovery() + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry(title=info["title"], data=self.data) + async def async_step_auth_discovery( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -326,12 +381,15 @@ async def token_manager() -> str: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") - # Unique id based on the application credentials OAuth Client ID if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=config_entry_data ) - await self.async_set_unique_id(config_entry_data["auth_implementation"]) + if self.unique_id is None: + # Unique id based on the application credentials OAuth Client ID. A + # discovered server keeps the Supervisor uuid instead, so that the + # entry is removed together with the app. + await self.async_set_unique_id(config_entry_data["auth_implementation"]) return self.async_create_entry( title=info["title"], data=config_entry_data, diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 2170976e084a96..1750ba4251e105 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -5,3 +5,4 @@ CONF_AUTHORIZATION_URL = "authorization_url" CONF_TOKEN_URL = "token_url" CONF_SCOPE = "scope" +CONF_SLUG = "slug" diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml index 3f3a08e46cb384..9b1e65f415b1f5 100644 --- a/homeassistant/components/mcp/quality_scale.yaml +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -58,8 +58,8 @@ rules: status: exempt comment: Integration does not have devices. diagnostics: todo - discovery-update-info: todo - discovery: todo + discovery-update-info: done + discovery: done docs-data-update: done docs-examples: done docs-known-limitations: done diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index a736b3b41da295..f4a2229fd50869 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -4,6 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_discovery_info": "Invalid discovery information received", "missing_capabilities": "The MCP server does not support a required capability (Tools)", "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", @@ -29,6 +30,10 @@ }, "title": "Choose how to authenticate with the MCP server" }, + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Model Context Protocol server provided by the app: {addon}?", + "title": "Model Context Protocol server via Home Assistant app" + }, "pick_implementation": { "data": { "implementation": "[%key:common::config_flow::data::implementation%]" diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 3e693463d25e7f..784d3149d22718 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -8,6 +8,6 @@ "requirements": [ "pymodbus==3.13.1", "modbus-connection[tmodbus]==4.10.0", - "tmodbus==0.6.1" + "tmodbus==0.6.2" ] } diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index d935c407efdb55..16af8da0c4d505 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -1,10 +1,12 @@ """Support for myStrom sensors of switches/plugs.""" -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import datetime, timedelta +import logging from typing import Any, override +from pymystrom.exceptions import MyStromConnectionError from pymystrom.pir import MyStromPir from pymystrom.switch import MyStromSwitch @@ -29,12 +31,17 @@ from .const import DOMAIN, MANUFACTURER from .models import MyStromConfigEntry +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True, kw_only=True) class MyStromSensorEntityDescription[_DeviceT](SensorEntityDescription): """Class describing mystrom sensor entities.""" value_fn: Callable[[_DeviceT], float | None] + # Only needed where nothing else on the device polls; a switch is kept + # fresh by its own entity refreshing the shared device. + update_fn: Callable[[_DeviceT], Coroutine[Any, Any, None]] | None = None SENSOR_TYPES_PIR: tuple[MyStromSensorEntityDescription[MyStromPir], ...] = ( @@ -50,6 +57,7 @@ class MyStromSensorEntityDescription[_DeviceT](SensorEntityDescription): else None ) ), + update_fn=lambda device: device.get_temperatures(), ), MyStromSensorEntityDescription( key="illuminance", @@ -61,6 +69,7 @@ class MyStromSensorEntityDescription[_DeviceT](SensorEntityDescription): float(device.intensity) if device.intensity is not None else None ) ), + update_fn=lambda device: device.get_light(), ), ) @@ -180,6 +189,20 @@ def native_value(self) -> float | None: """Return the value of the sensor.""" return self.entity_description.value_fn(self.device) + async def async_update(self) -> None: + """Get the latest reading from the device.""" + if (update_fn := self.entity_description.update_fn) is None: + return + + try: + await update_fn(self.device) + except MyStromConnectionError: + if self.available: + self._attr_available = False + _LOGGER.error("No route to myStrom device") + else: + self._attr_available = True + class MyStromSwitchUptimeSensor(MyStromSensorBase): """Representation of a MyStrom Switch uptime sensor.""" diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py index ab7a2040b89937..1c97f6dec0ca24 100644 --- a/homeassistant/components/peblar/__init__.py +++ b/homeassistant/components/peblar/__init__.py @@ -59,14 +59,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo system_information = await peblar.system_information() api = await peblar.rest_api(enable=True, access_mode=AccessMode.READ_WRITE) except PeblarConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryNotReady("Could not connect to Peblar charger") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err except PeblarAuthenticationError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from err except PeblarError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady( - "Unknown error occurred while connecting to Peblar charger" + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"error": str(err)}, ) from err # Setup the data coordinators diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index aa99c6b0d3a3ab..faaa5b05a12c85 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.6.0"], + "requirements": ["peblar==1.0.1"], "zeroconf": [{ "name": "pblr-*", "type": "_http._tcp.local." }] } diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index 4bc28c345f9586..3379ea5d0ab7d3 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -70,8 +70,16 @@ def __init__( coordinator=coordinator, description=NumberEntityDescription(key="charge_current_limit"), ) + # Not the user's own charge limit: that is the value being set here, + # so using it as the ceiling would ratchet the slider down and never + # let it back up. The charger accepts up to its hardware rating, and + # reduces anything above the installation limit configured during + # commissioning, so the lower of the two is what can actually be set. configuration = entry.runtime_data.user_configuration_coordinator.data - self._attr_native_max_value = configuration.user_defined_charge_limit_current + self._attr_native_max_value = min( + entry.runtime_data.system_information.hardware_max_current, + configuration.current_control_fixed_charge_current_limit, + ) @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py index 5f2720826254b8..37a4f491130c99 100644 --- a/homeassistant/components/peblar/select.py +++ b/homeassistant/components/peblar/select.py @@ -33,22 +33,38 @@ class PeblarSelectEntityDescription(SelectEntityDescription): """Class describing Peblar select entities.""" has_fn: Callable[[PeblarRuntimeData], bool] = lambda _: True + options_fn: Callable[[PeblarUserConfiguration], list[str]] | None = None current_fn: Callable[[PeblarUserConfiguration], str | None] select_fn: Callable[[Peblar, str], Awaitable[Any]] +def _smart_charging_options(configuration: PeblarUserConfiguration) -> list[str]: + """Return the smart charging modes this charger will accept. + + A charger without a power meter configured rejects solar charging, and + scheduled charging can be switched off during commissioning. Offering + those anyway lands the user on a mode the charger quietly ignores. + """ + solar = configuration.solar_charging_allowed + return [ + option + for option, allowed in ( + ("default", True), + ("fast_solar", solar), + ("pure_solar", solar), + ("scheduled", configuration.scheduled_charging_allowed), + ("smart_solar", solar), + ) + if allowed + ] + + DESCRIPTIONS = [ PeblarSelectEntityDescription( key="smart_charging", translation_key="smart_charging", entity_category=EntityCategory.CONFIG, - options=[ - "default", - "fast_solar", - "pure_solar", - "scheduled", - "smart_solar", - ], + options_fn=_smart_charging_options, current_fn=lambda x: x.smart_charging.value if x.smart_charging else None, select_fn=lambda x, mode: x.smart_charging(SmartChargingMode(mode)), ), @@ -118,6 +134,14 @@ class PeblarSelectEntity( entity_description: PeblarSelectEntityDescription + @property + @override + def options(self) -> list[str]: + """Return the options this charger currently accepts.""" + if (options_fn := self.entity_description.options_fn) is not None: + return options_fn(self.coordinator.data) + return super().options + @property @override def current_option(self) -> str | None: diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index bd04a8c77223a3..4dd721459e945c 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -38,6 +38,7 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=config_entry, name="binary_sensor", update_method=async_update_data, update_interval=timedelta(seconds=DEFAULT_POLLING_INTERVAL_SEC), diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 0a0a58a446c8ff..72ba1cc00de758 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -38,6 +38,7 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, _LOGGER, + config_entry=config_entry, name="switch", update_method=async_update_data, update_interval=timedelta(seconds=DEFAULT_POLLING_INTERVAL_SEC), diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py new file mode 100644 index 00000000000000..b9b93256494a0b --- /dev/null +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -0,0 +1,109 @@ +"""Support for SolarEdge inverters over Modbus. + +The inverter is a Modbus device. This integration does not own its connection: +it borrows a ``ModbusUnit`` from the ``modbus`` integration, which shares one +connection per device between everything talking to it, and hands that unit to +the ``solaredged`` library. +""" + +from typing import TYPE_CHECKING + +from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError + +from homeassistant.components.modbus import async_get_unit +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import device_registry as dr + +from .const import CONF_UNIT_ID, DOMAIN, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER +from .coordinator import ( + SolarEdgeModbusConfigEntry, + SolarEdgeModbusDataUpdateCoordinator, + SolarEdgeModbusRuntimeData, +) +from .entity import inverter_device_info +from .helpers import create_modbus_params + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry +) -> bool: + """Set up SolarEdge Modbus from a config entry.""" + serial_number = entry.unique_id + if TYPE_CHECKING: + assert serial_number is not None + + try: + unit = async_get_unit( + hass, entry, create_modbus_params(entry.data), entry.data[CONF_UNIT_ID] + ) + except HomeAssistantError as err: + # The device is already in use over different link settings, which one + # shared connection cannot honour. + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="link_settings_in_use", + translation_placeholders={"error": str(err)}, + ) from err + + try: + solaredge = await SolarEdge.async_probe(unit) + except SolarEdgeConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err + except SolarEdgeError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_solaredge_device", + ) from err + + readings = SolarEdgeModbusDataUpdateCoordinator(hass, entry, solaredge) + await readings.async_config_entry_first_refresh() + + # Identity arrives with that first read, and a poll can come back without + # it. Nothing can be checked then, so try again rather than accept the + # entry: an address or device ID can end up pointing at another inverter (a + # reused DHCP lease, a changed setting), and every identity here derives + # from the entry's serial number. + if SUBSYSTEM_COMMON in readings.data.failed: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="identity_unavailable", + ) + + # The platforms read the inverter's DID once, so without it the phase + # entities would stay missing until a reload. + if SUBSYSTEM_INVERTER in readings.data.failed: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="measurements_unavailable", + ) + + # Built once here: every entity hangs on the same device. + entry.runtime_data = SolarEdgeModbusRuntimeData( + readings=readings, device_info=inverter_device_info(solaredge, serial_number) + ) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **entry.runtime_data.device_info + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry +) -> bool: + """Unload SolarEdge Modbus config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/solaredge_modbus/config_flow.py b/homeassistant/components/solaredge_modbus/config_flow.py new file mode 100644 index 00000000000000..90606be3d17c0a --- /dev/null +++ b/homeassistant/components/solaredge_modbus/config_flow.py @@ -0,0 +1,243 @@ +"""Config flow to configure the SolarEdge Modbus integration.""" + +from collections.abc import Mapping +from typing import Any, override + +from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError +import voluptuous as vol + +from homeassistant.components.modbus import async_get_temporary_unit +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.data_entry_flow import section +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import ( + CONF_UNIT_ID, + DEFAULT_PORT, + DEFAULT_UNIT_ID, + DOMAIN, + SUBSYSTEM_COMMON, + SUBSYSTEM_INVERTER, + TYPE_TCP, +) +from .entity import inverter_name +from .helpers import create_modbus_params + +SECTION_MORE_OPTIONS = "more_options" + +STEP_USER = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, max=65535, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + # Almost every inverter answers on the factory-default device ID, so + # that setting is tucked away in a collapsed section. + vol.Required(SECTION_MORE_OPTIONS): section( + vol.Schema( + { + vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, max=247, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + } + ), + {"collapsed": True}, + ), + } +) + + +def _flatten(user_input: dict[str, Any]) -> dict[str, Any]: + """Flatten the sectioned form input into config entry data.""" + data = {CONF_TYPE: TYPE_TCP, **user_input} + data[CONF_UNIT_ID] = data.pop(SECTION_MORE_OPTIONS)[CONF_UNIT_ID] + # One connection is shared per host and port, so spelling matters. + data[CONF_HOST] = data[CONF_HOST].lower() + + return data + + +def _sectioned(data: Mapping[str, Any]) -> dict[str, Any]: + """Shape config entry data back into the sectioned form input.""" + return { + CONF_HOST: data[CONF_HOST], + CONF_PORT: data[CONF_PORT], + SECTION_MORE_OPTIONS: {CONF_UNIT_ID: data[CONF_UNIT_ID]}, + } + + +def _discovered_unit_id(discovery_info: ZeroconfServiceInfo) -> int: + """Read the Modbus device ID out of the announcement. + + SolarEdge puts it in a MODBUS_ID TXT record. Anything unusable there falls + back to the factory default, which is what the device would answer on. + """ + try: + unit_id = int(discovery_info.properties["MODBUS_ID"]) + except KeyError, TypeError, ValueError: + return DEFAULT_UNIT_ID + + if not 1 <= unit_id <= 247: + return DEFAULT_UNIT_ID + + return unit_id + + +class SolarEdgeModbusFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a SolarEdge Modbus config flow.""" + + VERSION = 1 + + _discovered: dict[str, Any] + _discovered_title: str + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle an inverter announcing itself over mDNS.""" + data = { + CONF_TYPE: TYPE_TCP, + CONF_HOST: discovery_info.host, + CONF_PORT: discovery_info.port or DEFAULT_PORT, + CONF_UNIT_ID: _discovered_unit_id(discovery_info), + } + + # The announcement carries no serial number, and every identity here + # derives from one, so the inverter has to be asked. An address is not + # an identity: the one an entry is configured with can end up hosting + # another inverter, and that one deserves to be offered. + errors, solaredge = await self._async_validate(data) + if solaredge is None: + return self.async_abort(reason=errors["base"]) + + await self.async_set_unique_id(solaredge.common.serial_number) + # Keep up with a device that moved, but leave the device ID alone: the + # user may be reaching it on one the announcement does not mention. + self._abort_if_unique_id_configured( + updates={CONF_HOST: data[CONF_HOST], CONF_PORT: data[CONF_PORT]} + ) + + self._discovered = data + self._discovered_title = inverter_name(solaredge.common.model) + self.context["title_placeholders"] = {"name": self._discovered_title} + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setting up a discovered inverter.""" + if user_input is not None: + return self.async_create_entry( + title=self._discovered_title, data=self._discovered + ) + + self._set_confirm_only() + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "name": self._discovered_title, + "host": self._discovered[CONF_HOST], + }, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask where the inverter is, then probe it.""" + errors: dict[str, str] = {} + + if user_input is not None: + data = _flatten(user_input) + errors, solaredge = await self._async_validate(data) + if solaredge is not None: + await self.async_set_unique_id(solaredge.common.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=inverter_name(solaredge.common.model), data=data + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of how the inverter is reached. + + The inverter may move to another address or device ID (a new gateway, a + changed setting), but it must stay the same inverter: the probed serial + number has to match the entry's unique ID. + """ + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + data = _flatten(user_input) + errors, solaredge = await self._async_validate(data) + + if solaredge is not None: + if solaredge.common.serial_number == entry.unique_id: + return self.async_update_reload_and_abort(entry, data_updates=data) + return self.async_abort(reason="wrong_device") + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER, user_input or _sectioned(entry.data) + ), + errors=errors, + ) + + async def _async_validate( + self, data: dict[str, Any] + ) -> tuple[dict[str, str], SolarEdge | None]: + """Probe the inverter, returning form errors and the probed device.""" + try: + async with async_get_temporary_unit( + self.hass, create_modbus_params(data), data[CONF_UNIT_ID] + ) as unit: + solaredge = await SolarEdge.async_probe(unit) + # Identity (serial number, model name) is read on the first refresh. + report = await solaredge.async_update() + except HomeAssistantError, SolarEdgeConnectionError: + # HomeAssistantError: the device is already in use over different + # link settings, which one connection cannot honour. + return {"base": "cannot_connect"}, None + except SolarEdgeError: + return {"base": "no_solaredge_device"}, None + + if solaredge.is_ev_charger: + return {"base": "ev_charger"}, None + + # Setup needs both blocks, so a partial answer here would only create + # an entry that cannot start. + if {SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER} & report.failed.keys(): + return {"base": "cannot_connect"}, None + + if not solaredge.common.serial_number: + return {"base": "no_serial_number"}, None + + return {}, solaredge diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py new file mode 100644 index 00000000000000..18f62b8ed8fc12 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/const.py @@ -0,0 +1,25 @@ +"""Constants for the SolarEdge Modbus integration.""" + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "solaredge_modbus" +LOGGER = logging.getLogger(__package__) + +CONF_UNIT_ID: Final = "unit_id" + +# How the inverter is reached is stored from the start, so that an inverter on +# something other than the network needs no migration to say so. +TYPE_TCP: Final = "tcp" + +# SolarEdge's factory defaults: Modbus TCP on port 1502, device ID 1. +DEFAULT_PORT: Final = 1502 +DEFAULT_UNIT_ID: Final = 1 + +# Sub-system names as the library reports them in an UpdateReport. +SUBSYSTEM_COMMON: Final = "common" +SUBSYSTEM_INVERTER: Final = "inverter" + +# Local Modbus is cheap to read and PV production moves fast. +SCAN_INTERVAL: Final = timedelta(seconds=10) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py new file mode 100644 index 00000000000000..2d6e7818ad6d2a --- /dev/null +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -0,0 +1,151 @@ +"""DataUpdateCoordinator for the SolarEdge Modbus integration.""" + +from dataclasses import dataclass +from typing import override + +from solaredged import SolarEdge, SolarEdgeConnectionError, UpdateReport + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL, SUBSYSTEM_COMMON + +type SolarEdgeModbusConfigEntry = ConfigEntry[SolarEdgeModbusRuntimeData] + + +def _merge(first: UpdateReport, second: UpdateReport) -> UpdateReport: + """Fold a retried poll into the one it followed. + + A sub-system that answered either attempt holds fresh values, so only the + ones that stayed silent throughout count as failed. + """ + return UpdateReport( + updated=first.updated | second.updated, + failed={ + subsystem: error + for subsystem, error in second.failed.items() + if subsystem not in first.updated + }, + ) + + +class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): + """Polls 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 + report names what refreshed, which is what entities read their availability + from. + """ + + config_entry: SolarEdgeModbusConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + solaredge: SolarEdge, + ) -> None: + """Initialize the coordinator.""" + self.solaredge = solaredge + self._silent: set[str] = set() + super().__init__( + hass, + LOGGER, + 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, + ) + + @override + async def _async_update_data(self) -> UpdateReport: + """Poll the inverter, reporting what answered.""" + report = await self._async_poll() + + # A sub-system that just fell silent gets a second chance: SolarEdge + # answers a single request late often enough that one blip should not + # blank its entities. One that has been silent a while does not, so a + # sub-system that is really gone cannot double every poll from here on. + if report.failed.keys() - self._silent: + report = await self._async_retry(report) + + # An address can move to another inverter, and its measurements are not + # this one's however the entities reading them are named. Checked on + # every poll that brought the identity along, not only at setup. + if ( + SUBSYSTEM_COMMON in report.updated + and self.solaredge.common.serial_number != self.config_entry.unique_id + ): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="wrong_inverter", + ) + + self._log_silence(report) + + return report + + async def _async_retry(self, report: UpdateReport) -> UpdateReport: + """Poll again, keeping the first attempt's report if the retry dies. + + A link that drops between the two attempts does not make values from a + second ago stale, and failing the whole refresh would blank every + sub-system that did answer. The next poll reports the dead link soon + enough. + """ + try: + retried = await self.solaredge.async_update_readings() + except SolarEdgeConnectionError as err: + LOGGER.debug( + "%s: nothing answered the retry (%s); keeping the first poll", + self.name, + err, + ) + return report + + return _merge(report, retried) + + async def _async_poll(self) -> UpdateReport: + """Poll the inverter's sub-systems, translating a dead link.""" + try: + return await self.solaredge.async_update_readings() + except SolarEdgeConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err + + def _log_silence(self, report: UpdateReport) -> None: + """Log a sub-system falling silent once, and log its return.""" + for subsystem, error in report.failed.items(): + if subsystem not in self._silent: + self._silent.add(subsystem) + LOGGER.warning( + "%s: %s did not answer this poll and kept its previous values: %s", + self.name, + subsystem, + error, + ) + + for subsystem in report.updated & self._silent: + self._silent.discard(subsystem) + LOGGER.info("%s: %s is answering again", self.name, subsystem) + + +@dataclass(kw_only=True) +class SolarEdgeModbusRuntimeData: + """Runtime data for a SolarEdge Modbus config entry.""" + + readings: SolarEdgeModbusDataUpdateCoordinator + device_info: DeviceInfo + + @property + def solaredge(self) -> SolarEdge: + """Return the polled device.""" + return self.readings.solaredge diff --git a/homeassistant/components/solaredge_modbus/entity.py b/homeassistant/components/solaredge_modbus/entity.py new file mode 100644 index 00000000000000..3c5d41e87f60c2 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/entity.py @@ -0,0 +1,89 @@ +"""Base entities for the SolarEdge Modbus integration. + +Every identity derives 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 SolarEdge + +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 .coordinator import ( + SolarEdgeModbusConfigEntry, + SolarEdgeModbusDataUpdateCoordinator, +) + + +def inverter_model(model: str | None) -> str | None: + """Return the model an inverter is sold as, without the variant code. + + SolarEdge reports a part number like "SE17K-RW0T0BNN4". Everything up to + the dash is what the thing is called in a brochure and in conversation; + the rest spells out region, connectors and options. The full string is kept + as the model ID, where a part number belongs. + """ + if not model: + return None + return model.split("-", 1)[0] + + +def inverter_name(model: str | None) -> str: + """Return a name for the inverter that reads like one.""" + if (commercial := inverter_model(model)) is None: + return "SolarEdge inverter" + return f"SolarEdge {commercial}" + + +def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo: + """Return device information for the inverter.""" + common = solaredge.common + return DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + manufacturer=common.manufacturer or "SolarEdge", + model=inverter_model(common.model), + model_id=common.model or None, + name=inverter_name(common.model), + sw_version=common.version or None, + serial_number=serial_number, + ) + + +class SolarEdgeModbusInverterEntity( + CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinator] +): + """Defines a SolarEdge Modbus entity on the inverter device.""" + + _attr_has_entity_name = True + + def __init__( + self, + *, + entry: SolarEdgeModbusConfigEntry, + description: EntityDescription, + ) -> None: + """Initialize a SolarEdge Modbus inverter entity.""" + super().__init__(coordinator=entry.runtime_data.readings) + self.entity_description = description + + serial_number = entry.unique_id + if TYPE_CHECKING: + assert serial_number is not None + self._attr_unique_id = f"{serial_number}_{description.key}" + self._attr_device_info = entry.runtime_data.device_info + + @property + @override + def available(self) -> bool: + """Return whether the inverter answered the most recent poll. + + A poll can come back partial, and an entity that reports a value from + an earlier read as if it were current is lying about the device. + """ + return ( + super().available and SUBSYSTEM_INVERTER not in self.coordinator.data.failed + ) diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py new file mode 100644 index 00000000000000..5a33382a1f9d5e --- /dev/null +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -0,0 +1,13 @@ +"""Helpers for the SolarEdge Modbus integration.""" + +from collections.abc import Mapping +from typing import Any + +from modbus_connection import ModbusTcpParams + +from homeassistant.const import CONF_HOST, CONF_PORT + + +def create_modbus_params(data: Mapping[str, Any]) -> ModbusTcpParams: + """Build the Modbus link parameters from config entry data.""" + return ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT]) diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json new file mode 100644 index 00000000000000..5477fcd56fc529 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "inverter_status": { + "default": "mdi:solar-power" + }, + "vendor_status": { + "default": "mdi:information-outline" + } + } + } +} diff --git a/homeassistant/components/solaredge_modbus/manifest.json b/homeassistant/components/solaredge_modbus/manifest.json new file mode 100644 index 00000000000000..4f0612f184b8d4 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "solaredge_modbus", + "name": "SolarEdge Modbus", + "codeowners": ["@frenck"], + "config_flow": true, + "dependencies": ["modbus"], + "documentation": "https://www.home-assistant.io/integrations/solaredge_modbus", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["modbus_connection", "solaredged", "tmodbus"], + "quality_scale": "bronze", + "requirements": ["solaredged==0.2.3"], + "zeroconf": ["_solaredge-modbus._tcp.local."] +} diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml new file mode 100644 index 00000000000000..315e541395649c --- /dev/null +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register any service actions. + docs-conditions: + status: exempt + comment: This integration provides no conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration provides no triggers. + entity-event-setup: + status: exempt + comment: | + Entities read cached state from the coordinator; they subscribe via + CoordinatorEntity and register no other event handlers. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not register any service actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: A Modbus link has no authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: done + discovery-update-info: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: A config entry is one inverter, which is one device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No repairable issues are raised. + stale-devices: + status: exempt + comment: A config entry is one inverter, so no device can go stale. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration talks Modbus, not HTTP. + strict-typing: done diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py new file mode 100644 index 00000000000000..aa34fb7b411f09 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -0,0 +1,379 @@ +"""Support for SolarEdge Modbus sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from solaredged import Inverter, InverterStatus, SunSpecDID + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfReactivePower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import LOGGER +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import SolarEdgeModbusInverterEntity + +PARALLEL_UPDATES = 0 + +# Per-phase points only carry data on split- and three-phase inverters. +_MULTI_PHASE = (SunSpecDID.SPLIT_PHASE_INVERTER, SunSpecDID.THREE_PHASE_INVERTER) + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusSensorEntityDescription(SensorEntityDescription): + """Describes a SolarEdge Modbus sensor entity.""" + + exists_fn: Callable[[Inverter], bool] = lambda _: True + value_fn: Callable[[Inverter], StateType] + + +INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription, ...] = ( + SolarEdgeModbusSensorEntityDescription( + key="ac_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda inverter: inverter.ac_power, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_energy", + 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 inverter: inverter.ac_energy, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda inverter: inverter.ac_current, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current_phase_a", + translation_key="current_phase_a", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + exists_fn=lambda inverter: inverter.did in _MULTI_PHASE, + value_fn=lambda inverter: inverter.ac_current_a, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current_phase_b", + translation_key="current_phase_b", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + exists_fn=lambda inverter: inverter.did in _MULTI_PHASE, + value_fn=lambda inverter: inverter.ac_current_b, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_current_phase_c", + translation_key="current_phase_c", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=2, + exists_fn=lambda inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER, + value_fn=lambda inverter: inverter.ac_current_c, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_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, + exists_fn=lambda inverter: inverter.did is SunSpecDID.SINGLE_PHASE_INVERTER, + value_fn=lambda inverter: inverter.ac_voltage_an, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_ab", + translation_key="voltage_phase_ab", + 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, + exists_fn=lambda inverter: inverter.did in _MULTI_PHASE, + value_fn=lambda inverter: inverter.ac_voltage_ab, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_bc", + translation_key="voltage_phase_bc", + 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, + exists_fn=lambda inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER, + value_fn=lambda inverter: inverter.ac_voltage_bc, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_ca", + translation_key="voltage_phase_ca", + 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, + exists_fn=lambda inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER, + value_fn=lambda inverter: inverter.ac_voltage_ca, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_an", + translation_key="voltage_phase_an", + 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, + exists_fn=lambda inverter: inverter.did in _MULTI_PHASE, + value_fn=lambda inverter: inverter.ac_voltage_an, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_bn", + translation_key="voltage_phase_bn", + 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, + exists_fn=lambda inverter: inverter.did in _MULTI_PHASE, + value_fn=lambda inverter: inverter.ac_voltage_bn, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_voltage_phase_cn", + translation_key="voltage_phase_cn", + 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, + exists_fn=lambda inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER, + value_fn=lambda inverter: inverter.ac_voltage_cn, + ), + SolarEdgeModbusSensorEntityDescription( + key="dc_power", + translation_key="dc_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda inverter: inverter.dc_power, + ), + 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 inverter: inverter.dc_current, + ), + 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 inverter: inverter.dc_voltage, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda inverter: inverter.ac_frequency, + ), + 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 inverter: inverter.temperature_heatsink, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda inverter: inverter.ac_va, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_reactive_power", + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda inverter: inverter.ac_var, + ), + SolarEdgeModbusSensorEntityDescription( + key="ac_power_factor", + device_class=SensorDeviceClass.POWER_FACTOR, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + value_fn=lambda inverter: inverter.ac_power_factor, + ), + SolarEdgeModbusSensorEntityDescription( + key="status", + translation_key="inverter_status", + 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 + ), + ), + SolarEdgeModbusSensorEntityDescription( + key="vendor_status", + translation_key="vendor_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda inverter: inverter.vendor_status, + ), +) + + +def _inverter_sensor( + entry: SolarEdgeModbusConfigEntry, + description: SolarEdgeModbusSensorEntityDescription, +) -> SensorEntity: + """Build an inverter sensor, monotonic where its state class asks for it.""" + if description.state_class is SensorStateClass.TOTAL_INCREASING: + return SolarEdgeModbusInverterEnergySensorEntity( + entry=entry, description=description + ) + return SolarEdgeModbusInverterSensorEntity(entry=entry, description=description) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus sensor entities based on a config entry.""" + solaredge = entry.runtime_data.solaredge + + async_add_entities( + _inverter_sensor(entry, description) + for description in INVERTER_SENSORS + if description.exists_fn(solaredge.inverter) + ) + + +class SolarEdgeModbusInverterSensorEntity(SolarEdgeModbusInverterEntity, SensorEntity): + """Defines a SolarEdge Modbus inverter sensor entity.""" + + entity_description: SolarEdgeModbusSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.solaredge.inverter) + + +class SolarEdgeModbusEnergySensorEntity(RestoreSensor): + """Keeps a lifetime-energy sensor monotonic across glitches and restarts. + + SolarEdge accumulators transiently report lower values (or zero) around + the inverter's sleep/wake transition; a single such sample fed to a + ``total_increasing`` sensor registers as a meter reset and corrupts the + long-term statistics. The highest value seen wins, and it is restored + across restarts so an overnight restart does not lose that truth. + """ + + _highest_value: float | None = None + _glitch_logged = False + + @override + async def async_added_to_hass(self) -> None: + """Restore the highest previously seen value.""" + await super().async_added_to_hass() + + data = await self.async_get_last_sensor_data() + if data is not None and isinstance(data.native_value, (int, float)): + self._highest_value = data.native_value + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value, never lower than seen before.""" + value = super().native_value + if not isinstance(value, (int, float)): + return self._highest_value + + if self._highest_value is None or value >= self._highest_value: + self._highest_value = value + self._glitch_logged = False + return value + + if not self._glitch_logged: + LOGGER.warning( + ( + "%s reported a lifetime energy of %s Wh, lower than the" + " %s Wh seen before; ignoring the lower value (a known" + " SolarEdge glitch around its sleep/wake transition)" + ), + self.entity_id, + value, + self._highest_value, + ) + self._glitch_logged = True + + return self._highest_value + + +class SolarEdgeModbusInverterEnergySensorEntity( + SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusInverterSensorEntity +): + """Defines a monotonic SolarEdge Modbus inverter energy sensor entity.""" diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json new file mode 100644 index 00000000000000..9bf17117598c52 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -0,0 +1,145 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "ev_charger": "[%key:component::solaredge_modbus::config::error::ev_charger%]", + "no_serial_number": "[%key:component::solaredge_modbus::config::error::no_serial_number%]", + "no_solaredge_device": "[%key:component::solaredge_modbus::config::error::no_solaredge_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_device": "The device at that address and device ID is a different inverter than the one this entry is set up for." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "ev_charger": "That device is a SolarEdge EV charger. It answers as an inverter, but serves no measurements over Modbus.", + "no_serial_number": "The inverter did not report a serial number, which is needed to identify it.", + "no_solaredge_device": "The device at that address and device ID does not answer as a SolarEdge inverter." + }, + "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::solaredge_modbus::config::step::user::data_description::host%]", + "port": "[%key:component::solaredge_modbus::config::step::user::data_description::port%]" + }, + "description": "Update how this inverter is reached, for example after it moved to another address or its device ID changed.", + "sections": { + "more_options": { + "data": { + "unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data::unit_id%]" + }, + "data_description": { + "unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data_description::unit_id%]" + }, + "name": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::name%]" + } + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your SolarEdge inverter. Modbus TCP has to be enabled on the inverter first, in the installer settings.", + "port": "The TCP port the inverter listens on for Modbus requests. The SolarEdge default is 1502." + }, + "description": "Connect to your SolarEdge inverter over Modbus to monitor your solar energy production locally.", + "sections": { + "more_options": { + "data": { + "unit_id": "Device ID" + }, + "data_description": { + "unit_id": "The Modbus device ID of the inverter, as configured on the inverter itself. The SolarEdge default is 1." + }, + "name": "More options" + } + } + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name} at {host}?", + "title": "Discovered SolarEdge inverter" + } + } + }, + "entity": { + "sensor": { + "current_phase_a": { + "name": "Current phase A" + }, + "current_phase_b": { + "name": "Current phase B" + }, + "current_phase_c": { + "name": "Current phase C" + }, + "dc_current": { + "name": "DC current" + }, + "dc_power": { + "name": "DC power" + }, + "dc_voltage": { + "name": "DC voltage" + }, + "inverter_status": { + "name": "Status", + "state": { + "fault": "Fault", + "off": "[%key:common::state::off%]", + "producing": "Producing", + "shutting_down": "Shutting down", + "sleeping": "Sleeping", + "standby": "[%key:common::state::standby%]", + "starting": "Starting", + "throttled": "Throttled" + } + }, + "vendor_status": { + "name": "Vendor status" + }, + "voltage_phase_ab": { + "name": "Voltage phase A-B" + }, + "voltage_phase_an": { + "name": "Voltage phase A-N" + }, + "voltage_phase_bc": { + "name": "Voltage phase B-C" + }, + "voltage_phase_bn": { + "name": "Voltage phase B-N" + }, + "voltage_phase_ca": { + "name": "Voltage phase C-A" + }, + "voltage_phase_cn": { + "name": "Voltage phase C-N" + } + } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the SolarEdge inverter: {error}" + }, + "identity_unavailable": { + "message": "The inverter did not report its identity, so it cannot be confirmed as the one this entry was set up for." + }, + "link_settings_in_use": { + "message": "The inverter cannot be set up with these link settings: {error}" + }, + "measurements_unavailable": { + "message": "The inverter did not report its measurements, so it is not yet known which sensors it offers." + }, + "no_solaredge_device": { + "message": "The configured Modbus device does not answer as a SolarEdge inverter." + }, + "wrong_inverter": { + "message": "The device at this address is a different inverter than the one this entry was set up for. Reconfigure the entry to point at the right device." + } + } +} diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 1a697a2ed9a6ba..53cc147d7879a2 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,6 +1,6 @@ """SwitchBot via API integration.""" -from asyncio import gather +from asyncio import Lock, gather from collections.abc import Awaitable, Callable import contextlib from dataclasses import dataclass, field @@ -310,9 +310,13 @@ async def async_setup_entry( ) entry.runtime_data = SwitchbotCloudData(api=api, devices=switchbot_devices) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # One at a time, so the cloud connecting cannot register a webhook next to + # the one being registered here + webhook_lock = Lock() - await _initialize_webhook(hass, entry, api, coordinators_by_id) + async def _async_initialize_webhook() -> None: + async with webhook_lock: + await _initialize_webhook(hass, entry, api, coordinators_by_id) async def _handle_cloud_connection_change( state: cloud.CloudConnectionState, @@ -324,12 +328,20 @@ async def _handle_cloud_connection_change( and re-register it with SwitchBot's cloud so push devices work. """ if state is cloud.CloudConnectionState.CLOUD_CONNECTED: - await _initialize_webhook(hass, entry, api, coordinators_by_id) + await _async_initialize_webhook() + # Listening before the first attempt, so a cloud that connects while the + # entry is still setting up is not missed entry.async_on_unload( cloud.async_listen_connection_change(hass, _handle_cloud_connection_change) ) + await _async_initialize_webhook() + + # Forwarded last, so a failure above cannot leave the platforms set up for a + # retry to set up a second time + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True @@ -445,12 +457,16 @@ async def _async_get_webhook_url( return str(entry.data[CONF_CLOUDHOOK_URL]) if cloud.async_is_connected(hass): webhook_id = entry.data[CONF_WEBHOOK_ID] - cloudhook_url = await cloud.async_get_or_create_cloudhook(hass, webhook_id) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook_url} - ) - _LOGGER.debug("Created SwitchBot Cloud cloudhook: %s", cloudhook_url) - return cloudhook_url + # The cloud can go away between being asked and being used + with contextlib.suppress(cloud.CloudNotAvailable): + cloudhook_url = await cloud.async_get_or_create_cloudhook( + hass, webhook_id + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUDHOOK_URL: cloudhook_url} + ) + _LOGGER.debug("Created SwitchBot Cloud cloudhook: %s", cloudhook_url) + return cloudhook_url return webhook.async_generate_url( hass, diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py index a26b86f6830337..5301ac6dc615de 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -137,6 +137,11 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self.coordinator.async_request_refresh() +_AIR_PURIFIER_PRESET_MODES = { + mode.value: mode.name.lower() for mode in AirPurifierModeV2 +} + + class SwitchBotAirPurifierEntity(SwitchBotCloudEntity, FanEntity): """Representation of a Switchbot air purifier.""" @@ -164,9 +169,9 @@ def _set_attributes(self) -> None: return self._attr_is_on = self.coordinator.data.get("power") == STATE_ON.upper() - mode = self.coordinator.data.get("mode") - self._attr_preset_mode = ( - AirPurifierModeV2(mode).name.lower() if mode is not None else None + # An unplugged purifier reports a mode of its own that is none of these + self._attr_preset_mode = _AIR_PURIFIER_PRESET_MODES.get( + self.coordinator.data.get("mode") ) @override diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 8d940d9e80fa46..b863f9db1761a5 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -41,15 +41,15 @@ class TuyaEventEntityDescription(EventEntityDescription): # end up being events. EVENTS: dict[DeviceCategory, tuple[TuyaEventEntityDescription, ...]] = { DeviceCategory.SP: ( + # Neither of these reports the doorbell being rung, which is what the + # doorbell device class stands for; they carry what it sent along TuyaEventEntityDescription( key=DPCode.ALARM_MESSAGE, - device_class=EventDeviceClass.DOORBELL, translation_key="doorbell_message", wrapper_class=Base64Utf8StringEventWrapper, ), TuyaEventEntityDescription( key=DPCode.DOORBELL_PIC, - device_class=EventDeviceClass.DOORBELL, translation_key="doorbell_picture", wrapper_class=Base64Utf8RawEventWrapper, ), diff --git a/homeassistant/components/uhoo/sensor.py b/homeassistant/components/uhoo/sensor.py index ed9b8298667bc5..0044d97ec1cd4b 100644 --- a/homeassistant/components/uhoo/sensor.py +++ b/homeassistant/components/uhoo/sensor.py @@ -99,7 +99,7 @@ class UhooSensorEntityDescription(SensorEntityDescription): UhooSensorEntityDescription( key=API_TVOC, translation_key="volatile_organic_compounds", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=UnitOfRatio.PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.tvoc, diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 5d5a7904944b6e..2e34b0f1910351 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.150.0"] + "requirements": ["zeroconf==0.151.1"] } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cdd6f503640f94..6630e10402be57 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -736,6 +736,7 @@ "snooz", "sofar", "solaredge", + "solaredge_modbus", "solarlog", "solarman", "solax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 01029c963ae5c5..1d08f636404027 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6833,6 +6833,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "SolarEdge Local" + }, + "solaredge_modbus": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "SolarEdge Modbus" } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 578e94bc88d228..3c034fec893e05 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -963,6 +963,11 @@ "domain": "cambridge_audio", }, ], + "_solaredge-modbus._tcp.local.": [ + { + "domain": "solaredge_modbus", + }, + ], "_solarman._tcp.local.": [ { "domain": "solarman", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7483b587d64091..811097703c6627 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -75,7 +75,7 @@ urllib3>=2.0 uv==0.12.5 webrtc-models==0.3.0 yarl==1.24.5 -zeroconf==0.150.0 +zeroconf==0.151.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/mypy.ini b/mypy.ini index 71bb6037404db1..02b32b2f882659 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5228,6 +5228,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.solaredge_modbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.solarlog.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 90ff726e9273cd..c6e75cd5bf6eb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ dependencies = [ "probatio==0.11.3", "yarl==1.24.5", "webrtc-models==0.3.0", - "zeroconf==0.150.0", + "zeroconf==0.151.1", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index dee4da7377ea27..4a3814aaf7d888 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,4 +60,4 @@ urllib3>=2.0 uv==0.12.5 webrtc-models==0.3.0 yarl==1.24.5 -zeroconf==0.150.0 +zeroconf==0.151.1 diff --git a/requirements_all.txt b/requirements_all.txt index 6bbf5eaad808de..275da687941d60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1872,7 +1872,7 @@ panasonic-viera==0.4.4 pdunehd==1.3.3 # homeassistant.components.peblar -peblar==0.6.0 +peblar==1.0.1 # homeassistant.components.peco peco==0.1.2 @@ -3113,6 +3113,9 @@ solaredge-local==0.2.3 # homeassistant.components.solaredge solaredge-web==0.3.1 +# homeassistant.components.solaredge_modbus +solaredged==0.2.3 + # homeassistant.components.solarlog solarlog_cli==0.7.1 @@ -3251,7 +3254,7 @@ tilt-pi==0.2.1 tmb==0.0.4 # homeassistant.components.modbus -tmodbus==0.6.1 +tmodbus==0.6.2 # homeassistant.components.todoist todoist-api-python==3.1.0 @@ -3516,7 +3519,7 @@ zamg==0.4.1 zcc-helper==3.8 # homeassistant.components.zeroconf -zeroconf==0.150.0 +zeroconf==0.151.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index ab3efe8dfb21ea..6d1fd602480dee 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -28,12 +28,15 @@ SERVICE_VOLUME_UP, MediaPlayerState, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from .conftest import PlayerMocks +from tests.common import MockConfigEntry + @pytest.mark.parametrize( ("service", "method"), @@ -362,6 +365,34 @@ async def test_attr_bluesound_group( assert attr_bluesound_group == ["player-name1111", "player-name2222"] +async def test_attr_bluesound_group_skips_an_entry_that_is_not_loaded( + hass: HomeAssistant, + setup_config_entry: None, + config_entry_secondary: MockConfigEntry, + player_mocks: PlayerMocks, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test grouping passes over a player whose entry never loaded. + + Such an entry carries no runtime data to read a sync status from. + """ + config_entry_secondary.add_to_hass(hass) + assert config_entry_secondary.state is ConfigEntryState.NOT_LOADED + + updated_sync_status = dataclasses.replace( + player_mocks.player_data.sync_status_long_polling_mock.get(), + followers=[PairedPlayer("2.2.2.2", 11000)], + ) + player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status) + + # give the long polling loop a chance to update the + # state; this could be any async call + await hass.async_block_till_done() + + assert "runtime_data" not in caplog.text + assert hass.states.get("media_player.player_name1111") is not None + + async def test_attr_bluesound_group_for_follower( hass: HomeAssistant, setup_config_entry: None, diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 15d24a53bce104..6a69e1fad42087 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -6,6 +6,7 @@ from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory from pydrawise.schema import Controller, User, Zone +import pytest from homeassistant.components.hydrawise.const import DOMAIN, MAIN_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState @@ -161,3 +162,34 @@ async def test_auto_remove_devices( device_registry, mock_added_config_entry.entry_id ) assert len(all_devices) == 0 + + +async def test_zones_of_one_controller_go_missing( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + zones: list[Zone], + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a controller answering without its zones does not crash the update. + + The controller is still there, so the entities of its zones are still + subscribed when the refresh that drops them arrives. + """ + assert hass.states.get("binary_sensor.zone_one_watering") is not None + + mock_pydrawise.get_zones.return_value = [] + + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_pydrawise.get_zones.return_value = zones + + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("binary_sensor.zone_one_watering") is not None + assert "KeyError" not in caplog.text diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 1b2b6c53cb8505..17303fe0bf7a0a 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, CONF_SCOPE, + CONF_SLUG, CONF_TOKEN_URL, DOMAIN, ) @@ -20,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .conftest import ( AUTH_DOMAIN, @@ -68,6 +70,13 @@ CALLBACK_PATH = "/auth/external/callback" OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" OAUTH_CODE = "abcd" +ADDON_NAME = "Example MCP Server" +ADDON_DISCOVERY_INFO = HassioServiceInfo( + config={"addon": ADDON_NAME, CONF_URL: MCP_SERVER_URL}, + name=ADDON_NAME, + slug="example_mcp_server", + uuid="1234", +) OAUTH_TOKEN_PAYLOAD = { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -1095,3 +1104,245 @@ async def test_reauth_flow_missing_implementation( assert config_entry.data["auth_implementation"] == AUTH_DOMAIN assert config_entry.data[CONF_TOKEN] assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_hassio_discovery_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test the discovery flow for an MCP server provided by an app.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": ADDON_NAME} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: MCP_SERVER_URL, + CONF_SLUG: ADDON_DISCOVERY_INFO.slug, + } + # The discovery uuid lets Supervisor remove the entry with the app + assert result["result"] + assert result["result"].unique_id == ADDON_DISCOVERY_INFO.uuid + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="missing_url"), + pytest.param({CONF_URL: "not a url"}, id="invalid_url"), + pytest.param({CONF_URL: "http://[::1/mcp"}, id="unparsable_url"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_invalid_url( + hass: HomeAssistant, config: dict[str, Any] +) -> None: + """Test an app that sends discovery info without a usable URL.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=HassioServiceInfo( + config=config, + name=ADDON_NAME, + slug="example_mcp_server", + uuid="1234", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +@pytest.mark.parametrize( + "entry_url", + [ + pytest.param("http://1.1.1.1:9999/mcp", id="app_moved"), + pytest.param(MCP_SERVER_URL, id="app_restarted"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_updates_url( + hass: HomeAssistant, entry_url: str +) -> None: + """Test discovery of an already configured app keeps its entry up to date.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ADDON_DISCOVERY_INFO.uuid, + data={CONF_URL: entry_url}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data == {CONF_URL: MCP_SERVER_URL} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_already_configured(hass: HomeAssistant) -> None: + """Test the discovered MCP server is already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_reason"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_mcp_client_error( + hass: HomeAssistant, + mock_mcp_client: Mock, + side_effect: Exception, + expected_reason: str, +) -> None: + """Test the discovered MCP server cannot be reached.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + mock_mcp_client.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_missing_capabilities( + hass: HomeAssistant, mock_mcp_client: Mock +) -> None: + """Test the discovered MCP server does not support tools.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" + + +@respx.mock +@pytest.mark.usefixtures("mock_setup_entry") +async def test_hassio_discovery_requires_authentication( + hass: HomeAssistant, mock_mcp_client: Mock +) -> None: + """Test the discovered MCP server continues into the OAuth flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # The user is taken to the application credentials UI to enter credentials. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_credentials" + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_hassio_discovery_authentication_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test an OAuth flow for a discovered MCP server keeps the discovery uuid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=ADDON_DISCOVERY_INFO, + ) + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "pick_implementation"}, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + scopes=SCOPES, + ) + + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"] + assert result["result"].unique_id == ADDON_DISCOVERY_INFO.uuid + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 845e33d99c89a5..745d47870f9254 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -10,8 +10,9 @@ import pytest import voluptuous as vol -from homeassistant.components.mcp.const import DOMAIN +from homeassistant.components.mcp.const import CONF_SLUG, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -734,3 +735,40 @@ async def test_sse_client_does_not_build_ssl_context( assert not mock_load_certs.called await client.aclose() + + +async def test_llm_api_id(hass: HomeAssistant, mock_mcp_client: Mock) -> None: + """Test the LLM API id of a discovered server survives a reinstall of the app.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL], + ) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/mcp", CONF_SLUG: "a0d7b954_mcp"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api.id == "mcp-a0d7b954_mcp" + + await hass.config_entries.async_remove(config_entry.entry_id) + + # Reinstalling the app discovers the server again as a new config entry + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/mcp", CONF_SLUG: "a0d7b954_mcp"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api.id == "mcp-a0d7b954_mcp" diff --git a/tests/components/mystrom/test_sensor.py b/tests/components/mystrom/test_sensor.py new file mode 100644 index 00000000000000..f165b1c78dccec --- /dev/null +++ b/tests/components/mystrom/test_sensor.py @@ -0,0 +1,39 @@ +"""Test the myStrom sensors.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.core import HomeAssistant + +from .test_init import init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_pir_sensors_are_polled( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the motion sensor readings are refreshed while polling.""" + await init_integration(hass, config_entry, 110) + + device = config_entry.runtime_data.device + assert hass.states.get("sensor.mystrom_device_temperature").state == "24.87" + assert hass.states.get("sensor.mystrom_device_illuminance").state == "16.0" + + # The mock only reports readings once they have been fetched, and nothing + # else talks to a motion sensor, so this stays cleared unless the sensors + # fetch them themselves. + device._requested_state = False + device._state["temperature_compensated"] = 21.5 + device._state["intensity"] = 42 + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device._requested_state is True + assert hass.states.get("sensor.mystrom_device_temperature").state == "21.5" + assert hass.states.get("sensor.mystrom_device_illuminance").state == "42.0" diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index bba7d46ab0ea3e..e8fe5fad7f446d 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -47,14 +47,22 @@ def mock_setup_entry() -> Generator[None]: def mock_peblar(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return a mocked Peblar client. - Parametrize indirectly with a dict to override single system - information fields, so a test that cares about one hardware flag does - not need a full copy of the fixture. + Parametrize indirectly with a dict to override single fixture fields, + so a test that cares about one flag or one limit does not need a full + copy of the fixture. Keys are looked up in both the system information + and the user configuration. """ - system_information = { - **json.loads(load_fixture("system_information.json", DOMAIN)), - **getattr(request, "param", {}), - } + overrides = getattr(request, "param", {}) + system_information = json.loads(load_fixture("system_information.json", DOMAIN)) + user_configuration = json.loads(load_fixture("user_configuration.json", DOMAIN)) + for key, value in overrides.items(): + if key in system_information: + system_information[key] = value + elif key in user_configuration: + user_configuration[key] = value + else: + msg = f"Unknown fixture field: {key}" + raise ValueError(msg) with ( patch("homeassistant.components.peblar.Peblar", autospec=True) as peblar_mock, patch("homeassistant.components.peblar.config_flow.Peblar", new=peblar_mock), @@ -66,8 +74,8 @@ def mock_peblar(request: pytest.FixtureRequest) -> Generator[MagicMock]: peblar.current_versions.return_value = PeblarVersions.from_json( load_fixture("current_versions.json", DOMAIN) ) - peblar.user_configuration.return_value = PeblarUserConfiguration.from_json( - load_fixture("user_configuration.json", DOMAIN) + peblar.user_configuration.return_value = PeblarUserConfiguration.from_dict( + user_configuration ) peblar.system_information.return_value = PeblarSystemInformation.from_dict( system_information diff --git a/tests/components/peblar/test_init.py b/tests/components/peblar/test_init.py index acbfefa4f35fec..980c9ad1ba709c 100644 --- a/tests/components/peblar/test_init.py +++ b/tests/components/peblar/test_init.py @@ -35,16 +35,35 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize( - "exception", - [PeblarConnectionError, PeblarError], + ("exception", "translation_key", "reason"), + [ + ( + PeblarConnectionError("Could not connect"), + "communication_error", + "An error occurred while communicating with the Peblar EV charger: " + "Could not connect", + ), + ( + PeblarError("Unknown error"), + "unknown_error", + "An unknown error occurred while communicating with the Peblar EV " + "charger: Unknown error", + ), + ], ) async def test_config_entry_not_ready( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_peblar: MagicMock, exception: Exception, + translation_key: str, + reason: str, ) -> None: - """Test the Peblar configuration entry not ready.""" + """Test the Peblar configuration entry not ready. + + The reason reaches the user, so it comes from strings.json rather than + from a sentence typed into the raise. + """ mock_peblar.login.side_effect = exception mock_config_entry.add_to_hass(hass) @@ -53,6 +72,8 @@ async def test_config_entry_not_ready( assert len(mock_peblar.login.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == translation_key + assert mock_config_entry.reason == reason async def test_config_entry_authentication_failed( @@ -69,6 +90,7 @@ async def test_config_entry_authentication_failed( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.error_reason_translation_key == "authentication_error" flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py index b8b82af12002a8..af4697274cf104 100644 --- a/tests/components/peblar/test_number.py +++ b/tests/components/peblar/test_number.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( + ATTR_MAX, ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -265,3 +266,55 @@ async def test_restore_state( # Check if state is restored and value is set correctly assert (state := hass.states.get("number.peblar_ev_charger_charge_limit")) assert state.state == expected_state + + +@pytest.mark.parametrize( + ("mock_peblar", "expected_max"), + [ + ({"UserDefinedChargeLimitCurrent": 10}, 16), + ({"CurrentCtrlFixedChargeCurrentLimit": 10}, 10), + ({"HwMaxCurrent": 10}, 10), + ], + ids=["user limit is not a ceiling", "installation limit", "hardware rating"], + indirect=["mock_peblar"], +) +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") +async def test_charge_limit_maximum( + hass: HomeAssistant, + expected_max: int, +) -> None: + """Test the ceiling on the charge limit. + + The charger accepts up to its hardware rating and reduces anything + above the installation limit. The user's own charge limit is the value + being set here, so it must not narrow the range it is chosen from. + """ + state = hass.states.get("number.peblar_ev_charger_charge_limit") + assert state + assert state.attributes[ATTR_MAX] == expected_max + + +@pytest.mark.parametrize( + "mock_peblar", + [{"UserDefinedChargeLimitCurrent": 10}], + indirect=True, +) +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") +async def test_charge_limit_can_be_raised_again( + hass: HomeAssistant, + mock_peblar: MagicMock, +) -> None: + """A charger left on a low limit can still be turned back up.""" + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.peblar_ev_charger_charge_limit", ATTR_VALUE: 16}, + blocking=True, + ) + + mocked_method.assert_any_call(charge_current_limit=16000) diff --git a/tests/components/peblar/test_select.py b/tests/components/peblar/test_select.py index 6c7ef209347a21..8c3d8b0459740b 100644 --- a/tests/components/peblar/test_select.py +++ b/tests/components/peblar/test_select.py @@ -17,6 +17,7 @@ from homeassistant.components.peblar.const import DOMAIN from homeassistant.components.select import ( ATTR_OPTION, + ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) @@ -250,3 +251,38 @@ async def test_hw_entity_absent_when_hw_flag_false( ) is None ) + + +@pytest.mark.parametrize( + ("mock_peblar", "expected_options"), + [ + ( + {"SolarChargingAllowed": False}, + ["default", "scheduled"], + ), + ( + {"ScheduledChargingAllowed": False}, + ["default", "fast_solar", "pure_solar", "smart_solar"], + ), + ( + {"SolarChargingAllowed": False, "ScheduledChargingAllowed": False}, + ["default"], + ), + ], + ids=["no solar", "no scheduled", "neither"], + indirect=["mock_peblar"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_smart_charging_options_follow_the_charger( + hass: HomeAssistant, + expected_options: list[str], +) -> None: + """Only offer the smart charging modes the charger accepts. + + A charger without a power meter rejects solar charging, and the web + interface hides those modes. Offering them anyway lets the user pick + something the charger quietly ignores. + """ + state = hass.states.get("select.peblar_ev_charger_smart_charging") + assert state + assert state.attributes[ATTR_OPTIONS] == expected_options diff --git a/tests/components/solaredge_modbus/__init__.py b/tests/components/solaredge_modbus/__init__.py new file mode 100644 index 00000000000000..f081742b54a431 --- /dev/null +++ b/tests/components/solaredge_modbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the SolarEdge Modbus integration.""" diff --git a/tests/components/solaredge_modbus/conftest.py b/tests/components/solaredge_modbus/conftest.py new file mode 100644 index 00000000000000..998fe382ba1e09 --- /dev/null +++ b/tests/components/solaredge_modbus/conftest.py @@ -0,0 +1,116 @@ +"""Fixtures for the SolarEdge Modbus tests. + +The ``mock_modbus_connection`` / ``mock_modbus_unit`` fixtures come from the +``modbus-connection`` library's pytest plugin (registered as a ``pytest11`` +entry point). Seeding the unit's holding store with a captured register dump +drives the real ``solaredged`` library exactly as a device would. +""" + +from collections.abc import AsyncIterator, Generator +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import patch + +from modbus_connection import ModbusUnit +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.solaredge_modbus.const import ( + CONF_UNIT_ID, + DOMAIN, + TYPE_TCP, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_load_json_object_fixture + +HOST = "1.2.3.4" +PORT = 1502 +UNIT_ID = 1 +SERIAL_NUMBER = "7E123ABC" + + +def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]: + """Config entry data for an inverter reached over Modbus TCP.""" + return { + CONF_TYPE: TYPE_TCP, + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_UNIT_ID: unit_id, + } + + +async def async_seed_unit( + hass: HomeAssistant, unit: MockModbusUnit, serial_registers: list[int] | None = None +) -> None: + """Seed a mock unit with the captured SE10000H register dump. + + The capture predates several of the points this integration reads, so those + registers carry hand-picked values instead: distinct per point, and + consistent with what the device did report (phase values sum to the + recorded totals, apparent power exceeds real power). Pass + ``serial_registers`` to override the inverter serial number ("7E123ABC" as + captured). + """ + registers = (await async_load_json_object_fixture(hass, "se10000h.json", DOMAIN))[ + "holding" + ] + unit.holding.update({int(address): value for address, value in registers.items()}) + + if serial_registers is not None: + unit.holding.update( + dict(zip(range(40052, 40056), serial_registers, strict=True)) + ) + + +@pytest.fixture +async def mock_modbus_unit( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> MockModbusUnit: + """A seeded SolarEdge inverter on unit ``UNIT_ID``. + + Overrides the library plugin's ``mock_modbus_unit`` to preload a captured + register dump of an SE10000H. + """ + unit = mock_modbus_connection.for_unit(UNIT_ID) + await async_seed_unit(hass, unit) + return unit + + +@pytest.fixture(autouse=True) +def mock_shared_connection( + mock_modbus_connection: MockModbusConnection, mock_modbus_unit: MockModbusUnit +) -> Generator[None]: + """Hand out units on the seeded mock instead of opening a real connection.""" + + @asynccontextmanager + async def async_temporary_unit( + hass: HomeAssistant, params: Any, unit_id: int + ) -> AsyncIterator[ModbusUnit]: + yield mock_modbus_connection.for_unit(unit_id) + + with ( + patch( + "homeassistant.components.solaredge_modbus.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: ( + mock_modbus_connection.for_unit(unit_id) + ), + ), + patch( + "homeassistant.components.solaredge_modbus.config_flow.async_get_temporary_unit", + async_temporary_unit, + ), + ): + yield + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """A SolarEdge Modbus config entry for the seeded inverter.""" + return MockConfigEntry( + domain=DOMAIN, + title="SolarEdge SE10000H", + unique_id=SERIAL_NUMBER, + data=tcp_data(), + ) diff --git a/tests/components/solaredge_modbus/fixtures/se10000h.json b/tests/components/solaredge_modbus/fixtures/se10000h.json new file mode 100644 index 00000000000000..604743a5f8bd0f --- /dev/null +++ b/tests/components/solaredge_modbus/fixtures/se10000h.json @@ -0,0 +1,175 @@ +{ + "holding": { + "40000": 21365, + "40001": 28243, + "40004": 21359, + "40005": 27745, + "40006": 29253, + "40007": 25703, + "40008": 25856, + "40009": 0, + "40010": 0, + "40011": 0, + "40012": 0, + "40013": 0, + "40014": 0, + "40015": 0, + "40016": 0, + "40017": 0, + "40018": 0, + "40019": 0, + "40020": 21317, + "40021": 12592, + "40022": 12336, + "40023": 12360, + "40024": 11585, + "40025": 21843, + "40026": 20034, + "40027": 16984, + "40028": 12596, + "40029": 0, + "40030": 0, + "40031": 0, + "40032": 0, + "40033": 0, + "40034": 0, + "40035": 0, + "40052": 14149, + "40053": 12594, + "40054": 13121, + "40055": 16963, + "40069": 101, + "40071": 3999, + "40075": 65534, + "40076": 2502, + "40079": 2502, + "40082": 65535, + "40083": 9490, + "40084": 0, + "40085": 50037, + "40086": 65533, + "40087": 9600, + "40088": 0, + "40089": 1449, + "40090": 0, + "40091": 98, + "40092": 0, + "40093": 286, + "40094": 42356, + "40095": 0, + "40100": 9635, + "40101": 0, + "40103": 4767, + "40106": 65534, + "40107": 4, + "40108": 4, + "40188": 203, + "40190": 3008, + "40191": 1000, + "40192": 1002, + "40193": 1006, + "40194": 65534, + "40195": 232, + "40196": 231, + "40197": 232, + "40198": 233, + "40200": 400, + "40201": 401, + "40202": 402, + "40203": 0, + "40204": 5002, + "40205": 65534, + "40206": 5279, + "40207": 1750, + "40208": 1760, + "40209": 1769, + "40210": 0, + "40211": 5350, + "40215": 0, + "40216": 869, + "40220": 0, + "40221": 98, + "40225": 0, + "40226": 3547, + "40227": 6208, + "40228": 1181, + "40229": 1984, + "40230": 1182, + "40231": 36448, + "40232": 1183, + "40233": 33312, + "40234": 18799, + "40235": 44236, + "40236": 6265, + "40237": 16960, + "40238": 6266, + "40239": 51424, + "40240": 6267, + "40241": 41388, + "40242": 65534, + "57348": 1, + "57349": 0, + "57350": 0, + "57351": 0, + "57352": 0, + "57353": 16384, + "57354": 0, + "57355": 3600, + "57356": 0, + "57357": 65535, + "57358": 8192, + "57359": 17970, + "57360": 8192, + "57361": 17970, + "57666": 36864, + "57667": 17943, + "57668": 16384, + "57669": 17820, + "57670": 24576, + "57671": 17823, + "57672": 32768, + "57673": 17851, + "57674": 40960, + "57675": 17854, + "57708": 57046, + "57709": 16828, + "57710": 0, + "57711": 16836, + "57712": 60513, + "57713": 17353, + "57714": 0, + "57715": 32768, + "57716": 0, + "57717": 0, + "57726": 36864, + "57727": 17943, + "57728": 46858, + "57729": 17941, + "57730": 0, + "57731": 17096, + "57732": 58696, + "57733": 17095, + "57734": 6, + "57735": 0, + "57922": 36864, + "57923": 17943, + "57964": 58463, + "57965": 16830, + "57968": 26311, + "57969": 17354, + "57970": 0, + "57971": 32768, + "57972": 0, + "57973": 0, + "57982": 36864, + "57983": 17943, + "57984": 43581, + "57985": 17943, + "57986": 0, + "57987": 17096, + "57988": 28445, + "57989": 17094, + "57990": 6, + "57991": 0 + } +} diff --git a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..d0055341ecf0da --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr @@ -0,0 +1,822 @@ +# serializer version: 1 +# name: test_sensors[sensor.solaredge_se10000h_apparent_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.solaredge_se10000h_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'apparent_power', + : 'SolarEdge SE10000H Apparent power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9600', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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': None, + 'entity_id': 'sensor.solaredge_se10000h_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'SolarEdge SE10000H Current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.99', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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.solaredge_se10000h_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_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'SolarEdge SE10000H DC current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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': , + 'entity_id': 'sensor.solaredge_se10000h_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_dc_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H DC power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_dc_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9635', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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.solaredge_se10000h_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_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'SolarEdge SE10000H DC voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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.solaredge_se10000h_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'SolarEdge SE10000H Energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18785.652', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_frequency-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.solaredge_se10000h_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'SolarEdge SE10000H Frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.037', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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.solaredge_se10000h_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9490', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power_factor-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.solaredge_se10000h_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power factor', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power_factor', + : 'SolarEdge SE10000H Power factor', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_reactive_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.solaredge_se10000h_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_reactive_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'SolarEdge SE10000H Reactive power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1449', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'sleeping', + 'starting', + 'producing', + 'throttled', + 'shutting_down', + 'fault', + 'standby', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_se10000h_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': 'inverter_status', + 'unique_id': '7E123ABC_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'SolarEdge SE10000H Status', + : list([ + 'off', + 'sleeping', + 'starting', + 'producing', + 'throttled', + 'shutting_down', + 'fault', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'producing', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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.solaredge_se10000h_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_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'SolarEdge SE10000H Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.67', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_vendor_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.solaredge_se10000h_vendor_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vendor status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vendor status', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vendor_status', + 'unique_id': '7E123ABC_vendor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_vendor_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Vendor status', + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_vendor_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_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.solaredge_se10000h_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'SolarEdge SE10000H Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.2', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_config_flow.py b/tests/components/solaredge_modbus/test_config_flow.py new file mode 100644 index 00000000000000..a8dbcd3325b43c --- /dev/null +++ b/tests/components/solaredge_modbus/test_config_flow.py @@ -0,0 +1,461 @@ +"""Tests for the SolarEdge Modbus config flow.""" + +from ipaddress import ip_address +from typing import Any + +from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.solaredge_modbus.config_flow import SECTION_MORE_OPTIONS +from homeassistant.components.solaredge_modbus.const import ( + CONF_UNIT_ID, + DEFAULT_UNIT_ID, + DOMAIN, + TYPE_TCP, +) +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .conftest import HOST, PORT, SERIAL_NUMBER, UNIT_ID, async_seed_unit, tcp_data + +from tests.common import MockConfigEntry + +TITLE = "SolarEdge SE10000H" + +# An inverter announcing itself, as captured from a real one. +DISCOVERY_HOST = "10.148.42.116" +DISCOVERY_NAME = "solaredgeinv-7E1DBB39" + + +def _discovery( + host: str = DISCOVERY_HOST, + properties: dict[str, Any] | None = None, +) -> ZeroconfServiceInfo: + """An mDNS announcement from a SolarEdge inverter.""" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=PORT, + hostname=f"{DISCOVERY_NAME}.local.", + type="_solaredge-modbus._tcp.local.", + name=f"{DISCOVERY_NAME}._solaredge-modbus._tcp.local.", + properties={"MODBUS_ID": "1"} if properties is None else properties, + ) + + +# The serial number of a second, different inverter: "OTHER123". +OTHER_SERIAL_REGISTERS = [20308, 18501, 21041, 12851] + + +def _user_input(unit_id: int = UNIT_ID) -> dict[str, Any]: + """Form input for the user step, with the sectioned device ID.""" + return { + CONF_HOST: HOST, + CONF_PORT: PORT, + SECTION_MORE_OPTIONS: {CONF_UNIT_ID: unit_id}, + } + + +def _model_registers(model: str) -> dict[int, int]: + """Registers holding a model name in the SunSpec common block.""" + padded = model.ljust(32, "\0").encode() + return { + 40020 + index: (padded[index * 2] << 8) | padded[index * 2 + 1] + for index in range(16) + } + + +async def test_user_flow_tcp(hass: HomeAssistant) -> None: + """An inverter on the network is probed and its entry created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE # read from the device + assert result["data"] == tcp_data() + assert result["result"].unique_id == SERIAL_NUMBER # the inverter serial + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, mock_modbus_unit: MockModbusUnit +) -> None: + """An unresponsive device surfaces cannot_connect, then the flow recovers.""" + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # The device answers again. + mock_modbus_unit.fail_read(40000, None) + + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + + +async def test_user_flow_partial_answer( + hass: HomeAssistant, mock_modbus_unit: MockModbusUnit +) -> None: + """An inverter that answers in part is not accepted, then the flow recovers. + + Setting up needs the inverter block as much as the identity block: what + entities the entry gets is decided from it. Accepting the form here would + hand the user an entry that setup can only retry. + """ + mock_modbus_unit.fail_read(40069, ServerDeviceFailureError()) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # The inverter answers for its measurements again. + mock_modbus_unit.fail_read(40069, None) + + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + + +async def test_user_flow_no_solaredge_device( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """A Modbus device without a SunSpec header surfaces no_solaredge_device.""" + # A device that answers reads but is not a SolarEdge inverter. + unit = mock_modbus_connection.for_unit(2) + unit.holding.update(dict.fromkeys(range(40000, 40004), 0)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + result = await hass.config_entries.flow.async_configure(flow_id, _user_input(2)) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_solaredge_device"} + + +async def test_user_flow_no_serial_number( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """An inverter without a serial number cannot be identified and is rejected.""" + # A valid inverter image, but with the serial-number registers zeroed out. + unit = mock_modbus_connection.for_unit(3) + await async_seed_unit(hass, unit) + unit.holding.update(dict.fromkeys(range(40052, 40068), 0)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + result = await hass.config_entries.flow.async_configure(flow_id, _user_input(3)) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_serial_number"} + + +async def test_user_flow_ev_charger( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """A SolarEdge EV charger answers as an inverter, but is rejected.""" + unit = mock_modbus_connection.for_unit(4) + await async_seed_unit(hass, unit) + unit.holding.update(_model_registers("SE-EV-SA-KIT")) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + result = await hass.config_entries.flow.async_configure(flow_id, _user_input(4)) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "ev_charger"} + + +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Setting up the same inverter twice aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + result = await hass.config_entries.flow.async_configure(flow_id, _user_input()) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """The inverter can be reconfigured to a new device ID.""" + mock_config_entry.add_to_hass(hass) + + # The same inverter, now answering on device ID 2. + await async_seed_unit(hass, mock_modbus_connection.for_unit(2)) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _user_input(2) + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_UNIT_ID] == 2 + + +async def test_reconfigure_flow_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Reconfiguring onto a different inverter is rejected.""" + mock_config_entry.add_to_hass(hass) + + await async_seed_unit( + hass, + mock_modbus_connection.for_unit(2), + serial_registers=OTHER_SERIAL_REGISTERS, + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _user_input(2) + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + assert mock_config_entry.data[CONF_UNIT_ID] == UNIT_ID + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A reconfigure attempt surfaces cannot_connect, then recovers.""" + mock_config_entry.add_to_hass(hass) + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _user_input() + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # The device answers again. + mock_modbus_unit.fail_read(40000, None) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], _user_input() + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: + """An announced inverter is probed, confirmed and set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery() + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["description_placeholders"] == { + "name": TITLE, + "host": DISCOVERY_HOST, + } + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TITLE + assert result["data"] == { + CONF_TYPE: TYPE_TCP, + CONF_HOST: DISCOVERY_HOST, + CONF_PORT: PORT, + CONF_UNIT_ID: UNIT_ID, + } + assert result["result"].unique_id == SERIAL_NUMBER + + +async def test_zeroconf_uses_the_announced_device_id( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """The announcement's MODBUS_ID says which device to talk to.""" + await async_seed_unit(hass, mock_modbus_connection.for_unit(2)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery(properties={"MODBUS_ID": "2"}), + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_UNIT_ID] == 2 + + +@pytest.mark.parametrize( + "properties", + [ + pytest.param({}, id="absent"), + pytest.param({"MODBUS_ID": "0"}, id="out of range"), + pytest.param({"MODBUS_ID": "solaredge"}, id="not a number"), + ], +) +async def test_zeroconf_falls_back_to_the_default_device_id( + hass: HomeAssistant, properties: dict[str, Any] +) -> None: + """An announcement without a usable device ID gets the factory default.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery(properties=properties), + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_UNIT_ID] == DEFAULT_UNIT_ID + + +async def test_zeroconf_known_inverter_that_moved_is_followed( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """An inverter announcing itself from a new address updates the entry. + + The device ID is left alone: the entry may be reaching the inverter on one + that the announcement does not mention, which is why the entry here is set + up on a different device ID than the inverter announces. + """ + entry = MockConfigEntry( + domain=DOMAIN, title=TITLE, unique_id=SERIAL_NUMBER, data=tcp_data(unit_id=2) + ) + entry.add_to_hass(hass) + await async_seed_unit(hass, mock_modbus_connection.for_unit(2)) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery() + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == DISCOVERY_HOST + assert entry.data[CONF_UNIT_ID] == 2 + + +async def test_zeroconf_known_inverter_is_dropped( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """An inverter that is already set up is dropped when it announces itself. + + Every inverter announces itself on every restart, and one that is already + configured has nothing to add. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery(host=HOST) + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_another_inverter_on_a_configured_address( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """An address an entry uses can end up hosting a different inverter. + + That inverter is a device of its own, and dropping its announcement because + something else already uses the address would leave it undiscoverable. + """ + entry = MockConfigEntry( + domain=DOMAIN, title=TITLE, unique_id=SERIAL_NUMBER, data=tcp_data(unit_id=2) + ) + entry.add_to_hass(hass) + await async_seed_unit( + hass, + mock_modbus_connection.for_unit(2), + serial_registers=OTHER_SERIAL_REGISTERS, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery(host=HOST, properties={"MODBUS_ID": "2"}), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "OTHER123" + + +async def test_zeroconf_unresponsive_device( + hass: HomeAssistant, mock_modbus_unit: MockModbusUnit +) -> None: + """An announcement from a device that will not answer is dropped.""" + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery() + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py new file mode 100644 index 00000000000000..4d4d240cadb62d --- /dev/null +++ b/tests/components/solaredge_modbus/test_init.py @@ -0,0 +1,316 @@ +"""Tests for the SolarEdge Modbus config-entry setup.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError +from modbus_connection.mock import MockModbusConnection, MockModbusUnit +import pytest + +from homeassistant.components.solaredge_modbus.const import DOMAIN, 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 SERIAL_NUMBER, async_seed_unit, tcp_data + +from tests.common import MockConfigEntry, async_fire_time_changed + +POWER_ENTITY = "sensor.solaredge_se10000h_power" + +# An address inside the inverter's read, to make that read fail. +INVERTER_REGISTER = 40069 + + +async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def test_load_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """The entry loads, produces entities, and unloads cleanly.""" + await _setup(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(POWER_ENTITY) + assert state is not None + assert state.state == "9490" + + 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_inverter_that_does_not_name_itself( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A device is still readable when the model string comes back empty.""" + mock_modbus_unit.holding.update(dict.fromkeys(range(40020, 40036), 0)) + + 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 + assert inverter.name == "SolarEdge inverter" + assert inverter.model is None + assert inverter.model_id is None + + +async def test_single_late_answer_is_retried( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """One missed read gets a second chance before the entities go unavailable.""" + await _setup(hass, mock_config_entry) + + read_holding_registers = mock_modbus_unit.read_holding_registers + missed: list[int] = [] + + async def miss_the_inverter_once(address: int, count: int) -> list[int]: + """Time out on the first read covering the inverter, then behave.""" + if not missed and address <= INVERTER_REGISTER < address + count: + missed.append(address) + raise ModbusTimeoutError("timed out") + return await read_holding_registers(address, count) + + with patch.object( + mock_modbus_unit, "read_holding_registers", miss_the_inverter_once + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert missed # the read really did fail + + state = hass.states.get(POWER_ENTITY) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_dead_link_fails_the_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A device that answers nothing at all fails the poll outright. + + A partial poll leaves what answered alone, but silence from end to end is + a dead link, and every value the entry can show is then stale. + """ + await _setup(hass, mock_config_entry) + + mock_modbus_unit.fail_requests(ModbusTimeoutError("link died")) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_config_entry.runtime_data.readings.last_update_success is False + + state = hass.states.get(POWER_ENTITY) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_another_inverter_on_the_address_fails_the_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """An address that moves to another inverter stops feeding these entities. + + Setting up checks the serial number, but the entry keeps polling an + address, and a lease handed out again can put a different inverter behind + it. Its production is not this entry's, whatever the entities are named + after. + """ + await _setup(hass, mock_config_entry) + + state = hass.states.get(POWER_ENTITY) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + await async_seed_unit( + hass, mock_modbus_unit, serial_registers=[20308, 18501, 21041, 12851] + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_config_entry.runtime_data.readings.last_update_success is False + + state = hass.states.get(POWER_ENTITY) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_setup_retry_when_device_unresponsive( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A device that does not answer puts the entry in setup retry.""" + mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out")) + + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_error_when_not_a_solaredge_device( + hass: HomeAssistant, mock_modbus_connection: MockModbusConnection +) -> None: + """A device without a SunSpec header fails setup permanently.""" + unit = mock_modbus_connection.for_unit(2) + unit.holding.update(dict.fromkeys(range(40000, 40004), 0)) + + entry = MockConfigEntry( + domain=DOMAIN, + title="SolarEdge SE10000H", + unique_id=SERIAL_NUMBER, + data=tcp_data(unit_id=2), + ) + + await _setup(hass, entry) + + assert entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + "serial_registers", + [ + pytest.param([20308, 18501, 21041, 12851], id="another inverter"), + pytest.param([0, 0, 0, 0], id="no serial number"), + ], +) +async def test_setup_error_when_the_identity_does_not_match( + hass: HomeAssistant, + mock_modbus_connection: MockModbusConnection, + serial_registers: list[int], +) -> None: + """An address that no longer holds this inverter must not adopt its data. + + Every identity in this integration derives from the entry's serial number, + so loading a device that reports another one, or none at all, would hang + this entry's name and history on the wrong inverter. + """ + await async_seed_unit( + hass, mock_modbus_connection.for_unit(2), serial_registers=serial_registers + ) + entry = MockConfigEntry( + domain=DOMAIN, + title="SolarEdge SE10000H", + unique_id=SERIAL_NUMBER, + data=tcp_data(unit_id=2), + ) + + await _setup(hass, entry) + + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_retry_when_the_identity_is_unreadable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A poll without the identity block proves nothing, so setup tries again. + + The rest of the device can answer perfectly well while the identity block + does not, and accepting the entry then would skip the check that this is + still the same inverter. A device fault says exactly that, where silence + from the first block on would mean a dead link. + """ + mock_modbus_unit.fail_read(40004, ServerDeviceFailureError()) + + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_retry_when_the_measurements_are_unreadable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A first poll without the inverter block would cost the phase entities. + + Which entities exist is decided once, from the inverter's DID, and without + it none of the phase measurements match. An entry accepted here would be + missing those entities until a reload, however well the inverter answers + after that. + """ + mock_modbus_unit.fail_read(40069, ServerDeviceFailureError()) + + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_retry_that_finds_nothing_keeps_the_first_poll( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A link that drops during the retry does not fail the whole refresh. + + The first attempt got the identity block a second ago. Failing the refresh + because the retry found a dead link would throw that away, and every + sub-system that did answer with it. + """ + await _setup(hass, mock_config_entry) + + read_holding_registers = mock_modbus_unit.read_holding_registers + dead = False + + async def die_from_the_inverter_on(address: int, count: int) -> list[int]: + """Go quiet at the inverter block, and stay quiet from then on.""" + nonlocal dead + if dead or address <= INVERTER_REGISTER < address + count: + dead = True + raise ModbusTimeoutError("link died") + return await read_holding_registers(address, count) + + with patch.object( + mock_modbus_unit, "read_holding_registers", die_from_the_inverter_on + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # The identity block answered the first attempt and the refresh stands; + # only the sub-systems that stayed silent are reported as failed. + coordinator = mock_config_entry.runtime_data.readings + assert coordinator.last_update_success is True + assert coordinator.data.updated == {"common"} + assert "inverter" in coordinator.data.failed + + +async def test_setup_error_when_link_settings_are_in_use( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Another integration holding the device on other line settings is fatal.""" + with patch( + "homeassistant.components.solaredge_modbus.async_get_unit", + side_effect=HomeAssistantError("already in use with different link settings"), + ): + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/solaredge_modbus/test_sensor.py b/tests/components/solaredge_modbus/test_sensor.py new file mode 100644 index 00000000000000..77bacc18a38229 --- /dev/null +++ b/tests/components/solaredge_modbus/test_sensor.py @@ -0,0 +1,204 @@ +"""Tests for the SolarEdge Modbus sensor entities.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from modbus_connection import ModbusTimeoutError +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.solaredge_modbus.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, + snapshot_platform, +) + +LIFETIME_ENERGY_ENTITY = "sensor.solaredge_se10000h_energy" + + +async def _setup_sensor_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.SENSOR] + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def _tick(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All sensor entities and their states match the snapshot.""" + await _setup_sensor_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_diagnostic_tail_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The niche diagnostic points stay out of the way until asked for.""" + await _setup_sensor_platform(hass, mock_config_entry) + + # What a solar owner looks at is there from the start. + assert hass.states.get("sensor.solaredge_se10000h_power") is not None + + for entity_id in ( + "sensor.solaredge_se10000h_apparent_power", + "sensor.solaredge_se10000h_frequency", + # Voltage barely moves and there is a lot of it; ask for it if you want it. + "sensor.solaredge_se10000h_voltage", + "sensor.solaredge_se10000h_dc_voltage", + ): + 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_sensors_unavailable_on_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A failed refresh marks the sensor entities unavailable.""" + await _setup_sensor_platform(hass, mock_config_entry) + + state = hass.states.get("sensor.solaredge_se10000h_power") + assert state is not None + assert state.state == "9490" + + # The device stops answering reads of the inverter block. + mock_modbus_unit.fail_read(40069, ModbusTimeoutError("timed out")) + + await _tick(hass, freezer) + + state = hass.states.get("sensor.solaredge_se10000h_power") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # ...and comes back once the inverter answers again. + mock_modbus_unit.fail_read(40069, None) + + await _tick(hass, freezer) + + state = hass.states.get("sensor.solaredge_se10000h_power") + assert state is not None + assert state.state == "9490" + + +async def test_no_phase_currents_on_single_phase( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """A single-phase inverter gets a total current sensor, but no phase ones.""" + await _setup_sensor_platform(hass, mock_config_entry) + + assert hass.states.get("sensor.solaredge_se10000h_current") is not None + assert hass.states.get("sensor.solaredge_se10000h_current_phase_a") is None + + +async def test_phase_currents_on_three_phase( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A three-phase inverter gets per-phase current sensors.""" + mock_modbus_unit.holding[40069] = 103 # SunSpec three-phase inverter model + + await _setup_sensor_platform(hass, mock_config_entry) + + assert hass.states.get("sensor.solaredge_se10000h_current_phase_a") is not None + assert hass.states.get("sensor.solaredge_se10000h_current_phase_b") is not None + assert hass.states.get("sensor.solaredge_se10000h_current_phase_c") is not None + + +async def test_lifetime_energy_never_goes_backwards( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, + caplog: pytest.LogCaptureFixture, +) -> None: + """A transiently lower lifetime energy reading is held at the last maximum.""" + await _setup_sensor_platform(hass, mock_config_entry) + + state = hass.states.get(LIFETIME_ENERGY_ENTITY) + assert state is not None + initial = float(state.state) + + # The inverter transiently reports 0 ("not accumulated", decodes to None); + # the sensor holds the last maximum instead of going unknown. + mock_modbus_unit.holding[40093] = 0 + mock_modbus_unit.holding[40094] = 0 + + await _tick(hass, freezer) + + state = hass.states.get(LIFETIME_ENERGY_ENTITY) + assert state is not None + assert float(state.state) == initial + + # The inverter glitches and reports a far lower lifetime energy. + mock_modbus_unit.holding[40093] = 0 + mock_modbus_unit.holding[40094] = 1000 + + await _tick(hass, freezer) + + state = hass.states.get(LIFETIME_ENERGY_ENTITY) + assert state is not None + assert float(state.state) == initial + assert "lower than" in caplog.text + + # The inverter recovers with a higher value; the sensor follows again. + mock_modbus_unit.holding[40093] = 0x1000 + mock_modbus_unit.holding[40094] = 0 + + await _tick(hass, freezer) + + state = hass.states.get(LIFETIME_ENERGY_ENTITY) + assert state is not None + assert float(state.state) > initial + + +async def test_lifetime_energy_restored_after_restart( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """The last seen maximum survives a restart and beats a lower device reading.""" + # A previous run saw a higher lifetime energy than the device reports now. + mock_restore_cache_with_extra_data( + hass, + ( + ( + State(LIFETIME_ENERGY_ENTITY, "99999.999"), + { + "native_value": 99999999, + "native_unit_of_measurement": "Wh", + }, + ), + ), + ) + + await _setup_sensor_platform(hass, mock_config_entry) + + state = hass.states.get(LIFETIME_ENERGY_ENTITY) + assert state is not None + assert float(state.state) == 99999.999 # kWh, from the restored maximum diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py index 61bf11b37a0d16..11c3e79835b00b 100644 --- a/tests/components/switchbot_cloud/test_fan.py +++ b/tests/components/switchbot_cloud/test_fan.py @@ -308,6 +308,29 @@ async def test_air_purifier( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) +async def test_air_purifier_unknown_mode( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, +) -> None: + """Test an air purifier reporting a mode that is not one of its presets. + + An unplugged purifier reports mode 0, which no preset maps to. + """ + mock_list_devices.return_value = [AIR_PURIFIER_INFO] + status = await async_load_json_object_fixture( + hass, "air_purifier_status.json", DOMAIN + ) + mock_get_status.return_value = {**status, "power": "OFF", "mode": 0} + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.FAN]): + await configure_integration(hass) + + state = hass.states.get("fan.air_purifier_1") + assert state.state == STATE_OFF + assert state.attributes[ATTR_PRESET_MODE] is None + + @pytest.mark.parametrize( ("service", "service_data", "expected_call_args"), [ diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 8667bc51a61ddc..a20492c33656a4 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -425,6 +425,54 @@ async def test_setup_creates_cloudhook_when_cloud_active( mock_setup_webhook.assert_called_once_with(CLOUDHOOK_URL) +async def test_setup_survives_the_cloud_going_away( + hass: HomeAssistant, + mock_list_devices: AsyncMock, + mock_get_status: AsyncMock, + mock_get_webook_configuration: AsyncMock, + mock_delete_webhook: AsyncMock, + mock_setup_webhook: AsyncMock, +) -> None: + """Test the entry still loads when the cloud goes away mid-setup. + + The connection is checked before the cloudhook is created, so it can be + gone by the time it is used. The local URL carries it until the + connection change listener creates the cloudhook. + """ + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + await mock_cloud(hass) + await hass.async_block_till_done() + + mock_get_webook_configuration.return_value = {"urls": []} + mock_list_devices.return_value = [_water_detector()] + mock_get_status.return_value = {"battery": 100} + mock_delete_webhook.return_value = {} + mock_setup_webhook.return_value = {} + + with ( + patch("homeassistant.components.cloud.async_is_logged_in", return_value=True), + patch("homeassistant.components.cloud.async_is_connected", return_value=True), + patch.object(cloud, "async_active_subscription", return_value=True), + patch( + "homeassistant.components.cloud.async_get_or_create_cloudhook", + side_effect=CloudNotAvailable, + ), + patch("homeassistant.components.cloud.async_delete_cloudhook"), + ): + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + assert CONF_CLOUDHOOK_URL not in entry.data + # SwitchBot was given the local URL to push to in the meantime + mock_setup_webhook.assert_called_once() + assert mock_setup_webhook.call_args[0][0].startswith( + "https://example.com/api/webhook/" + ) + + async def test_setup_reuses_persisted_cloudhook( hass: HomeAssistant, mock_list_devices, diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr index 6f3652079e5d43..53414db31a00d6 100644 --- a/tests/components/tuya/snapshots/test_event.ambr +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -1,7 +1,6 @@ # serializer version: 1 # name: test_alarm_message_event[event.intercom_doorbell_message-alarm_message-eyJzb21lIjogImpzb24iLCAicmFuZG9tIjogImRhdGEifQ==-sp_csr2fqitalj5o0tq] ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -12,7 +11,6 @@ # --- # name: test_alarm_message_event[event.intercom_doorbell_picture-doorbell_pic-aHR0cHM6Ly9zb21lLXBpY3R1cmUtdXJsLmNvbS9pbWFnZS5qcGc=-sp_csr2fqitalj5o0tq] ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -235,7 +233,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -250,7 +248,6 @@ # name: test_platform_setup_and_discovery[event.burocam_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -295,7 +292,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -310,7 +307,6 @@ # name: test_platform_setup_and_discovery[event.c9_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -355,7 +351,7 @@ 'object_id_base': 'Doorbell picture', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell picture', 'platform': 'tuya', @@ -370,7 +366,6 @@ # name: test_platform_setup_and_discovery[event.c9_doorbell_picture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -415,7 +410,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -430,7 +425,6 @@ # name: test_platform_setup_and_discovery[event.dolni_vchod_zapad_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -475,7 +469,7 @@ 'object_id_base': 'Doorbell picture', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell picture', 'platform': 'tuya', @@ -490,7 +484,6 @@ # name: test_platform_setup_and_discovery[event.dolni_vchod_zapad_doorbell_picture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -535,7 +528,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -550,7 +543,6 @@ # name: test_platform_setup_and_discovery[event.garage_camera_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -595,7 +587,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -610,7 +602,6 @@ # name: test_platform_setup_and_discovery[event.intercom_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -655,7 +646,7 @@ 'object_id_base': 'Doorbell picture', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell picture', 'platform': 'tuya', @@ -670,7 +661,6 @@ # name: test_platform_setup_and_discovery[event.intercom_doorbell_picture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -715,7 +705,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -730,7 +720,6 @@ # name: test_platform_setup_and_discovery[event.mirilla_puerta_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -775,7 +764,7 @@ 'object_id_base': 'Doorbell picture', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell picture', 'platform': 'tuya', @@ -790,7 +779,6 @@ # name: test_platform_setup_and_discovery[event.mirilla_puerta_doorbell_picture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', @@ -835,7 +823,7 @@ 'object_id_base': 'Doorbell message', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Doorbell message', 'platform': 'tuya', @@ -850,7 +838,6 @@ # name: test_platform_setup_and_discovery[event.security_camera_doorbell_message-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'doorbell', : 'triggered', : list([ 'triggered', diff --git a/tests/components/uhoo/snapshots/test_sensor.ambr b/tests/components/uhoo/snapshots/test_sensor.ambr index b8c8624aeb658f..8900f3a0516f04 100644 --- a/tests/components/uhoo/snapshots/test_sensor.ambr +++ b/tests/components/uhoo/snapshots/test_sensor.ambr @@ -629,7 +629,7 @@ 'object_id_base': 'Volatile organic compounds', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Volatile organic compounds', 'platform': 'uhoo', @@ -644,7 +644,7 @@ # name: test_sensor_snapshot[sensor.test_device_volatile_organic_compounds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'volatile_organic_compounds', + : 'volatile_organic_compounds_parts', : 'Test Device Volatile organic compounds', : , : , diff --git a/tests/components/zeroconf/test_websocket_api.py b/tests/components/zeroconf/test_websocket_api.py index 9677b3e34fdb69..04fc3f4eedec76 100644 --- a/tests/components/zeroconf/test_websocket_api.py +++ b/tests/components/zeroconf/test_websocket_api.py @@ -21,6 +21,14 @@ from tests.typing import WebSocketGenerator +PROPERTIES = { + "md": "HASS Bridge W9DN", + "pv": "1.0", + "id": "11:8E:DB:5B:5C:C5", + "c#": "12", + "s#": "1", +} + async def test_subscribe_discovery( hass: HomeAssistant, @@ -62,7 +70,7 @@ async def test_subscribe_discovery( socket.inet_aton("127.0.0.1"), ), DNSText( - "foo2.local.", + "foo2._fakeservice._tcp.local.", const._TYPE_TXT, const._CLASS_IN, const._DNS_HOST_TTL, @@ -87,7 +95,7 @@ async def test_subscribe_discovery( "foo3.local.", ), DNSText( - "foo3.local.", + "foo3._fakeservice._tcp.local.", const._TYPE_TXT, const._CLASS_IN, const._DNS_HOST_TTL, @@ -122,7 +130,7 @@ async def test_subscribe_discovery( "ip_addresses": ["127.0.0.1"], "name": "foo2._fakeservice._tcp.local.", "port": 1234, - "properties": {}, + "properties": PROPERTIES, "type": "_fakeservice._tcp.local.", } ] @@ -152,7 +160,7 @@ async def test_subscribe_discovery( "ip_addresses": ["127.0.0.1"], "name": "foo3._fakeservice._tcp.local.", "port": 1234, - "properties": {}, + "properties": PROPERTIES, "type": "_fakeservice._tcp.local.", } ] @@ -166,7 +174,7 @@ async def test_subscribe_discovery( "ip_addresses": ["127.0.0.1"], "name": "foo3._fakeservice._tcp.local.", "port": 1234, - "properties": {}, + "properties": PROPERTIES, "type": "_fakeservice._tcp.local.", } ]