From acb9ea7e0e6c821759040e84f2ed258daf5786b5 Mon Sep 17 00:00:00 2001 From: Michiel Van Cauwenberghe Date: Sat, 12 Sep 2026 12:13:04 +0200 Subject: [PATCH] Add cable lock/unlock support for socket-connector charging stations Expose a lock entity backed by the Dashboard v11 cableLocked property (PATCH /v11/chargingstations/{serial}), mirroring the existing availability/offline-charging switches. The entity is only marked available when the Dashboard API actually reports a cableLocked value, since this feature only applies to socket-connector stations and not fixed-cable models. Co-Authored-By: Claude Sonnet 5 --- README.md | 5 + custom_components/smappee_ev/__init__.py | 1 + .../smappee_ev/api/dashboard_client.py | 10 + .../smappee_ev/api/device_handle.py | 33 +++ .../coordinators/dashboard_merge.py | 3 + custom_components/smappee_ev/lock.py | 154 ++++++++++ custom_components/smappee_ev/models/state.py | 1 + custom_components/smappee_ev/strings.json | 5 + .../smappee_ev/translations/de.json | 5 + .../smappee_ev/translations/en.json | 5 + .../smappee_ev/translations/fr.json | 5 + .../smappee_ev/translations/nl.json | 5 + docs/HA_integration.md | 1 + tests/test_coordinator.py | 2 + tests/test_dashboard_client.py | 22 ++ tests/test_device_handle.py | 41 +++ tests/test_lock.py | 262 ++++++++++++++++++ 17 files changed, 560 insertions(+) create mode 100644 custom_components/smappee_ev/lock.py create mode 100644 tests/test_lock.py diff --git a/README.md b/README.md index a9f7d93..d3a59e5 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,11 @@ configuration. Diagnostics do not include these credentials. - Adjust LED ring brightness (%) via Dashboard v10 config writes when a Dashboard token is available. +### ✅ Cable Lock Control + +- Lock/unlock the charging cable in the connector socket via Dashboard v11 (`cableLocked`), matching the dashboard's charger configuration Lock/Unlock button. +- Only available on charging stations with a socket connector (not fixed-cable models). The `lock` entity reports as unavailable when the Dashboard API never reports a cable lock state for your station. + ### ✅ Charger State Feedback - Real-time **Session State**: diff --git a/custom_components/smappee_ev/__init__.py b/custom_components/smappee_ev/__init__.py index b91285f..60c2c3c 100644 --- a/custom_components/smappee_ev/__init__.py +++ b/custom_components/smappee_ev/__init__.py @@ -58,6 +58,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, + Platform.LOCK, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/custom_components/smappee_ev/api/dashboard_client.py b/custom_components/smappee_ev/api/dashboard_client.py index 4197f0b..cc4ff2c 100644 --- a/custom_components/smappee_ev/api/dashboard_client.py +++ b/custom_components/smappee_ev/api/dashboard_client.py @@ -457,6 +457,16 @@ async def async_set_charger_availability(self, serial: str, available: bool) -> ) ) + async def async_set_cable_lock(self, serial: str, locked: bool) -> bool: + return bool( + await self._request( + "PATCH", + f"v11/chargingstations/{serial}", + json={"cableLocked": bool(locked)}, + expected=(200, 201, 204), + ) + ) + async def async_restart_charging_station(self, serial: str) -> bool: return bool( await self._request( diff --git a/custom_components/smappee_ev/api/device_handle.py b/custom_components/smappee_ev/api/device_handle.py index ebd1669..1b5c335 100644 --- a/custom_components/smappee_ev/api/device_handle.py +++ b/custom_components/smappee_ev/api/device_handle.py @@ -127,6 +127,27 @@ async def _dashboard_charger_availability(self, available: bool) -> bool | None: raise RuntimeError("Dashboard charger availability returned no success") return True + async def _dashboard_cable_lock(self, locked: bool) -> bool | None: + self._check_write_allowed() + if not self._dashboard_configured(): + return None + dashboard = self.dashboard_client + station_serial = self.charging_station_serial or self.serial + method = getattr(dashboard, "async_set_cable_lock", None) + if method is None: + raise RuntimeError("Dashboard cable lock action is not available") + try: + success = bool(await method(station_serial, locked)) + except asyncio.CancelledError: + raise + except ConfigEntryAuthFailed: + raise + except (aiohttp.ClientError, RuntimeError, TimeoutError, TypeError, ValueError) as err: + raise RuntimeError(f"Dashboard cable lock failed for station {station_serial}") from err + if not success: + raise RuntimeError("Dashboard cable lock returned no success") + return True + async def _dashboard_charging_station_restart(self) -> bool | None: self._check_write_allowed() if not self._dashboard_configured(): @@ -311,6 +332,18 @@ async def set_unavailable(self) -> None: return raise RuntimeError("Dashboard API is not configured for charger availability") + async def set_cable_locked(self) -> None: + if await self._dashboard_cable_lock(True): + _LOGGER.debug("Locked charging cable successfully via Dashboard v11") + return + raise RuntimeError("Dashboard API is not configured for cable lock") + + async def set_cable_unlocked(self) -> None: + if await self._dashboard_cable_lock(False): + _LOGGER.debug("Unlocked charging cable successfully via Dashboard v11") + return + raise RuntimeError("Dashboard API is not configured for cable lock") + async def restart_charging_station(self) -> None: if await self._dashboard_charging_station_restart(): _LOGGER.debug("Restarted charging station successfully via Dashboard v11") diff --git a/custom_components/smappee_ev/coordinators/dashboard_merge.py b/custom_components/smappee_ev/coordinators/dashboard_merge.py index d3995f1..ffdbddd 100644 --- a/custom_components/smappee_ev/coordinators/dashboard_merge.py +++ b/custom_components/smappee_ev/coordinators/dashboard_merge.py @@ -288,6 +288,9 @@ def _merge_dashboard_station_details( changed |= self._set_if_changed( station, "maximum_capacity_a", self._as_int(details.get("maximumCapacity")) ) + changed |= self._set_if_changed( + station, "cable_locked", self._as_bool(details.get("cableLocked")) + ) changed |= self._set_if_changed(station, "dashboard_charging_station_details", details) offline = details.get("offlineCharging") diff --git a/custom_components/smappee_ev/lock.py b/custom_components/smappee_ev/lock.py new file mode 100644 index 0000000..52c60f1 --- /dev/null +++ b/custom_components/smappee_ev/lock.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import logging +from typing import Any, override + +from aiohttp import ClientError +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .api.device_handle import SmappeeDeviceHandle +from .api.errors import SmappeeError +from .const import DOMAIN +from .coordinator import SmappeeCoordinator +from .entity import SmappeeStationRestEntity +from .models.runtime_data import SmappeeEvConfigEntry +from .models.state import IntegrationData, StationState + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SmappeeEvConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Smappee EV locks (multi-station).""" + runtime = config_entry.runtime_data + + entities: list[LockEntity] = [] + for sid, site in (runtime.sites or {}).items(): + sid_int = int(sid) + for st_uuid, bucket in site.stations.items(): + coord: SmappeeCoordinator | None = bucket.station_coordinator + st_client: SmappeeDeviceHandle | None = bucket.station_client + + if coord is not None and st_client is not None: + entities.append( + SmappeeCableLock( + coordinator=coord, + api_client=st_client, + sid=sid_int, + station_uuid=st_uuid, + ) + ) + + async_add_entities(entities, False) + + +class SmappeeCableLock(SmappeeStationRestEntity, LockEntity): + """Lock to control the charging station's cable lock (socket version).""" + + _attr_has_entity_name = True + _attr_translation_key = "cable_lock" + + def __init__( + self, + *, + coordinator: SmappeeCoordinator, + api_client: SmappeeDeviceHandle, + sid: int, + station_uuid: str, + ) -> None: + SmappeeStationRestEntity.__init__( + self, + coordinator, + sid, + station_uuid, + unique_suffix="lock:cable_lock", + ) + self.api_client = api_client + + def _station_state(self) -> StationState | None: + data: IntegrationData | None = self.coordinator.data + return data.station if data else None + + @property + @override + def available(self) -> bool: + """Hide as unavailable on stations that never report a cable lock state. + + The Smappee dashboard only exposes ``cableLocked`` for stations with a + lockable socket connector (not fixed-cable models), and this integration + has no reliable way to tell those apart by model name alone. + """ + if not super().available: + return False + st = self._station_state() + return st is not None and st.cable_locked is not None + + @property + @override + def is_locked(self) -> bool | None: + st = self._station_state() + return getattr(st, "cable_locked", None) if st else None + + @override + async def async_lock(self, **kwargs: Any) -> None: + await self._set_locked(True) + + @override + async def async_unlock(self, **kwargs: Any) -> None: + await self._set_locked(False) + + async def _set_locked(self, value: bool) -> None: + data: IntegrationData | None = self.coordinator.data + st = self._station_state() + if data is None or st is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="station_unavailable", + ) + + prev = st.cable_locked + + if prev != value: + st.cable_locked = value + self.coordinator.async_set_updated_data(data) + + try: + if value: + await self.api_client.set_cable_locked() + else: + await self.api_client.set_cable_unlocked() + self.coordinator.async_schedule_dashboard_refresh() + except ConfigEntryAuthFailed: + st.cable_locked = prev + self.coordinator.async_set_updated_data(data) + raise + except ( + SmappeeError, + ClientError, + TimeoutError, + HomeAssistantError, + UpdateFailed, + RuntimeError, + ValueError, + ) as err: + _LOGGER.warning("Set cable lock failed (sid=%s): %s", self._sid, err) + st.cable_locked = prev + self.coordinator.async_set_updated_data(data) + if isinstance(err, HomeAssistantError): + raise + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="station_service_failed", + translation_placeholders={ + "method_name": "set_cable_locked" if value else "set_cable_unlocked", + "error": str(err), + }, + ) from err diff --git a/custom_components/smappee_ev/models/state.py b/custom_components/smappee_ev/models/state.py index 1e9a365..6ac7a18 100644 --- a/custom_components/smappee_ev/models/state.py +++ b/custom_components/smappee_ev/models/state.py @@ -92,6 +92,7 @@ class StationState: maximum_capacity_a: int | None = None offline_charging_enabled: bool | None = None offline_failsafe_current_a: int | None = None + cable_locked: bool | None = None capacity_protection_active: bool | None = None capacity_maximum_power_kw: float | None = None overload_protection_active: bool | None = None diff --git a/custom_components/smappee_ev/strings.json b/custom_components/smappee_ev/strings.json index 2047cb2..db36988 100644 --- a/custom_components/smappee_ev/strings.json +++ b/custom_components/smappee_ev/strings.json @@ -300,6 +300,11 @@ "name": "Offline charging" } }, + "lock": { + "cable_lock": { + "name": "Cable lock" + } + }, "light": { "led": { "name": "LED" diff --git a/custom_components/smappee_ev/translations/de.json b/custom_components/smappee_ev/translations/de.json index 9ed7dce..044b785 100644 --- a/custom_components/smappee_ev/translations/de.json +++ b/custom_components/smappee_ev/translations/de.json @@ -294,6 +294,11 @@ "name": "Offline-Laden" } }, + "lock": { + "cable_lock": { + "name": "Kabelverriegelung" + } + }, "light": { "led": { "name": "LED" diff --git a/custom_components/smappee_ev/translations/en.json b/custom_components/smappee_ev/translations/en.json index c843ba6..a7ece56 100644 --- a/custom_components/smappee_ev/translations/en.json +++ b/custom_components/smappee_ev/translations/en.json @@ -300,6 +300,11 @@ "name": "Offline charging" } }, + "lock": { + "cable_lock": { + "name": "Cable lock" + } + }, "light": { "led": { "name": "LED" diff --git a/custom_components/smappee_ev/translations/fr.json b/custom_components/smappee_ev/translations/fr.json index ccf0271..0fa445d 100644 --- a/custom_components/smappee_ev/translations/fr.json +++ b/custom_components/smappee_ev/translations/fr.json @@ -294,6 +294,11 @@ "name": "Charge hors ligne" } }, + "lock": { + "cable_lock": { + "name": "Verrouillage du câble" + } + }, "light": { "led": { "name": "LED" diff --git a/custom_components/smappee_ev/translations/nl.json b/custom_components/smappee_ev/translations/nl.json index 00afe8c..1c5a4bd 100644 --- a/custom_components/smappee_ev/translations/nl.json +++ b/custom_components/smappee_ev/translations/nl.json @@ -297,6 +297,11 @@ "name": "Offline laden" } }, + "lock": { + "cable_lock": { + "name": "Kabelvergrendeling" + } + }, "light": { "led": { "name": "LED" diff --git a/docs/HA_integration.md b/docs/HA_integration.md index a641662..435d6c8 100644 --- a/docs/HA_integration.md +++ b/docs/HA_integration.md @@ -165,6 +165,7 @@ Defines the maximum current in Ampere for the connector. This number is used to | `switch.smappee_ev_YOURSERIAL_charging_1` | EVCC-friendly switch: on sets `STANDARD`, off pauses charging. | | `button.smappee_ev_YOURSERIAL_restart_charging_station` | Restart the charging station. | | `switch.smappee_ev_YOURSERIAL_offline_charging` | Enable offline charging mode, in case the Smappee is offline. | +| `lock.smappee_ev_YOURSERIAL_cable_lock` | Permanently lock/unlock the charging cable in the connector socket. Only available on charging stations with a socket connector (not fixed-cable models); shows as unavailable otherwise. | ## Sensor Entities diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 41a1c10..8214dba 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -285,6 +285,7 @@ async def test_dashboard_refresh_merges_config_without_live_overwrite(self, coor "available": True, "features": ["SOLAR_SURPLUS_CHARGING", "MAX_CURRENT"], "maximumCapacity": 25, + "cableLocked": True, "offlineCharging": {"enabled": True, "failSafe": 6}, "modules": [ { @@ -367,6 +368,7 @@ async def test_dashboard_refresh_merges_config_without_live_overwrite(self, coor assert changed assert data.station.station_features == ["SOLAR_SURPLUS_CHARGING", "MAX_CURRENT"] assert data.station.maximum_capacity_a == 25 + assert data.station.cable_locked is True assert data.station.offline_charging_enabled is True assert data.station.capacity_protection_active is True assert data.station.capacity_maximum_power_kw == 5.0 diff --git a/tests/test_dashboard_client.py b/tests/test_dashboard_client.py index 8989f50..8ad605f 100644 --- a/tests/test_dashboard_client.py +++ b/tests/test_dashboard_client.py @@ -848,6 +848,28 @@ async def test_dashboard_charger_availability_uses_v11_patch(): ) +@pytest.mark.asyncio +async def test_dashboard_set_cable_lock_uses_v11_patch(): + client = SmappeeDashboardClient( + username=None, + password=None, + refresh_token=None, + session=MagicMock(), + token_update_callback=MagicMock(), + ) + client._request = AsyncMock(return_value=True) + + ok = await client.async_set_cable_lock("STATIONSERIAL", True) + + assert ok is True + client._request.assert_awaited_once_with( + "PATCH", + "v11/chargingstations/STATIONSERIAL", + json={"cableLocked": True}, + expected=(200, 201, 204), + ) + + @pytest.mark.asyncio async def test_dashboard_restart_charging_station_uses_v11_post(): client = SmappeeDashboardClient( diff --git a/tests/test_device_handle.py b/tests/test_device_handle.py index da8b4df..43c8395 100644 --- a/tests/test_device_handle.py +++ b/tests/test_device_handle.py @@ -76,6 +76,9 @@ async def async_set_percentage_limit( async def async_set_charger_availability(self, serial: str, available: bool) -> bool: return await self._record("async_set_charger_availability", serial, available) + async def async_set_cable_lock(self, serial: str, locked: bool) -> bool: + return await self._record("async_set_cable_lock", serial, locked) + async def async_restart_charging_station(self, serial: str) -> bool: return await self._record("async_restart_charging_station", serial) @@ -241,6 +244,10 @@ async def test_dashboard_authentication_errors_are_never_wrapped_or_converted_to with pytest.raises(ConfigEntryAuthFailed, match="reauth required"): await client.set_available() + dashboard.async_set_cable_lock = AsyncMock(side_effect=auth_error) + with pytest.raises(ConfigEntryAuthFailed, match="reauth required"): + await client.set_cable_locked() + dashboard.async_restart_charging_station = AsyncMock(side_effect=auth_error) with pytest.raises(ConfigEntryAuthFailed, match="reauth required"): await client.restart_charging_station() @@ -340,6 +347,14 @@ async def test_station_level_dashboard_action_error_paths(): with pytest.raises(RuntimeError, match="availability failed"): await client.set_unavailable() + dashboard.async_set_cable_lock = None + with pytest.raises(RuntimeError, match="cable lock action is not available"): + await client.set_cable_locked() + + dashboard.async_set_cable_lock = AsyncMock(side_effect=TimeoutError("timeout")) + with pytest.raises(RuntimeError, match="cable lock failed"): + await client.set_cable_unlocked() + dashboard.async_restart_charging_station = None with pytest.raises(RuntimeError, match="restart action is not available"): await client.restart_charging_station() @@ -527,6 +542,32 @@ async def test_availability_requires_dashboard(): await client.set_available() +@pytest.mark.asyncio +async def test_cable_lock_uses_dashboard_v11_station_serial(): + dashboard = RecordingDashboard() + client = make_client( + serial="CONNECTSERIAL", + station_serial="STATIONSERIAL", + dashboard=dashboard, + ) + + await client.set_cable_locked() + await client.set_cable_unlocked() + + assert dashboard.calls == [ + ("async_set_cable_lock", ("STATIONSERIAL", True)), + ("async_set_cable_lock", ("STATIONSERIAL", False)), + ] + + +@pytest.mark.asyncio +async def test_cable_lock_requires_dashboard(): + client = make_client() + + with pytest.raises(RuntimeError, match="cable lock"): + await client.set_cable_locked() + + @pytest.mark.asyncio async def test_restart_charging_station_uses_dashboard_v11_station_serial(): dashboard = RecordingDashboard() diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..6ebf9ce --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,262 @@ +"""Tests for the lock platform.""" + +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +import pytest + +from custom_components.smappee_ev import lock +from custom_components.smappee_ev.api.errors import SmappeeAuthenticationError +from custom_components.smappee_ev.coordinator import SmappeeCoordinator +from custom_components.smappee_ev.models.runtime_data import RuntimeData +from custom_components.smappee_ev.models.state import ConnectorState, IntegrationData, StationState +from tests.factories import make_connector_runtime, make_site_runtime, make_station_runtime + + +@pytest.fixture +def mock_runtime_data(): + """Create mock runtime data.""" + runtime = MagicMock(spec=RuntimeData) + runtime.sites = { + 12345: make_site_runtime( + site_location_id=12345, + stations={ + "station_uuid": make_station_runtime( + site_location_id=12345, + control_location_id=12345, + station_uuid="station_uuid", + coordinator=MagicMock(spec=SmappeeCoordinator), + station_client=MagicMock(), + connectors={ + "connector_uuid1": make_connector_runtime( + connector_key="connector_uuid1", + connector_uuid="connector_uuid1", + connector_client=MagicMock(), + ), + }, + ) + }, + ) + } + return runtime + + +@pytest.fixture +def mock_config_entry(mock_runtime_data): + """Create mock config entry.""" + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = mock_runtime_data + return entry + + +@pytest.fixture +def mock_integration_data(): + """Create mock integration data with station and connector states.""" + station = StationState(cable_locked=False) + connector = ConnectorState(connector_number=1) + return IntegrationData( + station=station, + connectors={"connector_uuid1": connector}, + ) + + +class TestLockPlatform: + """Test cases for lock platform.""" + + @pytest.mark.asyncio + async def test_async_setup_entry(self, hass: HomeAssistant, mock_config_entry): + """Test lock platform setup.""" + async_add_entities = MagicMock() + + await lock.async_setup_entry(hass, mock_config_entry, async_add_entities) + + async_add_entities.assert_called_once() + entities = async_add_entities.call_args[0][0] + assert len(entities) == 1 + assert all(isinstance(entity, LockEntity) for entity in entities) + assert isinstance(entities[0], lock.SmappeeCableLock) + + @pytest.mark.asyncio + async def test_async_setup_entry_no_sites(self, hass: HomeAssistant): + """Test lock platform setup with no sites.""" + runtime = MagicMock(spec=RuntimeData) + runtime.sites = {} + + entry = MagicMock(spec=ConfigEntry) + entry.runtime_data = runtime + + async_add_entities = MagicMock() + + await lock.async_setup_entry(hass, entry, async_add_entities) + + async_add_entities.assert_called_once_with([], False) + + +class TestSmappeeCableLock: + """Test the SmappeeCableLock class.""" + + def test_initialization(self): + coordinator = MagicMock(spec=SmappeeCoordinator) + api_client = MagicMock() + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + assert cable_lock._station_uuid == "station_uuid" + assert cable_lock._sid == 12345 + assert cable_lock.api_client == api_client + assert cable_lock.translation_key == "cable_lock" + + def test_available_only_when_station_reports_cable_lock_state(self, mock_integration_data): + """Stations that never report cableLocked (e.g. fixed-cable models) stay unavailable.""" + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = mock_integration_data + coordinator.monitoring_only = False + coordinator.last_update_success = True + api_client = MagicMock() + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + mock_integration_data.station.cable_locked = None + mock_integration_data.station.api_available = True + assert cable_lock.available is False + + mock_integration_data.station.cable_locked = True + assert cable_lock.available is True + + def test_is_locked_property(self, mock_integration_data): + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = mock_integration_data + api_client = MagicMock() + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + assert cable_lock.is_locked is False + + mock_integration_data.station.cable_locked = True + assert cable_lock.is_locked is True + + coordinator.data = None + assert cable_lock.is_locked is None + + @pytest.mark.asyncio + async def test_async_lock(self, mock_integration_data): + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = mock_integration_data + api_client = MagicMock() + api_client.set_cable_locked = AsyncMock() + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + mock_integration_data.station.cable_locked = False + await cable_lock.async_lock() + + api_client.set_cable_locked.assert_called_once() + assert mock_integration_data.station.cable_locked is True + coordinator.async_set_updated_data.assert_called_once_with(mock_integration_data) + coordinator.async_schedule_dashboard_refresh.assert_called_once() + + @pytest.mark.asyncio + async def test_async_unlock(self, mock_integration_data): + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = mock_integration_data + api_client = MagicMock() + api_client.set_cable_unlocked = AsyncMock() + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + mock_integration_data.station.cable_locked = True + await cable_lock.async_unlock() + + api_client.set_cable_unlocked.assert_called_once() + assert mock_integration_data.station.cable_locked is False + coordinator.async_set_updated_data.assert_called_once_with(mock_integration_data) + + @pytest.mark.asyncio + async def test_set_locked_error_reverts_state(self, mock_integration_data): + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = mock_integration_data + api_client = MagicMock() + api_client.set_cable_locked = AsyncMock(side_effect=RuntimeError("Connection error")) + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + mock_integration_data.station.cable_locked = False + + with pytest.raises(HomeAssistantError): + await cable_lock._set_locked(True) + + assert mock_integration_data.station.cable_locked is False + assert coordinator.async_set_updated_data.call_count == 2 + + @pytest.mark.asyncio + async def test_set_locked_auth_error_reverts_and_propagates(self, mock_integration_data): + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = mock_integration_data + api_client = MagicMock() + api_client.set_cable_locked = AsyncMock( + side_effect=SmappeeAuthenticationError("reauth required") + ) + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + mock_integration_data.station.cable_locked = False + + with pytest.raises(ConfigEntryAuthFailed, match="reauth required"): + await cable_lock.async_lock() + + assert mock_integration_data.station.cable_locked is False + assert coordinator.async_set_updated_data.call_count == 2 + + @pytest.mark.asyncio + async def test_set_locked_raises_when_station_state_missing(self): + coordinator = MagicMock(spec=SmappeeCoordinator) + coordinator.data = None + api_client = MagicMock() + + cable_lock = lock.SmappeeCableLock( + coordinator=coordinator, + api_client=api_client, + sid=12345, + station_uuid="station_uuid", + ) + + with pytest.raises(HomeAssistantError) as err: + await cable_lock.async_lock() + + assert err.value.translation_key == "station_unavailable"