"""Home Assistant integration module for Greencell EVSE sensor entities over MQTT.""" from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any, override from greencell_client.access import GreencellAccess from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase from greencell_client.mqtt_parser import MqttParser from greencell_client.utils import GreencellUtils from homeassistant.components import mqtt from homeassistant.components.mqtt import ReceiveMessage from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfPower, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( CONF_SERIAL_NUMBER, DOMAIN, GREENCELL_HABU_DEN, GREENCELL_OTHER_DEVICE, MANUFACTURER, ) from .models import GreencellConfigEntry _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class GreencellSensorDescription(SensorEntityDescription): """Describe a Greencell sensor.""" value_fn: Callable[[Any], StateType] SENSOR_DESCRIPTIONS = ( GreencellSensorDescription( key="current_l1", translation_key="current_l1", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=3, value_fn=lambda data: data / 1000, ), GreencellSensorDescription( key="current_l2", translation_key="current_l2", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=3, value_fn=lambda data: data / 1000, ), GreencellSensorDescription( key="current_l3", translation_key="current_l3", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, suggested_display_precision=3, value_fn=lambda data: data / 1000, ), GreencellSensorDescription( key="voltage_l1", translation_key="voltage_l1", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=2, value_fn=lambda data: data, ), GreencellSensorDescription( key="voltage_l2", translation_key="voltage_l2", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=2, value_fn=lambda data: data, ), GreencellSensorDescription( key="voltage_l3", translation_key="voltage_l3", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=2, value_fn=lambda data: data, ), GreencellSensorDescription( key="power", translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=1, value_fn=lambda data: data, ), GreencellSensorDescription( key="status", translation_key="status", device_class=SensorDeviceClass.ENUM, options=[ "idle", "connected", "waiting_for_car", "charging", "finished", "error_car", "error_evse", ], value_fn=lambda data: str(data).lower() if isinstance(data, str) else None, ), ) # --- Config Flow Setup --- async def async_setup_entry( hass: HomeAssistant, entry: GreencellConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Greencell EVSE sensors from a config entry.""" serial_number: str = entry.data[CONF_SERIAL_NUMBER] mqtt_topic_current = f"/greencell/evse/{serial_number}/current" mqtt_topic_voltage = f"/greencell/evse/{serial_number}/voltage" mqtt_topic_power = f"/greencell/evse/{serial_number}/power" mqtt_topic_status = f"/greencell/evse/{serial_number}/status" mqtt_topic_device_state = f"/greencell/evse/{serial_number}/device_state" desc_map = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} runtime = entry.runtime_data access = runtime.access current_data_obj = runtime.current_data voltage_data_obj = runtime.voltage_data power_data_obj = runtime.power_data state_data_obj = runtime.state_data data_mapping = { "current": current_data_obj, "voltage": voltage_data_obj, "power": power_data_obj, "status": state_data_obj, } sensors: list[HabuSensor] = [ Habu3PhaseSensor( sensor_data=data_mapping[description.key.split("_")[0]], phase=description.key.split("_")[-1], sensor_type=description.key, serial_number=serial_number, access=access, description=description, ) for description in SENSOR_DESCRIPTIONS if description.key.startswith(("current_l", "voltage_l")) ] sensors.extend( HabuSingleSensor( sensor_data=data_mapping[key], serial_number=serial_number, sensor_type=key, access=access, description=desc_map[key], ) for key in ("power", "status") ) @callback def current_message_received(msg: ReceiveMessage) -> None: """Handle the current message.""" MqttParser.parse_3phase_msg(msg.payload, current_data_obj) @callback def voltage_message_received(msg: ReceiveMessage) -> None: """Handle the voltage message.""" MqttParser.parse_3phase_msg(msg.payload, voltage_data_obj) @callback def power_message_received(msg: ReceiveMessage) -> None: """Handle the power message.""" MqttParser.parse_single_phase_msg(msg.payload, "momentary", power_data_obj) @callback def status_message_received(msg: ReceiveMessage) -> None: """Handle the status message. If the device is unavailable, disable the entity.""" str_payload = ( msg.payload.decode("utf-8", errors="ignore") if isinstance(msg.payload, (bytes, bytearray)) else str(msg.payload) ) if "UNAVAILABLE" in str_payload or "OFFLINE" in str_payload: access.update("UNAVAILABLE") else: MqttParser.parse_single_phase_msg(msg.payload, "state", state_data_obj) @callback def device_state_message_received(msg: ReceiveMessage) -> None: """Handle the device state message. If device was unavailable, enable the entity.""" access.on_msg(msg.payload) try: for topic, handler in ( (mqtt_topic_current, current_message_received), (mqtt_topic_voltage, voltage_message_received), (mqtt_topic_power, power_message_received), (mqtt_topic_status, status_message_received), (mqtt_topic_device_state, device_state_message_received), ): unsub = await mqtt.async_subscribe(hass, topic, handler) if unsub is not None: entry.async_on_unload(unsub) except HomeAssistantError as err: raise ConfigEntryNotReady(f"MQTT is unavailable: {err}") from err async_add_entities(sensors) class HabuSensor(SensorEntity): """Abstract base class for Habu sensors integration.""" entity_description: GreencellSensorDescription _attr_has_entity_name = True _remove_listener: Callable[[], None] | None = None def __init__( self, sensor_type: str, serial_number: str, access: GreencellAccess, description: GreencellSensorDescription, ) -> None: """Initialize the sensor entity.""" self._sensor_type = sensor_type self._serial_number = serial_number self._access = access self.entity_description = description self._attr_unique_id = f"{serial_number}_{description.key}" if GreencellUtils.device_is_habu_den(self._serial_number): device_name = GREENCELL_HABU_DEN else: device_name = GREENCELL_OTHER_DEVICE self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial_number)}, name=f"{device_name} {serial_number}", manufacturer=MANUFACTURER, model=device_name, serial_number=serial_number, ) @property @override def available(self) -> bool: """Return True if the entity is available.""" return not self._access.is_disabled() @override async def async_added_to_hass(self) -> None: """Register the entity with Home Assistant.""" unsub = self._access.register_listener(self._schedule_update) if unsub is not None: self.async_on_remove(unsub) def _schedule_update(self) -> None: """Schedule an update for the entity.""" self.async_schedule_update_ha_state() class Habu3PhaseSensor(HabuSensor): """Abstract class for 3-phase sensors (e.g. current, voltage).""" def __init__( self, sensor_data: ElecData3Phase, phase: str, sensor_type: str, serial_number: str, access: GreencellAccess, description: GreencellSensorDescription, ) -> None: """Initialize the 3-phase sensor.""" super().__init__(sensor_type, serial_number, access, description) self._sensor_data = sensor_data self._phase = phase @property @override def native_value(self) -> StateType: """Return the state of the sensor.""" raw_value = self._sensor_data.get_value(self._phase) if raw_value is None: return None return self.entity_description.value_fn(raw_value) class HabuSingleSensor(HabuSensor): """Example class for sensors that return a single value.""" def __init__( self, sensor_data: ElecDataSinglePhase, serial_number: str, sensor_type: str, access: GreencellAccess, description: GreencellSensorDescription, ) -> None: """Initialize the single-value sensor.""" super().__init__(sensor_type, serial_number, access, description) self._value = sensor_data @property @override def native_value(self) -> StateType: """Return the state of the sensor.""" raw_value = self._value.data if raw_value is None: return None return self.entity_description.value_fn(raw_value)