Add `charging enabled` switch to TechnoVE (#121484)

* Add session_active switch to TechnoVE

* Replace multi-line lambda with function def

* Make lambda one line
pull/124512/head
Christophe Gagnier 2024-08-23 16:45:26 -04:00 committed by GitHub
parent 26e87509be
commit 79ba315008
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 204 additions and 17 deletions

View File

@ -4,19 +4,28 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from technove import Station as TechnoVEStation from technove import Station as TechnoVEStation
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import TechnoVEConfigEntry from . import TechnoVEConfigEntry
from .const import DOMAIN
from .coordinator import TechnoVEDataUpdateCoordinator from .coordinator import TechnoVEDataUpdateCoordinator
from .entity import TechnoVEEntity from .entity import TechnoVEEntity
@ -25,6 +34,7 @@ from .entity import TechnoVEEntity
class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): class TechnoVEBinarySensorDescription(BinarySensorEntityDescription):
"""Describes TechnoVE binary sensor entity.""" """Describes TechnoVE binary sensor entity."""
deprecated_version: str | None = None
value_fn: Callable[[TechnoVEStation], bool | None] value_fn: Callable[[TechnoVEStation], bool | None]
@ -52,6 +62,9 @@ BINARY_SENSORS = [
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING, device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
value_fn=lambda station: station.info.is_session_active, value_fn=lambda station: station.info.is_session_active,
deprecated_version="2025.2.0",
# Disabled by default, as this entity is deprecated
entity_registry_enabled_default=False,
), ),
TechnoVEBinarySensorDescription( TechnoVEBinarySensorDescription(
key="is_static_ip", key="is_static_ip",
@ -100,3 +113,34 @@ class TechnoVEBinarySensorEntity(TechnoVEEntity, BinarySensorEntity):
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data) return self.entity_description.value_fn(self.coordinator.data)
async def async_added_to_hass(self) -> None:
"""Raise issue when entity is registered and was not disabled."""
if TYPE_CHECKING:
assert self.unique_id
if entity_id := er.async_get(self.hass).async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, self.unique_id
):
if self.enabled and self.entity_description.deprecated_version:
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_entity_{self.entity_description.key}",
breaks_in_ha_version=self.entity_description.deprecated_version,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_entity_{self.entity_description.key}",
translation_placeholders={
"sensor_name": self.name
if isinstance(self.name, str)
else entity_id,
"entity": entity_id,
},
)
else:
async_delete_issue(
self.hass,
DOMAIN,
f"deprecated_entity_{self.entity_description.key}",
)
await super().async_added_to_hass()

View File

@ -4,6 +4,11 @@
"ssid": { "ssid": {
"default": "mdi:wifi" "default": "mdi:wifi"
} }
},
"switch": {
"session_active": {
"default": "mdi:ev-station"
}
} }
} }
} }

View File

@ -77,12 +77,24 @@
"switch": { "switch": {
"auto_charge": { "auto_charge": {
"name": "Auto charge" "name": "Auto charge"
},
"session_active": {
"name": "Charging Enabled"
} }
} }
}, },
"exceptions": { "exceptions": {
"max_current_in_sharing_mode": { "max_current_in_sharing_mode": {
"message": "Cannot set the max current when power sharing mode is enabled." "message": "Cannot set the max current when power sharing mode is enabled."
},
"set_charging_enabled_on_auto_charge": {
"message": "Cannot enable or disable charging when auto-charge is enabled. Try disabling auto-charge first."
}
},
"issues": {
"deprecated_entity_is_session_active": {
"title": "The TechnoVE `{sensor_name}` binary sensor is deprecated",
"description": "`{entity}` is deprecated.\nPlease update your automations and scripts to replace the binary sensor entity with the newly added switch entity.\nWhen you are done migrating you can disable `{entity}`."
} }
} }
} }

View File

@ -2,30 +2,59 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from technove import Station as TechnoVEStation, TechnoVE from technove import Station as TechnoVEStation
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TechnoVEConfigEntry from . import TechnoVEConfigEntry
from .const import DOMAIN
from .coordinator import TechnoVEDataUpdateCoordinator from .coordinator import TechnoVEDataUpdateCoordinator
from .entity import TechnoVEEntity from .entity import TechnoVEEntity
from .helpers import technove_exception_handler from .helpers import technove_exception_handler
async def _set_charging_enabled(
coordinator: TechnoVEDataUpdateCoordinator, enabled: bool
) -> None:
if coordinator.data.info.auto_charge:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_charging_enabled_on_auto_charge",
)
await coordinator.technove.set_charging_enabled(enabled=enabled)
coordinator.data.info.is_session_active = enabled
coordinator.async_set_updated_data(coordinator.data)
async def _enable_charging(coordinator: TechnoVEDataUpdateCoordinator) -> None:
await _set_charging_enabled(coordinator, True)
async def _disable_charging(coordinator: TechnoVEDataUpdateCoordinator) -> None:
await _set_charging_enabled(coordinator, False)
async def _set_auto_charge(
coordinator: TechnoVEDataUpdateCoordinator, enabled: bool
) -> None:
await coordinator.technove.set_auto_charge(enabled=enabled)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class TechnoVESwitchDescription(SwitchEntityDescription): class TechnoVESwitchDescription(SwitchEntityDescription):
"""Describes TechnoVE binary sensor entity.""" """Describes TechnoVE binary sensor entity."""
is_on_fn: Callable[[TechnoVEStation], bool] is_on_fn: Callable[[TechnoVEStation], bool]
turn_on_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] turn_on_fn: Callable[[TechnoVEDataUpdateCoordinator], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] turn_off_fn: Callable[[TechnoVEDataUpdateCoordinator], Coroutine[Any, Any, None]]
SWITCHES = [ SWITCHES = [
@ -34,8 +63,16 @@ SWITCHES = [
translation_key="auto_charge", translation_key="auto_charge",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
is_on_fn=lambda station: station.info.auto_charge, is_on_fn=lambda station: station.info.auto_charge,
turn_on_fn=lambda technoVE: technoVE.set_auto_charge(enabled=True), turn_on_fn=lambda coordinator: _set_auto_charge(coordinator, True),
turn_off_fn=lambda technoVE: technoVE.set_auto_charge(enabled=False), turn_off_fn=lambda coordinator: _set_auto_charge(coordinator, False),
),
TechnoVESwitchDescription(
key="session_active",
translation_key="session_active",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda station: station.info.is_session_active,
turn_on_fn=_enable_charging,
turn_off_fn=_disable_charging,
), ),
] ]
@ -76,11 +113,9 @@ class TechnoVESwitchEntity(TechnoVEEntity, SwitchEntity):
@technove_exception_handler @technove_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the TechnoVE switch.""" """Turn on the TechnoVE switch."""
await self.entity_description.turn_on_fn(self.coordinator.technove) await self.entity_description.turn_on_fn(self.coordinator)
await self.coordinator.async_request_refresh()
@technove_exception_handler @technove_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the TechnoVE switch.""" """Turn off the TechnoVE switch."""
await self.entity_description.turn_off_fn(self.coordinator.technove) await self.entity_description.turn_off_fn(self.coordinator)
await self.coordinator.async_request_refresh()

View File

@ -6,7 +6,7 @@
"current": 23.75, "current": 23.75,
"network_ssid": "Connecting...", "network_ssid": "Connecting...",
"id": "AA:AA:AA:AA:AA:BB", "id": "AA:AA:AA:AA:AA:BB",
"auto_charge": true, "auto_charge": false,
"highChargePeriodActive": false, "highChargePeriodActive": false,
"normalPeriodActive": false, "normalPeriodActive": false,
"maxChargePourcentage": 0.9, "maxChargePourcentage": 0.9,

View File

@ -1,7 +1,7 @@
# serializer version: 1 # serializer version: 1
# name: test_diagnostics # name: test_diagnostics
dict({ dict({
'auto_charge': True, 'auto_charge': False,
'conflict_in_sharing_config': False, 'conflict_in_sharing_config': False,
'current': 23.75, 'current': 23.75,
'energy_session': 12.34, 'energy_session': 12.34,

View File

@ -42,6 +42,52 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[switch.technove_station_charging_enabled-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.technove_station_charging_enabled',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Charging Enabled',
'platform': 'technove',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'session_active',
'unique_id': 'AA:AA:AA:AA:AA:BB_session_active',
'unit_of_measurement': None,
})
# ---
# name: test_switches[switch.technove_station_charging_enabled-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'TechnoVE Station Charging Enabled',
}),
'context': <ANY>,
'entity_id': 'switch.technove_station_charging_enabled',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on', 'state': 'on',
}) })
# --- # ---

View File

@ -8,7 +8,7 @@ import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from technove import TechnoVEError from technove import TechnoVEError
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -43,7 +43,10 @@ async def test_sensors(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"entity_id", "entity_id",
["binary_sensor.technove_station_static_ip"], [
"binary_sensor.technove_station_static_ip",
"binary_sensor.technove_station_charging",
],
) )
@pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("init_integration")
async def test_disabled_by_default_binary_sensors( async def test_disabled_by_default_binary_sensors(
@ -64,9 +67,9 @@ async def test_binary_sensor_update_failure(
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test coordinator update failure.""" """Test coordinator update failure."""
entity_id = "binary_sensor.technove_station_charging" entity_id = "binary_sensor.technove_station_power_sharing_mode"
assert hass.states.get(entity_id).state == STATE_ON assert hass.states.get(entity_id).state == STATE_OFF
mock_technove.update.side_effect = TechnoVEError("Test error") mock_technove.update.side_effect = TechnoVEError("Test error")
freezer.tick(timedelta(minutes=5, seconds=1)) freezer.tick(timedelta(minutes=5, seconds=1))

View File

@ -15,7 +15,7 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import setup_with_selected_platforms from . import setup_with_selected_platforms
@ -53,6 +53,12 @@ async def test_switches(
{"enabled": True}, {"enabled": True},
{"enabled": False}, {"enabled": False},
), ),
(
"switch.technove_station_charging_enabled",
"set_charging_enabled",
{"enabled": True},
{"enabled": False},
),
], ],
) )
@pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("init_integration")
@ -96,6 +102,10 @@ async def test_switch_on_off(
"switch.technove_station_auto_charge", "switch.technove_station_auto_charge",
"set_auto_charge", "set_auto_charge",
), ),
(
"switch.technove_station_charging_enabled",
"set_charging_enabled",
),
], ],
) )
@pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("init_integration")
@ -130,6 +140,10 @@ async def test_invalid_response(
"switch.technove_station_auto_charge", "switch.technove_station_auto_charge",
"set_auto_charge", "set_auto_charge",
), ),
(
"switch.technove_station_charging_enabled",
"set_charging_enabled",
),
], ],
) )
@pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("init_integration")
@ -157,3 +171,31 @@ async def test_connection_error(
assert method_mock.call_count == 1 assert method_mock.call_count == 1
assert (state := hass.states.get(state.entity_id)) assert (state := hass.states.get(state.entity_id))
assert state.state == STATE_UNAVAILABLE assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("init_integration")
async def test_disable_charging_auto_charge(
hass: HomeAssistant,
mock_technove: MagicMock,
) -> None:
"""Test failure to disable charging when the station is in auto charge mode."""
entity_id = "switch.technove_station_charging_enabled"
state = hass.states.get(entity_id)
# Enable auto-charge mode
device = mock_technove.update.return_value
device.info.auto_charge = True
with pytest.raises(
ServiceValidationError,
match="auto-charge is enabled",
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert (state := hass.states.get(state.entity_id))
assert state.state != STATE_UNAVAILABLE