Add switches for blue current integration. (#146210)

This commit is contained in:
Nick Kuiper 2025-07-23 15:12:53 +02:00 committed by GitHub
parent 6d3872252b
commit 1c8ae8a21b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 409 additions and 24 deletions

View File

@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import (
) )
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send 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] type BlueCurrentConfigEntry = ConfigEntry[Connector]
PLATFORMS = [Platform.BUTTON, Platform.SENSOR] PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
CHARGE_POINTS = "CHARGE_POINTS" CHARGE_POINTS = "CHARGE_POINTS"
DATA = "data" DATA = "data"
DELAY = 5 DELAY = 5
GRID = "GRID" GRID = "GRID"
OBJECT = "object" OBJECT = "object"
VALUE_TYPES = ["CH_STATUS"] VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
async def async_setup_entry( async def async_setup_entry(
@ -94,7 +102,7 @@ class Connector:
elif object_name in VALUE_TYPES: elif object_name in VALUE_TYPES:
value_data: dict = message[DATA] value_data: dict = message[DATA]
evse_id = value_data.pop(EVSE_ID) 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 # gets grid key / values
elif GRID in object_name: elif GRID in object_name:
@ -106,26 +114,37 @@ class Connector:
"""Handle incoming chargepoint data.""" """Handle incoming chargepoint data."""
await asyncio.gather( await asyncio.gather(
*( *(
self.handle_charge_point( self.handle_charge_point(entry[EVSE_ID], entry)
entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME]
)
for entry in charge_points_data for entry in charge_points_data
), ),
self.client.get_grid_status(charge_points_data[0][EVSE_ID]), 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.""" """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) 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.""" """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.""" """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) self.dispatch_charge_point_update_signal(evse_id)
def dispatch_charge_point_update_signal(self, evse_id: str) -> None: def dispatch_charge_point_update_signal(self, evse_id: str) -> None:

View File

@ -8,3 +8,14 @@ LOGGER = logging.getLogger(__package__)
EVSE_ID = "evse_id" EVSE_ID = "evse_id"
MODEL_TYPE = "model_type" 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"

View File

@ -30,6 +30,17 @@
"stop_charge_session": { "stop_charge_session": {
"default": "mdi:stop" "default": "mdi:stop"
} }
},
"switch": {
"plug_and_charge": {
"default": "mdi:ev-plug-type2"
},
"linked_charge_cards": {
"default": "mdi:account-group"
},
"block": {
"default": "mdi:lock"
}
} }
} }
} }

View File

@ -124,6 +124,17 @@
"reset": { "reset": {
"name": "Reset" "name": "Reset"
} }
},
"switch": {
"plug_and_charge": {
"name": "Plug & Charge"
},
"linked_charge_cards_only": {
"name": "Linked charging cards only"
},
"block": {
"name": "Block charge point"
}
} }
} }
} }

View File

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

View File

@ -4,18 +4,28 @@ from __future__ import annotations
from asyncio import Event, Future from asyncio import Event, Future
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from bluecurrent_api import Client 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 homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry 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 = { DEFAULT_CHARGE_POINT = {
"evse_id": "101", "evse_id": "101",
"model_type": "", "model_type": "",
"name": "", "name": "",
"activity": "available",
**DEFAULT_CHARGE_POINT_OPTIONS,
} }
@ -77,11 +87,20 @@ def create_client_mock(
"""Send the grid status to the callback.""" """Send the grid status to the callback."""
await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) 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.connect.side_effect = connect
client_mock.wait_for_charge_points.side_effect = wait_for_charge_points 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_charge_points.side_effect = get_charge_points
client_mock.get_status.side_effect = get_status client_mock.get_status.side_effect = get_status
client_mock.get_grid_status.side_effect = get_grid_status client_mock.get_grid_status.side_effect = get_grid_status
client_mock.update_charge_point = update_charge_point
return client_mock return client_mock

View File

@ -7,17 +7,10 @@ import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import init_integration from . import DEFAULT_CHARGE_POINT, init_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
charge_point = {
"evse_id": "101",
"model_type": "",
"name": "",
}
charge_point_status = { charge_point_status = {
"actual_v1": 14, "actual_v1": 14,
"actual_v2": 18, "actual_v2": 18,
@ -97,7 +90,7 @@ async def test_sensors_created(
hass, hass,
config_entry, config_entry,
"sensor", "sensor",
charge_point, DEFAULT_CHARGE_POINT,
charge_point_status | charge_point_status_timestamps, charge_point_status | charge_point_status_timestamps,
grid, grid,
) )
@ -116,7 +109,7 @@ async def test_sensors(
) -> None: ) -> None:
"""Test the underlying sensors.""" """Test the underlying sensors."""
await init_integration( 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(): for entity_id, key in charge_point_entity_ids.items():

View File

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