diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 76e59c0d..0c51248d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -7,8 +7,6 @@ on: - master - dev pull_request: - schedule: - - cron: "0 0 * * *" env: DEFAULT_PYTHON: 3.9 diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 16b9cead..74120507 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -76,12 +76,6 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.DEBUG) -SCR_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Optional("limit_amps"): int, - vol.Optional("limit_watts"): int, - } -) UFW_SERVICE_DATA_SCHEMA = vol.Schema( { vol.Required("firmware_url"): str, @@ -199,6 +193,18 @@ def get_available(self, cp_id: str): return self.charge_points[cp_id].status == STATE_OK return False + def get_supported_features(self, cp_id: str): + """Return what profiles the charger supports.""" + if cp_id in self.charge_points: + return self.charge_points[cp_id].supported_features + return None + + async def set_max_charge_rate_amps(self, cp_id: str, value: float): + """Set the maximum charge rate in amps.""" + if cp_id in self.charge_points: + return await self.charge_points[cp_id].set_charge_rate(limit_amps=value) + return False + async def set_charger_state( self, cp_id: str, service_name: str, state: bool = True ): @@ -279,22 +285,6 @@ async def handle_clear_profile(call): return await self.clear_profile() - async def handle_set_charge_rate(call): - """Handle the set charge rate service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - lim_A = call.data.get("limit_amps") - lim_W = call.data.get("limit_watts") - if lim_A is not None and lim_W is not None: - await self.set_charge_rate(lim_A, lim_W) - elif lim_A is not None: - await self.set_charge_rate(limit_amps=lim_A) - elif lim_W is not None: - await self.set_charge_rate(limit_watts=lim_W) - else: - await self.set_charge_rate() - async def handle_update_firmware(call): """Handle the firmware update service call.""" if self.status == STATE_UNAVAILABLE: @@ -370,12 +360,6 @@ async def handle_get_diagnostics(call): self.hass.services.async_register( DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_set_charge_rate.value, - handle_set_charge_rate, - SCR_SERVICE_DATA_SCHEMA, - ) if prof.FW in self._attr_supported_features: self.hass.services.async_register( DOMAIN, diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index c4fbc8a1..adda6d6e 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -1,37 +1,47 @@ """Define constants for OCPP integration.""" +import homeassistant.components.input_number as input_number import homeassistant.const as ha from ocpp.v16.enums import ChargePointStatus, Measurand, UnitOfMeasure from .enums import HAChargerServices, HAChargerStatuses -DOMAIN = "ocpp" -CONF_METER_INTERVAL = "meter_interval" -CONF_USERNAME = ha.CONF_USERNAME -CONF_PASSWORD = ha.CONF_PASSWORD +CONF_CPI = "charge_point_identity" +CONF_CPID = "cpid" +CONF_CSID = "csid" CONF_HOST = ha.CONF_HOST +CONF_ICON = ha.CONF_ICON +CONF_INITIAL = input_number.CONF_INITIAL +CONF_MAX = input_number.CONF_MAX +CONF_MIN = input_number.CONF_MIN +CONF_METER_INTERVAL = "meter_interval" +CONF_MODE = ha.CONF_MODE CONF_MONITORED_VARIABLES = ha.CONF_MONITORED_VARIABLES CONF_NAME = ha.CONF_NAME -CONF_CPID = "cpid" -CONF_CSID = "csid" +CONF_PASSWORD = ha.CONF_PASSWORD CONF_PORT = ha.CONF_PORT +CONF_STEP = input_number.CONF_STEP CONF_SUBPROTOCOL = "subprotocol" -CONF_CPI = "charge_point_identity" +CONF_UNIT_OF_MEASUREMENT = ha.CONF_UNIT_OF_MEASUREMENT +CONF_USERNAME = ha.CONF_USERNAME DEFAULT_CSID = "central" DEFAULT_CPID = "charger" DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 9000 DEFAULT_SUBPROTOCOL = "ocpp1.6" DEFAULT_METER_INTERVAL = 60 - +DOMAIN = "ocpp" ICON = "mdi:ev-station" +MODE_SLIDER = input_number.MODE_SLIDER +MODE_BOX = input_number.MODE_BOX SLEEP_TIME = 60 # Platforms -BINARY_SENSOR = "binary_sensor" +NUMBER = "number" SENSOR = "sensor" SWITCH = "switch" -PLATFORMS = [SENSOR, SWITCH] + +PLATFORMS = [SENSOR, SWITCH, NUMBER] # Ocpp supported measurands MEASURANDS = [ @@ -94,3 +104,16 @@ "pulse": True, } SWITCHES = [SWITCH_CHARGE, SWITCH_RESET, SWITCH_UNLOCK, SWITCH_AVAILABILITY] + +# Input number definitions +NUMBER_MAX_CURRENT = { + CONF_NAME: "Maximum_Current", + CONF_ICON: ICON, + CONF_MIN: 0, + CONF_MAX: 32, + CONF_STEP: 1, + CONF_INITIAL: 32, + CONF_MODE: MODE_SLIDER, + CONF_UNIT_OF_MEASUREMENT: "A", +} +NUMBERS = [NUMBER_MAX_CURRENT] diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py new file mode 100644 index 00000000..61b861ad --- /dev/null +++ b/custom_components/ocpp/number.py @@ -0,0 +1,74 @@ +"""Number platform for ocpp.""" +from homeassistant.components.input_number import InputNumber +import voluptuous as vol + +from .api import CentralSystem +from .const import CONF_CPID, DEFAULT_CPID, DOMAIN, NUMBERS +from .enums import Profiles + + +async def async_setup_entry(hass, entry, async_add_devices): + """Configure the number platform.""" + central_system = hass.data[DOMAIN][entry.entry_id] + cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) + + entities = [] + + for cfg in NUMBERS: + entities.append(Number(central_system, cp_id, cfg)) + + async_add_devices(entities, False) + + +class Number(InputNumber): + """Individual slider for setting charge rate.""" + + def __init__(self, central_system: CentralSystem, cp_id: str, config: dict): + """Initialize a Number instance.""" + super().__init__(config) + self.cp_id = cp_id + self.central_system = central_system + self.id = ".".join(["number", self.cp_id, config["name"]]) + self._name = ".".join([self.cp_id, config["name"]]) + self.entity_id = "number." + "_".join([self.cp_id, config["name"]]) + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return self.id + + @property + def name(self): + """Return the name of this entity.""" + return self._name + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not ( + Profiles.SMART & self.central_system.get_supported_features(self.cp_id) + ): + return False + return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self.cp_id)}, + "via_device": (DOMAIN, self.central_system.id), + } + + async def async_set_value(self, value): + """Set new value.""" + num_value = float(value) + + if num_value < self._minimum or num_value > self._maximum: + raise vol.Invalid( + f"Invalid value for {self.entity_id}: {value} (range {self._minimum} - {self._maximum})" + ) + + resp = await self.central_system.set_max_charge_rate_amps(self.cp_id, num_value) + if resp: + self._current_value = num_value + self.async_write_ha_state() diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 866515e1..082d09c7 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -8,7 +8,7 @@ import websockets from custom_components.ocpp import async_setup_entry, async_unload_entry -from custom_components.ocpp.const import DOMAIN, SWITCH, SWITCHES +from custom_components.ocpp.const import DOMAIN, NUMBER, NUMBERS, SWITCH, SWITCHES from custom_components.ocpp.enums import ConfigurationKey, HAChargerServices as csvcs from ocpp.routing import on from ocpp.v16 import ChargePoint as cpclass, call, call_result @@ -68,7 +68,6 @@ async def test_services(hass): csvcs.service_get_configuration, csvcs.service_get_diagnostics, csvcs.service_clear_profile, - csvcs.service_set_charge_rate, ] for service in SERVICES: data = {} @@ -88,6 +87,17 @@ async def test_services(hass): ) assert result + for number in NUMBERS: + # test setting value of number slider + result = await hass.services.async_call( + NUMBER, + "set_value", + service_data={"value": "10"}, + blocking=True, + target={ATTR_ENTITY_ID: f"{NUMBER}.test_cpid_{number['name'].lower()}"}, + ) + assert result + # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms"