From 1c8ae8a21b5bd5df441b880a7e73568ccb609ad4 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:12:53 +0200 Subject: [PATCH] Add switches for blue current integration. (#146210) --- .../components/blue_current/__init__.py | 47 +++-- .../components/blue_current/const.py | 11 ++ .../components/blue_current/icons.json | 11 ++ .../components/blue_current/strings.json | 11 ++ .../components/blue_current/switch.py | 169 ++++++++++++++++++ tests/components/blue_current/__init__.py | 19 ++ tests/components/blue_current/test_sensor.py | 13 +- tests/components/blue_current/test_switch.py | 152 ++++++++++++++++ 8 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/blue_current/switch.py create mode 100644 tests/components/blue_current/test_switch.py diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 775ca16a12a..eeda91a70a3 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE +from .const import ( + CHARGEPOINT_SETTINGS, + CHARGEPOINT_STATUS, + DOMAIN, + EVSE_ID, + LOGGER, + PLUG_AND_CHARGE, + VALUE, +) type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 GRID = "GRID" OBJECT = "object" -VALUE_TYPES = ["CH_STATUS"] +VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] async def async_setup_entry( @@ -94,7 +102,7 @@ class Connector: elif object_name in VALUE_TYPES: value_data: dict = message[DATA] evse_id = value_data.pop(EVSE_ID) - self.update_charge_point(evse_id, value_data) + self.update_charge_point(evse_id, object_name, value_data) # gets grid key / values elif GRID in object_name: @@ -106,26 +114,37 @@ class Connector: """Handle incoming chargepoint data.""" await asyncio.gather( *( - self.handle_charge_point( - entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] - ) + self.handle_charge_point(entry[EVSE_ID], entry) for entry in charge_points_data ), self.client.get_grid_status(charge_points_data[0][EVSE_ID]), ) - async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + async def handle_charge_point( + self, evse_id: str, charge_point: dict[str, Any] + ) -> None: """Add the chargepoint and request their data.""" - self.add_charge_point(evse_id, model, name) + self.add_charge_point(evse_id, charge_point) await self.client.get_status(evse_id) - def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + def add_charge_point(self, evse_id: str, charge_point: dict[str, Any]) -> None: """Add a charge point to charge_points.""" - self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + self.charge_points[evse_id] = charge_point - def update_charge_point(self, evse_id: str, data: dict) -> None: + def update_charge_point(self, evse_id: str, update_type: str, data: dict) -> None: """Update the charge point data.""" - self.charge_points[evse_id].update(data) + charge_point = self.charge_points[evse_id] + if update_type == CHARGEPOINT_SETTINGS: + # Update the plug and charge object. The library parses this object to a bool instead of an object. + plug_and_charge = charge_point.get(PLUG_AND_CHARGE) + if plug_and_charge is not None: + plug_and_charge[VALUE] = data[PLUG_AND_CHARGE] + + # Remove the plug and charge object from the data list before updating. + del data[PLUG_AND_CHARGE] + + charge_point.update(data) + self.dispatch_charge_point_update_signal(evse_id) def dispatch_charge_point_update_signal(self, evse_id: str) -> None: diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 008e6efa872..33e0e8b1176 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,3 +8,14 @@ LOGGER = logging.getLogger(__package__) EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +PLUG_AND_CHARGE = "plug_and_charge" +VALUE = "value" +PERMISSION = "permission" +CHARGEPOINT_STATUS = "CH_STATUS" +CHARGEPOINT_SETTINGS = "CH_SETTINGS" +BLOCK = "block" +UNAVAILABLE = "unavailable" +AVAILABLE = "available" +LINKED_CHARGE_CARDS = "linked_charge_cards_only" +PUBLIC_CHARGING = "public_charging" +ACTIVITY = "activity" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index ce936902e91..28d4acbc1d8 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -30,6 +30,17 @@ "stop_charge_session": { "default": "mdi:stop" } + }, + "switch": { + "plug_and_charge": { + "default": "mdi:ev-plug-type2" + }, + "linked_charge_cards": { + "default": "mdi:account-group" + }, + "block": { + "default": "mdi:lock" + } } } } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 28eb20fa912..0a99af603cc 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -124,6 +124,17 @@ "reset": { "name": "Reset" } + }, + "switch": { + "plug_and_charge": { + "name": "Plug & Charge" + }, + "linked_charge_cards_only": { + "name": "Linked charging cards only" + }, + "block": { + "name": "Block charge point" + } } } } diff --git a/homeassistant/components/blue_current/switch.py b/homeassistant/components/blue_current/switch.py new file mode 100644 index 00000000000..a0848387901 --- /dev/null +++ b/homeassistant/components/blue_current/switch.py @@ -0,0 +1,169 @@ +"""Support for Blue Current switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PLUG_AND_CHARGE, BlueCurrentConfigEntry, Connector +from .const import ( + AVAILABLE, + BLOCK, + LINKED_CHARGE_CARDS, + PUBLIC_CHARGING, + UNAVAILABLE, + VALUE, +) +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class BlueCurrentSwitchEntityDescription(SwitchEntityDescription): + """Describes a Blue Current switch entity.""" + + function: Callable[[Connector, str, bool], Any] + + turn_on_off_fn: Callable[[str, Connector], tuple[bool, bool]] + """Update the switch based on the latest data received from the websocket. The first returned boolean is _attr_is_on, the second one has_value.""" + + +def update_on_value_and_activity( + key: str, evse_id: str, connector: Connector, reverse_is_on: bool = False +) -> tuple[bool, bool]: + """Return the updated state of the switch based on received chargepoint data and activity.""" + + data_object = connector.charge_points[evse_id].get(key) + is_on = data_object[VALUE] if data_object is not None else None + activity = connector.charge_points[evse_id].get("activity") + + if is_on is not None and activity == AVAILABLE: + return is_on if not reverse_is_on else not is_on, True + return False, False + + +def update_block_switch(evse_id: str, connector: Connector) -> tuple[bool, bool]: + """Return the updated data for a block switch.""" + activity = connector.charge_points[evse_id].get("activity") + return activity == UNAVAILABLE, activity in [AVAILABLE, UNAVAILABLE] + + +def update_charge_point( + key: str, evse_id: str, connector: Connector, new_switch_value: bool +) -> None: + """Change charge point data when the state of the switch changes.""" + data_objects = connector.charge_points[evse_id].get(key) + if data_objects is not None: + data_objects[VALUE] = new_switch_value + + +async def set_plug_and_charge(connector: Connector, evse_id: str, value: bool) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_plug_and_charge(evse_id, value) + update_charge_point(PLUG_AND_CHARGE, evse_id, connector, value) + + +async def set_linked_charge_cards( + connector: Connector, evse_id: str, value: bool +) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_linked_charge_cards_only(evse_id, value) + update_charge_point(PUBLIC_CHARGING, evse_id, connector, not value) + + +SWITCHES = ( + BlueCurrentSwitchEntityDescription( + key=PLUG_AND_CHARGE, + translation_key=PLUG_AND_CHARGE, + function=set_plug_and_charge, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity(PLUG_AND_CHARGE, evse_id, connector) + ), + ), + BlueCurrentSwitchEntityDescription( + key=LINKED_CHARGE_CARDS, + translation_key=LINKED_CHARGE_CARDS, + function=set_linked_charge_cards, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity( + PUBLIC_CHARGING, evse_id, connector, reverse_is_on=True + ) + ), + ), + BlueCurrentSwitchEntityDescription( + key=BLOCK, + translation_key=BLOCK, + function=lambda connector, evse_id, value: connector.client.block( + evse_id, value + ), + turn_on_off_fn=update_block_switch, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current switches.""" + connector = entry.runtime_data + + async_add_entities( + ChargePointSwitch( + connector, + evse_id, + switch, + ) + for evse_id in connector.charge_points + for switch in SWITCHES + ) + + +class ChargePointSwitch(ChargepointEntity, SwitchEntity): + """Base charge point switch.""" + + has_value = True + entity_description: BlueCurrentSwitchEntityDescription + + def __init__( + self, + connector: Connector, + evse_id: str, + switch: BlueCurrentSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(connector, evse_id) + + self.key = switch.key + self.entity_description = switch + self.evse_id = evse_id + self._attr_available = True + self._attr_unique_id = f"{switch.key}_{evse_id}" + + async def call_function(self, value: bool) -> None: + """Call the function to set setting.""" + await self.entity_description.function(self.connector, self.evse_id, value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(False) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the switch.""" + new_state = self.entity_description.turn_on_off_fn(self.evse_id, self.connector) + self._attr_is_on = new_state[0] + self.has_value = new_state[1] diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 97acff39a62..402d644747a 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -4,18 +4,28 @@ from __future__ import annotations from asyncio import Event, Future from dataclasses import dataclass +from typing import Any from unittest.mock import MagicMock, patch from bluecurrent_api import Client +from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import PUBLIC_CHARGING from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +DEFAULT_CHARGE_POINT_OPTIONS = { + PLUG_AND_CHARGE: {"value": False, "permission": "write"}, + PUBLIC_CHARGING: {"value": True, "permission": "write"}, +} + DEFAULT_CHARGE_POINT = { "evse_id": "101", "model_type": "", "name": "", + "activity": "available", + **DEFAULT_CHARGE_POINT_OPTIONS, } @@ -77,11 +87,20 @@ def create_client_mock( """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def update_charge_point( + evse_id: str, event_object: str, settings: dict[str, Any] + ) -> None: + """Update the charge point data by sending an event.""" + await client_mock.receiver( + {"object": event_object, "data": {EVSE_ID: evse_id, **settings}} + ) + client_mock.connect.side_effect = connect client_mock.wait_for_charge_points.side_effect = wait_for_charge_points client_mock.get_charge_points.side_effect = get_charge_points client_mock.get_status.side_effect = get_status client_mock.get_grid_status.side_effect = get_grid_status + client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py index cf20b7334b4..773ffbccd97 100644 --- a/tests/components/blue_current/test_sensor.py +++ b/tests/components/blue_current/test_sensor.py @@ -7,17 +7,10 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import DEFAULT_CHARGE_POINT, init_integration from tests.common import MockConfigEntry -charge_point = { - "evse_id": "101", - "model_type": "", - "name": "", -} - - charge_point_status = { "actual_v1": 14, "actual_v2": 18, @@ -97,7 +90,7 @@ async def test_sensors_created( hass, config_entry, "sensor", - charge_point, + DEFAULT_CHARGE_POINT, charge_point_status | charge_point_status_timestamps, grid, ) @@ -116,7 +109,7 @@ async def test_sensors( ) -> None: """Test the underlying sensors.""" await init_integration( - hass, config_entry, "sensor", charge_point, charge_point_status, grid + hass, config_entry, "sensor", DEFAULT_CHARGE_POINT, charge_point_status, grid ) for entity_id, key in charge_point_entity_ids.items(): diff --git a/tests/components/blue_current/test_switch.py b/tests/components/blue_current/test_switch.py new file mode 100644 index 00000000000..c7837816d75 --- /dev/null +++ b/tests/components/blue_current/test_switch.py @@ -0,0 +1,152 @@ +"""The tests for Bluecurrent switches.""" + +from homeassistant.components.blue_current import CHARGEPOINT_SETTINGS, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import ( + ACTIVITY, + CHARGEPOINT_STATUS, + PUBLIC_CHARGING, + UNAVAILABLE, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import DEFAULT_CHARGE_POINT, init_integration + +from tests.common import MockConfigEntry + + +async def test_switches( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the underlying switches.""" + + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.unique_id == switch.unique_id + + +async def test_switches_offline( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if switches are disabled when needed.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "offline" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == UNAVAILABLE + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.entity_id == switch.entity_id + + +async def test_block_switch_availability( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the block switch is unavailable when charging.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "charging" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + state = hass.states.get("switch.101_block_charge_point") + assert state and state.state == UNAVAILABLE + + +async def test_toggle( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the on / off methods and if the switch gets updated.""" + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_ON + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_OFF + + +async def test_setting_change( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the state of the switches are updated when an update message from the websocket comes in.""" + integration = await init_integration(hass, config_entry, Platform.SWITCH) + client_mock = integration[0] + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await client_mock.update_charge_point( + "101", + CHARGEPOINT_SETTINGS, + { + PLUG_AND_CHARGE: True, + PUBLIC_CHARGING: {"value": False, "permission": "write"}, + }, + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_block_charge_point") + assert plug_and_charge_switch.state == STATE_OFF + + await client_mock.update_charge_point( + "101", CHARGEPOINT_STATUS, {ACTIVITY: UNAVAILABLE} + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_UNAVAILABLE + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_UNAVAILABLE + + switch = hass.states.get("switch.101_block_charge_point") + assert switch.state == STATE_ON