Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
1 change: 1 addition & 0 deletions custom_components/smappee_ev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.LOCK,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Expand Down
10 changes: 10 additions & 0 deletions custom_components/smappee_ev/api/dashboard_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions custom_components/smappee_ev/api/device_handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions custom_components/smappee_ev/coordinators/dashboard_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
154 changes: 154 additions & 0 deletions custom_components/smappee_ev/lock.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions custom_components/smappee_ev/models/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions custom_components/smappee_ev/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@
"name": "Offline charging"
}
},
"lock": {
"cable_lock": {
"name": "Cable lock"
}
},
"light": {
"led": {
"name": "LED"
Expand Down
5 changes: 5 additions & 0 deletions custom_components/smappee_ev/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@
"name": "Offline-Laden"
}
},
"lock": {
"cable_lock": {
"name": "Kabelverriegelung"
}
},
"light": {
"led": {
"name": "LED"
Expand Down
5 changes: 5 additions & 0 deletions custom_components/smappee_ev/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@
"name": "Offline charging"
}
},
"lock": {
"cable_lock": {
"name": "Cable lock"
}
},
"light": {
"led": {
"name": "LED"
Expand Down
5 changes: 5 additions & 0 deletions custom_components/smappee_ev/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@
"name": "Charge hors ligne"
}
},
"lock": {
"cable_lock": {
"name": "Verrouillage du câble"
}
},
"light": {
"led": {
"name": "LED"
Expand Down
5 changes: 5 additions & 0 deletions custom_components/smappee_ev/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@
"name": "Offline laden"
}
},
"lock": {
"cable_lock": {
"name": "Kabelvergrendeling"
}
},
"light": {
"led": {
"name": "LED"
Expand Down
1 change: 1 addition & 0 deletions docs/HA_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions tests/test_dashboard_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading