diff --git a/CODEOWNERS b/CODEOWNERS index be7c1e5ee84d28..b31544e211f422 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -594,6 +594,7 @@ build.json @home-assistant/supervisor /tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche +/homeassistant/components/greencell/ @BrzezowskiGC /homeassistant/components/greeneye_monitor/ @jkeljo /tests/components/greeneye_monitor/ @jkeljo /homeassistant/components/group/ @home-assistant/core diff --git a/homeassistant/components/greencell/__init__.py b/homeassistant/components/greencell/__init__.py new file mode 100644 index 00000000000000..dc26805a231a5d --- /dev/null +++ b/homeassistant/components/greencell/__init__.py @@ -0,0 +1,76 @@ +import json +import logging + + +from .const import DOMAIN, GREENCELL_DISC_TOPIC +from .const import GreencellHaAccessLevelEnum as AccessLevel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.const import Platform +from homeassistant.components.mqtt import async_subscribe + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the GreenCell integration.""" + setup_reset_msg_listener(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up GreenCell from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data + + platforms = [Platform.SENSOR, Platform.BUTTON, Platform.NUMBER] + await hass.config_entries.async_forward_entry_setups(entry, platforms) + + setup_reset_msg_listener(hass) + return True + + +def setup_reset_msg_listener(hass: HomeAssistant) -> None: + """Set up a listener for hello/reset messages from devices.""" + + @callback + def handle_hello_message(message): + """Handle the hello message from a device.""" + try: + msg = json.loads(message.payload) + device_id = msg.get("id") + + if not device_id: + _LOGGER.warning(f"Received message without ID: {msg}") + return + + known_ids = [ + entry_data.get("serial_number") + for entry_data in hass.data.get(DOMAIN, {}).values() + ] + + if device_id in known_ids: + _LOGGER.info(f"Device {device_id} is already known") + return + + hass.async_create_task( + hass.services.async_call( + "mqtt", + "publish", + { + "topic": f"/greencell/evse/{device_id}/cmd", + "payload": json.dumps({"name": "QUERY"}), + "retain": False, + }, + ) + ) + + except Exception as e: + _LOGGER.error(f"Error processing hello/reset message: {e}") + + async def mqtt_subscribe(): + """Wrapper for async_subscribe to handle the subscription.""" + await async_subscribe(hass, GREENCELL_DISC_TOPIC, handle_hello_message) + + hass.async_create_task(mqtt_subscribe()) diff --git a/homeassistant/components/greencell/button.py b/homeassistant/components/greencell/button.py new file mode 100644 index 00000000000000..def6428f11bf48 --- /dev/null +++ b/homeassistant/components/greencell/button.py @@ -0,0 +1,306 @@ +import logging +import json +from typing import Callable + +from homeassistant.components.mqtt import async_subscribe +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.config_entries import ConfigEntry + +from .const import ( + EvseStateEnum, + GreencellHaAccessLevelEnum as AccessLevel, + MANUFACTURER, + GREENCELL_HABU_DEN, + GREENCELL_OTHER_DEVICE, + GREENCELL_HABU_DEN_SERIAL_PREFIX, +) + +from .helper import GreencellAccess + +_LOGGER = logging.getLogger(__name__) + + +class EvseStateData: + """Simple internal EVSE state tracker (charging / idle).""" + + def __init__(self) -> None: + self._state = EvseStateEnum.UNKNOWN + self._listeners = [] + + def update(self, new_state: str) -> None: + """Update the EVSE state based on the received message.""" + if "IDLE" == new_state: + self._state = EvseStateEnum.IDLE + elif "CONNECTED" == new_state: + self._state = EvseStateEnum.CONNECTED + elif "WAITING_FOR_CAR" == new_state: + self._state = EvseStateEnum.WAITING_FOR_CAR + elif "CHARGING" == new_state: + self._state = EvseStateEnum.CHARGING + elif "FINISHED" == new_state: + self._state = EvseStateEnum.FINISHED + elif "ERROR_CAR" == new_state: + self._state = EvseStateEnum.ERROR_CAR + elif "ERROR_EVSE" == new_state: + self._state = EvseStateEnum.ERROR_EVSE + else: + self._state = EvseStateEnum.UNKNOWN + + self._notify_listeners() + _LOGGER.debug(f"EVSE state updated to {self._state}") + + def is_charging(self) -> bool: + """Check if the EVSE is currently charging and can be stopped.""" + return EvseStateEnum.CHARGING == self._state + + def can_be_stopped(self) -> bool: + """Check if the EVSE is in a state where charging can be stopped.""" + return EvseStateEnum.WAITING_FOR_CAR == self._state + + def can_be_started(self) -> bool: + """Check if the EVSE is in a state where charging can be started.""" + return ( + EvseStateEnum.FINISHED == self._state + or EvseStateEnum.CONNECTED == self._state + ) + + def set_charging(self, value: bool) -> None: + """Set the charging state of the EVSE.""" + self._charging = value + + def register_listener(self, listener: Callable[[], None]) -> None: + """Register a listener to be notified of state changes.""" + self._listeners.append(listener) + + def _notify_listeners(self) -> None: + """Notify all registered listeners of a state change.""" + for listener in self._listeners: + listener() + + +class EVSEChargingButton(ButtonEntity): + """Base class for EVSE charging buttons.""" + + def __init__( + self, + serial_number: str, + mqtt_topic: str, + evse_state: EvseStateData, + name: str, + icon: str, + action: str, + access: GreencellAccess, + ) -> None: + self._serial = serial_number + self._mqtt_topic = mqtt_topic + self._evse_state = evse_state + self._attr_name = name + self._icon = icon + self._action = action + self._access = access + + def _device_is_habu_den(self) -> bool: + """Check if the device is a Habu Den based on its serial number.""" + return self._serial.startswith(GREENCELL_HABU_DEN_SERIAL_PREFIX) + + def _device_name(self) -> str: + """Return the device name based on its type.""" + if self._device_is_habu_den(): + return GREENCELL_HABU_DEN + else: + return GREENCELL_OTHER_DEVICE + + @property + def unique_id(self) -> str: + """Return a unique ID for the button (based on serial number of device).""" + return f"{self._device_name()}_{self._serial}_{self._action.lower()}" + + @property + def icon(self) -> str: + """Return the icon for the button.""" + return self._icon + + @property + def device_info(self) -> dict: + """Return device information for the button.""" + return { + "identifiers": {(self._serial,)}, + "name": f"{self._device_name()} {self._serial}", + "manufacturer": MANUFACTURER, + "model": self._device_name(), + } + + async def async_press(self) -> None: + """Handle button press.""" + payload = f'{{"name": "{self._action.upper()}"}}' + await self.hass.services.async_call( + "mqtt", + "publish", + { + "topic": self._mqtt_topic, + "payload": payload, + "retain": False, + }, + blocking=True, + ) + self._update_evse_state() + self.async_write_ha_state() + + def _update_evse_state(self) -> None: + """To be implemented in subclasses if needed.""" + pass + + async def async_added_to_hass(self) -> None: + """Called when entity is added to Home Assistant.""" + self._evse_state.register_listener(self._schedule_update) + self._access.register_listener(self._schedule_update) + + def _schedule_update(self): + """Schedule state update from external EVSE state change.""" + if self.hass: + self.async_write_ha_state() + + +class StartChargingButton(EVSEChargingButton): + def __init__( + self, serial_number: str, mqtt_topic: str, evse_state, access: GreencellAccess + ) -> None: + super().__init__( + serial_number, + mqtt_topic, + evse_state, + name="Start Charging", + icon="mdi:play-circle-outline", + action="START", + access=access, + ) + + @property + def available(self) -> bool: + """Return True if the button is available (when charging is not allowed by user).""" + return self._evse_state.can_be_started() and self._access.can_execute() + + def _update_evse_state(self) -> None: + """Update the EVSE state to indicate that charging has started.""" + self._evse_state.set_charging(True) + + +class StopChargingButton(EVSEChargingButton): + def __init__( + self, serial_number: str, mqtt_topic: str, evse_state, access: GreencellAccess + ) -> None: + super().__init__( + serial_number, + mqtt_topic, + evse_state, + name="Stop Charging", + icon="mdi:stop-circle-outline", + action="STOP", + access=access, + ) + + @property + def available(self) -> bool: + """Return True if the button is available (when device can charge / is charging).""" + return ( + self._evse_state.is_charging() or self._evse_state.can_be_stopped() + ) and self._access.can_execute() + + def _update_evse_state(self) -> None: + """Update the EVSE state to indicate that charging has stopped.""" + self._evse_state.set_charging(False) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Greencell EVSE buttons from YAML/discovery.""" + serial_number = ( + discovery_info.get("serial_number") + if discovery_info + else config.get("serial_number") + ) + + if not serial_number: + _LOGGER.error("Serial number not provided in discovery info or config.") + return + + await _setup_evse_buttons(hass, async_add_entities, serial_number) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Greencell EVSE buttons from config entry.""" + serial_number = ( + discovery_info.get("serial_number") + if discovery_info + else entry.data.get("serial_number") + ) + + if not serial_number: + _LOGGER.error("Serial number not provided in discovery info or entry data.") + return + + await _setup_evse_buttons(hass, async_add_entities, serial_number) + + +async def _setup_evse_buttons( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + serial_number: str, +) -> None: + """Set up the Greencell EVSE buttons.""" + mqtt_cmd_topic = f"/greencell/evse/{serial_number}/cmd" + mqtt_topic_status = f"/greencell/evse/{serial_number}/status" + mqtt_ha_access_topic = f"/greencell/evse/{serial_number}/device_state" + + evse_state_object = EvseStateData() + access = GreencellAccess(AccessLevel.EXECUTE) + + @callback + def state_msg_received(msg) -> None: + """Handle incoming MQTT messages for EVSE state. If LWT message is received, update the state to OFFLINE.""" + try: + data = json.loads(msg.payload) + if "state" in data: + state = data["state"] + if "OFFLINE" in state: + access.update("OFFLINE") + else: + evse_state_object.update(state) + except json.JSONDecodeError as e: + _LOGGER.error(f"Error decoding JSON message: {e}") + except Exception as e: + _LOGGER.error(f"Unexpected error: {e}") + + @callback + def device_state_msg_received(msg) -> None: + """Handle incoming MQTT messages for device state. If access level is different from EXECUTE, disable buttons.""" + try: + data = json.loads(msg.payload) + if "level" in data: + access.update(data["level"]) + except json.JSONDecodeError as e: + _LOGGER.error(f"Failed to decode HA access message: {e}") + except Exception as e: + _LOGGER.error(f"Unexpected error: {e}") + + await async_subscribe(hass, mqtt_ha_access_topic, device_state_msg_received) + await async_subscribe(hass, mqtt_topic_status, state_msg_received) + + buttons = [ + StartChargingButton(serial_number, mqtt_cmd_topic, evse_state_object, access), + StopChargingButton(serial_number, mqtt_cmd_topic, evse_state_object, access), + ] + + async_add_entities(buttons) diff --git a/homeassistant/components/greencell/config_flow.py b/homeassistant/components/greencell/config_flow.py new file mode 100644 index 00000000000000..9818aedc36337d --- /dev/null +++ b/homeassistant/components/greencell/config_flow.py @@ -0,0 +1,95 @@ +import asyncio +import logging +import json +from typing import Any + +from homeassistant import config_entries +from homeassistant.components import mqtt +from homeassistant.core import callback + +from .const import ( + DOMAIN, + GREENCELL_BROADCAST_TOPIC, + GREENCELL_DISC_TOPIC, + GREENCELL_HABU_DEN, + GREENCELL_OTHER_DEVICE, + GREENCELL_HABU_DEN_SERIAL_PREFIX, + DISCOVERY_TIMEOUT, +) + +_LOGGER = logging.getLogger(__name__) + + +class EVSEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for EVSE device.""" + + VERSION = 1 + + def __init__(self): + self.discovery_event = asyncio.Event() + self.discovery_data = None + self._remove_listener = None + + def get_device_name(self, serial_number: str) -> str: + """Get device name based on serial number.""" + if serial_number.startswith(GREENCELL_HABU_DEN_SERIAL_PREFIX): + return GREENCELL_HABU_DEN + else: + return GREENCELL_OTHER_DEVICE + + async def _publish_disc_request(self): + """Publish a discovery request to the MQTT topic.""" + payload = json.dumps({"name": "BROADCAST"}) + await mqtt.async_publish(self.hass, GREENCELL_BROADCAST_TOPIC, payload, 0, True) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle the initial step.""" + + self._remove_listener = await mqtt.async_subscribe( + self.hass, GREENCELL_DISC_TOPIC, self._mqtt_message_received + ) + + await self._publish_disc_request() + + try: + await asyncio.wait_for( + self.discovery_event.wait(), timeout=DISCOVERY_TIMEOUT + ) + except asyncio.TimeoutError: + _LOGGER.warning("Device discovery timed out") + return self.async_abort(reason="discovery_timeout") + finally: + if self._remove_listener: + self._remove_listener() + + discovery_payload = self.discovery_data + serial_number = discovery_payload.get("id") + + if not serial_number: + _LOGGER.error("Invalid discovery payload: {discovery_payload}") + return self.async_abort(reason="invalid_discovery_data") + + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + + _LOGGER.info(f"Device {serial_number} successfully added via config flow") + + dev_name = self.get_device_name(serial_number) + return self.async_create_entry( + title=f"{dev_name} {serial_number}", + data={ + "serial_number": serial_number, + }, + ) + + @callback + def _mqtt_message_received(self, msg): + """Handle incoming MQTT messages.""" + try: + payload = json.loads(msg.payload) + self.discovery_data = payload + self.discovery_event.set() + except json.JSONDecodeError: + _LOGGER.error(f"Failed to decode MQTT message payload: {msg.payload}") diff --git a/homeassistant/components/greencell/const.py b/homeassistant/components/greencell/const.py new file mode 100644 index 00000000000000..977154b84650f2 --- /dev/null +++ b/homeassistant/components/greencell/const.py @@ -0,0 +1,50 @@ +from typing import Final +from enum import Enum + + +class EvseStateEnum(Enum): + IDLE = (1,) + CONNECTED = (2,) + WAITING_FOR_CAR = (3,) + CHARGING = (4,) + FINISHED = (5,) + ERROR_CAR = (6,) + ERROR_EVSE = (7,) + UNKNOWN = 8 + + +class GreencellHaAccessLevelEnum(Enum): + DISABLED = (0,) + READ_ONLY = (1,) + EXECUTE = (2,) + OFFLINE = 3 + + +# Greencell constants + +DOMAIN = "greencell" +MANUFACTURER: Final = "Greencell" + +# Maximal current configuration + +DEFAULT_MIN_CURRENT = 6 +DEFAULT_MAX_CURRENT_OTHER = 16 +DEFAULT_MAX_CURRENT_HABU_DEN = 32 + +# Topics + +GREENCELL_BROADCAST_TOPIC = "/greencell/broadcast" +GREENCELL_DISC_TOPIC = "/greencell/broadcast/device" + +# Device names + +GREENCELL_HABU_DEN = "Habu Den" +GREENCELL_OTHER_DEVICE = "Greencell Device" + +# Serial prefixes + +GREENCELL_HABU_DEN_SERIAL_PREFIX = "EVGC02" + +# Other constants + +DISCOVERY_TIMEOUT = 30.0 diff --git a/homeassistant/components/greencell/helper.py b/homeassistant/components/greencell/helper.py new file mode 100644 index 00000000000000..b91e7026607bbe --- /dev/null +++ b/homeassistant/components/greencell/helper.py @@ -0,0 +1,50 @@ +from typing import Callable +import logging + +from .const import GreencellHaAccessLevelEnum as AccessLevel + +_LOGGER = logging.getLogger(__name__) + + +class GreencellAccess: + """Class to manage access levels for Greencell devices.""" + + def __init__(self, access_level: AccessLevel): + self._access_level = access_level + self._listeners = [] + + def update(self, new_access_level: str) -> None: + """Update the access level and notify listeners.""" + if new_access_level == "DISABLED": + self._access_level = AccessLevel.DISABLED + elif new_access_level == "READ": + self._access_level = AccessLevel.READ_ONLY + elif new_access_level == "EXECUTE": + self._access_level = AccessLevel.EXECUTE + elif new_access_level == "OFFLINE": + self._access_level = AccessLevel.OFFLINE + else: + _LOGGER.error(f"Invalid access level: {new_access_level}") + return + + self._notify_listeners() + + def register_listener(self, listener: Callable[[], None]) -> None: + """Register a listener to be notified when the access level changes.""" + self._listeners.append(listener) + + def _notify_listeners(self) -> None: + """Notify all registered listeners of the access level change.""" + for listener in self._listeners: + listener() + + def can_execute(self) -> bool: + """Check if the current access level allows execution of commands.""" + return self._access_level == AccessLevel.EXECUTE + + def is_disabled(self) -> bool: + """Check if the current access level is disabled.""" + return ( + self._access_level == AccessLevel.DISABLED + or self._access_level == AccessLevel.OFFLINE + ) diff --git a/homeassistant/components/greencell/manifest.json b/homeassistant/components/greencell/manifest.json new file mode 100644 index 00000000000000..829b91aaab22d7 --- /dev/null +++ b/homeassistant/components/greencell/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "greencell", + "name": "Greencell", + "codeowners": ["@greencell"], + "dependencies": ["mqtt"], + "config_flow": true, + "iot_class": "local_polling", + "supported_platforms": [ + "sensor", + "switch", + "number" + ] +} diff --git a/homeassistant/components/greencell/number.py b/homeassistant/components/greencell/number.py new file mode 100644 index 00000000000000..57b484bae37bba --- /dev/null +++ b/homeassistant/components/greencell/number.py @@ -0,0 +1,272 @@ +import logging +import json + + +from homeassistant.components.number import NumberEntity +from homeassistant.components.mqtt import async_publish, async_subscribe +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.config_entries import ConfigEntry + +from .const import ( + DEFAULT_MIN_CURRENT, + DEFAULT_MAX_CURRENT_OTHER, + DEFAULT_MAX_CURRENT_HABU_DEN, + MANUFACTURER, + GREENCELL_HABU_DEN, + GREENCELL_OTHER_DEVICE, + GREENCELL_HABU_DEN_SERIAL_PREFIX, +) +from .const import GreencellHaAccessLevelEnum as AccessLevel +from .helper import GreencellAccess + +_LOGGER = logging.getLogger(__name__) + + +class EVSEMaxCurrent(NumberEntity): + def __init__( + self, + hass: HomeAssistant, + serial_number: str, + max_current: int, + access: GreencellAccess, + ): + self._hass = hass + self._serial = serial_number + self._access = access + self._attr_name = "EVSE Max Current" + self._attr_native_unit_of_measurement = "A" + self._attr_native_min_value = DEFAULT_MIN_CURRENT + + self._attr_native_step = 1 + self._value = DEFAULT_MIN_CURRENT + + if self._device_is_habu_den(): + if max_current > DEFAULT_MAX_CURRENT_HABU_DEN: + _LOGGER.warning( + f"Max current for Habu Den is limited to {DEFAULT_MAX_CURRENT_HABU_DEN} A" + ) + max_current = DEFAULT_MAX_CURRENT_HABU_DEN + else: + if max_current > DEFAULT_MAX_CURRENT_OTHER: + _LOGGER.warning( + f"Max current for unknown device type is limited to {DEFAULT_MAX_CURRENT_OTHER} A" + ) + max_current = DEFAULT_MAX_CURRENT_OTHER + + self._attr_native_max_value = max_current + + def _device_is_habu_den(self) -> bool: + """Check if the device is a Habu Den based on its serial number.""" + return self._serial.startswith(GREENCELL_HABU_DEN_SERIAL_PREFIX) + + def _device_name(self) -> str: + """Return the device name based on its type.""" + if self._device_is_habu_den(): + return GREENCELL_HABU_DEN + else: + return GREENCELL_OTHER_DEVICE + + @property + def unique_id(self) -> str: + """Return a unique ID for the entity.""" + return f"{self._device_name()}_{self._serial}_max_current" + + @property + def native_value(self) -> int: + """Return the current value.""" + return self._value + + @property + def device_info(self) -> dict: + """Return device information.""" + if self._device_is_habu_den(): + device_name = GREENCELL_HABU_DEN + else: + device_name = GREENCELL_OTHER_DEVICE + return { + "identifiers": {(self._serial,)}, + "name": f"{device_name} {self._serial}", + "manufacturer": MANUFACTURER, + "model": device_name, + } + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return self._access.can_execute() + + @property + def max_current(self) -> int: + """Return the max current value.""" + return self._value + + @property + def enabled(self) -> bool: + """Return True if the entity is enabled.""" + return self._access.can_execute() + + async def _send_new_value_set_request(self, value: int) -> None: + """Send the new value to the device.""" + topic = f"/greencell/evse/{self._serial}/cmd" + payload = json.dumps({"name": "SET_CURRENT", "current": value}) + + _LOGGER.info(f"Set max current to {self._value} A on {self._serial}") + await async_publish(self._hass, topic, payload, qos=1) + + async def _update_current_value(self, value: int) -> None: + """Set the value of the entity.""" + self._value = value + self.async_schedule_update_ha_state() + + async def async_set_native_value(self, value: float) -> None: + """Set the value of the entity.""" + if value == self._value: + ## No change in value + return + + if value < DEFAULT_MIN_CURRENT or value > self._attr_native_max_value: + _LOGGER.error(f"Value {value} is out of range for {self._serial}") + return + + await self._send_new_value_set_request(int(value)) + + async def async_added_to_hass(self) -> None: + """Called when entity is added to Home Assistant.""" + self._access.register_listener(self._schedule_update) + + def _schedule_update(self): + """Schedule state update from external EVSE state change.""" + if self.hass: + self.async_schedule_update_ha_state() + + async def update_max_current(self, new_max_current: int) -> None: + """Update the max current value.""" + if new_max_current == self._attr_native_max_value: + ## No change in max current + return + + if new_max_current < self._value: + self._value = new_max_current + await self._send_new_value_set_request(new_max_current) + + self._attr_native_max_value = new_max_current + self.async_schedule_update_ha_state() + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up max current number entity from yaml (legacy setup).""" + + serial = ( + discovery_info.get("serial_number") + if discovery_info + else config.get("serial_number") + ) + max_current = ( + int(discovery_info.get("max_current", 32)) + if discovery_info + else int(config.get("max_current", 32)) + ) + + await _setup_current_number(hass, async_add_entities, serial, max_current) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up max current number entity from a config entry.""" + + serial = ( + discovery_info.get("serial_number") + if discovery_info + else entry.data.get("serial_number") + ) + max_current = ( + int(discovery_info.get("max_current", 32)) + if discovery_info + else int(entry.data.get("max_current", 32)) + ) + + if not serial: + _LOGGER.error("Missing serial_number in configuration or discovery_info") + return + + await _setup_current_number(hass, async_add_entities, serial, max_current) + + +async def _setup_current_number( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + serial_number: str, + max_current: int, +) -> None: + """Set up the Greencell EVSE max current number entity.""" + + mqtt_ha_access_topic = f"/greencell/evse/{serial_number}/device_state" + mqtt_ha_current_topic = f"/greencell/evse/{serial_number}/current" + mqtt_evse_state_topic = f"/greencell/evse/{serial_number}/status" + + access = GreencellAccess(AccessLevel.EXECUTE) + entity = EVSEMaxCurrent(hass, serial_number, max_current, access) + + @callback + async def device_state_msg_received(msg) -> None: + """Handle the device state message.""" + try: + data = json.loads(msg.payload) + if "level" in data: + access.update(data["level"]) + if "hems_current" in data: + hems_current = int(data["hems_current"]) + if entity.enabled: + if hems_current == 0: + await entity._send_new_value_set_request(entity.max_current) + else: + await entity._update_current_value(hems_current) + + except json.JSONDecodeError as e: + _LOGGER.error(f"Failed to decode HA access message: {e}") + except Exception as e: + _LOGGER.error(f"Unexpected error: {e}") + + @callback + async def current_msg_received(msg) -> None: + """Handle the current message. Catch maximal current set by electrician for the EVSE.""" + try: + data = json.loads(msg.payload) + if "i_max" in data: + max_current = int(data["i_max"]) + await entity.update_max_current(max_current) + except json.JSONDecodeError as e: + _LOGGER.error(f"Failed to decode current message: {e}") + except Exception as e: + _LOGGER.error(f"Unexpected error: {e}") + + @callback + def state_msg_received(msg) -> None: + """Handle the state message. If the device is offline, disable the entity.""" + try: + data = json.loads(msg.payload) + if "state" in data: + state = data["state"] + if "OFFLINE" in state: + access.update("OFFLINE") + except json.JSONDecodeError as e: + _LOGGER.error(f"Error decoding JSON message: {e}") + except Exception as e: + _LOGGER.error(f"Unexpected error: {e}") + + await async_subscribe(hass, mqtt_ha_access_topic, device_state_msg_received) + await async_subscribe(hass, mqtt_ha_current_topic, current_msg_received) + await async_subscribe(hass, mqtt_evse_state_topic, state_msg_received) + + async_add_entities([entity]) diff --git a/homeassistant/components/greencell/sensor.py b/homeassistant/components/greencell/sensor.py new file mode 100644 index 00000000000000..2c2be3f179cb96 --- /dev/null +++ b/homeassistant/components/greencell/sensor.py @@ -0,0 +1,533 @@ +import json +import logging +from abc import ABC, abstractmethod + +from homeassistant.components.mqtt import async_subscribe +from homeassistant.components.sensor import SensorEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.config_entries import ConfigEntry + +from .const import ( + MANUFACTURER, + GREENCELL_HABU_DEN, + GREENCELL_OTHER_DEVICE, + GREENCELL_HABU_DEN_SERIAL_PREFIX, +) + +from .const import GreencellHaAccessLevelEnum as AccessLevel +from .helper import GreencellAccess + +_LOGGER = logging.getLogger(__name__) + + +class Habu3PhaseSensorData: + """Class storing sensor data (e.g. current or voltage) for 3 phases.""" + + def __init__(self) -> None: + self._data = {"l1": None, "l2": None, "l3": None} + + @property + def data(self) -> dict: + """Return the internal data dictionary.""" + return self._data + + def update_data(self, new_data: dict) -> None: + """Update sensor data if the dictionary contains keys corresponding to the phases.""" + for phase in ["l1", "l2", "l3"]: + if phase in new_data: + self._data[phase] = new_data[phase] + + +class HabuSingleSensorData: + """Class storing single-value data like power, etc.""" + + def __init__(self) -> None: + self._data = None + + @property + def data(self) -> float: + """Return the internal data.""" + return self._data + + def update_data(self, new_data) -> None: + """Update sensor data""" + self._data = new_data + + +class HabuSensor(SensorEntity, ABC): + """Abstract base class for Habu sensors integration.""" + + def __init__( + self, + sensor_name: str, + unit: str, + sensor_type: str, + serial_number: str, + access: GreencellAccess, + ) -> None: + """ + :param sensor_name: Name of the sensor displayed in Home Assistant + :param unit: Unit of measurement (e.g. "A" or "V") + :param sensor_type: Sensor type (e.g. "current", "voltage" or another for single sensors) + :param serial_number: Serial number of the device + """ + self._attr_name = sensor_name + self._unit = unit + self._sensor_type = sensor_type + self._serial_number = serial_number + self._access = access + + def _device_is_habu_den(self) -> bool: + """Check if the device is a Habu Den based on its serial number.""" + return self._serial_number.startswith(GREENCELL_HABU_DEN_SERIAL_PREFIX) + + def _device_name(self) -> str: + """Return the device name based on its type.""" + if self._device_is_habu_den(): + return GREENCELL_HABU_DEN + else: + return GREENCELL_OTHER_DEVICE + + @property + def unique_id(self) -> str: + """Return a unique ID for the sensor based on type and serial number.""" + return f"{self._device_name()}_{self._serial_number}_{self._sensor_type}_sensor" + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return self._unit + + @abstractmethod + def convert_value(self, raw_value) -> str: + """Convert the raw value to a format suitable for display. + Must be implemented in derived classes. + """ + pass + + def update(self) -> None: + """Update method - updates are handled externally (e.g. via MQTT callbacks).""" + pass + + @property + def device_info(self) -> dict: + """Return device information.""" + if self._device_is_habu_den(): + device_name = GREENCELL_HABU_DEN + else: + device_name = GREENCELL_OTHER_DEVICE + return { + "identifiers": {(self._serial_number,)}, + "name": f"{device_name} {self._serial_number}", + "manufacturer": MANUFACTURER, + "model": device_name, + } + + @property + @abstractmethod + def icon(self) -> str: + """Return the icon for the sensor. + Must be implemented in derived classes. + """ + pass + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return not self._access.is_disabled() + + async def async_added_to_hass(self) -> None: + """Register the entity with Home Assistant.""" + self._access.register_listener(self._schedule_update) + + def _schedule_update(self) -> None: + """Schedule an update for the entity.""" + if self.hass: + self.async_schedule_update_ha_state() + + +class Habu3PhaseSensor(HabuSensor, ABC): + """Abstract class for 3-phase sensors (e.g. current, voltage).""" + + def __init__( + self, + sensor_data: Habu3PhaseSensorData, + phase: str, + sensor_name: str, + unit: str, + sensor_type: str, + serial_number: str, + access: GreencellAccess, + ) -> None: + """ + :param sensor_data: Object storing 3-phase data + :param phase: Phase identifier ('l1', 'l2', 'l3') + :param sensor_name: Name of the sensor displayed in Home Assistant + :param unit: Unit of measurement + :param sensor_type: Sensor type (e.g. "current" or "voltage") + :param serial_number: Device serial number + """ + super().__init__(sensor_name, unit, sensor_type, serial_number, access) + self._sensor_data = sensor_data.data + self._phase = phase + + @property + def state(self) -> str: + """Return the state of the sensor.""" + raw_value = self._sensor_data.get(self._phase) + if raw_value is None: + return None + return self.convert_value(raw_value) + + @property + def unique_id(self) -> str: + """Return a unique ID for the sensor based on type, phase, and serial number.""" + return f"{self._sensor_type}_sensor_{self._phase}_{self._serial_number}" + + +class HabuCurrentSensor(Habu3PhaseSensor): + def __init__( + self, + sensor_data: Habu3PhaseSensorData, + phase: str, + sensor_name: str, + serial_number: str, + access: GreencellAccess, + unit: str = "A", + ) -> None: + super().__init__( + sensor_data, + phase, + sensor_name, + unit, + sensor_type="current", + serial_number=serial_number, + access=access, + ) + + def convert_value(self, value) -> str: + """Convert the raw current value in mA to a string in A with 3 decimal places.""" + try: + return str(round(float(value) / 1000, 3)) + except Exception as ex: + _LOGGER.error(f"Cannot convert current: {ex}") + return str(value) + + @property + def icon(self) -> str: + """Return the icon for the current sensor.""" + return "mdi:flash-auto" + + +class HabuVoltageSensor(Habu3PhaseSensor): + def __init__( + self, + sensor_data: Habu3PhaseSensorData, + phase: str, + sensor_name: str, + serial_number: str, + access: GreencellAccess, + unit: str = "V", + ) -> None: + super().__init__( + sensor_data, + phase, + sensor_name, + unit, + sensor_type="voltage", + serial_number=serial_number, + access=access, + ) + + def convert_value(self, value) -> str: + """Convert the raw voltage value to a string in V with 2 decimal places.""" + try: + return str(round(float(value), 2)) + except Exception as ex: + _LOGGER.error(f"Cannot convert voltage: {ex}") + return str(value) + + @property + def icon(self) -> str: + """Return the icon for the voltage sensor.""" + return "mdi:meter-electric" + + +class HabuSingleSensor(HabuSensor): + """Example class for sensors that return a single value.""" + + def __init__( + self, + raw_value, + sensor_name: str, + serial_number: str, + unit: str, + sensor_type: str, + access: GreencellAccess, + ) -> None: + super().__init__(sensor_name, unit, sensor_type, serial_number, access) + self._value = raw_value + + @property + def state(self) -> str: + """Return the state of the sensor.""" + if self._value is None: + return None + return self.convert_value(self._value) + + def update_value(self, new_value) -> None: + """Update the stored value.""" + self._value = new_value + + @abstractmethod + def convert_value(self, raw_value) -> str: + """Concrete class should convert the raw value.""" + pass + + @property + def unique_id(self) -> str: + """Return a unique ID for the sensor based on type and serial number.""" + return f"{self._sensor_type}_sensor_{self._serial_number}" + + +class HabuPowerSensor(HabuSingleSensor): + def __init__( + self, + raw_value, + sensor_name: str, + serial_number: str, + access: GreencellAccess, + unit: str = "W", + ) -> None: + super().__init__( + raw_value, + sensor_name, + serial_number, + unit, + sensor_type="power", + access=access, + ) + + def convert_value(self, raw_value) -> str: + """Convert the raw power value to a string in W with 1 decimal place.""" + if raw_value.data is None: + return "0.0" + try: + return str(round(float(raw_value.data), 1)) + except Exception as ex: + _LOGGER.error(f"Cannot convert power: {ex}") + return "0.0" + + @property + def icon(self) -> str: + """Return the icon for the power sensor.""" + return "mdi:battery-charging-high" + + +class HabuStatusSensor(HabuSingleSensor): + def __init__( + self, + raw_value, + sensor_name: str, + serial_number: str, + access: GreencellAccess, + unit: str = "", + ) -> None: + super().__init__( + raw_value, + sensor_name, + serial_number, + unit, + sensor_type="status", + access=access, + ) + + def convert_value(self, raw_value) -> str: + """Convert the raw status value to a string.""" + try: + return str(raw_value.data) + except Exception as ex: + _LOGGER.error(f"Cannot convert status: {ex}") + return "UNKNOWN" + + @property + def icon(self) -> str: + """Return the icon for the status sensor.""" + return "mdi:ev-plug-type2" + + +# --- async_setup_platform function --- +async def setup_sensors( + hass: HomeAssistant, serial_number: str, async_add_entities: AddEntitiesCallback +): + """Set up the Greencell EVSE sensors.""" + 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" + + access = GreencellAccess(AccessLevel.EXECUTE) + current_data_obj = Habu3PhaseSensorData() + voltage_data_obj = Habu3PhaseSensorData() + power_data_obj = HabuSingleSensorData() + state_data_obj = HabuSingleSensorData() + + current_sensors = [ + HabuCurrentSensor( + current_data_obj, + phase="l1", + sensor_name="Current phase L1", + serial_number=serial_number, + access=access, + ), + HabuCurrentSensor( + current_data_obj, + phase="l2", + sensor_name="Current phase L2", + serial_number=serial_number, + access=access, + ), + HabuCurrentSensor( + current_data_obj, + phase="l3", + sensor_name="Current phase L3", + serial_number=serial_number, + access=access, + ), + ] + + voltage_sensors = [ + HabuVoltageSensor( + voltage_data_obj, + phase="l1", + sensor_name="Voltage phase L1", + serial_number=serial_number, + access=access, + ), + HabuVoltageSensor( + voltage_data_obj, + phase="l2", + sensor_name="Voltage phase L2", + serial_number=serial_number, + access=access, + ), + HabuVoltageSensor( + voltage_data_obj, + phase="l3", + sensor_name="Voltage phase L3", + serial_number=serial_number, + access=access, + ), + ] + + power_sensor = HabuPowerSensor( + power_data_obj, + sensor_name="Charging Power", + serial_number=serial_number, + access=access, + ) + state_sensor = HabuStatusSensor( + state_data_obj, + sensor_name="EVSE state", + serial_number=serial_number, + access=access, + ) + + @callback + def current_message_received(msg) -> None: + """Handle the current message.""" + try: + data = json.loads(msg.payload) + current_data_obj.update_data(data) + for sensor in current_sensors: + sensor.async_schedule_update_ha_state(True) + except Exception as ex: + _LOGGER.error(f"Error processing current data: {ex}") + + @callback + def voltage_message_received(msg) -> None: + """Handle the voltage message.""" + try: + data = json.loads(msg.payload) + voltage_data_obj.update_data(data) + for sensor in voltage_sensors: + sensor.async_schedule_update_ha_state(True) + except Exception as ex: + _LOGGER.error(f"Error processing voltage data: {ex}") + + @callback + def power_message_received(msg) -> None: + """Handle the power message.""" + try: + data = json.loads(msg.payload) + power_data_obj.update_data(data.get("momentary")) + power_sensor.async_schedule_update_ha_state(True) + except Exception as ex: + _LOGGER.error(f"Error processing power data: {ex}") + + @callback + def status_message_received(msg) -> None: + """Handle the status message. If the device is offline, disable the entity.""" + try: + data = json.loads(msg.payload) + state = data.get("state") + if "OFFLINE" in state: + access.update("OFFLINE") + state_data_obj.update_data(data.get("state")) + state_sensor.async_schedule_update_ha_state(True) + except Exception as ex: + _LOGGER.error(f"Error processing status data: {ex}") + + @callback + def device_state_message_received(msg) -> None: + """Handle the device state message. If device was offline, enable the entity.""" + try: + data = json.loads(msg.payload) + if "level" in data: + access.update(data["level"]) + except json.JSONDecodeError as e: + _LOGGER.error("Error processing device state data: {ex}") + + await async_subscribe(hass, mqtt_topic_current, current_message_received) + await async_subscribe(hass, mqtt_topic_voltage, voltage_message_received) + await async_subscribe(hass, mqtt_topic_power, power_message_received) + await async_subscribe(hass, mqtt_topic_status, status_message_received) + await async_subscribe(hass, mqtt_topic_device_state, device_state_message_received) + + async_add_entities( + current_sensors + voltage_sensors + [power_sensor, state_sensor], + update_before_add=True, + ) + + +# --- YAML Setup --- +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Greencell EVSE sensors from YAML configuration.""" + serial_number = config.get("serial_number") if config else None + if not serial_number: + _LOGGER.error("Serial number not provided in YAML config.") + return + await setup_sensors(hass, serial_number, async_add_entities) + + +# --- Config Flow Setup --- +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Greencell EVSE sensors from a config entry.""" + serial_number = entry.data.get("serial_number") if entry and entry.data else None + if not serial_number: + _LOGGER.error("Serial number not provided in ConfigEntry.") + return + await setup_sensors(hass, serial_number, async_add_entities) diff --git a/homeassistant/components/greencell/strings.json b/homeassistant/components/greencell/strings.json new file mode 100644 index 00000000000000..96d274a8b7ea63 --- /dev/null +++ b/homeassistant/components/greencell/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "title": "EVSE Device Setup" + } + }, + "abort": { + "discovery_timeout": "No device responded to the discovery request.", + "invalid_discovery_data": "The received discovery data is invalid." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1211ac20d0ab7..15eb8489b35d52 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -247,6 +247,7 @@ "gpsd", "gpslogger", "gree", + "greencell", "growatt_server", "guardian", "habitica", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7f335f4091d893..ac0c87683915fd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2505,6 +2505,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "greencell": { + "name": "Greencell", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "greeneye_monitor": { "name": "GreenEye Monitor (GEM)", "integration_type": "hub",