Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions custom_components/ocpp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
OCPP_2_0,
OCPP_VERSION_AUTO,
ChargerSystemSettings,
settings_from_config,
)
from .enums import (
HAChargerServices as csvcs,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
)
Expand Down Expand Up @@ -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
):
Expand Down
69 changes: 69 additions & 0 deletions custom_components/ocpp/chargepoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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.

Expand Down
43 changes: 38 additions & 5 deletions custom_components/ocpp/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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."""

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 15 additions & 1 deletion custom_components/ocpp/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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})
Loading
Loading