snapshot fixed sems hacs component
This commit is contained in:
279
homeassistant/custom_components/sems/__init__.py
Normal file
279
homeassistant/custom_components/sems/__init__.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""The sems integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.helpers.entity_registry import async_migrate_entries
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_SCAN_INTERVAL,
|
||||||
|
CONF_STATION_ID,
|
||||||
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
PLATFORMS,
|
||||||
|
)
|
||||||
|
from .sems_api import SemsApi
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: dict):
|
||||||
|
"""Set up the sems component."""
|
||||||
|
# Ensure our name space for storing objects is a known type. A dict is
|
||||||
|
# common/preferred as it allows a separate instance of your class for each
|
||||||
|
# instance that has been created in the UI.
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up sems from a config entry."""
|
||||||
|
semsApi = SemsApi(hass, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
|
||||||
|
coordinator = SemsDataUpdateCoordinator(hass, semsApi, entry)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
# """Set up this integration using UI."""
|
||||||
|
# if hass.data.get(DOMAIN) is None:
|
||||||
|
# hass.data.setdefault(DOMAIN, {})
|
||||||
|
# _LOGGER.info(STARTUP_MESSAGE)
|
||||||
|
|
||||||
|
# username = entry.data.get(CONF_USERNAME)
|
||||||
|
# password = entry.data.get(CONF_PASSWORD)
|
||||||
|
|
||||||
|
# conn = Connection(username, password)
|
||||||
|
# client = MyenergiClient(conn)
|
||||||
|
|
||||||
|
# coordinator = MyenergiDataUpdateCoordinator(hass, client=client, entry=entry)
|
||||||
|
# await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
# hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
# for platform in PLATFORMS:
|
||||||
|
# if entry.options.get(platform, True):
|
||||||
|
# coordinator.platforms.append(platform)
|
||||||
|
# hass.async_add_job(
|
||||||
|
# hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||||
|
# )
|
||||||
|
|
||||||
|
# entry.add_update_listener(async_reload_entry)
|
||||||
|
# return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = all(
|
||||||
|
await asyncio.gather(
|
||||||
|
*[
|
||||||
|
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||||
|
for platform in PLATFORMS
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if unload_ok:
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
# async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
# """Handle removal of an entry."""
|
||||||
|
# coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
# unloaded = all(
|
||||||
|
# await asyncio.gather(
|
||||||
|
# *[
|
||||||
|
# hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||||
|
# for platform in PLATFORMS
|
||||||
|
# if platform in coordinator.platforms
|
||||||
|
# ]
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# if unloaded:
|
||||||
|
# hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
# return unloaded
|
||||||
|
|
||||||
|
|
||||||
|
# async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
# """Reload config entry."""
|
||||||
|
# await async_unload_entry(hass, entry)
|
||||||
|
# await async_setup_entry(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
|
class SemsDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Class to manage fetching data from the API."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, semsApi: SemsApi, entry) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self.semsApi = semsApi
|
||||||
|
self.platforms = []
|
||||||
|
self.stationId = entry.data[CONF_STATION_ID]
|
||||||
|
self.hass = hass
|
||||||
|
|
||||||
|
update_interval = timedelta(
|
||||||
|
seconds=entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
config_entry=entry,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=update_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self):
|
||||||
|
"""Fetch data from API endpoint.
|
||||||
|
|
||||||
|
This is the place to pre-process the data to lookup tables
|
||||||
|
so entities can quickly look up their data.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||||
|
# handled by the data update coordinator.
|
||||||
|
# async with async_timeout.timeout(10):
|
||||||
|
result = await self.hass.async_add_executor_job(
|
||||||
|
self.semsApi.getData, self.stationId
|
||||||
|
)
|
||||||
|
_LOGGER.debug("semsApi.getData result: %s", result)
|
||||||
|
|
||||||
|
# _LOGGER.warning("SEMS - Try get getPowerStationIds")
|
||||||
|
# powerStationIds = await self.hass.async_add_executor_job(
|
||||||
|
# self.semsApi.getPowerStationIds
|
||||||
|
# )
|
||||||
|
# _LOGGER.warning(
|
||||||
|
# "SEMS - getPowerStationIds: Found power station IDs: %s",
|
||||||
|
# powerStationIds,
|
||||||
|
# )
|
||||||
|
|
||||||
|
inverters = result["inverter"]
|
||||||
|
|
||||||
|
# found = []
|
||||||
|
# _LOGGER.debug("Found inverters: %s", inverters)
|
||||||
|
data = {}
|
||||||
|
if inverters is None:
|
||||||
|
# something went wrong, probably token could not be fetched
|
||||||
|
raise UpdateFailed(
|
||||||
|
"Error communicating with API, probably token could not be fetched, see debug logs"
|
||||||
|
)
|
||||||
|
for inverter in inverters:
|
||||||
|
name = inverter["invert_full"]["name"]
|
||||||
|
# powerstation_id = inverter["invert_full"]["powerstation_id"]
|
||||||
|
sn = inverter["invert_full"]["sn"]
|
||||||
|
_LOGGER.debug("Found inverter attribute %s %s", name, sn)
|
||||||
|
data[sn] = inverter["invert_full"]
|
||||||
|
|
||||||
|
hasPowerflow = result["hasPowerflow"]
|
||||||
|
hasEnergeStatisticsCharts = result["hasEnergeStatisticsCharts"]
|
||||||
|
|
||||||
|
if hasPowerflow:
|
||||||
|
_LOGGER.debug("Found powerflow data")
|
||||||
|
if hasEnergeStatisticsCharts:
|
||||||
|
StatisticsCharts = {
|
||||||
|
f"Charts_{key}": val
|
||||||
|
for key, val in result["energeStatisticsCharts"].items()
|
||||||
|
}
|
||||||
|
StatisticsTotals = {
|
||||||
|
f"Totals_{key}": val
|
||||||
|
for key, val in result["energeStatisticsTotals"].items()
|
||||||
|
}
|
||||||
|
powerflow = {
|
||||||
|
**result["powerflow"],
|
||||||
|
**StatisticsCharts,
|
||||||
|
**StatisticsTotals,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
powerflow = result["powerflow"]
|
||||||
|
|
||||||
|
powerflow["sn"] = result["homKit"]["sn"]
|
||||||
|
|
||||||
|
# Goodwe 'Power Meter' (not HomeKit) doesn't have a sn
|
||||||
|
# Let's put something in, otherwise we can't see the data.
|
||||||
|
if powerflow["sn"] is None:
|
||||||
|
powerflow["sn"] = "GW-HOMEKIT-NO-SERIAL"
|
||||||
|
|
||||||
|
# _LOGGER.debug("homeKit sn: %s", result["homKit"]["sn"])
|
||||||
|
# This seems more accurate than the Chart_sum
|
||||||
|
powerflow["all_time_generation"] = result["kpi"]["total_power"]
|
||||||
|
|
||||||
|
data["homeKit"] = powerflow
|
||||||
|
|
||||||
|
_LOGGER.debug("Resulting data: %s", data)
|
||||||
|
return data
|
||||||
|
# except ApiError as err:
|
||||||
|
except Exception as err:
|
||||||
|
# logging.exception("Something awful happened!")
|
||||||
|
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||||
|
|
||||||
|
|
||||||
|
# # migrate to _power ids for inverter entry
|
||||||
|
# async def async_migrate_entry(hass, config_entry):
|
||||||
|
# """Migrate old entry."""
|
||||||
|
# _LOGGER.debug(
|
||||||
|
# "Migrating configuration from version %s.%s",
|
||||||
|
# config_entry.version,
|
||||||
|
# config_entry.minor_version,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# if config_entry.version < 7:
|
||||||
|
# # get existing entities for device
|
||||||
|
# semsApi = SemsApi(
|
||||||
|
# hass, config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
|
||||||
|
# )
|
||||||
|
# coordinator = SemsDataUpdateCoordinator(hass, semsApi, config_entry)
|
||||||
|
# await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
# _LOGGER.debug(f"found inverter {coordinator.data}")
|
||||||
|
|
||||||
|
# for idx, ent in enumerate(coordinator.data):
|
||||||
|
# _LOGGER.debug("Found inverter: %s", ent)
|
||||||
|
|
||||||
|
# old_unique_id = f"{ent}"
|
||||||
|
# new_unique_id = f"{old_unique_id}-XXX"
|
||||||
|
# _LOGGER.debug(
|
||||||
|
# "Old unique id: %s; new unique id: %s", old_unique_id, new_unique_id
|
||||||
|
# )
|
||||||
|
|
||||||
|
# @callback
|
||||||
|
# def update_unique_id(entity_entry):
|
||||||
|
# """Update unique ID of entity entry."""
|
||||||
|
# return {
|
||||||
|
# "new_unique_id": entity_entry.unique_id.replace(
|
||||||
|
# old_unique_id, new_unique_id
|
||||||
|
# )
|
||||||
|
# }
|
||||||
|
|
||||||
|
# if old_unique_id != new_unique_id:
|
||||||
|
# await async_migrate_entries(
|
||||||
|
# hass, config_entry.entry_id, update_unique_id
|
||||||
|
# )
|
||||||
|
|
||||||
|
# hass.config_entries.async_update_entry(
|
||||||
|
# config_entry, unique_id=new_unique_id
|
||||||
|
# )
|
||||||
|
# # version = 7
|
||||||
|
# _LOGGER.info(
|
||||||
|
# "Migrated unique id from %s to %s", old_unique_id, new_unique_id
|
||||||
|
# )
|
||||||
|
|
||||||
|
# _LOGGER.info(
|
||||||
|
# "Migration from version %s.%s successful",
|
||||||
|
# config_entry.version,
|
||||||
|
# config_entry.minor_version,
|
||||||
|
# )
|
||||||
|
|
||||||
|
# return True
|
||||||
112
homeassistant/custom_components/sems/config_flow.py
Normal file
112
homeassistant/custom_components/sems/config_flow.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Config flow for sems integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from .const import DOMAIN, SEMS_CONFIG_SCHEMA, CONF_STATION_ID
|
||||||
|
from .sems_api import SemsApi
|
||||||
|
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_password(user_input: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Mask password in user input for logging."""
|
||||||
|
masked_input = user_input.copy()
|
||||||
|
if CONF_PASSWORD in masked_input:
|
||||||
|
masked_input[CONF_PASSWORD] = "<masked>"
|
||||||
|
return masked_input
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate the user input allows us to connect.
|
||||||
|
|
||||||
|
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"SEMS - Start validation config flow user input, with input data: %s",
|
||||||
|
mask_password(data),
|
||||||
|
)
|
||||||
|
api = SemsApi(hass, data[CONF_USERNAME], data[CONF_PASSWORD])
|
||||||
|
|
||||||
|
authenticated = await hass.async_add_executor_job(api.test_authentication)
|
||||||
|
# If you cannot connect:
|
||||||
|
# throw CannotConnect
|
||||||
|
# If the authentication is wrong:
|
||||||
|
# InvalidAuth
|
||||||
|
if not authenticated:
|
||||||
|
raise InvalidAuth
|
||||||
|
|
||||||
|
# If optional station ID is not provided, query the SEMS API for the first found
|
||||||
|
if CONF_STATION_ID not in data:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"SEMS - No station ID provided, query SEMS API, using first found"
|
||||||
|
)
|
||||||
|
powerStationId = await hass.async_add_executor_job(api.getPowerStationIds)
|
||||||
|
_LOGGER.debug("SEMS - Found power station IDs: %s", powerStationId)
|
||||||
|
|
||||||
|
data[CONF_STATION_ID] = powerStationId
|
||||||
|
|
||||||
|
# Return info that you want to store in the config entry.
|
||||||
|
_LOGGER.debug("SEMS - validate_input Returning data: %s", mask_password(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for sems."""
|
||||||
|
|
||||||
|
_LOGGER.debug("SEMS - new config flow")
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
# CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
if user_input is None:
|
||||||
|
return self.async_show_form(step_id="user", data_schema=SEMS_CONFIG_SCHEMA)
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Creating config entry for %s with data: %s",
|
||||||
|
info[CONF_STATION_ID],
|
||||||
|
mask_password(info),
|
||||||
|
)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"Inverter {info[CONF_STATION_ID]}", data=info
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=SEMS_CONFIG_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(HomeAssistantError):
|
||||||
|
"""Error to indicate we cannot connect."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAuth(HomeAssistantError):
|
||||||
|
"""Error to indicate there is invalid auth."""
|
||||||
25
homeassistant/custom_components/sems/const.py
Normal file
25
homeassistant/custom_components/sems/const.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Constants for the SEMS integration."""
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
|
||||||
|
|
||||||
|
DOMAIN = "sems"
|
||||||
|
|
||||||
|
PLATFORMS = ["sensor", "switch"]
|
||||||
|
|
||||||
|
CONF_STATION_ID = "powerstation_id"
|
||||||
|
|
||||||
|
DEFAULT_SCAN_INTERVAL = 60 # timedelta(seconds=60)
|
||||||
|
|
||||||
|
# Validation of the user's configuration
|
||||||
|
SEMS_CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
vol.Optional(CONF_STATION_ID): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_SCAN_INTERVAL, description={"suggested_value": 60}
|
||||||
|
): int, # , default=DEFAULT_SCAN_INTERVAL
|
||||||
|
}
|
||||||
|
)
|
||||||
14
homeassistant/custom_components/sems/manifest.json
Normal file
14
homeassistant/custom_components/sems/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"domain": "sems",
|
||||||
|
"name": "GoodWe SEMS API",
|
||||||
|
"codeowners": [
|
||||||
|
"@TimSoethout"
|
||||||
|
],
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"documentation": "https://github.com/TimSoethout/goodwe-sems-home-assistant",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"issue_tracker": "https://github.com/TimSoethout/goodwe-sems-home-assistant/issues",
|
||||||
|
"requirements": [],
|
||||||
|
"version": "7.4.1"
|
||||||
|
}
|
||||||
259
homeassistant/custom_components/sems/sems_api.py
Normal file
259
homeassistant/custom_components/sems/sems_api.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from homeassistant import exceptions
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_LoginURL = "https://www.semsportal.com/api/v2/Common/CrossLogin"
|
||||||
|
_GetPowerStationIdByOwnerURLPart = "/PowerStation/GetPowerStationIdByOwner"
|
||||||
|
_PowerStationURLPart = "/v3/PowerStation/GetMonitorDetailByPowerstationId"
|
||||||
|
# _PowerControlURL = (
|
||||||
|
# "https://www.semsportal.com/api/PowerStation/SaveRemoteControlInverter"
|
||||||
|
# )
|
||||||
|
_PowerControlURLPart = "/PowerStation/SaveRemoteControlInverter"
|
||||||
|
_RequestTimeout = 30 # seconds
|
||||||
|
|
||||||
|
_DefaultHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"token": '{"version":"","client":"ios","language":"en"}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SemsApi:
|
||||||
|
"""Interface to the SEMS API."""
|
||||||
|
|
||||||
|
def __init__(self, hass, username, password):
|
||||||
|
"""Init dummy hub."""
|
||||||
|
self._hass = hass
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._token = None
|
||||||
|
|
||||||
|
def test_authentication(self) -> bool:
|
||||||
|
"""Test if we can authenticate with the host."""
|
||||||
|
try:
|
||||||
|
self._token = self.getLoginToken(self._username, self._password)
|
||||||
|
return self._token is not None
|
||||||
|
except Exception as exception:
|
||||||
|
_LOGGER.exception("SEMS Authentication exception: %s", exception)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def getLoginToken(self, userName, password):
|
||||||
|
"""Get the login token for the SEMS API."""
|
||||||
|
try:
|
||||||
|
# Get our Authentication Token from SEMS Portal API
|
||||||
|
_LOGGER.debug("SEMS - Getting API token")
|
||||||
|
|
||||||
|
# Prepare Login Data to retrieve Authentication Token
|
||||||
|
# Dict won't work here somehow, so this magic string creation must do.
|
||||||
|
login_data = '{"account":"' + userName + '","pwd":"' + password + '"}'
|
||||||
|
# login_data = {"account": userName, "pwd": password}
|
||||||
|
|
||||||
|
# Make POST request to retrieve Authentication Token from SEMS API
|
||||||
|
login_response = requests.post(
|
||||||
|
_LoginURL,
|
||||||
|
headers=_DefaultHeaders,
|
||||||
|
data=login_data,
|
||||||
|
timeout=_RequestTimeout,
|
||||||
|
)
|
||||||
|
_LOGGER.debug("Login Response: %s", login_response)
|
||||||
|
# _LOGGER.debug("Login Response text: %s", login_response.text)
|
||||||
|
|
||||||
|
login_response.raise_for_status()
|
||||||
|
|
||||||
|
# Process response as JSON
|
||||||
|
jsonResponse = login_response.json() # json.loads(login_response.text)
|
||||||
|
# _LOGGER.debug("Login JSON response %s", jsonResponse)
|
||||||
|
# Get all the details from our response, needed to make the next POST request (the one that really fetches the data)
|
||||||
|
# Also store the api url send with the authentication request for later use
|
||||||
|
tokenDict = jsonResponse["data"]
|
||||||
|
tokenDict["api"] = jsonResponse["api"]
|
||||||
|
|
||||||
|
_LOGGER.debug("SEMS - API Token received: %s", tokenDict)
|
||||||
|
return tokenDict
|
||||||
|
except Exception as exception:
|
||||||
|
_LOGGER.error("Unable to fetch login token from SEMS API. %s", exception)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getPowerStationIds(self, renewToken=False, maxTokenRetries=2) -> str:
|
||||||
|
"""Get the power station ids from the SEMS API."""
|
||||||
|
try:
|
||||||
|
# Get the status of our SEMS Power Station
|
||||||
|
_LOGGER.debug(
|
||||||
|
"SEMS - getPowerStationIds Making Power Station Status API Call"
|
||||||
|
)
|
||||||
|
if maxTokenRetries <= 0:
|
||||||
|
_LOGGER.info(
|
||||||
|
"SEMS - Maximum token fetch tries reached, aborting for now"
|
||||||
|
)
|
||||||
|
raise OutOfRetries
|
||||||
|
if self._token is None or renewToken:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"API token not set (%s) or new token requested (%s), fetching",
|
||||||
|
self._token,
|
||||||
|
renewToken,
|
||||||
|
)
|
||||||
|
self._token = self.getLoginToken(self._username, self._password)
|
||||||
|
|
||||||
|
# Prepare Power Station status Headers
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"token": json.dumps(self._token),
|
||||||
|
}
|
||||||
|
|
||||||
|
getPowerStationIdUrl = self._token["api"] + _GetPowerStationIdByOwnerURLPart
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Querying SEMS API (%s) for power station ids by owner",
|
||||||
|
getPowerStationIdUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
getPowerStationIdUrl,
|
||||||
|
headers=headers,
|
||||||
|
# data=data,
|
||||||
|
timeout=_RequestTimeout,
|
||||||
|
)
|
||||||
|
jsonResponse = response.json()
|
||||||
|
_LOGGER.debug("Response: %s", jsonResponse)
|
||||||
|
# try again and renew token is unsuccessful
|
||||||
|
if (
|
||||||
|
jsonResponse["msg"] not in ["Successful", "操作成功"]
|
||||||
|
or jsonResponse["data"] is None
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"GetPowerStationIdByOwner Query not successful (%s), retrying with new token, %s retries remaining",
|
||||||
|
jsonResponse["msg"],
|
||||||
|
maxTokenRetries,
|
||||||
|
)
|
||||||
|
return self.getPowerStationIds(
|
||||||
|
True, maxTokenRetries=maxTokenRetries - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonResponse["data"]
|
||||||
|
except Exception as exception:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Unable to fetch power station Ids from SEMS Api. %s", exception
|
||||||
|
)
|
||||||
|
|
||||||
|
def getData(self, powerStationId, renewToken=False, maxTokenRetries=2) -> dict:
|
||||||
|
"""Get the latest data from the SEMS API and updates the state."""
|
||||||
|
try:
|
||||||
|
# Get the status of our SEMS Power Station
|
||||||
|
_LOGGER.debug("SEMS - Making Power Station Status API Call")
|
||||||
|
if maxTokenRetries <= 0:
|
||||||
|
_LOGGER.info(
|
||||||
|
"SEMS - Maximum token fetch tries reached, aborting for now"
|
||||||
|
)
|
||||||
|
raise OutOfRetries
|
||||||
|
if self._token is None or renewToken:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"API token not set (%s) or new token requested (%s), fetching",
|
||||||
|
self._token,
|
||||||
|
renewToken,
|
||||||
|
)
|
||||||
|
self._token = self.getLoginToken(self._username, self._password)
|
||||||
|
|
||||||
|
# Prepare Power Station status Headers
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"token": json.dumps(self._token),
|
||||||
|
}
|
||||||
|
|
||||||
|
powerStationURL = self._token["api"] + _PowerStationURLPart
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Querying SEMS API (%s) for power station id: %s",
|
||||||
|
powerStationURL,
|
||||||
|
powerStationId,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = '{"powerStationId":"' + powerStationId + '"}'
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
powerStationURL, headers=headers, data=data, timeout=_RequestTimeout
|
||||||
|
)
|
||||||
|
jsonResponse = response.json()
|
||||||
|
_LOGGER.debug("Response: %s", jsonResponse)
|
||||||
|
# try again and renew token is unsuccessful
|
||||||
|
if (
|
||||||
|
jsonResponse["msg"] not in ["success", "操作成功"]
|
||||||
|
or jsonResponse["data"] is None
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Query not successful (%s), retrying with new token, %s retries remaining",
|
||||||
|
jsonResponse["msg"],
|
||||||
|
maxTokenRetries,
|
||||||
|
)
|
||||||
|
return self.getData(
|
||||||
|
powerStationId, True, maxTokenRetries=maxTokenRetries - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonResponse["data"]
|
||||||
|
except Exception as exception:
|
||||||
|
_LOGGER.error("Unable to fetch data from SEMS. %s", exception)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def change_status(self, inverterSn, status, renewToken=False, maxTokenRetries=2):
|
||||||
|
"""Schedule the downtime of the station"""
|
||||||
|
try:
|
||||||
|
# Get the status of our SEMS Power Station
|
||||||
|
_LOGGER.debug("SEMS - Making Power Station Status API Call")
|
||||||
|
if maxTokenRetries <= 0:
|
||||||
|
_LOGGER.info(
|
||||||
|
"SEMS - Maximum token fetch tries reached, aborting for now"
|
||||||
|
)
|
||||||
|
raise OutOfRetries
|
||||||
|
if self._token is None or renewToken:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"API token not set (%s) or new token requested (%s), fetching",
|
||||||
|
self._token,
|
||||||
|
renewToken,
|
||||||
|
)
|
||||||
|
self._token = self.getLoginToken(self._username, self._password)
|
||||||
|
|
||||||
|
# Prepare Power Station status Headers
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"token": json.dumps(self._token),
|
||||||
|
}
|
||||||
|
|
||||||
|
powerControlURL = self._token["api"] + _PowerControlURLPart
|
||||||
|
# powerControlURL = _PowerControlURL
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Sending power control command (%s) for power station id: %s",
|
||||||
|
powerControlURL,
|
||||||
|
inverterSn,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"InverterSN": inverterSn,
|
||||||
|
"InverterStatusSettingMark": "1",
|
||||||
|
"InverterStatus": str(status),
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
powerControlURL, headers=headers, json=data, timeout=_RequestTimeout
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
# try again and renew token is unsuccessful
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Power control command not successful, retrying with new token, %s retries remaining",
|
||||||
|
maxTokenRetries,
|
||||||
|
)
|
||||||
|
return self.change_status(
|
||||||
|
inverterSn, status, True, maxTokenRetries=maxTokenRetries - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
except Exception as exception:
|
||||||
|
_LOGGER.error("Unable to execute Power control command. %s", exception)
|
||||||
|
|
||||||
|
|
||||||
|
class OutOfRetries(exceptions.HomeAssistantError):
|
||||||
|
"""Error to indicate too many error attempts."""
|
||||||
628
homeassistant/custom_components/sems/sensor.py
Normal file
628
homeassistant/custom_components/sems/sensor.py
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
"""Support for power production statistics from GoodWe SEMS API.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://github.com/TimSoethout/goodwe-sems-home-assistant
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
# from typing import Coroutine
|
||||||
|
|
||||||
|
import homeassistant
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.components.switch import SwitchEntity
|
||||||
|
from homeassistant.const import CONF_SCAN_INTERVAL, UnitOfEnergy, UnitOfPower, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import CONF_STATION_ID, DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
|
"""Add sensors for passed config_entry in HA."""
|
||||||
|
# _LOGGER.debug("hass.data[DOMAIN] %s", hass.data[DOMAIN])
|
||||||
|
# semsApi = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
# stationId = config_entry.data[CONF_STATION_ID]
|
||||||
|
|
||||||
|
# _LOGGER.debug("config_entry %s", config_entry.data)
|
||||||
|
# update_interval = timedelta(
|
||||||
|
# seconds=config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||||
|
# )
|
||||||
|
|
||||||
|
# async def async_update_data():
|
||||||
|
# """Fetch data from API endpoint.
|
||||||
|
|
||||||
|
# This is the place to pre-process the data to lookup tables
|
||||||
|
# so entities can quickly look up their data.
|
||||||
|
# """
|
||||||
|
# try:
|
||||||
|
# # Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||||
|
# # handled by the data update coordinator.
|
||||||
|
# # async with async_timeout.timeout(10):
|
||||||
|
# result = await hass.async_add_executor_job(semsApi.getData, stationId)
|
||||||
|
# _LOGGER.debug("Resulting result: %s", result)
|
||||||
|
|
||||||
|
# inverters = result["inverter"]
|
||||||
|
|
||||||
|
# # found = []
|
||||||
|
# # _LOGGER.debug("Found inverters: %s", inverters)
|
||||||
|
# data = {}
|
||||||
|
# if inverters is None:
|
||||||
|
# # something went wrong, probably token could not be fetched
|
||||||
|
# raise UpdateFailed(
|
||||||
|
# "Error communicating with API, probably token could not be fetched, see debug logs"
|
||||||
|
# )
|
||||||
|
# for inverter in inverters:
|
||||||
|
# name = inverter["invert_full"]["name"]
|
||||||
|
# # powerstation_id = inverter["invert_full"]["powerstation_id"]
|
||||||
|
# sn = inverter["invert_full"]["sn"]
|
||||||
|
# _LOGGER.debug("Found inverter attribute %s %s", name, sn)
|
||||||
|
# data[sn] = inverter["invert_full"]
|
||||||
|
|
||||||
|
# hasPowerflow = result["hasPowerflow"]
|
||||||
|
# hasEnergeStatisticsCharts = result["hasEnergeStatisticsCharts"]
|
||||||
|
|
||||||
|
# if hasPowerflow:
|
||||||
|
# if hasEnergeStatisticsCharts:
|
||||||
|
# StatisticsCharts = {
|
||||||
|
# f"Charts_{key}": val
|
||||||
|
# for key, val in result["energeStatisticsCharts"].items()
|
||||||
|
# }
|
||||||
|
# StatisticsTotals = {
|
||||||
|
# f"Totals_{key}": val
|
||||||
|
# for key, val in result["energeStatisticsTotals"].items()
|
||||||
|
# }
|
||||||
|
# powerflow = {
|
||||||
|
# **result["powerflow"],
|
||||||
|
# **StatisticsCharts,
|
||||||
|
# **StatisticsTotals,
|
||||||
|
# }
|
||||||
|
# else:
|
||||||
|
# powerflow = result["powerflow"]
|
||||||
|
|
||||||
|
# powerflow["sn"] = result["homKit"]["sn"]
|
||||||
|
|
||||||
|
# # Goodwe 'Power Meter' (not HomeKit) doesn't have a sn
|
||||||
|
# # Let's put something in, otherwise we can't see the data.
|
||||||
|
# if powerflow["sn"] is None:
|
||||||
|
# powerflow["sn"] = "GW-HOMEKIT-NO-SERIAL"
|
||||||
|
|
||||||
|
# #_LOGGER.debug("homeKit sn: %s", result["homKit"]["sn"])
|
||||||
|
# # This seems more accurate than the Chart_sum
|
||||||
|
# powerflow["all_time_generation"] = result["kpi"]["total_power"]
|
||||||
|
|
||||||
|
# data["homeKit"] = powerflow
|
||||||
|
|
||||||
|
# # _LOGGER.debug("Resulting data: %s", data)
|
||||||
|
# return data
|
||||||
|
# # except ApiError as err:
|
||||||
|
# except Exception as err:
|
||||||
|
# # logging.exception("Something awful happened!")
|
||||||
|
# raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||||
|
|
||||||
|
# coordinator = DataUpdateCoordinator(
|
||||||
|
# hass,
|
||||||
|
# _LOGGER,
|
||||||
|
# # Name of the data. For logging purposes.
|
||||||
|
# name="SEMS API",
|
||||||
|
# update_method=async_update_data,
|
||||||
|
# # Polling interval. Will only be polled if there are subscribers.
|
||||||
|
# update_interval=update_interval,
|
||||||
|
# )
|
||||||
|
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
|
||||||
|
#
|
||||||
|
# Fetch initial data so we have data when entities subscribe
|
||||||
|
#
|
||||||
|
# If the refresh fails, async_config_entry_first_refresh will
|
||||||
|
# raise ConfigEntryNotReady and setup will try again later
|
||||||
|
#
|
||||||
|
# If you do not want to retry setup on failure, use
|
||||||
|
# coordinator.async_refresh() instead
|
||||||
|
#
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
# _LOGGER.debug("Initial coordinator data: %s", coordinator.data)
|
||||||
|
|
||||||
|
for _idx, ent in enumerate(coordinator.data):
|
||||||
|
_migrate_to_new_unique_id(hass, ent)
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
SemsSensor(coordinator, ent)
|
||||||
|
for idx, ent in enumerate(coordinator.data)
|
||||||
|
# Don't make SemsSensor for homeKit, since it is not an inverter; unsure how this could work before...
|
||||||
|
if ent != "homeKit"
|
||||||
|
)
|
||||||
|
async_add_entities(
|
||||||
|
SemsStatisticsSensor(coordinator, ent)
|
||||||
|
for idx, ent in enumerate(coordinator.data)
|
||||||
|
# Don't make SemsStatisticsSensor for homeKit, since it is not an inverter; unsure how this could work before...
|
||||||
|
if ent != "homeKit"
|
||||||
|
)
|
||||||
|
async_add_entities(
|
||||||
|
SemsPowerflowSensor(coordinator, ent)
|
||||||
|
for idx, ent in enumerate(coordinator.data)
|
||||||
|
if ent == "homeKit"
|
||||||
|
)
|
||||||
|
async_add_entities(
|
||||||
|
SemsTotalImportSensor(coordinator, ent)
|
||||||
|
for idx, ent in enumerate(coordinator.data)
|
||||||
|
if ent == "homeKit"
|
||||||
|
)
|
||||||
|
async_add_entities(
|
||||||
|
SemsTotalExportSensor(coordinator, ent)
|
||||||
|
for idx, ent in enumerate(coordinator.data)
|
||||||
|
if ent == "homeKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Migrate old power sensor unique ids to new unique ids (with `-power`)
|
||||||
|
def _migrate_to_new_unique_id(hass: HomeAssistant, sn: str) -> None:
|
||||||
|
"""Migrate old unique ids to new unique ids."""
|
||||||
|
ent_reg = entity_registry.async_get(hass)
|
||||||
|
|
||||||
|
old_unique_id = sn
|
||||||
|
new_unique_id = f"{old_unique_id}-power"
|
||||||
|
_LOGGER.debug("Old unique id: %s; new unique id: %s", old_unique_id, new_unique_id)
|
||||||
|
entity_id = ent_reg.async_get_entity_id(Platform.SENSOR, DOMAIN, old_unique_id)
|
||||||
|
_LOGGER.debug("Entity ID: %s", entity_id)
|
||||||
|
if entity_id is not None:
|
||||||
|
try:
|
||||||
|
ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
||||||
|
except ValueError:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Skip migration of id [%s] to [%s] because it already exists",
|
||||||
|
old_unique_id,
|
||||||
|
new_unique_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Migrating unique_id from [%s] to [%s]",
|
||||||
|
old_unique_id,
|
||||||
|
new_unique_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SemsSensor(CoordinatorEntity, SensorEntity):
|
||||||
|
"""SemsSensor using CoordinatorEntity.
|
||||||
|
|
||||||
|
The CoordinatorEntity class provides:
|
||||||
|
should_poll
|
||||||
|
async_update
|
||||||
|
async_added_to_hass
|
||||||
|
available
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Sensor has name determined by device class (e.g. Inverter 123456 Power)
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator, sn) -> None:
|
||||||
|
"""Pass coordinator to CoordinatorEntity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sn = sn
|
||||||
|
self._attr_unique_id = f"{self.coordinator.data[self.sn]['sn']}-power"
|
||||||
|
_LOGGER.debug("Creating SemsSensor with id %s", self.sn)
|
||||||
|
self._attr_unique_id = f"{self.coordinator.data[self.sn]['sn']}-power"
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Creating SemsSensor with id %s and data %s",
|
||||||
|
self.sn,
|
||||||
|
self.coordinator.data[self.sn],
|
||||||
|
)
|
||||||
|
|
||||||
|
_attr_device_class = SensorDeviceClass.POWER
|
||||||
|
_attr_native_unit_of_measurement = UnitOfPower.WATT
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self):
|
||||||
|
"""Return the value reported by the sensor."""
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
return data["pac"] if data["status"] == 1 else 0
|
||||||
|
|
||||||
|
def _statusText(self, status) -> str:
|
||||||
|
labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"}
|
||||||
|
return labels.get(status, "Unknown")
|
||||||
|
|
||||||
|
# For backwards compatibility
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self):
|
||||||
|
"""Return the state attributes of the monitored installation."""
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
attributes = {k: v for k, v in data.items() if k is not None and v is not None}
|
||||||
|
attributes["statusText"] = self._statusText(data["status"])
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return entity status."""
|
||||||
|
return self.coordinator.data[self.sn]["status"] == 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""Return if entity is available."""
|
||||||
|
return self.coordinator.last_update_success
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
"""Return device information."""
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self.sn)},
|
||||||
|
name=f"Inverter {self.coordinator.data[self.sn]['name']}",
|
||||||
|
manufacturer="GoodWe",
|
||||||
|
model=self.extra_state_attributes.get("model_type", "unknown"),
|
||||||
|
sw_version=self.extra_state_attributes.get("firmwareversion", "unknown"),
|
||||||
|
configuration_url=f"https://semsportal.com/PowerStation/PowerStatusSnMin/{self.coordinator.data[self.sn]['powerstation_id']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the entity.
|
||||||
|
|
||||||
|
Only used by the generic entity update service.
|
||||||
|
"""
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
class SemsStatisticsSensor(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Sensor in kWh to enable HA statistics, in the end usable in the power component."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator, sn) -> None:
|
||||||
|
"""Pass coordinator to CoordinatorEntity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sn = sn
|
||||||
|
_LOGGER.debug("Creating SemsStatisticsSensor with id %s", self.sn)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Creating SemsSensor with id %s and data %s",
|
||||||
|
self.sn,
|
||||||
|
self.coordinator.data[self.sn],
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self):
|
||||||
|
return SensorDeviceClass.ENERGY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_of_measurement(self):
|
||||||
|
return UnitOfEnergy.KILO_WATT_HOUR
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def name(self) -> str:
|
||||||
|
# """Return the name of the sensor."""
|
||||||
|
# return f"Inverter {self.coordinator.data[self.sn]['name']} Energy"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
return f"{self.coordinator.data[self.sn]['sn']}-energy"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the device."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"SemsStatisticsSensor state, coordinator data: %s", self.coordinator.data
|
||||||
|
)
|
||||||
|
_LOGGER.debug("SemsStatisticsSensor self.sn: %s", self.sn)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"SemsStatisticsSensor state, self data: %s", self.coordinator.data[self.sn]
|
||||||
|
)
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
return data["etotal"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""No need to poll. Coordinator notifies entity of updates."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
# _LOGGER.debug("self.device_state_attributes: %s", self.device_state_attributes)
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
return {
|
||||||
|
"identifiers": {
|
||||||
|
# Serial numbers are unique identifiers within a specific domain
|
||||||
|
(DOMAIN, self.sn)
|
||||||
|
},
|
||||||
|
# "name": self.name,
|
||||||
|
"manufacturer": "GoodWe",
|
||||||
|
"model": data.get("model_type", "unknown"),
|
||||||
|
"sw_version": data.get("firmwareversion", "unknown"),
|
||||||
|
# "via_device": (DOMAIN, self.api.bridgeid),
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_class(self):
|
||||||
|
"""used by Metered entities / Long Term Statistics"""
|
||||||
|
return SensorStateClass.TOTAL_INCREASING
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the entity.
|
||||||
|
|
||||||
|
Only used by the generic entity update service.
|
||||||
|
"""
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
class SemsTotalImportSensor(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Sensor in kWh to enable HA statistics, in the end usable in the power component."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator, sn):
|
||||||
|
"""Pass coordinator to CoordinatorEntity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sn = sn
|
||||||
|
_LOGGER.debug("Creating SemsStatisticsSensor with id %s", self.sn)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self):
|
||||||
|
return SensorDeviceClass.ENERGY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_of_measurement(self):
|
||||||
|
return UnitOfEnergy.KILO_WATT_HOUR
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the sensor."""
|
||||||
|
return "HomeKit Import"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
return f"{self.coordinator.data[self.sn]['sn']}-import-energy"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the device."""
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
return data["Charts_buy"]
|
||||||
|
|
||||||
|
def statusText(self, status) -> str:
|
||||||
|
labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"}
|
||||||
|
return labels[status] if status in labels else "Unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""No need to poll. Coordinator notifies entity of updates."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
return {
|
||||||
|
"identifiers": {
|
||||||
|
# Serial numbers are unique identifiers within a specific domain
|
||||||
|
(DOMAIN, self.sn)
|
||||||
|
},
|
||||||
|
"name": "Homekit",
|
||||||
|
"manufacturer": "GoodWe",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_class(self):
|
||||||
|
"""used by Metered entities / Long Term Statistics"""
|
||||||
|
return SensorStateClass.TOTAL_INCREASING
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the entity.
|
||||||
|
|
||||||
|
Only used by the generic entity update service.
|
||||||
|
"""
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
class SemsTotalExportSensor(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Sensor in kWh to enable HA statistics, in the end usable in the power component."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator, sn):
|
||||||
|
"""Pass coordinator to CoordinatorEntity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sn = sn
|
||||||
|
_LOGGER.debug("Creating SemsStatisticsSensor with id %s", self.sn)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self):
|
||||||
|
return SensorDeviceClass.ENERGY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_of_measurement(self):
|
||||||
|
return UnitOfEnergy.KILO_WATT_HOUR
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the sensor."""
|
||||||
|
return "HomeKit Export"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
return f"{self.coordinator.data[self.sn]['sn']}-export-energy"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the device."""
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
return data["Charts_sell"]
|
||||||
|
|
||||||
|
def statusText(self, status) -> str:
|
||||||
|
labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"}
|
||||||
|
return labels[status] if status in labels else "Unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""No need to poll. Coordinator notifies entity of updates."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
return {
|
||||||
|
"identifiers": {
|
||||||
|
# Serial numbers are unique identifiers within a specific domain
|
||||||
|
(DOMAIN, self.sn)
|
||||||
|
},
|
||||||
|
"name": "Homekit",
|
||||||
|
"manufacturer": "GoodWe",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_class(self):
|
||||||
|
"""used by Metered entities / Long Term Statistics"""
|
||||||
|
return SensorStateClass.TOTAL_INCREASING
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the entity.
|
||||||
|
|
||||||
|
Only used by the generic entity update service.
|
||||||
|
"""
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
class SemsPowerflowSensor(CoordinatorEntity, SensorEntity):
|
||||||
|
"""SemsPowerflowSensor using CoordinatorEntity.
|
||||||
|
|
||||||
|
The CoordinatorEntity class provides:
|
||||||
|
should_poll
|
||||||
|
async_update
|
||||||
|
async_added_to_hass
|
||||||
|
available
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator, sn):
|
||||||
|
"""Pass coordinator to CoordinatorEntity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sn = sn
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self):
|
||||||
|
return SensorDeviceClass.POWER_FACTOR
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unit_of_measurement(self):
|
||||||
|
return UnitOfPower.WATT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the sensor."""
|
||||||
|
return f"HomeKit {self.coordinator.data[self.sn]['sn']}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self) -> str:
|
||||||
|
return f"{self.coordinator.data[self.sn]['sn']}-homekit"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state of the device."""
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
load = data["load"]
|
||||||
|
|
||||||
|
if load:
|
||||||
|
load = load.replace("(W)", "")
|
||||||
|
|
||||||
|
return load if data["gridStatus"] == 1 else 0
|
||||||
|
|
||||||
|
def statusText(self, status) -> str:
|
||||||
|
labels = {-1: "Offline", 0: "Waiting", 1: "Normal", 2: "Fault"}
|
||||||
|
return labels[status] if status in labels else "Unknown"
|
||||||
|
|
||||||
|
# For backwards compatibility
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self):
|
||||||
|
"""Return the state attributes of the monitored installation."""
|
||||||
|
data = self.coordinator.data[self.sn]
|
||||||
|
|
||||||
|
attributes = {k: v for k, v in data.items() if k is not None and v is not None}
|
||||||
|
|
||||||
|
attributes["pv"] = data["pv"].replace("(W)", "")
|
||||||
|
attributes["bettery"] = data["bettery"].replace("(W)", "")
|
||||||
|
attributes["load"] = data["load"].replace("(W)", "")
|
||||||
|
attributes["grid"] = data["grid"].replace("(W)", "")
|
||||||
|
|
||||||
|
attributes["statusText"] = self.statusText(data["gridStatus"])
|
||||||
|
|
||||||
|
if data["loadStatus"] == -1:
|
||||||
|
attributes["PowerFlowDirection"] = "Export %s" % data["grid"]
|
||||||
|
if data["loadStatus"] == 1:
|
||||||
|
attributes["PowerFlowDirection"] = "Import %s" % data["grid"]
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return entity status."""
|
||||||
|
self.coordinator.data[self.sn]["gridStatus"] == 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_poll(self) -> bool:
|
||||||
|
"""No need to poll. Coordinator notifies entity of updates."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""Return if entity is available."""
|
||||||
|
return self.coordinator.last_update_success
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
return {
|
||||||
|
"identifiers": {
|
||||||
|
# Serial numbers are unique identifiers within a specific domain
|
||||||
|
(DOMAIN, self.sn)
|
||||||
|
},
|
||||||
|
"name": "Homekit",
|
||||||
|
"manufacturer": "GoodWe",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_added_to_hass(self):
|
||||||
|
"""When entity is added to hass."""
|
||||||
|
self.async_on_remove(
|
||||||
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the entity.
|
||||||
|
|
||||||
|
Only used by the generic entity update service.
|
||||||
|
"""
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
26
homeassistant/custom_components/sems/strings.json
Normal file
26
homeassistant/custom_components/sems/strings.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"title": "GoodWe SEMS PV API",
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"powerstation_id": "[%key:common::config_flow::data::powerstation_id%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"scan_interval": "[%key:common::config_flow::data::scan_interval%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"powerstation_id": "[%key:common::config_flow::data_description::powerstation_id%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
homeassistant/custom_components/sems/switch.py
Normal file
93
homeassistant/custom_components/sems/switch.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Support for switch controlling an output of a GoodWe SEMS inverter.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://github.com/TimSoethout/goodwe-sems-home-assistant
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||||
|
"""Add switches for passed config_entry in HA."""
|
||||||
|
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
# stationId = config_entry.data[CONF_STATION_ID]
|
||||||
|
|
||||||
|
# coordinator.data should contain a dictionary of inverters, with as data `invert_full`
|
||||||
|
# Don't make switches for homekit, since it is not an inverter
|
||||||
|
async_add_entities(
|
||||||
|
SemsStatusSwitch(coordinator, ent)
|
||||||
|
for idx, ent in enumerate(coordinator.data)
|
||||||
|
if ent != "homeKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SemsStatusSwitch(CoordinatorEntity, SwitchEntity):
|
||||||
|
"""SemsStatusSwitch using CoordinatorEntity.
|
||||||
|
|
||||||
|
The CoordinatorEntity class provides:
|
||||||
|
should_poll
|
||||||
|
async_update
|
||||||
|
async_added_to_hass
|
||||||
|
available
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Sensor has device name (e.g. Inverter 123456 Power)
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
# _attr_name = None
|
||||||
|
|
||||||
|
def __init__(self, coordinator, sn) -> None:
|
||||||
|
"""Initialize the SemsStatusSwitch.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coordinator: The data update coordinator for managing updates.
|
||||||
|
sn: The serial number of the inverter.
|
||||||
|
|
||||||
|
"""
|
||||||
|
_LOGGER.debug("Try create SemsStatusSwitch for Inverter %s", sn)
|
||||||
|
super().__init__(coordinator, context=sn)
|
||||||
|
self.coordinator = coordinator
|
||||||
|
self.sn = sn
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={
|
||||||
|
# Serial numbers are unique identifiers within a specific domain
|
||||||
|
(DOMAIN, self.sn)
|
||||||
|
},
|
||||||
|
# Commented out for now, since not all inverter entries have a name; could be related to creating too much switch devices, also for non-inverters such as homekit.
|
||||||
|
# name=f"Inverter {self.coordinator.data[self.sn]['name']}",
|
||||||
|
)
|
||||||
|
self._attr_unique_id = f"{self.sn}-switch"
|
||||||
|
# somehow needed, no default naming
|
||||||
|
self._attr_name = "Switch"
|
||||||
|
self._attr_device_class = SwitchDeviceClass.OUTLET
|
||||||
|
_LOGGER.debug("Creating SemsStatusSwitch for Inverter %s", self.sn)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return entity status."""
|
||||||
|
_LOGGER.debug("coordinator.data[sn]: %s", self.coordinator.data[self.sn])
|
||||||
|
return self.coordinator.data[self.sn]["status"] == 1
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs):
|
||||||
|
"""Turn off the inverter."""
|
||||||
|
_LOGGER.debug("Inverter %s set to Off", self.sn)
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
self.coordinator.semsApi.change_status, self.sn, 2
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs):
|
||||||
|
"""Turn on the inverter."""
|
||||||
|
_LOGGER.debug("Inverter %s set to On", self.sn)
|
||||||
|
await self.hass.async_add_executor_job(
|
||||||
|
self.coordinator.semsApi.change_status, self.sn, 4
|
||||||
|
)
|
||||||
26
homeassistant/custom_components/sems/translations/en.json
Normal file
26
homeassistant/custom_components/sems/translations/en.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"powerstation_id": "Power Station ID",
|
||||||
|
"password": "Password",
|
||||||
|
"username": "Username",
|
||||||
|
"scan_interval": "Update Interval (seconds)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"powerstation_id": "If empty, will query SEMS API and take first"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "GoodWe SEMS PV API"
|
||||||
|
}
|
||||||
26
homeassistant/custom_components/sems/translations/pt.json
Normal file
26
homeassistant/custom_components/sems/translations/pt.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Dispositivo já configurado"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Falha na ligação",
|
||||||
|
"invalid_auth": "Autenticação Inválida",
|
||||||
|
"unknown": "Erro inesperado"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"powerstation_id": "ID da Planta",
|
||||||
|
"password": "Password",
|
||||||
|
"username": "Nome de Utilizador",
|
||||||
|
"scan_interval": "Intervalo de Atualização (segundos)"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"powerstation_id": "Se vazio, irá consultar a API SEMS e usar o primeiro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "GoodWe SEMS PV API"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user