From 0cae37c56bf494cdd38f8d609c1c0bb0c51efde2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 14:42:11 +0200 Subject: [PATCH] Manage the Peblar autocharge vehicle list (#180606) --- homeassistant/components/peblar/const.py | 1 + homeassistant/components/peblar/icons.json | 9 ++ homeassistant/components/peblar/services.py | 96 +++++++++++-- homeassistant/components/peblar/services.yaml | 36 +++++ homeassistant/components/peblar/strings.json | 45 ++++++ .../peblar/fixtures/system_information.json | 2 +- .../peblar/snapshots/test_diagnostics.ambr | 2 +- tests/components/peblar/test_services.py | 132 ++++++++++++++++++ 8 files changed, 311 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index cc2129baa495..0982e1ef2352 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -7,6 +7,7 @@ from peblar import ChargeLimiter, CPState DOMAIN: Final = "peblar" +CONF_EVCC_ID: Final = "evcc_id" CONF_UID: Final = "uid" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index cbdf76a8a1cc..7ce7b3698d3e 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -85,11 +85,20 @@ "add_rfid_token": { "service": "mdi:card-plus" }, + "add_vehicle_token": { + "service": "mdi:car-connected" + }, "delete_rfid_token": { "service": "mdi:card-remove" }, + "delete_vehicle_token": { + "service": "mdi:car-off" + }, "list_rfid_tokens": { "service": "mdi:card-account-details" + }, + "list_vehicle_tokens": { + "service": "mdi:car-multiple" } } } diff --git a/homeassistant/components/peblar/services.py b/homeassistant/components/peblar/services.py index 6f141dd773b7..d63369aaf018 100644 --- a/homeassistant/components/peblar/services.py +++ b/homeassistant/components/peblar/services.py @@ -6,7 +6,7 @@ from contextlib import asynccontextmanager from peblar import Peblar, PeblarAuthenticationError, PeblarConnectionError, PeblarError import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_DESCRIPTION +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_ALIAS, CONF_DESCRIPTION from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -20,24 +20,31 @@ from homeassistant.helpers.service import ( async_register_admin_service, ) -from .const import CONF_UID, DOMAIN +from .const import CONF_EVCC_ID, CONF_UID, DOMAIN from .coordinator import PeblarConfigEntry SERVICE_ADD_RFID_TOKEN = "add_rfid_token" +SERVICE_ADD_VEHICLE_TOKEN = "add_vehicle_token" SERVICE_DELETE_RFID_TOKEN = "delete_rfid_token" +SERVICE_DELETE_VEHICLE_TOKEN = "delete_vehicle_token" SERVICE_LIST_RFID_TOKENS = "list_rfid_tokens" +SERVICE_LIST_VEHICLE_TOKENS = "list_vehicle_tokens" CHARGER_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): str}) + TOKEN_SCHEMA = CHARGER_SCHEMA.extend({vol.Required(CONF_UID): str}) ADD_TOKEN_SCHEMA = TOKEN_SCHEMA.extend({vol.Required(CONF_DESCRIPTION): str}) +VEHICLE_SCHEMA = CHARGER_SCHEMA.extend({vol.Required(CONF_EVCC_ID): str}) +ADD_VEHICLE_SCHEMA = VEHICLE_SCHEMA.extend({vol.Required(CONF_ALIAS): str}) -def _get_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: - """Return the Peblar client for the charger the call targets. - Every action here manages the standalone authorization list, which - lives on the RFID reader, so a charger without one is turned away - here instead of failing somewhere inside the charger. +def _get_rfid_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: + """Return the client, for a charger that has an RFID reader. + + The standalone authorization list lives on the reader, so a charger + without one is turned away here instead of failing somewhere inside + the charger. """ entry: PeblarConfigEntry = async_get_config_entry(hass, DOMAIN, entry_id) @@ -51,6 +58,26 @@ def _get_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: return entry.runtime_data.user_configuration_coordinator.peblar +def _get_autocharge_peblar(hass: HomeAssistant, entry_id: str) -> Peblar: + """Return the client, for a charger that can do autocharge. + + Autocharge identifies a car by what its own controller presents over + the power line, so it takes the PLC hardware. Without it the charger + still hands out the list, but refuses to change it, so this turns the + charger away rather than let that come back as a failed request. + """ + entry: PeblarConfigEntry = async_get_config_entry(hass, DOMAIN, entry_id) + + if not entry.runtime_data.system_information.hardware_has_plc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_autocharge_hardware", + translation_placeholders={"charger": entry.title}, + ) + + return entry.runtime_data.user_configuration_coordinator.peblar + + @asynccontextmanager async def _handle_peblar_errors( hass: HomeAssistant, entry_id: str @@ -88,7 +115,7 @@ def async_setup_services(hass: HomeAssistant) -> None: async def _handle_list_rfid_tokens(call: ServiceCall) -> ServiceResponse: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - peblar = _get_peblar(hass, entry_id) + peblar = _get_rfid_peblar(hass, entry_id) async with _handle_peblar_errors(hass, entry_id): tokens = await peblar.rfid_tokens() return { @@ -103,7 +130,7 @@ def async_setup_services(hass: HomeAssistant) -> None: async def _handle_add_rfid_token(call: ServiceCall) -> None: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - peblar = _get_peblar(hass, entry_id) + peblar = _get_rfid_peblar(hass, entry_id) async with _handle_peblar_errors(hass, entry_id): await peblar.add_rfid_token( rfid_token_uid=call.data[CONF_UID], @@ -112,10 +139,37 @@ def async_setup_services(hass: HomeAssistant) -> None: async def _handle_delete_rfid_token(call: ServiceCall) -> None: entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - peblar = _get_peblar(hass, entry_id) + peblar = _get_rfid_peblar(hass, entry_id) async with _handle_peblar_errors(hass, entry_id): await peblar.delete_rfid_token(uid=call.data[CONF_UID]) + async def _handle_list_vehicle_tokens(call: ServiceCall) -> ServiceResponse: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_autocharge_peblar(hass, entry_id) + async with _handle_peblar_errors(hass, entry_id): + vehicles = await peblar.vehicle_tokens() + return { + "vehicles": [ + {CONF_EVCC_ID: vehicle.evcc_id, CONF_ALIAS: vehicle.alias} + for vehicle in vehicles + ] + } + + async def _handle_add_vehicle_token(call: ServiceCall) -> None: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_autocharge_peblar(hass, entry_id) + async with _handle_peblar_errors(hass, entry_id): + await peblar.add_vehicle_token( + evcc_id=call.data[CONF_EVCC_ID], + alias=call.data[CONF_ALIAS], + ) + + async def _handle_delete_vehicle_token(call: ServiceCall) -> None: + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + peblar = _get_autocharge_peblar(hass, entry_id) + async with _handle_peblar_errors(hass, entry_id): + await peblar.delete_vehicle_token(evcc_id=call.data[CONF_EVCC_ID]) + async_register_admin_service( hass, DOMAIN, @@ -138,3 +192,25 @@ def async_setup_services(hass: HomeAssistant) -> None: _handle_delete_rfid_token, schema=TOKEN_SCHEMA, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LIST_VEHICLE_TOKENS, + _handle_list_vehicle_tokens, + schema=CHARGER_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ADD_VEHICLE_TOKEN, + _handle_add_vehicle_token, + schema=ADD_VEHICLE_SCHEMA, + ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DELETE_VEHICLE_TOKEN, + _handle_delete_vehicle_token, + schema=VEHICLE_SCHEMA, + ) diff --git a/homeassistant/components/peblar/services.yaml b/homeassistant/components/peblar/services.yaml index 179a2de9a4ed..633ec8e43b70 100644 --- a/homeassistant/components/peblar/services.yaml +++ b/homeassistant/components/peblar/services.yaml @@ -33,3 +33,39 @@ delete_rfid_token: required: true selector: text: + +list_vehicle_tokens: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + +add_vehicle_token: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + evcc_id: + required: true + selector: + text: + alias: + required: true + selector: + text: + +delete_vehicle_token: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: peblar + evcc_id: + required: true + selector: + text: diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index e16824fc3dfe..62d6f7678c16 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -206,6 +206,9 @@ "communication_error": { "message": "An error occurred while communicating with the Peblar EV charger: {error}" }, + "no_autocharge_hardware": { + "message": "{charger} has no power line communication hardware, so it cannot use autocharge." + }, "no_rfid_hardware": { "message": "{charger} has no RFID reader, so it has no standalone authorization list." }, @@ -232,6 +235,24 @@ }, "name": "Add RFID token" }, + "add_vehicle_token": { + "description": "Adds a vehicle to the charger's autocharge list, so it is authorized to charge as soon as it is plugged in.", + "fields": { + "alias": { + "description": "A human-readable label for this vehicle.", + "name": "Alias" + }, + "config_entry_id": { + "description": "The Peblar EV charger to add the vehicle to.", + "name": "Peblar EV charger" + }, + "evcc_id": { + "description": "The identifier the vehicle presents to the charger.", + "name": "EVCC ID" + } + }, + "name": "Add autocharge vehicle" + }, "delete_rfid_token": { "description": "Deletes an RFID token from the charger's standalone authorization list.", "fields": { @@ -246,6 +267,20 @@ }, "name": "Delete RFID token" }, + "delete_vehicle_token": { + "description": "Deletes a vehicle from the charger's autocharge list.", + "fields": { + "config_entry_id": { + "description": "The Peblar EV charger to delete the vehicle from.", + "name": "Peblar EV charger" + }, + "evcc_id": { + "description": "The identifier of the vehicle to delete.", + "name": "EVCC ID" + } + }, + "name": "Delete autocharge vehicle" + }, "list_rfid_tokens": { "description": "Returns the RFID tokens configured in the charger's standalone authorization list.", "fields": { @@ -255,6 +290,16 @@ } }, "name": "List RFID tokens" + }, + "list_vehicle_tokens": { + "description": "Returns the vehicles configured in the charger's autocharge list.", + "fields": { + "config_entry_id": { + "description": "The Peblar EV charger to list autocharge vehicles for.", + "name": "Peblar EV charger" + } + }, + "name": "List autocharge vehicles" } } } diff --git a/tests/components/peblar/fixtures/system_information.json b/tests/components/peblar/fixtures/system_information.json index 13fae9af9945..9731c59a18e1 100644 --- a/tests/components/peblar/fixtures/system_information.json +++ b/tests/components/peblar/fixtures/system_information.json @@ -22,7 +22,7 @@ "HwHasLte": false, "HwHasMeter": true, "HwHasMeterDisplay": true, - "HwHasPlc": false, + "HwHasPlc": true, "HwHasRfid": true, "HwHasRs485": true, "HwHasShutter": false, diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index a1f1bd19da4a..12d671c5100c 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -62,7 +62,7 @@ 'HwHasLte': False, 'HwHasMeter': True, 'HwHasMeterDisplay': True, - 'HwHasPlc': False, + 'HwHasPlc': True, 'HwHasRfid': True, 'HwHasRs485': True, 'HwHasShutter': False, diff --git a/tests/components/peblar/test_services.py b/tests/components/peblar/test_services.py index 94764e5d76b0..b4c51a32b4e7 100644 --- a/tests/components/peblar/test_services.py +++ b/tests/components/peblar/test_services.py @@ -8,14 +8,18 @@ from peblar import ( PeblarConnectionError, PeblarError, PeblarRfidToken, + PeblarVehicleToken, ) import pytest from homeassistant.components.peblar.const import DOMAIN from homeassistant.components.peblar.services import ( SERVICE_ADD_RFID_TOKEN, + SERVICE_ADD_VEHICLE_TOKEN, SERVICE_DELETE_RFID_TOKEN, + SERVICE_DELETE_VEHICLE_TOKEN, SERVICE_LIST_RFID_TOKENS, + SERVICE_LIST_VEHICLE_TOKENS, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant @@ -277,3 +281,131 @@ async def test_charger_without_rfid_reader( assert excinfo.value.translation_domain == DOMAIN assert excinfo.value.translation_key == "no_rfid_hardware" + + +async def test_list_vehicle_tokens( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test list_vehicle_tokens returns the vehicles on the charger.""" + mock_peblar.vehicle_tokens.return_value = [ + PeblarVehicleToken(evcc_id="EVCC-1234", alias="The blue one"), + PeblarVehicleToken(evcc_id="EVCC-5678", alias="The other one"), + ] + + result = await hass.services.async_call( + DOMAIN, + SERVICE_LIST_VEHICLE_TOKENS, + {"config_entry_id": init_integration.entry_id}, + blocking=True, + return_response=True, + ) + + assert result == { + "vehicles": [ + {"evcc_id": "EVCC-1234", "alias": "The blue one"}, + {"evcc_id": "EVCC-5678", "alias": "The other one"}, + ] + } + mock_peblar.vehicle_tokens.assert_called_once_with() + + +async def test_add_vehicle_token( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test add_vehicle_token calls the library with the right arguments.""" + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_VEHICLE_TOKEN, + { + "config_entry_id": init_integration.entry_id, + "evcc_id": "EVCC-1234", + "alias": "The blue one", + }, + blocking=True, + ) + + mock_peblar.add_vehicle_token.assert_called_once_with( + evcc_id="EVCC-1234", + alias="The blue one", + ) + + +async def test_delete_vehicle_token( + hass: HomeAssistant, + mock_peblar: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test delete_vehicle_token calls the library with the right arguments.""" + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_VEHICLE_TOKEN, + { + "config_entry_id": init_integration.entry_id, + "evcc_id": "EVCC-1234", + }, + blocking=True, + ) + + mock_peblar.delete_vehicle_token.assert_called_once_with(evcc_id="EVCC-1234") + + +AUTOCHARGE_CALLS: list[tuple[str, dict[str, Any]]] = [ + (SERVICE_LIST_VEHICLE_TOKENS, {}), + (SERVICE_ADD_VEHICLE_TOKEN, {"evcc_id": "EVCC-1234", "alias": "The blue one"}), + (SERVICE_DELETE_VEHICLE_TOKEN, {"evcc_id": "EVCC-1234"}), +] + + +@pytest.mark.parametrize("mock_peblar", [{"HwHasRfid": False}], indirect=True) +@pytest.mark.parametrize(("service", "service_data"), AUTOCHARGE_CALLS) +@pytest.mark.usefixtures("mock_peblar") +async def test_autocharge_does_not_need_an_rfid_reader( + hass: HomeAssistant, + init_integration: MockConfigEntry, + service: str, + service_data: dict[str, Any], +) -> None: + """Test the autocharge list is a separate one from the RFID list. + + Autocharge identifies a car by what its own controller presents, so it + has nothing to do with the reader. + """ + await hass.services.async_call( + DOMAIN, + service, + {"config_entry_id": init_integration.entry_id, **service_data}, + blocking=True, + return_response=service == SERVICE_LIST_VEHICLE_TOKENS, + ) + + +@pytest.mark.parametrize("mock_peblar", [{"HwHasPlc": False}], indirect=True) +@pytest.mark.parametrize(("service", "service_data"), AUTOCHARGE_CALLS) +@pytest.mark.usefixtures("mock_peblar") +async def test_autocharge_needs_power_line_communication( + hass: HomeAssistant, + init_integration: MockConfigEntry, + service: str, + service_data: dict[str, Any], +) -> None: + """Test a charger that cannot do autocharge is turned away. + + Checked against a charger without the hardware: it answers 200 with + null on the list, and 403 on adding and deleting. Letting that come + back as a failed request tells the user nothing. + """ + with pytest.raises(ServiceValidationError) as excinfo: + await hass.services.async_call( + DOMAIN, + service, + {"config_entry_id": init_integration.entry_id, **service_data}, + blocking=True, + return_response=service == SERVICE_LIST_VEHICLE_TOKENS, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "no_autocharge_hardware"