Skip to content

Commit e6a95c5

Browse files
KingHavokKingHavok
andauthored
Add an option to send Session Current Limit as a TxDefaultProfile (#2158) (#2183)
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. Co-authored-by: KingHavok <shane@shanewilliams.com.au>
1 parent 8d07bd8 commit e6a95c5

16 files changed

Lines changed: 859 additions & 28 deletions

‎custom_components/ocpp/api.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
OCPP_2_0,
3030
OCPP_VERSION_AUTO,
3131
ChargerSystemSettings,
32+
settings_from_config,
3233
)
3334
from .enums import (
3435
HAChargerServices as csvcs,
@@ -179,7 +180,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
179180
"""Instantiate instance of a CentralSystem."""
180181
self.hass = hass
181182
self.entry = entry
182-
self.settings = CentralSystemSettings(**entry.data)
183+
self.settings = settings_from_config(CentralSystemSettings, entry.data)
183184
self.subprotocols = self._resolve_subprotocols(self.settings)
184185
self._server = None
185186
self.id = self.settings.csid
@@ -338,7 +339,9 @@ async def on_connect(self, websocket: ServerConnection):
338339
for cfg in self.settings.cpids:
339340
if cfg.get(cp_id):
340341
config_flow = True
341-
cp_settings = ChargerSystemSettings(**list(cfg.values())[0])
342+
cp_settings = settings_from_config(
343+
ChargerSystemSettings, list(cfg.values())[0]
344+
)
342345
_LOGGER.info(
343346
f"Charger match found for {cp_settings.cpid}:{cp_id}"
344347
)
@@ -800,6 +803,38 @@ async def set_session_charge_rate_amps(
800803
)
801804
return await cp.set_session_limit(int(connector_id), float(value))
802805

806+
def session_default_limit_available(self, id: str, connector_id: int) -> bool:
807+
"""Whether a connector's default session limit can be set right now.
808+
809+
The charger must be up with SmartCharging and have said where the
810+
connector's profile goes. A TxDefaultProfile needs no transaction, so
811+
the connector's status does not matter. The charge point checks the
812+
target again before sending.
813+
"""
814+
_cp_id, _m, cp, _n = self._get_metrics(id)
815+
if cp is None or cp.status != STATE_OK:
816+
return False
817+
if not bool(cp.supported_features & prof.SMART):
818+
return False
819+
return bool(cp.session_default_target_known(self._norm_conn(connector_id)))
820+
821+
async def set_session_default_charge_rate_amps(
822+
self,
823+
id: str,
824+
connector_id: int,
825+
value: float,
826+
) -> bool:
827+
"""Set a connector's default session limit."""
828+
cp_id = self.cpids.get(id, id)
829+
cp = self.charge_points.get(cp_id)
830+
if cp is None:
831+
raise HomeAssistantError(
832+
translation_domain=DOMAIN,
833+
translation_key="unavailable",
834+
translation_placeholders={"message": id},
835+
)
836+
return await cp.set_session_default_limit(int(connector_id), float(value))
837+
803838
async def set_max_charge_rate_amps(
804839
self, id: str, value: float, connector_id: int = 0
805840
):

‎custom_components/ocpp/chargepoint.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ def session_profile_id(connector_id: int) -> int:
100100
return SESSION_PROFILE_BASE_ID + int(connector_id)
101101

102102

103+
SESSION_DEFAULT_PROFILE_BASE_ID = 2000
104+
105+
106+
def session_default_profile_id(connector_id: int) -> int:
107+
"""Return the profile id a connector's default session limit uses on both protocols.
108+
109+
It is the id the 1.6 ``ocpp.set_charge_rate`` action already uses for its
110+
TxDefaultProfile leg, so on 1.6 the slider and the action write one
111+
profile.
112+
"""
113+
return SESSION_DEFAULT_PROFILE_BASE_ID + int(connector_id)
114+
115+
103116
class Metric:
104117
"""Metric class."""
105118

@@ -490,6 +503,62 @@ def build_session_limit_request(
490503
"""Build the protocol's SetChargingProfile for a transaction-bound limit."""
491504
raise NotImplementedError
492505

506+
def build_session_default_request(
507+
self,
508+
connector_id: int,
509+
profile_id: int,
510+
prepared: dict,
511+
):
512+
"""Build the protocol's SetChargingProfile for a connector default limit."""
513+
raise NotImplementedError
514+
515+
def session_default_target_known(self, connector_id: int) -> bool:
516+
"""Return whether the charger has said where the connector's profile goes.
517+
518+
A default profile outlives the request, so it must not be sent to a
519+
guessed target. 1.6 addresses the connector itself; 2.0.1 overrides.
520+
"""
521+
return True
522+
523+
async def set_session_default_limit(
524+
self, connector_id: int, limit_amps: float
525+
) -> bool:
526+
"""Set a TxDefaultProfile on the connector, with or without a transaction.
527+
528+
For chargers that refuse both the station ceiling and a TxProfile but
529+
accept a connector default, which they also apply to the running
530+
transaction. Returns True only for an Accepted reply; anything else
531+
raises a HomeAssistantError, as set_session_limit does. The profile
532+
stays on the charger until it is replaced; nothing is cleared.
533+
"""
534+
conn = int(connector_id)
535+
prepared = await self.prepare_session_limit(conn, float(limit_amps))
536+
# Preparation waits for the charger's inventory, which is what
537+
# settles the target on 2.0.1, so it is checked after it.
538+
if not self.session_default_target_known(conn):
539+
raise HomeAssistantError(
540+
f"the charger has not reported which EVSE connector {conn} is on"
541+
)
542+
request = self.build_session_default_request(
543+
conn, session_default_profile_id(conn), prepared
544+
)
545+
try:
546+
resp = await self.call(request)
547+
except TimeoutError as ex:
548+
raise HomeAssistantError(
549+
f"the charger did not answer the default limit for connector {conn}"
550+
) from ex
551+
except Exception as ex:
552+
raise HomeAssistantError(
553+
f"default limit for connector {conn} failed: {ex}"
554+
) from ex
555+
status = getattr(resp, "status", None)
556+
if status == ChargingProfileStatus.accepted:
557+
return True
558+
raise HomeAssistantError(
559+
f"charger rejected the default limit for connector {conn} ({status})"
560+
)
561+
493562
async def set_session_limit(self, connector_id: int, limit_amps: float) -> bool:
494563
"""Bind a TxProfile to the connector's displayed transaction.
495564

‎custom_components/ocpp/config_flow.py‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
CONF_NUM_CONNECTORS,
2929
CONF_OCPP_VERSION,
3030
CONF_PORT,
31+
CONF_SESSION_LIMIT_DEFAULT_PROFILE,
3132
CONF_SKIP_SCHEMA_VALIDATION,
3233
CONF_SSL,
3334
CONF_SSL_CERTFILE_PATH,
@@ -51,6 +52,7 @@
5152
DEFAULT_NUM_CONNECTORS,
5253
DEFAULT_OCPP_VERSION,
5354
DEFAULT_PORT,
55+
DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE,
5456
DEFAULT_SKIP_SCHEMA_VALIDATION,
5557
DEFAULT_SSL,
5658
DEFAULT_SSL_CERTFILE_PATH,
@@ -110,6 +112,10 @@
110112
CONF_CHARGE_POINT_MAX_PROFILE_ABSOLUTE,
111113
default=DEFAULT_CHARGE_POINT_MAX_PROFILE_ABSOLUTE,
112114
): bool,
115+
vol.Required(
116+
CONF_SESSION_LIMIT_DEFAULT_PROFILE,
117+
default=DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE,
118+
): bool,
113119
vol.Required(
114120
CONF_ENABLE_HA_NOTIFICATIONS, default=DEFAULT_ENABLE_HA_NOTIFICATIONS
115121
): bool,
@@ -124,6 +130,22 @@
124130
)
125131

126132

133+
def _store_session_default_only_when_on(settings: dict[str, Any]) -> dict[str, Any]:
134+
"""Drop session_limit_default_profile from a charger record unless it is on.
135+
136+
Releases before it expand the record into ChargerSystemSettings and fail
137+
on a key they do not know. Leaving it out while it is off keeps a
138+
rollback safe for every charger that never enabled it.
139+
"""
140+
if settings.get(CONF_SESSION_LIMIT_DEFAULT_PROFILE):
141+
return settings
142+
return {
143+
key: value
144+
for key, value in settings.items()
145+
if key != CONF_SESSION_LIMIT_DEFAULT_PROFILE
146+
}
147+
148+
127149
class ConfigFlow(ConfigFlow, domain=DOMAIN):
128150
"""Handle a config flow for OCPP."""
129151

@@ -263,10 +285,12 @@ async def async_step_cp_user(
263285
errors["base"] = "duplicate_cpid"
264286

265287
if not errors:
266-
cp_data = {
267-
**user_input,
268-
CONF_NUM_CONNECTORS: self._detected_num_connectors,
269-
}
288+
cp_data = _store_session_default_only_when_on(
289+
{
290+
**user_input,
291+
CONF_NUM_CONNECTORS: self._detected_num_connectors,
292+
}
293+
)
270294
cpids_list = self._data.get(CONF_CPIDS, []).copy()
271295
cpids_list.append({self._cp_id: cp_data})
272296
self._data = {**self._data, CONF_CPIDS: cpids_list}
@@ -371,7 +395,9 @@ def _finalize(self) -> ConfigFlowResult:
371395
cpids = [
372396
{
373397
cp_id: (
374-
{**stored, **self._settings} if cp_id == self._cp_id else stored
398+
_store_session_default_only_when_on({**stored, **self._settings})
399+
if cp_id == self._cp_id
400+
else stored
375401
)
376402
for cp_id, stored in item.items()
377403
}
@@ -468,6 +494,13 @@ async def async_step_cp_settings(
468494
DEFAULT_CHARGE_POINT_MAX_PROFILE_ABSOLUTE,
469495
),
470496
): bool,
497+
vol.Required(
498+
CONF_SESSION_LIMIT_DEFAULT_PROFILE,
499+
default=current.get(
500+
CONF_SESSION_LIMIT_DEFAULT_PROFILE,
501+
DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE,
502+
),
503+
): bool,
471504
vol.Required(
472505
CONF_ENABLE_HA_NOTIFICATIONS,
473506
default=current.get(

‎custom_components/ocpp/const.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pathlib
44

5-
from dataclasses import dataclass, field
5+
from dataclasses import dataclass, field, fields
66
import homeassistant.components.input_number as input_number
77
from homeassistant.components.sensor import SensorDeviceClass
88
import homeassistant.const as ha
@@ -31,6 +31,7 @@
3131
CONF_OCPP_VERSION = "ocpp_version"
3232
CONF_PASSWORD = ha.CONF_PASSWORD
3333
CONF_PORT = ha.CONF_PORT
34+
CONF_SESSION_LIMIT_DEFAULT_PROFILE = "session_limit_default_profile"
3435
CONF_SKIP_SCHEMA_VALIDATION = "skip_schema_validation"
3536
CONF_FORCE_SMART_CHARGING = "force_smart_charging"
3637
CONF_SSL = "ssl"
@@ -55,6 +56,7 @@
5556
DEFAULT_PORT = 9000
5657
DEFAULT_SKIP_SCHEMA_VALIDATION = False
5758
DEFAULT_FORCE_SMART_CHARGING = False
59+
DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE = False
5860
DEFAULT_SSL = False
5961
DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem")
6062
DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem")
@@ -197,6 +199,7 @@ class ChargerSystemSettings:
197199
connection: int | None = None # number of this connection in central server
198200
num_connectors: int = DEFAULT_NUM_CONNECTORS
199201
charge_point_max_profile_absolute: bool = DEFAULT_CHARGE_POINT_MAX_PROFILE_ABSOLUTE
202+
session_limit_default_profile: bool = DEFAULT_SESSION_LIMIT_DEFAULT_PROFILE
200203

201204

202205
@dataclass
@@ -223,3 +226,14 @@ class CentralSystemSettings:
223226
# for id in self.cpids:
224227
# self.cpids[i] = ChargerSystemSettings(**id)
225228
# i =+ 1
229+
230+
231+
def settings_from_config(cls, data: dict):
232+
"""Build a settings dataclass from a stored config, ignoring unknown keys.
233+
234+
A key added by a later release is not a field here. Ignoring it lets
235+
this release still load an entry after a rollback, instead of failing
236+
every charger with an unexpected keyword argument.
237+
"""
238+
known = {f.name for f in fields(cls)}
239+
return cls(**{key: value for key, value in data.items() if key in known})

0 commit comments

Comments
 (0)