Add IQ Meter Collar and C6 Combiner to enphase_envoy integration (#150649)

This commit is contained in:
Arie Catsman
2025-08-14 22:34:37 +02:00
committed by GitHub
parent 9c21965a34
commit 7e6ceee9d1
7 changed files with 631 additions and 1 deletions

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from operator import attrgetter
from pyenphase import EnvoyEncharge, EnvoyEnpower
from pyenphase import EnvoyC6CC, EnvoyCollar, EnvoyEncharge, EnvoyEnpower
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -72,6 +72,42 @@ ENPOWER_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyCollarBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an Envoy IQ Meter Collar binary sensor entity."""
value_fn: Callable[[EnvoyCollar], bool]
COLLAR_SENSORS = (
EnvoyCollarBinarySensorEntityDescription(
key="communicating",
translation_key="communicating",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("communicating"),
),
)
@dataclass(frozen=True, kw_only=True)
class EnvoyC6CCBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an C6 Combiner controller binary sensor entity."""
value_fn: Callable[[EnvoyC6CC], bool]
C6CC_SENSORS = (
EnvoyC6CCBinarySensorEntityDescription(
key="communicating",
translation_key="communicating",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("communicating"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
@@ -95,6 +131,18 @@ async def async_setup_entry(
for description in ENPOWER_SENSORS
)
if envoy_data.collar:
entities.extend(
EnvoyCollarBinarySensorEntity(coordinator, description)
for description in COLLAR_SENSORS
)
if envoy_data.c6cc:
entities.extend(
EnvoyC6CCBinarySensorEntity(coordinator, description)
for description in C6CC_SENSORS
)
async_add_entities(entities)
@@ -168,3 +216,69 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity):
enpower = self.data.enpower
assert enpower is not None
return self.entity_description.value_fn(enpower)
class EnvoyCollarBinarySensorEntity(EnvoyBaseBinarySensorEntity):
"""Defines an IQ Meter Collar binary_sensor entity."""
entity_description: EnvoyCollarBinarySensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyCollarBinarySensorEntityDescription,
) -> None:
"""Init the Collar base entity."""
super().__init__(coordinator, description)
collar_data = self.data.collar
assert collar_data is not None
self._attr_unique_id = f"{collar_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, collar_data.serial_number)},
manufacturer="Enphase",
model="IQ Meter Collar",
name=f"Collar {collar_data.serial_number}",
sw_version=str(collar_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=collar_data.serial_number,
)
@property
def is_on(self) -> bool:
"""Return the state of the Collar binary_sensor."""
collar_data = self.data.collar
assert collar_data is not None
return self.entity_description.value_fn(collar_data)
class EnvoyC6CCBinarySensorEntity(EnvoyBaseBinarySensorEntity):
"""Defines an C6 Combiner binary_sensor entity."""
entity_description: EnvoyC6CCBinarySensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyC6CCBinarySensorEntityDescription,
) -> None:
"""Init the C6 Combiner base entity."""
super().__init__(coordinator, description)
c6cc_data = self.data.c6cc
assert c6cc_data is not None
self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, c6cc_data.serial_number)},
manufacturer="Enphase",
model="C6 COMBINER CONTROLLER",
name=f"C6 Combiner {c6cc_data.serial_number}",
sw_version=str(c6cc_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=c6cc_data.serial_number,
)
@property
def is_on(self) -> bool:
"""Return the state of the C6 Combiner binary_sensor."""
c6cc_data = self.data.c6cc
assert c6cc_data is not None
return self.entity_description.value_fn(c6cc_data)

View File

@@ -12,6 +12,8 @@ from typing import TYPE_CHECKING
from pyenphase import (
EnvoyACBPower,
EnvoyBatteryAggregate,
EnvoyC6CC,
EnvoyCollar,
EnvoyEncharge,
EnvoyEnchargeAggregate,
EnvoyEnchargePower,
@@ -790,6 +792,58 @@ ENPOWER_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyCollarSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy Collar sensor entity."""
value_fn: Callable[[EnvoyCollar], datetime.datetime | int | float | str]
COLLAR_SENSORS = (
EnvoyCollarSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=attrgetter("temperature"),
),
EnvoyCollarSensorEntityDescription(
key=LAST_REPORTED_KEY,
translation_key=LAST_REPORTED_KEY,
native_unit_of_measurement=None,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda collar: dt_util.utc_from_timestamp(collar.last_report_date),
),
EnvoyCollarSensorEntityDescription(
key="grid_state",
translation_key="grid_status",
value_fn=lambda collar: collar.grid_state,
),
EnvoyCollarSensorEntityDescription(
key="mid_state",
translation_key="mid_state",
value_fn=lambda collar: collar.mid_state,
),
)
@dataclass(frozen=True, kw_only=True)
class EnvoyC6CCSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy C6 Combiner controller sensor entity."""
value_fn: Callable[[EnvoyC6CC], datetime.datetime]
C6CC_SENSORS = (
EnvoyC6CCSensorEntityDescription(
key=LAST_REPORTED_KEY,
translation_key=LAST_REPORTED_KEY,
native_unit_of_measurement=None,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda c6cc: dt_util.utc_from_timestamp(c6cc.last_report_date),
),
)
@dataclass(frozen=True)
class EnvoyEnchargeAggregateRequiredKeysMixin:
"""Mixin for required keys."""
@@ -1050,6 +1104,15 @@ async def async_setup_entry(
AggregateBatteryEntity(coordinator, description)
for description in AGGREGATE_BATTERY_SENSORS
)
if envoy_data.collar:
entities.extend(
EnvoyCollarEntity(coordinator, description)
for description in COLLAR_SENSORS
)
if envoy_data.c6cc:
entities.extend(
EnvoyC6CCEntity(coordinator, description) for description in C6CC_SENSORS
)
async_add_entities(entities)
@@ -1488,3 +1551,70 @@ class AggregateBatteryEntity(EnvoySystemSensorEntity):
battery_aggregate = self.data.battery_aggregate
assert battery_aggregate is not None
return self.entity_description.value_fn(battery_aggregate)
class EnvoyCollarEntity(EnvoySensorBaseEntity):
"""Envoy Collar sensor entity."""
entity_description: EnvoyCollarSensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyCollarSensorEntityDescription,
) -> None:
"""Initialize Collar entity."""
super().__init__(coordinator, description)
collar_data = self.data.collar
assert collar_data is not None
self._serial_number = collar_data.serial_number
self._attr_unique_id = f"{collar_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, collar_data.serial_number)},
manufacturer="Enphase",
model="IQ Meter Collar",
name=f"Collar {collar_data.serial_number}",
sw_version=str(collar_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=collar_data.serial_number,
)
@property
def native_value(self) -> datetime.datetime | int | float | str:
"""Return the state of the collar sensors."""
collar_data = self.data.collar
assert collar_data is not None
return self.entity_description.value_fn(collar_data)
class EnvoyC6CCEntity(EnvoySensorBaseEntity):
"""Envoy C6CC sensor entity."""
entity_description: EnvoyC6CCSensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyC6CCSensorEntityDescription,
) -> None:
"""Initialize Encharge entity."""
super().__init__(coordinator, description)
c6cc_data = self.data.c6cc
assert c6cc_data is not None
self._attr_unique_id = f"{c6cc_data.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, c6cc_data.serial_number)},
manufacturer="Enphase",
model="C6 COMBINER CONTROLLER",
name=f"C6 Combiner {c6cc_data.serial_number}",
sw_version=str(c6cc_data.firmware_version),
via_device=(DOMAIN, self.envoy_serial_num),
serial_number=c6cc_data.serial_number,
)
@property
def native_value(self) -> datetime.datetime:
"""Return the state of the c6cc inventory sensors."""
c6cc_data = self.data.c6cc
assert c6cc_data is not None
return self.entity_description.value_fn(c6cc_data)

View File

@@ -407,6 +407,12 @@
},
"last_report_duration": {
"name": "Last report duration"
},
"grid_status": {
"name": "[%key:component::enphase_envoy::entity::binary_sensor::grid_status::name%]"
},
"mid_state": {
"name": "MID state"
}
},
"switch": {

View File

@@ -9,6 +9,8 @@ import multidict
from pyenphase import (
EnvoyACBPower,
EnvoyBatteryAggregate,
EnvoyC6CC,
EnvoyCollar,
EnvoyData,
EnvoyEncharge,
EnvoyEnchargeAggregate,
@@ -260,6 +262,10 @@ def _load_json_2_encharge_enpower_data(
)
if item := json_fixture["data"].get("battery_aggregate"):
mocked_data.battery_aggregate = EnvoyBatteryAggregate(**item)
if item := json_fixture["data"].get("collar"):
mocked_data.collar = EnvoyCollar(**item)
if item := json_fixture["data"].get("c6cc"):
mocked_data.c6cc = EnvoyC6CC(**item)
def _load_json_2_raw_data(mocked_data: EnvoyData, json_fixture: dict[str, Any]) -> None:

View File

@@ -407,6 +407,35 @@
"type": "NONE"
}
},
"collar": {
"admin_state": 88,
"admin_state_str": "ENCMN_MDE_ON_GRID",
"firmware_loaded_date": 1752939759,
"firmware_version": "3.0.6-D0",
"installed_date": 1752939759,
"last_report_date": 1752939759,
"communicating": true,
"mid_state": "close",
"grid_state": "on_grid",
"part_number": "865-00400-r22",
"serial_number": "482520020939",
"temperature": 42,
"temperature_unit": "C",
"control_error": 0,
"collar_state": "Installed"
},
"c6cc": {
"admin_state": 82,
"admin_state_str": "ENCMN_C6_CC_READY",
"firmware_loaded_date": 1752945451,
"firmware_version": "0.1.20-D1",
"installed_date": 1752945451,
"last_report_date": 1752945451,
"communicating": true,
"part_number": "800-02403-r08",
"serial_number": "482523040549",
"dmir_version": "0.1.20-D1"
},
"inverters": {
"1": {
"serial_number": "1",

View File

@@ -96,6 +96,104 @@
'state': 'on',
})
# ---
# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Communicating',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'communicating',
'unique_id': '482523040549_communicating',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.c6_combiner_482523040549_communicating-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'C6 Combiner 482523040549 Communicating',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.c6_combiner_482523040549_communicating',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.collar_482520020939_communicating',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Communicating',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'communicating',
'unique_id': '482520020939_communicating',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.collar_482520020939_communicating-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'Collar 482520020939 Communicating',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.collar_482520020939_communicating',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.encharge_123456_communicating-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -13947,6 +13947,253 @@
'state': 'unknown',
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.c6_combiner_482523040549_last_reported',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last reported',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_reported',
'unique_id': '482523040549_last_reported',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.c6_combiner_482523040549_last_reported-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'C6 Combiner 482523040549 Last reported',
}),
'context': <ANY>,
'entity_id': 'sensor.c6_combiner_482523040549_last_reported',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-07-19T17:17:31+00:00',
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.collar_482520020939_grid_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Grid status',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'grid_status',
'unique_id': '482520020939_grid_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_grid_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Collar 482520020939 Grid status',
}),
'context': <ANY>,
'entity_id': 'sensor.collar_482520020939_grid_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on_grid',
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.collar_482520020939_last_reported',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last reported',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_reported',
'unique_id': '482520020939_last_reported',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_last_reported-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Collar 482520020939 Last reported',
}),
'context': <ANY>,
'entity_id': 'sensor.collar_482520020939_last_reported',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-07-19T15:42:39+00:00',
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.collar_482520020939_mid_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'MID state',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'mid_state',
'unique_id': '482520020939_mid_state',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_mid_state-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Collar 482520020939 MID state',
}),
'context': <ANY>,
'entity_id': 'sensor.collar_482520020939_mid_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'close',
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.collar_482520020939_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'enphase_envoy',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '482520020939_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.collar_482520020939_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Collar 482520020939 Temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.collar_482520020939_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '42',
})
# ---
# name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({