mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Add charging switch to NexBlue integration (#180372)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user