mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add Sunsynk integration (#180593)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
co-authored by
Joostlek
parent
616fb5a6e8
commit
4d22d19381
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user