diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d55989 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Huawei Solar Sensors + +This integration splits out the various values that are fetched from your +Huawei Solar inverter into separate HomeAssistant sensors. These are properly +configured to allow immediate integration into the HA Energy view. + +![sensors](images/sensors-screenshot.png) +![energy-config](images/energy-config.png) + +## Installation + +1. Install this integration with HACS, or copy the contents of this +repository into the `custom_components/huawei_solar` directory +2. Restart HA +3. Go to `Configuration` -> `Integrations` and click the `+ Add Integration` +button +4. Select `Huawei Solar` from the list +5. Enter the IP address of your inverter (192.168.200.1 if you are connected to +its WiFi AP). Select if you have a battery and/or optimizers. The slave id is +typically 0. + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3e65eae --- /dev/null +++ b/__init__.py @@ -0,0 +1,38 @@ +"""The Huawei Solar integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_HOST + +from .const import DOMAIN, CONF_SLAVE, DATA_MODBUS_CLIENT + +from huawei_solar import HuaweiSolar, ConnectionException + +# TODO List the platforms that you want to support. +# For your initial PR, limit it to 1 platform. +PLATFORMS: list[str] = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Huawei Solar from a config entry.""" + # TODO Store an API object for your platforms to access + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_MODBUS_CLIENT: HuaweiSolar( + host=entry.data[CONF_HOST], slave=entry.data[CONF_SLAVE] + ) + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok +"""Huawei Solar integration which connects to the local Modbus TCP endpoint""" \ No newline at end of file diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..cb47766 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for Huawei Solar integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from homeassistant.const import CONF_HOST +from .const import ( + DOMAIN, + CONF_BATTERY, + CONF_OPTIMIZERS, + CONF_SLAVE, + ATTR_MODEL_ID, + ATTR_SERIAL_NUMBER, +) + +from huawei_solar import HuaweiSolar, ConnectionException + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_OPTIMIZERS, default=False): bool, + vol.Optional(CONF_BATTERY, default=False): bool, + vol.Optional(CONF_SLAVE, default=0): int, + } +) + + +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. + """ + # TODO validate the data can be used to set up a connection. + + # If your PyPI package is not built with async, pass your methods + # to the executor: + # await hass.async_add_executor_job( + # your_validate_func, data["username"], data["password"] + # ) + + inverter = HuaweiSolar(host=data[CONF_HOST]) + + try: + model_name = inverter.get(ATTR_MODEL_ID).value + serial_number = inverter.get(ATTR_SERIAL_NUMBER).value + + # Return info that you want to store in the config entry. + return dict(model_name=model_name, serial_number=serial_number) + except ConnectionException as ex: + raise CannotConnect from ex + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Huawei Solar.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_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: + return self.async_create_entry(title=info["model_name"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/const.py b/const.py new file mode 100644 index 0000000..de5bfc6 --- /dev/null +++ b/const.py @@ -0,0 +1,198 @@ +"""Constants for the Huawei Solar integration.""" +from dataclasses import dataclass + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + POWER_WATT, + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + PERCENTAGE, +) + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, + STATE_CLASS_TOTAL_INCREASING, + SensorEntityDescription, +) + +DOMAIN = "huawei_solar" + +DATA_MODBUS_CLIENT = "client" + + +ATTR_MODEL_ID = "model_id" +ATTR_SERIAL_NUMBER = "serial_number" + +CONF_OPTIMIZERS = "optimizers" +CONF_BATTERY = "battery" +CONF_SLAVE = "slave" + +ATTR_DAILY_YIELD = "daily_yield_energy" +ATTR_TOTAL_YIELD = "accumulated_yield_energy" + +ATTR_POWER_FACTOR = "power_factor" + +ATTR_STORAGE_TOTAL_CHARGE = "storage_total_charge" +ATTR_STORAGE_TOTAL_DISCHARGE = "storage_total_discharge" + +ATTR_STORAGE_DAY_CHARGE = "storage_current_day_charge_capacity" +ATTR_STORAGE_DAY_DISCHARGE = "storage_current_day_discharge_capacity" + +ATTR_STORAGE_STATE_OF_CAPACITY = "storage_state_of_capacity" +ATTR_STORAGE_CHARGE_DISCHARGE_POWER = "storage_charge_discharge_power" + +ATTR_GRID_EXPORTED = "grid_exported_energy" +ATTR_GRID_ACCUMULATED = "grid_accumulated_energy" + +ATTR_ACTIVE_POWER = "active_power" +ATTR_INPUT_POWER = "input_power" +ATTR_POWER_METER_ACTIVE_POWER = "power_meter_active_power" + +ATTR_NB_OPTIMIZERS = "nb_optimizers" +ATTR_NB_ONLINE_OPTIMIZERS = "nb_online_optimizers" + +ATTR_NB_PV_STRINGS = "nb_pv_strings" +ATTR_RATED_POWER = "rated_power" +ATTR_GRID_STANDARD = "grid_standard" +ATTR_GRID_COUNTRY = "grid_country" + +ATTR_DAY_POWER_PEAK = "day_active_power_peak" +ATTR_REACTIVE_POWER = "reactive_power" +ATTR_EFFICIENCY = "efficiency" +ATTR_GRID_FREQUENCY = "grid_frequency" +ATTR_GRID_VOLTAGE = "grid_voltage" +ATTR_GRID_CURRENT = "grid_current" +ATTR_STARTUP_TIME = "startup_time" +ATTR_SHUTDOWN_TIME = "shutdown_time" +ATTR_INTERNAL_TEMPERATURE = "internal_temperature" +ATTR_DEVICE_STATUS = "device_status" +ATTR_SYSTEM_TIME = "system_time" + + +@dataclass +class HuaweiSolarSensorEntityDescription(SensorEntityDescription): + pass + + +SENSOR_TYPES: tuple[HuaweiSolarSensorEntityDescription] = ( + HuaweiSolarSensorEntityDescription( + key=ATTR_DAILY_YIELD, + name="Daily Yield", + icon="mdi:solar-power", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_TOTAL_YIELD, + name="Total Yield", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_ACTIVE_POWER, + name="Active Power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_INPUT_POWER, + name="Input Power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_POWER_METER_ACTIVE_POWER, + name="Power Meter Active Power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_POWER_FACTOR, + name="Power Factor", + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_GRID_ACCUMULATED, + name="Grid Consumption", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_GRID_EXPORTED, + name="Grid Exported", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +) + +BATTERY_SENSOR_TYPES = ( + HuaweiSolarSensorEntityDescription( + key=ATTR_STORAGE_TOTAL_CHARGE, + name="Battery Total Charge", + icon="mdi:battery-plus-variant", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL, + device_class=DEVICE_CLASS_ENERGY, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_STORAGE_DAY_CHARGE, + name="Battery Day Charge", + icon="mdi:battery-plus-variant", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_STORAGE_TOTAL_DISCHARGE, + name="Battery Total Discharge", + icon="mdi:battery-minus-variant", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL, + device_class=DEVICE_CLASS_ENERGY, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_STORAGE_DAY_DISCHARGE, + name="Battery Day Discharge", + icon="mdi:battery-minus-variant", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=DEVICE_CLASS_ENERGY, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_STORAGE_STATE_OF_CAPACITY, + name="Battery State of Capacity", + icon="mdi:home-battery", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + HuaweiSolarSensorEntityDescription( + key=ATTR_STORAGE_CHARGE_DISCHARGE_POWER, + name="Charge/Discharge Power", + icon="mdi:flash", + native_unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_POWER, + ), +) + +OPTIMIZER_SENSOR_TYPES = ( + HuaweiSolarSensorEntityDescription( + key=ATTR_NB_ONLINE_OPTIMIZERS, + name="Optimizers Online", + icon="mdi:solar-panel", + native_unit_of_measurement="optimizers", + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), +) diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..b35c066 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Huawei Solar", + "content_in_root": true, + "render_readme": true +} \ No newline at end of file diff --git a/images/energy-config.png b/images/energy-config.png new file mode 100644 index 0000000..e711d83 Binary files /dev/null and b/images/energy-config.png differ diff --git a/images/sensors-screenshot.png b/images/sensors-screenshot.png new file mode 100644 index 0000000..c416fbc Binary files /dev/null and b/images/sensors-screenshot.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..8f60806 --- /dev/null +++ b/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "huawei_solar", + "name": "Huawei Solar", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/huawei_solar", + "requirements": [ + "huawei_solar==1.1.0" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@wlcrs" + ], + "iot_class": "local_polling", + "version": "2.0.0-alpha1" +} \ No newline at end of file diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..41df798 --- /dev/null +++ b/sensor.py @@ -0,0 +1,91 @@ +"""Support for Huawei inverter monitoring API.""" +import logging +import backoff + +from huawei_solar import HuaweiSolar, ConnectionException, ReadException + +from .const import ( + DOMAIN, + DATA_MODBUS_CLIENT, + CONF_BATTERY, + CONF_OPTIMIZERS, + OPTIMIZER_SENSOR_TYPES, + HuaweiSolarSensorEntityDescription, + SENSOR_TYPES, + BATTERY_SENSOR_TYPES, +) + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity + + +async def async_setup_entry(hass, entry, async_add_entities): + """Add Huawei Solar entry""" + inverter = hass.data[DOMAIN][entry.entry_id][DATA_MODBUS_CLIENT] + + serial_number = inverter.get("serial_number").value + name = inverter.get("model_name").value + + device_info = { + "identifiers": {(DOMAIN, name, serial_number)}, + "name": name, + "manufacturer": "Huawei", + "serial_number": serial_number, + } + + async_add_entities( + [HuaweiSolarSensor(inverter, descr, device_info) for descr in SENSOR_TYPES], + True, + ) + + if entry.data[CONF_BATTERY]: + async_add_entities( + [ + HuaweiSolarSensor(inverter, descr, device_info) + for descr in BATTERY_SENSOR_TYPES + ], + True, + ) + + if entry.data[CONF_OPTIMIZERS]: + async_add_entities( + [ + HuaweiSolarSensor(inverter, descr, device_info) + for descr in OPTIMIZER_SENSOR_TYPES + ], + True, + ) + + +class HuaweiSolarSensor(SensorEntity): + + entity_description: HuaweiSolarSensorEntityDescription + + def __init__( + self, + inverter: HuaweiSolar, + description: HuaweiSolarSensorEntityDescription, + device_info, + ): + + self._inverter = inverter + self.entity_description = description + + self._attr_device_info = device_info + self._attr_unique_id = f"{device_info['serial_number']}_{description.key}" + + self._state = None + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data from the Huawei solar inverter.""" + + @backoff.on_exception(backoff.expo, (ConnectionException, ReadException), max_time=120) + def _get_value(): + return self._inverter.get(self.entity_description.key).value + + self._state = _get_value() diff --git a/strings.json b/strings.json new file mode 100644 index 0000000..7db32d1 --- /dev/null +++ b/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "battery": "Battery Present", + "optimizers": "Optimizers present" + } + } + }, + "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%]" + } + } +} \ No newline at end of file diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..8991f41 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "battery": "Battery Present", + "host": "Host", + "optimizers": "Optimizers present" + } + } + } + } +} \ No newline at end of file