diff --git a/README.md b/README.md index 09ab5dc..834b08d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ + # Alfen Wallbox - HomeAssistant Integration This is a custom component to allow control of Alfen Wallboxes in [HomeAssistant](https://home-assistant.io). The component is a fork of the [Garo Wallbox custom integration](https://github.com/sockless-coding/garo_wallbox). -![image](https://github.com/leeyuentuen/alfen_wallbox/assets/1487966/a25af9bc-a6b3-496d-9c04-6812825cb375) +![Screenshot 2023-07-02 at 18 09 47](https://github.com/leeyuentuen/alfen_wallbox/assets/1487966/322e9e05-117f-4adc-b159-7177533fde01) + +![Screenshot 2023-07-02 at 18 09 58](https://github.com/leeyuentuen/alfen_wallbox/assets/1487966/310f0537-9bc4-49a0-9552-0c8414b97425) -Add Solar charging data: +![Screenshot 2023-07-02 at 18 10 13](https://github.com/leeyuentuen/alfen_wallbox/assets/1487966/f5e2670d-4bd8-40d2-bbbe-f0628cff6273) -image Example of running in Services: Note; the name of the configured charging point is "wallbox" in these examples. diff --git a/custom_components/alfen_wallbox/__init__.py b/custom_components/alfen_wallbox/__init__.py index f9dff5d..29efd75 100644 --- a/custom_components/alfen_wallbox/__init__.py +++ b/custom_components/alfen_wallbox/__init__.py @@ -3,27 +3,20 @@ import asyncio from datetime import timedelta import logging -from typing import Any, Dict from aiohttp import ClientConnectionError from async_timeout import timeout -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_NAME, CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.helpers.typing import HomeAssistantType from .alfen import AlfenDevice @@ -32,48 +25,48 @@ TIMEOUT, ) -PLATFORMS = [SENSOR_DOMAIN] +PLATFORMS = [ + Platform.SENSOR, + Platform.SELECT, + Platform.BINARY_SENSOR, + Platform.SWITCH, + Platform.NUMBER, +] SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: Dict) -> bool: +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Alfen Wallbox component.""" hass.data.setdefault(DOMAIN, {}) return True -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): conf = entry.data device = await alfen_setup( hass, conf[CONF_HOST], conf[CONF_NAME], conf[CONF_USERNAME], conf[CONF_PASSWORD] ) if not device: return False - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: device}) - for component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - - """device_registry = await dr.async_get_registry(hass) - device_registry.async_get_or_create(**device.device_info)""" + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = device + + # hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: device}) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Unload a config entry.""" _LOGGER.debug("async_unload_entry: %s", config_entry) - unload_ok = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(config_entry, component) - for component in PLATFORMS - ] - ) - ) + unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) @@ -81,7 +74,7 @@ async def async_unload_entry(hass, config_entry): return unload_ok -async def alfen_setup(hass, host, name, username, password): +async def alfen_setup(hass: HomeAssistant, host: str, name: str, username: str, password: str): """Create a Alfen instance only once.""" session = hass.helpers.aiohttp_client.async_get_clientsession() diff --git a/custom_components/alfen_wallbox/alfen.py b/custom_components/alfen_wallbox/alfen.py index 5d0acb2..aa532a8 100644 --- a/custom_components/alfen_wallbox/alfen.py +++ b/custom_components/alfen_wallbox/alfen.py @@ -1,4 +1,5 @@ import logging +from aiohttp import ClientSession import requests import time @@ -19,15 +20,23 @@ class AlfenDevice: - def __init__(self, host, name, session, username, password): + def __init__(self, + host: str, + name: str, + session: ClientSession, + username: str, + password: str) -> None: self.host = host self.name = name self._status = None self._session = session self.username = username + self.info = None + self.id = None if self.username is None: self.username = "admin" self.password = password + self.properties = [] # Default ciphers needed as of python 3.10 context = ssl.create_default_context() context.set_ciphers("DEFAULT") @@ -37,7 +46,7 @@ def __init__(self, host, name, session, username, password): async def init(self): await self.async_get_info() - self.id = "alfen_{}".format(self.info.identity) + self.id = "alfen_{}".format(self.name) if self.name is None: self.name = f"{self.info.identity} ({self.host})" await self.async_update() @@ -50,7 +59,7 @@ def status(self): def device_info(self): """Return a device description for device registry.""" return { - "identifiers": {(DOMAIN, self.id)}, + "identifiers": {(DOMAIN, self.name)}, "manufacturer": "Alfen", "model": self.info.model, "name": self.name, @@ -64,8 +73,8 @@ def _request(self, parameter_list): async def async_update(self): await self._do_update() - async def _do_update(self): - await self._session.request( + async def login(self): + response = await self._session.request( ssl=self.ssl, method="POST", headers=HEADER_JSON, @@ -73,52 +82,55 @@ async def _do_update(self): json={"username": self.username, "password": self.password}, ) - # max 32 ids each time - response = await self._session.request( - ssl=self.ssl, - method="GET", - headers=HEADER_JSON, - url=self.__get_url( - "prop?ids=2060_0,2056_0,2221_3,2221_4,2221_5,2221_A,2221_B,2221_C,2221_16,2201_0,2501_2,2221_22,2129_0,2126_0,2068_0,2069_0,2062_0,2064_0,212B_0,212D_0,2185_0,2053_0,2067_0,212F_1,212F_2,212F_3,2100_0,2101_0,2102_0,2104_0,2105_0" - ), - ) - _LOGGER.debug(f"Status Response {response}") - - response_json = await response.json(content_type=None) - _LOGGER.debug(response_json) + _LOGGER.debug(f"Login response {response}") - response2 = await self._session.request( + async def logout(self): + response = await self._session.request( ssl=self.ssl, - method="GET", + method="POST", headers=HEADER_JSON, - url=self.__get_url( - "prop?ids=2057_0,2112_0,2071_1,2071_2,2072_1,2073_1,2074_1,2075_1,2076_0,2078_1,2078_2,2079_1,207A_1,207B_1,207C_1,207D_1,207E_1,207F_1,2080_1,2081_0,2082_0,2110_0,3280_1,3280_2,3280_3,3280_4" - ), + url=self.__get_url("logout"), ) - _LOGGER.debug(f"Status Response {response2}") - - response_json2 = await response2.json(content_type=None) - _LOGGER.debug(response_json2) + _LOGGER.debug(f"Logout response {response}") - await self._session.request( + async def update_value(self, api_param, value): + response = await self._session.request( ssl=self.ssl, method="POST", - headers=HEADER_JSON, - url=self.__get_url("logout"), + headers=POST_HEADER_JSON, + url=self.__get_url("prop"), + json={api_param: {"id": api_param, "value": value}}, ) + _LOGGER.info(f"Set {api_param} value {value} response {response}") - response_json_combined = response_json - if response_json2 is not None: - response_json_combined["properties"] = ( - response_json["properties"] + response_json2["properties"] - ) - response_json_combined["total"] = ( - response_json["total"] + response_json2["total"] - ) - - _LOGGER.debug(response_json_combined) - - self._status = AlfenStatus(response_json_combined, self._status) + async def _do_update(self): + await self.login() + + properties = [] + for i in ("generic", "generic2", "meter1", "states", "temp"): + nextRequest = True + offset = 0 + while (nextRequest): + response = await self._session.request( + ssl=self.ssl, + method="GET", + headers=HEADER_JSON, + url=self.__get_url( + "prop?cat={}&offset={}".format(i, offset) + ), + ) + _LOGGER.debug(f"Status Response {response}") + + response_json = await response.json(content_type=None) + if response_json is not None: + properties += response_json["properties"] + nextRequest = response_json["total"] > ( + offset + len(response_json["properties"])) + offset += len(response_json["properties"]) + + await self.logout() + + self.properties = properties async def async_get_info(self): response = await self._session.request( @@ -343,143 +355,8 @@ def __get_url(self, action): return "https://{}/api/{}".format(self.host, action) -class AlfenStatus: - def __init__(self, response, prev_status): - for prop in response["properties"]: - _LOGGER.debug("Prop") - _LOGGER.debug(prop) - - if prop["id"] == "2060_0": - self.uptime = max(0, prop["value"] / 1000 * 60) - elif prop["id"] == "2056_0": - self.bootups = prop["value"] - elif prop["id"] == "2221_3": - self.voltage_l1 = round(prop["value"], 2) - elif prop["id"] == "2221_4": - self.voltage_l2 = round(prop["value"], 2) - elif prop["id"] == "2221_5": - self.voltage_l3 = round(prop["value"], 2) - elif prop["id"] == "2221_A": - self.current_l1 = round(prop["value"], 2) - elif prop["id"] == "2221_B": - self.current_l2 = round(prop["value"], 2) - elif prop["id"] == "2221_C": - self.current_l3 = round(prop["value"], 2) - elif prop["id"] == "2221_16": - self.active_power_total = round(prop["value"], 2) - elif prop["id"] == "2201_0": - self.temperature = round(prop["value"], 2) - elif prop["id"] == "2501_2": - self.status = prop["value"] - elif prop["id"] == "2221_22": - self.meter_reading = round((prop["value"] / 1000), 2) - elif prop["id"] == "2129_0": - self.current_limit = prop["value"] - elif prop["id"] == "2126_0": - self.auth_mode = self.auth_mode_as_str(prop["value"]) - elif prop["id"] == "2068_0": - self.alb_safe_current = prop["value"] - elif prop["id"] == "2069_0": - self.alb_phase_connection = prop["value"] - elif prop["id"] == "2062_0": - self.max_station_current = prop["value"] - elif prop["id"] == "2064_0": - self.load_balancing_mode = prop["value"] - elif prop["id"] == "212B_0": - self.main_static_lb_max_current = round(prop["value"], 2) - elif prop["id"] == "212D_0": - self.main_active_lb_max_current = round(prop["value"], 2) - elif prop["id"] == "2185_0": - self.enable_phase_switching = self.enable_phase_switching_as_str(prop["value"]) - elif prop["id"] == "2053_0": - self.charging_box_identifier = prop["value"] - elif prop["id"] == "2057_0": - self.boot_reason = prop["value"] - elif prop["id"] == "2067_0": - self.max_smart_meter_current = prop["value"] - elif prop["id"] == "212F_1": - self.p1_measurements_1 = round(prop["value"], 2) - elif prop["id"] == "212F_2": - self.p1_measurements_2 = round(prop["value"], 2) - elif prop["id"] == "212F_3": - self.p1_measurements_3 = round(prop["value"], 2) - elif prop["id"] == "2100_0": - self.gprs_apn_name = prop["value"] - elif prop["id"] == "2101_0": - self.gprs_apn_user = prop["value"] - elif prop["id"] == "2102_0": - self.gprs_apn_password = prop["value"] - elif prop["id"] == "2104_0": - self.gprs_sim_imsi = prop["value"] - elif prop["id"] == "2105_0": - self.gprs_sim_iccid = prop["value"] - elif prop["id"] == "2112_0": - self.gprs_provider = prop["value"] - elif prop["id"] == "2104_0": - self.p1_measurements_3 = prop["value"] - elif prop["id"] == "2071_1": - self.comm_bo_url_wired_server_domain_and_port = prop["value"] - elif prop["id"] == "2071_2": - self.comm_bo_url_wired_server_path = prop["value"] - elif prop["id"] == "2072_1": - self.comm_dhcp_address_1 = prop["value"] - elif prop["id"] == "2073_1": - self.comm_netmask_address_1 = prop["value"] - elif prop["id"] == "2074_1": - self.comm_gateway_address_1 = prop["value"] - elif prop["id"] == "2075_1": - self.comm_ip_address_1 = prop["value"] - elif prop["id"] == "2076_0": - self.comm_bo_short_name = prop["value"] - elif prop["id"] == "2078_1": - self.comm_bo_url_gprs_server_domain_and_port = prop["value"] - elif prop["id"] == "2078_2": - self.comm_bo_url_gprs_server_path = prop["value"] - elif prop["id"] == "2079_1": - self.comm_gprs_dns_1 = prop["value"] - elif prop["id"] == "207A_1": - self.comm_dhcp_address_2 = prop["value"] - elif prop["id"] == "207B_1": - self.comm_netmask_address_2 = prop["value"] - elif prop["id"] == "207C_1": - self.comm_gateway_address_2 = prop["value"] - elif prop["id"] == "207D_1": - self.comm_ip_address_2 = prop["value"] - elif prop["id"] == "207E_1": - self.comm_wired_dns_1 = prop["value"] - elif prop["id"] == "207F_1": - self.comm_wired_dns_2 = prop["value"] - elif prop["id"] == "2080_1": - self.comm_gprs_dns_2 = prop["value"] - elif prop["id"] == "2081_0": - self.comm_protocol_name = prop["value"] - elif prop["id"] == "2082_0": - self.comm_protocol_version = prop["value"] - elif prop["id"] == "2110_0": - self.gprs_signal_strength = prop["value"] - elif prop["id"] == "3280_1": - self.lb_solar_charging_mode = self.solar_charging_mode(prop["value"]) - elif prop["id"] == "3280_2": - self.lb_solar_charging_green_share = prop["value"] - elif prop["id"] == "3280_3": - self.lb_solar_charging_comfort_level = prop["value"] - elif prop["id"] == "3280_4": - self.lb_solar_charging_boost = prop["value"] - - def auth_mode_as_str(self, code): - switcher = {0: "Plug and Charge", 2: "RFID"} - return switcher.get(code, "Unknown") - - def solar_charging_mode(self, code): - switcher = {0: "Disable", 1: "Comfort", 2: "Green"} - return switcher.get(code, "Unknown") - - def enable_phase_switching_as_str(self, code): - switcher = {0: "Disabled", 1: "Enabled"} - return switcher.get(code, "Unknown") - class AlfenDeviceInfo: - def __init__(self, response): + def __init__(self, response) -> None: self.identity = response["Identity"] self.firmware_version = response["FWVersion"] self.model_id = response["Model"] diff --git a/custom_components/alfen_wallbox/binary_sensor.py b/custom_components/alfen_wallbox/binary_sensor.py new file mode 100644 index 0000000..6eb0d24 --- /dev/null +++ b/custom_components/alfen_wallbox/binary_sensor.py @@ -0,0 +1,82 @@ +import logging + +from dataclasses import dataclass +from typing import Final +from .alfen import AlfenDevice +from .entity import AlfenEntity + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN as ALFEN_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AlfenBinaryDescriptionMixin: + """Define an entity description mixin for binary sensor entities.""" + + api_param: str + + +@dataclass +class AlfenBinaryDescription(BinarySensorEntityDescription, AlfenBinaryDescriptionMixin): + """Class to describe an Alfen binary sensor entity.""" + + +ALFEN_BINARY_SENSOR_TYPES: Final[tuple[AlfenBinaryDescription, ...]] = ( + AlfenBinaryDescription( + key="system_date_light_savings", + name="System Daylight Savings", + device_class=None, + api_param="205B_0", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Alfen binary sensor entities from a config entry.""" + device = hass.data[ALFEN_DOMAIN][entry.entry_id] + binaries = [AlfenBinarySensor(device, description) + for description in ALFEN_BINARY_SENSOR_TYPES] + + async_add_entities(binaries) + + +class AlfenBinarySensor(AlfenEntity, BinarySensorEntity): + """Define an Alfen binary sensor.""" + + # entity_description: AlfenBinaryDescriptionMixin + + def __init__(self, + device: AlfenDevice, + description: AlfenBinaryDescription + ) -> None: + """Initialize.""" + super().__init__(device) + self._device = device + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + self.entity_description = description + + @property + def available(self) -> bool: + for prop in self._device.properties: + if prop["id"] == self.entity_description.api_param: + return True + return False + + @property + def is_on(self) -> bool: + for prop in self._device.properties: + if prop["id"] == self.entity_description.api_param: + return prop["value"] == 1 + + return False diff --git a/custom_components/alfen_wallbox/config_flow.py b/custom_components/alfen_wallbox/config_flow.py index 186a6e0..2ef6247 100644 --- a/custom_components/alfen_wallbox/config_flow.py +++ b/custom_components/alfen_wallbox/config_flow.py @@ -15,7 +15,6 @@ _LOGGER = logging.getLogger(__name__) - @config_entries.HANDLERS.register("alfen_wallbox") class FlowHandler(config_entries.ConfigFlow): """Handle a config flow.""" @@ -30,26 +29,14 @@ async def _create_entry(self, host, name, username, password): if entry.data[KEY_IP] == host: return self.async_abort(reason="already_configured") - return self.async_create_entry( - title=host, - data={ - CONF_HOST: host, - CONF_NAME: name, - CONF_USERNAME: username, - CONF_PASSWORD: password, - }, - ) + return self.async_create_entry(title=host, data={CONF_HOST: host, CONF_NAME: name, CONF_USERNAME: username, CONF_PASSWORD: password}) async def _create_device(self, host, name, username, password): """Create device.""" try: device = AlfenDevice( - host, - name, - self.hass.helpers.aiohttp_client.async_get_clientsession(), - username, - password, + host, name, self.hass.helpers.aiohttp_client.async_get_clientsession(), username, password ) with timeout(TIMEOUT): await device.init() @@ -68,31 +55,18 @@ async def async_step_user(self, user_input=None): """User initiated config flow.""" if user_input is None: return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_USERNAME, default="admin"): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_NAME): str, - } - ), + step_id="user", data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default="admin"): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_NAME): str + }) ) - return await self._create_device( - user_input[CONF_HOST], - user_input[CONF_NAME], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) + return await self._create_device(user_input[CONF_HOST], user_input[CONF_NAME], user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) async def async_step_import(self, user_input): """Import a config entry.""" host = user_input.get(CONF_HOST) if not host: return await self.async_step_user() - return await self._create_device( - host, - user_input[CONF_NAME], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) + return await self._create_device(host, user_input[CONF_NAME], user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) diff --git a/custom_components/alfen_wallbox/const.py b/custom_components/alfen_wallbox/const.py index 6bdcb90..a5e6717 100644 --- a/custom_components/alfen_wallbox/const.py +++ b/custom_components/alfen_wallbox/const.py @@ -1,3 +1,4 @@ + DOMAIN = "alfen_wallbox" KEY_MAC = "mac" diff --git a/custom_components/alfen_wallbox/entity.py b/custom_components/alfen_wallbox/entity.py new file mode 100644 index 0000000..f0c11ef --- /dev/null +++ b/custom_components/alfen_wallbox/entity.py @@ -0,0 +1,38 @@ +from datetime import timedelta +import logging + +from .alfen import AlfenDevice +from .const import DOMAIN as ALFEN_DOMAIN +from homeassistant.helpers.entity import DeviceInfo, Entity + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +class AlfenEntity(Entity): + + def __init__(self, device: AlfenDevice) -> None: + """Initialize the Alfen entity.""" + self._device = device + + self._attr_device_info = DeviceInfo( + identifiers={(ALFEN_DOMAIN, self._device.name)}, + manufacturer="Alfen", + model=self._device.info.model, + name=device.name, + sw_version=self._device.info.firmware_version, + ) + + async def async_added_to_hass(self) -> None: + """Add listener for state changes.""" + await super().async_added_to_hass() + + async def update_state(self, api_param, value): + """Get the state of the entity.""" + + await self._device.login() + + await self._device.update_value(api_param, value) + + await self._device.logout() diff --git a/custom_components/alfen_wallbox/number.py b/custom_components/alfen_wallbox/number.py new file mode 100644 index 0000000..cf0227f --- /dev/null +++ b/custom_components/alfen_wallbox/number.py @@ -0,0 +1,187 @@ +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DOMAIN as ALFEN_DOMAIN +from homeassistant.core import HomeAssistant, callback +import logging +from typing import Final, Any +from dataclasses import dataclass +from .entity import AlfenEntity +from .alfen import AlfenDevice +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfPower, +) + + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AlfenNumberDescriptionMixin: + """Define an entity description mixin for select entities.""" + assumed_state: bool + state: float + api_param: str + + +@dataclass +class AlfenNumberDescription(NumberEntityDescription, AlfenNumberDescriptionMixin): + """Class to describe an Alfen select entity.""" + + +ALFEN_NUMBER_TYPES: Final[tuple[AlfenNumberDescription, ...]] = ( + AlfenNumberDescription( + key="alb_safe_current", + name="ALB Safe Current", + state=None, + icon="mdi:current-ac", + assumed_state=False, + device_class=NumberDeviceClass.CURRENT, + native_min_value=1, + native_max_value=16, + native_step=1, + mode=NumberMode.BOX, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + api_param="2068_0", + ), + AlfenNumberDescription( + key="current_limit", + name="Current Limit", + state=None, + icon="mdi:current-ac", + assumed_state=False, + device_class=NumberDeviceClass.CURRENT, + native_min_value=1, + native_max_value=16, + native_step=1, + mode=NumberMode.BOX, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + api_param="2129_0", + ), + AlfenNumberDescription( + key="max_station_current", + name="Max. Station Current", + state=None, + icon="mdi:current-ac", + assumed_state=False, + device_class=NumberDeviceClass.CURRENT, + native_min_value=1, + native_max_value=16, + native_step=1, + mode=NumberMode.BOX, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + api_param="2062_0", + ), + AlfenNumberDescription( + key="max_smart_meter_current", + name="Max. Meter Current", + state=None, + icon="mdi:current-ac", + assumed_state=False, + device_class=NumberDeviceClass.CURRENT, + native_min_value=1, + native_max_value=16, + native_step=1, + mode=NumberMode.BOX, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + api_param="2067_0", + ), + AlfenNumberDescription( + key="lb_solar_charging_green_share", + name="Solar Green Share", + state=None, + icon="mdi:current-ac", + assumed_state=False, + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.BOX, + unit_of_measurement=PERCENTAGE, + api_param="3280_2", + ), + AlfenNumberDescription( + key="lb_solar_charging_comfort_level", + name="Solar Comfort Level", + state=None, + icon="mdi:current-ac", + assumed_state=False, + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=1400, + native_max_value=3500, + native_step=100, + mode=NumberMode.BOX, + unit_of_measurement=UnitOfPower.WATT, + api_param="3280_3", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Alfen select entities from a config entry.""" + device = hass.data[ALFEN_DOMAIN][entry.entry_id] + numbers = [AlfenNumber(device, description) + for description in ALFEN_NUMBER_TYPES] + + async_add_entities(numbers) + + +class AlfenNumber(AlfenEntity, NumberEntity): + """Define an Alfen select entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__( + self, + device: AlfenDevice, + description: AlfenNumberDescription, + ) -> None: + """Initialize the Demo Number entity.""" + super().__init__(device) + self._device = device + self._attr_name = f"{description.name}" + self._attr_unique_id = f"{description.key}" + self._attr_assumed_state = description.assumed_state + self._attr_device_class = description.device_class + self._attr_icon = description.icon + self._attr_mode = description.mode + self._attr_native_unit_of_measurement = description.unit_of_measurement + self._attr_native_value = description.state + self.entity_description = description + + if description.native_min_value is not None: + self._attr_native_min_value = description.native_min_value + if description.native_max_value is not None: + self._attr_native_max_value = description.native_max_value + if description.native_step is not None: + self._attr_native_step = description.native_step + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + self._attr_native_value = value + self.async_write_ha_state() + self._async_update_attrs() + + @property + def native_value(self) -> float | None: + """Return the entity value to represent the entity state.""" + for prop in self._device.properties: + if prop["id"] == self.entity_description.api_param: + return prop["value"] + return None + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.update_state(self.entity_description.api_param, value) + self.async_write_ha_state() + + @callback + def _async_update_attrs(self) -> None: + """Update attrs from device.""" + self._attr_native_value = self._get_current_option() diff --git a/custom_components/alfen_wallbox/select.py b/custom_components/alfen_wallbox/select.py new file mode 100644 index 0000000..0084bce --- /dev/null +++ b/custom_components/alfen_wallbox/select.py @@ -0,0 +1,207 @@ +import logging +from typing import Final, Any + +from dataclasses import dataclass +from .entity import AlfenEntity + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .alfen import AlfenDevice + +from homeassistant.components.select import ( + SelectEntity, + SelectEntityDescription, +) + +from homeassistant.core import HomeAssistant, callback +from . import DOMAIN as ALFEN_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AlfenSelectDescriptionMixin: + """Define an entity description mixin for select entities.""" + + api_param: str + options_dict: dict[str, int] + + +@dataclass +class AlfenSelectDescription(SelectEntityDescription, AlfenSelectDescriptionMixin): + """Class to describe an Alfen select entity.""" + + +CHARGING_MODE_DICT: Final[dict[str, int]] = { + "Disable": 0, "Comfort": 1, "Green": 2} + +ON_OFF_DICT: Final[dict[str, int]] = {"Off": 0, "On": 1} + +PHASE_ROTATION_DICT: Final[dict[str, str]] = { + "L1": "L1", + "L2": "L2", + "L3": "L3", + "L1,L2,L3": "L1L2L3", + "L1,L3,L2": "L1L3L2", + "L2,L1,L3": "L2L1L3", + "L2,L3,L1": "L2L3L1", + "L3,L1,L2": "L3L1L2", + "L3,L2,L1": "L3L2L1", +} + +SAFE_AMPS_DICT: Final[dict[str, int]] = { + "1 A": 1, + "2 A": 2, + "3 A": 3, + "4 A": 4, + "5 A": 5, + "6 A": 6, + "7 A": 7, + "8 A": 8, + "9 A": 9, + "10 A": 10, +} + +AUTH_MODE_DICT: Final[dict[str, int]] = { + "Plug and Charge": 0, + "RFID": 2 +} + +LOAD_BALANCE_MODE_DICT: Final[dict[str, int]] = { + "Modbus TCP/IP": 0, + "DSMR4.x / SMR5.0 (P1)": 3 +} + +LOAD_BALANCE_DATA_SOURCE_DICT: Final[dict[str, int]] = { + "Meter": 0, + "Energy Management System": 3 +} + +LOAD_BALANCE_RECEIVED_MEASUREMENTS_DICT: Final[dict[str, int]] = { + "Exclude Charging Ev": 0, + "Include Charging Ev": 1 +} + + +ALFEN_SELECT_TYPES: Final[tuple[AlfenSelectDescription, ...]] = ( + AlfenSelectDescription( + key="lb_solar_charging_mode", + name="Solar Charging Mode", + icon="mdi:solar-power", + options=list(CHARGING_MODE_DICT), + options_dict=CHARGING_MODE_DICT, + api_param="3280_1", + ), + AlfenSelectDescription( + key="lb_solar_charging_boost", + name="Solar Charging Boost", + icon="mdi:ev-station", + options=list(ON_OFF_DICT), + options_dict=ON_OFF_DICT, + api_param="3280_4", + ), + AlfenSelectDescription( + key="alb_phase_connection", + name="Active Load Balancing Phase Connection", + icon=None, + options=list(PHASE_ROTATION_DICT), + options_dict=PHASE_ROTATION_DICT, + api_param="2069_0", + ), + # AlfenSelectDescription( + # key="alb_safe_current", + # name="Active Load Balancing Safe Current", + # icon="mdi:current-ac", + # options=list(SAFE_AMPS_DICT), + # options_dict=SAFE_AMPS_DICT, + # api_param="2068_0", + # ), + + AlfenSelectDescription( + key="auth_mode", + name="Authorization Mode", + icon="mdi:key", + options=list(AUTH_MODE_DICT), + options_dict=AUTH_MODE_DICT, + api_param="2126_0", + ), + + AlfenSelectDescription( + key="load_balancing_mode", + name="Load Balancing Mode", + icon="mdi:scale-balance", + options=list(LOAD_BALANCE_MODE_DICT), + options_dict=LOAD_BALANCE_MODE_DICT, + api_param="2064_0", + ), + AlfenSelectDescription( + key="lb_active_balancing_received_measurements", + name="Load Balancing Received Measurements", + icon="mdi:scale-balance", + options=list(LOAD_BALANCE_RECEIVED_MEASUREMENTS_DICT), + options_dict=LOAD_BALANCE_RECEIVED_MEASUREMENTS_DICT, + api_param="206F_0", + ), + +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Alfen Select from a config_entry""" + + device = hass.data[ALFEN_DOMAIN][entry.entry_id] + selects = [AlfenSelect(device, description) + for description in ALFEN_SELECT_TYPES] + + async_add_entities(selects) + + +class AlfenSelect(AlfenEntity, SelectEntity): + """Define Alfen select.""" + + values_dict: dict[int, str] + + def __init__( + self, device: AlfenDevice, description: AlfenSelectDescription + ) -> None: + """Initialize.""" + super().__init__(device) + self._device = device + self._attr_name = f"{device.name} {description.name}" + + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + self._attr_options = description.options + self.entity_description = description + self.values_dict = {v: k for k, v in description.options_dict.items()} + self._async_update_attrs() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + value = {v: k for k, v in self.values_dict.items()}[option] + await self.update_state(self.entity_description.api_param, value) + self.async_write_ha_state() + + @property + def current_option(self) -> str | None: + """Return the current option.""" + value = self._get_current_option() + return self.values_dict.get(value) + + def _get_current_option(self) -> str | None: + """Return the current option.""" + for prop in self._device.properties: + if prop["id"] == self.entity_description.api_param: + return prop["value"] + return None + + async def async_update(self): + """Update the entity.""" + await self._device.async_update() + + @callback + def _async_update_attrs(self) -> None: + """Update select attributes.""" + self._attr_current_option = self._get_current_option() diff --git a/custom_components/alfen_wallbox/sensor.py b/custom_components/alfen_wallbox/sensor.py index 07cee35..324bd5c 100644 --- a/custom_components/alfen_wallbox/sensor.py +++ b/custom_components/alfen_wallbox/sensor.py @@ -1,21 +1,31 @@ import logging +from typing import Final +from dataclasses import dataclass import voluptuous as vol -from homeassistant.const import CONF_ICON, CONF_NAME, TEMP_CELSIUS -from homeassistant.helpers.entity import Entity +from .entity import AlfenEntity +from homeassistant import const +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfTemperature +import datetime + +from homeassistant.core import HomeAssistant, callback from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, - PLATFORM_SCHEMA, - STATE_CLASS_TOTAL_INCREASING, - STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, + SensorStateClass, + SensorDeviceClass ) -from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from homeassistant.helpers import config_validation as cv, entity_platform from . import DOMAIN as ALFEN_DOMAIN + from .alfen import AlfenDevice from .const import ( SERVICE_REBOOT_WALLBOX, @@ -32,111 +42,472 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +@dataclass +class AlfenSensorDescriptionMixin: + """Define an entity description mixin for sensor entities.""" + + api_param: str + unit: str + round_digits: int | None + + +@dataclass +class AlfenSensorDescription( + SensorEntityDescription, AlfenSensorDescriptionMixin +): + """Class to describe an Alfen sensor entity.""" + + +STATUS_DICT: Final[dict[int, str]] = { + 0: "Unknown", + 1: "Off", + 2: "Booting", + 3: "Booting Check Mains", + 4: "Available", + 5: "Prep. Authorising", + 6: "Prep. Authorised", + 7: "Prep. Cable connected", + 8: "Prep EV Connected", + 9: "Charging Preparing", + 10: "Vehicle connected", + 11: "Charging Active Normal", + 12: "Charging Active Simplified", + 13: "Charging Suyspended Over Current", + 14: "Charging Suspended HF Switching", + 15: "Charging Suspended EV Disconnected", + 16: "Finish Wait Vehicle", + 17: "Finished Wait Disconnect", + 18: "Error Protective Earth", + 19: "Error Powerline Fault", + 20: "Error Contactor Fault", + 21: "Error Charging", + 22: "Error Power Failure", + 23: "Error Temperature", + 24: "Error Illegal CP Value", + 25: "Error Illegal PP Value", + 26: "Error Too Many Restarts", + 27: "Error", + 28: "Error Message", + 29: "Error Message Not Authorised", + 30: "Error Message Cable Not Supported", + 31: "Error Message S2 Not Opened", + 32: "Error Message Time Out", + 33: "Reserved", + 34: "Inoperative", + 35: "Load Balancing Limited", + 36: "Load Balancing Forced Off", + 38: "Not Charging", + 39: "Solar Charging Wait", + 41: "Solar Charging", + 42: "Charge Point Ready, Waiting For Power", + 43: "Partial Solar Charging", +} + +ALFEN_SENSOR_TYPES: Final[tuple[AlfenSensorDescription, ...]] = ( + AlfenSensorDescription( + key="status", + name="Status Code", + icon="mdi:ev-station", + api_param="2501_2", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="uptime", + name="Uptime", + icon="mdi:timer-outline", + api_param="2060_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="last_modify_datetime", + name="Last Modify Config datetime", + icon="mdi:timer-outline", + api_param="2187_0", + unit=None, + state_class=SensorDeviceClass.DATE, + round_digits=None, + ), + # too much logging data + # AlfenSensorDescription( + # key="system_date_time", + # name="System Datetime", + # icon="mdi:timer-outline", + # api_param="2059_0", + # unit=None, + # round_digits=None, + # ), + AlfenSensorDescription( + key="bootups", + name="Bootups", + icon="mdi:reload", + api_param="2056_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="voltage_l1", + name="Voltage L1", + icon="mdi:flash", + api_param="2221_3", + unit=UnitOfElectricPotential.VOLT, + round_digits=2, + ), + AlfenSensorDescription( + key="voltage_l2", + name="Voltage L2", + icon="mdi:flash", + api_param="2221_4", + unit=UnitOfElectricPotential.VOLT, + round_digits=2, + ), + AlfenSensorDescription( + key="voltage_l3", + name="Voltage L3", + icon="mdi:flash", + api_param="2221_5", + unit=UnitOfElectricPotential.VOLT, + round_digits=2, + ), + AlfenSensorDescription( + key="current_l1", + name="Current L1", + icon="mdi:current-ac", + api_param="2221_A", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="current_l2", + name="Current L2", + icon="mdi:current-ac", + api_param="2221_B", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="current_l3", + name="Current L3", + icon="mdi:current-ac", + api_param="2221_C", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="active_power_total", + name="Active Power Total", + icon="mdi:circle-slice-3", + api_param="2221_16", + unit=UnitOfPower.WATT, + round_digits=2, + ), + AlfenSensorDescription( + key="meter_reading", + name="Meter Reading", + icon="mdi:counter", + api_param="2221_22", + unit=UnitOfEnergy.KILO_WATT_HOUR, + round_digits=None, + ), + AlfenSensorDescription( + key="temperature", + name="Temperature", + icon="mdi:thermometer", + api_param="2201_0", + unit=UnitOfTemperature.CELSIUS, + round_digits=2, + ), + + AlfenSensorDescription( + key="main_static_lb_max_current", + name="Main Static Load Balancing Max Current", + icon="mdi:current-ac", + api_param="212B_0", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="main_active_lb_max_current", + name="Main Active Load Balancing Max Current", + icon="mdi:current-ac", + api_param="212D_0", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="charging_box_identifier", + name="Charging Box Identifier", + icon="mdi:ev-station", + api_param="2053_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="boot_reason", + name="System Boot Reason", + icon="mdi:reload", + api_param="2057_0", + unit=None, + round_digits=None, + ), + + AlfenSensorDescription( + key="p1_measurements_1", + name="P1 Meter Phase 1 Current", + icon="mdi:current-ac", + api_param="212F_1", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="p1_measurements_2", + name="P1 Meter Phase 2 Current", + icon="mdi:current-ac", + api_param="212F_2", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="p1_measurements_3", + name="P1 Meter Phase 3 Current", + icon="mdi:current-ac", + api_param="212F_3", + unit=UnitOfElectricCurrent.AMPERE, + round_digits=2, + ), + AlfenSensorDescription( + key="gprs_apn_name", + name="GPRS APN Name", + icon="mdi:antenna", + api_param="2100_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="gprs_apn_user", + name="GPRS APN User", + icon="mdi:antenna", + api_param="2101_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="gprs_apn_password", + name="GPRS APN Password", + icon="mdi:antenna", + api_param="2102_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="gprs_sim_imsi", + name="GPRS SIM IMSI", + icon="mdi:antenna", + api_param="2104_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="gprs_sim_iccid", + name="GPRS SIM Serial", + icon="mdi:antenna", + api_param="2105_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="gprs_provider", + name="GPRS Provider", + icon="mdi:antenna", + api_param="2112_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_bo_url_wired_server_domain_and_port", + name="Wired Url Server Domain And Port", + icon="mdi:cable-data", + api_param="2071_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_bo_url_wired_server_path", + name="Wired Url Wired Server Path", + icon="mdi:cable-data", + api_param="2071_2", + unit=None, + round_digits=None, + ), + # AlfenSensorDescription( + # key="comm_dhcp_address_1", + # name="GPRS DHCP Address", + # icon="mdi:antenna", + # api_param="2072_1", + # unit=None, + # round_digits=None, + # ), + AlfenSensorDescription( + key="comm_netmask_address_1", + name="GPRS Netmask", + icon="mdi:antenna", + api_param="2073_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_gateway_address_1", + name="GPRS Gateway Address", + icon="mdi:antenna", + api_param="2074_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_ip_address_1", + name="GPRS IP Address", + icon="mdi:antenna", + api_param="2075_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_bo_short_name", + name="Backoffice Short Name", + icon="mdi:antenna", + api_param="2076_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_bo_url_gprs_server_domain_and_port", + name="GPRS Url Server Domain And Port", + icon="mdi:antenna", + api_param="2078_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_bo_url_gprs_server_path", + name="GPRS Url Server Path", + icon="mdi:antenna", + api_param="2078_2", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_gprs_dns_1", + name="GPRS DNS 1", + icon="mdi:antenna", + api_param="2079_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_gprs_dns_2", + name="GPRS DNS 2", + icon="mdi:antenna", + api_param="2080_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="gprs_signal_strength", + name="GPRS Signal", + icon="mdi:antenna", + api_param="2110_0", + unit=const.SIGNAL_STRENGTH_DECIBELS, + round_digits=None, + ), + # AlfenSensorDescription( + # key="comm_dhcp_address_2", + # name="Wired DHCP", + # icon="mdi:cable-data", + # api_param="207A_1", + # unit=None, + # round_digits=None, + # ), + AlfenSensorDescription( + key="comm_netmask_address_2", + name="Wired Netmask", + icon="mdi:cable-data", + api_param="207B_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_gateway_address_2", + name="Wired Gateway Address", + icon="mdi:cable-data", + api_param="207C_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_ip_address_2", + name="Wired IP Address", + icon="mdi:cable-data", + api_param="207D_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_wired_dns_1", + name="Wired DNS 1", + icon="mdi:cable-data", + api_param="207E_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_wired_dns_2", + name="Wired DNS 2", + icon="mdi:cable-data", + api_param="207F_1", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_protocol_name", + name="Protocol Name", + icon="mdi:information-outline", + api_param="2081_0", + unit=None, + round_digits=None, + ), + AlfenSensorDescription( + key="comm_protocol_version", + name="Protocol Version", + icon="mdi:information-outline", + api_param="2082_0", + unit=None, + round_digits=None, + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info=None): pass -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback): """Set up using config_entry.""" - device = hass.data[ALFEN_DOMAIN].get(entry.entry_id) - async_add_entities( - [ - AlfenMainSensor(device), - AlfenSensor(device, "Status Code", "status"), - AlfenSensor(device, "Uptime", "uptime", "s"), - AlfenSensor(device, "Bootups", "bootups"), - AlfenSensor(device, "Voltage L1", "voltage_l1", "V"), - AlfenSensor(device, "Voltage L2", "voltage_l2", "V"), - AlfenSensor(device, "Voltage L3", "voltage_l3", "V"), - AlfenSensor(device, "Current L1", "current_l1", "A"), - AlfenSensor(device, "Current L2", "current_l2", "A"), - AlfenSensor(device, "Current L3", "current_l3", "A"), - AlfenSensor(device, "Active Power Total", "active_power_total", "W"), - AlfenSensor(device, "Meter Reading", "meter_reading", "kWh"), - AlfenSensor(device, "Temperature", "temperature", TEMP_CELSIUS), - AlfenSensor(device, "Current Limit", "current_limit", "A"), - AlfenSensor(device, "Authorization Mode", "auth_mode"), - AlfenSensor( - device, "Active Load Balancing Safe Current", "alb_safe_current", "A" - ), - AlfenSensor( - device, "Active Load Balancing Phase Connection", "alb_phase_connection" - ), - AlfenSensor( - device, "Maximum Smart Meter current", "max_station_current", "A" - ), - AlfenSensor(device, "Load Balancing Mode", "load_balancing_mode"), - AlfenSensor( - device, - "Main Static Load Balancing Max Current", - "main_static_lb_max_current", - "A", - ), - AlfenSensor( - device, - "Main Active Load Balancing Max Current", - "main_active_lb_max_current", - "A", - ), - AlfenSensor(device, "Enable Phase Switching", "enable_phase_switching"), - AlfenSensor(device, "Charging Box Identifier", "charging_box_identifier"), - AlfenSensor(device, "System Boot Reason", "boot_reason"), - AlfenSensor(device, "Max Smart Meter Current", "max_smart_meter_current", "A"), - AlfenSensor(device, "P1 Meter Phase 1 Current", "p1_measurements_1", "A"), - AlfenSensor(device, "P1 Meter Phase 2 Current", "p1_measurements_2", "A"), - AlfenSensor(device, "P1 Meter Phase 3 Current", "p1_measurements_3", "A"), - AlfenSensor(device, "GPRS APN Name", "gprs_apn_name"), - AlfenSensor(device, "GPRS APN User", "gprs_apn_user"), - AlfenSensor(device, "GPRS APN Password", "gprs_apn_password"), - AlfenSensor(device, "GPRS SIM IMSI", "gprs_sim_imsi"), - AlfenSensor(device, "GPRS SIM Serial", "gprs_sim_iccid"), - AlfenSensor(device, "GPRS Provider", "gprs_provider"), - AlfenSensor( - device, - "Wired Url Server Domain And Port", - "comm_bo_url_wired_server_domain_and_port", - ), - AlfenSensor( - device, "Wired Url Wired Server Path", "comm_bo_url_wired_server_path" - ), - AlfenSensor(device, "GPRS DHCP Address", "comm_dhcp_address_1"), - AlfenSensor(device, "GPRS Netmask", "comm_netmask_address_1"), - AlfenSensor(device, "GPRS Gateway Address", "comm_gateway_address_1"), - AlfenSensor(device, "GPRS IP Address", "comm_ip_address_1"), - AlfenSensor(device, "Backoffice Short Name", "comm_bo_short_name"), - AlfenSensor( - device, - "GPRS Url Server Domain And Port", - "comm_bo_url_gprs_server_domain_and_port", - ), - AlfenSensor(device, "GPRS Url Server Path", "comm_bo_url_gprs_server_path"), - AlfenSensor(device, "GPRS DNS 1", "comm_gprs_dns_1"), - AlfenSensor(device, "GPRS DNS 2", "comm_gprs_dns_2"), - AlfenSensor(device, "GPRS Signal", "gprs_signal_strength"), - AlfenSensor(device, "Wired DHCP", "comm_dhcp_address_2"), - AlfenSensor(device, "Wired Netmask", "comm_netmask_address_2"), - AlfenSensor(device, "Wired Gateway Address", "comm_gateway_address_2"), - AlfenSensor(device, "Wired IP Address", "comm_ip_address_2"), - AlfenSensor(device, "Wired DNS 1", "comm_wired_dns_1"), - AlfenSensor(device, "Wired DNS 2", "comm_wired_dns_2"), - AlfenSensor(device, "Protocol Name", "comm_protocol_name"), - AlfenSensor(device, "Protocol Version", "comm_protocol_version"), - AlfenSensor(device, "Solar Charging Mode", "lb_solar_charging_mode"), - AlfenSensor( - device, - "Solar Charging Green Share %", - "lb_solar_charging_green_share", - "%", - ), - AlfenSensor( - device, - "Solar Charging Comfort Level w", - "lb_solar_charging_comfort_level", - "W", - ), - AlfenSensor(device, "Solar Charging Boost", "lb_solar_charging_boost"), - ] - ) + device = hass.data[ALFEN_DOMAIN][entry.entry_id] + + sensors = [ + AlfenSensor(device, description) for description in ALFEN_SENSOR_TYPES + ] + + async_add_entities(sensors) + async_add_entities([AlfenMainSensor(device, ALFEN_SENSOR_TYPES[0])]) platform = entity_platform.current_platform.get() @@ -203,62 +574,73 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class AlfenMainSensor(Entity): - def __init__(self, device: AlfenDevice): +class AlfenMainSensor(AlfenEntity): + def __init__(self, device: AlfenDevice, description: AlfenSensorDescription) -> None: """Initialize the sensor.""" + super().__init__(device) self._device = device - self._name = f"{device.name}" + self._attr_name = f"{device.name}" self._sensor = "sensor" + self.entity_description = description @property def unique_id(self): """Return a unique ID.""" return f"{self._device.id}-{self._sensor}" - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def icon(self): + """Return the icon.""" return "mdi:car-electric" @property def state(self): """Return the state of the sensor.""" - if self._device.status is not None: - return self.status_as_str() - return None + for prop in self._device.properties: + if prop['id'] == self.entity_description.api_param: + if self.entity_description.round_digits is not None: + return round(prop['value'], self.entity_description.round_digits) + return STATUS_DICT.get(prop['value'], 'Unknown') + return 'Unknown' async def async_reboot_wallbox(self): + """Reboot the wallbox.""" await self._device.reboot_wallbox() async def async_set_current_limit(self, limit): + """Set the current limit.""" await self._device.set_current_limit(limit) async def async_enable_rfid_auth_mode(self): + """Enable RFID authorization mode.""" await self._device.set_rfid_auth_mode(True) async def async_disable_rfid_auth_mode(self): + """Disable RFID authorization mode.""" await self._device.set_rfid_auth_mode(False) async def async_update(self): + """Update the sensor.""" await self._device.async_update() async def async_set_current_phase(self, phase): + """Set the current phase.""" await self._device.set_current_phase(phase) async def async_enable_phase_switching(self): + """Enable phase switching.""" await self._device.set_phase_switching(True) async def async_disable_phase_switching(self): + """Disable phase switching.""" await self._device.set_phase_switching(False) async def async_set_green_share(self, value): + """Set the green share.""" await self._device.set_green_share(value) async def async_set_comfort_power(self, value): + """Set the comfort power.""" await self._device.set_comfort_power(value) @property @@ -266,110 +648,60 @@ def device_info(self): """Return a device description for device registry.""" return self._device.device_info - def status_as_str(self): - switcher = { - 0: "Unknown", - 1: "Off", - 2: "Booting", - 3: "Booting Check Mains", - 4: "Available", - 5: "Prep. Authorising", - 6: "Prep. Authorised", - 7: "Prep. Cable connected", - 8: "Prep EV Connected", - 9: "Charging Preparing", - 10: "Vehicle connected", - 11: "Charging Active Normal", - 12: "Charging Active Simplified", - 13: "Charging Suyspended Over Current", - 14: "Charging Suspended HF Switching", - 15: "Charging Suspended EV Disconnected", - 16: "Finish Wait Vehicle", - 17: "Finished Wait Disconnect", - 18: "Error Protective Earth", - 19: "Error Powerline Fault", - 20: "Error Contactor Fault", - 21: "Error Charging", - 22: "Error Power Failure", - 23: "Error Temperature", - 24: "Error Illegal CP Value", - 25: "Error Illegal PP Value", - 26: "Error Too Many Restarts", - 27: "Error", - 28: "Error Message", - 29: "Error Message Not Authorised", - 30: "Error Message Cable Not Supported", - 31: "Error Message S2 Not Opened", - 32: "Error Message Time Out", - 33: "Reserved", - 34: "Inoperative", - 35: "Load Balancing Limited", - 36: "Load Balancing Forced Off", - 38: "Not Charging", - 39: "Solar Charging Wait", - 41: "Solar Charging", - 42: "Charge Point Ready, Waiting For Power", - 43: "Partial Solar Charging", - } - return switcher.get(self._device.status.status, "Unknown") - - -class AlfenSensor(SensorEntity): - def __init__(self, device: AlfenDevice, name, sensor, unit=None): + +class AlfenSensor(AlfenEntity, SensorEntity): + """Representation of a Alfen Sensor.""" + + entity_description: AlfenSensorDescription + + def __init__(self, + device: AlfenDevice, + description: AlfenSensorDescription) -> None: """Initialize the sensor.""" + super().__init__(device) self._device = device - self._name = f"{device.name} {name}" - self._sensor = sensor - self._unit = unit - if self._sensor == "active_power_total": - _LOGGER.info(f"Initiating State sensors {self._name}") + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" + self.entity_description = description + if self.entity_description.key == "active_power_total": + _LOGGER.info(f"Initiating State sensors {self._attr_name}") self._attr_device_class = DEVICE_CLASS_POWER - self._attr_state_class = STATE_CLASS_MEASUREMENT - elif self._sensor == "uptime": - _LOGGER.info(f"Initiating State sensors {self._name}") - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING - elif self._sensor == "meter_reading": - _LOGGER.info(f"Initiating State sensors {self._name}") + self._attr_state_class = SensorStateClass.MEASUREMENT + elif self.entity_description.key == "uptime": + _LOGGER.info(f"Initiating State sensors {self._attr_name}") + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + elif self.entity_description.key == "meter_reading": + _LOGGER.info(f"Initiating State sensors {self._attr_name}") self._attr_device_class = DEVICE_CLASS_ENERGY - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + self._async_update_attrs() + + def _get_current_value(self): + """Get the current value.""" + for prop in self._device.properties: + if prop['id'] == self.entity_description.api_param: + return prop['value'] + return None + + @callback + def _async_update_attrs(self) -> None: + """Update the state and attributes.""" + self._attr_native_value = self._get_current_value() @property def unique_id(self): """Return a unique ID.""" - return f"{self._device.id}-{self._sensor}" + return f"{self._device.id}-{self.entity_description.key}" @property def name(self): """Return the name of the sensor.""" - return self._name + return self._attr_name @property def icon(self): """Return the icon of the sensor.""" - icon = None - if self._sensor == "temperature": - icon = "mdi:thermometer" - elif ( - (self._sensor.startswith("current_")) - | (self._sensor.startswith("p1_measurements")) - | ("_current" in self._sensor) - ): - icon = "mdi:current-ac" - elif self._sensor.startswith("voltage_"): - icon = "mdi:flash" - elif self._sensor == "uptime": - icon = "mdi:timer-outline" - elif self._sensor == "bootups": - icon = "mdi:reload" - elif self._sensor == "active_power_total": - icon = "mdi:circle-slice-3" - elif ("gprs_" in self._sensor) | ("_address_1" in self._sensor): - icon = "mdi:antenna" - elif ("wired_" in self._sensor) | ("_address_2" in self._sensor): - icon = "mdi:cable-data" - elif self._sensor.startswith("lb_solar_charging"): - icon = "mdi:solar-power" - return icon + return self.entity_description.icon @property def native_value(self): @@ -379,19 +711,39 @@ def native_value(self): @property def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._unit + return self.entity_description.unit @property def state(self): """Return the state of the sensor.""" - if self._sensor in self._device.status.__dict__: - return self._device.status.__dict__[self._sensor] + for prop in self._device.properties: + if prop['id'] == self.entity_description.api_param: + # some exception of return value + + # meter_reading from w to kWh + if self.entity_description.api_param == "2221_22": + return round((prop["value"] / 1000), 2) + + # change milliseconds to HH:MM:SS + if self.entity_description.api_param == "2060_0": + return str(datetime.timedelta(milliseconds=prop['value'])).split('.', maxsplit=1)[0] + + # change milliseconds to d/m/y HH:MM:SS + if self.entity_description.api_param == "2187_0" or self.entity_description.api_param == "2059_0": + return datetime.datetime.fromtimestamp(prop['value'] / 1000).strftime("%d/%m/%Y %H:%M:%S") + + if self.entity_description.round_digits is not None: + return round(prop['value'], self.entity_description.round_digits) + + return prop['value'] @property def unit_of_measurement(self): - return self._unit + """Return the unit of measurement.""" + return self.entity_description.unit async def async_update(self): + """Get the latest data and updates the states.""" await self._device.async_update() @property diff --git a/custom_components/alfen_wallbox/switch.py b/custom_components/alfen_wallbox/switch.py new file mode 100644 index 0000000..c66fdba --- /dev/null +++ b/custom_components/alfen_wallbox/switch.py @@ -0,0 +1,92 @@ +import logging + +from dataclasses import dataclass +from typing import Any, Final +from .alfen import AlfenDevice +from .entity import AlfenEntity + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN as ALFEN_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AlfenSwitchDescriptionMixin: + """Define an entity description mixin for binary sensor entities.""" + + api_param: str + + +@dataclass +class AlfenSwitchDescription(SwitchEntityDescription, AlfenSwitchDescriptionMixin): + """Class to describe an Alfen binary sensor entity.""" + + +ALFEN_BINARY_SENSOR_TYPES: Final[tuple[AlfenSwitchDescription, ...]] = ( + AlfenSwitchDescription( + key="enable_phase_switching", + name="Enable Phase Switching", + api_param="2185_0", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Alfen switch entities from a config entry.""" + device = hass.data[ALFEN_DOMAIN][entry.entry_id] + switches = [AlfenSwitchSensor(device, description) + for description in ALFEN_BINARY_SENSOR_TYPES] + + async_add_entities(switches) + + +class AlfenSwitchSensor(AlfenEntity, SwitchEntity): + """Define an Alfen binary sensor.""" + + # entity_description: AlfenSwitchDescriptionMixin + + def __init__(self, + device: AlfenDevice, + description: AlfenSwitchDescription + ) -> None: + """Initialize.""" + super().__init__(device) + self._device = device + self._attr_name = f"{device.name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + self.entity_description = description + + @property + def available(self) -> bool: + for prop in self._device.properties: + if prop["id"] == self.entity_description.api_param: + return True + return False + + @property + def is_on(self) -> bool: + for prop in self._device.properties: + if prop["id"] == self.entity_description.api_param: + return prop["value"] == 1 + + return False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + # Do the turning on. + await self.update_state(self.entity_description.api_param, 1) + await self._device.async_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.update_state(self.entity_description.api_param, 0) + await self._device.async_update()