diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index a1ff9d16baf..1ecefe6f85c 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -4,19 +4,28 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from technove import Station as TechnoVEStation from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import TechnoVEConfigEntry +from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity @@ -25,6 +34,7 @@ from .entity import TechnoVEEntity class TechnoVEBinarySensorDescription(BinarySensorEntityDescription): """Describes TechnoVE binary sensor entity.""" + deprecated_version: str | None = None value_fn: Callable[[TechnoVEStation], bool | None] @@ -52,6 +62,9 @@ BINARY_SENSORS = [ entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, 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( key="is_static_ip", @@ -100,3 +113,34 @@ class TechnoVEBinarySensorEntity(TechnoVEEntity, BinarySensorEntity): """Return the state of the sensor.""" 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() diff --git a/homeassistant/components/technove/icons.json b/homeassistant/components/technove/icons.json index ff47d3c32bc..52307405ff7 100644 --- a/homeassistant/components/technove/icons.json +++ b/homeassistant/components/technove/icons.json @@ -4,6 +4,11 @@ "ssid": { "default": "mdi:wifi" } + }, + "switch": { + "session_active": { + "default": "mdi:ev-station" + } } } } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 8799909d95c..06c93939db8 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -77,12 +77,24 @@ "switch": { "auto_charge": { "name": "Auto charge" + }, + "session_active": { + "name": "Charging Enabled" } } }, "exceptions": { "max_current_in_sharing_mode": { "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}`." } } } diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py index bb9250215be..a8ad7581da5 100644 --- a/homeassistant/components/technove/switch.py +++ b/homeassistant/components/technove/switch.py @@ -2,30 +2,59 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass 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.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TechnoVEConfigEntry +from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity 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) class TechnoVESwitchDescription(SwitchEntityDescription): """Describes TechnoVE binary sensor entity.""" is_on_fn: Callable[[TechnoVEStation], bool] - turn_on_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] - turn_off_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] + turn_on_fn: Callable[[TechnoVEDataUpdateCoordinator], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[TechnoVEDataUpdateCoordinator], Coroutine[Any, Any, None]] SWITCHES = [ @@ -34,8 +63,16 @@ SWITCHES = [ translation_key="auto_charge", entity_category=EntityCategory.CONFIG, is_on_fn=lambda station: station.info.auto_charge, - turn_on_fn=lambda technoVE: technoVE.set_auto_charge(enabled=True), - turn_off_fn=lambda technoVE: technoVE.set_auto_charge(enabled=False), + turn_on_fn=lambda coordinator: _set_auto_charge(coordinator, True), + 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 async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the TechnoVE switch.""" - await self.entity_description.turn_on_fn(self.coordinator.technove) - await self.coordinator.async_request_refresh() + await self.entity_description.turn_on_fn(self.coordinator) @technove_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the TechnoVE switch.""" - await self.entity_description.turn_off_fn(self.coordinator.technove) - await self.coordinator.async_request_refresh() + await self.entity_description.turn_off_fn(self.coordinator) diff --git a/tests/components/technove/fixtures/station_charging.json b/tests/components/technove/fixtures/station_charging.json index 63e68d0db0e..4f50bf1a645 100644 --- a/tests/components/technove/fixtures/station_charging.json +++ b/tests/components/technove/fixtures/station_charging.json @@ -6,7 +6,7 @@ "current": 23.75, "network_ssid": "Connecting...", "id": "AA:AA:AA:AA:AA:BB", - "auto_charge": true, + "auto_charge": false, "highChargePeriodActive": false, "normalPeriodActive": false, "maxChargePourcentage": 0.9, diff --git a/tests/components/technove/snapshots/test_diagnostics.ambr b/tests/components/technove/snapshots/test_diagnostics.ambr index 2e81f124ba5..175e8f2022a 100644 --- a/tests/components/technove/snapshots/test_diagnostics.ambr +++ b/tests/components/technove/snapshots/test_diagnostics.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'auto_charge': True, + 'auto_charge': False, 'conflict_in_sharing_config': False, 'current': 23.75, 'energy_session': 12.34, diff --git a/tests/components/technove/snapshots/test_switch.ambr b/tests/components/technove/snapshots/test_switch.ambr index 1a707971fc8..6febc8c768c 100644 --- a/tests/components/technove/snapshots/test_switch.ambr +++ b/tests/components/technove/snapshots/test_switch.ambr @@ -42,6 +42,52 @@ 'last_changed': , 'last_reported': , 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.technove_station_charging_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.technove_station_charging_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'switch.technove_station_charging_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , 'state': 'on', }) # --- diff --git a/tests/components/technove/test_binary_sensor.py b/tests/components/technove/test_binary_sensor.py index 0ee4f3f3db7..0a90093779e 100644 --- a/tests/components/technove/test_binary_sensor.py +++ b/tests/components/technove/test_binary_sensor.py @@ -8,7 +8,7 @@ import pytest from syrupy import SnapshotAssertion 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.helpers import entity_registry as er @@ -43,7 +43,10 @@ async def test_sensors( @pytest.mark.parametrize( "entity_id", - ["binary_sensor.technove_station_static_ip"], + [ + "binary_sensor.technove_station_static_ip", + "binary_sensor.technove_station_charging", + ], ) @pytest.mark.usefixtures("init_integration") async def test_disabled_by_default_binary_sensors( @@ -64,9 +67,9 @@ async def test_binary_sensor_update_failure( freezer: FrozenDateTimeFactory, ) -> None: """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") freezer.tick(timedelta(minutes=5, seconds=1)) diff --git a/tests/components/technove/test_switch.py b/tests/components/technove/test_switch.py index b1a66607f66..dc0293b6443 100644 --- a/tests/components/technove/test_switch.py +++ b/tests/components/technove/test_switch.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) 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 . import setup_with_selected_platforms @@ -53,6 +53,12 @@ async def test_switches( {"enabled": True}, {"enabled": False}, ), + ( + "switch.technove_station_charging_enabled", + "set_charging_enabled", + {"enabled": True}, + {"enabled": False}, + ), ], ) @pytest.mark.usefixtures("init_integration") @@ -96,6 +102,10 @@ async def test_switch_on_off( "switch.technove_station_auto_charge", "set_auto_charge", ), + ( + "switch.technove_station_charging_enabled", + "set_charging_enabled", + ), ], ) @pytest.mark.usefixtures("init_integration") @@ -130,6 +140,10 @@ async def test_invalid_response( "switch.technove_station_auto_charge", "set_auto_charge", ), + ( + "switch.technove_station_charging_enabled", + "set_charging_enabled", + ), ], ) @pytest.mark.usefixtures("init_integration") @@ -157,3 +171,31 @@ async def test_connection_error( assert method_mock.call_count == 1 assert (state := hass.states.get(state.entity_id)) 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