From 94f96a0c4c047de8969de027f5585c8181a87b8a Mon Sep 17 00:00:00 2001 From: KingHavok Date: Sun, 4 Oct 2026 17:57:30 +1100 Subject: [PATCH] Add an option to send Session Current Limit as a TxDefaultProfile (#2158) Some chargers (Timxon A0, ABB Terra AC, Morek MEV11 on OCPP 1.6) refuse both the station ChargePointMaxProfile and a transaction-bound TxProfile, but accept a TxDefaultProfile on the connector and apply it to the running transaction. Before #2131 the station slider reached them through its TxDefaultProfile fallback; since 0.12.0 nothing does. A per-charger option, session_limit_default_profile (off by default), makes Session Current Limit send a Relative TxDefaultProfile instead: on the connector on 1.6, on the connector's EVSE on 2.0.1. It uses profile id 2000+n and one stack level below the reported maximum (at least 1, and 0 for a maximum of 0), which on 1.6 is the profile the set_charge_rate action writes for its TxDefaultProfile leg. With the option on, the slider needs no transaction. It is available whenever the charger is up with SmartCharging and, on 2.0.1, has reported which EVSE the connector is on; nothing is sent to a guessed EVSE. Its value is the last limit the charger accepted, kept across transactions and restored after a restart, saving only accepted values and only those this mode set. A cancelled request reverts the display, in both modes. The option is stored only while it is on and needs no migration, so a charger that never enables it stays loadable by older releases. Settings are now built from the keys their dataclass knows, so later releases can add settings without breaking a rollback to this one. The station slider and the transaction-bound mode are unchanged. --- custom_components/ocpp/api.py | 39 +++- custom_components/ocpp/chargepoint.py | 69 ++++++ custom_components/ocpp/config_flow.py | 43 +++- custom_components/ocpp/const.py | 16 +- custom_components/ocpp/number.py | 134 +++++++++-- custom_components/ocpp/ocppv16.py | 31 +++ custom_components/ocpp/ocppv201.py | 34 +++ custom_components/ocpp/translations/en.json | 2 + .../ocpp/translations/i-default.json | 2 + docs/Charge_automation.md | 10 + tests/test_api_paths.py | 92 ++++++++ tests/test_config_flow.py | 46 ++++ tests/test_flat_maximum_current.py | 213 ++++++++++++++++++ tests/test_options_flow.py | 38 ++++ tests/test_set_charge_rate_v16.py | 73 ++++++ tests/test_v201_set_charge_rate_return.py | 45 ++++ 16 files changed, 859 insertions(+), 28 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 23549b706..c49d0f80a 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -29,6 +29,7 @@ OCPP_2_0, OCPP_VERSION_AUTO, ChargerSystemSettings, + settings_from_config, ) from .enums import ( HAChargerServices as csvcs, @@ -179,7 +180,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): """Instantiate instance of a CentralSystem.""" self.hass = hass self.entry = entry - self.settings = CentralSystemSettings(**entry.data) + self.settings = settings_from_config(CentralSystemSettings, entry.data) self.subprotocols = self._resolve_subprotocols(self.settings) self._server = None self.id = self.settings.csid @@ -338,7 +339,9 @@ async def on_connect(self, websocket: ServerConnection): for cfg in self.settings.cpids: if cfg.get(cp_id): config_flow = True - cp_settings = ChargerSystemSettings(**list(cfg.values())[0]) + cp_settings = settings_from_config( + ChargerSystemSettings, list(cfg.values())[0] + ) _LOGGER.info( f"Charger match found for {cp_settings.cpid}:{cp_id}" ) @@ -800,6 +803,38 @@ async def set_session_charge_rate_amps( ) return await cp.set_session_limit(int(connector_id), float(value)) + def session_default_limit_available(self, id: str, connector_id: int) -> bool: + """Whether a connector's default session limit can be set right now. + + The charger must be up with SmartCharging and have said where the + connector's profile goes. A TxDefaultProfile needs no transaction, so + the connector's status does not matter. The charge point checks the + target again before sending. + """ + _cp_id, _m, cp, _n = self._get_metrics(id) + if cp is None or cp.status != STATE_OK: + return False + if not bool(cp.supported_features & prof.SMART): + return False + return bool(cp.session_default_target_known(self._norm_conn(connector_id))) + + async def set_session_default_charge_rate_amps( + self, + id: str, + connector_id: int, + value: float, + ) -> bool: + """Set a connector's default session limit.""" + cp_id = self.cpids.get(id, id) + cp = self.charge_points.get(cp_id) + if cp is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unavailable", + translation_placeholders={"message": id}, + ) + return await cp.set_session_default_limit(int(connector_id), float(value)) + async def set_max_charge_rate_amps( self, id: str, value: float, connector_id: int = 0 ): diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 80bfa1960..2f806e7f9 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -100,6 +100,19 @@ def session_profile_id(connector_id: int) -> int: return SESSION_PROFILE_BASE_ID + int(connector_id) +SESSION_DEFAULT_PROFILE_BASE_ID = 2000 + + +def session_default_profile_id(connector_id: int) -> int: + """Return the profile id a connector's default session limit uses on both protocols. + + It is the id the 1.6 ``ocpp.set_charge_rate`` action already uses for its + TxDefaultProfile leg, so on 1.6 the slider and the action write one + profile. + """ + return SESSION_DEFAULT_PROFILE_BASE_ID + int(connector_id) + + class Metric: """Metric class.""" @@ -490,6 +503,62 @@ def build_session_limit_request( """Build the protocol's SetChargingProfile for a transaction-bound limit.""" raise NotImplementedError + def build_session_default_request( + self, + connector_id: int, + profile_id: int, + prepared: dict, + ): + """Build the protocol's SetChargingProfile for a connector default limit.""" + raise NotImplementedError + + def session_default_target_known(self, connector_id: int) -> bool: + """Return whether the charger has said where the connector's profile goes. + + A default profile outlives the request, so it must not be sent to a + guessed target. 1.6 addresses the connector itself; 2.0.1 overrides. + """ + return True + + async def set_session_default_limit( + self, connector_id: int, limit_amps: float + ) -> bool: + """Set a TxDefaultProfile on the connector, with or without a transaction. + + For chargers that refuse both the station ceiling and a TxProfile but + accept a connector default, which they also apply to the running + transaction. Returns True only for an Accepted reply; anything else + raises a HomeAssistantError, as set_session_limit does. The profile + stays on the charger until it is replaced; nothing is cleared. + """ + conn = int(connector_id) + prepared = await self.prepare_session_limit(conn, float(limit_amps)) + # Preparation waits for the charger's inventory, which is what + # settles the target on 2.0.1, so it is checked after it. + if not self.session_default_target_known(conn): + raise HomeAssistantError( + f"the charger has not reported which EVSE connector {conn} is on" + ) + request = self.build_session_default_request( + conn, session_default_profile_id(conn), prepared + ) + try: + resp = await self.call(request) + except TimeoutError as ex: + raise HomeAssistantError( + f"the charger did not answer the default limit for connector {conn}" + ) from ex + except Exception as ex: + raise HomeAssistantError( + f"default limit for connector {conn} failed: {ex}" + ) from ex + status = getattr(resp, "status", None) + if status == ChargingProfileStatus.accepted: + return True + raise HomeAssistantError( + f"charger rejected the default limit for connector {conn} ({status})" + ) + async def set_session_limit(self, connector_id: int, limit_amps: float) -> bool: """Bind a TxProfile to the connector's displayed transaction. diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index f1f5604ca..515bb75b2 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -28,6 +28,7 @@ CONF_NUM_CONNECTORS, CONF_OCPP_VERSION, CONF_PORT, + CONF_SESSION_LIMIT_DEFAULT_PROFILE, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, CONF_SSL_CERTFILE_PATH, @@ -51,6 +52,7 @@ DEFAULT_NUM_CONNECTORS, DEFAULT_OCPP_VERSION, DEFAULT_PORT, + DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, DEFAULT_SSL_CERTFILE_PATH, @@ -110,6 +112,10 @@ CONF_CHARGE_POINT_MAX_PROFILE_ABSOLUTE, default=DEFAULT_CHARGE_POINT_MAX_PROFILE_ABSOLUTE, ): bool, + vol.Required( + CONF_SESSION_LIMIT_DEFAULT_PROFILE, + default=DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE, + ): bool, vol.Required( CONF_ENABLE_HA_NOTIFICATIONS, default=DEFAULT_ENABLE_HA_NOTIFICATIONS ): bool, @@ -124,6 +130,22 @@ ) +def _store_session_default_only_when_on(settings: dict[str, Any]) -> dict[str, Any]: + """Drop session_limit_default_profile from a charger record unless it is on. + + Releases before it expand the record into ChargerSystemSettings and fail + on a key they do not know. Leaving it out while it is off keeps a + rollback safe for every charger that never enabled it. + """ + if settings.get(CONF_SESSION_LIMIT_DEFAULT_PROFILE): + return settings + return { + key: value + for key, value in settings.items() + if key != CONF_SESSION_LIMIT_DEFAULT_PROFILE + } + + class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" @@ -263,10 +285,12 @@ async def async_step_cp_user( errors["base"] = "duplicate_cpid" if not errors: - cp_data = { - **user_input, - CONF_NUM_CONNECTORS: self._detected_num_connectors, - } + cp_data = _store_session_default_only_when_on( + { + **user_input, + CONF_NUM_CONNECTORS: self._detected_num_connectors, + } + ) cpids_list = self._data.get(CONF_CPIDS, []).copy() cpids_list.append({self._cp_id: cp_data}) self._data = {**self._data, CONF_CPIDS: cpids_list} @@ -371,7 +395,9 @@ def _finalize(self) -> ConfigFlowResult: cpids = [ { cp_id: ( - {**stored, **self._settings} if cp_id == self._cp_id else stored + _store_session_default_only_when_on({**stored, **self._settings}) + if cp_id == self._cp_id + else stored ) for cp_id, stored in item.items() } @@ -468,6 +494,13 @@ async def async_step_cp_settings( DEFAULT_CHARGE_POINT_MAX_PROFILE_ABSOLUTE, ), ): bool, + vol.Required( + CONF_SESSION_LIMIT_DEFAULT_PROFILE, + default=current.get( + CONF_SESSION_LIMIT_DEFAULT_PROFILE, + DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE, + ), + ): bool, vol.Required( CONF_ENABLE_HA_NOTIFICATIONS, default=current.get( diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 3a875bbea..779f82b1c 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -2,7 +2,7 @@ import pathlib -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields import homeassistant.components.input_number as input_number from homeassistant.components.sensor import SensorDeviceClass import homeassistant.const as ha @@ -31,6 +31,7 @@ CONF_OCPP_VERSION = "ocpp_version" CONF_PASSWORD = ha.CONF_PASSWORD CONF_PORT = ha.CONF_PORT +CONF_SESSION_LIMIT_DEFAULT_PROFILE = "session_limit_default_profile" CONF_SKIP_SCHEMA_VALIDATION = "skip_schema_validation" CONF_FORCE_SMART_CHARGING = "force_smart_charging" CONF_SSL = "ssl" @@ -55,6 +56,7 @@ DEFAULT_PORT = 9000 DEFAULT_SKIP_SCHEMA_VALIDATION = False DEFAULT_FORCE_SMART_CHARGING = False +DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE = False DEFAULT_SSL = False DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem") DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem") @@ -197,6 +199,7 @@ class ChargerSystemSettings: connection: int | None = None # number of this connection in central server num_connectors: int = DEFAULT_NUM_CONNECTORS charge_point_max_profile_absolute: bool = DEFAULT_CHARGE_POINT_MAX_PROFILE_ABSOLUTE + session_limit_default_profile: bool = DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE @dataclass @@ -223,3 +226,14 @@ class CentralSystemSettings: # for id in self.cpids: # self.cpids[i] = ChargerSystemSettings(**id) # i =+ 1 + + +def settings_from_config(cls, data: dict): + """Build a settings dataclass from a stored config, ignoring unknown keys. + + A key added by a later release is not a field here. Ignoring it lets + this release still load an entry after a rollback, instead of failing + every charger with an unexpected keyword argument. + """ + known = {f.name for f in fields(cls)} + return cls(**{key: value for key, value in data.items() if key in known}) diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index e13f61f19..fef71d13f 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -13,6 +13,7 @@ DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, + NumberExtraStoredData, RestoreNumber, ) from homeassistant.const import UnitOfElectricCurrent @@ -24,20 +25,23 @@ from homeassistant.util import slugify from .api import CentralSystem -from .chargepoint import session_profile_id +from .chargepoint import session_default_profile_id, session_profile_id from .const import ( CONF_NUM_CONNECTORS, CONF_CPID, CONF_CPIDS, CONF_MAX_CURRENT, + CONF_SESSION_LIMIT_DEFAULT_PROFILE, DATA_UPDATED, DEFAULT_MAX_CURRENT, + DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE, DOMAIN, ICON, ) from .enums import Profiles MAX_SESSION_CONNECTORS = 10 +_TX_DEFAULT_PROFILE = "TxDefaultProfile" _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -166,9 +170,17 @@ async def async_setup_entry(hass, entry, async_add_devices): cpid, MAX_SESSION_CONNECTORS, ) + session_cls = ( + SessionDefaultLimitNumber + if cp_id_settings.get( + CONF_SESSION_LIMIT_DEFAULT_PROFILE, + DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE, + ) + else SessionCurrentLimitNumber + ) for connector_id in range(1, min(connector_count, MAX_SESSION_CONNECTORS) + 1): entities.append( - SessionCurrentLimitNumber( + session_cls( hass, central_system, cpid, @@ -521,28 +533,17 @@ async def async_set_native_value(self, value: float) -> None: were asked for. """ async with self._operation_lock: - key = self._current_key() - if key is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_charge_rate_error", - translation_placeholders={ - "message": ( - f"connector {self.connector_id} has no active " - "transaction to limit" - ) - }, - ) + self._begin_set() target = float(value) - if key != self._display_key: - self._display_key = key - self._confirmed_value = None self._attr_native_value = target self.async_write_ha_state() try: - ok = await self.central_system.set_session_charge_rate_amps( - self.cpid, self.connector_id, target - ) + ok = await self._send(target) + except asyncio.CancelledError: + # Not an Exception: without this the in-flight value would + # stay on display as if the charger had accepted it. + self._revert_to_confirmed() + raise except HomeAssistantError: self._revert_to_confirmed() raise @@ -566,8 +567,101 @@ async def async_set_native_value(self, value: float) -> None: self._attr_native_value = target self.async_write_ha_state() + def _begin_set(self) -> None: + """Bind the display to the transaction the request is for.""" + key = self._current_key() + if key is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_charge_rate_error", + translation_placeholders={ + "message": ( + f"connector {self.connector_id} has no active " + "transaction to limit" + ) + }, + ) + if key != self._display_key: + self._display_key = key + self._confirmed_value = None + + async def _send(self, target: float) -> bool: + return await self.central_system.set_session_charge_rate_amps( + self.cpid, self.connector_id, target + ) + def _revert_to_confirmed(self) -> None: if self._attr_native_value == self._confirmed_value: return self._attr_native_value = self._confirmed_value self.async_write_ha_state() + + +class SessionDefaultLimitNumber(SessionCurrentLimitNumber, RestoreNumber): + """Session Current Limit sent as a connector TxDefaultProfile. + + Selected per charger by session_limit_default_profile, for chargers + that refuse both the station ceiling and a TxProfile but accept a + connector default and apply it to the running transaction (#2158). The + profile is not bound to a transaction, so the slider is available + whenever the charger is up with SmartCharging, and its value is the last + limit the charger accepted for the connector. That value is restored + across restarts, because the charger keeps the profile; a value left by + the transaction-bound slider is not, because that profile has ended. + """ + + async def async_added_to_hass(self) -> None: + """Restore the last accepted default, if this mode left one.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if ( + last_state is None + or last_state.attributes.get("profile_purpose") != _TX_DEFAULT_PROFILE + ): + return + restored = await self.async_get_last_number_data() + if restored is not None and restored.native_value is not None: + self._attr_native_value = restored.native_value + self._confirmed_value = restored.native_value + + @property + def extra_restore_state_data(self) -> NumberExtraStoredData: + """Save what the charger accepted, never a request still in flight.""" + return NumberExtraStoredData( + self.native_max_value, + self.native_min_value, + self.native_step, + self.native_unit_of_measurement, + self._confirmed_value, + ) + + @property + def available(self) -> bool: + """Charger up with SmartCharging; no transaction needed.""" + return bool( + self.central_system.session_default_limit_available( + self.cpid, self.connector_id + ) + ) + + @property + def native_value(self) -> float | None: + """The last limit the charger accepted for this connector.""" + return self._attr_native_value + + @property + def extra_state_attributes(self) -> dict: + """Expose what the request writes.""" + return { + "profile_purpose": _TX_DEFAULT_PROFILE, + "profile_id": session_default_profile_id(self.connector_id), + "confirmed_current": self._confirmed_value, + } + + def _begin_set(self) -> None: + """Send without a transaction: a default profile needs none.""" + + async def _send(self, target: float) -> bool: + return await self.central_system.set_session_default_charge_rate_amps( + self.cpid, self.connector_id, target + ) diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index ea8cbd9c3..a93ea57d6 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -999,6 +999,37 @@ def build_session_limit_request( }, ) + def build_session_default_request( + self, + connector_id: int, + profile_id: int, + prepared: dict, + ): + """Build a Relative TxDefaultProfile for one connector. + + The id and the stack level one below the reported maximum (at least + 1) are the ones the set_charge_rate action uses for its + TxDefaultProfile leg, so the two address one profile. It is the shape + 0.11.4 sent after a refused station ceiling, which chargers in #2158 + apply. A reported maximum of 0 is still honoured. + """ + maximum = int(prepared["stack_level"]) + return call.SetChargingProfile( + connector_id=int(connector_id), + cs_charging_profiles={ + om.charging_profile_id: int(profile_id), + om.stack_level: min(maximum, max(1, maximum - 1)), + om.charging_profile_kind: ChargingProfileKindType.relative.value, + om.charging_profile_purpose: ChargingProfilePurposeType.tx_default_profile.value, + om.charging_schedule: { + om.charging_rate_unit: prepared["unit"], + om.charging_schedule_period: [ + {om.start_period: 0, om.limit: prepared["value"]} + ], + }, + }, + ) + async def set_station_charge_rate(self, limit_amps: int | float) -> bool: """Set only a station ceiling; transaction defaults cannot replace one.""" try: diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 7043cfe02..2a477ed61 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -910,6 +910,40 @@ def build_session_limit_request( } return call.SetChargingProfile(int(prepared["evse_id"]), profile) + def session_default_target_known(self, connector_id: int) -> bool: + """Only a reported mapping; _global_to_pair would guess EVSE n.""" + return ( + self._ensure_connector_map() and int(connector_id) in self._global_to_evse + ) + + def build_session_default_request( + self, + connector_id: int, + profile_id: int, + prepared: dict, + ): + """Build a Relative 2.0.1 TxDefaultProfile on the connector's EVSE. + + The same shape as 1.6: one stack level below the reported maximum, + at least 1, without exceeding a reported maximum of 0. + """ + maximum = int(prepared["stack_level"]) + schedule = { + "id": 1, + "charging_rate_unit": prepared["unit"], + "charging_schedule_period": [ + {"start_period": 0, "limit": prepared["value"]} + ], + } + profile = { + "id": int(profile_id), + "stack_level": min(maximum, max(1, maximum - 1)), + "charging_profile_purpose": ChargingProfilePurposeEnumType.tx_default_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [schedule], + } + return call.SetChargingProfile(int(prepared["evse_id"]), profile) + async def set_station_charge_rate(self, limit_amps: int | float) -> bool: """Use the managed station limit for the Maximum Current entity.""" return await self.set_charge_rate(limit_amps=limit_amps, conn_id=0) diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index e70e103f3..c274ba41f 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -53,6 +53,7 @@ "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile", "charge_point_max_profile_absolute": "Send the station charging limit as an absolute schedule", + "session_limit_default_profile": "Send Session Current Limit as a default profile, settable at any time", "enable_ha_notifications": "Enable Home Assistant notifications" } }, @@ -146,6 +147,7 @@ "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile", "charge_point_max_profile_absolute": "Send the station charging limit as an absolute schedule", + "session_limit_default_profile": "Send Session Current Limit as a default profile, settable at any time", "enable_ha_notifications": "Enable Home Assistant notifications" } }, diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index f3a7ea877..78e378446 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -33,6 +33,7 @@ "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile", "charge_point_max_profile_absolute": "Send the station charging limit as an absolute schedule", + "session_limit_default_profile": "Send Session Current Limit as a default profile, settable at any time", "enable_ha_notifications": "Enable Home Assistant notifications" } }, @@ -146,6 +147,7 @@ "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile", "charge_point_max_profile_absolute": "Send the station charging limit as an absolute schedule", + "session_limit_default_profile": "Send Session Current Limit as a default profile, settable at any time", "enable_ha_notifications": "Enable Home Assistant notifications" } }, diff --git a/docs/Charge_automation.md b/docs/Charge_automation.md index 27860dc1b..b7acc44c2 100644 --- a/docs/Charge_automation.md +++ b/docs/Charge_automation.md @@ -64,6 +64,16 @@ What this control does not do, so that automations do not assume it: * Once moved, the slider's profile stays on the charger until the transaction ends; the slider cannot remove it. At the maximum it still takes precedence over a `TxDefaultProfile` or a `TxProfile` at a lower stack level rather than handing control back to it. On a watt-only station the maximum is converted to watts like any other value, so if fewer phases are counted than the supply has, it caps the session below what it could draw. * A custom `TxProfile` sent with the same stack level and connector replaces the slider's profile under the SetChargingProfile replacement rule, and the slider does not learn of it. When creating custom profiles, avoid the stack level the session slider uses, which is the charger's reported maximum, and its profile id. The slider and the 1.6 action's TxProfile leg share one profile id, so they overwrite each other. `ChargePointMaxProfile` remains a ceiling over everything regardless of stack level, so the station and session sliders never compete: the applied limit is the lower of the two. +### Session Current Limit as a default profile + +Some chargers refuse both the station-wide `ChargePointMaxProfile` and a transaction-bound `TxProfile`, but accept a `TxDefaultProfile` on the connector and apply it to the running transaction too (#2158: Timxon A0, ABB Terra AC, Morek MEV11). For those, enable **Send Session Current Limit as a default profile** in the charge point's options. The option changes only how `Session Current Limit` works: + +* Moving the slider sends one `SetChargingProfile`: a `TxDefaultProfile` of kind `Relative` with no transaction id, on the slider's connector (1.6) or on that connector's EVSE (2.0.1). It uses profile id `2000+n`, where `n` is the connector, at one stack level below the charger's reported maximum (minimum 1, and 0 if the charger reports 0). On 1.6 that is the same profile id and stack level the `ocpp.set_charge_rate` action uses for its `TxDefaultProfile` leg, so the action and the slider overwrite each other, and the slider does not learn of a value the action set. +* The slider is available whenever the charger is connected with SmartCharging, whether or not a transaction is running. On 2.0.1 it also waits until the charger has reported which EVSE the connector is on, so a profile that outlives the request is never sent to a guessed EVSE. Its value is the last limit the charger accepted for the connector. It is kept across transactions and restored after a restart, because the charger keeps the profile. A value left over from before the option was enabled is not restored. +* The profile stays on the charger after the option is turned off. To lift it, set the slider to the maximum before turning the option off. On 1.6, `ocpp.clear_profile` also removes it, together with every other profile on the charger. On 2.0.1 it does not: that action clears only the station-wide `ChargingStationMaxProfile`, so raise the slider first. +* On a 2.0.1 charger with more than one connector on an EVSE, the connectors' sliders target the same EVSE with different profile ids at the same stack level. OCPP 2.0.1 forbids that combination (K01.FR.06), so expect the charger to reject the second connector's limit. One connector per EVSE, the usual layout, is unaffected. +* Turn the option off before installing an older release of the integration. Releases before it do not know the setting, and fail to start a charger whose configuration has it switched on. A charger that never had it on stores nothing, so it is unaffected. + Essentially, the slider in your GUI maintains control over the absolute maximum current the charger can utilize. ### What `ocpp.set_charge_rate` does with `limit_amps` diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py index e7c11ab11..2d01c84c0 100644 --- a/tests/test_api_paths.py +++ b/tests/test_api_paths.py @@ -1396,3 +1396,95 @@ async def set_limit(connector_id, amps): assert calls == [(1, 16.0), (2, 32.0)] with pytest.raises(HomeAssistantError): await cs.set_session_charge_rate_amps("missing", 1, 16) + + +@pytest.mark.asyncio +async def test_session_default_limit_availability_rule(hass): + """Charger up with SMART; no transaction or connector status needed.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + cs = CentralSystem(hass, entry) + targets = {1} + cp, metrics = _session_cp( + session_default_target_known=lambda connector: connector in targets + ) + cs.charge_points["CP_1"] = cp + cs.cpids["cpid_1"] = "CP_1" + + assert cs.session_default_limit_available("cpid_1", 1) + assert not cs.session_default_limit_available("cpid_1", 2) # target unknown + metrics[(1, cstat.status_connector)].value = "Available" + metrics[(1, csess.transaction_id)].value = None + cp.transaction_is_unsafe = lambda _c: True + assert cs.session_default_limit_available("cpid_1", 1) + assert not cs.session_default_limit_available("missing", 1) + + cp.supported_features = 0 + assert not cs.session_default_limit_available("cpid_1", 1) + cp.supported_features = prof.SMART + cp.status = STATE_UNAVAILABLE + assert not cs.session_default_limit_available("cpid_1", 1) + + +@pytest.mark.asyncio +async def test_set_session_default_charge_rate_amps_reaches_the_charge_point(hass): + """The value goes to the connector's default limit, by cpid or cp_id.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + cs = CentralSystem(hass, entry) + calls = [] + + async def set_default(connector_id, amps): + calls.append((connector_id, amps)) + return True + + cp, _metrics = _session_cp(set_session_default_limit=set_default) + cs.charge_points["CP_1"] = cp + cs.cpids["cpid_1"] = "CP_1" + + assert await cs.set_session_default_charge_rate_amps("cpid_1", 1, 16) is True + assert await cs.set_session_default_charge_rate_amps("CP_1", 2, 32) is True + assert calls == [(1, 16.0), (2, 32.0)] + with pytest.raises(HomeAssistantError): + await cs.set_session_default_charge_rate_amps("missing", 1, 16) + + +@pytest.mark.asyncio +async def test_settings_ignore_keys_from_a_later_release(hass, monkeypatch): + """A rollback must not fail a charger on a setting this release does not know.""" + from unittest.mock import AsyncMock + + from custom_components.ocpp.const import CONF_CPIDS + + from tests.const import MOCK_CONFIG_CP_APPEND + + stored = {**MOCK_CONFIG_CP_APPEND, "setting_from_the_future": True} + entry = MockConfigEntry( + domain=DOMAIN, + data={ + **MOCK_CONFIG_DATA, + "setting_from_the_future": True, + CONF_CPIDS: [{"CP_future": stored}], + }, + ) + entry.add_to_hass(hass) + cs = CentralSystem(hass, entry) + assert cs.settings.csid == MOCK_CONFIG_DATA["csid"] + + cp = SimpleNamespace(start=AsyncMock()) + built = [] + + def build(_cp_id, _websocket, settings): + built.append(settings) + return cp + + monkeypatch.setattr(cs, "_build_charge_point", build) + websocket = SimpleNamespace( + subprotocol="ocpp1.6", request=SimpleNamespace(path="/CP_future") + ) + await cs.on_connect(websocket) + + assert [settings.cpid for settings in built] == [stored["cpid"]] + assert not hasattr(built[0], "setting_from_the_future") + assert cs.charge_points["CP_future"] is cp + cp.start.assert_awaited_once() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 2e041266c..bd4eabc12 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -11,6 +11,7 @@ from custom_components.ocpp.const import ( CONF_ENABLE_HA_NOTIFICATIONS, CONF_NUM_CONNECTORS, + CONF_SESSION_LIMIT_DEFAULT_PROFILE, DEFAULT_NUM_CONNECTORS, DOMAIN, ) @@ -615,3 +616,48 @@ async def test_reconfigure_does_not_schedule_second_reload(hass, bypass_get_data "reconfigure scheduled its own reload; the update listener already " f"reloads the entry (scheduled={scheduled})" ) + + +async def test_discovery_flow_stores_the_session_default_only_when_on( + hass, bypass_get_data +): + """An enabled session default is stored; the default (off) is not.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_CS, + entry_id="test_cms_session_default", + title="test_cms_session_default", + version=2, + minor_version=0, + ) + hass.data.setdefault(DOMAIN, {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + + async def add(cp_id, cpid, enabled): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={"cp_id": cp_id, "entry": entry}, + ) + cp_input = {**MOCK_CONFIG_CP, CONF_CPID: cpid} + if enabled is not None: + cp_input[CONF_SESSION_LIMIT_DEFAULT_PROFILE] = enabled + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=cp_input + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await add("cp_on", "cpid_on", True) + await add("cp_off", "cpid_off", False) + await add("cp_default", "cpid_default", None) + stored = { + cp_id: settings + for item in entry.data[CONF_CPIDS] + for cp_id, settings in item.items() + } + assert stored["cp_on"][CONF_SESSION_LIMIT_DEFAULT_PROFILE] is True + assert CONF_SESSION_LIMIT_DEFAULT_PROFILE not in stored["cp_off"] + assert CONF_SESSION_LIMIT_DEFAULT_PROFILE not in stored["cp_default"] diff --git a/tests/test_flat_maximum_current.py b/tests/test_flat_maximum_current.py index f0cc733d6..a5d8675ef 100644 --- a/tests/test_flat_maximum_current.py +++ b/tests/test_flat_maximum_current.py @@ -819,3 +819,216 @@ async def accepted(_id, _connector, _value): await entity.async_set_native_value(7) assert entity.native_value == 7 + + +def _restore_session(hass, value, attributes): + entity_id = "number.test_cpid_session_current_limit" + data = NumberExtraStoredData(48, 0, 1, UnitOfElectricCurrent.AMPERE, value) + mock_restore_cache_with_extra_data( + hass, [(State(entity_id, str(value), attributes), data.as_dict())] + ) + + +async def test_default_profile_session_entity_needs_no_transaction( + hass, flat_entry, setup_flat, monkeypatch +): + """With the option on, the slider writes a connector TxDefaultProfile.""" + from ocpp.v16.enums import ChargingProfileStatus + + from custom_components.ocpp.number import SessionDefaultLimitNumber + + _configure(hass, flat_entry, num_connectors=1, session_limit_default_profile=True) + central = await setup_flat() + entity = _session_number(hass) + assert isinstance(entity, SessionDefaultLimitNumber) + assert not entity.available + assert entity.native_value is None + + cp = _attach_protocol(hass, flat_entry, central) + assert entity.available # no transaction, connector status unknown + assert entity.extra_state_attributes == { + "profile_purpose": "TxDefaultProfile", + "profile_id": 2001, + "confirmed_current": None, + } + + sent = [] + + async def configuration(key): + if key == ConfigurationKey.charging_schedule_allowed_charging_rate_unit: + return "Current" + if key == ConfigurationKey.charge_profile_max_stack_level: + return "5" + return None + + async def accept(request): + sent.append(request) + return SimpleNamespace(status=ChargingProfileStatus.accepted) + + monkeypatch.setattr(cp, "get_configuration", configuration) + monkeypatch.setattr(cp, "call", accept) + + await entity.async_set_native_value(10) + assert entity.native_value == 10 + assert sent[-1].connector_id == 1 + profile = sent[-1].cs_charging_profiles + assert profile["chargingProfileId"] == 2001 + assert profile["stackLevel"] == 4 + assert profile["chargingProfilePurpose"] == "TxDefaultProfile" + assert "transactionId" not in profile + assert profile["chargingSchedule"]["chargingSchedulePeriod"][0]["limit"] == 10.0 + assert hass.states.get(entity.entity_id).attributes["profile_purpose"] == ( + "TxDefaultProfile" + ) + + # The profile outlives transactions, so the value does too. + _charging(cp, 1, 55) + assert entity.native_value == 10 + _charging(cp, 1, None) + assert entity.native_value == 10 + assert entity.available + + async def not_supported(request): + sent.append(request) + return SimpleNamespace(status="NotSupported") + + monkeypatch.setattr(cp, "call", not_supported) + with pytest.raises(HomeAssistantError, match="NotSupported"): + await entity.async_set_native_value(6) + assert entity.native_value == 10 # back to the last accepted value + assert entity.extra_state_attributes["confirmed_current"] == 10 + + cp._attr_supported_features = 0 + assert not entity.available + + +async def test_default_profile_session_entity_works_on_201( + hass, flat_entry, setup_flat, monkeypatch +): + """On 2.0.1 the slider is available without a transaction and sets the EVSE default.""" + _configure(hass, flat_entry, num_connectors=1, session_limit_default_profile=True) + central = await setup_flat() + entity = _session_number(hass) + cp = _attach_protocol(hass, flat_entry, central, protocol="2.0.1") + cp._inventory = InventoryReport( + evse_count=1, + connector_count=[1], + charging_rate_units=frozenset({"A"}), + profile_stack_level=3, + ) + cp._build_connector_map() + assert entity.available + sent = [] + + async def accept(request): + sent.append(request) + return SimpleNamespace(status="Accepted") + + monkeypatch.setattr(cp, "call", accept) + await entity.async_set_native_value(12) + assert entity.native_value == 12 + assert sent[-1].evse_id == 1 + profile = sent[-1].charging_profile + assert (profile["id"], profile["stack_level"]) == (2001, 2) + assert profile["charging_profile_purpose"] == "TxDefaultProfile" + + +async def test_default_profile_session_entity_waits_for_the_evse_mapping_on_201( + hass, flat_entry, setup_flat, monkeypatch +): + """No persistent profile goes to a guessed EVSE before the charger names it.""" + _configure(hass, flat_entry, num_connectors=1, session_limit_default_profile=True) + central = await setup_flat() + entity = _session_number(hass) + cp = _attach_protocol(hass, flat_entry, central, protocol="2.0.1") + cp.post_connect_success = True + cp.num_connectors = 1 + # Capabilities without connector topology: no mapping can be built yet. + cp._inventory = InventoryReport( + charging_rate_units=frozenset({"A"}), profile_stack_level=3 + ) + cp._inventory_mapping_pending = False + sent = [] + + async def accept(request): + sent.append(request) + return SimpleNamespace(status="Accepted") + + monkeypatch.setattr(cp, "call", accept) + assert not entity.available + with pytest.raises(HomeAssistantError, match="which EVSE connector 1 is on"): + await entity.async_set_native_value(6) + assert sent == [] + assert entity.native_value is None + + # The first reported pair becomes logical connector 1. + cp.on_status_notification("2026-10-04T00:00:00Z", "Available", 2, 1) + assert entity.available + await entity.async_set_native_value(6) + assert sent[-1].evse_id == 2 + assert entity.native_value == 6 + + +@pytest.mark.parametrize( + ("attributes", "expected"), + [ + ({"profile_purpose": "TxDefaultProfile"}, 12), + ({"transaction_id": "55", "profile_id": 3001}, None), + ({}, None), + ], +) +async def test_default_profile_session_entity_restores_only_its_own_value( + hass, flat_entry, setup_flat, attributes, expected +): + """The charger keeps a default profile; a transaction-bound value has ended.""" + _restore_session(hass, 12, attributes) + _configure(hass, flat_entry, num_connectors=1, session_limit_default_profile=True) + await setup_flat() + entity = _session_number(hass) + assert entity.native_value == expected + assert entity.extra_state_attributes["confirmed_current"] == expected + + +async def test_default_profile_session_entity_ignores_an_unknown_restored_value( + hass, flat_entry, setup_flat +): + """A restored state with no number stays unknown.""" + _restore_session(hass, None, {"profile_purpose": "TxDefaultProfile"}) + _configure(hass, flat_entry, num_connectors=1, session_limit_default_profile=True) + await setup_flat() + assert _session_number(hass).native_value is None + + +async def test_default_profile_session_entity_saves_only_what_was_accepted( + hass, flat_entry, setup_flat, monkeypatch +): + """A request in flight is never saved as a limit, and a cancel reverts it.""" + _configure(hass, flat_entry, num_connectors=1, session_limit_default_profile=True) + central = await setup_flat() + entity = _session_number(hass) + _attach_protocol(hass, flat_entry, central) + + async def accepted(_id, _connector, _value): + return True + + monkeypatch.setattr(central, "set_session_default_charge_rate_amps", accepted) + await entity.async_set_native_value(32) + + started = asyncio.Event() + + async def stalled(_id, _connector, _value): + started.set() + await asyncio.Event().wait() + + monkeypatch.setattr(central, "set_session_default_charge_rate_amps", stalled) + task = asyncio.create_task(entity.async_set_native_value(6)) + await started.wait() + assert entity.native_value == 6 # optimistic display + assert entity.extra_restore_state_data.native_value == 32 + assert entity.extra_state_attributes["confirmed_current"] == 32 + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert entity.native_value == 32 + assert hass.states.get(entity.entity_id).state == "32.0" diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index ae31ec92c..52d8b2e71 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -23,6 +23,7 @@ CONF_CPID, CONF_CPIDS, CONF_ENABLE_HA_NOTIFICATIONS, + CONF_SESSION_LIMIT_DEFAULT_PROFILE, CONF_FORCE_SMART_CHARGING, CONF_IDLE_INTERVAL, CONF_MAX_CURRENT, @@ -171,6 +172,43 @@ async def test_editing_absolute_charge_point_max_profile(hass): assert _stored(entry, "CP_1")[CONF_CHARGE_POINT_MAX_PROFILE_ABSOLUTE] is True +async def test_editing_session_limit_default_profile(hass): + """The default-profile session limit toggle round-trips through the options flow.""" + entry = _entry(hass, [{"CP_1": _cp_settings()}]) + + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MAX_CURRENT: 32, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_METER_INTERVAL: 60, + CONF_IDLE_INTERVAL: 900, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, + CONF_SESSION_LIMIT_DEFAULT_PROFILE: True, + }, + ) + + assert _stored(entry, "CP_1")[CONF_SESSION_LIMIT_DEFAULT_PROFILE] is True + + # Off again: the key leaves the record, so a rollback can still load it. + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MAX_CURRENT: 32, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_METER_INTERVAL: 60, + CONF_IDLE_INTERVAL: 900, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, + CONF_SESSION_LIMIT_DEFAULT_PROFILE: False, + }, + ) + assert CONF_SESSION_LIMIT_DEFAULT_PROFILE not in _stored(entry, "CP_1") + + async def test_the_entry_is_updated_exactly_once(hass): """One write, one reload - the double-reload bug must stay dead. diff --git a/tests/test_set_charge_rate_v16.py b/tests/test_set_charge_rate_v16.py index ad48437f6..c00fef847 100644 --- a/tests/test_set_charge_rate_v16.py +++ b/tests/test_set_charge_rate_v16.py @@ -666,3 +666,76 @@ async def record(request): with pytest.raises(HomeAssistantError, match="changed while"): await cp_v16.set_session_limit(1, 10) assert sent == [] + + +def test_session_default_request_builder_v16(cp_v16): + """The default profile is the action's TxDefaultProfile leg, with no transaction.""" + request = cp_v16.build_session_default_request( + 2, 2002, {"unit": "A", "value": 12.0, "stack_level": 5} + ) + assert request.connector_id == 2 + profile = request.cs_charging_profiles + assert profile["chargingProfileId"] == 2002 + assert profile["stackLevel"] == 4 + assert "transactionId" not in profile + assert profile["chargingProfileKind"] == "Relative" + assert profile["chargingProfilePurpose"] == "TxDefaultProfile" + assert profile["chargingSchedule"]["chargingSchedulePeriod"] == [ + {"startPeriod": 0, "limit": 12.0} + ] + for reported, expected in ((0, 0), (1, 1), (2, 1)): + request = cp_v16.build_session_default_request( + 1, 2001, {"unit": "A", "value": 6.0, "stack_level": reported} + ) + assert request.cs_charging_profiles["stackLevel"] == expected + assert expected <= reported + + +@pytest.mark.asyncio +async def test_set_session_default_limit_v16_needs_no_transaction(cp_v16, monkeypatch): + """Only an Accepted reply confirms; everything else raises a clear error.""" + from homeassistant.exceptions import HomeAssistantError + + async def configuration(key): + if key == ckey.charging_schedule_allowed_charging_rate_unit: + return "Current" + if key == ckey.charge_profile_max_stack_level: + return "5" + return None + + monkeypatch.setattr(cp_v16, "get_configuration", configuration) + sent = [] + + async def accept(request): + sent.append(request) + return SimpleNamespace(status=ChargingProfileStatus.accepted) + + monkeypatch.setattr(cp_v16, "call", accept) + cp_v16._tx_indeterminate = {1} # a held connector does not matter here + assert await cp_v16.set_session_default_limit(1, 10) is True + assert sent[-1].connector_id == 1 + profile = sent[-1].cs_charging_profiles + assert (profile["chargingProfileId"], profile["stackLevel"]) == (2001, 4) + assert profile["chargingProfilePurpose"] == "TxDefaultProfile" + assert profile["chargingSchedule"]["chargingSchedulePeriod"][0]["limit"] == 10.0 + + async def not_supported(_request): + return SimpleNamespace(status="NotSupported") + + monkeypatch.setattr(cp_v16, "call", not_supported) + with pytest.raises(HomeAssistantError, match=r"rejected .*\(NotSupported\)"): + await cp_v16.set_session_default_limit(1, 10) + + async def timeout(_request): + raise TimeoutError() + + monkeypatch.setattr(cp_v16, "call", timeout) + with pytest.raises(HomeAssistantError, match="did not answer"): + await cp_v16.set_session_default_limit(1, 10) + + async def broken(_request): + raise RuntimeError("closed") + + monkeypatch.setattr(cp_v16, "call", broken) + with pytest.raises(HomeAssistantError, match="failed: closed"): + await cp_v16.set_session_default_limit(1, 10) diff --git a/tests/test_v201_set_charge_rate_return.py b/tests/test_v201_set_charge_rate_return.py index b8340ad90..d9d22a25b 100644 --- a/tests/test_v201_set_charge_rate_return.py +++ b/tests/test_v201_set_charge_rate_return.py @@ -663,3 +663,48 @@ async def record(request): with pytest.raises(HomeAssistantError, match="changed while"): await cp.set_session_limit(1, 10) assert sent == [] + + +@pytest.mark.asyncio +async def test_session_default_limit_targets_the_connectors_evse(hass): + """A Relative TxDefaultProfile on the EVSE, no transaction, below the maximum.""" + cp = _mk_cp(hass) + cp._inventory = InventoryReport( + evse_count=2, + connector_count=[1, 1], + charging_rate_units=frozenset({"A"}), + profile_stack_level=5, + ) + cp._build_connector_map() + sent = [] + + async def accept(request): + sent.append(request) + return SimpleNamespace(status="Accepted") + + cp.call = accept + assert await cp.set_session_default_limit(2, 10) is True + assert sent[-1].evse_id == 2 + profile = sent[-1].charging_profile + assert profile["id"] == 2002 + assert profile["stack_level"] == 4 + assert profile["charging_profile_purpose"] == "TxDefaultProfile" + assert profile["charging_profile_kind"] == "Relative" + assert "transaction_id" not in profile + assert profile["charging_schedule"][0]["charging_rate_unit"] == "A" + assert profile["charging_schedule"][0]["charging_schedule_period"] == [ + {"start_period": 0, "limit": 10.0} + ] + + for reported, expected in ((0, 0), (1, 1), (2, 1)): + request = cp.build_session_default_request( + 1, 2001, {"unit": "A", "value": 6.0, "stack_level": reported, "evse_id": 1} + ) + assert request.charging_profile["stack_level"] == expected + + async def reject(_request): + return SimpleNamespace(status="Rejected") + + cp.call = reject + with pytest.raises(HomeAssistantError, match=r"rejected .*\(Rejected\)"): + await cp.set_session_default_limit(1, 10)