From f05ebbc7c7b6ceade3e7c347c5f9566f66c075ea Mon Sep 17 00:00:00 2001 From: dkarv Date: Thu, 25 Jan 2024 23:06:18 +0100 Subject: [PATCH] initial commit --- .github/workflows/validate.yaml | 18 ++++ copy.sh | 3 + custom_components/lg_ess/__init__.py | 42 +++++++++ custom_components/lg_ess/config_flow.py | 68 ++++++++++++++ custom_components/lg_ess/const.py | 3 + custom_components/lg_ess/coordinator.py | 65 +++++++++++++ custom_components/lg_ess/manifest.json | 15 +++ custom_components/lg_ess/sensor.py | 119 ++++++++++++++++++++++++ custom_components/lg_ess/strings.json | 21 +++++ hacs.json | 3 + 10 files changed, 357 insertions(+) create mode 100644 .github/workflows/validate.yaml create mode 100755 copy.sh create mode 100644 custom_components/lg_ess/__init__.py create mode 100644 custom_components/lg_ess/config_flow.py create mode 100644 custom_components/lg_ess/const.py create mode 100644 custom_components/lg_ess/coordinator.py create mode 100644 custom_components/lg_ess/manifest.json create mode 100644 custom_components/lg_ess/sensor.py create mode 100644 custom_components/lg_ess/strings.json create mode 100644 hacs.json diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..c422ec3 --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,18 @@ +name: Validate + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + validate-hacs: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" diff --git a/copy.sh b/copy.sh new file mode 100755 index 0000000..0cf95e2 --- /dev/null +++ b/copy.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +cp -R ../homeassistant-core/homeassistant/components/lg_ess custom_components/ diff --git a/custom_components/lg_ess/__init__.py b/custom_components/lg_ess/__init__.py new file mode 100644 index 0000000..c518bd4 --- /dev/null +++ b/custom_components/lg_ess/__init__.py @@ -0,0 +1,42 @@ +"""The LG ESS inverter integration.""" + +import logging + +from pyess.aio_ess import ESS, ESSException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LG ESS from config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + try: + ess = await ESS.create("LG_ESS", entry.data["password"], entry.data["host"]) + hass.data[DOMAIN][entry.entry_id] = ess + except ESSException as e: + _LOGGER.exception("Error setting up ESS api") + await ess.destruct() + raise ConfigEntryNotReady from e + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + api = hass.data[DOMAIN].pop(entry.entry_id) + await api.destruct() + + return unload_ok diff --git a/custom_components/lg_ess/config_flow.py b/custom_components/lg_ess/config_flow.py new file mode 100644 index 0000000..c089686 --- /dev/null +++ b/custom_components/lg_ess/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for LG ESS integration.""" +import logging +from typing import Any + +from pyess.aio_ess import ESS, ESSAuthException, ESSException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +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. + """ + # We cannot check with current ESS implementation... + + try: + ess = await ESS.create("TEST_ENTRY", data[CONF_HOST], data[CONF_PASSWORD]) + except Exception as e: + await ess.destruct() + raise e + + # Return info that you want to store in the config entry. + return {"title": "LG ESS"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for LG ESS.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + return self.async_create_entry(title=info["title"], data=user_input) + except ESSAuthException: + _LOGGER.exception("Wrong password") + errors["base"] = "invalid_auth" + except ESSException: + _LOGGER.exception("Generic error setting up the ESS Api") + errors["base"] = "unknown" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/custom_components/lg_ess/const.py b/custom_components/lg_ess/const.py new file mode 100644 index 0000000..7ed280f --- /dev/null +++ b/custom_components/lg_ess/const.py @@ -0,0 +1,3 @@ +"""Constants for the LG ESS Inverter integration.""" + +DOMAIN = "lg_ess" diff --git a/custom_components/lg_ess/coordinator.py b/custom_components/lg_ess/coordinator.py new file mode 100644 index 0000000..9b8a837 --- /dev/null +++ b/custom_components/lg_ess/coordinator.py @@ -0,0 +1,65 @@ +"""Coordinator to fetch the data once for all sensors.""" + +from datetime import timedelta +import logging + +from pyess.aio_ess import ESS + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class CommonCoordinator(DataUpdateCoordinator): + """LG ESS coordinator. + + Data: + {'PV': + {'brand': 'LGE-SOLAR', 'capacity': '10935', + 'pv1_voltage': '52.900002', 'pv2_voltage': '36.099998', 'pv3_voltage': '35.500000', + 'pv1_power': '0', 'pv2_power': '1', 'pv3_power': '1', + 'pv1_current': '0.010000', 'pv2_current': '0.030000', 'pv3_current': '0.030000', + 'today_pv_generation_sum': '16294', 'today_month_pv_generation_sum': '17469'}, + 'BATT': + {'status': '2', 'soc': '10.3', 'dc_power': '627', + 'winter_setting': 'off', 'winter_status': 'off', 'safety_soc': '20', + 'backup_setting': 'off', 'backup_status': 'off', 'backup_soc': '30', + 'today_batt_discharge_enery': '6855', 'today_batt_charge_energy': '9050', 'month_batt_charge_energy': '9616', 'month_batt_discharge_energy': '9264'}, + 'GRID': + {'active_power': '9', 'a_phase': '230.199997', 'freq': '50.020000', + 'today_grid_feed_in_energy': '968', 'today_grid_power_purchase_energy': '7442', + 'month_grid_feed_in_energy': '994', 'month_grid_power_purchase_energy': '13497'}, + 'LOAD': + {'load_power': '638', + 'today_load_consumption_sum': '20573', 'today_pv_direct_consumption_enegy': '6276', 'today_batt_discharge_enery': '6855', 'today_grid_power_purchase_energy': '7442', + 'month_load_consumption_sum': '29620', 'month_pv_direct_consumption_energy': '6859', 'month_batt_discharge_energy': '9264', 'month_grid_power_purchase_energy': '13497'}, + 'PCS': + {'today_self_consumption': '94.1', 'month_co2_reduction_accum': '12402', + 'today_pv_generation_sum': '16294', 'today_grid_feed_in_energy': '968', + 'month_pv_generation_sum': '17469', 'month_grid_feed_in_energy': '994', + 'pcs_stauts': '3', 'feed_in_limitation': '100', 'operation_mode': '0'}} + """ + + def __init__(self, hass: HomeAssistant, ess: ESS) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="LG ESS common", + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=5), + ) + self.ess = ess + + 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. + return await self.ess.get_common() diff --git a/custom_components/lg_ess/manifest.json b/custom_components/lg_ess/manifest.json new file mode 100644 index 0000000..4a2a19e --- /dev/null +++ b/custom_components/lg_ess/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "lg_ess", + "name": "LG ESS Inverter", + "codeowners": ["@dkarv"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/dkarv/hacs-lg-ess/blob/main/README.md", + "version": "0.1.0", + "homekit": {}, + "iot_class": "local_polling", + "issue_tracker": "https://github.com/dkarv/hacs-lg-ess/issues", + "requirements": ["pyess==0.1.15"], + "ssdp": [], + "zeroconf": [] +} diff --git a/custom_components/lg_ess/sensor.py b/custom_components/lg_ess/sensor.py new file mode 100644 index 0000000..e942649 --- /dev/null +++ b/custom_components/lg_ess/sensor.py @@ -0,0 +1,119 @@ +"""Example integration using DataUpdateCoordinator.""" + +import logging + +from pyess.aio_ess import ESSAuthException + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CommonCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors from config entry.""" + ess = hass.data[DOMAIN][config_entry.entry_id] + coordinator = CommonCoordinator(hass, ess) + + # 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 + # + try: + await coordinator.async_config_entry_first_refresh() + except ESSAuthException as e: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from e + + async_add_entities( + [ + MeasurementSensor(coordinator, "BATT", "soc", PERCENTAGE), + MeasurementSensor(coordinator, "BATT", "dc_power", UnitOfPower.WATT), + MeasurementSensor(coordinator, "LOAD", "load_power", UnitOfPower.WATT), + MeasurementSensor(coordinator, "PCS", "today_self_consumption", PERCENTAGE), + IncreasingSensor(coordinator, "BATT", "today_batt_discharge_enery"), + IncreasingSensor(coordinator, "BATT", "today_batt_charge_energy"), + IncreasingSensor(coordinator, "BATT", "month_batt_discharge_energy"), + IncreasingSensor(coordinator, "BATT", "month_batt_charge_energy"), + IncreasingSensor(coordinator, "LOAD", "today_load_consumption_sum"), + IncreasingSensor(coordinator, "LOAD", "today_pv_direct_consumption_enegy"), + IncreasingSensor(coordinator, "LOAD", "today_grid_power_purchase_energy"), + IncreasingSensor(coordinator, "LOAD", "month_load_consumption_sum"), + IncreasingSensor(coordinator, "LOAD", "month_pv_direct_consumption_energy"), + IncreasingSensor(coordinator, "LOAD", "month_grid_power_purchase_energy"), + IncreasingSensor(coordinator, "PCS", "today_pv_generation_sum"), + IncreasingSensor(coordinator, "PCS", "today_grid_feed_in_energy"), + IncreasingSensor(coordinator, "PCS", "month_pv_generation_sum"), + IncreasingSensor(coordinator, "PCS", "month_grid_feed_in_energy"), + ] + ) + + +class EssSensor(CoordinatorEntity[CommonCoordinator], SensorEntity): + """Basic sensor with common functionality.""" + + _group: str + _key: str + + def __init__(self, coordinator, group: str, key: str) -> None: + """Initialize the sensor with the common coordinator.""" + super().__init__(coordinator) + self._group = group + self._key = key + # Fix typos + self._attr_translation_key = key.replace("_enery", "_energy").replace( + "_enegy", "_energy" + ) + self._attr_unique_id = self._attr_translation_key + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.coordinator.data[self._group][self._key] + self.async_write_ha_state() + + +class MeasurementSensor(EssSensor): + """Measurement sensor.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator, group: str, key: str, unit) -> None: + """Initialize the sensor with the common coordinator.""" + super().__init__(coordinator, group, key) + self._attr_native_unit_of_measurement = unit + + +class IncreasingSensor(EssSensor): + """Increasing total sensor.""" + + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_device_class = SensorDeviceClass.ENERGY + + def __init__( + self, coordinator, group: str, key: str, unit=UnitOfEnergy.WATT_HOUR + ) -> None: + """Initialize the sensor with the common coordinator.""" + super().__init__(coordinator, group, key) + self._attr_native_unit_of_measurement = unit diff --git a/custom_components/lg_ess/strings.json b/custom_components/lg_ess/strings.json new file mode 100644 index 0000000..d6e3212 --- /dev/null +++ b/custom_components/lg_ess/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..c351b90 --- /dev/null +++ b/hacs.json @@ -0,0 +1,3 @@ +{ + "name": "LG ESS Inverter Integration" +}