1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 21:06:19 +00:00

Add start charge session action for blue current integration. (#145446)

This commit is contained in:
Nick Kuiper
2025-09-24 08:11:33 +02:00
committed by GitHub
parent 32aacac550
commit ddea2206c3
7 changed files with 283 additions and 7 deletions

View File

@@ -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."""

View File

@@ -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"

View File

@@ -42,5 +42,10 @@
"default": "mdi:lock"
}
}
},
"services": {
"start_charge_session": {
"service": "mdi:play"
}
}
}

View File

@@ -0,0 +1,12 @@
start_charge_session:
fields:
device_id:
selector:
device:
integration: blue_current
required: true
charging_card_id:
selector:
text:
required: false

View File

@@ -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."
}
}
}

View File

@@ -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

View File

@@ -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,
)