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.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:

View File

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

View File

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

View File

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

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

View File

@ -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():

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