diff --git a/.strict-typing b/.strict-typing index 8c8075013873..11510bd810a1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -566,6 +566,7 @@ homeassistant.components.streamlabswater.* homeassistant.components.stt.* homeassistant.components.suez_water.* homeassistant.components.sun.* +homeassistant.components.sunsynk.* homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switch_as_x.* diff --git a/CODEOWNERS b/CODEOWNERS index e62ce07ea5b6..0bb869649dcb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1804,6 +1804,8 @@ CLAUDE.md @home-assistant/core /tests/components/sun/ @home-assistant/core /homeassistant/components/sunricher_dali/ @niracler /tests/components/sunricher_dali/ @niracler +/homeassistant/components/sunsynk/ @jamesridgway +/tests/components/sunsynk/ @jamesridgway /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/sunsynk/__init__.py b/homeassistant/components/sunsynk/__init__.py new file mode 100644 index 000000000000..a40f9df399a2 --- /dev/null +++ b/homeassistant/components/sunsynk/__init__.py @@ -0,0 +1,59 @@ +"""The Sunsynk integration.""" + +import asyncio + +from sunsynk.client import SunsynkClient +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import SunsynkConfigEntry, SunsynkDataUpdateCoordinator +from .entity import inverter_device_info + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: SunsynkConfigEntry) -> bool: + """Set up Sunsynk from a config entry.""" + client = SunsynkClient( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + try: + inverters = await client.get_inverters() + except SunsynkAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except SunsynkConnectionError as err: + raise ConfigEntryNotReady(err) from err + + coordinators = [ + SunsynkDataUpdateCoordinator(hass, entry, client, inverter) + for inverter in inverters + ] + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators + ) + ) + entry.runtime_data = coordinators + + # The battery device links to its inverter, so the inverter must exist first. + device_registry = dr.async_get(hass) + for inverter in inverters: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, **inverter_device_info(inverter) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SunsynkConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sunsynk/config_flow.py b/homeassistant/components/sunsynk/config_flow.py new file mode 100644 index 000000000000..29a1eec9a811 --- /dev/null +++ b/homeassistant/components/sunsynk/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for the Sunsynk integration.""" + +from typing import Any, override + +from sunsynk.client import SunsynkClient +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="current-password" + ) + ), + } +) + + +class SunsynkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sunsynk.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = SunsynkClient( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + try: + user = await client.get_user() + except SunsynkAuthenticationError: + errors["base"] = "invalid_auth" + except SunsynkConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(str(user.id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/sunsynk/const.py b/homeassistant/components/sunsynk/const.py new file mode 100644 index 000000000000..5802989eb907 --- /dev/null +++ b/homeassistant/components/sunsynk/const.py @@ -0,0 +1,11 @@ +"""Constants for the Sunsynk integration.""" + +from datetime import timedelta +import logging +from typing import Final + +DOMAIN: Final = "sunsynk" +LOGGER = logging.getLogger(__package__) + +# The inverter uploads new data to the Sunsynk cloud every five minutes. +SCAN_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/sunsynk/coordinator.py b/homeassistant/components/sunsynk/coordinator.py new file mode 100644 index 000000000000..bb449356771c --- /dev/null +++ b/homeassistant/components/sunsynk/coordinator.py @@ -0,0 +1,73 @@ +"""Coordinator for the Sunsynk integration.""" + +import asyncio +from dataclasses import dataclass +from typing import override + +from sunsynk.battery import Battery +from sunsynk.client import SunsynkClient +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError +from sunsynk.grid import Grid +from sunsynk.input import Input +from sunsynk.inverter import Inverter +from sunsynk.load import Load + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type SunsynkConfigEntry = ConfigEntry[list[SunsynkDataUpdateCoordinator]] + + +@dataclass +class SunsynkInverterData: + """Realtime data for one inverter.""" + + battery: Battery + grid: Grid + load: Load + solar: Input + + +class SunsynkDataUpdateCoordinator(DataUpdateCoordinator[SunsynkInverterData]): + """Fetch the realtime data of one Sunsynk inverter.""" + + config_entry: SunsynkConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SunsynkConfigEntry, + client: SunsynkClient, + inverter: Inverter, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{inverter.sn}", + update_interval=SCAN_INTERVAL, + ) + self.client = client + self.inverter = inverter + + @override + async def _async_update_data(self) -> SunsynkInverterData: + """Fetch data from the Sunsynk API.""" + serial_number = self.inverter.sn + try: + battery, grid, load, solar = await asyncio.gather( + self.client.get_inverter_realtime_battery(serial_number), + self.client.get_inverter_realtime_grid(serial_number), + self.client.get_inverter_realtime_load(serial_number), + self.client.get_inverter_realtime_input(serial_number), + ) + except SunsynkAuthenticationError as err: + raise ConfigEntryAuthFailed(err) from err + except SunsynkConnectionError as err: + raise UpdateFailed(err) from err + return SunsynkInverterData(battery=battery, grid=grid, load=load, solar=solar) diff --git a/homeassistant/components/sunsynk/entity.py b/homeassistant/components/sunsynk/entity.py new file mode 100644 index 000000000000..ad5b5936b293 --- /dev/null +++ b/homeassistant/components/sunsynk/entity.py @@ -0,0 +1,70 @@ +"""Base entities for the Sunsynk integration.""" + +from sunsynk.inverter import Inverter + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SunsynkDataUpdateCoordinator + + +def inverter_device_info(inverter: Inverter) -> DeviceInfo: + """Return the device info of an inverter.""" + name = f"Inverter {inverter.sn}" + if inverter.alias and inverter.alias != inverter.sn: + name = inverter.alias + return DeviceInfo( + identifiers={(DOMAIN, inverter.sn)}, + name=name, + manufacturer="Sunsynk", + model=inverter.model or None, + serial_number=inverter.sn, + sw_version=inverter.version.soft_ver if inverter.version else None, + ) + + +class SunsynkInverterEntity(CoordinatorEntity[SunsynkDataUpdateCoordinator]): + """An entity of a Sunsynk inverter.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SunsynkDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.inverter.sn}_{description.key}" + self._attr_device_info = inverter_device_info(coordinator.inverter) + + +class SunsynkBatteryEntity(CoordinatorEntity[SunsynkDataUpdateCoordinator]): + """An entity of the battery of a Sunsynk inverter.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SunsynkDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + serial_number = coordinator.inverter.sn + self._attr_unique_id = f"{serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{serial_number}_battery")}, + name=f"Battery {serial_number}", + manufacturer="Sunsynk", + via_device_id=dr.async_get_device_id_by_identifier( + coordinator.hass, + (DOMAIN, serial_number), + config_entry_id=coordinator.config_entry.entry_id, + ), + ) diff --git a/homeassistant/components/sunsynk/icons.json b/homeassistant/components/sunsynk/icons.json new file mode 100644 index 000000000000..39e1ac406cc6 --- /dev/null +++ b/homeassistant/components/sunsynk/icons.json @@ -0,0 +1,54 @@ +{ + "entity": { + "sensor": { + "charge_today": { + "default": "mdi:battery-arrow-up" + }, + "charge_total": { + "default": "mdi:battery-arrow-up" + }, + "discharge_today": { + "default": "mdi:battery-arrow-down" + }, + "discharge_total": { + "default": "mdi:battery-arrow-down" + }, + "grid_export_today": { + "default": "mdi:transmission-tower-export" + }, + "grid_export_total": { + "default": "mdi:transmission-tower-export" + }, + "grid_import_today": { + "default": "mdi:transmission-tower-import" + }, + "grid_import_total": { + "default": "mdi:transmission-tower-import" + }, + "grid_power": { + "default": "mdi:transmission-tower" + }, + "load_energy_today": { + "default": "mdi:home-lightning-bolt" + }, + "load_energy_total": { + "default": "mdi:home-lightning-bolt" + }, + "load_power": { + "default": "mdi:home-lightning-bolt" + }, + "power": { + "default": "mdi:home-battery" + }, + "solar_energy_today": { + "default": "mdi:solar-power-variant" + }, + "solar_energy_total": { + "default": "mdi:solar-power-variant" + }, + "solar_power": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/sunsynk/manifest.json b/homeassistant/components/sunsynk/manifest.json new file mode 100644 index 000000000000..af1ab3754cb8 --- /dev/null +++ b/homeassistant/components/sunsynk/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "sunsynk", + "name": "Sunsynk", + "codeowners": ["@jamesridgway"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunsynk", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["sunsynk-api-client==1.4.0"] +} diff --git a/homeassistant/components/sunsynk/quality_scale.yaml b/homeassistant/components/sunsynk/quality_scale.yaml new file mode 100644 index 000000000000..464ec627dd79 --- /dev/null +++ b/homeassistant/components/sunsynk/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide 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 provide actions. + docs-conditions: + status: exempt + comment: This integration does not provide conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide triggers. + entity-event-setup: + status: exempt + comment: The entities do not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: The integration only reads data through a coordinator. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: no mDNS and DHCP is a generic device. + discovery-update-info: + status: exempt + comment: The integration connects to the cloud. It does not store a local address. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not have a case where a repair issue is needed. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/sunsynk/sensor.py b/homeassistant/components/sunsynk/sensor.py new file mode 100644 index 000000000000..d987cfe9cbc1 --- /dev/null +++ b/homeassistant/components/sunsynk/sensor.py @@ -0,0 +1,262 @@ +"""Sensors for the Sunsynk integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import SunsynkConfigEntry, SunsynkInverterData +from .entity import SunsynkBatteryEntity, SunsynkInverterEntity + + +@dataclass(frozen=True, kw_only=True) +class SunsynkSensorEntityDescription(SensorEntityDescription): + """Describes a Sunsynk sensor entity.""" + + value_fn: Callable[[SunsynkInverterData], StateType] + + +SENSORS_INVERTER: tuple[SunsynkSensorEntityDescription, ...] = ( + SunsynkSensorEntityDescription( + key="solar_power", + translation_key="solar_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar.get_power(), + ), + SunsynkSensorEntityDescription( + key="solar_energy_today", + translation_key="solar_energy_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.solar.generated_today, + ), + SunsynkSensorEntityDescription( + key="solar_energy_total", + translation_key="solar_energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.solar.generated_total, + ), + SunsynkSensorEntityDescription( + key="grid_power", + translation_key="grid_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.grid.get_total_power(), + ), + SunsynkSensorEntityDescription( + key="grid_frequency", + translation_key="grid_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.grid.fac, + ), + SunsynkSensorEntityDescription( + key="grid_import_today", + translation_key="grid_import_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.today_import, + ), + SunsynkSensorEntityDescription( + key="grid_import_total", + translation_key="grid_import_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.total_import, + ), + SunsynkSensorEntityDescription( + key="grid_export_today", + translation_key="grid_export_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.today_export, + ), + SunsynkSensorEntityDescription( + key="grid_export_total", + translation_key="grid_export_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.grid.total_export, + ), + SunsynkSensorEntityDescription( + key="load_power", + translation_key="load_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.load.get_total_power(), + ), + SunsynkSensorEntityDescription( + key="load_energy_today", + translation_key="load_energy_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.load.daily_used, + ), + SunsynkSensorEntityDescription( + key="load_energy_total", + translation_key="load_energy_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.load.total_used, + ), +) + +SENSORS_BATTERY: tuple[SunsynkSensorEntityDescription, ...] = ( + SunsynkSensorEntityDescription( + key="battery_power", + translation_key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.battery.power, + ), + SunsynkSensorEntityDescription( + key="battery_state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.battery.soc, + ), + SunsynkSensorEntityDescription( + key="battery_voltage", + translation_key="voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.battery.voltage, + ), + SunsynkSensorEntityDescription( + key="battery_current", + translation_key="current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.battery.current, + ), + SunsynkSensorEntityDescription( + key="battery_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.battery.temp, + ), + SunsynkSensorEntityDescription( + key="battery_charge_today", + translation_key="charge_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.charge_today, + ), + SunsynkSensorEntityDescription( + key="battery_charge_total", + translation_key="charge_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.charge_total, + ), + SunsynkSensorEntityDescription( + key="battery_discharge_today", + translation_key="discharge_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.discharge_today, + ), + SunsynkSensorEntityDescription( + key="battery_discharge_total", + translation_key="discharge_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.battery.discharge_total, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SunsynkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sunsynk sensors from a config entry.""" + entities: list[SensorEntity] = [] + for coordinator in entry.runtime_data: + entities.extend( + SunsynkInverterSensorEntity(coordinator, description) + for description in SENSORS_INVERTER + ) + if coordinator.data.battery.is_present: + entities.extend( + SunsynkBatterySensorEntity(coordinator, description) + for description in SENSORS_BATTERY + ) + async_add_entities(entities) + + +class SunsynkInverterSensorEntity(SunsynkInverterEntity, SensorEntity): + """A sensor of a Sunsynk inverter.""" + + entity_description: SunsynkSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) + + +class SunsynkBatterySensorEntity(SunsynkBatteryEntity, SensorEntity): + """A sensor of the battery of a Sunsynk inverter.""" + + entity_description: SunsynkSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/sunsynk/strings.json b/homeassistant/components/sunsynk/strings.json new file mode 100644 index 000000000000..2f90b2a34a0d --- /dev/null +++ b/homeassistant/components/sunsynk/strings.json @@ -0,0 +1,91 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password of your Sunsynk Connect account.", + "username": "The email address of your Sunsynk Connect account." + }, + "description": "Connect to your Sunsynk Connect account to get data from your inverters." + } + } + }, + "entity": { + "sensor": { + "charge_today": { + "name": "Charge today" + }, + "charge_total": { + "name": "Charge total" + }, + "current": { + "name": "Current" + }, + "discharge_today": { + "name": "Discharge today" + }, + "discharge_total": { + "name": "Discharge total" + }, + "grid_export_today": { + "name": "Grid export today" + }, + "grid_export_total": { + "name": "Grid export total" + }, + "grid_frequency": { + "name": "Grid frequency" + }, + "grid_import_today": { + "name": "Grid import today" + }, + "grid_import_total": { + "name": "Grid import total" + }, + "grid_power": { + "name": "Grid power" + }, + "load_energy_today": { + "name": "Load energy today" + }, + "load_energy_total": { + "name": "Load energy total" + }, + "load_power": { + "name": "Load power" + }, + "power": { + "name": "Power" + }, + "solar_energy_today": { + "name": "Solar energy today" + }, + "solar_energy_total": { + "name": "Solar energy total" + }, + "solar_power": { + "name": "Solar power" + }, + "state_of_charge": { + "name": "State of charge" + }, + "temperature": { + "name": "Temperature" + }, + "voltage": { + "name": "Voltage" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2b010508cc07..a2f734ef1ad8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -765,6 +765,7 @@ FLOWS = { "suez_water", "sun", "sunricher_dali", + "sunsynk", "sunweg", "surepetcare", "swiss_public_transport", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 79a286946fcf..a64bcbe34547 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7069,6 +7069,12 @@ "config_flow": true, "iot_class": "local_push" }, + "sunsynk": { + "name": "Sunsynk", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "sunweg": { "name": "Sun WEG", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 02b32b2f8826..bd1582511efa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5420,6 +5420,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sunsynk.*] +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.surepetcare.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 907010958c5b..77d85a815d07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3173,6 +3173,9 @@ streamlabswater==1.0.1 # homeassistant.components.subaru subarulink==0.7.19 +# homeassistant.components.sunsynk +sunsynk-api-client==1.4.0 + # homeassistant.components.surepetcare surepy==0.9.0 diff --git a/tests/components/sunsynk/__init__.py b/tests/components/sunsynk/__init__.py new file mode 100644 index 000000000000..d6ffe145b589 --- /dev/null +++ b/tests/components/sunsynk/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Sunsynk integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Sunsynk integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/sunsynk/conftest.py b/tests/components/sunsynk/conftest.py new file mode 100644 index 000000000000..784be487d056 --- /dev/null +++ b/tests/components/sunsynk/conftest.py @@ -0,0 +1,83 @@ +"""Fixtures for the Sunsynk tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from sunsynk.battery import Battery +from sunsynk.grid import Grid +from sunsynk.input import Input +from sunsynk.inverter import Inverter +from sunsynk.load import Load +from sunsynk.user import User + +from homeassistant.components.sunsynk.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +USERNAME = "test@example.com" +PASSWORD = "test-password" +USER_ID = "281092" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sunsynk.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_sunsynk_client() -> Generator[AsyncMock]: + """Mock the Sunsynk API client.""" + with ( + patch( + "homeassistant.components.sunsynk.SunsynkClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.sunsynk.config_flow.SunsynkClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_user.return_value = User( + load_json_object_fixture("user.json", DOMAIN) + ) + client.get_inverters.return_value = [ + Inverter(inverter) + for inverter in load_json_array_fixture("inverters.json", DOMAIN) + ] + client.get_inverter_realtime_battery.side_effect = lambda sn: Battery( + load_json_object_fixture( + "battery.json" if sn == "1029384756" else "battery_absent.json", + DOMAIN, + ) + ) + client.get_inverter_realtime_grid.side_effect = lambda sn: Grid( + load_json_object_fixture("grid.json", DOMAIN) + ) + client.get_inverter_realtime_input.side_effect = lambda sn: Input( + load_json_object_fixture("input.json", DOMAIN) + ) + client.get_inverter_realtime_load.side_effect = lambda sn: Load( + load_json_object_fixture("load.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + unique_id=USER_ID, + ) diff --git a/tests/components/sunsynk/fixtures/battery.json b/tests/components/sunsynk/fixtures/battery.json new file mode 100644 index 000000000000..227583121df9 --- /dev/null +++ b/tests/components/sunsynk/fixtures/battery.json @@ -0,0 +1,40 @@ +{ + "time": null, + "etodayChg": "1.1", + "etodayDischg": "0.6", + "emonthChg": "7.5", + "emonthDischg": "6.2", + "eyearChg": "7.5", + "eyearDischg": "6.2", + "etotalChg": "188.5", + "etotalDischg": "147.9", + "type": 1, + "power": -18, + "capacity": "100.0", + "correctCap": 100, + "current": "-0.4", + "voltage": "53.3", + "temp": "18.7", + "soc": "20.0", + "chargeVolt": 56.1, + "dischargeVolt": 0.0, + "chargeCurrentLimit": 50.0, + "dischargeCurrentLimit": 50.0, + "maxChargeCurrentLimit": 0.0, + "maxDischargeCurrentLimit": 0.0, + "status": 1, + "batterySoc1": 0.0, + "batteryCurrent1": 0.0, + "batteryVolt1": 0.0, + "batteryPower1": 0.0, + "batteryTemp1": 0.0, + "batteryStatus2": 0, + "batterySoc2": null, + "batteryCurrent2": null, + "batteryVolt2": null, + "batteryPower2": null, + "batteryTemp2": null, + "numberOfBatteries": null, + "batt1Factory": null, + "batt2Factory": null +} diff --git a/tests/components/sunsynk/fixtures/battery_absent.json b/tests/components/sunsynk/fixtures/battery_absent.json new file mode 100644 index 000000000000..8a477c25b4dd --- /dev/null +++ b/tests/components/sunsynk/fixtures/battery_absent.json @@ -0,0 +1,40 @@ +{ + "time": null, + "etodayChg": "0.0", + "etodayDischg": "0.0", + "emonthChg": "0.0", + "emonthDischg": "0.0", + "eyearChg": "0.0", + "eyearDischg": "0.0", + "etotalChg": "0.0", + "etotalDischg": "0.0", + "type": 0, + "power": 0, + "capacity": "0.0", + "correctCap": 0, + "current": "0.0", + "voltage": "0.0", + "temp": "0.0", + "soc": "0.0", + "chargeVolt": 0.0, + "dischargeVolt": 0.0, + "chargeCurrentLimit": 0.0, + "dischargeCurrentLimit": 0.0, + "maxChargeCurrentLimit": 0.0, + "maxDischargeCurrentLimit": 0.0, + "status": 0, + "batterySoc1": null, + "batteryCurrent1": null, + "batteryVolt1": null, + "batteryPower1": null, + "batteryTemp1": null, + "batteryStatus2": null, + "batterySoc2": null, + "batteryCurrent2": null, + "batteryVolt2": null, + "batteryPower2": null, + "batteryTemp2": null, + "numberOfBatteries": null, + "batt1Factory": null, + "batt2Factory": null +} diff --git a/tests/components/sunsynk/fixtures/grid.json b/tests/components/sunsynk/fixtures/grid.json new file mode 100644 index 000000000000..97b3bd96ec3c --- /dev/null +++ b/tests/components/sunsynk/fixtures/grid.json @@ -0,0 +1,30 @@ +{ + "vip": [ + { + "volt": "233.6", + "current": "0.8", + "power": 200 + }, + { + "volt": "234.1", + "current": "1.6", + "power": 390 + }, + { + "volt": "232.9", + "current": "0.1", + "power": 20 + } + ], + "pac": 610, + "qac": 0, + "fac": 50.08, + "pf": 1.0, + "status": 1, + "etodayFrom": "12.2", + "etodayTo": "0.0", + "etotalFrom": "998.5", + "etotalTo": "48.2", + "limiterPowerArr": [200, 390, 20], + "limiterTotalPower": 610 +} diff --git a/tests/components/sunsynk/fixtures/input.json b/tests/components/sunsynk/fixtures/input.json new file mode 100644 index 000000000000..fa30fabd6794 --- /dev/null +++ b/tests/components/sunsynk/fixtures/input.json @@ -0,0 +1,28 @@ +{ + "pac": 9, + "pvIV": [ + { + "id": null, + "pvNo": 1, + "vpv": "91.5", + "ipv": "0.1", + "ppv": "9.0", + "todayPv": "0.0", + "sn": "1029384756", + "time": "2023-01-07 16:50:17" + }, + { + "id": null, + "pvNo": 2, + "vpv": "2.4", + "ipv": "0.1", + "ppv": "0.0", + "todayPv": "0.0", + "sn": "1029384756", + "time": "2023-01-07 16:50:17" + } + ], + "mpptIV": [], + "etoday": 1.8, + "etotal": 375.2 +} diff --git a/tests/components/sunsynk/fixtures/inverters.json b/tests/components/sunsynk/fixtures/inverters.json new file mode 100644 index 000000000000..a393fea276ec --- /dev/null +++ b/tests/components/sunsynk/fixtures/inverters.json @@ -0,0 +1,72 @@ +[ + { + "sn": "1029384756", + "alias": "Garage inverter", + "gsn": "E0192837465", + "status": 1, + "type": 2, + "commTypeName": "RS485", + "custCode": 29, + "version": { + "masterVer": "2.3.7.4", + "softVer": "1.5.1.5", + "hardVer": "", + "hmiVer": "E.4.2.4", + "bmsVer": "" + }, + "model": "SUNSYNK-5K-SG04LP1", + "equipMode": null, + "pac": 61, + "etoday": 1.7, + "etotal": 375.1, + "updateAt": "2023-01-07T15:40:02Z", + "opened": 1, + "plant": { + "id": 12345, + "name": "John Smith", + "type": 2, + "master": null, + "installer": null, + "email": null, + "phone": null + }, + "gatewayVO": { + "gsn": "E0192837465", + "status": 2 + }, + "sunsynkEquip": true, + "protocolIdentifier": "2" + }, + { + "sn": "2938475610", + "alias": "", + "gsn": "E0192837466", + "status": 1, + "type": 2, + "commTypeName": "RS485", + "custCode": 29, + "version": null, + "model": "", + "equipMode": null, + "pac": 61, + "etoday": 1.7, + "etotal": 375.1, + "updateAt": "2023-01-07T15:40:02Z", + "opened": 1, + "plant": { + "id": 12345, + "name": "John Smith", + "type": 2, + "master": null, + "installer": null, + "email": null, + "phone": null + }, + "gatewayVO": { + "gsn": "E0192837466", + "status": 2 + }, + "sunsynkEquip": true, + "protocolIdentifier": "2" + } +] diff --git a/tests/components/sunsynk/fixtures/load.json b/tests/components/sunsynk/fixtures/load.json new file mode 100644 index 000000000000..ae70f5c3682b --- /dev/null +++ b/tests/components/sunsynk/fixtures/load.json @@ -0,0 +1,28 @@ +{ + "totalUsed": 3133.1, + "dailyUsed": 34.7, + "vip": [ + { + "volt": "246.6", + "current": "0.0", + "power": 1200 + }, + { + "volt": "245.9", + "current": "0.0", + "power": 2000 + }, + { + "volt": "246.2", + "current": "0.0", + "power": 227 + } + ], + "totalPower": 3427, + "smartLoadStatus": -1, + "loadFac": 50.01, + "upsPowerL1": 5.0, + "upsPowerL2": 0.0, + "upsPowerL3": 0.0, + "upsPowerTotal": 5.0 +} diff --git a/tests/components/sunsynk/fixtures/user.json b/tests/components/sunsynk/fixtures/user.json new file mode 100644 index 000000000000..0c3fa2e80baa --- /dev/null +++ b/tests/components/sunsynk/fixtures/user.json @@ -0,0 +1,14 @@ +{ + "id": 281092, + "nickname": "test@example.com", + "avatar": "https://sunsynk-s3.s3.eu-west-2.amazonaws.com/avatar/20210126155052363929.png", + "gender": 1, + "mobile": null, + "createAt": "2022-10-03T15:39:04Z", + "type": null, + "tempUnit": "\u2103", + "company": null, + "userSrc": "sunsynk", + "email": "test@example.com", + "sex": 1 +} diff --git a/tests/components/sunsynk/snapshots/test_init.ambr b/tests/components/sunsynk/snapshots/test_init.ambr new file mode 100644 index 000000000000..477609ec4b8c --- /dev/null +++ b/tests/components/sunsynk/snapshots/test_init.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunsynk', + '1029384756', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunsynk', + 'model': 'SUNSYNK-5K-SG04LP1', + 'model_id': None, + 'name': 'Garage inverter', + 'name_by_user': None, + 'serial_number': '1029384756', + 'sw_version': '1.5.1.5', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunsynk', + '2938475610', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunsynk', + 'model': None, + 'model_id': None, + 'name': 'Inverter 2938475610', + 'name_by_user': None, + 'serial_number': '2938475610', + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sunsynk', + '1029384756_battery', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Sunsynk', + 'model': None, + 'model_id': None, + 'name': 'Battery 1029384756', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/sunsynk/snapshots/test_sensor.ambr b/tests/components/sunsynk/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..844fe07a0a54 --- /dev/null +++ b/tests/components/sunsynk/snapshots/test_sensor.ambr @@ -0,0 +1,1912 @@ +# serializer version: 1 +# name: test_sensors[sensor.battery_1029384756_charge_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_charge_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_today', + 'unique_id': '1029384756_battery_charge_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_charge_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Charge today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_charge_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_charge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_charge_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_total', + 'unique_id': '1029384756_battery_charge_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_charge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Charge total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_charge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '188.5', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1029384756_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': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': '1029384756_battery_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Battery 1029384756 Current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.4', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_discharge_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discharge today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge_today', + 'unique_id': '1029384756_battery_discharge_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Discharge today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_discharge_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.6', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_discharge_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discharge total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge_total', + 'unique_id': '1029384756_battery_discharge_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_discharge_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Battery 1029384756 Discharge total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_discharge_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '147.9', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_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': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '1029384756_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Battery 1029384756 Power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_1029384756_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of charge', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge', + 'unique_id': '1029384756_battery_state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Battery 1029384756 State of charge', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1029384756_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': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '1029384756_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Battery 1029384756 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.7', + }) +# --- +# name: test_sensors[sensor.battery_1029384756_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.battery_1029384756_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': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': '1029384756_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.battery_1029384756_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Battery 1029384756 Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.battery_1029384756_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.3', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_today-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.garage_inverter_grid_export_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_today', + 'unique_id': '1029384756_grid_export_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid export today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_export_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_total-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.garage_inverter_grid_export_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_total', + 'unique_id': '1029384756_grid_export_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_export_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid export total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_export_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.2', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_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.garage_inverter_grid_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid frequency', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '1029384756_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'Garage inverter Grid frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.08', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_today-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.garage_inverter_grid_import_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_today', + 'unique_id': '1029384756_grid_import_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid import today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_import_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.2', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_total-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.garage_inverter_grid_import_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_total', + 'unique_id': '1029384756_grid_import_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_import_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Grid import total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_import_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '998.5', + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_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.garage_inverter_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '1029384756_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage inverter Grid power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '610.0', + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_today-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.garage_inverter_load_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_today', + 'unique_id': '1029384756_load_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Load energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_load_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.7', + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_total-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.garage_inverter_load_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_total', + 'unique_id': '1029384756_load_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Load energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_load_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3133.1', + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_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.garage_inverter_load_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_power', + 'unique_id': '1029384756_load_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_load_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage inverter Load power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_load_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3427.0', + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_today-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.garage_inverter_solar_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_today', + 'unique_id': '1029384756_solar_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Solar energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_solar_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.8', + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_total-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.garage_inverter_solar_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_total', + 'unique_id': '1029384756_solar_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Garage inverter Solar energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_solar_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '375.2', + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_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.garage_inverter_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_power', + 'unique_id': '1029384756_solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.garage_inverter_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Garage inverter Solar power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.garage_inverter_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_today-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.inverter_2938475610_grid_export_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_today', + 'unique_id': '2938475610_grid_export_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid export today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_export_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_total-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.inverter_2938475610_grid_export_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_total', + 'unique_id': '2938475610_grid_export_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_export_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid export total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_export_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.2', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_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.inverter_2938475610_grid_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid frequency', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_frequency', + 'unique_id': '2938475610_grid_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'Inverter 2938475610 Grid frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.08', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_today-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.inverter_2938475610_grid_import_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_today', + 'unique_id': '2938475610_grid_import_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid import today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_import_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.2', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_total-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.inverter_2938475610_grid_import_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_total', + 'unique_id': '2938475610_grid_import_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_import_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Grid import total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_import_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '998.5', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_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.inverter_2938475610_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '2938475610_grid_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Inverter 2938475610 Grid power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '610.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_today-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.inverter_2938475610_load_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_today', + 'unique_id': '2938475610_load_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Load energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_load_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.7', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_total-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.inverter_2938475610_load_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_energy_total', + 'unique_id': '2938475610_load_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Load energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_load_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3133.1', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_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.inverter_2938475610_load_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Load power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Load power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'load_power', + 'unique_id': '2938475610_load_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_load_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Inverter 2938475610 Load power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_load_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3427.0', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_today-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.inverter_2938475610_solar_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy today', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_today', + 'unique_id': '2938475610_solar_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Solar energy today', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_solar_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.8', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_total-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.inverter_2938475610_solar_energy_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar energy total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar energy total', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_total', + 'unique_id': '2938475610_solar_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_energy_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Inverter 2938475610 Solar energy total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_solar_energy_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '375.2', + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_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.inverter_2938475610_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'sunsynk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_power', + 'unique_id': '2938475610_solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.inverter_2938475610_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Inverter 2938475610 Solar power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.inverter_2938475610_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- diff --git a/tests/components/sunsynk/test_config_flow.py b/tests/components/sunsynk/test_config_flow.py new file mode 100644 index 000000000000..264ea0ce8919 --- /dev/null +++ b/tests/components/sunsynk/test_config_flow.py @@ -0,0 +1,97 @@ +"""Test the Sunsynk config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError + +from homeassistant.components.sunsynk.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import PASSWORD, USER_ID, USERNAME + +from tests.common import MockConfigEntry + +DHCP_SERVICE_INFO = DhcpServiceInfo( + hostname="e-linter", ip="192.168.1.20", macaddress="1091a8aabbcc" +) + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"] == {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + assert result["result"].unique_id == USER_ID + assert len(mock_sunsynk_client.get_user.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_sunsynk_client", "mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the flow aborts when the account is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: "other@example.com", CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + pytest.param(SunsynkAuthenticationError, "invalid_auth", id="invalid_auth"), + pytest.param(SunsynkConnectionError, "cannot_connect", id="cannot_connect"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_errors( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test the user flow shows an error and can recover.""" + mock_sunsynk_client.get_user.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_sunsynk_client.get_user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/sunsynk/test_init.py b/tests/components/sunsynk/test_init.py new file mode 100644 index 000000000000..fae0af9b3b65 --- /dev/null +++ b/tests/components/sunsynk/test_init.py @@ -0,0 +1,75 @@ +"""Test the Sunsynk integration setup.""" + +from unittest.mock import AsyncMock + +import pytest +from sunsynk.exceptions import SunsynkAuthenticationError, SunsynkConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_load_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry loads and unloads.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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 + + +@pytest.mark.parametrize( + ("method", "exception", "result"), + [ + ("get_inverters", SunsynkConnectionError, ConfigEntryState.SETUP_RETRY), + ("get_inverters", SunsynkAuthenticationError, ConfigEntryState.SETUP_ERROR), + ( + "get_inverter_realtime_grid", + SunsynkConnectionError, + ConfigEntryState.SETUP_RETRY, + ), + ( + "get_inverter_realtime_grid", + SunsynkAuthenticationError, + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_setup_connection_error( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_config_entry: MockConfigEntry, + method: str, + exception: Exception, + result: ConfigEntryState, +) -> None: + """Test the config entry retries when the API cannot be reached.""" + getattr(mock_sunsynk_client, method).side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is result + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test a device is created for each inverter.""" + await setup_integration(hass, mock_config_entry) + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 3 + assert devices == snapshot diff --git a/tests/components/sunsynk/test_sensor.py b/tests/components/sunsynk/test_sensor.py new file mode 100644 index 000000000000..ce27d694c571 --- /dev/null +++ b/tests/components/sunsynk/test_sensor.py @@ -0,0 +1,128 @@ +"""Test the Sunsynk sensors.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from sunsynk.exceptions import SunsynkConnectionError +from sunsynk.grid import Grid +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sunsynk.const import DOMAIN, SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID_GRID_POWER = "sensor.garage_inverter_grid_power" +ENTITY_ID_GRID_POWER_2 = "sensor.inverter_2938475610_grid_power" + + +@pytest.mark.usefixtures("mock_sunsynk_client", "entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entities.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_unavailable_on_error( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensors become unavailable when an update fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + + grid = mock_sunsynk_client.get_inverter_realtime_grid.side_effect + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = SunsynkConnectionError + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == STATE_UNAVAILABLE + + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = grid + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + + +async def test_one_inverter_unavailable( + hass: HomeAssistant, + mock_sunsynk_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a failing inverter does not affect the other inverters.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + assert hass.states.get(ENTITY_ID_GRID_POWER_2).state == "610.0" + + grid = mock_sunsynk_client.get_inverter_realtime_grid.side_effect + + def failing_grid(sn: str) -> Grid: + if sn == "2938475610": + raise SunsynkConnectionError + return grid(sn) + + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = failing_grid + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + assert hass.states.get(ENTITY_ID_GRID_POWER_2).state == STATE_UNAVAILABLE + + mock_sunsynk_client.get_inverter_realtime_grid.side_effect = grid + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert hass.states.get(ENTITY_ID_GRID_POWER_2).state == "610.0" + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_power_sensors_use_total_of_all_phases( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the grid and load power sensors report the total across all phases.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID_GRID_POWER).state == "610.0" + assert hass.states.get("sensor.garage_inverter_load_power").state == "3427.0" + + +@pytest.mark.usefixtures("mock_sunsynk_client") +async def test_no_battery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an inverter without a battery gets no battery device or entities.""" + await setup_integration(hass, mock_config_entry) + entry_id = mock_config_entry.entry_id + inverter = device_registry.async_get_device_by_identifier( + (DOMAIN, "1029384756"), entry_id + ) + battery = device_registry.async_get_device_by_identifier( + (DOMAIN, "1029384756_battery"), entry_id + ) + assert inverter is not None + assert battery is not None + assert battery.via_device_id == inverter.id + assert hass.states.get("sensor.battery_1029384756_state_of_charge").state == "20.0" + + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, "2938475610_battery"), entry_id + ) + is None + ) + assert hass.states.get("sensor.battery_2938475610_state_of_charge") is None