diff --git a/README.md b/README.md index 9b4649f..49e4172 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,19 @@ The current charge config, discharge config and charging range will only update If you want to adjust the restrictions yourself, you are able to by modifying the `ALPHA_POST_REQUEST_RESTRICTION` varible in const.py to the amount of seconds allowed per call +## Local Inverter Support + +To use the local inverter support, you will need to have a local inverter that is able to reach your HA instance (preferably on the same subnet). + +To add a local inverter to an existing AlphaESS integration, you will need to select the "Configure" option from the AlphaESS integration in Home Assistant, and then input your inverter's IP address, you can also do this if you need to reconfigure your inverter's IP address (due to DHCP changes, etc). + +To remove/reset the local inverter integration, you will need to go back to the configuration settings, and set it to 0. (this will "remove" all the sensors linked, and will need to be manually deleted) + +For now, if you have more than one inverter linked to your OpenAPI Account, the local inverter settings will only work on the first inverter that is linked to your account. support for setting it to be a custom one is coming. + +![](https://i.imgur.com/rHWI2gh.png) + + ## Issues with registering systems to the AlphaESS OpenAPI There has been a few issues regarding registering systems to the AlphaESS OpenAPI. The following are some of the issues that have been reported and how to resolve them. diff --git a/custom_components/alphaess/__init__.py b/custom_components/alphaess/__init__.py index 3b61758..5aae310 100644 --- a/custom_components/alphaess/__init__.py +++ b/custom_components/alphaess/__init__.py @@ -43,7 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Alpha ESS from a config entry.""" - client = alphaess.alphaess(entry.data["AppID"], entry.data["AppSecret"]) + ip_address = entry.options.get("IPAddress", entry.data.get("IPAddress")) + + client = alphaess.alphaess(entry.data["AppID"], entry.data["AppSecret"], ipaddress=ip_address) ESSList = await client.getESSList() for unit in ESSList: diff --git a/custom_components/alphaess/button.py b/custom_components/alphaess/button.py index 2a2084f..392ee00 100644 --- a/custom_components/alphaess/button.py +++ b/custom_components/alphaess/button.py @@ -42,19 +42,21 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: description.key: description for description in EV_DISCHARGE_AND_CHARGE_BUTTONS } + for serial, data in coordinator.data.items(): model = data.get("Model") + has_local_ip_data = 'Local IP' in data if model not in INVERTER_SETTING_BLACKLIST: for description in full_button_supported_states: button_entities.append( - AlphaESSBatteryButton(coordinator, entry, serial, full_button_supported_states[description])) + AlphaESSBatteryButton(coordinator, entry, serial, full_button_supported_states[description], has_local_connection=has_local_ip_data)) ev_charger = data.get("EV Charger S/N") if ev_charger: for description in ev_charging_supported_states: button_entities.append( AlphaESSBatteryButton( - coordinator, entry, serial, ev_charging_supported_states[description], True + coordinator, entry, serial, ev_charging_supported_states[description], True, has_local_connection=has_local_ip_data ) ) @@ -63,7 +65,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: class AlphaESSBatteryButton(CoordinatorEntity, ButtonEntity): - def __init__(self, coordinator, config, serial, key_supported_states, ev_charger=False): + def __init__(self, coordinator, config, serial, key_supported_states, ev_charger=False, has_local_connection=False): super().__init__(coordinator) self._serial = serial self._coordinator = coordinator @@ -92,6 +94,19 @@ def __init__(self, coordinator, config, serial, key_supported_states, ev_charger model_id=coordinator.data[invertor]["EV Charger S/N"], name=f"Alpha ESS Charger : {coordinator.data[invertor]["EV Charger S/N"]}", ) + elif "Local IP" in coordinator.data[invertor] and coordinator.data[invertor].get('Local IP') != '0': + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, serial)}, + serial_number=coordinator.data[invertor]["Device Serial Number"], + sw_version=coordinator.data[invertor]["Software Version"], + hw_version=coordinator.data[invertor]["Hardware Version"], + manufacturer="AlphaESS", + model=coordinator.data[invertor]["Model"], + model_id=self._serial, + name=f"Alpha ESS Energy Statistics : {serial}", + configuration_url=f"http://{coordinator.data[invertor]["Local IP"]}" + ) elif self._serial == serial: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/custom_components/alphaess/config_flow.py b/custom_components/alphaess/config_flow.py index 92c201d..8526f84 100644 --- a/custom_components/alphaess/config_flow.py +++ b/custom_components/alphaess/config_flow.py @@ -9,22 +9,23 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN, add_inverter_to_list, increment_inverter_count - -STEP_USER_DATA_SCHEMA = vol.Schema( - {vol.Required("AppID", description={"AppID"}): str, vol.Required("AppSecret", description={"AppSecret"}): str} -) +STEP_USER_DATA_SCHEMA = vol.Schema({ + vol.Required("AppID", description="AppID"): str, + vol.Required("AppSecret", description="AppSecret"): str, + vol.Optional("IPAddress", default='0'): vol.Any(None, str) +}) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" - client = alphaess.alphaess(data["AppID"], data["AppSecret"]) + client = alphaess(data["AppID"], data["AppSecret"], ipaddress=data["IPAddress"]) try: await client.authenticate() @@ -56,7 +57,7 @@ class AlphaESSConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" @@ -73,13 +74,20 @@ async def async_step_user( errors["base"] = "invalid_auth" return self.async_create_entry( - title=user_input["AppID"], data=user_input + title=user_input["AppID"], data=user_input ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AlphaESSOptionsFlowHandler: + return AlphaESSOptionsFlowHandler(config_entry) + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" @@ -87,3 +95,28 @@ class InvalidAuth(HomeAssistantError): class CannotConnect(HomeAssistantError): """Error to indicate there is a problem connecting.""" + + +class AlphaESSOptionsFlowHandler(config_entries.OptionsFlow): + """AlphaESS options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + schema = { + vol.Optional( + "IPAddress", + default=self._config_entry.options.get( + "IPAddress", + self._config_entry.data.get("IPAddress", ""), + ), + ): str + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) diff --git a/custom_components/alphaess/const.py b/custom_components/alphaess/const.py index 5716d74..a97ea17 100644 --- a/custom_components/alphaess/const.py +++ b/custom_components/alphaess/const.py @@ -39,14 +39,55 @@ ------------------------------------------------------------------- """ -ev_charger_states = { - 1: "Available (not plugged in)", - 2: "Preparing (plugged in and not activated)", - 3: "Charging (charging with power output)", - 4: "SuspendedEVSE (already started but no available power)", - 5: "SuspendedEV (waiting for the car to respond)", - 6: "Finishing (actively stopping charging)", - 9: "Faulted (pile failure)" +EV_CHARGER_STATE_KEYS = { + 1: "available", + 2: "preparing", + 3: "charging", + 4: "suspended_evse", + 5: "suspended_ev", + 6: "finishing", + 9: "faulted" +} + +TCP_STATUS_KEYS = { + 0: "connected_ok", + -1: "initialization", + -2: "not_connected_router", + -3: "dns_lookup_error", + -4: "connect_fail", + -5: "signal_too_weak", + -6: "failed_register_base_station", + -7: "sim_card_not_inserted", + -8: "not_bound_plant", + -9: "key_error", + -10: "sn_error", + -11: "communication_timeout", + -12: "communication_abort_server", + -13: "server_address_error" +} + +WIFI_STATUS_KEYS = { + 0: "connection_idle", + 1: "connecting", + 2: "password_error", + 3: "ap_not_found", + 4: "connect_fail", + 5: "connected_ok" + # All other values default to unknown_error +} + +ETHERNET_STATUS_KEYS = { + 0: "link_up", + # All other values default to link_down +} + +FOUR_G_STATUS_KEYS = { + 0: "ok", + -1: "initialization", + -2: "connected_fail", + -3: "connected_lost", + -4: "connected_fail" + # All other values default to unknown_error } diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index ad78d39..fa9a06d 100644 --- a/custom_components/alphaess/coordinator.py +++ b/custom_components/alphaess/coordinator.py @@ -1,6 +1,7 @@ """Coordinator for AlphaEss integration.""" import logging from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Union import aiohttp from alphaess import alphaess @@ -8,243 +9,449 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, SCAN_INTERVAL, THROTTLE_MULTIPLIER, get_inverter_count, set_throttle_count_lower, \ - get_inverter_list, LOWER_INVERTER_API_CALL_LIST +from .const import ( + DOMAIN, + SCAN_INTERVAL, + THROTTLE_MULTIPLIER, + get_inverter_count, + set_throttle_count_lower, + get_inverter_list, + LOWER_INVERTER_API_CALL_LIST +) +from .enums import AlphaESSNames _LOGGER: logging.Logger = logging.getLogger(__package__) -async def process_value(value, default=None): - if value is None or (isinstance(value, str) and value.strip() == ''): - return default - return value +class DataProcessor: + """Helper class for data processing utilities.""" + + @staticmethod + async def process_value(value: Any, default: Any = None) -> Any: + """Process and validate a value, returning default if empty.""" + if value is None or (isinstance(value, str) and value.strip() == ''): + return default + return value + + @staticmethod + async def safe_get(dictionary: Optional[Dict], key: str, default: Any = None) -> Any: + """Safely get a value from a dictionary.""" + if dictionary is None: + return default + return await DataProcessor.process_value(dictionary.get(key), default) + + @staticmethod + async def safe_calculate(val1: Optional[float], val2: Optional[float]) -> Optional[float]: + """Safely calculate difference between two values.""" + if val1 is None or val2 is None: + return None + return val1 - val2 -async def safe_get(dictionary, key, default=None): - if dictionary is None: - return default - return await process_value(dictionary.get(key), default) +class TimeHelper: + """Helper class for time-related operations.""" + @staticmethod + async def get_rounded_time() -> str: + """Get time rounded to next 15-minute interval.""" + now = datetime.now() -async def safe_calculate(val1, val2): - if val1 is None or val2 is None: - return None - else: - return val1 - val2 + if now.minute > 45: + rounded_time = now + timedelta(hours=1) + rounded_time = rounded_time.replace(minute=0, second=0, microsecond=0) + else: + rounded_time = now + timedelta(minutes=15 - (now.minute % 15)) + rounded_time = rounded_time.replace(second=0, microsecond=0) + return rounded_time.strftime("%H:%M") -async def get_rounded_time(): - now = datetime.now() + @staticmethod + def calculate_time_window(time_period_minutes: int) -> tuple[str, str]: + """Calculate start and end time for a given period.""" + now = datetime.now() + start_time_str = TimeHelper.get_rounded_time() + start_time = datetime.strptime(start_time_str, "%H:%M").replace( + year=now.year, month=now.month, day=now.day + ) + end_time = start_time + timedelta(minutes=time_period_minutes) + return start_time.strftime("%H:%M"), end_time.strftime("%H:%M") + + +class InverterDataParser: + """Parse inverter data into structured format.""" + + def __init__(self, data_processor: DataProcessor): + self.dp = data_processor + + async def parse_basic_info(self, invertor: Dict) -> Dict[str, Any]: + """Parse basic inverter information.""" + return { + "Model": await self.dp.process_value(invertor.get("minv")), + AlphaESSNames.mbat: await self.dp.process_value(invertor.get("mbat")), + AlphaESSNames.poinv: await self.dp.process_value(invertor.get("poinv")), + AlphaESSNames.popv: await self.dp.process_value(invertor.get("popv")), + AlphaESSNames.EmsStatus: await self.dp.process_value(invertor.get("emsStatus")), + AlphaESSNames.usCapacity: await self.dp.process_value(invertor.get("usCapacity")), + AlphaESSNames.surplusCobat: await self.dp.process_value(invertor.get("surplusCobat")), + AlphaESSNames.cobat: await self.dp.process_value(invertor.get("cobat")), + } + + async def parse_local_ip_data(self, local_ip_data: Dict) -> Dict[str, Any]: + """Parse local IP system data.""" + if not local_ip_data: + return {} + + status = local_ip_data.get("status", {}) + device_info = local_ip_data.get("device_info", {}) + + return { + AlphaESSNames.localIP: local_ip_data.get("ip"), + AlphaESSNames.deviceStatus: await self.dp.safe_get(status, "devstatus"), + AlphaESSNames.cloudConnectionStatus: await self.dp.safe_get(status, "serverstatus"), + AlphaESSNames.wifiStatus: await self.dp.safe_get(status, "wifistatus"), + AlphaESSNames.connectedSSID: await self.dp.safe_get(status, "connssid"), + AlphaESSNames.wifiDHCP: await self.dp.safe_get(status, "wifidhcp"), + AlphaESSNames.wifiIP: await self.dp.safe_get(status, "wifiip"), + AlphaESSNames.wifiMask: await self.dp.safe_get(status, "wifimask"), + AlphaESSNames.wifiGateway: await self.dp.safe_get(status, "wifigateway"), + AlphaESSNames.deviceSerialNumber: await self.dp.safe_get(device_info, "sn"), + AlphaESSNames.registerKey: await self.dp.safe_get(device_info, "key"), + AlphaESSNames.hardwareVersion: await self.dp.safe_get(device_info, "hw"), + AlphaESSNames.softwareVersion: await self.dp.safe_get(device_info, "sw"), + AlphaESSNames.apn: await self.dp.safe_get(device_info, "apn"), + AlphaESSNames.username: await self.dp.safe_get(device_info, "username"), + AlphaESSNames.password: await self.dp.safe_get(device_info, "password"), + AlphaESSNames.ethernetModule: await self.dp.safe_get(device_info, "ethmoudle"), + AlphaESSNames.fourGModule: await self.dp.safe_get(device_info, "g4moudle"), + } + + async def parse_ev_data(self, ev_data: Optional[Dict], invertor: Dict) -> Dict[str, Any]: + """Parse EV charger data.""" + if not ev_data: + return {} + + ev_data = ev_data[0] if isinstance(ev_data, list) else ev_data + ev_status = invertor.get("EVStatus", {}) + ev_current = invertor.get("EVCurrent", {}) + + return { + AlphaESSNames.evchargersn: await self.dp.safe_get(ev_data, "evchargerSn"), + AlphaESSNames.evchargermodel: await self.dp.safe_get(ev_data, "evchargerModel"), + AlphaESSNames.evchargerstatus: await self.dp.safe_get(ev_status, "evchargerStatus"), + AlphaESSNames.evchargerstatusraw: await self.dp.safe_get(ev_status, "evchargerStatus"), + AlphaESSNames.evcurrentsetting: await self.dp.safe_get(ev_current, "currentsetting"), + } + + async def parse_summary_data(self, sum_data: Dict) -> Dict[str, Any]: + """Parse summary statistics.""" + data = { + AlphaESSNames.TotalLoad: await self.dp.safe_get(sum_data, "eload"), + AlphaESSNames.Income: await self.dp.safe_get(sum_data, "totalIncome"), + AlphaESSNames.Total_Generation: await self.dp.safe_get(sum_data, "epvtotal"), + AlphaESSNames.treePlanted: await self.dp.safe_get(sum_data, "treeNum"), + AlphaESSNames.carbonReduction: await self.dp.safe_get(sum_data, "carbonNum"), + "Currency": await self.dp.safe_get(sum_data, "moneyType"), + } + + # Handle self consumption and sufficiency correctly + self_consumption = await self.dp.safe_get(sum_data, "eselfConsumption") + self_sufficiency = await self.dp.safe_get(sum_data, "eselfSufficiency") + + data[AlphaESSNames.SelfConsumption] = self_consumption * 100 if self_consumption is not None else None + data[AlphaESSNames.SelfSufficiency] = self_sufficiency * 100 if self_sufficiency is not None else None + + return data + + async def parse_energy_data(self, energy_data: Dict) -> Dict[str, Any]: + """Parse daily energy flow data.""" + pv = await self.dp.safe_get(energy_data, "epv") + feedin = await self.dp.safe_get(energy_data, "eOutput") + gridcharge = await self.dp.safe_get(energy_data, "eGridCharge") + charge = await self.dp.safe_get(energy_data, "eCharge") + + return { + AlphaESSNames.SolarProduction: pv, + AlphaESSNames.SolarToLoad: await self.dp.safe_calculate(pv, feedin), + AlphaESSNames.SolarToGrid: feedin, + AlphaESSNames.SolarToBattery: await self.dp.safe_calculate(charge, gridcharge), + AlphaESSNames.GridToLoad: await self.dp.safe_get(energy_data, "eInput"), + AlphaESSNames.GridToBattery: gridcharge, + AlphaESSNames.Charge: charge, + AlphaESSNames.Discharge: await self.dp.safe_get(energy_data, "eDischarge"), + AlphaESSNames.EVCharger: await self.dp.safe_get(energy_data, "eChargingPile"), + } + + async def parse_power_data(self, power_data: Dict, one_day_power: Optional[list]) -> Dict[str, Any]: + """Parse instantaneous power data.""" + soc = await self.dp.safe_get(power_data, "soc") + grid_details = power_data.get("pgridDetail", {}) + pv_details = power_data.get("ppvDetail", {}) + ev_details = power_data.get("pevDetail", {}) + + data = { + AlphaESSNames.BatterySOC: soc, + AlphaESSNames.BatteryIO: await self.dp.safe_get(power_data, "pbat"), + AlphaESSNames.Load: await self.dp.safe_get(power_data, "pload"), + AlphaESSNames.Generation: await self.dp.safe_get(power_data, "ppv"), + AlphaESSNames.GridIOTotal: await self.dp.safe_get(power_data, "pgrid"), + AlphaESSNames.pev: await self.dp.safe_get(power_data, "pev"), + AlphaESSNames.PrealL1: await self.dp.safe_get(power_data, "prealL1"), + AlphaESSNames.PrealL2: await self.dp.safe_get(power_data, "prealL2"), + AlphaESSNames.PrealL3: await self.dp.safe_get(power_data, "prealL3"), + } + + # PV string data + for i in range(1, 5): + data[getattr(AlphaESSNames, f"PPV{i}")] = await self.dp.safe_get(pv_details, f"ppv{i}") + + data[AlphaESSNames.pmeterDc] = await self.dp.safe_get(pv_details, "pmeterDc") + + # Grid phase data + for i in range(1, 4): + data[getattr(AlphaESSNames, f"GridIOL{i}")] = await self.dp.safe_get(grid_details, f"pmeterL{i}") + + # EV power data + for i in range(1, 5): + key_map = {1: "One", 2: "Two", 3: "Three", 4: "Four"} + data[getattr(AlphaESSNames, f"ElectricVehiclePower{key_map[i]}")] = await self.dp.safe_get(ev_details, f"ev{i}Power") + + # Fallback SOC from daily data + if one_day_power and soc == 0: + first_entry = one_day_power[0] + cbat = first_entry.get("cbat") + if cbat is not None: + data[AlphaESSNames.StateOfCharge] = cbat + + return data + + async def parse_charge_config(self, config: Dict) -> Dict[str, Any]: + """Parse charge configuration.""" + data = {} + for key in ["gridCharge", AlphaESSNames.batHighCap]: + if key == AlphaESSNames.batHighCap: + data[key] = await self.dp.safe_get(config, "batHighCap") + else: + data[key] = await self.dp.safe_get(config, key) + + # Parse time slots with the correct key names + time_start_1 = await self.dp.safe_get(config, "timeChaf1") + time_end_1 = await self.dp.safe_get(config, "timeChae1") + time_start_2 = await self.dp.safe_get(config, "timeChaf2") + time_end_2 = await self.dp.safe_get(config, "timeChae2") + + # Format as "HH:MM - HH:MM" to match expected format + if time_start_1 and time_end_1: + data[AlphaESSNames.ChargeTime1] = f"{time_start_1} - {time_end_1}" + else: + data[AlphaESSNames.ChargeTime1] = "00:00 - 00:00" - if now.minute > 45: - rounded_time = now + timedelta(hours=1) - rounded_time = rounded_time.replace(minute=0, second=0, microsecond=0) - else: - rounded_time = now + timedelta(minutes=15 - (now.minute % 15)) - rounded_time = rounded_time.replace(second=0, microsecond=0) + if time_start_2 and time_end_2: + data[AlphaESSNames.ChargeTime2] = f"{time_start_2} - {time_end_2}" + else: + data[AlphaESSNames.ChargeTime2] = "00:00 - 00:00" + + # Also keep the raw values for compatibility + data["charge_timeChaf1"] = time_start_1 + data["charge_timeChae1"] = time_end_1 + data["charge_timeChaf2"] = time_start_2 + data["charge_timeChae2"] = time_end_2 + + return data + + async def parse_discharge_config(self, config: Dict) -> Dict[str, Any]: + """Parse discharge configuration.""" + data = {} + for key in ["ctrDis", AlphaESSNames.batUseCap]: + if key == AlphaESSNames.batUseCap: + data[key] = await self.dp.safe_get(config, "batUseCap") + else: + data[key] = await self.dp.safe_get(config, key) + + # Parse time slots with the correct key names + time_start_1 = await self.dp.safe_get(config, "timeDisf1") + time_end_1 = await self.dp.safe_get(config, "timeDise1") + time_start_2 = await self.dp.safe_get(config, "timeDisf2") + time_end_2 = await self.dp.safe_get(config, "timeDise2") + + # Format as "HH:MM - HH:MM" to match expected format + if time_start_1 and time_end_1: + data[AlphaESSNames.DischargeTime1] = f"{time_start_1} - {time_end_1}" + else: + data[AlphaESSNames.DischargeTime1] = "00:00 - 00:00" - return rounded_time.strftime("%H:%M") + if time_start_2 and time_end_2: + data[AlphaESSNames.DischargeTime2] = f"{time_start_2} - {time_end_2}" + else: + data[AlphaESSNames.DischargeTime2] = "00:00 - 00:00" + + # Also keep the raw values for compatibility + data["discharge_timeDisf1"] = time_start_1 + data["discharge_timeDise1"] = time_end_1 + data["discharge_timeDisf2"] = time_start_2 + data["discharge_timeDise2"] = time_end_2 + + return data class AlphaESSDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" def __init__(self, hass: HomeAssistant, client: alphaess.alphaess) -> None: - """Initialize.""" + """Initialize coordinator.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) self.api = client - self.update_method = self._async_update_data - self.has_throttle = True + self.hass = hass self.data: dict[str, dict[str, float]] = {} - self.LOCAL_INVERTER_COUNT = 0 + + # Initialize helpers + self.data_processor = DataProcessor() + self.time_helper = TimeHelper() + self.parser = InverterDataParser(self.data_processor) + + # Configure based on inverter types + self._configure_inverter_settings() + + def _configure_inverter_settings(self) -> None: + """Configure settings based on inverter types.""" self.model_list = get_inverter_list() self.inverter_count = get_inverter_count() - self.hass = hass + self.LOCAL_INVERTER_COUNT = 0 if self.inverter_count <= 1 else self.inverter_count - # Reduce the throttle count lower due to the reduced API calls it makes - if all(inverter not in self.model_list for inverter in LOWER_INVERTER_API_CALL_LIST) and len( - self.model_list) > 0: + # Check if we need reduced API calls + self.has_throttle = True + if (all(inverter not in self.model_list for inverter in LOWER_INVERTER_API_CALL_LIST) + and len(self.model_list) > 0): self.has_throttle = False set_throttle_count_lower() - if self.inverter_count <= 1: - self.LOCAL_INVERTER_COUNT = 0 - else: - self.LOCAL_INVERTER_COUNT = self.inverter_count - - async def Control_EV(self, serial, ev_serial, direction): - return_data = await self.api.remoteControlEvCharger(serial, ev_serial, direction) - _LOGGER.info(f"Control EV Charger: {ev_serial} for serial: {serial} Direction: {direction}") - _LOGGER.info(return_data) - - async def reset_config(self, serial): - batUseCap = self.hass.data[DOMAIN][serial].get("batUseCap", 10) - batHighCap = self.hass.data[DOMAIN][serial].get("batHighCap", 90) + async def control_ev(self, serial: str, ev_serial: str, direction: str) -> None: + """Control EV charger.""" + result = await self.api.remoteControlEvCharger(serial, ev_serial, direction) + _LOGGER.info( + f"Control EV Charger: {ev_serial} for serial: {serial} " + f"Direction: {direction} - Result: {result}" + ) - return_charge_data = await self.api.updateChargeConfigInfo(serial, batHighCap, 1, "00:00", "00:00", - "00:00", "00:00") - return_discharge_data = await self.api.updateDisChargeConfigInfo(serial, batUseCap, 1, "00:00", "00:00", - "00:00", "00:00") + async def reset_config(self, serial: str) -> None: + """Reset charge and discharge configuration.""" + bat_use_cap = self.hass.data[DOMAIN][serial].get(AlphaESSNames.batUseCap, 10) + bat_high_cap = self.hass.data[DOMAIN][serial].get(AlphaESSNames.batHighCap, 90) + results = await self._reset_charge_discharge_config(serial, bat_high_cap, bat_use_cap) _LOGGER.info( - f"Reset Charge and Discharge status, now is reset, API response:\n Charge: {return_charge_data}\n Discharge: {return_discharge_data}") + f"Reset Charge and Discharge configuration - " + f"Charge: {results['charge']}, Discharge: {results['discharge']}" + ) + + async def _reset_charge_discharge_config( + self, serial: str, bat_high_cap: int, bat_use_cap: int + ) -> Dict[str, Any]: + """Internal method to reset configurations.""" + charge_result = await self.api.updateChargeConfigInfo( + serial, bat_high_cap, 1, "00:00", "00:00", "00:00", "00:00" + ) + discharge_result = await self.api.updateDisChargeConfigInfo( + serial, bat_use_cap, 1, "00:00", "00:00", "00:00", "00:00" + ) + return {"charge": charge_result, "discharge": discharge_result} + + async def update_discharge(self, name: str, serial: str, time_period: int) -> None: + """Update discharge configuration for specified time period.""" + bat_use_cap = self.hass.data[DOMAIN][serial].get(name) + start_time, end_time = self.time_helper.calculate_time_window(time_period) + + result = await self.api.updateDisChargeConfigInfo( + serial, bat_use_cap, 1, end_time, "00:00", start_time, "00:00" + ) - async def update_discharge(self, name, serial, time_period): - batUseCap = self.hass.data[DOMAIN][serial].get(name, None) - start_time_str = await get_rounded_time() - now = datetime.now() - start_time = datetime.strptime(start_time_str, "%H:%M").replace(year=now.year, month=now.month, day=now.day) - future_time = start_time + timedelta(minutes=time_period) - future_time_str = future_time.strftime("%H:%M") - return_data = await self.api.updateDisChargeConfigInfo(serial, batUseCap, 1, future_time_str, "00:00", - start_time.strftime("%H:%M"), "00:00") _LOGGER.info( - f"Retrieved value for Discharge: {batUseCap} for serial: {serial} Running for {start_time.strftime('%H:%M')} to {future_time_str}") - _LOGGER.info(return_data) + f"Updated discharge config - Capacity: {bat_use_cap}, " + f"Period: {start_time} to {end_time}, Result: {result}" + ) - _LOGGER.info(f"DATA RECEIVED:{await self.api.getDisChargeConfigInfo(serial)}") + async def update_charge(self, name: str, serial: str, time_period: int) -> None: + """Update charge configuration for specified time period.""" + bat_high_cap = self.hass.data[DOMAIN][serial].get(name) + start_time, end_time = self.time_helper.calculate_time_window(time_period) - async def update_charge(self, name, serial, time_period): + result = await self.api.updateChargeConfigInfo( + serial, bat_high_cap, 1, end_time, "00:00", start_time, "00:00" + ) - batHighCap = self.hass.data[DOMAIN][serial].get(name, None) - start_time_str = await get_rounded_time() - now = datetime.now() - start_time = datetime.strptime(start_time_str, "%H:%M").replace(year=now.year, month=now.month, day=now.day) - future_time = start_time + timedelta(minutes=time_period) - future_time_str = future_time.strftime("%H:%M") - return_data = await self.api.updateChargeConfigInfo(serial, batHighCap, 1, future_time_str, "00:00", - start_time.strftime("%H:%M"), "00:00") _LOGGER.info( - f"Retrieved value for Charge: {batHighCap} for serial: {serial} Running from {start_time.strftime('%H:%M')} to {future_time_str}") - _LOGGER.info(return_data) + f"Updated charge config - Capacity: {bat_high_cap}, " + f"Period: {start_time} to {end_time}, Result: {result}" + ) - async def _async_update_data(self): + async def _async_update_data(self) -> Optional[Dict[str, Dict[str, Any]]]: """Update data via library.""" try: - jsondata = await self.api.getdata(True, True, THROTTLE_MULTIPLIER * self.LOCAL_INVERTER_COUNT) - if jsondata is not None: - for invertor in jsondata: - - # data from system list data - inverterdata = {} - if invertor.get("minv") is not None: - inverterdata["Model"] = await process_value(invertor.get("minv")) - - if invertor.get("mbat") is not None: - inverterdata["Battery Model"] = await process_value(invertor.get("mbat")) - - inverterdata["Inverter nominal Power"] = await process_value(invertor.get("poinv")) - inverterdata["Pv nominal Power"] = await process_value(invertor.get("popv")) - - inverterdata["EMS Status"] = await process_value(invertor.get("emsStatus")) - inverterdata["Maximum Battery Capacity"] = await process_value(invertor.get("usCapacity")) - inverterdata["Current Capacity"] = await process_value(invertor.get("surplusCobat")) - inverterdata["Installed Capacity"] = await process_value(invertor.get("cobat")) - - _sumdata = invertor.get("SumData", {}) - _onedateenergy = invertor.get("OneDateEnergy", {}) - _powerdata = invertor.get("LastPower", {}) - _onedatepower = invertor.get("OneDayPower", {}) - _evdata = invertor.get("EVData", {}) - - if _evdata: - _evdata = _evdata[0] - inverterdata["EV Charger S/N"] = await safe_get(_evdata, "evchargerSn") - inverterdata["EV Charger Model"] = await safe_get(_evdata, "evchargerModel") - _evstatus = invertor.get("EVStatus", {}) - inverterdata["EV Charger Status"] = await safe_get(_evstatus, "evchargerStatus") - inverterdata["EV Charger Status Raw"] = await safe_get(_evstatus, "evchargerStatus") - _evcurrent = invertor.get("EVCurrent", {}) - inverterdata["Household current setup"] = await safe_get(_evcurrent, "currentsetting") - - inverterdata["Total Load"] = await safe_get(_sumdata, "eload") - inverterdata["Total Income"] = await safe_get(_sumdata, "totalIncome") - inverterdata["Total Generation"] = await safe_get(_sumdata, "epvtotal") - inverterdata["Trees Planted"] = await safe_get(_sumdata, "treeNum") - inverterdata["Co2 Reduction"] = await safe_get(_sumdata, "carbonNum") - - self_data = { - "Self Consumption": await safe_get(_sumdata, "eselfConsumption"), - "Self Sufficiency": await safe_get(_sumdata, "eselfSufficiency") - } - - inverterdata["Currency"] = await safe_get(_sumdata, "moneyType") - - for key, value in self_data.items(): - inverterdata[key] = value * 100 if value is not None else None - - _pv = await safe_get(_onedateenergy, "epv") - _feedin = await safe_get(_onedateenergy, "eOutput") - _gridcharge = await safe_get(_onedateenergy, "eGridCharge") - _charge = await safe_get(_onedateenergy, "eCharge") - - inverterdata["Solar Production"] = _pv - inverterdata["Solar to Load"] = await safe_calculate(_pv, _feedin) - inverterdata["Solar to Grid"] = _feedin - inverterdata["Solar to Battery"] = await safe_calculate(_charge, _gridcharge) - inverterdata["Grid to Load"] = await safe_get(_onedateenergy, "eInput") - inverterdata["Grid to Battery"] = _gridcharge - inverterdata["Charge"] = _charge - inverterdata["Discharge"] = await safe_get(_onedateenergy, "eDischarge") - inverterdata["EV Charger"] = await safe_get(_onedateenergy, "eChargingPile") - - _soc = await safe_get(_powerdata, "soc") - _gridpowerdetails = _powerdata.get("pgridDetail", {}) - _pvpowerdetails = _powerdata.get("ppvDetail", {}) - _getEVdetails = _powerdata.get("pevDetail", {}) - - inverterdata["Instantaneous Battery SOC"] = _soc - - if _onedatepower and _soc == 0: - first_entry = _onedatepower[0] - _cbat = first_entry.get("cbat", None) - inverterdata["State of Charge"] = _cbat - - inverterdata["Instantaneous Battery I/O"] = await safe_get(_powerdata, "pbat") - inverterdata["Instantaneous Load"] = await safe_get(_powerdata, "pload") - inverterdata["Instantaneous Generation"] = await safe_get(_powerdata, "ppv") - inverterdata["Instantaneous PPV1"] = await safe_get(_pvpowerdetails, "ppv1") - inverterdata["Instantaneous PPV2"] = await safe_get(_pvpowerdetails, "ppv2") - inverterdata["Instantaneous PPV3"] = await safe_get(_pvpowerdetails, "ppv3") - inverterdata["Instantaneous PPV4"] = await safe_get(_pvpowerdetails, "ppv4") - inverterdata["pmeterDc"] = await safe_get(_pvpowerdetails, "pmeterDc") - inverterdata["pev"] = await safe_get(_powerdata, "pev") - inverterdata["Electric Vehicle Power One"] = await safe_get(_getEVdetails, "ev1Power") - inverterdata["Electric Vehicle Power Two"] = await safe_get(_getEVdetails, "ev2Power") - inverterdata["Electric Vehicle Power Three"] = await safe_get(_getEVdetails, "ev3Power") - inverterdata["Electric Vehicle Power Four"] = await safe_get(_getEVdetails, "ev4Power") - inverterdata["Instantaneous Grid I/O Total"] = await safe_get(_powerdata, "pgrid") - inverterdata["Instantaneous Grid I/O L1"] = await safe_get(_gridpowerdetails, "pmeterL1") - inverterdata["Instantaneous Grid I/O L2"] = await safe_get(_gridpowerdetails, "pmeterL2") - inverterdata["Instantaneous Grid I/O L3"] = await safe_get(_gridpowerdetails, "pmeterL3") - inverterdata["PrealL1"] = await safe_get(_powerdata, "prealL1") - inverterdata["PrealL2"] = await safe_get(_powerdata, "prealL1") - inverterdata["PrealL3"] = await safe_get(_powerdata, "prealL1") - - # Get Charge Config - _charge_config = invertor.get("ChargeConfig", {}) - - inverterdata["gridCharge"] = await safe_get(_charge_config, "gridCharge") - inverterdata["charge_timeChaf1"] = await safe_get(_charge_config, "timeChaf1") - inverterdata["charge_timeChae1"] = await safe_get(_charge_config, "timeChae1") - inverterdata["charge_timeChaf2"] = await safe_get(_charge_config, "timeChaf2") - inverterdata["charge_timeChae2"] = await safe_get(_charge_config, "timeChae2") - inverterdata["batHighCap"] = await safe_get(_charge_config, "batHighCap") - - # Get Discharge Config - _discharge_config = invertor.get("DisChargeConfig", {}) - - inverterdata["ctrDis"] = await safe_get(_discharge_config, "ctrDis") - inverterdata["discharge_timeDisf1"] = await safe_get(_discharge_config, "timeDisf1") - inverterdata["discharge_timeDise1"] = await safe_get(_discharge_config, "timeDise1") - inverterdata["discharge_timeDisf2"] = await safe_get(_discharge_config, "timeDisf2") - inverterdata["discharge_timeDise2"] = await safe_get(_discharge_config, "timeDise2") - inverterdata["batUseCap"] = await safe_get(_discharge_config, "batUseCap") - - self.data.update({invertor["sysSn"]: inverterdata}) + throttle_factor = THROTTLE_MULTIPLIER * self.LOCAL_INVERTER_COUNT + jsondata = await self.api.getdata(True, True, throttle_factor) + if jsondata is None: return self.data - except (aiohttp.client_exceptions.ClientConnectorError, aiohttp.ClientResponseError) as error: + + for invertor in jsondata: + serial = invertor.get("sysSn") + if not serial: + continue + + # Parse all data sections + inverter_data = await self._parse_inverter_data(invertor) + self.data[serial] = inverter_data + + return self.data + + except (aiohttp.ClientConnectorError, aiohttp.ClientResponseError) as error: _LOGGER.error(f"Error fetching data: {error}") self.data = None return self.data + + async def _parse_inverter_data(self, invertor: Dict) -> Dict[str, Any]: + """Parse all data for a single inverter.""" + # Start with basic info + data = await self.parser.parse_basic_info(invertor) + + # Add LocalIPData if available + local_ip_data = invertor.get("LocalIPData", {}) + if local_ip_data: + data.update(await self.parser.parse_local_ip_data(local_ip_data)) + + # Add EV data if available + ev_data = invertor.get("EVData", {}) + if ev_data: + data.update(await self.parser.parse_ev_data(ev_data, invertor)) + + # Add summary data + sum_data = invertor.get("SumData", {}) + if sum_data: + data.update(await self.parser.parse_summary_data(sum_data)) + + # Add energy data + energy_data = invertor.get("OneDateEnergy", {}) + if energy_data: + data.update(await self.parser.parse_energy_data(energy_data)) + + # Add power data + power_data = invertor.get("LastPower", {}) + if power_data: + one_day_power = invertor.get("OneDayPower", {}) + data.update(await self.parser.parse_power_data(power_data, one_day_power)) + + # Add configuration data + charge_config = invertor.get("ChargeConfig", {}) + if charge_config: + data.update(await self.parser.parse_charge_config(charge_config)) + + discharge_config = invertor.get("DisChargeConfig", {}) + if discharge_config: + data.update(await self.parser.parse_discharge_config(discharge_config)) + + # Add Charging Range (combining charge and discharge data) + if charge_config or discharge_config: + bat_high_cap = charge_config.get("batHighCap", 90) if charge_config else 90 + bat_use_cap = discharge_config.get("batUseCap", 10) if discharge_config else 10 + data[AlphaESSNames.ChargeRange] = f"{bat_use_cap}% - {bat_high_cap}%" + + return data \ No newline at end of file diff --git a/custom_components/alphaess/enums.py b/custom_components/alphaess/enums.py index d63b4d2..152dcdf 100644 --- a/custom_components/alphaess/enums.py +++ b/custom_components/alphaess/enums.py @@ -72,3 +72,23 @@ class AlphaESSNames(str, Enum): stopcharging = "Stop Charging" treePlanted = "Trees Planted" carbonReduction = "Co2 Reduction" + softwareVersion = "Software Version" + serialNumber = "Serial Number" + localIP = "Local IP" + hardwareVersion = "Hardware Version" + cloudConnectionStatus = "Cloud Connection Status" + deviceStatus = "Device Status" + serverStatus = "Server Status" + wifiStatus = "WiFi Status" + connectedSSID = "Connected SSID" + wifiDHCP = "WiFi DHCP" + wifiIP = "WiFi IP" + wifiMask = "WiFi Mask" + wifiGateway = "WiFi Gateway" + deviceSerialNumber = "Device Serial Number" + registerKey = "Register Key" + apn = "APN" + username = "Username" + password = "Password" + ethernetModule = "Ethernet Module" + fourGModule = "4G Module" diff --git a/custom_components/alphaess/manifest.json b/custom_components/alphaess/manifest.json index 6e118f8..27148af 100644 --- a/custom_components/alphaess/manifest.json +++ b/custom_components/alphaess/manifest.json @@ -13,9 +13,9 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CharlesGillanders/homeassistant-alphaESS/issues", "requirements": [ - "alphaessopenapi==0.0.13" + "alphaessopenapi==0.0.15" ], - "version": "0.6.1" + "version": "0.7.0" } diff --git a/custom_components/alphaess/number.py b/custom_components/alphaess/number.py index 99a8591..38d97d0 100644 --- a/custom_components/alphaess/number.py +++ b/custom_components/alphaess/number.py @@ -23,10 +23,11 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: for serial, data in coordinator.data.items(): model = data.get("Model") + has_local_ip_data = 'Local IP' in data if model not in INVERTER_SETTING_BLACKLIST: for description in full_number_supported_states: number_entities.append( - AlphaNumber(coordinator, serial, entry, full_number_supported_states[description])) + AlphaNumber(coordinator, serial, entry, full_number_supported_states[description], has_local_connection=has_local_ip_data)) async_add_entities(number_entities) @@ -34,7 +35,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: class AlphaNumber(CoordinatorEntity, RestoreNumber): """Battery use capacity number entity.""" - def __init__(self, coordinator, serial, config, full_number_supported_states): + def __init__(self, coordinator, serial, config, full_number_supported_states, has_local_connection=False): super().__init__(coordinator) self._coordinator = coordinator self._serial = serial @@ -52,7 +53,21 @@ def __init__(self, coordinator, serial, config, full_number_supported_states): for invertor in coordinator.data: serial = invertor.upper() - if self._serial == serial: + if "Local IP" in coordinator.data[invertor] and coordinator.data[invertor].get('Local IP') != '0': + _LOGGER.info(f"INVERTER LOCAL DATA = {coordinator.data[invertor]}") + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, serial)}, + serial_number=coordinator.data[invertor]["Device Serial Number"], + sw_version=coordinator.data[invertor]["Software Version"], + hw_version=coordinator.data[invertor]["Hardware Version"], + manufacturer="AlphaESS", + model=coordinator.data[invertor]["Model"], + model_id=self._serial, + name=f"Alpha ESS Energy Statistics LOCAL : {serial}", + configuration_url=f"http://{coordinator.data[invertor]["Local IP"]}" + ) + elif self._serial == serial: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, serial)}, diff --git a/custom_components/alphaess/sensor.py b/custom_components/alphaess/sensor.py index d539a2d..764ce34 100644 --- a/custom_components/alphaess/sensor.py +++ b/custom_components/alphaess/sensor.py @@ -3,17 +3,19 @@ from typing import List from homeassistant.components.sensor import ( - SensorEntity + SensorEntity, SensorDeviceClass ) from homeassistant.const import CURRENCY_DOLLAR +from homeassistant.helpers.typing import StateType from .enums import AlphaESSNames -from .sensorlist import FULL_SENSOR_DESCRIPTIONS, LIMITED_SENSOR_DESCRIPTIONS, EV_CHARGING_DETAILS +from .sensorlist import FULL_SENSOR_DESCRIPTIONS, LIMITED_SENSOR_DESCRIPTIONS, EV_CHARGING_DETAILS, LOCAL_IP_SYSTEM_SENSORS from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, LIMITED_INVERTER_SENSOR_LIST, ev_charger_states +from .const import DOMAIN, LIMITED_INVERTER_SENSOR_LIST, EV_CHARGER_STATE_KEYS, TCP_STATUS_KEYS, ETHERNET_STATUS_KEYS, \ + FOUR_G_STATUS_KEYS, WIFI_STATUS_KEYS from .coordinator import AlphaESSDataUpdateCoordinator _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -37,6 +39,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: description.key: description for description in EV_CHARGING_DETAILS } + local_ip_supported_states = { + description.key: description for description in LOCAL_IP_SYSTEM_SENSORS + } + _LOGGER.info(f"Initializing Inverters") for serial, data in coordinator.data.items(): model = data.get("Model") @@ -46,19 +52,23 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: _LOGGER.info(f"New Inverter: Serial: {serial}, Model: {model}") + _LOGGER.info("DATA RECEIVED IS: %s", data) + + has_local_ip_data = 'Local IP' in data + # This is done due to the limited data that inverters like the Storion-S5 support if model in LIMITED_INVERTER_SENSOR_LIST: for description in limited_key_supported_states: entities.append( AlphaESSSensor( - coordinator, entry, serial, limited_key_supported_states[description], currency + coordinator, entry, serial, limited_key_supported_states[description], currency, has_local_connection=has_local_ip_data ) ) else: for description in full_key_supported_states: entities.append( AlphaESSSensor( - coordinator, entry, serial, full_key_supported_states[description], currency + coordinator, entry, serial, full_key_supported_states[description], currency, has_local_connection=has_local_ip_data ) ) @@ -70,7 +80,21 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: for description in EV_CHARGING_DETAILS: entities.append( AlphaESSSensor( - coordinator, entry, serial, ev_charging_supported_states[description.key], currency, True + coordinator, entry, serial, ev_charging_supported_states[description.key], currency, True, has_local_connection=has_local_ip_data + ) + ) + + if has_local_ip_data and data.get('Local IP') != '0' and data.get('Device Status') is not None: + _LOGGER.info(f"New local IP system sensor for {serial}") + for description in LOCAL_IP_SYSTEM_SENSORS: + entities.append( + AlphaESSSensor( + coordinator, + entry, + serial, + local_ip_supported_states[description.key], + currency, + has_local_connection=has_local_ip_data ) ) @@ -82,7 +106,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: class AlphaESSSensor(CoordinatorEntity, SensorEntity): """Alpha ESS Base Sensor.""" - def __init__(self, coordinator, config, serial, key_supported_states, currency, ev_charger=False): + def __init__(self, coordinator, config, serial, key_supported_states, currency, ev_charger=False, has_local_connection=False): """Initialize the sensor.""" super().__init__(coordinator) self._config = config @@ -111,6 +135,19 @@ def __init__(self, coordinator, config, serial, key_supported_states, currency, model_id=coordinator.data[invertor]["EV Charger S/N"], name=f"Alpha ESS Charger : {coordinator.data[invertor]["EV Charger S/N"]}", ) + elif "Local IP" in coordinator.data[invertor]: + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, serial)}, + serial_number=coordinator.data[invertor]["Device Serial Number"], + sw_version=coordinator.data[invertor]["Software Version"], + hw_version=coordinator.data[invertor]["Hardware Version"], + manufacturer="AlphaESS", + model=coordinator.data[invertor]["Model"], + model_id=self._serial, + name=f"Alpha ESS Energy Statistics : {serial}", + configuration_url=f"http://{coordinator.data[invertor]["Local IP"]}" + ) elif self._serial == serial: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -121,6 +158,7 @@ def __init__(self, coordinator, config, serial, key_supported_states, currency, name=f"Alpha ESS Energy Statistics : {serial}", ) + @property def unique_id(self): """Return a unique ID to use for this entity.""" @@ -132,27 +170,68 @@ def name(self): return f"{self._name}" @property - def native_value(self): - """Return the state of the resources.""" - keys = { - AlphaESSNames.DischargeTime1, - AlphaESSNames.ChargeTime1, - AlphaESSNames.DischargeTime2, - AlphaESSNames.DischargeTime2, - AlphaESSNames.ChargeTime2 - } - - if self._key in keys: - time_value = str(self._name.split()[-1]) - return self.get_time(self._name, time_value) + def native_value(self) -> StateType: + """Return the value of the sensor.""" + if self._coordinator.data is None: + return None + # Handle EV charger status enum if self._key == AlphaESSNames.evchargerstatus: - return ev_charger_states.get(self._coordinator.data[self._serial][self._name], "Unknown state") - - if self._key == AlphaESSNames.ChargeRange: - return self.get_charge() - - return self._coordinator.data[self._serial][self._name] + raw_state = self._coordinator.data.get(self._serial, {}).get(self._key) + if raw_state is None: + return None + return EV_CHARGER_STATE_KEYS.get(raw_state, "unknown") + + # Handle TCP status for cloud connection + if self._key == AlphaESSNames.cloudConnectionStatus: + raw_state = self._coordinator.data.get(self._serial, {}).get(self._key) + if raw_state is None: + return None + try: + tcp_status = int(raw_state) + return TCP_STATUS_KEYS.get(tcp_status, "connect_fail") + except (ValueError, TypeError): + return "connect_fail" + + # Handle Ethernet status + if self._key == AlphaESSNames.ethernetModule: + raw_state = self._coordinator.data.get(self._serial, {}).get(self._key) + if raw_state is None: + return None + try: + eth_status = int(raw_state) + return ETHERNET_STATUS_KEYS.get(eth_status, "link_down") + except (ValueError, TypeError): + return "link_down" + + # Handle 4G status + if self._key == AlphaESSNames.fourGModule: + raw_state = self._coordinator.data.get(self._serial, {}).get(self._key) + if raw_state is None: + return None + try: + g4_status = int(raw_state) + return FOUR_G_STATUS_KEYS.get(g4_status, "unknown_error") + except (ValueError, TypeError): + return "unknown_error" + + # Handle WiFi status + if self._key == AlphaESSNames.wifiStatus: + raw_state = self._coordinator.data.get(self._serial, {}).get(self._key) + if raw_state is None: + return None + try: + wifi_status = int(raw_state) + return WIFI_STATUS_KEYS.get(wifi_status, "unknown_error") + except (ValueError, TypeError): + return "unknown_error" + + if self._key in [AlphaESSNames.ChargeTime1, AlphaESSNames.ChargeTime2, + AlphaESSNames.DischargeTime1, AlphaESSNames.DischargeTime2]: + return self._coordinator.data.get(self._serial, {}).get(self._key) + + # Normal sensor handling - use the key instead of name for consistency + return self._coordinator.data.get(self._serial, {}).get(self._key) @property def native_unit_of_measurement(self): @@ -164,6 +243,46 @@ def device_class(self): """Return the device_class of the sensor.""" return self._device_class + @property + def options(self) -> list[str] | None: + """Return the list of possible options for enum sensors.""" + if self._key == AlphaESSNames.evchargerstatus: + return ["available", "preparing", "charging", "suspended_evse", + "suspended_ev", "finishing", "faulted", "unknown"] + + if self._key == AlphaESSNames.cloudConnectionStatus: + return ["connected_ok", "initialization", "not_connected_router", "dns_lookup_error", + "connect_fail", "signal_too_weak", "failed_register_base_station", + "sim_card_not_inserted", "not_bound_plant", "key_error", "sn_error", + "communication_timeout", "communication_abort_server", "server_address_error"] + + if self._key == AlphaESSNames.ethernetModule: + return ["link_up", "link_down"] + + if self._key == AlphaESSNames.fourGModule: + return ["ok", "initialization", "connected_fail", "connected_lost", "unknown_error"] + + if self._key == AlphaESSNames.wifiStatus: + return ["connection_idle", "connecting", "password_error", "ap_not_found", + "connect_fail", "connected_ok", "unknown_error"] + + return None + + @property + def translation_key(self) -> str | None: + """Return the translation key.""" + if self._key == AlphaESSNames.evchargerstatus and self._device_class == SensorDeviceClass.ENUM: + return "ev_charger_status" + if self._key == AlphaESSNames.cloudConnectionStatus and self._device_class == SensorDeviceClass.ENUM: + return "tcp_status" + if self._key == AlphaESSNames.ethernetModule and self._device_class == SensorDeviceClass.ENUM: + return "ethernet_status" + if self._key == AlphaESSNames.fourGModule and self._device_class == SensorDeviceClass.ENUM: + return "four_g_status" + if self._key == AlphaESSNames.wifiStatus and self._device_class == SensorDeviceClass.ENUM: + return "wifi_status" + return None + @property def state_class(self): """Return the state_class of the sensor.""" @@ -205,4 +324,4 @@ def get_time_range(prefix): elif direction == "Charge": return get_time_range("charge") - return None + return None \ No newline at end of file diff --git a/custom_components/alphaess/sensorlist.py b/custom_components/alphaess/sensorlist.py index 623cdb1..72195ce 100644 --- a/custom_components/alphaess/sensorlist.py +++ b/custom_components/alphaess/sensorlist.py @@ -279,7 +279,7 @@ key=AlphaESSNames.poinv, name="Inverter nominal Power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=None, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lightning-bolt", @@ -288,7 +288,7 @@ key=AlphaESSNames.popv, name="Pv nominal Power", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=None, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lightning-bolt", @@ -758,6 +758,93 @@ ] +LOCAL_IP_SYSTEM_SENSORS: List[AlphaESSSensorDescription] = [ + AlphaESSSensorDescription( + key=AlphaESSNames.localIP, + name="Local IP", + icon="mdi:ip-network", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.wifiStatus, + name="WiFi Status", + icon="mdi:wifi", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.connectedSSID, + name="Connected SSID", + icon="mdi:wifi-marker", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.softwareVersion, + name="Software Version", + icon="mdi:package-variant", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.hardwareVersion, + name="Hardware Version", + icon="mdi:chip", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.deviceSerialNumber, + name="Device Serial Number", + icon="mdi:identifier", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.cloudConnectionStatus, + name="Cloud Connection Status", + icon="mdi:cloud-sync", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.ethernetModule, + name="Ethernet Status", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:ethernet" + ), + AlphaESSSensorDescription( + key=AlphaESSNames.fourGModule, + name="4G Status", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:signal-4g" + ), + AlphaESSSensorDescription( + key=AlphaESSNames.registerKey, + name="Register Key", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:key" + ), +] + EV_CHARGING_DETAILS: List[AlphaESSSensorDescription] = [ AlphaESSSensorDescription( key=AlphaESSNames.evchargersn, diff --git a/custom_components/alphaess/translations/en.json b/custom_components/alphaess/translations/en.json index 35fc9aa..5f68aff 100644 --- a/custom_components/alphaess/translations/en.json +++ b/custom_components/alphaess/translations/en.json @@ -1,22 +1,97 @@ -{ - "title": "Alpha ESS", - "config": { - "step": { - "user": { - "description": "Enter your AppID amd AppSecret from the AlphaESS OpenAPI developer portal \n \n If you have any issues with the OpenAPI, read a list of potential fixes [here](https://github.com/CharlesGillanders/homeassistant-alphaESS?tab=readme-ov-file#issues-with-registering-systems-to-the-alphaess-openapi)", - "data": { - "AppID": "AppID", - "AppSecret": "AppSecret" - } - } - }, - "abort": { - "already_configured": "Account is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" +{ + "title": "Alpha ESS", + "config": { + "step": { + "user": { + "description": "Enter your AppID and AppSecret from the AlphaESS OpenAPI developer portal \n \n If you have any issues with the OpenAPI, read a list of potential fixes [here](https://github.com/CharlesGillanders/homeassistant-alphaESS?tab=readme-ov-file#issues-with-registering-systems-to-the-alphaess-openapi)", + "data": { + "AppID": "AppID", + "AppSecret": "AppSecret", + "IPAddress": "IP Address (for local API access), 0 to disable" } + } + }, + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" } -} + }, + "options": { + "step": { + "init": { + "data": { + "IPAddress": "IP Address (local API access), 0 to disable" + } + } + } + }, + "entity": { + "sensor": { + "ev_charger_status": { + "name": "EV Charger Status", + "state": { + "available": "Available (not plugged in)", + "preparing": "Preparing (plugged in)", + "charging": "Charging", + "suspended_evse": "Suspended by EVSE", + "suspended_ev": "Suspended by EV", + "finishing": "Finishing", + "faulted": "Faulted", + "unknown": "Unknown" + } + }, + "tcp_status": { + "name": "TCP Status", + "state": { + "connected_ok": "Connected OK", + "initialization": "Initialization", + "not_connected_router": "Not connected to a router", + "dns_lookup_error": "DNS lookup error", + "connect_fail": "Connect fail", + "signal_too_weak": "Signal too weak", + "failed_register_base_station": "Failed to register base station", + "sim_card_not_inserted": "SIM Card not inserted", + "not_bound_plant": "Not bound to a plant", + "key_error": "KEY error", + "sn_error": "SN error", + "communication_timeout": "Communication timeout", + "communication_abort_server": "Communication abort by server", + "server_address_error": "Server address error" + } + }, + "ethernet_status": { + "name": "Ethernet Status", + "state": { + "link_up": "Link Up", + "link_down": "Link Down" + } + }, + "four_g_status": { + "name": "4G Status", + "state": { + "ok": "OK", + "initialization": "Initialization", + "connected_fail": "Connected fail", + "connected_lost": "Connected lost", + "unknown_error": "Unknown Error" + } + }, + "wifi_status": { + "name": "WiFi Status", + "state": { + "connection_idle": "Connection Idle", + "connecting": "Connecting...", + "password_error": "Password Error", + "ap_not_found": "AP Not Found", + "connect_fail": "Connect Fail", + "connected_ok": "Connected OK", + "unknown_error": "Unknown Error" + } + } + } + } +} \ No newline at end of file