From f3af76457a00daef0b7af99d1f8f24ab8ee0b40f Mon Sep 17 00:00:00 2001 From: isottipietro <86808453+isottipietro@users.noreply.github.com> Date: Mon, 17 Feb 2025 21:36:43 +0100 Subject: [PATCH] Add battery power charge and discharge --- .../enphase_envoy_custom/__init__.py | 11 ++++ .../enphase_envoy_custom/const.py | 21 ++++++ .../enphase_envoy_custom/envoy_reader.py | 58 ++++++++++++++++ .../enphase_envoy_custom/sensor.py | 66 ++++++++++++++++++- 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/custom_components/enphase_envoy_custom/__init__.py b/custom_components/enphase_envoy_custom/__init__.py index 2f72bb1..c458740 100644 --- a/custom_components/enphase_envoy_custom/__init__.py +++ b/custom_components/enphase_envoy_custom/__init__.py @@ -84,6 +84,15 @@ async def async_update_data(): data[description.key] = battery_dict + elif description.key == "batteries_power": + battery_data_power = await envoy_reader.battery_power() + if isinstance(battery_data_power, list) and len(battery_data_power) > 0: + battery_dict = {} + for item in battery_data_power: + battery_dict[item["serial_num"]] = item + + data[description.key] = battery_dict + elif (description.key not in ["current_battery_capacity", "total_battery_percentage"]): data[description.key] = await getattr( envoy_reader, description.key @@ -107,6 +116,8 @@ async def async_update_data(): data["grid_status"] = await envoy_reader.grid_status() data["envoy_info"] = await envoy_reader.envoy_info() + data["charge"] = await envoy_reader.charge() + data["discharge"] = await envoy_reader.discharge() _LOGGER.debug("Retrieved data from API: %s", data) diff --git a/custom_components/enphase_envoy_custom/const.py b/custom_components/enphase_envoy_custom/const.py index 3ae508b..14ed50c 100644 --- a/custom_components/enphase_envoy_custom/const.py +++ b/custom_components/enphase_envoy_custom/const.py @@ -179,6 +179,27 @@ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + SensorEntityDescription( + key="discharge", + name="Battery Power Discharge", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + SensorEntityDescription( + key="charge", + name="Battery Power Charge", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + SensorEntityDescription( + key="batteries_power", + name="Battery Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), ) diff --git a/custom_components/enphase_envoy_custom/envoy_reader.py b/custom_components/enphase_envoy_custom/envoy_reader.py index 4f0b4f3..370d27f 100644 --- a/custom_components/enphase_envoy_custom/envoy_reader.py +++ b/custom_components/enphase_envoy_custom/envoy_reader.py @@ -43,6 +43,8 @@ ENDPOINT_URL_METERS = "http{}://{}/ivp/meters" ENDPOINT_URL_METERS_REPORTS = "http{}://{}/ivp/meters/reports" ENDPOINT_URL_METERS_READINGS = "http{}://{}/ivp/meters/readings" +ENDPOINT_URL_ENSEMBLE_POWER = "http{}://{}/ivp/ensemble/power" +ENDPOINT_URL_ARF_PROFILE = "http{}://{}/ivp/arf/profile" # pylint: disable=pointless-string-statement @@ -222,6 +224,9 @@ def __init__( # pylint: disable=too-many-arguments self._fetch_holdoff_seconds = fetch_holdoff_seconds self._fetch_retries = max(fetch_retries,1) self._do_not_use_production_json=do_not_use_production_json + self.batteries_power = 0 + self.grid_power = 0 + self.pv_power = 0 @property def _token(self): @@ -294,6 +299,9 @@ async def _update_from_pc_endpoint(self,detectmode=False): await self._update_endpoint( "endpoint_ensemble_json_results", ENDPOINT_URL_ENSEMBLE_INVENTORY ) + await self._update_endpoint( + "endpoint_ensemble_power_results", ENDPOINT_URL_ENSEMBLE_POWER + ) if self.has_grid_status: await self._update_endpoint( "endpoint_home_json_results", ENDPOINT_URL_HOME_JSON @@ -844,6 +852,7 @@ async def production(self,phase=None): production = float(match.group(1)) else: raise RuntimeError("No match for production, check REGEX " + text) + self.pv_power = int(production) return int(production) async def production_phase(self, phase): @@ -858,6 +867,8 @@ async def consumption(self,phase=None): jsondata = await self._meters_report_value("currW",report="total-consumption",phase=phase) if jsondata is None: return self.message_consumption_not_available if phase is None else None + if self.batteries_power != 0: + jsondata = self.batteries_power + self.pv_power + self.grid_power return int(jsondata) async def net_consumption(self,phase=None): @@ -865,6 +876,7 @@ async def net_consumption(self,phase=None): jsondata = await self._meters_readings_value("instantaneousDemand",report="net-consumption",phase=phase) if jsondata is None: return self.message_consumption_not_available if phase is None else None + self.grid_power = int(jsondata) return int(jsondata) async def daily_production(self,phase=None): @@ -1101,6 +1113,52 @@ async def battery_storage(self): return self.message_battery_not_available return raw_json["storage"][0] + + async def discharge(self): + """Return battery discharge data from Envoys that support and have batteries installed""" + try: + raw_json = self.endpoint_ensemble_power_results.json() + except JSONDecodeError: + return None + + power = 0 + try: + for item in raw_json["devices:"]: + power += item["real_power_mw"] + except (JSONDecodeError, KeyError, IndexError, TypeError, AttributeError): + return None + self.batteries_power = int(power / 1000) + if power > 0: + return int(power / 1000) + else: + return 0 + + async def charge(self): + """Return battery discharge data from Envoys that support and have batteries installed""" + try: + raw_json = self.endpoint_ensemble_power_results.json() + except JSONDecodeError: + return None + + power = 0 + try: + for item in raw_json["devices:"]: + power += item["real_power_mw"] + except (JSONDecodeError, KeyError, IndexError, TypeError, AttributeError): + return None + if power < 0: + return int(power / -1000) + else: + return 0 + + async def battery_power(self): + """Return single IQ Battery power data from Envoys that support and have ENCHARGE batteries installed""" + try: + raw_json = self.endpoint_ensemble_power_results.json() + except JSONDecodeError: + return None + # TBD fix if not encharge found + return raw_json["devices:"] async def pf(self,phase=None): """Report cumulative or phase PowerFactor from consumption CT meters report""" diff --git a/custom_components/enphase_envoy_custom/sensor.py b/custom_components/enphase_envoy_custom/sensor.py index dc2fbde..67810ea 100644 --- a/custom_components/enphase_envoy_custom/sensor.py +++ b/custom_components/enphase_envoy_custom/sensor.py @@ -60,6 +60,22 @@ async def async_setup_entry( ) ) + elif (sensor_description.key == "batteries_power"): + if (coordinator.data.get("batteries_power") is not None): + for battery in coordinator.data["batteries_power"]: + entity_name = f"{name} {sensor_description.name} {battery}" + serial_number = battery + entities.append( + EnvoyBatteryPowerEntity( + sensor_description, + entity_name, + name, + config_entry.unique_id, + serial_number, + coordinator + ) + ) + elif (sensor_description.key == "current_battery_capacity"): if (coordinator.data.get("batteries") is not None): battery_capacity_entity = TotalBatteryCapacityEntity( @@ -316,13 +332,61 @@ def extra_state_attributes(self): last_reported = strftime( "%Y-%m-%d %H:%M:%S", localtime(battery.get("last_rpt_date")) ) + bmu_fw_version = battery.get("bmu_fw_version") + zigbee_dongle_fw_version = battery.get("zigbee_dongle_fw_version") + comm_level_sub_ghz = battery.get("comm_level_sub_ghz") + comm_level_2_4_ghz = battery.get("comm_level_2_4_ghz") return { "last_reported": last_reported, - "capacity": battery.get("encharge_capacity") + "capacity": battery.get("encharge_capacity"), + "bmu_fw_version": bmu_fw_version, + "comm_level_sub_ghz": comm_level_sub_ghz, + "comm_level_2_4_ghz": comm_level_2_4_ghz } return None +class EnvoyBatteryPowerEntity(CoordinatedEnvoyEntity): + """Envoy battery power entity.""" + + def __init__( + self, + description, + name, + device_name, + device_serial_number, + serial_number, + coordinator, + ): + super().__init__( + description=description, + name=name, + device_name=device_name, + device_serial_number=device_serial_number, + serial_number=serial_number, + coordinator=coordinator + ) + + @property + def native_value(self): + """Return the state of the sensor.""" + if ( + self.coordinator.data.get("batteries_power") is not None + ): + power = self.coordinator.data.get("batteries_power").get( + self._serial_number + ).get("real_power_mw") + return (power / -1000) + + return None + @property + def unique_id(self): + """Return the unique id of the sensor.""" + if self._serial_number: + return f"{self._serial_number}_power" + if self._device_serial_number: + return f"{self._device_serial_number}_{self.entity_description.key}" + class TotalBatteryCapacityEntity(CoordinatedEnvoyEntity): def __init__( self,