Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add IQ Battery power charge and discharge #207

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions custom_components/enphase_envoy_custom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions custom_components/enphase_envoy_custom/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),

)

Expand Down
58 changes: 58 additions & 0 deletions custom_components/enphase_envoy_custom/envoy_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -858,13 +867,16 @@ 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces a value reported by the Envoy with a calculated value. This integration is always reporting Envoy values as they are. So best add a new entity like calculated_consumption and give it the value of the calculation.

Also, with the construct !=0 the calculation will not be done when battery is not charging or discharging (value=0), and if so, in current construct the entity will then use the value from the CT again. Again, best use a new entity for this

return int(jsondata)

async def net_consumption(self,phase=None):
"""Report cumulative or phase Power consumption (to/from grid) from consumption CT meters report"""
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):
Expand Down Expand Up @@ -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"""
Expand Down
66 changes: 65 additions & 1 deletion custom_components/enphase_envoy_custom/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a unique_id like

"unique_id":"12345678901006_power"

If you want to keep the option for future migration to the HA Core integration as open as possible, consider using

"unique_id":"12345678901005_real_power_mw"

As that's the unique_id created by the core one.

if self._device_serial_number:
return f"{self._device_serial_number}_{self.entity_description.key}"

class TotalBatteryCapacityEntity(CoordinatedEnvoyEntity):
def __init__(
self,
Expand Down