Add Sunsynk integration (#180593)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
James Ridgway
2026-08-30 16:44:15 +02:00
committed by GitHub
co-authored by Joostlek
parent 616fb5a6e8
commit 4d22d19381
30 changed files with 3456 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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)
@@ -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,
)
+11
View File
@@ -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)
@@ -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)
@@ -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,
),
)
@@ -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"
}
}
}
}
@@ -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"]
}
@@ -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
+262
View File
@@ -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)
@@ -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"
}
}
}
}
+1
View File
@@ -765,6 +765,7 @@ FLOWS = {
"suez_water",
"sun",
"sunricher_dali",
"sunsynk",
"sunweg",
"surepetcare",
"swiss_public_transport",
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+12
View File
@@ -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()
+83
View File
@@ -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,
)
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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"
}
]
@@ -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
}
@@ -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
}
@@ -0,0 +1,89 @@
# serializer version: 1
# name: test_devices
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
}),
])
# ---
File diff suppressed because it is too large Load Diff
@@ -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
+75
View File
@@ -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
+128
View File
@@ -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