diff --git a/homeassistant/components/nexblue/const.py b/homeassistant/components/nexblue/const.py index 5a0e734aecaf..b158ea663197 100644 --- a/homeassistant/components/nexblue/const.py +++ b/homeassistant/components/nexblue/const.py @@ -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) diff --git a/homeassistant/components/nexblue/coordinator.py b/homeassistant/components/nexblue/coordinator.py index b504e26391ff..0e88bafe7cff 100644 --- a/homeassistant/components/nexblue/coordinator.py +++ b/homeassistant/components/nexblue/coordinator.py @@ -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.""" diff --git a/homeassistant/components/nexblue/strings.json b/homeassistant/components/nexblue/strings.json index 0f2055a6102e..caaf0f16d717 100644 --- a/homeassistant/components/nexblue/strings.json +++ b/homeassistant/components/nexblue/strings.json @@ -74,6 +74,11 @@ } }, "voltage": { "name": "Voltage {phase}" } + }, + "switch": { + "charging": { + "name": "Charging" + } } } } diff --git a/homeassistant/components/nexblue/switch.py b/homeassistant/components/nexblue/switch.py new file mode 100644 index 000000000000..14ab3bdc6113 --- /dev/null +++ b/homeassistant/components/nexblue/switch.py @@ -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) diff --git a/tests/components/nexblue/snapshots/test_switch.ambr b/tests/components/nexblue/snapshots/test_switch.ambr new file mode 100644 index 000000000000..38bed90b189e --- /dev/null +++ b/tests/components/nexblue/snapshots/test_switch.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.nb123456_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'NB123456 Charging', + }), + 'context': , + 'entity_id': 'switch.nb123456_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nexblue/test_sensor.py b/tests/components/nexblue/test_sensor.py index 099938206709..e96434993ef8 100644 --- a/tests/components/nexblue/test_sensor.py +++ b/tests/components/nexblue/test_sensor.py @@ -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, diff --git a/tests/components/nexblue/test_switch.py b/tests/components/nexblue/test_switch.py new file mode 100644 index 000000000000..0e4ba763ac5d --- /dev/null +++ b/tests/components/nexblue/test_switch.py @@ -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