Add charging switch to NexBlue integration (#180372)

This commit is contained in:
Kristy
2026-09-08 21:15:37 +02:00
committed by GitHub
parent 269e533991
commit cbf2b95597
7 changed files with 665 additions and 4 deletions
+1 -1
View File
@@ -9,5 +9,5 @@ DOMAIN = "nexblue"
CONF_REFRESH_TOKEN = "refresh_token"
DEFAULT_API_URL = "https://api.nexblue.com/third_party"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
UPDATE_INTERVAL = timedelta(minutes=1)
@@ -1,5 +1,7 @@
"""Data update coordinator for NexBlue."""
from collections.abc import Callable
from datetime import datetime
from typing import override
from nexblue_api import (
@@ -14,14 +16,22 @@ from nexblue_api.models import ChargerStatus
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_REFRESH_TOKEN, LOGGER, UPDATE_INTERVAL
type NexBlueConfigEntry = ConfigEntry["NexBlueDataUpdateCoordinator"]
INITIAL_COMMAND_REFRESH_DELAY = 3
FINAL_COMMAND_REFRESH_DELAY = 20
COMMAND_REFRESH_DELAYS = (
INITIAL_COMMAND_REFRESH_DELAY,
FINAL_COMMAND_REFRESH_DELAY,
)
class NexBlueDataUpdateCoordinator(
DataUpdateCoordinator[dict[str, ChargerStatus | None]]
@@ -38,6 +48,8 @@ class NexBlueDataUpdateCoordinator(
) -> None:
"""Initialize the coordinator."""
self.client = client
self._pending_command_refreshes: dict[str, set[Callable[[], None]]] = {}
entry.async_on_unload(self.async_cancel_pending_command_refreshes)
super().__init__(
hass,
LOGGER,
@@ -46,6 +58,55 @@ class NexBlueDataUpdateCoordinator(
update_interval=UPDATE_INTERVAL,
)
@callback
def async_schedule_command_refreshes(self, serial_number: str) -> None:
"""Schedule shared follow-up refreshes after a charger command."""
self.async_cancel_pending_command_refreshes(serial_number)
pending_refreshes: set[Callable[[], None]] = set()
self._pending_command_refreshes[serial_number] = pending_refreshes
def _schedule_refresh(delay: int) -> None:
cancel: Callable[[], None] | None = None
@callback
def _request_refresh(_now: datetime) -> None:
"""Request coordinator data after a charger command."""
if cancel is not None:
pending_refreshes.discard(cancel)
if not pending_refreshes:
self._pending_command_refreshes.pop(serial_number, None)
self.config_entry.async_create_task(
self.hass,
self.async_request_refresh(),
name="NexBlue command refresh",
)
cancel = async_call_later(self.hass, delay, _request_refresh)
pending_refreshes.add(cancel)
for delay in COMMAND_REFRESH_DELAYS:
_schedule_refresh(delay)
@callback
def async_cancel_pending_command_refreshes(
self, serial_number: str | None = None
) -> None:
"""Cancel pending command refreshes for a charger or the whole entry."""
if serial_number is None:
pending_refreshes = [
cancel
for refreshes in self._pending_command_refreshes.values()
for cancel in refreshes
]
self._pending_command_refreshes.clear()
else:
pending_refreshes = list(
self._pending_command_refreshes.pop(serial_number, set())
)
for cancel in pending_refreshes:
cancel()
@override
async def _async_update_data(self) -> dict[str, ChargerStatus | None]:
"""Fetch status for every charger visible to the configured account."""
@@ -74,6 +74,11 @@
}
},
"voltage": { "name": "Voltage {phase}" }
},
"switch": {
"charging": {
"name": "Charging"
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
"""Switches for the NexBlue integration."""
import time
from typing import Any, override
from nexblue_api import NexBlueError
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import (
FINAL_COMMAND_REFRESH_DELAY,
NexBlueConfigEntry,
NexBlueDataUpdateCoordinator,
)
PARALLEL_UPDATES = 1
ACTIVE_CHARGING_STATES = frozenset(
{
2, # Charging
5, # Waiting for available power
7, # Waiting for car response
}
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NexBlueConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a charging switch for every discovered charger."""
coordinator = entry.runtime_data
async_add_entities(
NexBlueChargingSwitch(coordinator, serial_number)
for serial_number in coordinator.data
)
class NexBlueChargingSwitch(
CoordinatorEntity[NexBlueDataUpdateCoordinator], SwitchEntity
):
"""Control whether a NexBlue charger is actively charging."""
_attr_has_entity_name = True
_attr_translation_key = "charging"
def __init__(
self,
coordinator: NexBlueDataUpdateCoordinator,
serial_number: str,
) -> None:
"""Initialize the charging switch."""
super().__init__(coordinator)
self._serial_number = serial_number
self._assumed_is_on: bool | None = None
self._assumed_state_confirm_after = 0.0
self._attr_unique_id = f"{serial_number}_charging"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
manufacturer="NexBlue",
name=serial_number,
serial_number=serial_number,
)
@property
@override
def available(self) -> bool:
"""Return whether this charger is currently reachable."""
return (
super().available
and self.coordinator.data.get(self._serial_number) is not None
)
@property
@override
def assumed_state(self) -> bool:
"""Return whether the charging state is currently assumed."""
return self._assumed_is_on is not None
@property
@override
def is_on(self) -> bool:
"""Return whether the charger is actively charging."""
assumed_is_on = self._assumed_is_on
if assumed_is_on is not None:
return assumed_is_on
status = self.coordinator.data.get(self._serial_number)
if status is None:
return False
return status.charging_state in ACTIVE_CHARGING_STATES
@callback
@override
def _handle_coordinator_update(self) -> None:
"""Clear an assumed state once a successful refresh confirms it."""
status = self.coordinator.data.get(self._serial_number)
assumed_is_on = self._assumed_is_on
if (
self.coordinator.last_update_success
and status is not None
and assumed_is_on is not None
and (
(status.charging_state in ACTIVE_CHARGING_STATES) == assumed_is_on
or time.monotonic() >= self._assumed_state_confirm_after
)
):
self._assumed_is_on = None
super()._handle_coordinator_update()
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start charging."""
await self._async_set_charging(True)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop charging."""
await self._async_set_charging(False)
async def _async_set_charging(self, should_charge: bool) -> None:
"""Send a command, immediately update state, and refresh status."""
if self.assumed_state and self._assumed_is_on == should_charge:
return
try:
if should_charge:
await self.coordinator.client.async_start_charging(self._serial_number)
else:
await self.coordinator.client.async_stop_charging(self._serial_number)
except NexBlueError as err:
raise HomeAssistantError(str(err)) from err
self._assumed_is_on = should_charge
self._assumed_state_confirm_after = (
time.monotonic() + FINAL_COMMAND_REFRESH_DELAY
)
self.async_write_ha_state()
self.coordinator.async_schedule_command_refreshes(self._serial_number)
@@ -0,0 +1,51 @@
# serializer version: 1
# name: test_switch_entities_snapshot[switch.nb123456_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.nb123456_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Charging',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Charging',
'platform': 'nexblue',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'charging',
'unique_id': 'NB123456_charging',
'unit_of_measurement': None,
})
# ---
# name: test_switch_entities_snapshot[switch.nb123456_charging-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'NB123456 Charging',
}),
'context': <ANY>,
'entity_id': 'switch.nb123456_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
+10 -2
View File
@@ -1,15 +1,16 @@
"""Tests for NexBlue sensors."""
from collections.abc import Generator
from dataclasses import replace
from datetime import timedelta
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from nexblue_api import NexBlueConnectionError, NexBlueDeviceOfflineError, NexBlueError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -18,6 +19,13 @@ from .conftest import CHARGER, CHARGER_STATUS
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
def fixture_platforms() -> Generator[None]:
"""Limit this module's setup to the sensor platform."""
with patch("homeassistant.components.nexblue.PLATFORMS", [Platform.SENSOR]):
yield
async def test_sensor_entities_snapshot(
hass: HomeAssistant,
init_integration: MockConfigEntry,
+390
View File
@@ -0,0 +1,390 @@
"""Tests for NexBlue switches."""
from collections.abc import Generator
from dataclasses import replace
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from nexblue_api import NexBlueError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.nexblue.const import DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import CHARGER, CHARGER_STATUS
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture(autouse=True)
def fixture_platforms() -> Generator[None]:
"""Limit this module's setup to the switch platform."""
with patch("homeassistant.components.nexblue.PLATFORMS", [Platform.SWITCH]):
yield
async def test_switch_entities_snapshot(
hass: HomeAssistant,
init_integration: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the complete NexBlue switch platform through a snapshot."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
init_integration.entry_id,
)
async def test_charger_removed_from_list_becomes_unavailable(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test a charger missing from a refresh does not raise an exception."""
mock_client.async_list_chargers.return_value = []
await init_integration.runtime_data.async_refresh()
assert hass.states.get("switch.nb123456_charging").state == STATE_UNAVAILABLE
async def test_turn_on_starts_charging(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test turning on sends the start command and updates the state."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
mock_client.async_start_charging.assert_awaited_once_with("NB123456")
assert hass.states.get(entity_id).state == "on"
async def test_turn_off_stops_charging(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test turning off sends the stop command and updates the state."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
mock_client.async_stop_charging.assert_awaited_once_with("NB123456")
assert hass.states.get(entity_id).state == "off"
@pytest.mark.parametrize(
("service", "method"),
[
("turn_on", "async_start_charging"),
("turn_off", "async_stop_charging"),
],
)
async def test_repeated_assumed_command_is_ignored(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
service: str,
method: str,
) -> None:
"""Test a repeated command is ignored while its state is assumed."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{"entity_id": entity_id},
blocking=True,
)
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{"entity_id": entity_id},
blocking=True,
)
getattr(mock_client, method).assert_awaited_once_with("NB123456")
async def test_command_error_is_reported(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test a rejected command is reported as a Home Assistant error."""
mock_client.async_start_charging.side_effect = NexBlueError(
"The charger rejected the command"
)
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
with pytest.raises(HomeAssistantError, match="rejected the command"):
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
async def test_assumed_state_clears_after_confirmed_refresh(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a confirmed refresh clears the assumed switch state early."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
mock_client.async_get_charger_status.return_value = replace(
CHARGER_STATUS, charging_state=0
)
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
assert hass.states.get(entity_id).attributes[ATTR_ASSUMED_STATE] is True
freezer.tick(timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.state == "off"
assert not state.attributes.get(ATTR_ASSUMED_STATE)
async def test_assumed_state_clears_after_final_command_refresh(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the final command refresh replaces an unconfirmed assumed state."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
mock_client.async_list_chargers.reset_mock()
mock_client.async_get_charger_status.reset_mock()
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
assert hass.states.get(entity_id).state == "off"
assert hass.states.get(entity_id).attributes[ATTR_ASSUMED_STATE] is True
assert mock_client.async_list_chargers.await_count == 0
assert mock_client.async_get_charger_status.await_count == 0
freezer.tick(timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_client.async_list_chargers.await_count == 1
assert mock_client.async_get_charger_status.await_count == 1
assert hass.states.get(entity_id).attributes[ATTR_ASSUMED_STATE] is True
freezer.tick(timedelta(seconds=17))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_client.async_list_chargers.await_count == 2
assert mock_client.async_get_charger_status.await_count == 2
assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE)
assert hass.states.get(entity_id).state == "on"
async def test_new_command_replaces_pending_command_refreshes(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a new command replaces the previous command's refreshes."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
mock_client.async_list_chargers.reset_mock()
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
freezer.tick(timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_client.async_list_chargers.await_count == 1
async def test_command_keeps_other_charger_pending_refreshes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a command does not cancel another charger's pending refreshes."""
second_charger = replace(CHARGER, serial_number="NB654321")
mock_client.async_list_chargers.return_value = [CHARGER, second_charger]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
coordinator = mock_config_entry.runtime_data
first_entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
second_entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB654321_charging"
)
assert first_entity_id
assert second_entity_id
with patch.object(coordinator, "async_request_refresh") as mock_refresh:
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{"entity_id": first_entity_id},
blocking=True,
)
freezer.tick(timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_refresh.await_count == 1
freezer.tick(timedelta(seconds=7))
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_off",
{"entity_id": second_entity_id},
blocking=True,
)
freezer.tick(timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_refresh.await_count == 2
freezer.tick(timedelta(seconds=7))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_refresh.await_count == 3
async def test_pending_command_refreshes_cancelled_on_unload(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test pending command refreshes do not run after unloading the entry."""
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, "NB123456_charging"
)
assert entity_id
mock_client.async_list_chargers.reset_mock()
await hass.services.async_call(
SWITCH_DOMAIN,
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
assert mock_client.async_list_chargers.await_count == 0
assert await hass.config_entries.async_unload(init_integration.entry_id)
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_client.async_list_chargers.await_count == 0
@pytest.mark.parametrize(
("charging_state", "expected_state"),
[
(5, "on"),
(6, "off"),
(7, "on"),
],
)
async def test_charging_session_states(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
charging_state: int,
expected_state: str,
) -> None:
"""Test switch state reflects whether a charging session is active."""
mock_client.async_get_charger_status.return_value = replace(
CHARGER_STATUS, charging_state=charging_state
)
await init_integration.runtime_data.async_refresh()
assert hass.states.get("switch.nb123456_charging").state == expected_state