From f4e1e230a5d98079b2d458b3ef5531d964edd77c Mon Sep 17 00:00:00 2001 From: Chris <1105672+firstof9@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:07:40 -0700 Subject: [PATCH] feat: add LED brightness control (#404) * feat: add LED brightness control * formatting * linting * update test * formatting again --- README.md | 2 + custom_components/openevse/__init__.py | 19 ++++ custom_components/openevse/const.py | 26 ++++-- custom_components/openevse/entity.py | 8 ++ custom_components/openevse/light.py | 111 +++++++++++++++++++++++ custom_components/openevse/manifest.json | 2 +- tests/const.py | 1 + tests/test_light.py | 99 ++++++++++++++++++++ 8 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 custom_components/openevse/light.py create mode 100644 tests/test_light.py diff --git a/README.md b/README.md index e1fb47b..cb706cb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Platform | Description -- | -- `binary_sensor` | On/Off sensors for various settings. `button` | Button to restart the ESP chip or the EVSE. +`light` | Set the LED brightness. +`number` | Set the charge rate. `select` | Select the ampers limit and service level. `sensor` | Show info from an OpenEVSE charger's API. `switch` | Switch to toggle various charger modes. diff --git a/custom_components/openevse/__init__.py b/custom_components/openevse/__init__.py index 3c609ba..d08a002 100644 --- a/custom_components/openevse/__init__.py +++ b/custom_components/openevse/__init__.py @@ -39,6 +39,7 @@ DOMAIN, FW_COORDINATOR, ISSUE_URL, + LIGHT_TYPES, MANAGER, PLATFORMS, SELECT_TYPES, @@ -388,6 +389,24 @@ def parse_sensors(self) -> None: select, ) data.update(_sensor) + for light in LIGHT_TYPES: # pylint: disable=consider-using-dict-items + _sensor = {} + try: + sensor_property = LIGHT_TYPES[light].key + # Data can be sent as boolean or as 1/0 + _sensor[light] = getattr(self._manager, sensor_property) + _LOGGER.debug( + "light: %s sensor_property: %s value %s", + select, + sensor_property, + _sensor[light], + ) + except (ValueError, KeyError): + _LOGGER.info( + "Could not update status for %s", + light, + ) + data.update(_sensor) _LOGGER.debug("DEBUG: %s", data) self._data = data diff --git a/custom_components/openevse/const.py b/custom_components/openevse/const.py index 6775cc3..af709fe 100644 --- a/custom_components/openevse/const.py +++ b/custom_components/openevse/const.py @@ -18,6 +18,7 @@ from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( PERCENTAGE, + Platform, SIGNAL_STRENGTH_DECIBELS, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -33,6 +34,7 @@ OpenEVSESelectEntityDescription, OpenEVSESwitchEntityDescription, OpenEVSENumberEntityDescription, + OpenEVSELightEntityDescription, ) # config flow @@ -55,13 +57,14 @@ VERSION = "1.0.0" ISSUE_URL = "http://github.com/firstof9/openevse/" PLATFORMS = [ - "binary_sensor", - "button", - "number", - "sensor", - "select", - "switch", - "update", + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.UPDATE, ] USER_AGENT = "Home Assistant" MANAGER = "manager" @@ -510,3 +513,12 @@ mode=NumberMode.AUTO, ), } + +LIGHT_TYPES: Final[dict[str, OpenEVSELightEntityDescription]] = { + "led_brightness": OpenEVSELightEntityDescription( + key="led_brightness", + name="LED Brightness", + entity_category=EntityCategory.CONFIG, + command="set_led_brightness", + ), +} diff --git a/custom_components/openevse/entity.py b/custom_components/openevse/entity.py index a20336c..3eb7901 100644 --- a/custom_components/openevse/entity.py +++ b/custom_components/openevse/entity.py @@ -4,6 +4,7 @@ from dataclasses import dataclass +from homeassistant.components.light import LightEntityDescription from homeassistant.components.number import NumberEntityDescription from homeassistant.components.select import SelectEntityDescription from homeassistant.components.switch import SwitchEntityDescription @@ -32,3 +33,10 @@ class OpenEVSENumberEntityDescription(NumberEntityDescription): default_options: list | None = None min: int | None = None max: int | None = None + + +@dataclass +class OpenEVSELightEntityDescription(LightEntityDescription): + """Class describing OpenEVSE light entities.""" + + command: str | None = None diff --git a/custom_components/openevse/light.py b/custom_components/openevse/light.py new file mode 100644 index 0000000..6cb37e9 --- /dev/null +++ b/custom_components/openevse/light.py @@ -0,0 +1,111 @@ +"""Support for OpenEVSE controls using the light platform.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_NAME, COORDINATOR, DOMAIN, LIGHT_TYPES, MANAGER +from .entity import OpenEVSELightEntityDescription + +from . import ( + OpenEVSEManager, + OpenEVSEUpdateCoordinator, +) + +_LOGGER = logging.getLogger(__name__) +DEFAULT_ON = 125 +DEFAULT_OFF = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up OpenEVSE Number entity from Config Entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + manager = hass.data[DOMAIN][config_entry.entry_id][MANAGER] + + entities: list[LightEntity] = [] + + for light in LIGHT_TYPES: # pylint: disable=consider-using-dict-items + entities.append( + OpenEVSELight(config_entry, coordinator, LIGHT_TYPES[light], manager) + ) + async_add_entities(entities) + + +class OpenEVSELight(CoordinatorEntity, LightEntity): + """Implementation of an OpenEVSE light.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + + def __init__( + self, + config: ConfigEntry, + coordinator: OpenEVSEUpdateCoordinator, + light_description: OpenEVSELightEntityDescription, + manager: OpenEVSEManager, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._config = config + self.entity_description = light_description + self._name = light_description.name + self._type = light_description.key + self._unique_id = config.entry_id + self._command = light_description.command + self._data = coordinator.data + self.coordinator = coordinator + self.manager = manager + + self._attr_name = f"{self._config.data[CONF_NAME]} {self._name}" + self._attr_unique_id = f"{self._name}_{self._unique_id}" + self._attr_brightness = coordinator.data[self._type] + + @property + def device_info(self) -> dict: + """Return a port description for device registry.""" + info = { + "manufacturer": "OpenEVSE", + "name": self._config.data[CONF_NAME], + "connections": {(DOMAIN, self._unique_id)}, + } + + return info + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + self._attr_brightness = self.coordinator.data[self._type] + return self._attr_brightness + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return bool(self._attr_brightness != 0) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + + if ATTR_BRIGHTNESS in kwargs: + await self.manager.set_led_brightness(brightness) + return + await self.manager.set_led_brightness(DEFAULT_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + await self.manager.set_led_brightness(DEFAULT_OFF) diff --git a/custom_components/openevse/manifest.json b/custom_components/openevse/manifest.json index c8d1a86..ba5b0c4 100644 --- a/custom_components/openevse/manifest.json +++ b/custom_components/openevse/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/firstof9/openevse/issues", "loggers": ["openevsehttp"], - "requirements": ["python-openevse-http==0.1.62"], + "requirements": ["python-openevse-http==0.1.63"], "version": "0.0.0-dev", "zeroconf": ["_openevse._tcp.local."] } diff --git a/tests/const.py b/tests/const.py index 16a2955..fbdc029 100644 --- a/tests/const.py +++ b/tests/const.py @@ -45,6 +45,7 @@ "using_ethernet": False, "shaper_active": False, "max_current_soft": 48, + "led_brightness": 128, } CONFIG_DATA = { "name": "openevse", diff --git a/tests/test_light.py b/tests/test_light.py new file mode 100644 index 0000000..624dec6 --- /dev/null +++ b/tests/test_light.py @@ -0,0 +1,99 @@ +"""Provide tests for OpenEVSE light platform.""" + +from datetime import timedelta +from unittest.mock import patch + +import pytest +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN, ATTR_BRIGHTNESS +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.openevse.const import DOMAIN + +from .const import CONFIG_DATA +from .conftest import TEST_URL_CONFIG + +pytestmark = pytest.mark.asyncio + +CHARGER_NAME = "openevse" + + +async def test_light( + hass, + test_charger, + mock_ws_start, + mock_aioclient, +): + """Test setup_entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CHARGER_NAME, + data=CONFIG_DATA, + ) + + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 21 + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(SELECT_DOMAIN)) == 2 + assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert DOMAIN in hass.config.components + + entity_id = "light.openevse_led_brightness" + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + assert state.attributes[ATTR_BRIGHTNESS] == 128 + + mock_aioclient.post( + TEST_URL_CONFIG, + status=200, + body='{"msg": "Ok"}', + repeat=True, + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_off", + {"entity_id": entity_id}, + blocking=True, + ) + + mock_aioclient.assert_any_call( + TEST_URL_CONFIG, method="POST", data={ATTR_BRIGHTNESS: 0} + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + + mock_aioclient.assert_any_call( + TEST_URL_CONFIG, method="POST", data={ATTR_BRIGHTNESS: 128} + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {"entity_id": entity_id, "brightness": 26}, + blocking=True, + ) + + mock_aioclient.assert_any_call( + TEST_URL_CONFIG, method="POST", data={ATTR_BRIGHTNESS: 26} + )