diff --git a/custom_components/enphase_envoy/binary_sensor.py b/custom_components/enphase_envoy/binary_sensor.py index 1fd2ac9..279be4f 100644 --- a/custom_components/enphase_envoy/binary_sensor.py +++ b/custom_components/enphase_envoy/binary_sensor.py @@ -95,6 +95,24 @@ async def async_setup_entry( ) ) + elif sensor_description.key.startswith("batteries_"): + if coordinator.data.get("batteries") is not None: + for battery in coordinator.data["batteries"].keys(): + device_name = f"Battery {battery}" + entity_name = f"{device_name} {sensor_description.name}" + serial_number = battery + entities.append( + EnvoyBatteryEntity( + sensor_description, + entity_name, + device_name, + serial_number, + None, + coordinator, + config_entry.unique_id, + ) + ) + elif sensor_description.key == "firmware": if coordinator.data.get("envoy_info", {}).get("update_status") is not None: entity_name = f"{name} {sensor_description.name}" @@ -420,3 +438,91 @@ def is_on(self) -> bool | None: @property def extra_state_attributes(self) -> dict | None: return None + + +class EnvoyBatteryEntity(CoordinatorEntity, BinarySensorEntity): + """Envoy battery entity.""" + + def __init__( + self, + description, + name, + device_name, + device_serial_number, + serial_number, + coordinator, + parent_device, + ): + self.entity_description = description + self._name = name + self._serial_number = serial_number + self._device_name = device_name + self._device_serial_number = device_serial_number + self._parent_device = parent_device + CoordinatorEntity.__init__(self, coordinator) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + if self._serial_number: + return self._serial_number + if self._device_serial_number: + return f"{self._device_serial_number}_{self.entity_description.key}" + + @property + def is_on(self) -> bool: + """Return the status of the requested attribute.""" + if self.coordinator.data.get("batteries") is not None: + return ( + self.coordinator.data.get("batteries") + .get(self._device_serial_number) + .get(self.entity_description.key[10:]) + ) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + if self.coordinator.data.get("batteries") is not None: + battery = self.coordinator.data.get("batteries").get( + self._device_serial_number + ) + return {"last_reported": battery.get("report_date")} + + return None + + @property + def device_info(self) -> DeviceInfo | None: + """Return the device_info of the device.""" + if not self._device_serial_number: + return None + + sw_version = None + hw_version = None + if self.coordinator.data.get("batteries") and self.coordinator.data.get( + "batteries" + ).get(self._device_serial_number): + sw_version = ( + self.coordinator.data.get("batteries") + .get(self._device_serial_number) + .get("img_pnum_running") + ) + hw_version = ( + self.coordinator.data.get("batteries") + .get(self._device_serial_number) + .get("part_num") + ) + + return DeviceInfo( + identifiers={(DOMAIN, str(self._device_serial_number))}, + manufacturer="Enphase", + model=get_model_name("Battery", hw_version), + name=self._device_name, + via_device=(DOMAIN, self._parent_device), + sw_version=sw_version, + hw_version=resolve_hardware_id(hw_version), + ) diff --git a/custom_components/enphase_envoy/const.py b/custom_components/enphase_envoy/const.py index ac36667..64f87e8 100644 --- a/custom_components/enphase_envoy/const.py +++ b/custom_components/enphase_envoy/const.py @@ -50,7 +50,7 @@ "800-00656-r06": {"name": "Envoy-S-Standard-EU", "sku": "ENV-S-WB-230"}, "800-00656-r07": {"name": "Envoy-S-Standard-EU", "sku": "ENV-S-WB-230"}, "800-01359-r02": {"name": "IQ8+ Microinverter", "sku": "IQ8PLUS-72-M-INT"}, - "800-01391-r03 ": {"name": "IQ8HC Microinverter", "sku": "IQ8HC-72-M-INT"}, + "800-01391-r03": {"name": "IQ8HC Microinverter", "sku": "IQ8HC-72-M-INT"}, "800-01736-r02": {"name": "IQ7+ Microinverter", "sku": "IQ7PLUS-72-M-INT"}, "800-00631-r02": {"name": "IQ7+ Microinverter", "sku": "IQ7PLUS-72-2-INT"}, "800-01127-r02": {"name": "IQ7A Microinverter", "sku": "IQ7A-72-M-INT"}, @@ -174,24 +174,52 @@ def get_model_name(model, hardware_id): device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key="batteries", - name="Battery", + key="batteries_percentFull", + name="Percentage Full", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( - key="total_battery_percentage", - name="Total Battery Percentage", + key="batteries_temperature", + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + SensorEntityDescription( + key="batteries_encharge_capacity", + name="Capacity", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="batteries_encharge_capacity_current", + name="Current Capacity", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="avg_batteries_percentFull", + name="Batteries Percentage Full", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( - key="current_battery_capacity", - name="Current Battery Capacity", + key="total_batteries_encharge_capacity", + name="Batteries Capacity", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="total_batteries_encharge_capacity_current", + name="Batteries Current Capacity", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -380,22 +408,18 @@ def get_model_name(model, hardware_id): name="Firmware", device_class=BinarySensorDeviceClass.UPDATE, ), -) - -BATTERY_ENERGY_DISCHARGED_SENSOR = SensorEntityDescription( - key="battery_energy_discharged", - name="Battery Energy Discharged", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, -) - -BATTERY_ENERGY_CHARGED_SENSOR = SensorEntityDescription( - key="battery_energy_charged", - name="Battery Energy Charged", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + BinarySensorEntityDescription( + key="batteries_operating", + name="Operating", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="batteries_communicating", + name="Communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) PRODUCION_POWER_SWITCH = SwitchEntityDescription( diff --git a/custom_components/enphase_envoy/envoy_reader.py b/custom_components/enphase_envoy/envoy_reader.py index e0abb83..de94396 100644 --- a/custom_components/enphase_envoy/envoy_reader.py +++ b/custom_components/enphase_envoy/envoy_reader.py @@ -149,6 +149,23 @@ class EnvoyError(EnvoyReaderError): pass +class TestData: + def __init__(self, file): + with open(url) as json_file: + self.json_data = json.load(json_file) + + @property + def status_code(self): + return 200 + + @property + def headers(self): + return {"content-type": "application/json"} + + def json(self): + return self.json_data + + class StreamData: class PhaseData: def __init__(self, phase_data): @@ -530,6 +547,15 @@ def batteries(self): if isinstance(battery_data, list) and len(battery_data) > 0: battery_dict = {} for item in battery_data: + if "last_rpt_date" in item: + item["report_date"] = time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(item["last_rpt_date"]) + ) + if "encharge_capacity" in item and "percentFull" in item: + item["encharge_capacity_current"] = item["encharge_capacity"] * ( + item["percentFull"] / 100 + ) + battery_dict[item["serial_num"]] = item return battery_dict @@ -750,12 +776,16 @@ def async_client(self): async def _update_endpoint(self, attr, url, only_on_success=False): """Update a property from an endpoint.""" - formatted_url = url.format(self.host) - response = await self._async_fetch_with_retry( - formatted_url, follow_redirects=False - ) - if not only_on_success or response.status_code == 200: - setattr(self, attr, response) + if url.startswith("https://"): + formatted_url = url.format(self.host) + response = await self._async_fetch_with_retry( + formatted_url, follow_redirects=False + ) + if not only_on_success or response.status_code == 200: + setattr(self, attr, response) + else: + data = TestData(url) + setattr(self, attr, data) async def _async_fetch_with_retry(self, url, **kwargs): """Retry 3 times to fetch the url if there is a transport error.""" diff --git a/custom_components/enphase_envoy/sensor.py b/custom_components/enphase_envoy/sensor.py index f57c4fd..f34b93b 100644 --- a/custom_components/enphase_envoy/sensor.py +++ b/custom_components/enphase_envoy/sensor.py @@ -2,10 +2,6 @@ from __future__ import annotations -import datetime - -from time import strftime, localtime - from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, CONF_HOST @@ -16,8 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - BATTERY_ENERGY_DISCHARGED_SENSOR, - BATTERY_ENERGY_CHARGED_SENSOR, COORDINATOR, DOMAIN, NAME, @@ -84,68 +78,51 @@ async def async_setup_entry( ) ) - elif sensor_description.key == "batteries": + elif sensor_description.key.startswith("batteries_"): if coordinator.data.get("batteries") is not None: - for battery in coordinator.data["batteries"]: - entity_name = f"{name} {sensor_description.name} {battery}" + for battery in coordinator.data["batteries"].keys(): + device_name = f"Battery {battery}" + entity_name = f"{device_name} {sensor_description.name}" serial_number = battery entities.append( EnvoyBatteryEntity( sensor_description, entity_name, - name, - config_entry.unique_id, + device_name, serial_number, + None, coordinator, + config_entry.unique_id, ) ) - elif sensor_description.key == "current_battery_capacity": + elif sensor_description.key.startswith("total_batteries_"): if coordinator.data.get("batteries") is not None: - battery_capacity_entity = TotalBatteryCapacityEntity( - sensor_description, - f"{name} {sensor_description.name}", - name, - config_entry.unique_id, - None, - coordinator, - ) - entities.append(battery_capacity_entity) - + entity_name = f"{name} {sensor_description.name}" entities.append( - BatteryEnergyChangeEntity( - BATTERY_ENERGY_CHARGED_SENSOR, - f"{name} {BATTERY_ENERGY_CHARGED_SENSOR.name}", - name, - config_entry.unique_id, - None, - battery_capacity_entity, - True, - ) - ) - - entities.append( - BatteryEnergyChangeEntity( - BATTERY_ENERGY_DISCHARGED_SENSOR, - f"{name} {BATTERY_ENERGY_DISCHARGED_SENSOR.name}", + TotalBatteriesEntity( + sensor_description, + entity_name, name, config_entry.unique_id, None, - battery_capacity_entity, - False, + coordinator, + config_entry.data[CONF_HOST], ) ) - elif sensor_description.key == "total_battery_percentage": + elif sensor_description.key.startswith("avg_batteries_"): if coordinator.data.get("batteries") is not None: + entity_name = f"{name} {sensor_description.name}" entities.append( - TotalBatteryPercentageEntity( + AvgBatteriesEntity( sensor_description, - f"{name} {sensor_description.name}", + entity_name, name, config_entry.unique_id, None, coordinator, + config_entry.data[CONF_HOST], ) ) @@ -380,7 +357,7 @@ def device_info(self) -> DeviceInfo | None: ) -class EnvoyBatteryEntity(CoordinatedEnvoyEntity): +class EnvoyBatteryEntity(CoordinatorEntity, SensorEntity): """Envoy battery entity.""" def __init__( @@ -391,15 +368,28 @@ def __init__( device_serial_number, serial_number, coordinator, + parent_device, ): - super().__init__( - description=description, - name=name, - device_name=device_name, - device_serial_number=device_serial_number, - serial_number=serial_number, - coordinator=coordinator, - ) + self.entity_description = description + self._name = name + self._serial_number = serial_number + self._device_name = device_name + self._device_serial_number = device_serial_number + self._parent_device = parent_device + CoordinatorEntity.__init__(self, coordinator) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + if self._serial_number: + return self._serial_number + if self._device_serial_number: + return f"{self._device_serial_number}_{self.entity_description.key}" @property def native_value(self): @@ -407,8 +397,8 @@ def native_value(self): if self.coordinator.data.get("batteries") is not None: return ( self.coordinator.data.get("batteries") - .get(self._serial_number) - .get("percentFull") + .get(self._device_serial_number) + .get(self.entity_description.key[10:]) ) return None @@ -417,153 +407,77 @@ def native_value(self): def extra_state_attributes(self): """Return the state attributes.""" if self.coordinator.data.get("batteries") is not None: - battery = self.coordinator.data.get("batteries").get(self._serial_number) - last_reported = strftime( - "%Y-%m-%d %H:%M:%S", localtime(battery.get("last_rpt_date")) + battery = self.coordinator.data.get("batteries").get( + self._device_serial_number ) - return { - "last_reported": last_reported, - "capacity": battery.get("encharge_capacity"), - } + return {"last_reported": battery.get("report_date")} return None + @property + def device_info(self) -> DeviceInfo | None: + """Return the device_info of the device.""" + if not self._device_serial_number: + return None -class TotalBatteryCapacityEntity(CoordinatedEnvoyEntity): - 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, + sw_version = None + hw_version = None + if self.coordinator.data.get("batteries") and self.coordinator.data.get( + "batteries" + ).get(self._device_serial_number): + sw_version = ( + self.coordinator.data.get("batteries") + .get(self._device_serial_number) + .get("img_pnum_running") + ) + hw_version = ( + self.coordinator.data.get("batteries") + .get(self._device_serial_number) + .get("part_num") + ) + + return DeviceInfo( + identifiers={(DOMAIN, str(self._device_serial_number))}, + manufacturer="Enphase", + model=get_model_name("Battery", hw_version), + name=self._device_name, + via_device=(DOMAIN, self._parent_device), + sw_version=sw_version, + hw_version=resolve_hardware_id(hw_version), ) + +class AvgBatteriesEntity(CoordinatedEnvoyEntity): @property def native_value(self): """Return the state of the sensor.""" - batteries = self.coordinator.data.get("batteries") - if batteries is not None: - total = 0 - for battery in batteries: - percentage = batteries.get(battery).get("percentFull") - capacity = batteries.get(battery).get("encharge_capacity") - total += round(capacity * (percentage / 100.0)) + if self.coordinator.data.get("batteries") is not None: + avg_value = 0 + for battery in self.coordinator.data.get("batteries").keys(): + avg_value += ( + self.coordinator.data.get("batteries") + .get(battery) + .get(self.entity_description.key[14:]) + ) - return total + return avg_value / len(self.coordinator.data.get("batteries")) return None -class TotalBatteryPercentageEntity(CoordinatedEnvoyEntity): - 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, - ) - +class TotalBatteriesEntity(CoordinatedEnvoyEntity): @property def native_value(self): """Return the state of the sensor.""" - batteries = self.coordinator.data.get("batteries") - if batteries is not None: - battery_sum = 0 - for battery in batteries: - battery_sum += batteries.get(battery).get("percentFull", 0) + if self.coordinator.data.get("batteries") is not None: + total_value = 0 + for battery in self.coordinator.data.get("batteries").keys(): + total_value += ( + self.coordinator.data.get("batteries") + .get(battery) + .get(self.entity_description.key[16:]) + ) - return round(battery_sum / len(batteries), 2) + return total_value return None - - -class BatteryEnergyChangeEntity(EnvoyEntity): - def __init__( - self, - description, - name, - device_name, - device_serial_number, - serial_number, - total_battery_capacity_entity, - positive: bool, - ): - super().__init__( - description=description, - name=name, - device_name=device_name, - device_serial_number=device_serial_number, - serial_number=serial_number, - ) - - self._sensor_source = total_battery_capacity_entity - self._positive = positive - self._state = 0 - self._attr_last_reset = datetime.datetime.now() - - async def async_added_to_hass(self): - """Handle entity which will be added.""" - await super().async_added_to_hass() - - @callback - def calc_change(event): - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") - - if ( - old_state is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): - self._state = 0 - - else: - old_state_value = int(old_state.state) - new_state_value = int(new_state.state) - - if self._positive: - if new_state_value > old_state_value: - self._state = new_state_value - old_state_value - else: - self._state = 0 - - else: - if old_state_value > new_state_value: - self._state = old_state_value - new_state_value - else: - self._state = 0 - - self._attr_last_reset = datetime.datetime.now() - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._sensor_source.entity_id, calc_change - ) - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state diff --git a/test_data/envoy_metered/endpoint_ensemble_json_results.json b/test_data/envoy_metered/endpoint_ensemble_json_results.json new file mode 100644 index 0000000..065ef8b --- /dev/null +++ b/test_data/envoy_metered/endpoint_ensemble_json_results.json @@ -0,0 +1,94 @@ +[ + { + "type": "ENCHARGE", + "devices": [ + { + "part_num": "830-01760-r46", + "installed": 1712942814, + "serial_num": "122323085065", + "device_status": [ + "envoy.global.ok", + "prop.done" + ], + "last_rpt_date": 1712944638, + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "created_date": 1712942814, + "img_load_date": 1712942814, + "img_pnum_running": "2.6.5973_rel/22.11", + "zigbee_dongle_fw_version": "100F", + "bmu_fw_version": "2.1.34", + "operating": true, + "communicating": true, + "sleep_enabled": false, + "percentFull": 20, + "temperature": 20, + "maxCellTemp": 20, + "comm_level_sub_ghz": 4, + "comm_level_2_4_ghz": 4, + "led_status": 17, + "dc_switch_off": false, + "encharge_rev": 2, + "encharge_capacity": 3500 + }, + { + "part_num": "830-01760-r46", + "installed": 1712942814, + "serial_num": "122323085067", + "device_status": [ + "envoy.global.ok", + "prop.done" + ], + "last_rpt_date": 1712944677, + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "created_date": 1712942814, + "img_load_date": 1712942814, + "img_pnum_running": "2.6.5973_rel/22.11", + "zigbee_dongle_fw_version": "100F", + "bmu_fw_version": "2.1.34", + "operating": true, + "communicating": true, + "sleep_enabled": false, + "percentFull": 20, + "temperature": 20, + "maxCellTemp": 20, + "comm_level_sub_ghz": 4, + "comm_level_2_4_ghz": 4, + "led_status": 17, + "dc_switch_off": false, + "encharge_rev": 2, + "encharge_capacity": 3500 + }, + { + "part_num": "830-01760-r46", + "installed": 1712942449, + "serial_num": "122323085069", + "device_status": [ + "envoy.global.ok", + "prop.done" + ], + "last_rpt_date": 1712944722, + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "created_date": 1712942449, + "img_load_date": 1712942449, + "img_pnum_running": "2.6.5973_rel/22.11", + "zigbee_dongle_fw_version": "100F", + "bmu_fw_version": "2.1.34", + "operating": true, + "communicating": true, + "sleep_enabled": false, + "percentFull": 20, + "temperature": 20, + "maxCellTemp": 20, + "comm_level_sub_ghz": 4, + "comm_level_2_4_ghz": 4, + "led_status": 17, + "dc_switch_off": false, + "encharge_rev": 2, + "encharge_capacity": 3500 + } + ] + } +] \ No newline at end of file