From ddea2206c3e8ef3dcfd65bc9298437bab97c19a1 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:11:33 +0200 Subject: [PATCH] Add start charge session action for blue current integration. (#145446) --- .../components/blue_current/__init__.py | 90 ++++++++++++- .../components/blue_current/const.py | 6 + .../components/blue_current/icons.json | 5 + .../components/blue_current/services.yaml | 12 ++ .../components/blue_current/strings.json | 44 +++++++ tests/components/blue_current/__init__.py | 14 ++- tests/components/blue_current/test_init.py | 119 +++++++++++++++++- 7 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/blue_current/services.yaml diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index eeda91a70a3..5d066968873 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -13,20 +13,30 @@ from bluecurrent_api.exceptions import ( RequestLimitReached, WebsocketError, ) +import voluptuous as vol -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + ServiceValidationError, +) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( + BCU_APP, CHARGEPOINT_SETTINGS, CHARGEPOINT_STATUS, + CHARGING_CARD_ID, DOMAIN, EVSE_ID, LOGGER, PLUG_AND_CHARGE, + SERVICE_START_CHARGE_SESSION, VALUE, ) @@ -34,6 +44,7 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector] PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" +CHARGE_CARDS = "CHARGE_CARDS" DATA = "data" DELAY = 5 @@ -41,6 +52,16 @@ GRID = "GRID" OBJECT = "object" VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + # When no charging card is provided, use no charging card (BCU_APP = no charging card). + vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string, + } +) + async def async_setup_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry @@ -67,6 +88,66 @@ async def async_setup_entry( return True +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Blue Current.""" + + async def start_charge_session(service_call: ServiceCall) -> None: + """Start a charge session with the provided device and charge card ID.""" + # When no charge card is provided, use the default charge card set in the config flow. + charging_card_id = service_call.data[CHARGING_CARD_ID] + device_id = service_call.data[CONF_DEVICE_ID] + + # Get the device based on the given device ID. + device = dr.async_get(hass).devices.get(device_id) + + if device is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_device_id" + ) + + blue_current_config_entry: ConfigEntry | None = None + + for config_entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry_id) + if not config_entry or config_entry.domain != DOMAIN: + # Not the blue_current config entry. + continue + + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="config_entry_not_loaded" + ) + + blue_current_config_entry = config_entry + break + + if not blue_current_config_entry: + # The device is not connected to a valid blue_current config entry. + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="no_config_entry" + ) + + connector = blue_current_config_entry.runtime_data + + # Get the evse_id from the identifier of the device. + evse_id = next( + identifier[1] + for identifier in device.identifiers + if identifier[0] == DOMAIN + ) + + await connector.client.start_session(evse_id, charging_card_id) + + hass.services.async_register( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + start_charge_session, + SERVICE_START_CHARGE_SESSION_SCHEMA, + ) + + return True + + async def async_unload_entry( hass: HomeAssistant, config_entry: BlueCurrentConfigEntry ) -> bool: @@ -87,6 +168,7 @@ class Connector: self.client = client self.charge_points: dict[str, dict] = {} self.grid: dict[str, Any] = {} + self.charge_cards: dict[str, dict[str, Any]] = {} async def on_data(self, message: dict) -> None: """Handle received data.""" diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 33e0e8b1176..16b737730b9 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,6 +8,12 @@ LOGGER = logging.getLogger(__package__) EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +CARD = "card" +UID = "uid" +BCU_APP = "BCU-APP" +WITHOUT_CHARGING_CARD = "without_charging_card" +CHARGING_CARD_ID = "charging_card_id" +SERVICE_START_CHARGE_SESSION = "start_charge_session" PLUG_AND_CHARGE = "plug_and_charge" VALUE = "value" PERMISSION = "permission" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index 28d4acbc1d8..b8c6a5f045b 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -42,5 +42,10 @@ "default": "mdi:lock" } } + }, + "services": { + "start_charge_session": { + "service": "mdi:play" + } } } diff --git a/homeassistant/components/blue_current/services.yaml b/homeassistant/components/blue_current/services.yaml new file mode 100644 index 00000000000..70992b5f277 --- /dev/null +++ b/homeassistant/components/blue_current/services.yaml @@ -0,0 +1,12 @@ +start_charge_session: + fields: + device_id: + selector: + device: + integration: blue_current + required: true + + charging_card_id: + selector: + text: + required: false diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 0a99af603cc..9fdbd756392 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -22,6 +22,16 @@ "wrong_account": "Wrong account: Please authenticate with the API token for {email}." } }, + "options": { + "step": { + "init": { + "data": { + "card": "Card" + }, + "description": "Select the default charging card you want to use" + } + } + }, "entity": { "sensor": { "activity": { @@ -136,5 +146,39 @@ "name": "Block charge point" } } + }, + "selector": { + "select_charging_card": { + "options": { + "without_charging_card": "Without charging card" + } + } + }, + "services": { + "start_charge_session": { + "name": "Start charge session", + "description": "Starts a new charge session on a specified charge point.", + "fields": { + "charging_card_id": { + "name": "Charging card ID", + "description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used." + }, + "device_id": { + "name": "Device ID", + "description": "The ID of the Blue Current charge point." + } + } + } + }, + "exceptions": { + "invalid_device_id": { + "message": "Invalid device ID given." + }, + "config_entry_not_loaded": { + "message": "Config entry not loaded." + }, + "no_config_entry": { + "message": "Device has not a valid blue_current config entry." + } } } diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 402d644747a..420c3bdfdc5 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -10,7 +10,8 @@ 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.components.blue_current.const import PUBLIC_CHARGING, UID +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -87,6 +88,16 @@ def create_client_mock( """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def get_charge_cards() -> None: + """Send the charge cards list to the callback.""" + await client_mock.receiver( + { + "object": "CHARGE_CARDS", + "default_card": {UID: "BCU-APP", CONF_ID: "BCU-APP"}, + "cards": [{UID: "MOCK-CARD", CONF_ID: "MOCK-CARD", "valid": 1}], + } + ) + async def update_charge_point( evse_id: str, event_object: str, settings: dict[str, Any] ) -> None: @@ -100,6 +111,7 @@ def create_client_mock( 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.get_charge_cards.side_effect = get_charge_cards client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index b740e6c91f9..563a8392dc8 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -1,7 +1,7 @@ """Test Blue Current Init Component.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from bluecurrent_api.exceptions import ( BlueCurrentException, @@ -10,15 +10,24 @@ from bluecurrent_api.exceptions import ( WebsocketError, ) import pytest +from voluptuous import MultipleInvalid -from homeassistant.components.blue_current import async_setup_entry +from homeassistant.components.blue_current import ( + CHARGING_CARD_ID, + DOMAIN, + SERVICE_START_CHARGE_SESSION, + async_setup_entry, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, IntegrationError, + ServiceValidationError, ) +from homeassistant.helpers.device_registry import DeviceRegistry from . import init_integration @@ -32,6 +41,7 @@ async def test_load_unload_entry( with ( patch("homeassistant.components.blue_current.Client.validate_api_token"), patch("homeassistant.components.blue_current.Client.wait_for_charge_points"), + patch("homeassistant.components.blue_current.Client.get_charge_cards"), patch("homeassistant.components.blue_current.Client.disconnect"), patch( "homeassistant.components.blue_current.Client.connect", @@ -103,3 +113,108 @@ async def test_connect_request_limit_reached_error( await started_loop.wait() assert mock_client.get_next_reset_delta.call_count == 1 assert mock_client.connect.call_count == 2 + + +async def test_start_charging_action( + hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry +) -> None: + """Test the start charing action when a charging card is provided.""" + integration = await init_integration(hass, config_entry, Platform.BUTTON) + client = integration[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + CHARGING_CARD_ID: "TEST_CARD", + }, + blocking=True, + ) + + client.start_session.assert_called_once_with("101", "TEST_CARD") + + +async def test_start_charging_action_without_card( + hass: HomeAssistant, config_entry: MockConfigEntry, device_registry: DeviceRegistry +) -> None: + """Test the start charing action when no charging card is provided.""" + integration = await init_integration(hass, config_entry, Platform.BUTTON) + client = integration[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) + + client.start_session.assert_called_once_with("101", "BCU-APP") + + +async def test_start_charging_action_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test the start charing action errors.""" + await init_integration(hass, config_entry, Platform.BUTTON) + + with pytest.raises(MultipleInvalid): + # No device id + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + {}, + blocking=True, + ) + + with pytest.raises(ServiceValidationError): + # Invalid device id + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + {CONF_DEVICE_ID: "INVALID"}, + blocking=True, + ) + + # Test when the device is not connected to a valid blue_current config entry. + get_entry_mock = MagicMock() + get_entry_mock.state = ConfigEntryState.LOADED + + with ( + patch.object( + hass.config_entries, "async_get_entry", return_value=get_entry_mock + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + ) + + # Test when the blue_current config entry is not loaded. + get_entry_mock = MagicMock() + get_entry_mock.domain = DOMAIN + get_entry_mock.state = ConfigEntryState.NOT_LOADED + + with ( + patch.object( + hass.config_entries, "async_get_entry", return_value=get_entry_mock + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_CHARGE_SESSION, + { + CONF_DEVICE_ID: list(device_registry.devices)[0], + }, + blocking=True, + )