From 68490d33abfb6c16bf15bac6ff7454f374bf72ee Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Tue, 12 Aug 2025 09:13:25 +0000 Subject: [PATCH 01/15] Update .gitignore for HA dev. Use symlinks for integration. --- .gitignore | 4 ++++ scripts/develop | 23 +++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index b6e47617..734c2a98 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +# HA Development +config/ +**/.DS_Store diff --git a/scripts/develop b/scripts/develop index 5bddb789..10f5af38 100755 --- a/scripts/develop +++ b/scripts/develop @@ -1,5 +1,4 @@ #!/usr/bin/env bash - set -e cd "$(dirname "$0")/.." @@ -7,18 +6,18 @@ cd "$(dirname "$0")/.." # Create config dir if not present if [[ ! -d "${PWD}/config" ]]; then mkdir -p "${PWD}/config" - hass --config "${PWD}/config" --script ensure_config + /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --script ensure_config fi -# Create custom components dir if not present -if [[ ! -d "${PWD}/config/custom_components" ]]; then - mkdir -p "${PWD}/config/custom_components" - hass --config "${PWD}/config" --script ensure_config -fi +# Ensure custom components dir exists +mkdir -p "${PWD}/config/custom_components" + +# Link the ocpp integration (overwrite if exists) +ln -sfn "${PWD}/custom_components/ocpp" "${PWD}/config/custom_components/ocpp" -# copy the ocpp integration -rm -rf $PWD/config/custom_components/ocpp -cp -r -l $PWD/custom_components/ocpp $PWD/config/custom_components/ +# Install debugpy if missing +/home/vscode/.local/ha-venv/bin/python -m pip install --quiet debugpy || true -# Start Home Assistant -hass --config "${PWD}/config" --debug +# Start Home Assistant with debugger +exec /home/vscode/.local/ha-venv/bin/python -m debugpy --listen 0.0.0.0:5678 \ + -m homeassistant --config "${PWD}/config" --debug From a72cec5f54e6fc28af9aac435a99b9a54871af66 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Fri, 15 Aug 2025 14:39:16 +0000 Subject: [PATCH 02/15] Handle multiple connectors, initial framework. --- custom_components/ocpp/api.py | 84 ++++++--- custom_components/ocpp/button.py | 59 ++++-- custom_components/ocpp/chargepoint.py | 257 +++++++++++++++++++------- custom_components/ocpp/config_flow.py | 7 +- custom_components/ocpp/const.py | 3 + custom_components/ocpp/number.py | 65 +++++-- custom_components/ocpp/ocppv16.py | 240 +++++++++++++----------- custom_components/ocpp/ocppv201.py | 96 +++++----- custom_components/ocpp/sensor.py | 66 +++++-- custom_components/ocpp/switch.py | 38 +++- tests/const.py | 25 +++ tests/test_charge_point_v16.py | 203 +++++++++++++++----- tests/test_charge_point_v201.py | 20 +- tests/test_config_flow.py | 2 + tests/test_init.py | 43 ++++- 15 files changed, 839 insertions(+), 369 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 467021a4..cdfd97ce 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import json import logging import ssl @@ -28,6 +29,7 @@ ) from .enums import ( HAChargerServices as csvcs, + HAChargerStatuses as cstat, ) from .chargepoint import SetVariableResult @@ -273,59 +275,93 @@ async def on_connect(self, websocket: ServerConnection): charge_point = self.charge_points[cp_id] await charge_point.reconnect(websocket) - def get_metric(self, id: str, measurand: str): + def get_metric(self, id: str, measurand: str, connector_id: int = 1): """Return last known value for given measurand.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].value - return None + if cp_id not in self.charge_points: + return None - def del_metric(self, id: str, measurand: str): + m = self.charge_points[cp_id]._metrics + try: + return m[connector_id][measurand].value + except Exception: + return None + + def del_metric(self, id: str, measurand: str, connector_id: int = 1): """Set given measurand to None.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: + return None - if self.cpids.get(cp_id) in self.charge_points: - self.charge_points[cp_id]._metrics[measurand].value = None + m = self.charge_points[cp_id]._metrics + m[connector_id][measurand].value = None return None - def get_unit(self, id: str, measurand: str): + def get_unit(self, id: str, measurand: str, connector_id: int = 1): """Return unit of given measurand.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: + return None - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].unit - return None + m = self.charge_points[cp_id]._metrics + return m[connector_id][measurand].unit - def get_ha_unit(self, id: str, measurand: str): + def get_ha_unit(self, id: str, measurand: str, connector_id: int = 1): """Return home assistant unit of given measurand.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].ha_unit - return None + if cp_id not in self.charge_points: + return None - def get_extra_attr(self, id: str, measurand: str): + m = self.charge_points[cp_id]._metrics + return m[connector_id][measurand].ha_unit + + def get_extra_attr(self, id: str, measurand: str, connector_id: int = 1): """Return last known extra attributes for given measurand.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: + return None - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].extra_attr - return None + m = self.charge_points[cp_id]._metrics + return m[connector_id][measurand].extra_attr - def get_available(self, id: str): - """Return whether the charger is available.""" + def get_available(self, id: str, connector_id: int | None = None): + """Return whether the charger (or a specific connector) is available.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: + return False - if cp_id in self.charge_points: - return self.charge_points[cp_id].status == STATE_OK - return False + cp = self.charge_points[cp_id] + + if connector_id is None or connector_id == 0: + return cp.status == STATE_OK + + m = cp._metrics + status_val = None + with contextlib.suppress(Exception): + status_val = m[connector_id][cstat.status_connector.value].value + + if not status_val: + try: + flat = m[cstat.status_connector.value] + if hasattr(flat, "extra_attr"): + status_val = flat.extra_attr.get(connector_id) or getattr( + flat, "value", None + ) + except Exception: + pass + + if not status_val: + return cp.status == STATE_OK + + return str(status_val).lower() in ("available", "preparing", "charging") def get_supported_features(self, id: str): """Return what profiles the charger supports.""" diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index d5763c86..24693586 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .api import CentralSystem -from .const import CONF_CPID, CONF_CPIDS, DOMAIN +from .const import CONF_CPID, CONF_CPIDS, CONF_NUM_CONNECTORS, DOMAIN from .enums import HAChargerServices @@ -23,6 +23,7 @@ class OcppButtonDescription(ButtonEntityDescription): """Class to describe a Button entity.""" press_action: str | None = None + per_connector: bool = False BUTTONS: Final = [ @@ -32,6 +33,7 @@ class OcppButtonDescription(ButtonEntityDescription): device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_reset.name, + per_connector=False, ), OcppButtonDescription( key="unlock", @@ -39,22 +41,42 @@ class OcppButtonDescription(ButtonEntityDescription): device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_unlock.name, + per_connector=True, ), ] async def async_setup_entry(hass, entry, async_add_devices): """Configure the Button platform.""" + central_system: CentralSystem = hass.data[DOMAIN][entry.entry_id] + entities: list[ChargePointButton] = [] - central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - for ent in BUTTONS: - cpx = ChargePointButton(central_system, cpid, ent) - entities.append(cpx) + num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1)) + + for desc in BUTTONS: + if desc.per_connector and num_connectors > 1: + for connector_id in range(1, num_connectors + 1): + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=connector_id, + ) + ) + else: + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=None, + ) + ) async_add_devices(entities, False) @@ -70,23 +92,34 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppButtonDescription, + connector_id: int | None = None, ): """Instantiate instance of a ChargePointButton.""" self.cpid = cpid self.central_system = central_system self.entity_description = description - self._attr_unique_id = ".".join( - [BUTTON_DOMAIN, DOMAIN, self.cpid, self.entity_description.key] - ) + self.connector_id = connector_id + parts = [BUTTON_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) @property def available(self) -> bool: """Return charger availability.""" - return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid, self.connector_id) # type: ignore[no-any-return] async def async_press(self) -> None: """Triggers the charger press action service.""" diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index d8fbe8f9..ff5e905d 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -2,6 +2,7 @@ import asyncio from collections import defaultdict +from collections.abc import MutableMapping from dataclasses import dataclass from enum import Enum import logging @@ -112,6 +113,93 @@ def extra_attr(self, extra_attr: dict): self._extra_attr = extra_attr +class _ConnectorAwareMetrics(MutableMapping): + """Backwards compatible mapping for metrics. + + - m["Power.Active.Import"] -> Metric for connector 0 (flat access) + - m[(2, "Power.Active.Import")] -> Metric for connector 2 (per connector) + - m[2] -> dict[str -> Metric] for connector 2 + + Iteration, len, keys(), items() etc act like flat dict (connector 0). + """ + + def __init__(self): + self._by_conn = defaultdict(lambda: defaultdict(lambda: Metric(None, None))) + + # --- flat (default connector 0) & connector-indexerad access --- + def __getitem__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + return self._by_conn[conn][meas] + if isinstance(key, int): + # Returnerar dict[str -> Metric] för connectorn + return self._by_conn[key] + # Platt: returnera Metric i connector 0 + return self._by_conn[0][key] + + def __setitem__(self, key, value): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + if not isinstance(value, Metric): + raise TypeError("Metric assignment must be a Metric instance.") + self._by_conn[conn][meas] = value + return + if isinstance(key, int): + if not isinstance(value, dict): + raise TypeError("Connector mapping must be dict[str, Metric].") + self._by_conn[key] = value + return + # Platt + if not isinstance(value, Metric): + raise TypeError("Metric assignment must be a Metric instance.") + self._by_conn[0][key] = value + + def __delitem__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + del self._by_conn[conn][meas] + return + if isinstance(key, int): + del self._by_conn[key] + return + del self._by_conn[0][key] + + def __iter__(self): + # Iterera som ett platt dict (connector 0) + return iter(self._by_conn[0]) + + def __len__(self): + # Storlek som platt dict (connector 0) + return len(self._by_conn[0]) + + # Hjälpmetoder i dict-stil + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def keys(self): + return self._by_conn[0].keys() + + def values(self): + return self._by_conn[0].values() + + def items(self): + return self._by_conn[0].items() + + def clear(self): + self._by_conn.clear() + + def __contains__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + return meas in self._by_conn.get(conn, {}) + if isinstance(key, int): + return key in self._by_conn + return key in self._by_conn[0] + + class OcppVersion(str, Enum): """OCPP version choice.""" @@ -188,19 +276,25 @@ def __init__( self.post_connect_success = False self.tasks = None self._charger_reports_session_energy = False - self._metrics = defaultdict(lambda: Metric(None, None)) - self._metrics[cdet.identifier.value].value = id - self._metrics[csess.session_time.value].unit = TIME_MINUTES - self._metrics[csess.session_energy.value].unit = UnitOfMeasure.kwh.value - self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value + + # Connector-aware, but backwards compatible: + self._metrics: _ConnectorAwareMetrics = _ConnectorAwareMetrics() + + # Init standard metrics for connector 0 + self._metrics[(0, cdet.identifier.value)].value = id + self._metrics[(0, csess.session_time.value)].unit = TIME_MINUTES + self._metrics[(0, csess.session_energy.value)].unit = UnitOfMeasure.kwh.value + self._metrics[(0, csess.meter_start.value)].unit = UnitOfMeasure.kwh.value + self._metrics[(0, cstat.reconnects.value)].value = 0 + self._attr_supported_features = prof.NONE - self._metrics[cstat.reconnects.value].value = 0 alphabet = string.ascii_uppercase + string.digits self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) + self.num_connectors: int = 1 async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" - return 0 + return self.num_connectors async def get_heartbeat_interval(self): """Retrieve heartbeat interval from the charger and store it.""" @@ -221,17 +315,17 @@ async def get_supported_features(self) -> prof: async def fetch_supported_features(self): """Get supported features.""" self._attr_supported_features = await self.get_supported_features() - self._metrics[cdet.features.value].value = self._attr_supported_features + self._metrics[(0, cdet.features.value)].value = self._attr_supported_features _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) async def post_connect(self): """Logic to be executed right after a charger connects.""" - try: self.status = STATE_OK await self.fetch_supported_features() num_connectors: int = await self.get_number_of_connectors() - self._metrics[cdet.connectors.value].value = num_connectors + self.num_connectors = num_connectors + self._metrics[(0, cdet.connectors.value)].value = num_connectors await self.get_heartbeat_interval() accepted_measurands: str = await self.get_supported_measurands() @@ -349,8 +443,8 @@ async def _get_specific_response(self, unique_id, timeout): async def monitor_connection(self): """Monitor the connection, by measuring the connection latency.""" - self._metrics[cstat.latency_ping.value].unit = "ms" - self._metrics[cstat.latency_pong.value].unit = "ms" + self._metrics[(0, cstat.latency_ping.value)].unit = "ms" + self._metrics[(0, cstat.latency_pong.value)].unit = "ms" connection = self._connection timeout_counter = 0 # Add backstop to start post connect for non-compliant chargers @@ -378,15 +472,15 @@ async def monitor_connection(self): _LOGGER.debug( f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong + self._metrics[(0, cstat.latency_ping.value)].value = latency_ping + self._metrics[(0, cstat.latency_pong.value)].value = latency_pong except TimeoutError as timeout_exception: _LOGGER.debug( f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong + self._metrics[(0, cstat.latency_ping.value)].value = latency_ping + self._metrics[(0, cstat.latency_pong.value)].value = latency_pong timeout_counter += 1 if timeout_counter > self.cs_settings.websocket_ping_tries: _LOGGER.debug( @@ -405,7 +499,6 @@ async def _handle_call(self, msg): async def start(self): """Start charge point.""" - # post connect now handled on receiving boot notification or with backstop in monitor connection await self.run([super().start(), self.monitor_connection()]) async def run(self, tasks): @@ -441,19 +534,19 @@ async def reconnect(self, connection: ServerConnection): await self.stop() self.status = STATE_OK self._connection = connection - self._metrics[cstat.reconnects.value].value += 1 + self._metrics[(0, cstat.reconnects.value)].value += 1 # post connect now handled on receiving boot notification or with backstop in monitor connection await self.run([super().start(), self.monitor_connection()]) async def async_update_device_info( self, serial: str, vendor: str, model: str, firmware_version: str ): - """Update device info asynchronuously.""" + """Update device info asynchronously.""" - self._metrics[cdet.model.value].value = model - self._metrics[cdet.vendor.value].value = vendor - self._metrics[cdet.firmware_version.value].value = firmware_version - self._metrics[cdet.serial.value].value = serial + self._metrics[(0, cdet.model.value)].value = model + self._metrics[(0, cdet.vendor.value)].value = vendor + self._metrics[(0, cdet.firmware_version.value)].value = firmware_version + self._metrics[(0, cdet.serial.value)].value = serial identifiers = {(DOMAIN, self.id), (DOMAIN, self.settings.cpid)} @@ -490,7 +583,6 @@ def get_authorization_status(self, id_tag): # authorize if its the tag of this charger used for remote start_transaction if id_tag == self._remote_id_tag: return AuthorizationStatus.accepted.value - # get the domain wide configuration config = self.hass.data[DOMAIN].get(CONFIG, {}) # get the default authorization status. Use accept if not configured default_auth_status = config.get( @@ -517,8 +609,8 @@ def get_authorization_status(self, id_tag): ) return auth_status - def process_phases(self, data: list[MeasurandValue]): - """Process phase data from meter values .""" + def process_phases(self, data: list[MeasurandValue], connector_id: int = 0): + """Process phase data from meter values.""" def average_of_nonzero(values): nonzero_values: list = [v for v in values if v != 0.0] @@ -539,10 +631,14 @@ def average_of_nonzero(values): measurand_data[measurand] = {} measurand_data[measurand][om.unit.value] = unit measurand_data[measurand][phase] = value - self._metrics[measurand].unit = unit - self._metrics[measurand].extra_attr[om.unit.value] = unit - self._metrics[measurand].extra_attr[phase] = value - self._metrics[measurand].extra_attr[om.context.value] = context + self._metrics[(connector_id, measurand)].unit = unit + self._metrics[(connector_id, measurand)].extra_attr[om.unit.value] = ( + unit + ) + self._metrics[(connector_id, measurand)].extra_attr[phase] = value + self._metrics[(connector_id, measurand)].extra_attr[ + om.context.value + ] = context line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value] line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] @@ -588,14 +684,14 @@ def average_of_nonzero(values): metric_unit, ) if metric_unit == DEFAULT_POWER_UNIT: - self._metrics[metric].value = metric_value / 1000 - self._metrics[metric].unit = HA_POWER_UNIT + self._metrics[(connector_id, metric)].value = metric_value / 1000 + self._metrics[(connector_id, metric)].unit = HA_POWER_UNIT elif metric_unit == DEFAULT_ENERGY_UNIT: - self._metrics[metric].value = metric_value / 1000 - self._metrics[metric].unit = HA_ENERGY_UNIT + self._metrics[(connector_id, metric)].value = metric_value / 1000 + self._metrics[(connector_id, metric)].unit = HA_ENERGY_UNIT else: - self._metrics[metric].value = metric_value - self._metrics[metric].unit = metric_unit + self._metrics[(connector_id, metric)].value = metric_value + self._metrics[(connector_id, metric)].unit = metric_unit @staticmethod def get_energy_kwh(measurand_value: MeasurandValue) -> float: @@ -605,9 +701,12 @@ def get_energy_kwh(measurand_value: MeasurandValue) -> float: return measurand_value.value def process_measurands( - self, meter_values: list[list[MeasurandValue]], is_transaction: bool + self, + meter_values: list[list[MeasurandValue]], + is_transaction: bool, + connector_id: int = 0, ): - """Process all value from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" + """Process all values from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" for bucket in meter_values: unprocessed: list[MeasurandValue] = [] for idx in range(len(bucket)): @@ -635,7 +734,7 @@ def process_measurands( value = value / 1000 unit = HA_POWER_UNIT - if self._metrics[csess.meter_start.value].value == 0: + if self._metrics[(connector_id, csess.meter_start.value)].value == 0: # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. self._charger_reports_session_energy = True @@ -647,48 +746,66 @@ def process_measurands( # Ignore messages with Transaction Begin context if context != ReadingContext.transaction_begin.value: if is_transaction: - self._metrics[csess.session_energy.value].value = value - self._metrics[csess.session_energy.value].unit = unit - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = value + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = unit + self._metrics[ + (connector_id, csess.session_energy.value) + ].extra_attr[cstat.id_tag.name] = self._metrics[ + (connector_id, cstat.id_tag.value) + ].value else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit + self._metrics[(connector_id, measurand)].value = value + self._metrics[(connector_id, measurand)].unit = unit else: continue else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit + self._metrics[(connector_id, measurand)].value = value + self._metrics[(connector_id, measurand)].unit = unit if ( is_transaction and (measurand == DEFAULT_MEASURAND) - and (self._metrics[csess.meter_start].value is not None) - and (self._metrics[csess.meter_start].unit == unit) - ): - meter_start = self._metrics[csess.meter_start].value - self._metrics[csess.session_energy.value].value = ( - round(1000 * (value - meter_start)) / 1000 + and ( + self._metrics[(connector_id, csess.meter_start)].value + is not None ) - self._metrics[csess.session_energy.value].unit = unit + and ( + self._metrics[(connector_id, csess.meter_start)].unit + == unit + ) + ): + meter_start = self._metrics[ + (connector_id, csess.meter_start) + ].value + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = round(1000 * (value - meter_start)) / 1000 + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = unit if location is not None: - self._metrics[measurand].extra_attr[om.location.value] = ( - location - ) + self._metrics[(connector_id, measurand)].extra_attr[ + om.location.value + ] = location if context is not None: - self._metrics[measurand].extra_attr[om.context.value] = context + self._metrics[(connector_id, measurand)].extra_attr[ + om.context.value + ] = context else: unprocessed.append(sampled_value) - self.process_phases(unprocessed) + self.process_phases(unprocessed, connector_id) @property def supported_features(self) -> int: """Flag of Ocpp features that are supported.""" return self._attr_supported_features - def get_metric(self, measurand: str): + def get_metric(self, measurand: str, connector_id: int = 0): """Return last known value for given measurand.""" - return self._metrics[measurand].value + return self._metrics[(connector_id, measurand)].value def get_ha_metric(self, measurand: str): """Return last known value in HA for given measurand.""" @@ -704,17 +821,17 @@ def get_ha_metric(self, measurand: str): return None return value - def get_extra_attr(self, measurand: str): - """Return last known extra attributes for given measurand.""" - return self._metrics[measurand].extra_attr + def get_extra_attr(self, measurand: str, connector_id: int = 0): + """Return extra attributes for given measurand (per connector).""" + return self._metrics[(connector_id, measurand)].extra_attr - def get_unit(self, measurand: str): + def get_unit(self, measurand: str, connector_id: int = 0): """Return unit of given measurand.""" - return self._metrics[measurand].unit + return self._metrics[(connector_id, measurand)].unit - def get_ha_unit(self, measurand: str): - """Return home assistant unit of given measurand.""" - return self._metrics[measurand].ha_unit + def get_ha_unit(self, measurand: str, connector_id: int = 0): + """Return HA unit of given measurand.""" + return self._metrics[(connector_id, measurand)].ha_unit async def notify_ha(self, msg: str, title: str = "Ocpp integration"): """Notify user via HA web frontend.""" diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 67e6c2c5..8ff49748 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -20,6 +20,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -39,6 +40,7 @@ DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -91,6 +93,9 @@ vol.Required( CONF_FORCE_SMART_CHARGING, default=DEFAULT_FORCE_SMART_CHARGING ): bool, + vol.Required(CONF_NUM_CONNECTORS, default=DEFAULT_NUM_CONNECTORS): vol.All( + int, vol.Range(min=1, max=16) + ), } ) @@ -106,7 +111,7 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" VERSION = 2 - MINOR_VERSION = 0 + MINOR_VERSION = 1 CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH def __init__(self): diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index a27066bf..2e61430c 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -25,6 +25,7 @@ CONF_MONITORED_VARIABLES = ha.CONF_MONITORED_VARIABLES CONF_MONITORED_VARIABLES_AUTOCONFIG = "monitored_variables_autoconfig" CONF_NAME = ha.CONF_NAME +CONF_NUM_CONNECTORS = "num_connectors" CONF_PASSWORD = ha.CONF_PASSWORD CONF_PORT = ha.CONF_PORT CONF_SKIP_SCHEMA_VALIDATION = "skip_schema_validation" @@ -45,6 +46,7 @@ DEFAULT_CPID = "charger" DEFAULT_HOST = "0.0.0.0" DEFAULT_MAX_CURRENT = 32 +DEFAULT_NUM_CONNECTORS = 1 DEFAULT_PORT = 9000 DEFAULT_SKIP_SCHEMA_VALIDATION = False DEFAULT_FORCE_SMART_CHARGING = False @@ -151,6 +153,7 @@ class ChargerSystemSettings: skip_schema_validation: bool force_smart_charging: bool connection: int | None = None # number of this connection in central server + num_connectors: int = 1 @dataclass diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index e3a501dd..68ae42db 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -21,6 +21,7 @@ CONF_CPID, CONF_CPIDS, CONF_MAX_CURRENT, + CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_MAX_CURRENT, DOMAIN, @@ -34,6 +35,7 @@ class OcppNumberDescription(NumberEntityDescription): """Class to describe a Number entity.""" initial_value: float | None = None + connector_id: int | None = None ELECTRIC_CURRENT_AMPERE = UnitOfElectricCurrent.AMPERE @@ -54,20 +56,42 @@ class OcppNumberDescription(NumberEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the number platform.""" - central_system = hass.data[DOMAIN][entry.entry_id] entities = [] for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - - for ent in NUMBERS: - if ent.key == "maximum_current": - ent.initial_value = cp_id_settings[CONF_MAX_CURRENT] - ent.native_max_value = cp_id_settings[CONF_MAX_CURRENT] - cpx = ChargePointNumber(hass, central_system, cpid, ent) - entities.append(cpx) - + num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + for connector_id in ( + range(1, num_connectors + 1) if num_connectors > 1 else [None] + ): + for ent in NUMBERS: + if ent.key == "maximum_current": + ent_initial = cp_id_settings[CONF_MAX_CURRENT] + ent_max = cp_id_settings[CONF_MAX_CURRENT] + else: + ent_initial = ent.initial_value + ent_max = ent.native_max_value + name_suffix = f" Connector {connector_id}" if connector_id else "" + entities.append( + ChargePointNumber( + hass, + central_system, + cpid, + OcppNumberDescription( + key=ent.key, + name=ent.name + name_suffix, + icon=ent.icon, + initial_value=ent_initial, + native_min_value=ent.native_min_value, + native_max_value=ent_max, + native_step=ent.native_step, + native_unit_of_measurement=ent.native_unit_of_measurement, + connector_id=connector_id, + ), + connector_id=connector_id, + ) + ) async_add_devices(entities, False) @@ -83,19 +107,30 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppNumberDescription, + connector_id: int | None = None, ): """Initialize a Number instance.""" self.cpid = cpid self._hass = hass self.central_system = central_system self.entity_description = description - self._attr_unique_id = ".".join( - [NUMBER_DOMAIN, self.cpid, self.entity_description.key] - ) + self.connector_id = connector_id + parts = [NUMBER_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False self._attr_available = True diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index f4093f20..a204d27d 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -85,6 +85,7 @@ def __init__( central, charger, ) + self._active_tx: dict[int, int] = {} # connector_id -> transaction_id async def get_number_of_connectors(self): """Return number of connectors on this charger.""" @@ -210,25 +211,25 @@ async def trigger_status_notification(self): """Trigger status notifications for all connectors.""" return_value = True try: - nof_connectors = int(self._metrics[cdet.connectors.value].value) - except TypeError: + nof_connectors = int(self._metrics[0][cdet.connectors.value].value or 1) + except Exception: nof_connectors = 1 - for id in range(0, nof_connectors + 1): - _LOGGER.debug(f"trigger status notification for connector={id}") + for cid in range(0, nof_connectors + 1): + _LOGGER.debug(f"trigger status notification for connector={cid}") req = call.TriggerMessage( requested_message=MessageTrigger.status_notification, - connector_id=int(id), + connector_id=int(cid), ) resp = await self.call(req) if resp.status != TriggerMessageStatus.accepted: _LOGGER.warning("Failed with response: %s", resp.status) _LOGGER.warning( "Forcing number of connectors to %d, charger returned %d", - id - 1, + cid - 1, nof_connectors, ) - self._metrics[cdet.connectors.value].value = max(1, id - 1) - return_value = id > 1 + self._metrics[0][cdet.connectors.value].value = max(1, cid - 1) + return_value = cid > 1 break return return_value @@ -394,9 +395,14 @@ async def stop_transaction(self): Leaves charger in finishing state until unplugged. Use reset() to make the charger available again for remote start """ - if self.active_transaction_id == 0: + if self.active_transaction_id == 0 and not any(self._active_tx.values()): return True - req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) + tx_id = self.active_transaction_id or next( + (v for v in self._active_tx.values() if v), 0 + ) + if tx_id == 0: + return True + req = call.RemoteStopTransaction(transaction_id=tx_id) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True @@ -409,7 +415,7 @@ async def stop_transaction(self): async def reset(self, typ: str = ResetType.hard): """Hard reset charger unless soft reset requested.""" - self._metrics[cstat.reconnects.value].value = 0 + self._metrics[0][cstat.reconnects.value].value = 0 req = call.Reset(typ) resp = await self.call(req) if resp.status == ResetStatus.accepted: @@ -479,8 +485,10 @@ async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = data, resp.data, ) - self._metrics[cdet.data_response.value].value = datetime.now(tz=UTC) - self._metrics[cdet.data_response.value].extra_attr = {message_id: resp.data} + self._metrics[0][cdet.data_response.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.data_response.value].extra_attr = { + message_id: resp.data + } return True else: _LOGGER.warning("Failed with response: %s", resp.status) @@ -499,8 +507,8 @@ async def get_configuration(self, key: str = "") -> str: if resp.configuration_key: value = resp.configuration_key[0][om.value.value] _LOGGER.debug("Get Configuration for %s: %s", key, value) - self._metrics[cdet.config_response.value].value = datetime.now(tz=UTC) - self._metrics[cdet.config_response.value].extra_attr = {key: value} + self._metrics[0][cdet.config_response.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.config_response.value].extra_attr = {key: value} return value if resp.unknown_key: _LOGGER.warning("Get Configuration returned unknown key for: %s", key) @@ -571,80 +579,85 @@ async def async_update_device_info_v16(self, boot_info: dict): @on(Action.meter_values) def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): """Request handler for MeterValues Calls.""" - transaction_id: int = kwargs.get(om.transaction_id.name, 0) + active_tx_for_conn = self._active_tx.get(connector_id, 0) # If missing meter_start or active_transaction_id try to restore from HA states. If HA # does not have values either, generate new ones. - if self._metrics[csess.meter_start.value].value is None: + if self._metrics[connector_id][csess.meter_start.value].value is None: value = self.get_ha_metric(csess.meter_start.value) if value is None: - value = self._metrics[DEFAULT_MEASURAND].value + value = self._metrics[connector_id][DEFAULT_MEASURAND].value else: value = float(value) _LOGGER.debug( f"{csess.meter_start.value} was None, restored value={value} from HA." ) - self._metrics[csess.meter_start.value].value = value - if self._metrics[csess.transaction_id.value].value is None: + self._metrics[connector_id][csess.meter_start.value].value = value + if self._metrics[connector_id][csess.transaction_id.value].value is None: value = self.get_ha_metric(csess.transaction_id.value) if value is None: - value = kwargs.get(om.transaction_id.name) + candidate = transaction_id or active_tx_for_conn or None else: - value = int(value) - _LOGGER.debug( - f"{csess.transaction_id.value} was None, restored value={value} from HA." - ) - self._metrics[csess.transaction_id.value].value = value - self.active_transaction_id = value + candidate = int(value) + + if candidate is not None and candidate != 0: + self._metrics[connector_id][ + csess.transaction_id.value + ].value = candidate + self._active_tx[connector_id] = candidate - transaction_matches: bool = False - # match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0 - if transaction_id == self.active_transaction_id and transaction_id != 0: - transaction_matches = True - elif transaction_id != 0: - _LOGGER.warning("Unknown transaction detected with id=%i", transaction_id) + transaction_matches = ( + transaction_id != 0 and transaction_id == active_tx_for_conn + ) meter_values: list[list[MeasurandValue]] = [] for bucket in meter_value: measurands: list[MeasurandValue] = [] for sampled_value in bucket[om.sampled_value.name]: measurand = sampled_value.get(om.measurand.value, None) - value = sampled_value.get(om.value.value, None) + v = sampled_value.get(om.value.value, None) # where an empty string is supplied convert to 0 try: - value = float(value) - except ValueError: - value = 0 + v = float(v) + except (ValueError, TypeError): + v = 0.0 unit = sampled_value.get(om.unit.value, None) phase = sampled_value.get(om.phase.value, None) location = sampled_value.get(om.location.value, None) context = sampled_value.get(om.context.value, None) measurands.append( - MeasurandValue(measurand, value, phase, unit, context, location) + MeasurandValue(measurand, v, phase, unit, context, location) ) meter_values.append(measurands) - self.process_measurands(meter_values, transaction_matches) + self.process_measurands(meter_values, transaction_matches, connector_id) if transaction_matches: - self._metrics[csess.session_time.value].value = round( - ( - int(time.time()) - - float(self._metrics[csess.transaction_id.value].value) - ) - / 60 + tx_start = float( + self._metrics[connector_id][csess.transaction_id.value].value + or time.time() ) - self._metrics[csess.session_time.value].unit = "min" + self._metrics[connector_id][csess.session_time.value].value = round( + (int(time.time()) - tx_start) / 60 + ) + self._metrics[connector_id][csess.session_time.value].unit = "min" if ( - self._metrics[csess.meter_start.value].value is not None + self._metrics[connector_id][csess.meter_start.value].value is not None and not self._charger_reports_session_energy ): - self._metrics[csess.session_energy.value].value = float( - self._metrics[DEFAULT_MEASURAND].value or 0 - ) - float(self._metrics[csess.meter_start.value].value) - self._metrics[csess.session_energy.value].extra_attr[ + current_total = float( + self._metrics[connector_id][DEFAULT_MEASURAND].value or 0 + ) + meter_start = float( + self._metrics[connector_id][csess.meter_start.value].value or 0 + ) + self._metrics[connector_id][csess.session_energy.value].value = ( + current_total - meter_start + ) + self._metrics[connector_id][csess.session_energy.value].extra_attr[ cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value + ] = self._metrics[connector_id][cstat.id_tag.value].value + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @@ -668,41 +681,36 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): """Handle a status notification.""" if connector_id == 0 or connector_id is None: - self._metrics[cstat.status.value].value = status - self._metrics[cstat.error_code.value].value = error_code - elif connector_id == 1: - self._metrics[cstat.status_connector.value].value = status - self._metrics[cstat.error_code_connector.value].value = error_code - if connector_id >= 1: - self._metrics[cstat.status_connector.value].extra_attr[connector_id] = ( - status - ) - self._metrics[cstat.error_code_connector.value].extra_attr[connector_id] = ( - error_code - ) - if ( - status == ChargePointStatus.suspended_ev.value - or status == ChargePointStatus.suspended_evse.value - ): - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 + self._metrics[0][cstat.status.value].value = status + self._metrics[0][cstat.error_code.value].value = error_code + else: + self._metrics[connector_id][cstat.status_connector.value].value = status + self._metrics[connector_id][ + cstat.error_code_connector.value + ].value = error_code + + if status in ( + ChargePointStatus.suspended_ev.value, + ChargePointStatus.suspended_evse.value, + ): + for meas in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + if meas in self._metrics[connector_id]: + self._metrics[connector_id][meas].value = 0 + self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @on(Action.firmware_status_notification) def on_firmware_status(self, status, **kwargs): """Handle firmware status notification.""" - self._metrics[cstat.firmware_status.value].value = status + self._metrics[0][cstat.firmware_status.value].value = status self.hass.async_create_task(self.update(self.settings.cpid)) self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) return call_result.FirmwareStatusNotification() @@ -733,7 +741,7 @@ def on_security_event(self, type, timestamp, **kwargs): @on(Action.authorize) def on_authorize(self, id_tag, **kwargs): """Handle an Authorization request.""" - self._metrics[cstat.id_tag.value].value = id_tag + self._metrics[0][cstat.id_tag.value].value = id_tag auth_status = self.get_authorization_status(id_tag) return call_result.Authorize(id_tag_info={om.status.value: auth_status}) @@ -743,14 +751,19 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): auth_status = self.get_authorization_status(id_tag) if auth_status == AuthorizationStatus.accepted.value: - self.active_transaction_id = int(time.time()) - self._metrics[cstat.id_tag.value].value = id_tag - self._metrics[cstat.stop_reason.value].value = "" - self._metrics[csess.transaction_id.value].value = self.active_transaction_id - self._metrics[csess.meter_start.value].value = int(meter_start) / 1000 + tx_id = int(time.time()) + self._active_tx[connector_id] = tx_id + self.active_transaction_id = tx_id + self._metrics[connector_id][cstat.id_tag.value].value = id_tag + self._metrics[connector_id][cstat.stop_reason.value].value = "" + self._metrics[connector_id][csess.transaction_id.value].value = tx_id + self._metrics[connector_id][csess.meter_start.value].value = ( + int(meter_start) / 1000 + ) + result = call_result.StartTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, - transaction_id=self.active_transaction_id, + transaction_id=tx_id, ) else: result = call_result.StartTransaction( @@ -762,33 +775,40 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): @on(Action.stop_transaction) def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): """Stop the current transaction.""" - - if transaction_id != self.active_transaction_id: + conn = next( + (c for c, tx in self._active_tx.items() if tx == transaction_id), None + ) + if conn is None: _LOGGER.error( "Stop transaction received for unknown transaction id=%i", transaction_id, ) + conn = 1 + + self._active_tx[conn] = 0 self.active_transaction_id = 0 - self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) + + self._metrics[conn][cstat.stop_reason.value].value = kwargs.get( + om.reason.name, None + ) + if ( - self._metrics[csess.meter_start.value].value is not None + self._metrics[conn][csess.meter_start.value].value is not None and not self._charger_reports_session_energy ): - self._metrics[csess.session_energy.value].value = int( - meter_stop - ) / 1000 - float(self._metrics[csess.meter_start.value].value) - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 + start_kwh = float(self._metrics[conn][csess.meter_start.value].value or 0) + stop_kwh = int(meter_stop) / 1000.0 + self._metrics[conn][csess.session_energy.value].value = stop_kwh - start_kwh + + for meas in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + self._metrics[conn][meas].value = 0 self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StopTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value} @@ -798,14 +818,14 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): def on_data_transfer(self, vendor_id, **kwargs): """Handle a Data transfer request.""" _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) - self._metrics[cdet.data_transfer.value].value = datetime.now(tz=UTC) - self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} + self._metrics[0][cdet.data_transfer.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} return call_result.DataTransfer(status=DataTransferStatus.accepted.value) @on(Action.heartbeat) def on_heartbeat(self, **kwargs): """Handle a Heartbeat.""" now = datetime.now(tz=UTC) - self._metrics[cstat.heartbeat.value].value = now + self._metrics[0][cstat.heartbeat.value].value = now self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 171662c3..2616bc0d 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -78,7 +78,7 @@ class ChargePoint(cp): _inventory: InventoryReport | None = None _wait_inventory: asyncio.Event | None = None _connector_status: list[list[ConnectorStatusEnumType | None]] = [] - _tx_start_time: datetime | None = None + _tx_start_time: dict[int, datetime] def __init__( self, @@ -100,6 +100,7 @@ def __init__( central, charger, ) + self._tx_start_time = {} async def async_update_device_info_v201(self, boot_info: dict): """Update device info asynchronuously.""" @@ -292,9 +293,14 @@ async def start_transaction(self) -> bool: return resp.status == RequestStartStopStatusEnumType.accepted.value async def stop_transaction(self) -> bool: - """Request remote stop of current transaction.""" + """Request remote stop of current transaction (default EVSE 1).""" + tx_id = ( + self._metrics[1][csess.transaction_id.value].value + if 1 in self._metrics + else "" + ) req: call.RequestStopTransaction = call.RequestStopTransaction( - transaction_id=self._metrics[csess.transaction_id.value].value + transaction_id=tx_id ) resp: call_result.RequestStopTransaction = await self.call(req) return resp.status == RequestStartStopStatusEnumType.accepted.value @@ -404,13 +410,7 @@ def on_heartbeat(self, **kwargs): def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv16): evse_status_str: str = evse_status_v16.value - - if evse_id == 1: - self._metrics[cstat.status_connector.value].value = evse_status_str - else: - self._metrics[cstat.status_connector.value].extra_attr[evse_id] = ( - evse_status_str - ) + self._metrics[evse_id][cstat.status_connector.value].value = evse_status_str self.hass.async_create_task(self.update(self.settings.cpid)) @on(Action.status_notification) @@ -430,25 +430,21 @@ def on_status_notification( evse_status: ConnectorStatusEnumType | None = None for status in evse: if status is None: - evse_status = status + evse_status = None + break + evse_status = status + if status != ConnectorStatusEnumType.available: break - else: - evse_status = status - if status != ConnectorStatusEnumType.available: - break - evse_status_v16: ChargePointStatusv16 | None - if evse_status is None: - evse_status_v16 = None - elif evse_status == ConnectorStatusEnumType.available: - evse_status_v16 = ChargePointStatusv16.available - elif evse_status == ConnectorStatusEnumType.faulted: - evse_status_v16 = ChargePointStatusv16.faulted - elif evse_status == ConnectorStatusEnumType.unavailable: - evse_status_v16 = ChargePointStatusv16.unavailable - else: - evse_status_v16 = ChargePointStatusv16.preparing - if evse_status_v16: + if evse_status is not None: + if evse_status == ConnectorStatusEnumType.available: + evse_status_v16 = ChargePointStatusv16.available + elif evse_status == ConnectorStatusEnumType.faulted: + evse_status_v16 = ChargePointStatusv16.faulted + elif evse_status == ConnectorStatusEnumType.unavailable: + evse_status_v16 = ChargePointStatusv16.unavailable + else: + evse_status_v16 = ChargePointStatusv16.preparing self._report_evse_status(evse_id, evse_status_v16) return call_result.StatusNotification() @@ -545,7 +541,9 @@ def on_authorize(self, id_token: dict, **kwargs): status = self.get_authorization_status(token) return call_result.Authorize(id_token_info={"status": status}) - def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): + def _set_meter_values( + self, tx_event_type: str, meter_values: list[dict], evse_id: int + ): converted_values: list[list[MeasurandValue]] = [] for meter_value in meter_values: measurands: list[MeasurandValue] = [] @@ -569,18 +567,18 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): if (tx_event_type == TransactionEventEnumType.started.value) or ( (tx_event_type == TransactionEventEnumType.updated.value) - and (self._metrics[csess.meter_start].value is None) + and (self._metrics[evse_id][csess.meter_start].value is None) ): energy_measurand = MeasurandEnumType.energy_active_import_register.value for meter_value in converted_values: for measurand_item in meter_value: if measurand_item.measurand == energy_measurand: - energy_value = ChargePoint.get_energy_kwh(measurand_item) + energy_value = cp.get_energy_kwh(measurand_item) energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None - self._metrics[csess.meter_start].value = energy_value - self._metrics[csess.meter_start].unit = energy_unit + self._metrics[evse_id][csess.meter_start].value = energy_value + self._metrics[evse_id][csess.meter_start].unit = energy_unit - self.process_measurands(converted_values, True) + self.process_measurands(converted_values, True, evse_id) if tx_event_type == TransactionEventEnumType.ended.value: measurands_in_tx: set[str] = set() @@ -593,10 +591,10 @@ def _set_meter_values(self, tx_event_type: str, meter_values: list[dict]): for measurand in self._inventory.tx_updated_measurands: if ( (measurand not in measurands_in_tx) - and (measurand in self._metrics) + and (measurand in self._metrics[evse_id]) and not measurand.startswith("Energy") ): - self._metrics[measurand].value = 0 + self._metrics[evse_id][measurand].value = 0 @on(Action.transaction_event) def on_transaction_event( @@ -609,14 +607,14 @@ def on_transaction_event( **kwargs, ): """Perform OCPP callback.""" + evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 offline: bool = kwargs.get("offline", False) meter_values: list[dict] = kwargs.get("meter_value", []) - self._set_meter_values(event_type, meter_values) + self._set_meter_values(event_type, meter_values, evse_id) t = datetime.fromisoformat(timestamp) if "charging_state" in transaction_info: state = transaction_info["charging_state"] - evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 evse_status_v16: ChargePointStatusv16 | None = None if state == ChargingStateEnumType.idle: evse_status_v16 = ChargePointStatusv16.available @@ -636,22 +634,24 @@ def on_transaction_event( if id_token: response.id_token_info = {"status": AuthorizationStatusEnumType.accepted} id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] - self._metrics[cstat.id_tag.value].value = id_tag_string + self._metrics[evse_id][cstat.id_tag.value].value = id_tag_string if event_type == TransactionEventEnumType.started.value: - self._tx_start_time = t + self._tx_start_time[evse_id] = t tx_id: str = transaction_info["transaction_id"] - self._metrics[csess.transaction_id.value].value = tx_id - self._metrics[csess.session_time].value = 0 - self._metrics[csess.session_time].unit = UnitOfTime.MINUTES + self._metrics[evse_id][csess.transaction_id.value].value = tx_id + self._metrics[evse_id][csess.session_time].value = 0 + self._metrics[evse_id][csess.session_time].unit = UnitOfTime.MINUTES else: - if self._tx_start_time: - duration_minutes: int = ((t - self._tx_start_time).seconds + 59) // 60 - self._metrics[csess.session_time].value = duration_minutes - self._metrics[csess.session_time].unit = UnitOfTime.MINUTES + if self._tx_start_time.get(evse_id): + duration_minutes: int = ( + (t - self._tx_start_time[evse_id]).seconds + 59 + ) // 60 + self._metrics[evse_id][csess.session_time].value = duration_minutes + self._metrics[evse_id][csess.session_time].unit = UnitOfTime.MINUTES if event_type == TransactionEventEnumType.ended.value: - self._metrics[csess.transaction_id.value].value = "" - self._metrics[cstat.id_tag.value].value = "" + self._metrics[evse_id][csess.transaction_id.value].value = "" + self._metrics[evse_id][cstat.id_tag.value].value = "" if not offline: self.hass.async_create_task(self.update(self.settings.cpid)) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index fc743281..3ba5042b 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -22,6 +22,7 @@ from .const import ( CONF_CPID, CONF_CPIDS, + CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_CLASS_UNITS_HA, DOMAIN, @@ -46,6 +47,8 @@ async def async_setup_entry(hass, entry, async_add_devices): for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] + num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + SENSORS = [] for metric in list( set( @@ -70,14 +73,38 @@ async def async_setup_entry(hass, entry, async_add_devices): ) ) - for ent in SENSORS: - cpx = ChargePointMetric( - hass, - central_system, - cpid, - ent, - ) - entities.append(cpx) + if num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + name_suffix = f" Connector {conn_id}" + for ent in SENSORS: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + OcppSensorDescription( + key=ent.key, + name=ent.name + name_suffix, + metric=ent.metric, + icon=ent.icon, + device_class=ent.device_class, + state_class=ent.state_class, + entity_category=ent.entity_category, + ), + connector_id=conn_id, + ) + ) + else: + for ent in SENSORS: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + ent, + connector_id=None, + ) + ) async_add_devices(entities, False) @@ -94,22 +121,33 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppSensorDescription, + connector_id: int | None = None, ): """Instantiate instance of a ChargePointMetrics.""" self.central_system = central_system self.cpid = cpid self.entity_description = description self.metric = self.entity_description.metric + self.connector_id = connector_id self._hass = hass self._extra_attr = {} self._last_reset = homeassistant.util.dt.utc_from_timestamp(0) - self._attr_unique_id = ".".join( - [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] - ) + parts = [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] + if self.connector_id: + parts.insert(2, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) self._attr_icon = ICON self._attr_native_unit_of_measurement = None diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 99ec4bd5..c4a41be8 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -15,7 +15,7 @@ from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem -from .const import CONF_CPID, CONF_CPIDS, DOMAIN, ICON +from .const import CONF_CPID, CONF_CPIDS, CONF_NUM_CONNECTORS, DOMAIN, ICON from .enums import HAChargerServices, HAChargerStatuses @@ -66,13 +66,22 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the switch platform.""" central_system = hass.data[DOMAIN][entry.entry_id] entities = [] + for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] + num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) for ent in SWITCHES: - cpx = ChargePointSwitch(central_system, cpid, ent) - entities.append(cpx) + if ent.metric_state and num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + entities.append( + ChargePointSwitch( + central_system, cpid, ent, connector_id=conn_id + ) + ) + else: + entities.append(ChargePointSwitch(central_system, cpid, ent)) async_add_devices(entities, False) @@ -88,19 +97,30 @@ def __init__( central_system: CentralSystem, cpid: str, description: OcppSwitchDescription, + connector_id: int | None = None, ): """Instantiate instance of a ChargePointSwitch.""" self.cpid = cpid self.central_system = central_system self.entity_description = description + self.connector_id = connector_id self._state = self.entity_description.default_state - self._attr_unique_id = ".".join( - [SWITCH_DOMAIN, DOMAIN, self.cpid, self.entity_description.key] - ) + parts = [SWITCH_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cpid)}, - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) @property def available(self) -> bool: diff --git a/tests/const.py b/tests/const.py index c70e2208..a3f76911 100644 --- a/tests/const.py +++ b/tests/const.py @@ -11,6 +11,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -115,6 +116,30 @@ CONF_METER_INTERVAL: 60, CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_NUM_CONNECTORS: 2, + CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], +} + +# different port with skip schema validation enabled, auto config false +# and multiple connector support +MOCK_CONFIG_DATA_1_MC = { + **MOCK_CONFIG_DATA, + CONF_CSID: "test_csid_1_mc", + CONF_PORT: 9001, + CONF_CPIDS: [ + { + "CP_1_mc": { + CONF_CPID: "test_cpid_9001", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_NUM_CONNECTORS: 2, CONF_SKIP_SCHEMA_VALIDATION: True, CONF_FORCE_SMART_CHARGING: True, } diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 919cbe13..a6edf315 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -640,14 +640,100 @@ async def test_cms_responses_errors_v16( ) +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9007, "cp_id": "CP_1_norm_mc", "cms": "cms_norm", "num_connectors": 2}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_norm_mc"]) +@pytest.mark.parametrize("port", [9007]) +async def test_cms_responses_normal_multiple_connectors_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger under normal operation with multiple connectors.""" + + cs = setup_config_entry + num_connectors = 2 + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=num_connectors) + + tasks = [ + cp.start(), + cp.send_boot_notification(), + cp.send_authorize(), + cp.send_heartbeat(), + cp.send_security_event(), + cp.send_firmware_status(), + cp.send_data_transfer(), + cp.send_status_for_all_connectors(), + cp.send_start_transaction(12345), + ] + + for conn_id in range(1, num_connectors + 1): + tasks.extend( + [ + cp.send_meter_err_phases(connector_id=conn_id), + cp.send_meter_line_voltage(connector_id=conn_id), + cp.send_meter_periodic_data(connector_id=conn_id), + ] + ) + + tasks.append(cp.send_stop_transaction(1)) + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.gather(*tasks), timeout=10) + + await ws.close() + + cpid = cs.charge_points[cp_id].settings.cpid + + assert int( + cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + ) == int(1305570 / 1000) + assert int(cs.get_metric(cpid, "Energy.Session", connector_id=1)) == int( + (54321 - 12345) / 1000 + ) + assert int(cs.get_metric(cpid, "Current.Import", connector_id=1)) == 0 + # assert int(cs.get_metric(cpid, "Voltage")) == 228 + assert cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) == "kWh" + assert cs.get_ha_unit(cpid, "Power.Reactive.Import", connector_id=1) == "var" + assert cs.get_unit(cpid, "Power.Reactive.Import", connector_id=1) == "var" + assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None + assert int(cs.get_supported_features("unknown_cpid")) == 0 + assert ( + await asyncio.wait_for( + cs.set_max_charge_rate_amps("unknown_cpid", 0), timeout=1 + ) + is False + ) + assert ( + await asyncio.wait_for( + cs.set_charger_state("unknown_cpid", csvcs.service_clear_profile, False), + timeout=1, + ) + is False + ) + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" - def __init__(self, id, connection, response_timeout=30): + def __init__(self, id, connection, response_timeout=30, no_connectors=1): """Init extra variables for testing.""" super().__init__(id, connection) + self.no_connectors = int(no_connectors) self.active_transactionId: int = 0 self.accept: bool = True + self.task = None # reused for background triggers + self._tasks: set[asyncio.Task] = set() @on(Action.get_configuration) def on_get_configuration(self, key, **kwargs): @@ -672,7 +758,9 @@ def on_get_configuration(self, key, **kwargs): ) if key[0] == ConfigurationKey.number_of_connectors.value: return call_result.GetConfiguration( - configuration_key=[{"key": key[0], "readonly": False, "value": "1"}] + configuration_key=[ + {"key": key[0], "readonly": False, "value": f"{self.no_connectors}"} + ] ) if key[0] == ConfigurationKey.web_socket_ping_interval.value: if self.accept is True: @@ -767,7 +855,7 @@ def on_unlock_connector(self, **kwargs): @on(Action.reset) def on_reset(self, **kwargs): - """Handle change availability request.""" + """Handle reset request.""" if self.accept is True: return call_result.Reset(ResetStatus.accepted) else: @@ -807,13 +895,35 @@ def on_clear_charging_profile(self, **kwargs): return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) @on(Action.trigger_message) - def on_trigger_message(self, **kwargs): + def on_trigger_message(self, requested_message, **kwargs): """Handle trigger message request.""" - if self.accept is True: - return call_result.TriggerMessage(TriggerMessageStatus.accepted) - else: + if not self.accept: return call_result.TriggerMessage(TriggerMessageStatus.rejected) + resp = call_result.TriggerMessage(TriggerMessageStatus.accepted) + + try: + from ocpp.v16.enums import ( + MessageTrigger, + ) + + connector_id = kwargs.get("connector_id") + if requested_message == MessageTrigger.status_notification: + if connector_id in (None, 0): + task = asyncio.create_task(self.send_status_for_all_connectors()) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + else: + task = asyncio.create_task( + self.send_status_notification(connector_id) + ) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + except Exception: + pass + + return resp + @on(Action.update_firmware) def on_update_firmware(self, **kwargs): """Handle update firmware request.""" @@ -886,49 +996,40 @@ async def send_start_transaction(self, meter_start: int = 12345): self.active_transactionId = resp.transaction_id assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value - async def send_status_notification(self): - """Send a status notification.""" - request = call.StatusNotification( - connector_id=0, - error_code=ChargePointErrorCode.no_error, - status=ChargePointStatus.suspended_ev, - timestamp=datetime.now(tz=UTC).isoformat(), - info="Test info", - vendor_id="The Mobility House", - vendor_error_code="Test error", - ) - resp = await self.call(request) + async def send_status_notification(self, connector_id: int = 0): + """Send one StatusNotification for a specific connector.""" + # Connector 0 = CP-level + if connector_id == 0: + status = ChargePointStatus.suspended_ev + elif connector_id == 1: + status = ChargePointStatus.charging + else: + status = ChargePointStatus.available + request = call.StatusNotification( - connector_id=1, + connector_id=connector_id, error_code=ChargePointErrorCode.no_error, - status=ChargePointStatus.charging, + status=status, timestamp=datetime.now(tz=UTC).isoformat(), info="Test info", vendor_id="The Mobility House", vendor_error_code="Test error", ) - resp = await self.call(request) - request = call.StatusNotification( - connector_id=2, - error_code=ChargePointErrorCode.no_error, - status=ChargePointStatus.available, - timestamp=datetime.now(tz=UTC).isoformat(), - info="Test info", - vendor_id="The Mobility House", - vendor_error_code="Available", - ) - resp = await self.call(request) + await self.call(request) - assert resp is not None + async def send_status_for_all_connectors(self): + """Send StatusNotification for 0..no_connectors.""" + for cid in range(0, max(1, self.no_connectors) + 1): + await self.send_status_notification(cid) - async def send_meter_periodic_data(self): - """Send periodic meter data notification.""" + async def send_meter_periodic_data(self, connector_id: int = 1): + """Send periodic meter data notification for a given connector.""" n = 0 while self.active_transactionId == 0 and n < 2: await asyncio.sleep(1) n += 1 request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1067,12 +1168,12 @@ async def send_meter_periodic_data(self): resp = await self.call(request) assert resp is not None - async def send_meter_line_voltage(self): - """Send line voltages.""" + async def send_meter_line_voltage(self, connector_id: int = 1): + """Send line voltages for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1109,12 +1210,12 @@ async def send_meter_line_voltage(self): resp = await self.call(request) assert resp is not None - async def send_meter_err_phases(self): - """Send erroneous voltage phase.""" + async def send_meter_err_phases(self, connector_id: int = 1): + """Send erroneous voltage phase for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1143,12 +1244,12 @@ async def send_meter_err_phases(self): resp = await self.call(request) assert resp is not None - async def send_meter_energy_kwh(self): - """Send periodic energy meter value with kWh unit.""" + async def send_meter_energy_kwh(self, connector_id: int = 1): + """Send periodic energy meter value with kWh unit for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { @@ -1168,12 +1269,12 @@ async def send_meter_energy_kwh(self): resp = await self.call(request) assert resp is not None - async def send_main_meter_clock_data(self): - """Send periodic main meter value. Main meter values dont have transaction_id.""" + async def send_main_meter_clock_data(self, connector_id: int = 1): + """Send periodic main meter value (no transaction_id) for a given connector.""" while self.active_transactionId == 0: await asyncio.sleep(1) request = call.MeterValues( - connector_id=1, + connector_id=connector_id, meter_value=[ { "timestamp": "2021-06-21T16:15:09Z", @@ -1192,11 +1293,11 @@ async def send_main_meter_clock_data(self): resp = await self.call(request) assert resp is not None - async def send_meter_clock_data(self): - """Send periodic meter data notification.""" + async def send_meter_clock_data(self, connector_id: int = 1): + """Send periodic meter data (clock) for a given connector.""" self.active_transactionId = 0 request = call.MeterValues( - connector_id=1, + connector_id=connector_id, transaction_id=self.active_transactionId, meter_value=[ { diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index b00dae95..1e7d4421 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -1058,12 +1058,12 @@ async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): # Junk report to be ignored await cp.call(call.NotifyReport(2, datetime.now(tz=UTC).isoformat(), 0)) - assert cs.get_metric(cpid, cdet.serial.value) == "SERIAL" - assert cs.get_metric(cpid, cdet.model.value) == "MODEL" - assert cs.get_metric(cpid, cdet.vendor.value) == "VENDOR" - assert cs.get_metric(cpid, cdet.firmware_version.value) == "VERSION" + assert cs.get_metric(cpid, cdet.serial.value, connector_id=0) == "SERIAL" + assert cs.get_metric(cpid, cdet.model.value, connector_id=0) == "MODEL" + assert cs.get_metric(cpid, cdet.vendor.value, connector_id=0) == "VENDOR" + assert cs.get_metric(cpid, cdet.firmware_version.value, connector_id=0) == "VERSION" assert ( - cs.get_metric(cpid, cdet.features.value) + cs.get_metric(cpid, cdet.features.value, connector_id=0) == Profiles.CORE | Profiles.SMART | Profiles.RES | Profiles.AUTH ) assert ( @@ -1164,10 +1164,7 @@ async def _extra_features_test( await wait_ready(cs.charge_points[cp_id]) assert ( - cs.get_metric( - cpid, - cdet.features.value, - ) + cs.get_metric(cpid, cdet.features.value, connector_id=0) == Profiles.CORE | Profiles.SMART | Profiles.RES @@ -1219,10 +1216,7 @@ async def _unsupported_base_report_test( ) await wait_ready(cs.charge_points[cp_id]) assert ( - cs.get_metric( - cpid, - cdet.features.value, - ) + cs.get_metric(cpid, cdet.features.value, connector_id=0) == Profiles.CORE | Profiles.REM | Profiles.FW ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 2f69e6cf..be8d48a0 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -15,6 +15,7 @@ MOCK_CONFIG_FLOW, CONF_CPIDS, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, DEFAULT_MONITORED_VARIABLES, ) @@ -116,6 +117,7 @@ async def test_successful_discovery_flow(hass, bypass_get_data): flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( False ) + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = 1 assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] assert entry.data == flow_output diff --git a/tests/test_init.py b/tests/test_init.py index 965b75f7..f7557c91 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -10,7 +10,12 @@ from custom_components.ocpp import CentralSystem from custom_components.ocpp.const import DOMAIN -from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_1, MOCK_CONFIG_MIGRATION_FLOW +from .const import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_DATA_1, + MOCK_CONFIG_MIGRATION_FLOW, + MOCK_CONFIG_DATA_1_MC, +) # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture @@ -54,6 +59,42 @@ async def test_setup_unload_and_reload_entry( assert config_entry.entry_id not in hass.data[DOMAIN] +async def test_setup_unload_and_reload_entry_multiple_connectors( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_DATA_1_MC, + entry_id="test_cms1_mc", + title="test_cms1_mc", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data + # call, no code from custom_components/ocpp/api.py actually runs. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Reload the entry and assert that the data from above is still there + assert await hass.config_entries.async_reload(config_entry.entry_id) + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Unload the entry and verify that the data has been removed + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[DOMAIN] + + async def test_migration_entry( hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None ): From 5791af087707dc1c9dc1d2178c393f7ada70c9f1 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Sun, 17 Aug 2025 19:53:29 +0000 Subject: [PATCH 03/15] Fix sensor updates and placement on proper devices. --- custom_components/ocpp/api.py | 112 ++++++++++++----- custom_components/ocpp/button.py | 2 +- custom_components/ocpp/chargepoint.py | 114 +++++++++++++----- custom_components/ocpp/config_flow.py | 4 +- custom_components/ocpp/number.py | 3 +- custom_components/ocpp/ocppv16.py | 109 ++++++++++------- custom_components/ocpp/ocppv201.py | 36 +++--- custom_components/ocpp/sensor.py | 110 +++++++++++------ custom_components/ocpp/switch.py | 4 +- custom_components/ocpp/translations/de.json | 3 +- custom_components/ocpp/translations/en.json | 3 +- custom_components/ocpp/translations/es.json | 3 +- .../ocpp/translations/i-default.json | 3 +- custom_components/ocpp/translations/nl.json | 3 +- 14 files changed, 330 insertions(+), 179 deletions(-) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index cdfd97ce..a4f1abfa 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -190,6 +190,15 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): self._server = server return self + @staticmethod + def _norm_conn(connector_id: int | None) -> int: + if connector_id in (None, 0): + return 0 + try: + return int(connector_id) + except Exception: + return 0 + def select_subprotocol( self, connection: ServerConnection, subprotocols ) -> Subprotocol | None: @@ -275,61 +284,88 @@ async def on_connect(self, websocket: ServerConnection): charge_point = self.charge_points[cp_id] await charge_point.reconnect(websocket) - def get_metric(self, id: str, measurand: str, connector_id: int = 1): + def _get_metrics(self, id: str): + """Return metrics.""" + cp_id = self.cpids.get(id, id) + cp = self.charge_points.get(cp_id) + return (cp_id, cp._metrics) if cp is not None else (None, None) + + def get_metric(self, id: str, measurand: str, connector_id: int | None = None): """Return last known value for given measurand.""" # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) + cp_id, m = self._get_metrics(id) - if cp_id not in self.charge_points: + if m is None: return None - m = self.charge_points[cp_id]._metrics + conn = self._norm_conn(connector_id) try: - return m[connector_id][measurand].value + return m[(conn, measurand)].value except Exception: + if conn == 0: + with contextlib.suppress(Exception): + return m[measurand].value return None - def del_metric(self, id: str, measurand: str, connector_id: int = 1): + def del_metric(self, id: str, measurand: str, connector_id: int | None = None): """Set given measurand to None.""" # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: + cp_id, m = self._get_metrics(id) + if m is None: return None - m = self.charge_points[cp_id]._metrics - m[connector_id][measurand].value = None + conn = self._norm_conn(connector_id) + try: + m[(conn, measurand)].value = None + except Exception: + if conn == 0: + with contextlib.suppress(Exception): + m[measurand].value = None return None - def get_unit(self, id: str, measurand: str, connector_id: int = 1): + def get_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return unit of given measurand.""" # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: + cp_id, m = self._get_metrics(id) + if m is None: + return None + conn = self._norm_conn(connector_id) + try: + return m[(conn, measurand)].unit + except Exception: + if conn == 0: + with contextlib.suppress(Exception): + return m[measurand].unit return None - m = self.charge_points[cp_id]._metrics - return m[connector_id][measurand].unit - - def get_ha_unit(self, id: str, measurand: str, connector_id: int = 1): + def get_ha_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return home assistant unit of given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - - if cp_id not in self.charge_points: + cp_id, m = self._get_metrics(id) + if m is None: + return None + conn = self._norm_conn(connector_id) + try: + return m[(conn, measurand)].ha_unit + except Exception: + if conn == 0: + with contextlib.suppress(Exception): + return m[measurand].ha_unit return None - m = self.charge_points[cp_id]._metrics - return m[connector_id][measurand].ha_unit - - def get_extra_attr(self, id: str, measurand: str, connector_id: int = 1): + def get_extra_attr(self, id: str, measurand: str, connector_id: int | None = None): """Return last known extra attributes for given measurand.""" # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: + cp_id, m = self._get_metrics(id) + if m is None: + return None + conn = self._norm_conn(connector_id) + try: + return m[(conn, measurand)].extra_attr + except Exception: + if conn == 0: + with contextlib.suppress(Exception): + return m[measurand].extra_attr return None - - m = self.charge_points[cp_id]._metrics - return m[connector_id][measurand].extra_attr def get_available(self, id: str, connector_id: int | None = None): """Return whether the charger (or a specific connector) is available.""" @@ -346,7 +382,7 @@ def get_available(self, id: str, connector_id: int | None = None): m = cp._metrics status_val = None with contextlib.suppress(Exception): - status_val = m[connector_id][cstat.status_connector.value].value + status_val = m[(connector_id, cstat.status_connector.value)].value if not status_val: try: @@ -361,7 +397,19 @@ def get_available(self, id: str, connector_id: int | None = None): if not status_val: return cp.status == STATE_OK - return str(status_val).lower() in ("available", "preparing", "charging") + ok_statuses = { + "available", + "preparing", + "charging", + "suspendedev", + "suspendedevse", + "finishing", + "occupied", + "reserved", + } + + ret = str(status_val).lower() in ok_statuses + return ret def get_supported_features(self, id: str): """Return what profiles the charger supports.""" diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index 24693586..a8c86d96 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -119,7 +119,7 @@ def __init__( @property def available(self) -> bool: """Return charger availability.""" - return self.central_system.get_available(self.cpid, self.connector_id) # type: ignore[no-any-return] + return self.central_system.get_available(self.cpid, self.connector_id) async def async_press(self) -> None: """Triggers the charger press action service.""" diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index ff5e905d..92d2344a 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -17,6 +17,7 @@ from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.const import UnitOfTime from homeassistant.helpers import device_registry, entity_component, entity_registry +from homeassistant.helpers.dispatcher import async_dispatcher_send from websockets.asyncio.server import ServerConnection from websockets.exceptions import WebSocketException from websockets.protocol import State @@ -54,6 +55,7 @@ CONF_MONITORED_VARIABLES, CONF_CPIDS, CONFIG, + DATA_UPDATED, DEFAULT_ENERGY_UNIT, DEFAULT_POWER_UNIT, DEFAULT_MEASURAND, @@ -325,6 +327,12 @@ async def post_connect(self): await self.fetch_supported_features() num_connectors: int = await self.get_number_of_connectors() self.num_connectors = num_connectors + for conn in range(1, self.num_connectors + 1): + _ = self._metrics[(conn, cstat.status_connector.value)] + _ = self._metrics[(conn, cstat.error_code_connector.value)] + _ = self._metrics[(conn, csess.session_energy.value)] + _ = self._metrics[(conn, csess.meter_start.value)] + _ = self._metrics[(conn, csess.transaction_id.value)] self._metrics[(0, cdet.connectors.value)].value = num_connectors await self.get_heartbeat_interval() @@ -566,17 +574,37 @@ def _register_boot_notification(self): self.hass.async_create_task(self.post_connect()) async def update(self, cpid: str): - """Update sensors values in HA.""" + """Update sensors values in HA (charger + connector child devices).""" er = entity_registry.async_get(self.hass) dr = device_registry.async_get(self.hass) identifiers = {(DOMAIN, cpid), (DOMAIN, self.id)} - dev = dr.async_get_device(identifiers) - # _LOGGER.info("Device id: %s updating", dev.name) - for ent in entity_registry.async_entries_for_device(er, dev.id): - # _LOGGER.info("Entity id: %s updating", ent.entity_id) - self.hass.async_create_task( - entity_component.async_update_entity(self.hass, ent.entity_id) - ) + root_dev = dr.async_get_device(identifiers) + if root_dev is None: + return + + to_visit = [root_dev.id] + visited = set() + updated_entities = 0 + found_children = 0 + + while to_visit: + dev_id = to_visit.pop(0) + if dev_id in visited: + continue + visited.add(dev_id) + + for ent in entity_registry.async_entries_for_device(er, dev_id): + self.hass.async_create_task( + entity_component.async_update_entity(self.hass, ent.entity_id) + ) + updated_entities += 1 + + for dev in dr.devices.values(): + if dev.via_device_id == dev_id and dev.id not in visited: + found_children += 1 + to_visit.append(dev.id) + + async_dispatcher_send(self.hass, DATA_UPDATED) def get_authorization_status(self, id_tag): """Get the authorization status for an id_tag.""" @@ -676,13 +704,6 @@ def average_of_nonzero(values): if metric_value is not None: metric_unit = phase_info.get(om.unit.value) - _LOGGER.debug( - "process_phases: metric: %s, phase_info: %s value: %f unit :%s", - metric, - phase_info, - metric_value, - metric_unit, - ) if metric_unit == DEFAULT_POWER_UNIT: self._metrics[(connector_id, metric)].value = metric_value / 1000 self._metrics[(connector_id, metric)].unit = HA_POWER_UNIT @@ -734,8 +755,10 @@ def process_measurands( value = value / 1000 unit = HA_POWER_UNIT - if self._metrics[(connector_id, csess.meter_start.value)].value == 0: - # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. + if self._metrics[(connector_id, csess.meter_start.value)].value in ( + 0, + None, + ): self._charger_reports_session_energy = True if phase is None: @@ -805,25 +828,52 @@ def supported_features(self) -> int: def get_metric(self, measurand: str, connector_id: int = 0): """Return last known value for given measurand.""" - return self._metrics[(connector_id, measurand)].value + val = self._metrics[(connector_id, measurand)].value + if val is not None: + return val + + if connector_id and connector_id > 0: + if measurand == cstat.status_connector.value: + agg = self._metrics[(0, cstat.status_connector.value)] + return agg.extra_attr.get(connector_id, agg.value) + if measurand == cstat.error_code_connector.value: + agg = self._metrics[(0, cstat.error_code_connector.value)] + return agg.extra_attr.get(connector_id, agg.value) - def get_ha_metric(self, measurand: str): - """Return last known value in HA for given measurand.""" - entity_id = "sensor." + "_".join( - [self.settings.cpid.lower(), measurand.lower().replace(".", "_")] - ) - try: - value = self.hass.states.get(entity_id).state - except Exception as e: - _LOGGER.debug(f"An error occurred when getting entity state from HA: {e}") - return None - if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN: - return None - return value + return None + + def get_ha_metric(self, measurand: str, connector_id: int | None = None): + """Return last known value in HA for given measurand, or None if not available.""" + base = self.settings.cpid.lower() + meas_slug = measurand.lower().replace(".", "_") + + candidates: list[str] = [] + if connector_id and connector_id > 0: + candidates.append(f"sensor.{base}_connector_{connector_id}_{meas_slug}") + else: + candidates.append(f"sensor.{base}_{meas_slug}") + + for entity_id in candidates: + st = self.hass.states.get(entity_id) + if st and st.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return st.state + return None def get_extra_attr(self, measurand: str, connector_id: int = 0): """Return extra attributes for given measurand (per connector).""" - return self._metrics[(connector_id, measurand)].extra_attr + attrs = self._metrics[(connector_id, measurand)].extra_attr + if attrs: + return attrs + + if connector_id and connector_id > 0: + if measurand in ( + cstat.status_connector.value, + cstat.error_code_connector.value, + ): + agg = self._metrics[(0, measurand)] + if connector_id in agg.extra_attr: + return {connector_id: agg.extra_attr[connector_id]} + return {} def get_unit(self, measurand: str, connector_id: int = 0): """Return unit of given measurand.""" diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 8ff49748..d7888383 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -93,9 +93,7 @@ vol.Required( CONF_FORCE_SMART_CHARGING, default=DEFAULT_FORCE_SMART_CHARGING ): bool, - vol.Required(CONF_NUM_CONNECTORS, default=DEFAULT_NUM_CONNECTORS): vol.All( - int, vol.Range(min=1, max=16) - ), + vol.Required(CONF_NUM_CONNECTORS, default=DEFAULT_NUM_CONNECTORS): int, } ) diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 68ae42db..602f3b38 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -72,7 +72,6 @@ async def async_setup_entry(hass, entry, async_add_devices): else: ent_initial = ent.initial_value ent_max = ent.native_max_value - name_suffix = f" Connector {connector_id}" if connector_id else "" entities.append( ChargePointNumber( hass, @@ -80,7 +79,7 @@ async def async_setup_entry(hass, entry, async_add_devices): cpid, OcppNumberDescription( key=ent.key, - name=ent.name + name_suffix, + name=ent.name, icon=ent.icon, initial_value=ent_initial, native_min_value=ent.native_min_value, diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index a204d27d..aed9503f 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -87,9 +87,14 @@ def __init__( ) self._active_tx: dict[int, int] = {} # connector_id -> transaction_id - async def get_number_of_connectors(self): + async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" - return await self.get_configuration(ckey.number_of_connectors.value) + val = await self.get_configuration(ckey.number_of_connectors.value) + try: + n = int(val) + except (TypeError, ValueError): + n = 1 # fallback + return max(1, n) async def get_heartbeat_interval(self): """Retrieve heartbeat interval from the charger and store it.""" @@ -578,43 +583,50 @@ async def async_update_device_info_v16(self, boot_info: dict): @on(Action.meter_values) def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): - """Request handler for MeterValues Calls.""" - transaction_id: int = kwargs.get(om.transaction_id.name, 0) - active_tx_for_conn = self._active_tx.get(connector_id, 0) + """Request handler for MeterValues (per connector).""" + transaction_id: int | None = kwargs.get(om.transaction_id.name, None) + active_tx_for_conn: int = int(self._active_tx.get(connector_id, 0) or 0) # If missing meter_start or active_transaction_id try to restore from HA states. If HA # does not have values either, generate new ones. - if self._metrics[connector_id][csess.meter_start.value].value is None: - value = self.get_ha_metric(csess.meter_start.value) - if value is None: - value = self._metrics[connector_id][DEFAULT_MEASURAND].value + if self._metrics[(connector_id, csess.meter_start.value)].value is None: + restored = self.get_ha_metric(csess.meter_start.value, connector_id) + if restored is None: + restored = self._metrics[(connector_id, DEFAULT_MEASURAND)].value else: - value = float(value) - _LOGGER.debug( - f"{csess.meter_start.value} was None, restored value={value} from HA." - ) - self._metrics[connector_id][csess.meter_start.value].value = value - if self._metrics[connector_id][csess.transaction_id.value].value is None: - value = self.get_ha_metric(csess.transaction_id.value) - if value is None: - candidate = transaction_id or active_tx_for_conn or None + try: + restored = float(restored) + except (ValueError, TypeError): + restored = None + if restored is not None: + self._metrics[(connector_id, csess.meter_start.value)].value = restored + + if self._metrics[(connector_id, csess.transaction_id.value)].value is None: + restored_tx = self.get_ha_metric(csess.transaction_id.value, connector_id) + candidate: int | None + if restored_tx is not None: + try: + candidate = int(restored_tx) + except (ValueError, TypeError): + candidate = None else: - candidate = int(value) + candidate = transaction_id if transaction_id not in (None, 0) else None if candidate is not None and candidate != 0: - self._metrics[connector_id][ - csess.transaction_id.value + self._metrics[ + (connector_id, csess.transaction_id.value) ].value = candidate self._active_tx[connector_id] = candidate + active_tx_for_conn = candidate - transaction_matches = ( - transaction_id != 0 and transaction_id == active_tx_for_conn + transaction_matches: bool = ( + transaction_id not in (None, 0) and transaction_id == active_tx_for_conn ) meter_values: list[list[MeasurandValue]] = [] for bucket in meter_value: measurands: list[MeasurandValue] = [] - for sampled_value in bucket[om.sampled_value.name]: + for sampled_value in bucket.get(om.sampled_value.name, []): measurand = sampled_value.get(om.measurand.value, None) v = sampled_value.get(om.value.value, None) # where an empty string is supplied convert to 0 @@ -629,34 +641,35 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): measurands.append( MeasurandValue(measurand, v, phase, unit, context, location) ) + meter_values.append(measurands) self.process_measurands(meter_values, transaction_matches, connector_id) if transaction_matches: tx_start = float( - self._metrics[connector_id][csess.transaction_id.value].value + self._metrics[(connector_id, csess.transaction_id.value)].value or time.time() ) - self._metrics[connector_id][csess.session_time.value].value = round( + self._metrics[(connector_id, csess.session_time.value)].value = round( (int(time.time()) - tx_start) / 60 ) - self._metrics[connector_id][csess.session_time.value].unit = "min" + self._metrics[(connector_id, csess.session_time.value)].unit = "min" if ( - self._metrics[connector_id][csess.meter_start.value].value is not None + self._metrics[(connector_id, csess.meter_start.value)].value is not None and not self._charger_reports_session_energy ): current_total = float( - self._metrics[connector_id][DEFAULT_MEASURAND].value or 0 + self._metrics[(connector_id, DEFAULT_MEASURAND)].value or 0.0 ) meter_start = float( - self._metrics[connector_id][csess.meter_start.value].value or 0 + self._metrics[(connector_id, csess.meter_start.value)].value or 0.0 ) - self._metrics[connector_id][csess.session_energy.value].value = ( + self._metrics[(connector_id, csess.session_energy.value)].value = ( current_total - meter_start ) - self._metrics[connector_id][csess.session_energy.value].extra_attr[ + self._metrics[(connector_id, csess.session_energy.value)].extra_attr[ cstat.id_tag.name - ] = self._metrics[connector_id][cstat.id_tag.value].value + ] = self._metrics[(connector_id, cstat.id_tag.value)].value self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @@ -684,9 +697,11 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): self._metrics[0][cstat.status.value].value = status self._metrics[0][cstat.error_code.value].value = error_code else: - self._metrics[connector_id][cstat.status_connector.value].value = status - self._metrics[connector_id][ - cstat.error_code_connector.value + self._metrics[ + (connector_id or 0, cstat.status_connector.value) + ].value = status + self._metrics[ + (connector_id or 0, cstat.error_code_connector.value) ].value = error_code if status in ( @@ -702,7 +717,7 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): Measurand.power_reactive_export.value, ]: if meas in self._metrics[connector_id]: - self._metrics[connector_id][meas].value = 0 + self._metrics[(connector_id, meas)].value = 0 self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StatusNotification() @@ -754,10 +769,10 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): tx_id = int(time.time()) self._active_tx[connector_id] = tx_id self.active_transaction_id = tx_id - self._metrics[connector_id][cstat.id_tag.value].value = id_tag - self._metrics[connector_id][cstat.stop_reason.value].value = "" - self._metrics[connector_id][csess.transaction_id.value].value = tx_id - self._metrics[connector_id][csess.meter_start.value].value = ( + self._metrics[(connector_id, cstat.id_tag.value)].value = id_tag + self._metrics[(connector_id, cstat.stop_reason.value)].value = "" + self._metrics[(connector_id, csess.transaction_id.value)].value = tx_id + self._metrics[(connector_id, csess.meter_start.value)].value = ( int(meter_start) / 1000 ) @@ -788,17 +803,19 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): self._active_tx[conn] = 0 self.active_transaction_id = 0 - self._metrics[conn][cstat.stop_reason.value].value = kwargs.get( + self._metrics[(conn, cstat.stop_reason.value)].value = kwargs.get( om.reason.name, None ) if ( - self._metrics[conn][csess.meter_start.value].value is not None + self._metrics[(conn, csess.meter_start.value)].value is not None and not self._charger_reports_session_energy ): - start_kwh = float(self._metrics[conn][csess.meter_start.value].value or 0) + start_kwh = float(self._metrics[(conn, csess.meter_start.value)].value or 0) stop_kwh = int(meter_stop) / 1000.0 - self._metrics[conn][csess.session_energy.value].value = stop_kwh - start_kwh + self._metrics[(conn, csess.session_energy.value)].value = ( + stop_kwh - start_kwh + ) for meas in [ Measurand.current_import.value, @@ -808,7 +825,7 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): Measurand.power_active_export.value, Measurand.power_reactive_export.value, ]: - self._metrics[conn][meas].value = 0 + self._metrics[(conn, meas)].value = 0 self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.StopTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value} diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 2616bc0d..44fee964 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -120,7 +120,7 @@ async def _get_inventory(self): req = call.GetBaseReport(1, "FullInventory") resp: call_result.GetBaseReport | None = None try: - resp: call_result.GetBaseReport = await self.call(req) + resp = await self.call(req) except ocpp.exceptions.NotImplementedError: self._inventory = InventoryReport() except OCPPError: @@ -294,11 +294,7 @@ async def start_transaction(self) -> bool: async def stop_transaction(self) -> bool: """Request remote stop of current transaction (default EVSE 1).""" - tx_id = ( - self._metrics[1][csess.transaction_id.value].value - if 1 in self._metrics - else "" - ) + tx_id = self._metrics[(1, csess.transaction_id.value)].value or "" req: call.RequestStopTransaction = call.RequestStopTransaction( transaction_id=tx_id ) @@ -410,7 +406,7 @@ def on_heartbeat(self, **kwargs): def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv16): evse_status_str: str = evse_status_v16.value - self._metrics[evse_id][cstat.status_connector.value].value = evse_status_str + self._metrics[(evse_id, cstat.status_connector.value)].value = evse_status_str self.hass.async_create_task(self.update(self.settings.cpid)) @on(Action.status_notification) @@ -567,7 +563,7 @@ def _set_meter_values( if (tx_event_type == TransactionEventEnumType.started.value) or ( (tx_event_type == TransactionEventEnumType.updated.value) - and (self._metrics[evse_id][csess.meter_start].value is None) + and (self._metrics[(evse_id, csess.meter_start)].value is None) ): energy_measurand = MeasurandEnumType.energy_active_import_register.value for meter_value in converted_values: @@ -575,8 +571,8 @@ def _set_meter_values( if measurand_item.measurand == energy_measurand: energy_value = cp.get_energy_kwh(measurand_item) energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None - self._metrics[evse_id][csess.meter_start].value = energy_value - self._metrics[evse_id][csess.meter_start].unit = energy_unit + self._metrics[(evse_id, csess.meter_start)].value = energy_value + self._metrics[(evse_id, csess.meter_start)].unit = energy_unit self.process_measurands(converted_values, True, evse_id) @@ -591,10 +587,10 @@ def _set_meter_values( for measurand in self._inventory.tx_updated_measurands: if ( (measurand not in measurands_in_tx) - and (measurand in self._metrics[evse_id]) + and ((evse_id, measurand) in self._metrics) and not measurand.startswith("Energy") ): - self._metrics[evse_id][measurand].value = 0 + self._metrics[(evse_id, measurand)].value = 0 @on(Action.transaction_event) def on_transaction_event( @@ -634,24 +630,24 @@ def on_transaction_event( if id_token: response.id_token_info = {"status": AuthorizationStatusEnumType.accepted} id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] - self._metrics[evse_id][cstat.id_tag.value].value = id_tag_string + self._metrics[(evse_id, cstat.id_tag.value)].value = id_tag_string if event_type == TransactionEventEnumType.started.value: self._tx_start_time[evse_id] = t tx_id: str = transaction_info["transaction_id"] - self._metrics[evse_id][csess.transaction_id.value].value = tx_id - self._metrics[evse_id][csess.session_time].value = 0 - self._metrics[evse_id][csess.session_time].unit = UnitOfTime.MINUTES + self._metrics[(evse_id, csess.transaction_id.value)].value = tx_id + self._metrics[(evse_id, csess.session_time)].value = 0 + self._metrics[(evse_id, csess.session_time)].unit = UnitOfTime.MINUTES else: if self._tx_start_time.get(evse_id): duration_minutes: int = ( (t - self._tx_start_time[evse_id]).seconds + 59 ) // 60 - self._metrics[evse_id][csess.session_time].value = duration_minutes - self._metrics[evse_id][csess.session_time].unit = UnitOfTime.MINUTES + self._metrics[(evse_id, csess.session_time)].value = duration_minutes + self._metrics[(evse_id, csess.session_time)].unit = UnitOfTime.MINUTES if event_type == TransactionEventEnumType.ended.value: - self._metrics[evse_id][csess.transaction_id.value].value = "" - self._metrics[evse_id][cstat.id_tag.value].value = "" + self._metrics[(evse_id, csess.transaction_id.value)].value = "" + self._metrics[(evse_id, cstat.id_tag.value)].value = "" if not offline: self.hass.async_create_task(self.update(self.settings.cpid)) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 3ba5042b..717c33f4 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass - import homeassistant from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -42,66 +41,96 @@ class OcppSensorDescription(SensorEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the sensor platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] + entities: list[ChargePointMetric] = [] # setup all chargers added to config for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) - SENSORS = [] - for metric in list( + measurands = list( set( cp_id_settings[CONF_MONITORED_VARIABLES].split(",") + list(HAChargerSession) ) - ): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - ) + ) + + CHARGER_ONLY = [ + HAChargerStatuses.status.value, + HAChargerStatuses.error_code.value, + HAChargerStatuses.heartbeat.value, + HAChargerStatuses.latency_ping.value, + HAChargerStatuses.latency_pong.value, + HAChargerStatuses.reconnects.value, + HAChargerDetails.vendor.value, + HAChargerDetails.model.value, + HAChargerDetails.serial.value, + HAChargerDetails.firmware_version.value, + HAChargerDetails.features.value, + HAChargerDetails.connectors.value, + HAChargerDetails.config_response.value, + HAChargerDetails.data_response.value, + ] + + CONNECTOR_ONLY = measurands + [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ] + + def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: + return OcppSensorDescription( + key=metric.lower(), + name=metric.replace(".", " "), + metric=metric, + entity_category=EntityCategory.DIAGNOSTIC if cat_diag else None, ) - for metric in list(HAChargerStatuses) + list(HAChargerDetails): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - entity_category=EntityCategory.DIAGNOSTIC, + + # Root/charger-entities + for metric in CHARGER_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc(metric, cat_diag=True), + connector_id=None, ) ) if num_connectors > 1: for conn_id in range(1, num_connectors + 1): - name_suffix = f" Connector {conn_id}" - for ent in SENSORS: + for metric in CONNECTOR_ONLY: entities.append( ChargePointMetric( hass, central_system, cpid, - OcppSensorDescription( - key=ent.key, - name=ent.name + name_suffix, - metric=ent.metric, - icon=ent.icon, - device_class=ent.device_class, - state_class=ent.state_class, - entity_category=ent.entity_category, + _mk_desc( + metric, + cat_diag=metric + in [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ], ), connector_id=conn_id, ) ) else: - for ent in SENSORS: + for metric in CONNECTOR_ONLY: entities.append( ChargePointMetric( hass, central_system, cpid, - ent, + _mk_desc( + metric, + cat_diag=metric + in [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ], + ), connector_id=None, ) ) @@ -148,26 +177,29 @@ def __init__( identifiers={(DOMAIN, cpid)}, name=cpid, ) + self._attr_icon = ICON self._attr_native_unit_of_measurement = None @property def available(self) -> bool: """Return if sensor is available.""" - return self.central_system.get_available(self.cpid) + return self.central_system.get_available(self.cpid, self.connector_id) @property - def should_poll(self): + def should_poll(self) -> bool: """Return True if entity has to be polled for state. False if entity pushes its state to HA. """ - return True + return False @property def extra_state_attributes(self): """Return the state attributes.""" - return self.central_system.get_extra_attr(self.cpid, self.metric) + return self.central_system.get_extra_attr( + self.cpid, self.metric, self.connector_id + ) @property def state_class(self): @@ -227,7 +259,9 @@ def device_class(self): @property def native_value(self): """Return the state of the sensor, rounding if a number.""" - value = self.central_system.get_metric(self.cpid, self.metric) + value = self.central_system.get_metric( + self.cpid, self.metric, self.connector_id + ) if value is not None: self._attr_native_value = value return self._attr_native_value @@ -235,7 +269,9 @@ def native_value(self): @property def native_unit_of_measurement(self): """Return the native unit of measurement.""" - value = self.central_system.get_ha_unit(self.cpid, self.metric) + value = self.central_system.get_ha_unit( + self.cpid, self.metric, self.connector_id + ) if value is not None: self._attr_native_unit_of_measurement = value else: @@ -255,6 +291,8 @@ async def async_added_to_hass(self) -> None: self._hass, DATA_UPDATED, self._schedule_immediate_update ) + self.async_schedule_update_ha_state(True) + @callback def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index c4a41be8..e4bab81f 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -125,7 +125,7 @@ def __init__( @property def available(self) -> bool: """Return if switch is available.""" - return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid, self.connector_id) # type: ignore [no-any-return] @property def is_on(self) -> bool: @@ -133,7 +133,7 @@ def is_on(self) -> bool: """Test metric state against condition if present""" if self.entity_description.metric_state is not None: resp = self.central_system.get_metric( - self.cpid, self.entity_description.metric_state + self.cpid, self.entity_description.metric_state, self.connector_id ) if resp in self.entity_description.metric_condition: self._state = True diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index 8d87f7dd..c60e0861 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -27,7 +27,8 @@ "idle_interval": "Abtastintervall Leerlauf (Sekunden)", "skip_schema_validation": "Überspringe OCPP-Schemavalidierung", "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil", - "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte" + "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte", + "num_connectors": "Anzahl der Anschlüsse pro Ladestation" } }, "measurands": { diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 36a8562d..54534c71 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -27,7 +27,8 @@ "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", "skip_schema_validation": "Skip OCPP schema validation", - "force_smart_charging": "Force Smart Charging feature profile" + "force_smart_charging": "Force Smart Charging feature profile", + "num_connectors": "Number of connectors per charger" } }, "measurands": { diff --git a/custom_components/ocpp/translations/es.json b/custom_components/ocpp/translations/es.json index f40ebdc0..21493511 100644 --- a/custom_components/ocpp/translations/es.json +++ b/custom_components/ocpp/translations/es.json @@ -22,7 +22,8 @@ "meter_interval": "Intervalo de mediciones (segundos)", "idle_interval": "Intervalo de muestreo del cargador en reposo (segundos)", "skip_schema_validation": "Omitir validación esquema OCPP", - "force_smart_charging": "Forzar perfil de función Smart Charging" + "force_smart_charging": "Forzar perfil de función Smart Charging", + "num_connectors": "Número de conectores por punto de carga" } }, "measurands": { diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index e01afa80..15ed4777 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -27,7 +27,8 @@ "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", "skip_schema_validation": "Skip OCPP schema validation", - "force_smart_charging": "Force Smart Charging feature profile" + "force_smart_charging": "Force Smart Charging feature profile", + "num_connectors": "Number of connectors per charger" } }, "measurands": { diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index ce4cc92c..cfd658e1 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -22,7 +22,8 @@ "max_current": "Maximale laadstroom", "meter_interval": "Meetinterval (secondes)", "skip_schema_validation": "Skip OCPP schema validation", - "force_smart_charging": "Functieprofiel Smart Charging forceren" + "force_smart_charging": "Functieprofiel Smart Charging forceren", + "num_connectors": "Aantal connectoren per Charge point" } }, "measurands": { From bcfe97b43438493a2a9ff9a068bb2724a05b4aa5 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Tue, 19 Aug 2025 13:53:10 +0000 Subject: [PATCH 04/15] Add test for v2x multi connector. Handle meter values properly. max_current working per connector. --- custom_components/ocpp/api.py | 221 +++++++++++---- custom_components/ocpp/button.py | 6 +- custom_components/ocpp/chargepoint.py | 122 ++++----- custom_components/ocpp/number.py | 6 +- custom_components/ocpp/ocppv16.py | 323 ++++++++++++++-------- custom_components/ocpp/ocppv201.py | 226 ++++++++++++---- custom_components/ocpp/sensor.py | 11 + custom_components/ocpp/switch.py | 58 ++-- tests/charge_point_test.py | 5 +- tests/test_charge_point_v201_multi.py | 370 ++++++++++++++++++++++++++ 10 files changed, 1042 insertions(+), 306 deletions(-) create mode 100644 tests/test_charge_point_v201_multi.py diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index a4f1abfa..f5bbcf67 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -5,6 +5,7 @@ import contextlib import json import logging +import re import ssl from functools import partial @@ -90,6 +91,10 @@ ) +def _norm(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", str(s).lower()) + + class CentralSystem: """Server for handling OCPP connections.""" @@ -192,7 +197,7 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): @staticmethod def _norm_conn(connector_id: int | None) -> int: - if connector_id in (None, 0): + if connector_id is None: return 0 try: return int(connector_id) @@ -293,20 +298,50 @@ def _get_metrics(self, id: str): def get_metric(self, id: str, measurand: str, connector_id: int | None = None): """Return last known value for given measurand.""" # allow id to be either cpid or cp_id - cp_id, m = self._get_metrics(id) - - if m is None: + cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: return None - conn = self._norm_conn(connector_id) - try: - return m[(conn, measurand)].value - except Exception: - if conn == 0: - with contextlib.suppress(Exception): - return m[measurand].value + cp = self.charge_points[cp_id] + m = cp._metrics + n_connectors = getattr(cp, "num_connectors", 1) or 1 + + def _try_val(key): + with contextlib.suppress(Exception): + val = m[key].value + return val return None + # 1) Explicit connector_id (including 0): just get it + if connector_id is not None: + conn = 0 if connector_id == 0 else connector_id + return _try_val((conn, measurand)) + + # 2) No connector_id: try CHARGER level (conn=0) + val = _try_val((0, measurand)) + if val is not None: + return val + + # 3) Legacy "flat" key (before the connector support) + with contextlib.suppress(Exception): + val = m[measurand].value + if val is not None: + return val + + # 4) Fallback to connector 1 (old tests often expect this) + if n_connectors >= 1: + val = _try_val((1, measurand)) + if val is not None: + return val + + # 5) Last resort: find the first connector 2..N with value + for c in range(2, int(n_connectors) + 1): + val = _try_val((c, measurand)) + if val is not None: + return val + + return None + def del_metric(self, id: str, measurand: str, connector_id: int | None = None): """Set given measurand to None.""" # allow id to be either cpid or cp_id @@ -326,47 +361,125 @@ def del_metric(self, id: str, measurand: str, connector_id: int | None = None): def get_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return unit of given measurand.""" # allow id to be either cpid or cp_id - cp_id, m = self._get_metrics(id) - if m is None: + cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: return None - conn = self._norm_conn(connector_id) - try: - return m[(conn, measurand)].unit - except Exception: - if conn == 0: - with contextlib.suppress(Exception): - return m[measurand].unit + + cp = self.charge_points[cp_id] + m = cp._metrics + n_connectors = getattr(cp, "num_connectors", 1) or 1 + + def _try_unit(key): + with contextlib.suppress(Exception): + return m[key].unit return None + if connector_id is not None: + conn = 0 if connector_id == 0 else connector_id + return _try_unit((conn, measurand)) + + val = _try_unit((0, measurand)) + if val is not None: + return val + + with contextlib.suppress(Exception): + val = m[measurand].unit + if val is not None: + return val + + if n_connectors >= 1: + val = _try_unit((1, measurand)) + if val is not None: + return val + + for c in range(2, int(n_connectors) + 1): + val = _try_unit((c, measurand)) + if val is not None: + return val + + return None + def get_ha_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return home assistant unit of given measurand.""" - cp_id, m = self._get_metrics(id) - if m is None: + cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: return None - conn = self._norm_conn(connector_id) - try: - return m[(conn, measurand)].ha_unit - except Exception: - if conn == 0: - with contextlib.suppress(Exception): - return m[measurand].ha_unit + + cp = self.charge_points[cp_id] + m = cp._metrics + n_connectors = getattr(cp, "num_connectors", 1) or 1 + + def _try_ha_unit(key): + with contextlib.suppress(Exception): + return m[key].ha_unit return None + if connector_id is not None: + conn = 0 if connector_id == 0 else connector_id + return _try_ha_unit((conn, measurand)) + + val = _try_ha_unit((0, measurand)) + if val is not None: + return val + + with contextlib.suppress(Exception): + val = m[measurand].ha_unit + if val is not None: + return val + + if n_connectors >= 1: + val = _try_ha_unit((1, measurand)) + if val is not None: + return val + + for c in range(2, int(n_connectors) + 1): + val = _try_ha_unit((c, measurand)) + if val is not None: + return val + + return None + def get_extra_attr(self, id: str, measurand: str, connector_id: int | None = None): - """Return last known extra attributes for given measurand.""" + """Return extra attributes for given measurand.""" # allow id to be either cpid or cp_id - cp_id, m = self._get_metrics(id) - if m is None: + cp_id = self.cpids.get(id, id) + if cp_id not in self.charge_points: return None - conn = self._norm_conn(connector_id) - try: - return m[(conn, measurand)].extra_attr - except Exception: - if conn == 0: - with contextlib.suppress(Exception): - return m[measurand].extra_attr + + cp = self.charge_points[cp_id] + m = cp._metrics + n_connectors = getattr(cp, "num_connectors", 1) or 1 + + def _try_extra(key): + with contextlib.suppress(Exception): + return m[key].extra_attr return None + if connector_id is not None: + conn = 0 if connector_id == 0 else connector_id + return _try_extra((conn, measurand)) + + val = _try_extra((0, measurand)) + if val is not None: + return val + + with contextlib.suppress(Exception): + val = m[measurand].extra_attr + if val is not None: + return val + + if n_connectors >= 1: + val = _try_extra((1, measurand)) + if val is not None: + return val + + for c in range(2, int(n_connectors) + 1): + val = _try_extra((c, measurand)) + if val is not None: + return val + + return None + def get_available(self, id: str, connector_id: int | None = None): """Return whether the charger (or a specific connector) is available.""" # allow id to be either cpid or cp_id @@ -397,7 +510,7 @@ def get_available(self, id: str, connector_id: int | None = None): if not status_val: return cp.status == STATE_OK - ok_statuses = { + ok_statuses_norm = { "available", "preparing", "charging", @@ -408,7 +521,7 @@ def get_available(self, id: str, connector_id: int | None = None): "reserved", } - ret = str(status_val).lower() in ok_statuses + ret = _norm(status_val) in ok_statuses_norm return ret def get_supported_features(self, id: str): @@ -420,16 +533,26 @@ def get_supported_features(self, id: str): return self.charge_points[cp_id].supported_features return 0 - async def set_max_charge_rate_amps(self, id: str, value: float): + async def set_max_charge_rate_amps( + self, id: str, value: float, connector_id: int = 0 + ): """Set the maximum charge rate in amps.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) if cp_id in self.charge_points: - return await self.charge_points[cp_id].set_charge_rate(limit_amps=value) + return await self.charge_points[cp_id].set_charge_rate( + limit_amps=value, conn_id=connector_id + ) return False - async def set_charger_state(self, id: str, service_name: str, state: bool = True): + async def set_charger_state( + self, + id: str, + service_name: str, + state: bool = True, + connector_id: int | None = 1, + ): """Carry out requested service/state change on connected charger.""" # allow id to be either cpid or cp_id cp_id = self.cpids.get(id, id) @@ -437,15 +560,19 @@ async def set_charger_state(self, id: str, service_name: str, state: bool = True resp = False if cp_id in self.charge_points: if service_name == csvcs.service_availability.name: - resp = await self.charge_points[cp_id].set_availability(state) + resp = await self.charge_points[cp_id].set_availability( + state, connector_id=connector_id + ) if service_name == csvcs.service_charge_start.name: - resp = await self.charge_points[cp_id].start_transaction() + resp = await self.charge_points[cp_id].start_transaction( + connector_id=connector_id + ) if service_name == csvcs.service_charge_stop.name: resp = await self.charge_points[cp_id].stop_transaction() if service_name == csvcs.service_reset.name: resp = await self.charge_points[cp_id].reset() if service_name == csvcs.service_unlock.name: - resp = await self.charge_points[cp_id].unlock() + resp = await self.charge_points[cp_id].unlock(connector_id=connector_id) return resp def device_info(self): diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index a8c86d96..d547ad16 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -58,7 +58,7 @@ async def async_setup_entry(hass, entry, async_add_devices): num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1)) for desc in BUTTONS: - if desc.per_connector and num_connectors > 1: + if desc.per_connector: for connector_id in range(1, num_connectors + 1): entities.append( ChargePointButton( @@ -124,5 +124,7 @@ def available(self) -> bool: async def async_press(self) -> None: """Triggers the charger press action service.""" await self.central_system.set_charger_state( - self.cpid, self.entity_description.press_action + self.cpid, + self.entity_description.press_action, + connector_id=self.connector_id, ) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 92d2344a..3f79d64b 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -395,7 +395,7 @@ async def set_availability(self, state: bool = True) -> bool: """Change availability.""" return False - async def start_transaction(self) -> bool: + async def start_transaction(self, connector_id: int = 1) -> bool: """Remote start a transaction.""" return False @@ -730,95 +730,97 @@ def process_measurands( """Process all values from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" for bucket in meter_values: unprocessed: list[MeasurandValue] = [] - for idx in range(len(bucket)): - sampled_value: MeasurandValue = bucket[idx] + for sampled_value in bucket: measurand = sampled_value.measurand value = sampled_value.value unit = sampled_value.unit phase = sampled_value.phase location = sampled_value.location context = sampled_value.context - # where an empty string is supplied convert to 0 - if sampled_value.measurand is None: # Backwards compatibility + # If the measurand is missing: treat as EAIR but respect existing unit + if measurand is None: measurand = DEFAULT_MEASURAND - unit = DEFAULT_ENERGY_UNIT + if unit is None: + unit = DEFAULT_ENERGY_UNIT + # If EAIR and unit missing, assume Wh (charger not sending unit) if measurand == DEFAULT_MEASURAND and unit is None: unit = DEFAULT_ENERGY_UNIT - if unit == DEFAULT_ENERGY_UNIT: - value = ChargePoint.get_energy_kwh(sampled_value) + # Normalize units + if unit == DEFAULT_ENERGY_UNIT or ( + measurand == DEFAULT_MEASURAND and unit is None + ): + # Wh → kWh + value = ChargePoint.get_energy_kwh( + MeasurandValue(measurand, value, phase, unit, context, location) + ) unit = HA_ENERGY_UNIT - - if unit == DEFAULT_POWER_UNIT: + elif unit == DEFAULT_POWER_UNIT: + # W → kW value = value / 1000 unit = HA_POWER_UNIT - if self._metrics[(connector_id, csess.meter_start.value)].value in ( - 0, - None, - ): + # Only flag if meter_start explicitly is 0 (not None) + if self._metrics[(connector_id, csess.meter_start.value)].value == 0: self._charger_reports_session_energy = True if phase is None: + # Set main measurand + self._metrics[(connector_id, measurand)].value = value + self._metrics[(connector_id, measurand)].unit = unit + + if location is not None: + self._metrics[(connector_id, measurand)].extra_attr[ + om.location.value + ] = location + if context is not None: + self._metrics[(connector_id, measurand)].extra_attr[ + om.context.value + ] = context + + # Energy.Session is calculated here only for OCPP 2.x (not 1.6) if ( measurand == DEFAULT_MEASURAND - and self._charger_reports_session_energy + and is_transaction + and self._ocpp_version != "1.6" ): - # Ignore messages with Transaction Begin context - if context != ReadingContext.transaction_begin.value: - if is_transaction: - self._metrics[ - (connector_id, csess.session_energy.value) - ].value = value - self._metrics[ - (connector_id, csess.session_energy.value) - ].unit = unit - self._metrics[ - (connector_id, csess.session_energy.value) - ].extra_attr[cstat.id_tag.name] = self._metrics[ - (connector_id, cstat.id_tag.value) - ].value - else: - self._metrics[(connector_id, measurand)].value = value - self._metrics[(connector_id, measurand)].unit = unit - else: - continue - else: - self._metrics[(connector_id, measurand)].value = value - self._metrics[(connector_id, measurand)].unit = unit if ( - is_transaction - and (measurand == DEFAULT_MEASURAND) - and ( - self._metrics[(connector_id, csess.meter_start)].value - is not None - ) - and ( - self._metrics[(connector_id, csess.meter_start)].unit - == unit - ) + self._charger_reports_session_energy + and context != ReadingContext.transaction_begin.value ): - meter_start = self._metrics[ - (connector_id, csess.meter_start) - ].value + # The charger reports session energy directly (2.x case) self._metrics[ (connector_id, csess.session_energy.value) - ].value = round(1000 * (value - meter_start)) / 1000 + ].value = value self._metrics[ (connector_id, csess.session_energy.value) - ].unit = unit - if location is not None: - self._metrics[(connector_id, measurand)].extra_attr[ - om.location.value - ] = location - if context is not None: - self._metrics[(connector_id, measurand)].extra_attr[ - om.context.value - ] = context + ].unit = HA_ENERGY_UNIT + self._metrics[ + (connector_id, csess.session_energy.value) + ].extra_attr[cstat.id_tag.name] = self._metrics[ + (connector_id, cstat.id_tag.value) + ].value + else: + # Derive: EAIR_kWh - meter_start_kWh + ms_val = self._metrics[ + (connector_id, csess.meter_start) + ].value + if ms_val is not None: + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = ( + round(1000 * (float(value) - float(ms_val))) / 1000 + ) + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT else: + # Handle phase values separately unprocessed.append(sampled_value) + + # Sum/calculate phase values self.process_phases(unprocessed, connector_id) @property diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 602f3b38..3d742e59 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -62,9 +62,7 @@ async def async_setup_entry(hass, entry, async_add_devices): cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) - for connector_id in ( - range(1, num_connectors + 1) if num_connectors > 1 else [None] - ): + for connector_id in range(1, num_connectors + 1): for ent in NUMBERS: if ent.key == "maximum_current": ent_initial = cp_id_settings[CONF_MAX_CURRENT] @@ -163,7 +161,7 @@ async def async_set_native_value(self, value): self.cpid ) and Profiles.SMART & self.central_system.get_supported_features(self.cpid): resp = await self.central_system.set_max_charge_rate_amps( - self.cpid, num_value + self.cpid, num_value, self.connector_id ) if resp is True: self._attr_native_value = num_value diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index aed9503f..7da52fb6 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.const import UnitOfTime import voluptuous as vol from websockets.asyncio.server import ServerConnection @@ -55,13 +56,30 @@ CentralSystemSettings, ChargerSystemSettings, DEFAULT_MEASURAND, + DEFAULT_ENERGY_UNIT, DOMAIN, + HA_ENERGY_UNIT, ) _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.INFO) +def _to_message_trigger(name: str) -> MessageTrigger | None: + if isinstance(name, MessageTrigger): + return name + key = str(name).strip().replace(" ", "").replace("_", "").lower() + mapping = { + "bootnotification": MessageTrigger.boot_notification, + "heartbeat": MessageTrigger.heartbeat, + "metervalues": MessageTrigger.meter_values, + "statusnotification": MessageTrigger.status_notification, + "diagnosticsstatusnotification": MessageTrigger.diagnostics_status_notification, + "firmwarestatusnotification": MessageTrigger.firmware_status_notification, + } + return mapping.get(key) + + class ChargePoint(cp): """Server side representation of a charger.""" @@ -240,10 +258,15 @@ async def trigger_status_notification(self): async def trigger_custom_message( self, - requested_message: str = "StatusNotification", + requested_message: str | MessageTrigger = "StatusNotification", ): """Trigger Custom Message.""" - req = call.TriggerMessage(requested_message) + trig = _to_message_trigger(requested_message) + if trig is None: + _LOGGER.warning("Unsupported TriggerMessage: %s", requested_message) + return False + + req = call.TriggerMessage(requested_message=trig) resp = await self.call(req) if resp.status != TriggerMessageStatus.accepted: _LOGGER.warning("Failed with response: %s", resp.status) @@ -270,103 +293,90 @@ async def set_charge_rate( conn_id: int = 0, profile: dict | None = None, ): - """Set a charging profile with defined limit.""" - if profile is not None: # assumes advanced user and correct profile format + """Set charging profile with defined limit. + + - conn_id == 0 -> ChargePointMaxProfile (connector_id=0) + - conn_id > 0 -> TxDefaultProfile (connector_id=conn_id) + """ + if profile is not None: req = call.SetChargingProfile( - connector_id=conn_id, cs_charging_profiles=profile + connector_id=(0 if conn_id == 0 else conn_id), + cs_charging_profiles=profile, ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False - - if prof.SMART in self._attr_supported_features: - resp = await self.get_configuration( - ckey.charging_schedule_allowed_charging_rate_unit.value - ) - _LOGGER.info( - "Charger supports setting the following units: %s", - resp, - ) - _LOGGER.info("If more than one unit supported default unit is Amps") - # Some chargers (e.g. Teison) don't support querying charging rate unit - if resp is None: - _LOGGER.warning("Failed to query charging rate unit, assuming Amps") - resp = om.current.value - if om.current.value in resp: - lim = limit_amps - units = ChargingRateUnitType.amps.value - else: - lim = limit_watts - units = ChargingRateUnitType.watts.value - resp = await self.get_configuration( - ckey.charge_profile_max_stack_level.value - ) - stack_level = int(resp) - req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" ) - else: + return False + + if prof.SMART not in self._attr_supported_features: _LOGGER.info("Smart charging is not supported by this charger") return False + + resp_units = await self.get_configuration( + ckey.charging_schedule_allowed_charging_rate_unit.value + ) + if resp_units is None: + _LOGGER.warning("Failed to query charging rate unit, assuming Amps") + resp_units = om.current.value + if om.current.value in resp_units: + lim = max(0, float(limit_amps)) + units = ChargingRateUnitType.amps.value + else: + lim = max(0, float(limit_watts)) + units = ChargingRateUnitType.watts.value + resp = await self.get_configuration(ckey.charge_profile_max_stack_level.value) + stack_level = int(resp or 0) + if conn_id == 0: + purpose = ChargingProfilePurposeType.charge_point_max_profile.value + target_connector = 0 + else: + purpose = ChargingProfilePurposeType.tx_default_profile.value + target_connector = conn_id + profile_dict = { + om.charging_profile_id.value: 8, + om.stack_level.value: max(0, stack_level), + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: purpose, + om.charging_schedule.value: { + om.charging_rate_unit.value: units, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: lim} + ], + }, + } + req = call.SetChargingProfile( + connector_id=target_connector, cs_charging_profiles=profile_dict + ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - else: - _LOGGER.debug( - "ChargePointMaxProfile is not supported by this charger, trying TxDefaultProfile instead..." - ) - # try a lower stack level for chargers where level < maximum, not <= + if target_connector != 0: + profile_dict[om.stack_level.value] = max(0, stack_level - 1) req = call.SetChargingProfile( - connector_id=conn_id, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level - 1, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, + connector_id=target_connector, cs_charging_profiles=profile_dict ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False - async def set_availability(self, state: bool = True): + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set charging profile failed with response {resp.status}" + ) + return False + + async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" if state is True: typ = AvailabilityType.operative.value else: typ = AvailabilityType.inoperative.value - req = call.ChangeAvailability(connector_id=0, type=typ) + req = call.ChangeAvailability(connector_id=int(connector_id or 0), type=typ) resp = await self.call(req) if resp.status in [ AvailabilityStatus.accepted, @@ -380,10 +390,12 @@ async def set_availability(self, state: bool = True): ) return False - async def start_transaction(self): + async def start_transaction(self, connector_id: int = 1): """Remote start a transaction.""" _LOGGER.info("Start transaction with remote ID tag: %s", self._remote_id_tag) - req = call.RemoteStartTransaction(connector_id=1, id_tag=self._remote_id_tag) + req = call.RemoteStartTransaction( + connector_id=connector_id, id_tag=self._remote_id_tag + ) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: return True @@ -583,8 +595,17 @@ async def async_update_device_info_v16(self, boot_info: dict): @on(Action.meter_values) def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): - """Request handler for MeterValues (per connector).""" + """Handle MeterValues (per connector). + + - EAIR (Energy.Active.Import.Register) **without** transactionId is treated as main meter, + written to connector 0 (aggregate). + - EAIR **with** transactionId is written to the proper connector (connector_id) and used + to update Energy.Session (kWh). + - Other measurands handled via process_measurands(). + """ transaction_id: int | None = kwargs.get(om.transaction_id.name, None) + tx_has_id: bool = transaction_id not in (None, 0) + active_tx_for_conn: int = int(self._active_tx.get(connector_id, 0) or 0) # If missing meter_start or active_transaction_id try to restore from HA states. If HA @@ -610,7 +631,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): except (ValueError, TypeError): candidate = None else: - candidate = transaction_id if transaction_id not in (None, 0) else None + candidate = transaction_id if tx_has_id else None if candidate is not None and candidate != 0: self._metrics[ @@ -619,9 +640,10 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self._active_tx[connector_id] = candidate active_tx_for_conn = candidate - transaction_matches: bool = ( - transaction_id not in (None, 0) and transaction_id == active_tx_for_conn - ) + if tx_has_id: + transaction_matches = transaction_id == active_tx_for_conn + else: + transaction_matches = active_tx_for_conn not in (None, 0) meter_values: list[list[MeasurandValue]] = [] for bucket in meter_value: @@ -641,11 +663,31 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): measurands.append( MeasurandValue(measurand, v, phase, unit, context, location) ) - meter_values.append(measurands) + + # Write main meter value (EAIR) to connector 0 om this message is missing transactionId + if not tx_has_id: + for bucket in meter_values: + for item in bucket: + measurand = item.measurand or DEFAULT_MEASURAND + if measurand == DEFAULT_MEASURAND: + eair_kwh = cp.get_energy_kwh(item) # Wh→kWh if necessary + # Aggregate (connector 0) carries the latest main meter value + self._metrics[(0, DEFAULT_MEASURAND)].value = eair_kwh + self._metrics[(0, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT + if item.location is not None: + self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ + om.location.value + ] = item.location + if item.context is not None: + self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[ + om.context.value + ] = item.context + self.process_measurands(meter_values, transaction_matches, connector_id) - if transaction_matches: + # Update session time if ongoing transaction + if active_tx_for_conn not in (None, 0): tx_start = float( self._metrics[(connector_id, csess.transaction_id.value)].value or time.time() @@ -654,22 +696,30 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): (int(time.time()) - tx_start) / 60 ) self._metrics[(connector_id, csess.session_time.value)].unit = "min" - if ( - self._metrics[(connector_id, csess.meter_start.value)].value is not None - and not self._charger_reports_session_energy - ): - current_total = float( - self._metrics[(connector_id, DEFAULT_MEASURAND)].value or 0.0 - ) - meter_start = float( - self._metrics[(connector_id, csess.meter_start.value)].value or 0.0 - ) - self._metrics[(connector_id, csess.session_energy.value)].value = ( - current_total - meter_start - ) - self._metrics[(connector_id, csess.session_energy.value)].extra_attr[ - cstat.id_tag.name - ] = self._metrics[(connector_id, cstat.id_tag.value)].value + + # Update Energy.Session ONLY from EAIR in this message if txId exists and matches + if tx_has_id and transaction_matches: + eair_kwh_in_msg: float | None = None + for bucket in meter_values: + for item in bucket: + measurand = item.measurand or DEFAULT_MEASURAND + if measurand == DEFAULT_MEASURAND: + eair_kwh_in_msg = cp.get_energy_kwh(item) + if eair_kwh_in_msg is not None: + try: + meter_start_kwh = float( + self._metrics[(connector_id, csess.meter_start.value)].value + or 0.0 + ) + except Exception: + meter_start_kwh = 0.0 + session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh) + self._metrics[ + (connector_id, csess.session_energy.value) + ].value = session_kwh + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT self.hass.async_create_task(self.update(self.settings.cpid)) return call_result.MeterValues() @@ -772,9 +822,23 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): self._metrics[(connector_id, cstat.id_tag.value)].value = id_tag self._metrics[(connector_id, cstat.stop_reason.value)].value = "" self._metrics[(connector_id, csess.transaction_id.value)].value = tx_id - self._metrics[(connector_id, csess.meter_start.value)].value = ( - int(meter_start) / 1000 - ) + try: + meter_start_kwh = float(meter_start) / 1000.0 + except Exception: + meter_start_kwh = 0.0 + self._metrics[ + (connector_id, csess.meter_start.value) + ].value = meter_start_kwh + self._metrics[(connector_id, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + self._metrics[(connector_id, csess.session_time.value)].value = 0 + self._metrics[ + (connector_id, csess.session_time.value) + ].unit = UnitOfTime.MINUTES + self._metrics[(connector_id, csess.session_energy.value)].value = 0.0 + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT result = call_result.StartTransaction( id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, @@ -782,8 +846,10 @@ def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): ) else: result = call_result.StartTransaction( - id_tag_info={om.status.value: auth_status}, transaction_id=0 + id_tag_info={om.status.value: auth_status}, + transaction_id=0, ) + self.hass.async_create_task(self.update(self.settings.cpid)) return result @@ -807,15 +873,44 @@ def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): om.reason.name, None ) - if ( - self._metrics[(conn, csess.meter_start.value)].value is not None - and not self._charger_reports_session_energy - ): - start_kwh = float(self._metrics[(conn, csess.meter_start.value)].value or 0) - stop_kwh = int(meter_stop) / 1000.0 - self._metrics[(conn, csess.session_energy.value)].value = ( - stop_kwh - start_kwh - ) + use_eair_from_tx = bool(self._charger_reports_session_energy) + + if use_eair_from_tx: + sess_val = self._metrics[(conn, csess.session_energy.value)].value + if sess_val is None: + last_eair = self._metrics[(conn, DEFAULT_MEASURAND)].value + last_unit = self._metrics[(conn, DEFAULT_MEASURAND)].unit + try: + if last_eair is not None: + if last_unit == DEFAULT_ENERGY_UNIT: + eair_kwh = float(last_eair) / 1000.0 + elif last_unit == HA_ENERGY_UNIT: + eair_kwh = float(last_eair) + else: + eair_kwh = float(last_eair) + self._metrics[ + (conn, csess.session_energy.value) + ].value = eair_kwh + self._metrics[ + (conn, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT + except Exception: + pass + else: + try: + meter_stop_kwh = float(meter_stop) / 1000.0 + except Exception: + meter_stop_kwh = 0.0 + try: + meter_start_kwh = float( + self._metrics[(conn, csess.meter_start.value)].value or 0.0 + ) + except Exception: + meter_start_kwh = 0.0 + + session_kwh = max(0.0, meter_stop_kwh - meter_start_kwh) + self._metrics[(conn, csess.session_energy.value)].value = session_kwh + self._metrics[(conn, csess.session_energy.value)].unit = HA_ENERGY_UNIT for meas in [ Measurand.current_import.value, diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 44fee964..301fc3e8 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -56,6 +56,7 @@ DOMAIN, HA_ENERGY_UNIT, ) +import contextlib _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.INFO) @@ -79,6 +80,8 @@ class ChargePoint(cp): _wait_inventory: asyncio.Event | None = None _connector_status: list[list[ConnectorStatusEnumType | None]] = [] _tx_start_time: dict[int, datetime] + _global_to_evse: dict[int, tuple[int, int]] # global_idx -> (evse_id, connector_id) + _evse_to_global: dict[tuple[int, int], int] # (evse_id, connector_id) -> global_idx def __init__( self, @@ -101,6 +104,39 @@ def __init__( charger, ) self._tx_start_time = {} + self._global_to_evse = {} + self._evse_to_global = {} + + # --- Connector mapping helpers (EVSE <-> global index) --- + def _build_connector_map(self): + """Build maps between global connector index and (evse_id, connector_id).""" + self._global_to_evse.clear() + self._evse_to_global.clear() + if not self._inventory: + return + idx = 0 + for evse_id in range(1, self._inventory.evse_count + 1): + cnt = self._inventory.connector_count[evse_id - 1] + for conn_id in range(1, cnt + 1): + idx += 1 + self._global_to_evse[idx] = (evse_id, conn_id) + self._evse_to_global[(evse_id, conn_id)] = idx + + def _pair_to_global(self, evse_id: int, conn_id: int) -> int: + """Return global index for (evse_id, connector_id). Fallback: first connector of EVSE.""" + return self._evse_to_global.get( + (evse_id, conn_id), self._evse_to_global.get((evse_id, 1), evse_id) + ) + + def _global_to_pair(self, global_idx: int) -> tuple[int, int]: + """Return (evse_id, connector_id) for a global index. Fallback: (global_idx,1).""" + return self._global_to_evse.get(global_idx, (global_idx, 1)) + + def _total_connectors(self) -> int: + """Total physical connectors across all EVSE.""" + if not self._inventory: + return 0 + return sum(self._inventory.connector_count or [0]) async def async_update_device_info_v201(self, boot_info: dict): """Update device info asynchronuously.""" @@ -128,11 +164,13 @@ async def _get_inventory(self): if (resp is not None) and (resp.status == "Accepted"): await asyncio.wait_for(self._wait_inventory.wait(), self._response_timeout) self._wait_inventory = None + if self._inventory: + self._build_connector_map() async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" await self._get_inventory() - return self._inventory.evse_count if self._inventory else 0 + return self._total_connectors() async def set_standard_configuration(self): """Send configuration values to the charger.""" @@ -220,47 +258,67 @@ async def clear_profile(self): req: call.ClearChargingProfile = call.ClearChargingProfile( None, { - "charging_profile_Purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value }, ) await self.call(req) async def set_charge_rate( self, - limit_amps: int = 32, - limit_watts: int = 22000, + limit_amps: int | None = None, + limit_watts: int | None = None, conn_id: int = 0, profile: dict | None = None, ): - """Set a charging profile with defined limit.""" - req: call.SetChargingProfile - if profile: + """Set a charging profile with defined limit (OCPP 2.x).""" + if profile is not None: req = call.SetChargingProfile(0, profile) - else: - period: dict = {"start_period": 0} - schedule: dict = {"id": 1} - if limit_amps < 32: - period["limit"] = limit_amps - schedule["charging_rate_unit"] = ChargingRateUnitEnumType.amps.value - elif limit_watts < 22000: - period["limit"] = limit_watts - schedule["charging_rate_unit"] = ChargingRateUnitEnumType.watts.value - else: + resp: call_result.SetChargingProfile = await self.call(req) + if resp.status != ChargingProfileStatusEnumType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={ + "message": f"{str(resp.status)}: {str(resp.status_info)}" + }, + ) + return + + if limit_watts is not None: + if float(limit_watts) >= 22000: await self.clear_profile() return + period_limit = int(limit_watts) + unit_value = ChargingRateUnitEnumType.watts.value - schedule["charging_schedule_period"] = [period] - req = call.SetChargingProfile( - 0, - { - "id": 1, - "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, - "charging_profile_kind": ChargingProfileKindEnumType.relative.value, - "charging_schedule": [schedule], - }, + elif limit_amps is not None: + if float(limit_amps) >= 32: + await self.clear_profile() + return + period_limit = ( + int(limit_amps) if float(limit_amps).is_integer() else float(limit_amps) ) + unit_value = ChargingRateUnitEnumType.amps.value + else: + await self.clear_profile() + return + + schedule: dict = { + "id": 1, + "charging_rate_unit": unit_value, + "charging_schedule_period": [{"start_period": 0, "limit": period_limit}], + } + + charging_profile: dict = { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [schedule], + } + + req: call.SetChargingProfile = call.SetChargingProfile(0, charging_profile) resp: call_result.SetChargingProfile = await self.call(req) if resp.status != ChargingProfileStatusEnumType.accepted: raise HomeAssistantError( @@ -271,18 +329,34 @@ async def set_charge_rate( }, ) - async def set_availability(self, state: bool = True): + async def set_availability(self, state: bool = True, connector_id: int | None = 0): """Change availability.""" - req: call.ChangeAvailability = call.ChangeAvailability( + status = ( OperationalStatusEnumType.operative.value if state else OperationalStatusEnumType.inoperative.value ) - await self.call(req) + if not connector_id: + await self.call(call.ChangeAvailability(status)) + return + + evse_id = None + with contextlib.suppress(Exception): + evse_id, _ = self._global_to_pair(int(connector_id)) - async def start_transaction(self) -> bool: + if evse_id: + await self.call(call.ChangeAvailability(status, evse={"id": evse_id})) + else: + await self.call(call.ChangeAvailability(status)) + + async def start_transaction(self, connector_id: int = 1) -> bool: """Remote start a transaction.""" + evse_id = connector_id + if connector_id and connector_id > 0: + evse_id, _ = self._global_to_pair(connector_id) + req: call.RequestStartTransaction = call.RequestStartTransaction( + evse_id=evse_id, id_token={ "id_token": self._remote_id_tag, "type": IdTokenEnumType.central.value, @@ -294,7 +368,14 @@ async def start_transaction(self) -> bool: async def stop_transaction(self) -> bool: """Request remote stop of current transaction (default EVSE 1).""" - tx_id = self._metrics[(1, csess.transaction_id.value)].value or "" + await self._get_inventory() + tx_id = "" + total = self._total_connectors() or 1 + for g in range(1, total + 1): + val = self._metrics[(g, csess.transaction_id.value)].value + if val: + tx_id = val + break req: call.RequestStopTransaction = call.RequestStopTransaction( transaction_id=tx_id ) @@ -405,8 +486,8 @@ def on_heartbeat(self, **kwargs): return call_result.Heartbeat(current_time=datetime.now(tz=UTC).isoformat()) def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv16): - evse_status_str: str = evse_status_v16.value - self._metrics[(evse_id, cstat.status_connector.value)].value = evse_status_str + """Report EVSE-level status on the global connector.""" + self._metrics[(0, cstat.status_connector.value)].value = evse_status_v16.value self.hass.async_create_task(self.update(self.settings.cpid)) @on(Action.status_notification) @@ -423,6 +504,14 @@ def on_status_notification( evse: list[ConnectorStatusEnumType] = self._connector_status[evse_id - 1] evse[connector_id - 1] = ConnectorStatusEnumType(connector_status) + + global_idx = self._pair_to_global(evse_id, connector_id) + self._metrics[ + (global_idx, cstat.status_connector.value) + ].value = ConnectorStatusEnumType(connector_status).value + + self.hass.async_create_task(self.update(self.settings.cpid)) + evse_status: ConnectorStatusEnumType | None = None for status in evse: if status is None: @@ -446,12 +535,24 @@ def on_status_notification( return call_result.StatusNotification() @on(Action.firmware_status_notification) + def on_firmware_status_notification(self, **kwargs): + """Perform OCPP callback.""" + return call_result.FirmwareStatusNotification() + @on(Action.meter_values) + def on_meter_values(self, **kwargs): + """Perform OCPP callback.""" + return call_result.MeterValues() + @on(Action.log_status_notification) + def on_log_status_notification(self, **kwargs): + """Perform OCPP callback.""" + return call_result.LogStatusNotification() + @on(Action.notify_event) - def ack(self, **kwargs): + def on_notify_event(self, **kwargs): """Perform OCPP callback.""" - return call_result.StatusNotification() + return call_result.NotifyEvent() @on(Action.notify_report) def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): @@ -538,8 +639,13 @@ def on_authorize(self, id_token: dict, **kwargs): return call_result.Authorize(id_token_info={"status": status}) def _set_meter_values( - self, tx_event_type: str, meter_values: list[dict], evse_id: int + self, + tx_event_type: str, + meter_values: list[dict], + evse_id: int, + connector_id: int, ): + global_idx: int = self._pair_to_global(evse_id, connector_id) converted_values: list[list[MeasurandValue]] = [] for meter_value in meter_values: measurands: list[MeasurandValue] = [] @@ -563,7 +669,7 @@ def _set_meter_values( if (tx_event_type == TransactionEventEnumType.started.value) or ( (tx_event_type == TransactionEventEnumType.updated.value) - and (self._metrics[(evse_id, csess.meter_start)].value is None) + and (self._metrics[(global_idx, csess.meter_start)].value is None) ): energy_measurand = MeasurandEnumType.energy_active_import_register.value for meter_value in converted_values: @@ -571,10 +677,14 @@ def _set_meter_values( if measurand_item.measurand == energy_measurand: energy_value = cp.get_energy_kwh(measurand_item) energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None - self._metrics[(evse_id, csess.meter_start)].value = energy_value - self._metrics[(evse_id, csess.meter_start)].unit = energy_unit + self._metrics[ + (global_idx, csess.meter_start) + ].value = energy_value + self._metrics[ + (global_idx, csess.meter_start) + ].unit = energy_unit - self.process_measurands(converted_values, True, evse_id) + self.process_measurands(converted_values, True, global_idx) if tx_event_type == TransactionEventEnumType.ended.value: measurands_in_tx: set[str] = set() @@ -587,10 +697,10 @@ def _set_meter_values( for measurand in self._inventory.tx_updated_measurands: if ( (measurand not in measurands_in_tx) - and ((evse_id, measurand) in self._metrics) + and ((global_idx, measurand) in self._metrics) and not measurand.startswith("Energy") ): - self._metrics[(evse_id, measurand)].value = 0 + self._metrics[(global_idx, measurand)].value = 0 @on(Action.transaction_event) def on_transaction_event( @@ -604,9 +714,13 @@ def on_transaction_event( ): """Perform OCPP callback.""" evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 + evse_conn_id: int = ( + kwargs["evse"].get("connector_id", 1) if "evse" in kwargs else 1 + ) + global_idx: int = self._pair_to_global(evse_id, evse_conn_id) offline: bool = kwargs.get("offline", False) meter_values: list[dict] = kwargs.get("meter_value", []) - self._set_meter_values(event_type, meter_values, evse_id) + self._set_meter_values(event_type, meter_values, evse_id, evse_conn_id) t = datetime.fromisoformat(timestamp) if "charging_state" in transaction_info: @@ -630,24 +744,26 @@ def on_transaction_event( if id_token: response.id_token_info = {"status": AuthorizationStatusEnumType.accepted} id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] - self._metrics[(evse_id, cstat.id_tag.value)].value = id_tag_string + self._metrics[(global_idx, cstat.id_tag.value)].value = id_tag_string if event_type == TransactionEventEnumType.started.value: - self._tx_start_time[evse_id] = t + self._tx_start_time[global_idx] = t tx_id: str = transaction_info["transaction_id"] - self._metrics[(evse_id, csess.transaction_id.value)].value = tx_id - self._metrics[(evse_id, csess.session_time)].value = 0 - self._metrics[(evse_id, csess.session_time)].unit = UnitOfTime.MINUTES + self._metrics[(global_idx, csess.transaction_id.value)].value = tx_id + self._metrics[(global_idx, csess.session_time)].value = 0 + self._metrics[(global_idx, csess.session_time)].unit = UnitOfTime.MINUTES else: - if self._tx_start_time.get(evse_id): + if self._tx_start_time.get(global_idx): duration_minutes: int = ( - (t - self._tx_start_time[evse_id]).seconds + 59 + (t - self._tx_start_time[global_idx]).seconds + 59 ) // 60 - self._metrics[(evse_id, csess.session_time)].value = duration_minutes - self._metrics[(evse_id, csess.session_time)].unit = UnitOfTime.MINUTES + self._metrics[(global_idx, csess.session_time)].value = duration_minutes + self._metrics[ + (global_idx, csess.session_time) + ].unit = UnitOfTime.MINUTES if event_type == TransactionEventEnumType.ended.value: - self._metrics[(evse_id, csess.transaction_id.value)].value = "" - self._metrics[(evse_id, cstat.id_tag.value)].value = "" + self._metrics[(global_idx, csess.transaction_id.value)].value = "" + self._metrics[(global_idx, cstat.id_tag.value)].value = "" if not offline: self.hass.async_create_task(self.update(self.settings.cpid)) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 717c33f4..93feb496 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -116,6 +116,17 @@ def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: connector_id=conn_id, ) ) + + for metric in ["Energy.Active.Import.Register"]: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc(metric), + connector_id=None, + ) + ) else: for metric in CONNECTOR_ONLY: entities.append( diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index e4bab81f..a0cb64df 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -29,13 +29,14 @@ class OcppSwitchDescription(SwitchEntityDescription): on_action: str | None = None off_action: str | None = None metric_state: str | None = None - metric_condition: str | None = None + metric_condition: list[str] | None = None default_state: bool = False + per_connector: bool = False POWER_KILO_WATT = UnitOfPower.KILO_WATT -SWITCHES: Final = [ +SWITCHES: Final[list[OcppSwitchDescription]] = [ OcppSwitchDescription( key="charge_control", name="Charge Control", @@ -48,6 +49,7 @@ class OcppSwitchDescription(SwitchEntityDescription): ChargePointStatus.suspended_evse.value, ChargePointStatus.suspended_ev.value, ], + per_connector=True, ), OcppSwitchDescription( key="availability", @@ -55,9 +57,10 @@ class OcppSwitchDescription(SwitchEntityDescription): icon=ICON, on_action=HAChargerServices.service_availability.name, off_action=HAChargerServices.service_availability.name, - metric_state=HAChargerStatuses.status_connector.value, + metric_state=HAChargerStatuses.status.value, # charger-level status metric_condition=[ChargePointStatus.available.value], default_state=True, + per_connector=False, ), ] @@ -65,23 +68,25 @@ class OcppSwitchDescription(SwitchEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the switch platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] + entities: list[ChargePointSwitch] = [] for charger in entry.data[CONF_CPIDS]: - cp_id_settings = list(charger.values())[0] - cpid = cp_id_settings[CONF_CPID] - num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + cp_settings = list(charger.values())[0] + cpid = cp_settings[CONF_CPID] + num_connectors = int(cp_settings.get(CONF_NUM_CONNECTORS, 1) or 1) - for ent in SWITCHES: - if ent.metric_state and num_connectors > 1: + for desc in SWITCHES: + if desc.per_connector: for conn_id in range(1, num_connectors + 1): entities.append( ChargePointSwitch( - central_system, cpid, ent, connector_id=conn_id + central_system, cpid, desc, connector_id=conn_id ) ) else: - entities.append(ChargePointSwitch(central_system, cpid, ent)) + entities.append( + ChargePointSwitch(central_system, cpid, desc, connector_id=None) + ) async_add_devices(entities, False) @@ -105,9 +110,10 @@ def __init__( self.entity_description = description self.connector_id = connector_id self._state = self.entity_description.default_state - parts = [SWITCH_DOMAIN, DOMAIN, cpid, description.key] + parts = [SWITCH_DOMAIN, DOMAIN, cpid] if self.connector_id: - parts.insert(3, f"conn{self.connector_id}") + parts.append(f"conn{self.connector_id}") + parts.append(description.key) self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name if self.connector_id: @@ -122,10 +128,12 @@ def __init__( name=cpid, ) + self._attr_icon = ICON + @property def available(self) -> bool: """Return if switch is available.""" - return self.central_system.get_available(self.cpid, self.connector_id) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid, self.connector_id) @property def is_on(self) -> bool: @@ -135,16 +143,17 @@ def is_on(self) -> bool: resp = self.central_system.get_metric( self.cpid, self.entity_description.metric_state, self.connector_id ) - if resp in self.entity_description.metric_condition: - self._state = True - else: - self._state = False - return self._state # type: ignore [no-any-return] + if self.entity_description.metric_condition is not None: + self._state = resp in self.entity_description.metric_condition + return self._state # type: ignore[no-any-return] async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._state = await self.central_system.set_charger_state( - self.cpid, self.entity_description.on_action + self.cpid, + self.entity_description.on_action, + True, + connector_id=self.connector_id, ) async def async_turn_off(self, **kwargs: Any) -> None: @@ -154,10 +163,15 @@ async def async_turn_off(self, **kwargs: Any) -> None: resp = True elif self.entity_description.off_action == self.entity_description.on_action: resp = await self.central_system.set_charger_state( - self.cpid, self.entity_description.off_action, False + self.cpid, + self.entity_description.off_action, + False, + connector_id=self.connector_id, ) else: resp = await self.central_system.set_charger_state( - self.cpid, self.entity_description.off_action + self.cpid, + self.entity_description.off_action, + connector_id=self.connector_id, ) self._state = not resp diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py index 3882636d..42123644 100644 --- a/tests/charge_point_test.py +++ b/tests/charge_point_test.py @@ -28,10 +28,11 @@ async def set_switch(hass: HomeAssistant, cpid: str, key: str, on: bool): """Toggle a switch.""" + prefix = "_" if key == "availability" else "_connector_1_" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON if on else SERVICE_TURN_OFF, - service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cpid}_{key}"}, + service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cpid}{prefix}{key}"}, blocking=True, ) @@ -43,7 +44,7 @@ async def set_number(hass: HomeAssistant, cpid: str, key: str, value: int): "set_value", service_data={"value": str(value)}, blocking=True, - target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_{key}"}, + target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_connector_1_{key}"}, ) diff --git a/tests/test_charge_point_v201_multi.py b/tests/test_charge_point_v201_multi.py new file mode 100644 index 00000000..589e1d5a --- /dev/null +++ b/tests/test_charge_point_v201_multi.py @@ -0,0 +1,370 @@ +"""Implement a test by a simulating an OCPP 2.0.1 chargepoint.""" + +import asyncio +from datetime import datetime, UTC + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ocpp.const import CONF_CPIDS, CONF_CPID +from custom_components.ocpp.const import ( + DOMAIN as OCPP_DOMAIN, +) +from custom_components.ocpp.enums import ( + HAChargerServices as csvcs, +) +import ocpp +from ocpp.routing import on +import ocpp.exceptions +from ocpp.v201 import ChargePoint as cpclass, call, call_result +from ocpp.v201.enums import ( + Action, + BootReasonEnumType, + ChangeAvailabilityStatusEnumType, + ChargingStateEnumType, + ConnectorStatusEnumType, + DataEnumType, + GenericDeviceModelStatusEnumType, + MutabilityEnumType, + OperationalStatusEnumType, + RegistrationStatusEnumType, + ReportBaseEnumType, + SetVariableStatusEnumType, + TransactionEventEnumType, + TriggerMessageStatusEnumType, + TriggerReasonEnumType, + UpdateFirmwareStatusEnumType, +) +from ocpp.v201.datatypes import ( + ComponentType, + EVSEType, + VariableType, + VariableAttributeType, + VariableCharacteristicsType, + ReportDataType, + SetVariableResultType, +) + +from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_CP_APPEND + +from .charge_point_test import ( + create_configuration, + run_charge_point_test, + remove_configuration, + wait_ready, +) + + +class MultiConnectorChargePoint(cpclass): + """Minimal OCPP 2.0.1 client som rapporterar 2 EVSE (1: två connectors, 2: en).""" + + def __init__(self, cp_id, ws): + """Initialize.""" + super().__init__(cp_id, ws) + self.inventory_done = asyncio.Event() + self.last_start_evse_id = None + + @on(Action.get_base_report) + async def on_get_base_report(self, request_id: int, report_base: str, **kwargs): + """Get base report.""" + assert report_base in (ReportBaseEnumType.full_inventory, "FullInventory") + asyncio.create_task(self._send_full_inventory(request_id)) # noqa: RUF006 + return call_result.GetBaseReport( + GenericDeviceModelStatusEnumType.accepted.value + ) + + @on(Action.trigger_message) + async def on_trigger_message(self, requested_message: str, **kwargs): + """Handle trigger message.""" + self.last_trigger = (requested_message, kwargs.get("evse")) + return call_result.TriggerMessage(TriggerMessageStatusEnumType.accepted.value) + + @on(Action.update_firmware) + async def on_update_firmware(self, request_id: int, firmware: dict, **kwargs): + """Handle update firmware.""" + return call_result.UpdateFirmware(UpdateFirmwareStatusEnumType.rejected.value) + + @on(Action.set_variables) + async def on_set_variables(self, set_variable_data: list[dict], **kwargs): + """Handle SetVariables.""" + results: list[SetVariableResultType] = [] + for item in set_variable_data: + comp = item.get("component", {}) + var = item.get("variable", {}) + results.append( + SetVariableResultType( + SetVariableStatusEnumType.accepted, + ComponentType( + comp.get("name"), + instance=comp.get("instance"), + evse=comp.get("evse"), + ), + VariableType( + var.get("name"), + instance=var.get("instance"), + ), + ) + ) + return call_result.SetVariables(results) + + async def _send_full_inventory(self, request_id: int): + ts = datetime.now(UTC).isoformat() + report_data = [ + # EVSE 1 + ReportDataType( + ComponentType("EVSE", evse=EVSEType(1)), + VariableType("Status"), + [VariableAttributeType(value="OK")], + ), + # EVSE 2 + ReportDataType( + ComponentType("EVSE", evse=EVSEType(2)), + VariableType("Status"), + [VariableAttributeType(value="OK")], + ), + # Connector(1,1) + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=1)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # Connector(1,2) + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=2)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # Connector(2,1) + ReportDataType( + ComponentType("Connector", evse=EVSEType(2, connector_id=1)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # SmartChargingCtrlr.Available + ReportDataType( + ComponentType("SmartChargingCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # SampledDataCtrlr.TxUpdatedMeasurands + ReportDataType( + ComponentType("SampledDataCtrlr"), + VariableType("TxUpdatedMeasurands"), + [VariableAttributeType(value="", persistent=True)], + VariableCharacteristicsType( + DataEnumType.member_list, + False, + values_list="Energy.Active.Import.Register,Current.Import,Voltage", + ), + ), + ] + + req = call.NotifyReport( + request_id=request_id, + generated_at=ts, + seq_no=0, + report_data=report_data, + tbc=False, + ) + await self.call(req) + self.inventory_done.set() + + @on(Action.change_availability) + async def on_change_availability(self, operational_status: str, **kwargs): + """Handle change availability.""" + self.operative = operational_status == OperationalStatusEnumType.operative.value + return call_result.ChangeAvailability( + ChangeAvailabilityStatusEnumType.accepted.value + ) + + @on(Action.request_start_transaction) + async def on_request_start_transaction( + self, evse_id: int, id_token: dict, **kwargs + ): + """Handle request for start transaction.""" + self.last_start_evse_id = evse_id + return call_result.RequestStartTransaction(status="Accepted") + + @on(Action.request_stop_transaction) + async def on_request_stop_transaction(self, transaction_id: str, **kwargs): + """Handle request for stop transaction.""" + return call_result.RequestStopTransaction(status="Accepted") + + async def send_status( + self, evse_id: int, connector_id: int, status: ConnectorStatusEnumType + ): + """Send status.""" + await self.call( + call.StatusNotification( + timestamp=datetime.now(UTC).isoformat(), + connector_status=status.value, + evse_id=evse_id, + connector_id=connector_id, + ) + ) + + async def send_tx_started_eair_wh( + self, evse_id: int, connector_id: int, tx_id: str, eair_wh: int + ): + """Send EAIR on transaction started.""" + await self.call( + call.TransactionEvent( + event_type=TransactionEventEnumType.started, + timestamp=datetime.now(UTC).isoformat(), + trigger_reason=TriggerReasonEnumType.authorized, + seq_no=0, + transaction_info={ + "transaction_id": tx_id, + "charging_state": ChargingStateEnumType.charging, + }, + evse={"id": evse_id, "connector_id": connector_id}, + meter_value=[ + { + "timestamp": datetime.now(UTC).isoformat(), + "sampled_value": [ + { + "measurand": "Energy.Active.Import.Register", + "value": eair_wh, + "unit_of_measure": {"unit": "Wh"}, + } + ], + } + ], + ) + ) + + async def send_tx_updated_eair_wh( + self, evse_id: int, connector_id: int, tx_id: str, eair_wh: int + ): + """Send EAIR on transaction updated.""" + await self.call( + call.TransactionEvent( + event_type=TransactionEventEnumType.updated, + timestamp=datetime.now(UTC).isoformat(), + trigger_reason=TriggerReasonEnumType.meter_value_periodic, + seq_no=1, + transaction_info={ + "transaction_id": tx_id, + "charging_state": ChargingStateEnumType.charging, + }, + evse={"id": evse_id, "connector_id": connector_id}, + meter_value=[ + { + "timestamp": datetime.now(UTC).isoformat(), + "sampled_value": [ + { + "measurand": "Energy.Active.Import.Register", + "value": eair_wh, + "unit_of_measure": {"unit": "Wh"}, + } + ], + } + ], + ) + ) + + +@pytest.mark.timeout(150) +async def test_v201_multi_connectors_per_evse(hass, socket_enabled): + """Test multi connector per EVSE functionality.""" + cp_id = "CP_v201_multi" + + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_multi", + title="test_v201_multi", + version=2, + minor_version=0, + ) + + cs = await create_configuration(hass, config_entry) + ocpp.messages.ASYNC_VALIDATION = False + + async def _scenario(hass, cs, cp: MultiConnectorChargePoint): + boot = await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonEnumType.power_up.value, + ) + ) + assert boot.status == RegistrationStatusEnumType.accepted.value + + await wait_ready(cs.charge_points["CP_v201_multi"]) + + await asyncio.wait_for(cp.inventory_done.wait(), timeout=5) + + cp_srv = cs.charge_points["CP_v201_multi"] + for _ in range(50): + if getattr(cp_srv, "num_connectors", 0) == 3: + break + await asyncio.sleep(0.1) + assert cp_srv.num_connectors == 3 + + cpid = cp_srv.settings.cpid + + await cp.send_status(1, 1, ConnectorStatusEnumType.available) + await cp.send_status(1, 2, ConnectorStatusEnumType.occupied) + await cp.send_status(2, 1, ConnectorStatusEnumType.unavailable) + await asyncio.sleep(0.05) + + assert cs.get_metric(cpid, "Status.Connector", connector_id=1) == "Available" + assert cs.get_metric(cpid, "Status.Connector", connector_id=2) == "Occupied" + assert cs.get_metric(cpid, "Status.Connector", connector_id=3) == "Unavailable" + + await cp.send_tx_started_eair_wh(1, 2, "TX-1", 10_000) + await cp.send_tx_updated_eair_wh(1, 2, "TX-1", 10_500) + await asyncio.sleep(0.05) + + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=2 + ) == pytest.approx(10.5) + assert cs.get_metric(cpid, "Energy.Session", connector_id=2) == pytest.approx( + 0.5 + ) + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=2) == "kWh" + ) + assert cs.get_unit(cpid, "Energy.Session", connector_id=2) == "kWh" + + ok = await cs.set_charger_state( + cpid, csvcs.service_charge_start.name, True, connector_id=3 + ) + assert ok is True + await asyncio.sleep(0.05) + assert cp.last_start_evse_id == 2 + + await run_charge_point_test( + config_entry, + "CP_v201_multi", + ["ocpp2.0.1"], + lambda ws: MultiConnectorChargePoint("CP_v201_multi_client", ws), + [lambda cp: _scenario(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) From 853f80ac370dc050e8f7e38edb4472bf0f38d952 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Tue, 19 Aug 2025 18:56:07 +0000 Subject: [PATCH 05/15] Fix per connector set charge rate. Clean up swenglish comments. --- custom_components/ocpp/chargepoint.py | 7 -- custom_components/ocpp/ocppv16.py | 132 +++++++++++++++++--------- 2 files changed, 85 insertions(+), 54 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 3f79d64b..1277a82d 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -128,15 +128,12 @@ class _ConnectorAwareMetrics(MutableMapping): def __init__(self): self._by_conn = defaultdict(lambda: defaultdict(lambda: Metric(None, None))) - # --- flat (default connector 0) & connector-indexerad access --- def __getitem__(self, key): if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): conn, meas = key return self._by_conn[conn][meas] if isinstance(key, int): - # Returnerar dict[str -> Metric] för connectorn return self._by_conn[key] - # Platt: returnera Metric i connector 0 return self._by_conn[0][key] def __setitem__(self, key, value): @@ -151,7 +148,6 @@ def __setitem__(self, key, value): raise TypeError("Connector mapping must be dict[str, Metric].") self._by_conn[key] = value return - # Platt if not isinstance(value, Metric): raise TypeError("Metric assignment must be a Metric instance.") self._by_conn[0][key] = value @@ -167,14 +163,11 @@ def __delitem__(self, key): del self._by_conn[0][key] def __iter__(self): - # Iterera som ett platt dict (connector 0) return iter(self._by_conn[0]) def __len__(self): - # Storlek som platt dict (connector 0) return len(self._by_conn[0]) - # Hjälpmetoder i dict-stil def get(self, key, default=None): try: return self[key] diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index 7da52fb6..b3a3da84 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -105,6 +105,10 @@ def __init__( ) self._active_tx: dict[int, int] = {} # connector_id -> transaction_id + def _profile_ids_for_connector(self, conn_id: int) -> tuple[int, int]: + """Return (profile_id, stack_level) that is stable and unique per connector.""" + return 1000 + max(1, int(conn_id)), 1 + async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" val = await self.get_configuration(ckey.number_of_connectors.value) @@ -273,18 +277,35 @@ async def trigger_custom_message( return False return True - async def clear_profile(self): - """Clear all charging profiles.""" - req = call.ClearChargingProfile() + async def clear_profile( + self, + conn_id: int | None = None, + purpose: ChargingProfilePurposeType | None = None, + ) -> bool: + """Clear charging profiles (per connector and/or purpose).""" + criteria = {} + if purpose is not None: + criteria["charging_profile_purpose"] = purpose.value + + target_connector = None + if conn_id is not None: + target_connector = int(conn_id) + + req = call.ClearChargingProfile( + connector_id=target_connector, + charging_profile_purpose=criteria if criteria else None, + ) resp = await self.call(req) - if resp.status == ClearChargingProfileStatus.accepted: + if resp.status in ( + ClearChargingProfileStatus.accepted, + ClearChargingProfileStatus.unknown, + ): return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Clear profile failed with response {resp.status}" - ) - return False + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Clear profile failed with response {resp.status}" + ) + return False async def set_charge_rate( self, @@ -292,15 +313,11 @@ async def set_charge_rate( limit_watts: int = 22000, conn_id: int = 0, profile: dict | None = None, - ): - """Set charging profile with defined limit. - - - conn_id == 0 -> ChargePointMaxProfile (connector_id=0) - - conn_id > 0 -> TxDefaultProfile (connector_id=conn_id) - """ + ) -> bool: + """Set charge rate.""" if profile is not None: req = call.SetChargingProfile( - connector_id=(0 if conn_id == 0 else conn_id), + connector_id=int(conn_id), cs_charging_profiles=profile, ) resp = await self.call(req) @@ -312,54 +329,75 @@ async def set_charge_rate( ) return False - if prof.SMART not in self._attr_supported_features: - _LOGGER.info("Smart charging is not supported by this charger") - return False - resp_units = await self.get_configuration( ckey.charging_schedule_allowed_charging_rate_unit.value ) if resp_units is None: _LOGGER.warning("Failed to query charging rate unit, assuming Amps") resp_units = om.current.value - if om.current.value in resp_units: - lim = max(0, float(limit_amps)) - units = ChargingRateUnitType.amps.value - else: - lim = max(0, float(limit_watts)) - units = ChargingRateUnitType.watts.value - resp = await self.get_configuration(ckey.charge_profile_max_stack_level.value) - stack_level = int(resp or 0) - if conn_id == 0: - purpose = ChargingProfilePurposeType.charge_point_max_profile.value - target_connector = 0 + + use_amps = om.current.value in resp_units + limit_val = float(limit_amps if use_amps else limit_watts) + unit_val = ( + ChargingRateUnitType.amps.value + if use_amps + else ChargingRateUnitType.watts.value + ) + + conn_id = int(conn_id or 0) + is_station_level = conn_id == 0 + + if is_station_level: + purpose = ChargingProfilePurposeType.charge_point_max_profile + resp_stack = await self.get_configuration( + ckey.charge_profile_max_stack_level.value + ) + try: + stack_level = int(resp_stack) + except Exception: + stack_level = 1 + profile_id = 8 else: - purpose = ChargingProfilePurposeType.tx_default_profile.value - target_connector = conn_id - profile_dict = { - om.charging_profile_id.value: 8, - om.stack_level.value: max(0, stack_level), + purpose = ChargingProfilePurposeType.tx_default_profile + profile_id, stack_level = self._profile_ids_for_connector(conn_id) + + is_default = (limit_amps >= 32) and (limit_watts >= 22000) + if is_default: + return await self.clear_profile( + conn_id=None if is_station_level else conn_id, + purpose=purpose, + ) + + cs_profile = { + om.charging_profile_id.value: profile_id, + om.stack_level.value: stack_level, om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: purpose, + om.charging_profile_purpose.value: purpose.value, om.charging_schedule.value: { - om.charging_rate_unit.value: units, + om.charging_rate_unit.value: unit_val, om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} + {om.start_period.value: 0, om.limit.value: limit_val} ], }, } + req = call.SetChargingProfile( - connector_id=target_connector, cs_charging_profiles=profile_dict + connector_id=conn_id, + cs_charging_profiles=cs_profile, ) resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True - if target_connector != 0: - profile_dict[om.stack_level.value] = max(0, stack_level - 1) - req = call.SetChargingProfile( - connector_id=target_connector, cs_charging_profiles=profile_dict + + if is_station_level and resp.status != ChargingProfileStatus.accepted: + _LOGGER.debug("Station profile rejected, trying lower stack level …") + cs_profile[om.stack_level.value] = max(1, stack_level - 1) + resp = await self.call( + call.SetChargingProfile( + connector_id=0, + cs_charging_profiles=cs_profile, + ) ) - resp = await self.call(req) if resp.status == ChargingProfileStatus.accepted: return True @@ -665,7 +703,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): ) meter_values.append(measurands) - # Write main meter value (EAIR) to connector 0 om this message is missing transactionId + # Write main meter value (EAIR) to connector 0 if this message is missing transactionId if not tx_has_id: for bucket in meter_values: for item in bucket: From c6d9f825e59d29f882440a94901892d983c2cb75 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Thu, 21 Aug 2025 13:15:05 +0000 Subject: [PATCH 06/15] Remove separate connector device breaking change. Fix test race condition. CodeRabbit fixes. --- .gitignore | 4 +- custom_components/ocpp/api.py | 78 +++-- custom_components/ocpp/button.py | 25 +- custom_components/ocpp/chargepoint.py | 10 +- custom_components/ocpp/config_flow.py | 4 +- custom_components/ocpp/const.py | 2 +- custom_components/ocpp/enums.py | 4 +- custom_components/ocpp/number.py | 107 ++++--- custom_components/ocpp/ocppv201.py | 297 +++++++++++++------- custom_components/ocpp/sensor.py | 34 +-- custom_components/ocpp/switch.py | 71 +++-- custom_components/ocpp/translations/nl.json | 2 +- scripts/develop | 22 +- tests/charge_point_test.py | 4 +- tests/test_charge_point_v16.py | 25 +- tests/test_charge_point_v201_multi.py | 2 +- tests/test_config_flow.py | 6 +- 17 files changed, 432 insertions(+), 265 deletions(-) diff --git a/.gitignore b/.gitignore index 734c2a98..d70fba6b 100644 --- a/.gitignore +++ b/.gitignore @@ -129,5 +129,5 @@ dmypy.json .pyre/ # HA Development -config/ -**/.DS_Store +/config/ +.DS_Store diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index f5bbcf67..e4df8061 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -293,19 +293,19 @@ def _get_metrics(self, id: str): """Return metrics.""" cp_id = self.cpids.get(id, id) cp = self.charge_points.get(cp_id) - return (cp_id, cp._metrics) if cp is not None else (None, None) + n_connectors = getattr(cp, "num_connectors", 1) or 1 + return ( + (cp_id, cp._metrics, cp, n_connectors) + if cp is not None + else (None, None, None, None) + ) def get_metric(self, id: str, measurand: str, connector_id: int | None = None): """Return last known value for given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: + cp_id, m, cp, n_connectors = self._get_metrics(id) + if cp is None: return None - cp = self.charge_points[cp_id] - m = cp._metrics - n_connectors = getattr(cp, "num_connectors", 1) or 1 - def _try_val(key): with contextlib.suppress(Exception): val = m[key].value @@ -314,7 +314,7 @@ def _try_val(key): # 1) Explicit connector_id (including 0): just get it if connector_id is not None: - conn = 0 if connector_id == 0 else connector_id + conn = self._norm_conn(connector_id) return _try_val((conn, measurand)) # 2) No connector_id: try CHARGER level (conn=0) @@ -344,8 +344,8 @@ def _try_val(key): def del_metric(self, id: str, measurand: str, connector_id: int | None = None): """Set given measurand to None.""" - # allow id to be either cpid or cp_id - cp_id, m = self._get_metrics(id) + cp_id, m, cp, n_connectors = self._get_metrics(id) + if m is None: return None @@ -360,14 +360,10 @@ def del_metric(self, id: str, measurand: str, connector_id: int | None = None): def get_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return unit of given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: - return None + cp_id, m, cp, n_connectors = self._get_metrics(id) - cp = self.charge_points[cp_id] - m = cp._metrics - n_connectors = getattr(cp, "num_connectors", 1) or 1 + if cp is None: + return None def _try_unit(key): with contextlib.suppress(Exception): @@ -375,7 +371,7 @@ def _try_unit(key): return None if connector_id is not None: - conn = 0 if connector_id == 0 else connector_id + conn = self._norm_conn(connector_id) return _try_unit((conn, measurand)) val = _try_unit((0, measurand)) @@ -401,13 +397,10 @@ def _try_unit(key): def get_ha_unit(self, id: str, measurand: str, connector_id: int | None = None): """Return home assistant unit of given measurand.""" - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: - return None + cp_id, m, cp, n_connectors = self._get_metrics(id) - cp = self.charge_points[cp_id] - m = cp._metrics - n_connectors = getattr(cp, "num_connectors", 1) or 1 + if cp is None: + return None def _try_ha_unit(key): with contextlib.suppress(Exception): @@ -415,7 +408,7 @@ def _try_ha_unit(key): return None if connector_id is not None: - conn = 0 if connector_id == 0 else connector_id + conn = self._norm_conn(connector_id) return _try_ha_unit((conn, measurand)) val = _try_ha_unit((0, measurand)) @@ -441,14 +434,10 @@ def _try_ha_unit(key): def get_extra_attr(self, id: str, measurand: str, connector_id: int | None = None): """Return extra attributes for given measurand.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: - return None + cp_id, m, cp, n_connectors = self._get_metrics(id) - cp = self.charge_points[cp_id] - m = cp._metrics - n_connectors = getattr(cp, "num_connectors", 1) or 1 + if cp is None: + return None def _try_extra(key): with contextlib.suppress(Exception): @@ -456,7 +445,7 @@ def _try_extra(key): return None if connector_id is not None: - conn = 0 if connector_id == 0 else connector_id + conn = self._norm_conn(connector_id) return _try_extra((conn, measurand)) val = _try_extra((0, measurand)) @@ -482,28 +471,27 @@ def _try_extra(key): def get_available(self, id: str, connector_id: int | None = None): """Return whether the charger (or a specific connector) is available.""" - # allow id to be either cpid or cp_id - cp_id = self.cpids.get(id, id) - if cp_id not in self.charge_points: - return False + cp_id, m, cp, n_connectors = self._get_metrics(id) - cp = self.charge_points[cp_id] + if cp is None: + return None - if connector_id is None or connector_id == 0: + if self._norm_conn(connector_id) == 0: return cp.status == STATE_OK - m = cp._metrics status_val = None with contextlib.suppress(Exception): - status_val = m[(connector_id, cstat.status_connector.value)].value + status_val = m[ + (self._norm_conn(connector_id), cstat.status_connector.value) + ].value if not status_val: try: flat = m[cstat.status_connector.value] if hasattr(flat, "extra_attr"): - status_val = flat.extra_attr.get(connector_id) or getattr( - flat, "value", None - ) + status_val = flat.extra_attr.get( + self._norm_conn(connector_id) + ) or getattr(flat, "value", None) except Exception: pass diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index d547ad16..817e869f 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -55,17 +55,29 @@ async def async_setup_entry(hass, entry, async_add_devices): cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1)) + num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) for desc in BUTTONS: if desc.per_connector: - for connector_id in range(1, num_connectors + 1): + if num_connectors > 1: + for connector_id in range(1, num_connectors + 1): + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=connector_id, + op_connector_id=connector_id, + ) + ) + else: entities.append( ChargePointButton( central_system=central_system, cpid=cpid, description=desc, - connector_id=connector_id, + connector_id=None, + op_connector_id=1, ) ) else: @@ -75,6 +87,7 @@ async def async_setup_entry(hass, entry, async_add_devices): cpid=cpid, description=desc, connector_id=None, + op_connector_id=None, ) ) @@ -93,12 +106,14 @@ def __init__( cpid: str, description: OcppButtonDescription, connector_id: int | None = None, + op_connector_id: int | None = None, ): """Instantiate instance of a ChargePointButton.""" self.cpid = cpid self.central_system = central_system self.entity_description = description self.connector_id = connector_id + self._op_connector_id = op_connector_id parts = [BUTTON_DOMAIN, DOMAIN, cpid, description.key] if self.connector_id: parts.insert(3, f"conn{self.connector_id}") @@ -119,12 +134,12 @@ def __init__( @property def available(self) -> bool: """Return charger availability.""" - return self.central_system.get_available(self.cpid, self.connector_id) + return self.central_system.get_available(self.cpid, self._op_connector_id) async def async_press(self) -> None: """Triggers the charger press action service.""" await self.central_system.set_charger_state( self.cpid, self.entity_description.press_action, - connector_id=self.connector_id, + connector_id=self._op_connector_id, ) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 1277a82d..6b4acd76 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -118,11 +118,11 @@ def extra_attr(self, extra_attr: dict): class _ConnectorAwareMetrics(MutableMapping): """Backwards compatible mapping for metrics. - - m["Power.Active.Import"] -> Metric for connector 0 (flat access) - - m[(2, "Power.Active.Import")] -> Metric for connector 2 (per connector) - - m[2] -> dict[str -> Metric] for connector 2 + - m["Power.Active.Import"] -> Metric for connector 0 (flat access) + - m[(2, "Power.Active.Import")] -> Metric for connector 2 (per connector) + - m[2] -> dict[str -> Metric] for connector 2 - Iteration, len, keys(), items() etc act like flat dict (connector 0). + Iteration, len, keys(), values(), items() operate on connector 0 (flat view). """ def __init__(self): @@ -798,7 +798,7 @@ def process_measurands( else: # Derive: EAIR_kWh - meter_start_kWh ms_val = self._metrics[ - (connector_id, csess.meter_start) + (connector_id, csess.meter_start.value) ].value if ms_val is not None: self._metrics[ diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index d7888383..255fb4b6 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -93,7 +93,9 @@ vol.Required( CONF_FORCE_SMART_CHARGING, default=DEFAULT_FORCE_SMART_CHARGING ): bool, - vol.Required(CONF_NUM_CONNECTORS, default=DEFAULT_NUM_CONNECTORS): int, + vol.Required(CONF_NUM_CONNECTORS, default=DEFAULT_NUM_CONNECTORS): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), } ) diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 2e61430c..fbb5b2ad 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -153,7 +153,7 @@ class ChargerSystemSettings: skip_schema_validation: bool force_smart_charging: bool connection: int | None = None # number of this connection in central server - num_connectors: int = 1 + num_connectors: int = DEFAULT_NUM_CONNECTORS @dataclass diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index 241ed524..2e2aa405 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -1,6 +1,6 @@ """Additional enumerated values to use in home assistant.""" -from enum import Enum, Flag, auto +from enum import Enum, IntFlag, auto class HAChargerServices(str, Enum): @@ -62,7 +62,7 @@ class HAChargerSession(str, Enum): meter_start = "Energy.Meter.Start" # in kWh -class Profiles(Flag): +class Profiles(IntFlag): """Flags to indicate supported feature profiles.""" NONE = 0 diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 3d742e59..e64c73aa 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -35,7 +35,6 @@ class OcppNumberDescription(NumberEntityDescription): """Class to describe a Number entity.""" initial_value: float | None = None - connector_id: int | None = None ELECTRIC_CURRENT_AMPERE = UnitOfElectricCurrent.AMPERE @@ -57,38 +56,61 @@ class OcppNumberDescription(NumberEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the number platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - entities = [] + entities: list[ChargePointNumber] = [] for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) - for connector_id in range(1, num_connectors + 1): - for ent in NUMBERS: - if ent.key == "maximum_current": - ent_initial = cp_id_settings[CONF_MAX_CURRENT] - ent_max = cp_id_settings[CONF_MAX_CURRENT] - else: - ent_initial = ent.initial_value - ent_max = ent.native_max_value + for desc in NUMBERS: + if desc.key == "maximum_current": + ent_initial = cp_id_settings[CONF_MAX_CURRENT] + ent_max = cp_id_settings[CONF_MAX_CURRENT] + else: + ent_initial = desc.initial_value + ent_max = desc.native_max_value + + if num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + entities.append( + ChargePointNumber( + hass=hass, + central_system=central_system, + cpid=cpid, + description=OcppNumberDescription( + key=desc.key, + name=desc.name, + icon=desc.icon, + initial_value=ent_initial, + native_min_value=desc.native_min_value, + native_max_value=ent_max, + native_step=desc.native_step, + native_unit_of_measurement=desc.native_unit_of_measurement, + ), + connector_id=conn_id, + op_connector_id=conn_id, + ) + ) + else: entities.append( ChargePointNumber( - hass, - central_system, - cpid, - OcppNumberDescription( - key=ent.key, - name=ent.name, - icon=ent.icon, + hass=hass, + central_system=central_system, + cpid=cpid, + description=OcppNumberDescription( + key=desc.key, + name=desc.name, + icon=desc.icon, initial_value=ent_initial, - native_min_value=ent.native_min_value, + native_min_value=desc.native_min_value, native_max_value=ent_max, - native_step=ent.native_step, - native_unit_of_measurement=ent.native_unit_of_measurement, - connector_id=connector_id, + native_step=desc.native_step, + native_unit_of_measurement=desc.native_unit_of_measurement, ), - connector_id=connector_id, + connector_id=None, + op_connector_id=0, ) ) + async_add_devices(entities, False) @@ -105,6 +127,7 @@ def __init__( cpid: str, description: OcppNumberDescription, connector_id: int | None = None, + op_connector_id: int | None = None, ): """Initialize a Number instance.""" self.cpid = cpid @@ -112,6 +135,10 @@ def __init__( self.central_system = central_system self.entity_description = description self.connector_id = connector_id + self._op_connector_id = ( + op_connector_id if op_connector_id is not None else (connector_id or 1) + ) + parts = [NUMBER_DOMAIN, DOMAIN, cpid, description.key] if self.connector_id: parts.insert(3, f"conn{self.connector_id}") @@ -145,24 +172,24 @@ async def async_added_to_hass(self) -> None: def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) - # @property - # def available(self) -> bool: - # """Return if entity is available.""" - # if not ( - # Profiles.SMART & self.central_system.get_supported_features(self.cpid) - # ): - # return False - # return self.central_system.get_available(self.cpid) # type: ignore [no-any-return] + @property + def available(self) -> bool: + """Return if entity is available.""" + features = self.central_system.get_supported_features(self.cpid) + has_smart = bool(features & Profiles.SMART) + return bool( + self.central_system.get_available(self.cpid, self._op_connector_id) + and has_smart + ) async def async_set_native_value(self, value): - """Set new value.""" + """Set new value for station-wide max current (EVSE 0).""" num_value = float(value) - if self.central_system.get_available( - self.cpid - ) and Profiles.SMART & self.central_system.get_supported_features(self.cpid): - resp = await self.central_system.set_max_charge_rate_amps( - self.cpid, num_value, self.connector_id - ) - if resp is True: - self._attr_native_value = num_value - self.async_write_ha_state() + resp = await self.central_system.set_max_charge_rate_amps( + self.cpid, + num_value, + connector_id=0, + ) + if resp is True: + self._attr_native_value = num_value + self.async_write_ha_state() diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 301fc3e8..518f4bd4 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -82,6 +82,9 @@ class ChargePoint(cp): _tx_start_time: dict[int, datetime] _global_to_evse: dict[int, tuple[int, int]] # global_idx -> (evse_id, connector_id) _evse_to_global: dict[tuple[int, int], int] # (evse_id, connector_id) -> global_idx + _pending_status_notifications: list[ + tuple[str, str, int, int] + ] # (timestamp, connector_status, evse_id, connector_id) def __init__( self, @@ -104,34 +107,107 @@ def __init__( charger, ) self._tx_start_time = {} - self._global_to_evse = {} - self._evse_to_global = {} + self._global_to_evse: dict[int, tuple[int, int]] = {} + self._evse_to_global: dict[tuple[int, int], int] = {} + self._pending_status_notifications: list[tuple[str, str, int, int]] = [] # --- Connector mapping helpers (EVSE <-> global index) --- - def _build_connector_map(self): - """Build maps between global connector index and (evse_id, connector_id).""" - self._global_to_evse.clear() + def _build_connector_map(self) -> bool: + if not self._inventory or self._inventory.evse_count == 0: + return False + if self._evse_to_global and self._global_to_evse: + return True + + g = 1 self._evse_to_global.clear() - if not self._inventory: - return - idx = 0 + self._global_to_evse.clear() for evse_id in range(1, self._inventory.evse_count + 1): - cnt = self._inventory.connector_count[evse_id - 1] - for conn_id in range(1, cnt + 1): - idx += 1 - self._global_to_evse[idx] = (evse_id, conn_id) - self._evse_to_global[(evse_id, conn_id)] = idx + count = 0 + if len(self._inventory.connector_count) >= evse_id: + count = int(self._inventory.connector_count[evse_id - 1] or 0) + for conn_id in range(1, count + 1): + self._evse_to_global[(evse_id, conn_id)] = g + self._global_to_evse[g] = (evse_id, conn_id) + g += 1 + return bool(self._evse_to_global) + + def _ensure_connector_map(self) -> bool: + if self._evse_to_global and self._global_to_evse: + return True + return self._build_connector_map() def _pair_to_global(self, evse_id: int, conn_id: int) -> int: - """Return global index for (evse_id, connector_id). Fallback: first connector of EVSE.""" - return self._evse_to_global.get( - (evse_id, conn_id), self._evse_to_global.get((evse_id, 1), evse_id) - ) + """Return global index for (evse_id, conn_id).""" + # Exact match available + idx = self._evse_to_global.get((evse_id, conn_id)) + if idx is not None: + return idx + # Build from inventory if we have it + if self._inventory and not self._evse_to_global: + self._build_connector_map() + idx = self._evse_to_global.get( + (evse_id, conn_id) + ) or self._evse_to_global.get((evse_id, 1)) + if idx is not None: + return idx + # Allocate a unique index to avoid collisions until inventory arrives + new_idx = max(self._global_to_evse.keys(), default=0) + 1 + self._global_to_evse[new_idx] = (evse_id, conn_id) + self._evse_to_global[(evse_id, conn_id)] = new_idx + return new_idx def _global_to_pair(self, global_idx: int) -> tuple[int, int]: """Return (evse_id, connector_id) for a global index. Fallback: (global_idx,1).""" return self._global_to_evse.get(global_idx, (global_idx, 1)) + def _apply_status_notification( + self, timestamp: str, connector_status: str, evse_id: int, connector_id: int + ): + """Update per connector and evse aggregated.""" + if evse_id > len(self._connector_status): + self._connector_status += [[]] * (evse_id - len(self._connector_status)) + if connector_id > len(self._connector_status[evse_id - 1]): + self._connector_status[evse_id - 1] += [None] * ( + connector_id - len(self._connector_status[evse_id - 1]) + ) + + evse_list = self._connector_status[evse_id - 1] + evse_list[connector_id - 1] = ConnectorStatusEnumType(connector_status) + + global_idx = self._pair_to_global(evse_id, connector_id) + self._metrics[ + (global_idx, cstat.status_connector.value) + ].value = ConnectorStatusEnumType(connector_status).value + + evse_status: ConnectorStatusEnumType | None = None + for st in evse_list: + if st is None: + evse_status = None + break + evse_status = st + if st != ConnectorStatusEnumType.available: + break + if evse_status is not None: + if evse_status == ConnectorStatusEnumType.available: + v16 = ChargePointStatusv16.available + elif evse_status == ConnectorStatusEnumType.faulted: + v16 = ChargePointStatusv16.faulted + elif evse_status == ConnectorStatusEnumType.unavailable: + v16 = ChargePointStatusv16.unavailable + else: + v16 = ChargePointStatusv16.preparing + self._report_evse_status(evse_id, v16) + + def _flush_pending_status_notifications(self): + """Flush buffered status notifications when the map is ready.""" + if not self._ensure_connector_map(): + return + pending = self._pending_status_notifications + self._pending_status_notifications = [] + for t, st, evse_id, conn_id in pending: + self._apply_status_notification(t, st, evse_id, conn_id) + self.hass.async_create_task(self.update(self.settings.cpid)) + def _total_connectors(self) -> int: """Total physical connectors across all EVSE.""" if not self._inventory: @@ -313,7 +389,7 @@ async def set_charge_rate( charging_profile: dict = { "id": 1, "stack_level": 0, - "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value, "charging_profile_kind": ChargingProfileKindEnumType.relative.value, "charging_schedule": [schedule], } @@ -376,6 +452,9 @@ async def stop_transaction(self) -> bool: if val: tx_id = val break + if not tx_id: + _LOGGER.info("No active transaction found to stop") + return False req: call.RequestStopTransaction = call.RequestStopTransaction( transaction_id=tx_id ) @@ -495,43 +574,16 @@ def on_status_notification( self, timestamp: str, connector_status: str, evse_id: int, connector_id: int ): """Perform OCPP callback.""" - if evse_id > len(self._connector_status): - self._connector_status += [[]] * (evse_id - len(self._connector_status)) - if connector_id > len(self._connector_status[evse_id - 1]): - self._connector_status[evse_id - 1] += [None] * ( - connector_id - len(self._connector_status[evse_id - 1]) + if not self._ensure_connector_map(): + self._pending_status_notifications.append( + (timestamp, connector_status, evse_id, connector_id) ) + return call_result.StatusNotification() - evse: list[ConnectorStatusEnumType] = self._connector_status[evse_id - 1] - evse[connector_id - 1] = ConnectorStatusEnumType(connector_status) - - global_idx = self._pair_to_global(evse_id, connector_id) - self._metrics[ - (global_idx, cstat.status_connector.value) - ].value = ConnectorStatusEnumType(connector_status).value - + self._apply_status_notification( + timestamp, connector_status, evse_id, connector_id + ) self.hass.async_create_task(self.update(self.settings.cpid)) - - evse_status: ConnectorStatusEnumType | None = None - for status in evse: - if status is None: - evse_status = None - break - evse_status = status - if status != ConnectorStatusEnumType.available: - break - - if evse_status is not None: - if evse_status == ConnectorStatusEnumType.available: - evse_status_v16 = ChargePointStatusv16.available - elif evse_status == ConnectorStatusEnumType.faulted: - evse_status_v16 = ChargePointStatusv16.faulted - elif evse_status == ConnectorStatusEnumType.unavailable: - evse_status_v16 = ChargePointStatusv16.unavailable - else: - evse_status_v16 = ChargePointStatusv16.preparing - self._report_evse_status(evse_id, evse_status_v16) - return call_result.StatusNotification() @on(Action.firmware_status_notification) @@ -556,72 +608,112 @@ def on_notify_event(self, **kwargs): @on(Action.notify_report) def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): - """Perform OCPP callback.""" + """Handle OCPP 2.x inventory/report updates.""" if self._wait_inventory is None: return call_result.NotifyReport() + if self._inventory is None: self._inventory = InventoryReport() - reports: list[dict] = kwargs.get("report_data", []) + + reports: list[dict] = kwargs.get("report_data", []) or [] for report_data in reports: - component: dict = report_data["component"] - variable: dict = report_data["variable"] - component_name = component["name"] - variable_name = variable["name"] + component: dict = report_data.get("component", {}) or {} + variable: dict = report_data.get("variable", {}) or {} + component_name: str = str(component.get("name", "") or "") + variable_name: str = str(variable.get("name", "") or "") + value: str | None = None - for attribute in report_data["variable_attribute"]: - if (("type" not in attribute) or (attribute["type"] == "Actual")) and ( - "value" in attribute + for attr in report_data.get("variable_attribute", []) or []: + if ("type" not in attr) or ( + str(attr.get("type", "")).casefold() == "actual" ): - value = attribute["value"] - break - bool_value: bool = value and (value.casefold() == "true".casefold()) + if "value" in attr: + v = attr.get("value") + value = str(v) if v is not None else None + break + + bool_value: bool = False + if value is not None and str(value).strip(): + bool_value = str(value).strip().casefold() == "true" if (component_name == "SmartChargingCtrlr") and ( variable_name == "Available" ): self._inventory.smart_charging_available = bool_value - elif (component_name == "ReservationCtrlr") and ( + continue + if (component_name == "ReservationCtrlr") and ( variable_name == "Available" ): self._inventory.reservation_available = bool_value - elif (component_name == "LocalAuthListCtrlr") and ( + continue + if (component_name == "LocalAuthListCtrlr") and ( variable_name == "Available" ): self._inventory.local_auth_available = bool_value - elif (component_name == "EVSE") and ("evse" in component): - self._inventory.evse_count = max( - self._inventory.evse_count, component["evse"]["id"] - ) - self._inventory.connector_count += [0] * ( - self._inventory.evse_count - len(self._inventory.connector_count) - ) - elif ( + continue + + if (component_name == "EVSE") and ("evse" in component): + evse_id = int(component["evse"].get("id", 0) or 0) + if evse_id > 0: + self._inventory.evse_count = max( + self._inventory.evse_count, evse_id + ) + if ( + len(self._inventory.connector_count) + < self._inventory.evse_count + ): + self._inventory.connector_count += [0] * ( + self._inventory.evse_count + - len(self._inventory.connector_count) + ) + continue + + if ( (component_name == "Connector") and ("evse" in component) and ("connector_id" in component["evse"]) ): - evse_id = component["evse"]["id"] - self._inventory.evse_count = max(self._inventory.evse_count, evse_id) - self._inventory.connector_count += [0] * ( - self._inventory.evse_count - len(self._inventory.connector_count) - ) - self._inventory.connector_count[evse_id - 1] = max( - self._inventory.connector_count[evse_id - 1], - component["evse"]["connector_id"], - ) - elif ( - (component_name == "SampledDataCtrlr") - and (variable_name == "TxUpdatedMeasurands") - and ("variable_characteristics" in report_data) + evse_id = int(component["evse"].get("id", 0) or 0) + conn_id = int(component["evse"].get("connector_id", 0) or 0) + if evse_id > 0 and conn_id > 0: + self._inventory.evse_count = max( + self._inventory.evse_count, evse_id + ) + if ( + len(self._inventory.connector_count) + < self._inventory.evse_count + ): + self._inventory.connector_count += [0] * ( + self._inventory.evse_count + - len(self._inventory.connector_count) + ) + self._inventory.connector_count[evse_id - 1] = max( + self._inventory.connector_count[evse_id - 1], conn_id + ) + continue + + if (component_name == "SampledDataCtrlr") and ( + variable_name == "TxUpdatedMeasurands" ): - characteristics: dict = report_data["variable_characteristics"] - values: str = characteristics.get("values_list", "") + characteristics: dict = ( + report_data.get("variable_characteristics", {}) or {} + ) + values: str = str(characteristics.get("values_list", "") or "") + meas_list = [ + s.strip() for s in values.split(",") if s is not None and s.strip() + ] self._inventory.tx_updated_measurands = [ - MeasurandEnumType(s) for s in values.split(",") + MeasurandEnumType(s) for s in meas_list ] + continue if not kwargs.get("tbc", False): + if hasattr(self, "_build_connector_map"): + self._build_connector_map() + if hasattr(self, "_flush_pending_status_notifications"): + self._flush_pending_status_notifications() self._wait_inventory.set() + return call_result.NotifyReport() @on(Action.authorize) @@ -669,7 +761,7 @@ def _set_meter_values( if (tx_event_type == TransactionEventEnumType.started.value) or ( (tx_event_type == TransactionEventEnumType.updated.value) - and (self._metrics[(global_idx, csess.meter_start)].value is None) + and (self._metrics[(global_idx, csess.meter_start.value)].value is None) ): energy_measurand = MeasurandEnumType.energy_active_import_register.value for meter_value in converted_values: @@ -678,10 +770,10 @@ def _set_meter_values( energy_value = cp.get_energy_kwh(measurand_item) energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None self._metrics[ - (global_idx, csess.meter_start) + (global_idx, csess.meter_start.value) ].value = energy_value self._metrics[ - (global_idx, csess.meter_start) + (global_idx, csess.meter_start.value) ].unit = energy_unit self.process_measurands(converted_values, True, global_idx) @@ -721,7 +813,7 @@ def on_transaction_event( offline: bool = kwargs.get("offline", False) meter_values: list[dict] = kwargs.get("meter_value", []) self._set_meter_values(event_type, meter_values, evse_id, evse_conn_id) - t = datetime.fromisoformat(timestamp) + t = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) if "charging_state" in transaction_info: state = transaction_info["charging_state"] @@ -750,16 +842,19 @@ def on_transaction_event( self._tx_start_time[global_idx] = t tx_id: str = transaction_info["transaction_id"] self._metrics[(global_idx, csess.transaction_id.value)].value = tx_id - self._metrics[(global_idx, csess.session_time)].value = 0 - self._metrics[(global_idx, csess.session_time)].unit = UnitOfTime.MINUTES + self._metrics[(global_idx, csess.session_time.value)].value = 0 + self._metrics[ + (global_idx, csess.session_time.value) + ].unit = UnitOfTime.MINUTES else: if self._tx_start_time.get(global_idx): - duration_minutes: int = ( - (t - self._tx_start_time[global_idx]).seconds + 59 - ) // 60 - self._metrics[(global_idx, csess.session_time)].value = duration_minutes + elapsed = (t - self._tx_start_time[global_idx]).total_seconds() + duration_minutes: int = int((elapsed + 59) // 60) + self._metrics[ + (global_idx, csess.session_time.value) + ].value = duration_minutes self._metrics[ - (global_idx, csess.session_time) + (global_idx, csess.session_time.value) ].unit = UnitOfTime.MINUTES if event_type == TransactionEventEnumType.ended.value: self._metrics[(global_idx, csess.transaction_id.value)].value = "" diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 93feb496..523b17ca 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -48,12 +48,12 @@ async def async_setup_entry(hass, entry, async_add_devices): cpid = cp_id_settings[CONF_CPID] num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) - measurands = list( - set( - cp_id_settings[CONF_MONITORED_VARIABLES].split(",") - + list(HAChargerSession) - ) - ) + configured = [ + m.strip() + for m in cp_id_settings[CONF_MONITORED_VARIABLES].split(",") + if m and m.strip() + ] + measurands = list(set(configured + list(HAChargerSession))) CHARGER_ONLY = [ HAChargerStatuses.status.value, @@ -78,10 +78,11 @@ async def async_setup_entry(hass, entry, async_add_devices): ] def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: + ms = str(metric).strip() return OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, + key=ms.lower(), + name=ms.replace(".", " "), + metric=ms, entity_category=EntityCategory.DIAGNOSTIC if cat_diag else None, ) @@ -116,17 +117,6 @@ def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: connector_id=conn_id, ) ) - - for metric in ["Energy.Active.Import.Register"]: - entities.append( - ChargePointMetric( - hass, - central_system, - cpid, - _mk_desc(metric), - connector_id=None, - ) - ) else: for metric in CONNECTOR_ONLY: entities.append( @@ -173,11 +163,11 @@ def __init__( self._extra_attr = {} self._last_reset = homeassistant.util.dt.utc_from_timestamp(0) parts = [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] - if self.connector_id: + if self.connector_id is not None: parts.insert(2, f"conn{self.connector_id}") self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - if self.connector_id: + if self.connector_id is not None: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, name=f"{cpid} Connector {self.connector_id}", diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index a0cb64df..bdb524e0 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -3,14 +3,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final +from typing import Final from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import UnitOfPower from homeassistant.helpers.entity import DeviceInfo from ocpp.v16.enums import ChargePointStatus @@ -34,8 +33,6 @@ class OcppSwitchDescription(SwitchEntityDescription): per_connector: bool = False -POWER_KILO_WATT = UnitOfPower.KILO_WATT - SWITCHES: Final[list[OcppSwitchDescription]] = [ OcppSwitchDescription( key="charge_control", @@ -74,18 +71,29 @@ async def async_setup_entry(hass, entry, async_add_devices): cp_settings = list(charger.values())[0] cpid = cp_settings[CONF_CPID] num_connectors = int(cp_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + flatten_single = num_connectors == 1 for desc in SWITCHES: if desc.per_connector: for conn_id in range(1, num_connectors + 1): entities.append( ChargePointSwitch( - central_system, cpid, desc, connector_id=conn_id + central_system, + cpid, + desc, + connector_id=conn_id, + flatten_single=flatten_single, ) ) else: entities.append( - ChargePointSwitch(central_system, cpid, desc, connector_id=None) + ChargePointSwitch( + central_system, + cpid, + desc, + connector_id=None, + flatten_single=False, + ) ) async_add_devices(entities, False) @@ -103,12 +111,14 @@ def __init__( cpid: str, description: OcppSwitchDescription, connector_id: int | None = None, + flatten_single: bool = False, ): """Instantiate instance of a ChargePointSwitch.""" self.cpid = cpid self.central_system = central_system self.entity_description = description self.connector_id = connector_id + self._flatten_single = flatten_single self._state = self.entity_description.default_state parts = [SWITCH_DOMAIN, DOMAIN, cpid] if self.connector_id: @@ -116,7 +126,12 @@ def __init__( parts.append(description.key) self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - if self.connector_id: + if self.entity_description.per_connector and self.connector_id: + if self._flatten_single: + self._attr_name = ( + f"Connector {self.connector_id} {self.entity_description.name}" + ) + if self.connector_id and not self._flatten_single: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, name=f"{cpid} Connector {self.connector_id}", @@ -128,37 +143,49 @@ def __init__( name=cpid, ) - self._attr_icon = ICON - @property def available(self) -> bool: """Return if switch is available.""" - return self.central_system.get_available(self.cpid, self.connector_id) + target_conn = ( + self.connector_id if self.entity_description.per_connector else None + ) + return self.central_system.get_available(self.cpid, target_conn) @property def is_on(self) -> bool: """Return true if the switch is on.""" """Test metric state against condition if present""" if self.entity_description.metric_state is not None: + metric_conn = ( + self.connector_id + if ( + self.entity_description.metric_state + == HAChargerStatuses.status_connector.value + or self.entity_description.per_connector + ) + else None + ) resp = self.central_system.get_metric( - self.cpid, self.entity_description.metric_state, self.connector_id + self.cpid, self.entity_description.metric_state, metric_conn ) if self.entity_description.metric_condition is not None: self._state = resp in self.entity_description.metric_condition - return self._state # type: ignore[no-any-return] + return self._state - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs): """Turn the switch on.""" + target_conn = ( + self.connector_id if self.entity_description.per_connector else None + ) self._state = await self.central_system.set_charger_state( - self.cpid, - self.entity_description.on_action, - True, - connector_id=self.connector_id, + self.cpid, self.entity_description.on_action, True, connector_id=target_conn ) - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs): """Turn the switch off.""" - """Response is True if successful but State is False""" + target_conn = ( + self.connector_id if self.entity_description.per_connector else None + ) if self.entity_description.off_action is None: resp = True elif self.entity_description.off_action == self.entity_description.on_action: @@ -166,12 +193,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: self.cpid, self.entity_description.off_action, False, - connector_id=self.connector_id, + connector_id=target_conn, ) else: resp = await self.central_system.set_charger_state( - self.cpid, - self.entity_description.off_action, - connector_id=self.connector_id, + self.cpid, self.entity_description.off_action, connector_id=target_conn ) self._state = not resp diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index cfd658e1..e1d8da16 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -23,7 +23,7 @@ "meter_interval": "Meetinterval (secondes)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Functieprofiel Smart Charging forceren", - "num_connectors": "Aantal connectoren per Charge point" + "num_connectors": "Aantal connectoren per laadpunt" } }, "measurands": { diff --git a/scripts/develop b/scripts/develop index 10f5af38..967fdbbd 100755 --- a/scripts/develop +++ b/scripts/develop @@ -13,11 +13,25 @@ fi mkdir -p "${PWD}/config/custom_components" # Link the ocpp integration (overwrite if exists) -ln -sfn "${PWD}/custom_components/ocpp" "${PWD}/config/custom_components/ocpp" +src="${PWD}/custom_components/ocpp" +dst="${PWD}/config/custom_components/ocpp" +if [[ ! -d "$src" ]]; then + echo "Missing integration sources at: $src" >&2 + exit 1 +fi +ln -sfn "$src" "$dst" # Install debugpy if missing -/home/vscode/.local/ha-venv/bin/python -m pip install --quiet debugpy || true +if ! /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then + /home/vscode/.local/ha-venv/bin/python -m pip install --quiet debugpy +fi -# Start Home Assistant with debugger -exec /home/vscode/.local/ha-venv/bin/python -m debugpy --listen 0.0.0.0:5678 \ +# Start Home Assistant (prefer debugger if available) +DEBUGPY_LISTEN="${DEBUGPY_LISTEN:-0.0.0.0:5678}" +if /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then + exec /home/vscode/.local/ha-venv/bin/python -m debugpy --listen "${DEBUGPY_LISTEN}" \ -m homeassistant --config "${PWD}/config" --debug +else + echo "debugpy not available; starting Home Assistant without debugger" >&2 + exec /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --debug +fi \ No newline at end of file diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py index 42123644..c31ef04d 100644 --- a/tests/charge_point_test.py +++ b/tests/charge_point_test.py @@ -42,9 +42,9 @@ async def set_number(hass: HomeAssistant, cpid: str, key: str, value: int): await hass.services.async_call( NUMBER_DOMAIN, "set_value", - service_data={"value": str(value)}, + service_data={"value": value}, blocking=True, - target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_connector_1_{key}"}, + target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_{key}"}, ) diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index a6edf315..18f19e30 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -652,7 +652,10 @@ async def test_cms_responses_errors_v16( async def test_cms_responses_normal_multiple_connectors_v16( hass, socket_enabled, cp_id, port, setup_config_entry ): - """Test central system responses to a charger under normal operation with multiple connectors.""" + """Test central system responses to a charger. + + Normal operation with multiple connectors. + """ cs = setup_config_entry num_connectors = 2 @@ -663,8 +666,9 @@ async def test_cms_responses_normal_multiple_connectors_v16( ) as ws: cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=num_connectors) - tasks = [ - cp.start(), + cp_task = asyncio.create_task(cp.start()) + + phase1 = [ cp.send_boot_notification(), cp.send_authorize(), cp.send_heartbeat(), @@ -674,9 +678,8 @@ async def test_cms_responses_normal_multiple_connectors_v16( cp.send_status_for_all_connectors(), cp.send_start_transaction(12345), ] - for conn_id in range(1, num_connectors + 1): - tasks.extend( + phase1.extend( [ cp.send_meter_err_phases(connector_id=conn_id), cp.send_meter_line_voltage(connector_id=conn_id), @@ -684,10 +687,16 @@ async def test_cms_responses_normal_multiple_connectors_v16( ] ) - tasks.append(cp.send_stop_transaction(1)) - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(asyncio.gather(*tasks), timeout=10) + await asyncio.wait_for(asyncio.gather(*phase1), timeout=10) + + await cp.send_stop_transaction(1) + + await asyncio.sleep(0.05) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() diff --git a/tests/test_charge_point_v201_multi.py b/tests/test_charge_point_v201_multi.py index 589e1d5a..9ca09e7b 100644 --- a/tests/test_charge_point_v201_multi.py +++ b/tests/test_charge_point_v201_multi.py @@ -56,7 +56,7 @@ class MultiConnectorChargePoint(cpclass): - """Minimal OCPP 2.0.1 client som rapporterar 2 EVSE (1: två connectors, 2: en).""" + """Minimal OCPP 2.0.1 client that reports 2 EVSE (1: two connectors, 2: one).""" def __init__(self, cp_id, ws): """Initialize.""" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index be8d48a0..ea80fd95 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant.data_entry_flow import InvalidData import pytest -from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.const import DEFAULT_NUM_CONNECTORS, DOMAIN from .const import ( MOCK_CONFIG_CS, @@ -117,7 +117,9 @@ async def test_successful_discovery_flow(hass, bypass_get_data): flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( False ) - flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = 1 + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = ( + DEFAULT_NUM_CONNECTORS + ) assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] assert entry.data == flow_output From 7ab26169c1b663d8cdbe80f27166693658f50a2c Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Thu, 21 Aug 2025 19:21:23 +0000 Subject: [PATCH 07/15] Fix max_current per connector bug. Remove ChargerSession sensors (used for debugging). Fix Charge Control naming when only one connector. More CodeRabbit suggestions. --- custom_components/ocpp/button.py | 2 +- custom_components/ocpp/number.py | 3 +-- custom_components/ocpp/ocppv201.py | 21 +++++++++++++++++---- custom_components/ocpp/sensor.py | 6 +++--- custom_components/ocpp/switch.py | 15 +++------------ scripts/develop | 14 +++++++++----- tests/charge_point_test.py | 3 +-- 7 files changed, 35 insertions(+), 29 deletions(-) diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index 817e869f..ec7b116f 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -38,7 +38,7 @@ class OcppButtonDescription(ButtonEntityDescription): OcppButtonDescription( key="unlock", name="Unlock", - device_class=ButtonDeviceClass.UPDATE, + device_class=None, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_unlock.name, per_connector=True, diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index e64c73aa..86f91f73 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -157,7 +157,6 @@ def __init__( ) self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False - self._attr_available = True async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -188,7 +187,7 @@ async def async_set_native_value(self, value): resp = await self.central_system.set_max_charge_rate_amps( self.cpid, num_value, - connector_id=0, + connector_id=self._op_connector_id, ) if resp is True: self._attr_native_value = num_value diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 518f4bd4..b0056807 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -165,7 +165,8 @@ def _apply_status_notification( ): """Update per connector and evse aggregated.""" if evse_id > len(self._connector_status): - self._connector_status += [[]] * (evse_id - len(self._connector_status)) + needed = evse_id - len(self._connector_status) + self._connector_status.extend([[] for _ in range(needed)]) if connector_id > len(self._connector_status[evse_id - 1]): self._connector_status[evse_id - 1] += [None] * ( connector_id - len(self._connector_status[evse_id - 1]) @@ -346,9 +347,18 @@ async def set_charge_rate( conn_id: int = 0, profile: dict | None = None, ): - """Set a charging profile with defined limit (OCPP 2.x).""" + """Set a charging profile with defined limit (OCPP 2.x). + + - conn_id=0 (default) targets the Charging Station (evse_id=0). + - conn_id>0 targets the specific EVSE corresponding to the global connector index. + """ + + evse_target = 0 + if conn_id and conn_id > 0: + with contextlib.suppress(Exception): + evse_target, _ = self._global_to_pair(int(conn_id)) if profile is not None: - req = call.SetChargingProfile(0, profile) + req = call.SetChargingProfile(evse_target, profile) resp: call_result.SetChargingProfile = await self.call(req) if resp.status != ChargingProfileStatusEnumType.accepted: raise HomeAssistantError( @@ -394,7 +404,9 @@ async def set_charge_rate( "charging_schedule": [schedule], } - req: call.SetChargingProfile = call.SetChargingProfile(0, charging_profile) + req: call.SetChargingProfile = call.SetChargingProfile( + evse_target, charging_profile + ) resp: call_result.SetChargingProfile = await self.call(req) if resp.status != ChargingProfileStatusEnumType.accepted: raise HomeAssistantError( @@ -859,6 +871,7 @@ def on_transaction_event( if event_type == TransactionEventEnumType.ended.value: self._metrics[(global_idx, csess.transaction_id.value)].value = "" self._metrics[(global_idx, cstat.id_tag.value)].value = "" + self._tx_start_time.pop(global_idx, None) if not offline: self.hass.async_create_task(self.update(self.settings.cpid)) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 523b17ca..2fcf7377 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -28,7 +28,7 @@ ICON, Measurand, ) -from .enums import HAChargerDetails, HAChargerSession, HAChargerStatuses +from .enums import HAChargerDetails, HAChargerStatuses @dataclass @@ -50,10 +50,10 @@ async def async_setup_entry(hass, entry, async_add_devices): configured = [ m.strip() - for m in cp_id_settings[CONF_MONITORED_VARIABLES].split(",") + for m in str(cp_id_settings.get(CONF_MONITORED_VARIABLES, "")).split(",") if m and m.strip() ] - measurands = list(set(configured + list(HAChargerSession))) + measurands = sorted(configured) CHARGER_ONLY = [ HAChargerStatuses.status.value, diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index bdb524e0..034ff8b0 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -121,16 +121,11 @@ def __init__( self._flatten_single = flatten_single self._state = self.entity_description.default_state parts = [SWITCH_DOMAIN, DOMAIN, cpid] - if self.connector_id: + if self.connector_id and not self._flatten_single: parts.append(f"conn{self.connector_id}") parts.append(description.key) self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - if self.entity_description.per_connector and self.connector_id: - if self._flatten_single: - self._attr_name = ( - f"Connector {self.connector_id} {self.entity_description.name}" - ) if self.connector_id and not self._flatten_single: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, @@ -174,18 +169,14 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs): """Turn the switch on.""" - target_conn = ( - self.connector_id if self.entity_description.per_connector else None - ) + target_conn = self.connector_id if self.entity_description.per_connector else 0 self._state = await self.central_system.set_charger_state( self.cpid, self.entity_description.on_action, True, connector_id=target_conn ) async def async_turn_off(self, **kwargs): """Turn the switch off.""" - target_conn = ( - self.connector_id if self.entity_description.per_connector else None - ) + target_conn = self.connector_id if self.entity_description.per_connector else 0 if self.entity_description.off_action is None: resp = True elif self.entity_description.off_action == self.entity_description.on_action: diff --git a/scripts/develop b/scripts/develop index 967fdbbd..3b6c6ebd 100755 --- a/scripts/develop +++ b/scripts/develop @@ -3,10 +3,10 @@ set -e cd "$(dirname "$0")/.." -# Create config dir if not present -if [[ ! -d "${PWD}/config" ]]; then - mkdir -p "${PWD}/config" - /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --script ensure_config +# Create/prepare config +mkdir -p "${PWD}/config" +if [[ ! -f "${PWD}/config/configuration.yaml" ]]; then + /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --script ensure_config fi # Ensure custom components dir exists @@ -19,7 +19,11 @@ if [[ ! -d "$src" ]]; then echo "Missing integration sources at: $src" >&2 exit 1 fi -ln -sfn "$src" "$dst" + +# Remove existing dir/symlink/file at destination +if [[ -e "$dst" || -L "$dst" ]]; then + rm -rf -- "$dst" +fi # Install debugpy if missing if ! /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py index c31ef04d..a9f3db95 100644 --- a/tests/charge_point_test.py +++ b/tests/charge_point_test.py @@ -28,11 +28,10 @@ async def set_switch(hass: HomeAssistant, cpid: str, key: str, on: bool): """Toggle a switch.""" - prefix = "_" if key == "availability" else "_connector_1_" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON if on else SERVICE_TURN_OFF, - service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cpid}{prefix}{key}"}, + service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cpid}_{key}"}, blocking=True, ) From f9b286d5930873013dc396deb3ac32a6bb18c1b4 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Thu, 21 Aug 2025 20:14:58 +0000 Subject: [PATCH 08/15] Fix Connector naming. More CodeRabbit suggestions. --- custom_components/ocpp/button.py | 2 +- custom_components/ocpp/number.py | 11 +++++++---- custom_components/ocpp/ocppv201.py | 8 +++++--- custom_components/ocpp/sensor.py | 5 +++-- custom_components/ocpp/switch.py | 4 ++-- scripts/develop | 3 +++ 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index ec7b116f..4a5f1ff1 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -122,7 +122,7 @@ def __init__( if self.connector_id: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"{cpid} Connector {self.connector_id}", + name=f"Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 86f91f73..9f13bac6 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -63,8 +63,11 @@ async def async_setup_entry(hass, entry, async_add_devices): num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) for desc in NUMBERS: if desc.key == "maximum_current": - ent_initial = cp_id_settings[CONF_MAX_CURRENT] - ent_max = cp_id_settings[CONF_MAX_CURRENT] + max_cur = float( + cp_id_settings.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) + ) + ent_initial = max_cur + ent_max = max_cur else: ent_initial = desc.initial_value ent_max = desc.native_max_value @@ -147,7 +150,7 @@ def __init__( if self.connector_id: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"{cpid} Connector {self.connector_id}", + name=f"Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: @@ -182,7 +185,7 @@ def available(self) -> bool: ) async def async_set_native_value(self, value): - """Set new value for station-wide max current (EVSE 0).""" + """Set new value for max current (station-wide when _op_connector_id==0, otherwise per-connector).""" num_value = float(value) resp = await self.central_system.set_max_charge_rate_amps( self.cpid, diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index b0056807..9c3044d1 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -2,6 +2,7 @@ import asyncio from datetime import datetime, UTC +from dataclasses import dataclass, field import logging import ocpp.exceptions @@ -62,15 +63,16 @@ logging.getLogger(DOMAIN).setLevel(logging.INFO) +@dataclass class InventoryReport: """Cached full inventory report for a charger.""" evse_count: int = 0 - connector_count: list[int] = [] + connector_count: list[int] = field(default_factory=list) smart_charging_available: bool = False reservation_available: bool = False local_auth_available: bool = False - tx_updated_measurands: list[MeasurandEnumType] = [] + tx_updated_measurands: list[MeasurandEnumType] = field(default_factory=list) class ChargePoint(cp): @@ -283,7 +285,7 @@ async def get_supported_measurands(self) -> str: return "" async def get_supported_features(self) -> Profiles: - """Get comma-separated list of measurands supported by the charger.""" + """Get feature profiles supported by the charger.""" await self._get_inventory() features = Profiles.CORE if self._inventory and self._inventory.smart_charging_available: diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 2fcf7377..a373af85 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -53,7 +53,8 @@ async def async_setup_entry(hass, entry, async_add_devices): for m in str(cp_id_settings.get(CONF_MONITORED_VARIABLES, "")).split(",") if m and m.strip() ] - measurands = sorted(configured) + default_measurands: list[str] = [] + measurands = sorted(configured or default_measurands) CHARGER_ONLY = [ HAChargerStatuses.status.value, @@ -170,7 +171,7 @@ def __init__( if self.connector_id is not None: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"{cpid} Connector {self.connector_id}", + name=f"Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 034ff8b0..988171d8 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -129,7 +129,7 @@ def __init__( if self.connector_id and not self._flatten_single: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"{cpid} Connector {self.connector_id}", + name=f"Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: @@ -144,7 +144,7 @@ def available(self) -> bool: target_conn = ( self.connector_id if self.entity_description.per_connector else None ) - return self.central_system.get_available(self.cpid, target_conn) + return bool(self.central_system.get_available(self.cpid, target_conn)) @property def is_on(self) -> bool: diff --git a/scripts/develop b/scripts/develop index 3b6c6ebd..002d933f 100755 --- a/scripts/develop +++ b/scripts/develop @@ -25,6 +25,9 @@ if [[ -e "$dst" || -L "$dst" ]]; then rm -rf -- "$dst" fi +# Create the symlink +ln -s "$src" "$dst" + # Install debugpy if missing if ! /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then /home/vscode/.local/ha-venv/bin/python -m pip install --quiet debugpy From a8acba1cf15cfaca179e9e7fd6260301e138bb86 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Fri, 22 Aug 2025 17:15:03 +0000 Subject: [PATCH 09/15] Add more tests. More CodeRabbit suggestions. --- custom_components/ocpp/chargepoint.py | 8 +- custom_components/ocpp/ocppv16.py | 67 +- custom_components/ocpp/ocppv201.py | 3 +- custom_components/ocpp/switch.py | 2 + tests/test_charge_point_v16.py | 1064 ++++++++++++++++++++++++- tests/test_connector_aware_metrics.py | 264 ++++++ 6 files changed, 1375 insertions(+), 33 deletions(-) create mode 100644 tests/test_connector_aware_metrics.py diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index 6b4acd76..bf501756 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -409,9 +409,11 @@ async def unlock(self, connector_id: int = 1) -> bool: return False async def update_firmware(self, firmware_url: str, wait_time: int = 0): - """Update charger with new firmware if available.""" - """where firmware_url is the http or https url of the new firmware""" - """and wait_time is hours from now to wait before install""" + """Update charger with new firmware if available. + + - firmware_url is the http or https url of the new firmware + - wait_time is hours from now to wait before install + """ pass async def get_diagnostics(self, upload_url: str): diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index b3a3da84..f0076ea3 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -283,17 +283,10 @@ async def clear_profile( purpose: ChargingProfilePurposeType | None = None, ) -> bool: """Clear charging profiles (per connector and/or purpose).""" - criteria = {} - if purpose is not None: - criteria["charging_profile_purpose"] = purpose.value - - target_connector = None - if conn_id is not None: - target_connector = int(conn_id) - + target_connector = int(conn_id) if conn_id is not None else None req = call.ClearChargingProfile( connector_id=target_connector, - charging_profile_purpose=criteria if criteria else None, + charging_profile_purpose=purpose.value if purpose is not None else None, ) resp = await self.call(req) if resp.status in ( @@ -492,40 +485,58 @@ async def unlock(self, connector_id: int = 1): return False async def update_firmware(self, firmware_url: str, wait_time: int = 0): - """Update charger with new firmware if available.""" - """where firmware_url is the http or https url of the new firmware""" - """and wait_time is hours from now to wait before install""" - if prof.FW in self._attr_supported_features: - schema = vol.Schema(vol.Url()) - try: - url = schema(firmware_url) - except vol.MultipleInvalid as e: - _LOGGER.debug("Failed to parse url: %s", e) - update_time = (datetime.now(tz=UTC) + timedelta(hours=wait_time)).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - req = call.UpdateFirmware(location=url, retrieve_date=update_time) + """Update charger with new firmware if available. + + - firmware_url: http/https URL of the new firmware + - wait_time: hours from now to wait before install + """ + features = int(self._attr_supported_features or 0) + if not (features & prof.FW): + _LOGGER.warning("Charger does not support OCPP firmware updating") + return False + + schema = vol.Schema(vol.Url()) + try: + url = schema(firmware_url) + except vol.MultipleInvalid as e: + _LOGGER.warning("Failed to parse url: %s", e) + return False + + try: + retrieve_time = ( + datetime.now(tz=UTC) + timedelta(hours=max(0, int(wait_time or 0))) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception: + retrieve_time = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + try: + req = call.UpdateFirmware(location=str(url), retrieve_date=retrieve_time) resp = await self.call(req) - _LOGGER.info("Response: %s", resp) + _LOGGER.info("UpdateFirmware response: %s", resp) return True - else: - _LOGGER.warning("Charger does not support ocpp firmware updating") + except Exception as e: + _LOGGER.error("UpdateFirmware failed: %s", e) return False async def get_diagnostics(self, upload_url: str): """Upload diagnostic data to server from charger.""" - if prof.FW in self._attr_supported_features: + features = int(self._attr_supported_features or 0) + if features & prof.FW: schema = vol.Schema(vol.Url()) try: url = schema(upload_url) except vol.MultipleInvalid as e: _LOGGER.warning("Failed to parse url: %s", e) - req = call.GetDiagnostics(location=url) + return + req = call.GetDiagnostics(location=str(url)) resp = await self.call(req) _LOGGER.info("Response: %s", resp) return True else: - _LOGGER.warning("Charger does not support ocpp diagnostics uploading") + _LOGGER.debug( + "Charger %s does not support ocpp diagnostics uploading", + self.id, + ) return False async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 9c3044d1..6454e3a8 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -80,7 +80,7 @@ class ChargePoint(cp): _inventory: InventoryReport | None = None _wait_inventory: asyncio.Event | None = None - _connector_status: list[list[ConnectorStatusEnumType | None]] = [] + _connector_status: list[list[ConnectorStatusEnumType | None]] _tx_start_time: dict[int, datetime] _global_to_evse: dict[int, tuple[int, int]] # global_idx -> (evse_id, connector_id) _evse_to_global: dict[tuple[int, int], int] # (evse_id, connector_id) -> global_idx @@ -112,6 +112,7 @@ def __init__( self._global_to_evse: dict[int, tuple[int, int]] = {} self._evse_to_global: dict[tuple[int, int], int] = {} self._pending_status_notifications: list[tuple[str, str, int, int]] = [] + self._connector_status = [] # --- Connector mapping helpers (EVSE <-> global index) --- def _build_connector_map(self) -> bool: diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 988171d8..13afd2f4 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -165,6 +165,8 @@ def is_on(self) -> bool: ) if self.entity_description.metric_condition is not None: self._state = resp in self.entity_description.metric_condition + else: + self._state = bool(resp) return self._state async def async_turn_on(self, **kwargs): diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 18f19e30..b3e1d1fa 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -3,12 +3,16 @@ import asyncio import contextlib from datetime import datetime, UTC # timedelta, +import inspect +import logging +import re import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.exceptions import HomeAssistantError import websockets +from custom_components.ocpp.chargepoint import Metric as M from custom_components.ocpp.api import CentralSystem from custom_components.ocpp.button import BUTTONS from custom_components.ocpp.const import ( @@ -17,7 +21,11 @@ CONF_CPID, CONF_PORT, ) -from custom_components.ocpp.enums import ConfigurationKey, HAChargerServices as csvcs +from custom_components.ocpp.enums import ( + ConfigurationKey, + HAChargerServices as csvcs, + Profiles as prof, +) from custom_components.ocpp.number import NUMBERS from custom_components.ocpp.switch import SWITCHES from ocpp.routing import on @@ -732,6 +740,1045 @@ async def test_cms_responses_normal_multiple_connectors_v16( ) +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9008, "cp_id": "CP_1_diag_dt", "cms": "cms_diag_dt"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_diag_dt"]) +@pytest.mark.parametrize("port", [9008]) +async def test_get_diagnostics_and_data_transfer_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Ensure HA services trigger correct OCPP 1.6 calls with expected payload. + + including DataTransfer rejected path and get_diagnostics error/feature branches. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + # Bring charger to ready state (boot + post_connect) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Resolve HA device id (cpid) + cpid = cs.charge_points[cp_id].settings.cpid + + # --- get_diagnostics: happy path with valid URL --- + upload_url = "https://example.test/diag" + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": upload_url}, + blocking=True, + ) + + # --- data_transfer: Accepted path --- + vendor_id = "VendorX" + message_id = "Msg42" + payload = '{"hello":"world"}' + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": vendor_id, + "message_id": message_id, + "data": payload, + }, + blocking=True, + ) + + # Give event loop a tick to flush ws calls + await asyncio.sleep(0.05) + + # Assert CP handlers received expected fields (as captured by the fake CP) + assert cp.last_diag_location == upload_url + assert cp.last_data_transfer == (vendor_id, message_id, payload) + # If your fake CP stores status, assert it was Accepted + if hasattr(cp, "last_data_transfer_status"): + from ocpp.v16.enums import DataTransferStatus + + assert cp.last_data_transfer_status == DataTransferStatus.accepted + + # --- data_transfer: Rejected path (flip cp.accept -> False) --- + cp.accept = False + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": "VendorX", + "message_id": "MsgRejected", + "data": "nope", + }, + blocking=True, + ) + await asyncio.sleep(0.05) + if hasattr(cp, "last_data_transfer_status"): + from ocpp.v16.enums import DataTransferStatus + + assert cp.last_data_transfer_status == DataTransferStatus.rejected + + # --- get_diagnostics: invalid URL triggers vol.MultipleInvalid warning --- + caplog.clear() + caplog.set_level(logging.WARNING) + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "not-a-valid-url"}, + blocking=True, + ) + assert any( + "Failed to parse url" in rec.message for rec in caplog.records + ), "Expected warning for invalid diagnostics upload_url not found" + + # --- get_diagnostics: FW profile NOT supported branch --- + # Simulate that FirmwareManagement profile is not supported by the CP + cpobj = cs.charge_points[cp_id] + original_features = getattr(cpobj, "_attr_supported_features", None) + + # Try to blank out features regardless of type (set/list/tuple/int) + try: + tp = type(original_features) + if isinstance(original_features, set | list | tuple): + new_val = tp() # empty same container type + else: + new_val = 0 # fall back to "no features" + setattr(cpobj, "_attr_supported_features", new_val) + except Exception: + setattr(cpobj, "_attr_supported_features", 0) + + # Valid URL, but without FW support the handler should skip/return gracefully + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "https://example.com/diag2"}, + blocking=True, + ) + + # Restore original features to avoid impacting other tests + if original_features is not None: + setattr(cpobj, "_attr_supported_features", original_features) + + # Cleanup + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9009, "cp_id": "CP_1_clear", "cms": "cms_clear"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_clear"]) +@pytest.mark.parametrize("port", [9009]) +async def test_clear_profile_v16(hass, socket_enabled, cp_id, port, setup_config_entry): + """Verify that HA's clear_profile service triggers OCPP 1.6 ClearChargingProfile.""" + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + # Make CP ready so HA can run services + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cpid = cs.charge_points[cp_id].settings.cpid + + # Minimal clear: no filters -> clears any CS/CP max profiles + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_clear_profile.value, + service_data={"devid": cpid}, + blocking=True, + ) + + # Let the request propagate + await asyncio.sleep(0.05) + + # Assert the CP handler was called + assert cp.last_clear_profile_kwargs is not None + # Common default: empty dict (no id/purpose/stack/connector filters) + assert isinstance(cp.last_clear_profile_kwargs, dict) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9010, "cp_id": "CP_1_stop_paths", "cms": "cms_stop_paths"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths"]) +@pytest.mark.parametrize("port", [9010]) +async def test_stop_transaction_paths_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO C: _charger_reports_session_energy = False -> compute from meter_stop - meter_start + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Disable "charger reports session energy" branch + cs.charge_points[cp_id]._charger_reports_session_energy = False + + # Start a transaction with known meter_start (Wh); server lagrar meter_start som kWh + await cp.send_start_transaction(meter_start=12345) # 12.345 kWh på servern + + # Stop with meter_stop=54321 (→ 54.321 kWh) + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + + # Expect session = 54.321 - 12.345 = 41.976 kWh + sess = float(cs.get_metric(cpid, "Energy.Session")) + assert round(sess, 3) == round(54.321 - 12.345, 3) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" + + # After stop, these measurands must be zeroed + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert float(cs.get_metric(cpid, meas)) == 0.0 + + # Optional: stop reason captured + assert cs.get_metric(cpid, "Stop.Reason") is not None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + # + # SCENARIO A: _charger_reports_session_energy = True and SessionEnergy is None + # Use last Energy.Active.Import.Register to populate SessionEnergy. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + + # Ensure there is an active tx so stop is accepted + await cp.send_start_transaction(meter_start=0) + + # Force SessionEnergy to be None before stop + m = cs.charge_points[cp_id]._metrics + m[(1, "Energy.Session")].value = None # connector 1 + + # Case A1: last EAIR in Wh → should convert to kWh + m[(1, "Energy.Active.Import.Register")].value = 1300000 # Wh + m[(1, "Energy.Active.Import.Register")].unit = "Wh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 1300000 / 1000.0 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + # + # SCENARIO A (variant): charger reports session energy AND last EAIR already kWh. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + await cp.send_start_transaction(meter_start=0) + + m = cs.charge_points[cp_id]._metrics + m[(1, "Energy.Session")].value = None + m[(1, "Energy.Active.Import.Register")].value = 42.5 # already kWh + m[(1, "Energy.Active.Import.Register")].unit = "kWh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 42.5 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + # + # SCENARIO B: charger reports session energy BUT SessionEnergy already set → do not overwrite. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + await cp.send_start_transaction(meter_start=0) + + m = cs.charge_points[cp_id]._metrics + # Pre-set SessionEnergy (should remain unchanged) + m[(1, "Energy.Session")].value = 7.777 + m[(1, "Energy.Session")].unit = "kWh" + + # Set EAIR to a different value to ensure we would notice an overwrite + m[(1, "Energy.Active.Import.Register")].value = 999999 + m[(1, "Energy.Active.Import.Register")].unit = "Wh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 7.777 # unchanged + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9011, "cp_id": "CP_1_meter_paths", "cms": "cms_meter_paths"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_meter_paths"]) +@pytest.mark.parametrize("port", [9011]) +async def test_on_meter_values_paths_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise important branches of ocppv16.on_meter_values. + + - Main meter (EAIR) without transaction_id -> connector 0 (kWh) + - Restore meter_start/transaction_id when missing + - With transaction_id and match -> update Energy.Session + - Empty strings for other measurands -> coerced to 0.0 + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + # Keep the OCPP task running in the background. + cp_task = asyncio.create_task(cp.start()) + try: + # Boot (enough for the CS to register the CPID). + await cp.send_boot_notification() + cpid = cs.charge_points[cp_id].settings.cpid + + # 1) Start a transaction so the helper for "main meter" won't block. + await cp.send_start_transaction(meter_start=10000) # Wh (10 kWh) + # Give CS a tick to persist state. + await asyncio.sleep(0.1) + active_tx = cs.charge_points[cp_id].active_transaction_id + assert active_tx != 0 + + # 2) MAIN METER (no transaction_id): updates aggregate connector (0) in kWh + # Note: helper waits for active tx, but still omits transaction_id in the message. + await cp.send_main_meter_clock_data() + agg_eair = float( + cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=0) + ) + assert agg_eair == pytest.approx(67230012 / 1000.0, rel=1e-6) + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=0) + == "kWh" + ) + + # 3) Force-loss: clear meter_start and transaction_id; provide last EAIR to restore from. + m = cs.charge_points[cp_id]._metrics + m[(1, "Energy.Meter.Start")].value = None + m[(1, "Transaction.Id")].value = None + m[(1, "Energy.Active.Import.Register")].value = 12.5 + m[(1, "Energy.Active.Import.Register")].unit = "kWh" + + # 4) Send MeterValues WITH transaction_id and include: + # - EAIR = 15000 Wh (-> 15.0 kWh) + # - Power.Active.Import = "" (should coerce to 0.0) + mv = call.MeterValues( + connector_id=1, + transaction_id=active_tx, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + { + "value": "", + "measurand": "Power.Active.Import", + "unit": "W", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp = await cp.call(mv) + assert resp is not None + + # meter_start restored from last EAIR on connector 1 -> 12.5 kWh; session = 15.0 - 12.5 = 2.5 kWh + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert sess == pytest.approx(2.5, rel=1e-6) + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + # Empty-string coerced to 0.0 + pai = float(cs.get_metric(cpid, "Power.Active.Import", connector_id=1)) + assert pai == 0.0 + + # Transaction id restored/kept + tx_restored = int(cs.get_metric(cpid, "Transaction.Id", connector_id=1)) + assert tx_restored == active_tx + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9018, "cp_id": "CP_1_mv_restore", "cms": "cms_mv_restore"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_mv_restore"]) +@pytest.mark.parametrize("port", [9018]) +async def test_on_meter_values_restore_paths_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Cover both restore branches in on_meter_values. + + - restored (meter_start) is not None + - restored_tx (transaction_id) is not None + Then verify SessionEnergy behavior. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Ensure the metric slots look "missing" so both restore branches run. + srv._metrics[(1, "Energy.Meter.Start")].value = None + srv._metrics[(1, "Transaction.Id")].value = None + + # Patch get_ha_metric so both restores succeed. + def fake_get_ha_metric(name: str, connector_id: int | None = None): + if name == "Energy.Meter.Start" and connector_id == 1: + return "12.5" # kWh + if name == "Transaction.Id" and connector_id == 1: + return "123456" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # (1) Send a MeterValues WITHOUT transaction_id -> updates aggregate EAIR (conn 0) + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", # Wh -> 15.0 kWh + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + resp = await cp.call(mv_no_tx) + assert resp is not None + + # Verify both restore branches happened. + assert srv._metrics[(1, "Energy.Meter.Start")].value == 12.5 + assert srv._metrics[(1, "Transaction.Id")].value == 123456 + assert srv._active_tx.get(1, 0) == 123456 + + # Aggregate EAIR (connector 0) updated to 15.0 kWh with attrs. + assert srv._metrics[(0, "Energy.Active.Import.Register")].value == 15.0 + assert srv._metrics[(0, "Energy.Active.Import.Register")].unit == "kWh" + assert ( + srv._metrics[(0, "Energy.Active.Import.Register")].extra_attr.get( + "location" + ) + == "Inlet" + ) + assert ( + srv._metrics[(0, "Energy.Active.Import.Register")].extra_attr.get("context") + == "Sample.Clock" + ) + + # (2) Send a MeterValues WITH matching transaction_id and EAIR=16.0 kWh + mv_with_tx = call.MeterValues( + connector_id=1, + transaction_id=123456, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "16.0", + "measurand": "Energy.Active.Import.Register", + "unit": "kWh", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp2 = await cp.call(mv_with_tx) + assert resp2 is not None + + # SessionEnergy = 16.0 − 12.5 = 3.5 kWh + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert pytest.approx(sess, rel=1e-6) == 3.5 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9012, "cp_id": "CP_1_monconn", "cms": "cms_monconn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_monconn"]) +@pytest.mark.parametrize("port", [9012]) +async def test_monitor_connection_timeout_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise TimeoutError branch in chargepoint.monitor_connection and ensure it raises after exceeded tries.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + + # Patch asyncio in the module under test. + from custom_components.ocpp import chargepoint as cp_mod + + async def fast_sleep(_): + return None # skip the initial sleep(10) and interval sleeps + + monkeypatch.setattr(cp_mod.asyncio, "sleep", fast_sleep, raising=True) + + # First wait_for returns a never-finishing "pong waiter", + # second wait_for raises TimeoutError -> hits the except branch + calls = {"n": 0} + + class _NeverFinishes: + def __await__(self): + fut = asyncio.get_event_loop().create_future() + return fut.__await__() + + calls = {"n": 0} + + async def fake_wait_for(awaitable, timeout): + calls["n"] += 1 + if inspect.iscoroutine(awaitable): + awaitable.close() + if calls["n"] == 1: + + class _NeverFinishes: + def __await__(self): + fut = asyncio.get_event_loop().create_future() + return fut.__await__() + + return _NeverFinishes() + raise TimeoutError + + monkeypatch.setattr(cp_mod.asyncio, "wait_for", fake_wait_for, raising=True) + + # Make the code raise on first timeout + srv_cp.cs_settings.websocket_ping_interval = 0.0 + srv_cp.cs_settings.websocket_ping_timeout = 0.01 + srv_cp.cs_settings.websocket_ping_tries = 0 # => > tries -> raise + + with pytest.raises(TimeoutError): + await srv_cp.monitor_connection() + + assert calls["n"] >= 2 # both wait_for calls were exercised + + # Cleanup + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9013, "cp_id": "CP_1_extra", "cms": "cms_extra"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_extra"]) +@pytest.mark.parametrize("port", [9013]) +async def test_api_get_extra_attr_paths( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise CentralSystem.get_extra_attr() without driving full post-connect. + + We connect briefly to ensure the CS has a server-side CP object, then we + seed _metrics extra_attr directly and verify lookup order: + - explicit connector_id returns that connector's attrs, + - no connector_id prefers aggregate (conn 0), + - if conn 0 is missing, fallback to conn 1 succeeds. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # Start a minimal CP so CS creates/keeps the server-side object + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + # One Boot is enough to associate the CP id in CS + await cp.send_boot_notification() + + # Grab server-side CP and seed metrics directly + cp_srv = cs.charge_points[cp_id] + cpid = cp_srv.settings.cpid + + meas = "Energy.Active.Import.Register" + + # Seed aggregate (connector 0) extra_attr + cp_srv._metrics[(0, meas)].extra_attr = { + "location": "Inlet", + "context": "Sample.Clock", + } + + # (A) No connector_id -> prefers aggregate (0) + attrs = cs.get_extra_attr(cpid, measurand=meas) + assert attrs == {"location": "Inlet", "context": "Sample.Clock"} + + # (B) Explicit connector 1 -> returns that connector's attrs + cp_srv._metrics[(1, meas)].extra_attr = {"custom": "c1", "context": "Override"} + attrs_c1 = cs.get_extra_attr(cpid, measurand=meas, connector_id=1) + assert attrs_c1 == {"custom": "c1", "context": "Override"} + + # (C) Fallback order when aggregate is missing -> falls back to connector 1 + cp_srv._metrics[(0, meas)].extra_attr = None + attrs_fallback = cs.get_extra_attr(cpid, measurand=meas) + assert attrs_fallback == {"custom": "c1", "context": "Override"} + + # Clean up + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9014, "cp_id": "CP_1_fw_ok", "cms": "cms_fw_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_ok"]) +@pytest.mark.parametrize("port", [9014]) +async def test_update_firmware_supported_valid_url_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW supported + valid URL -> returns True and RPC is sent with correct payload.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + # Enable FW bit + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + url = "https://example.com/fw.bin" + caplog.set_level(logging.INFO) + + ok = await server_cp.update_firmware(url, wait_time=0) + assert ok is True + + # Assert the client actually received an UpdateFirmware call with expected data + # retrieveDate format: YYYY-mm-ddTHH:MM:SSZ + assert cp.last_update_firmware is not None + assert cp.last_update_firmware.get("location") == url + rd = cp.last_update_firmware.get("retrieve_date") + assert isinstance(rd, str) and re.match( + r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$", rd + ) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9015, "cp_id": "CP_1_fw_badurl", "cms": "cms_fw_badurl"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_badurl"]) +@pytest.mark.parametrize("port", [9015]) +async def test_update_firmware_supported_invalid_url_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW supported + invalid URL -> returns False and no RPC is sent.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + bad_url = "not-a-valid-url" + caplog.set_level(logging.WARNING) + + ok = await server_cp.update_firmware(bad_url, wait_time=1) + assert ok is False + # Should warn about invalid URL + assert any("Failed to parse url" in rec.message for rec in caplog.records) + # Client must not have received any UpdateFirmware + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9016, "cp_id": "CP_1_fw_nosupport", "cms": "cms_fw_nosupport"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_nosupport"]) +@pytest.mark.parametrize("port", [9016]) +async def test_update_firmware_not_supported_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW not supported -> returns False; no RPC.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + # Ensure FW bit is NOT set + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) & ~prof.FW + ) + + caplog.set_level(logging.WARNING) + ok = await server_cp.update_firmware("https://example.com/fw.bin", wait_time=0) + assert ok is False + assert any( + "does not support OCPP firmware updating" in rec.message + for rec in caplog.records + ) + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9017, "cp_id": "CP_1_fw_rpcfail", "cms": "cms_fw_rpcfail"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_rpcfail"]) +@pytest.mark.parametrize("port", [9017]) +async def test_update_firmware_rpc_failure_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog, monkeypatch +): + """FW supported but self.call raises -> returns False and logs error.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + # Make the server-side call() fail + async def boom(_req): + raise RuntimeError("boom") + + monkeypatch.setattr(server_cp, "call", boom, raising=True) + + caplog.set_level(logging.ERROR) + ok = await server_cp.update_firmware("https://example.com/fw.bin", wait_time=0) + assert ok is False + assert any("UpdateFirmware failed" in rec.message for rec in caplog.records) + # No successful RPC reached the client + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9018, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_unit_fallback"]) +@pytest.mark.parametrize("port", [9018]) +async def test_api_get_unit_fallback_to_later_connectors( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """get_unit() should fall back to connectors >=2 when (0) and (1) have no unit.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # IMPORTANT: advertise 3 connectors so the CS learns n_connectors >= 3 + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=3) + cp_task = asyncio.create_task(cp.start()) + + # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + meas = "Power.Active.Import" + + # Ensure no flat-key unit short-circuits the fallback + if meas in srv._metrics: + srv._metrics[meas].unit = None + + # Seed (0) and (1) with metrics but no unit… + srv._metrics[(0, meas)] = srv._metrics.get((0, meas), M(0.0, None)) + srv._metrics[(0, meas)].unit = None + srv._metrics[(1, meas)] = srv._metrics.get((1, meas), M(0.0, None)) + srv._metrics[(1, meas)].unit = None + + # …and (2) with a concrete unit the fallback should discover. + srv._metrics[(2, meas)] = M(0.0, "kW") + + unit = cs.get_unit(cpid, measurand=meas) + assert unit == "kW" + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9019, + "cp_id": "CP_1_extra_fallback", + "cms": "cms_extra_fallback", + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_extra_fallback"]) +@pytest.mark.parametrize("port", [9019]) +async def test_api_get_extra_attr_fallback_to_later_connectors( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Ensure get_extra_attr() falls back. + + To connectors >=2 when (0), flat-key, (1) and (2) have no attrs (extra_attr=None), so connector 3 is returned. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=3) + cp_task = asyncio.create_task(cp.start()) + + # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + from custom_components.ocpp.chargepoint import Metric as M + + meas = "Energy.Active.Import.Register" + + # (1) Force early checks to return None (NOT {}): + # - Access the flat key via __getitem__ to create the exact object the API will read, + # then set its extra_attr to None. + srv._metrics[(0, meas)] = M(0.0, None) + srv._metrics[(0, meas)].extra_attr = None + + _flat = srv._metrics[meas] # <-- pre-touch the flat key + _flat.extra_attr = None # <-- ensure it returns None, not {} + + srv._metrics[(1, meas)] = M(0.0, None) + srv._metrics[(1, meas)].extra_attr = None + + srv._metrics[(2, meas)] = M(0.0, None) + srv._metrics[(2, meas)].extra_attr = None + + # (2) Seed connector 3 with the only non-empty attrs. + expected = {"source": "conn3", "context": "Sample.Clock"} + srv._metrics[(3, meas)] = M(0.0, None) + srv._metrics[(3, meas)].extra_attr = expected + + # (3) Now the API should fall through to connector 3. + got = cs.get_extra_attr(cpid, measurand=meas) + assert got == expected + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" @@ -743,6 +1790,10 @@ def __init__(self, id, connection, response_timeout=30, no_connectors=1): self.accept: bool = True self.task = None # reused for background triggers self._tasks: set[asyncio.Task] = set() + self.last_diag_location: str | None = None + self.last_data_transfer: tuple[str | None, str | None, str | None] | None = None + self.last_clear_profile_kwargs: dict | None = None + self.last_update_firmware: dict | None = None @on(Action.get_configuration) def on_get_configuration(self, key, **kwargs): @@ -898,6 +1949,8 @@ def on_set_charging_profile(self, **kwargs): @on(Action.clear_charging_profile) def on_clear_charging_profile(self, **kwargs): """Handle clear charging profile request.""" + # keep what was requested so the test can assert + self.last_clear_profile_kwargs = dict(kwargs) if kwargs else {} if self.accept is True: return call_result.ClearChargingProfile(ClearChargingProfileStatus.accepted) else: @@ -936,16 +1989,25 @@ def on_trigger_message(self, requested_message, **kwargs): @on(Action.update_firmware) def on_update_firmware(self, **kwargs): """Handle update firmware request.""" + self.last_update_firmware = dict(kwargs) return call_result.UpdateFirmware() @on(Action.get_diagnostics) def on_get_diagnostics(self, **kwargs): """Handle get diagnostics request.""" + # OCPP 1.6 GetDiagnostics request uses 'location' + self.last_diag_location = kwargs.get("location") return call_result.GetDiagnostics() @on(Action.data_transfer) def on_data_transfer(self, **kwargs): """Handle get data transfer request.""" + # OCPP 1.6 DataTransfer request uses 'vendor_id', 'message_id', 'data' + self.last_data_transfer = ( + kwargs.get("vendor_id"), + kwargs.get("message_id"), + kwargs.get("data"), + ) if self.accept is True: return call_result.DataTransfer(DataTransferStatus.accepted) else: diff --git a/tests/test_connector_aware_metrics.py b/tests/test_connector_aware_metrics.py new file mode 100644 index 00000000..e8eccfea --- /dev/null +++ b/tests/test_connector_aware_metrics.py @@ -0,0 +1,264 @@ +"""Test connector-aware metrics handling.""" + +import pytest + +from custom_components.ocpp.chargepoint import _ConnectorAwareMetrics, Metric + + +def M(v=None, unit=None): + """Help to create a Metric with a value and a None timestamp.""" + return Metric(v, unit) + + +def test_flat_set_get_contains_len_iter(): + """Flat access (connector 0) should work and be independent from per-connector.""" + m = _ConnectorAwareMetrics() + + # Flat = connector 0 + m["Power.Active.Import"] = M(1.5) + assert "Power.Active.Import" in m + assert len(m) == 1 + assert list(iter(m)) == ["Power.Active.Import"] + assert m["Power.Active.Import"].value == 1.5 + + # Per-connector should not affect flat view length/keys + m[(2, "Power.Active.Import")] = M(7.0) + assert len(m) == 1 # len() reports flat/conn-0 size only + assert "Power.Active.Import" in m + assert m[(2, "Power.Active.Import")].value == 7.0 + assert m["Power.Active.Import"].value == 1.5 + + +def test_get_whole_connector_mapping_and_assign_full_mapping(): + """Accessing m[conn_id] returns the dict for that connector; setting dict replaces it.""" + m = _ConnectorAwareMetrics() + + # When first accessed, connector dict exists (created by defaultdict) + conn1_map = m[1] + assert isinstance(conn1_map, dict) + assert conn1_map == {} + + # Replace entire mapping for connector 1 + m[1] = {"Voltage": M(229.9), "Current.Import": M(6.0)} + assert m[(1, "Voltage")].value == 229.9 + assert m[(1, "Current.Import")].value == 6.0 + + # Flat (connector 0) remains independent — do NOT access m["Voltage"] (would create) + assert "Voltage" not in m + assert "Current.Import" not in m + assert len(m) == 0 + + +def test_delete_per_connector_and_flat(): + """Deleting per-connector keys must not touch flat; deleting flat must not touch others.""" + m = _ConnectorAwareMetrics() + + m["A"] = M(10) # flat + m[(2, "A")] = M(20) # connector 2 + + # Delete per-connector key (check via the connector dict, not tuple get) + del m[(2, "A")] + assert "A" not in m[2] # still present flat + assert m["A"].value == 10 + + # Delete flat key (verify flat keys(), avoid m["A"] which would recreate) + del m["A"] + assert "A" not in m + + # Recreate per-connector and verify it stays independent + m[(2, "A")] = M(99) + assert m[(2, "A")].value == 99 + assert "A" not in m # flat untouched + + +def test_keys_values_items_are_flat_only(): + """keys()/values()/items() only reflect connector 0 (flat) mapping.""" + m = _ConnectorAwareMetrics() + m["k0"] = M(0) + m[(1, "k1")] = M(1) + m[(2, "k2")] = M(2) + + assert list(m.keys()) == ["k0"] + assert [v.value for v in m.values()] == [0] + items = list(m.items()) + assert items == [("k0", m["k0"])] + + +def test_type_checks_on_setitem(): + """__setitem__ must enforce types for flat, per-connector tuple, and connector dict.""" + m = _ConnectorAwareMetrics() + + # Flat must be Metric + with pytest.raises(TypeError): + m["foo"] = 123 # not a Metric + + # Per-connector must be Metric + with pytest.raises(TypeError): + m[(1, "foo")] = 123 # not a Metric + + # Connector mapping must be dict[str, Metric] + with pytest.raises(TypeError): + m[1] = 123 + + # Correct types should pass + m["ok_flat"] = M(1) + m[(1, "ok_pc")] = M(2) + m[2] = {"ok_map": M(3)} + + assert m["ok_flat"].value == 1 + assert m[(1, "ok_pc")].value == 2 + assert m[(2, "ok_map")].value == 3 + + +def test_clear_and_contains_tuple_semantics(): + """clear() empties everything; __contains__ tuple logic works without creating entries.""" + m = _ConnectorAwareMetrics() + m["flat"] = M(1) + m[(1, "pc")] = M(2) + + # Membership checks + assert "flat" in m + assert "pc" in m[1] # check in the connector dict + + m.clear() + + # After clear, no flat keys and no connector dicts + assert len(list(m.keys())) == 0 + + +def test_get_variants_and_contains_behavior(): + """Exercise get() for flat keys, per-connector keys, connector dicts, and defaults.""" + m = _ConnectorAwareMetrics() + + # Seed some values + m["Voltage"] = M(230.0, "V") # flat (connector 0) + m[(2, "Voltage")] = M(231.0, "V") # connector 2 + + # 1) get() existing flat key + got = m.get("Voltage") + assert isinstance(got, Metric) + assert got.value == 230.0 + assert "Voltage" in m + + # 2) get() missing flat key -> defaultdict creates Metric(None, None); default is ignored + default_metric = M(0.0, "V") + got_default = m.get("Nope", default_metric) + assert isinstance(got_default, Metric) + assert got_default is not default_metric + assert got_default.value is None + # Key is now present due to defaultdict insertion + assert "Nope" in m + + # 3) get() existing tuple key + got_c2 = m.get((2, "Voltage")) + assert isinstance(got_c2, Metric) + assert got_c2.value == 231.0 + assert 2 in m + + # 4) get() missing tuple key -> also inserts a Metric(None, None) + missing_default = M(7.0) + got_missing = m.get((2, "Missing"), missing_default) + assert isinstance(got_missing, Metric) + assert got_missing is not missing_default + assert got_missing.value is None + assert 2 in m and "Missing" in m[2] + + # 5) get() on a brand new connector id returns its (empty) dict and creates it + conn99 = m.get(99) + assert isinstance(conn99, dict) + assert 99 in m + + +def test_delitem_all_paths_and_errors(): + """Cover deletion of flat keys, per-connector keys, and entire connector maps.""" + m = _ConnectorAwareMetrics() + + # Seed: + m["Voltage"] = M(230.0, "V") # flat (connector 0) + m[(2, "Voltage")] = M(231.0, "V") + m[(2, "Current.Import")] = M(6.0, "A") + + # A) Delete a flat key + del m["Voltage"] + assert "Voltage" not in m + # Accessing again returns a fresh Metric(None, None) due to defaultdict + after_del_flat = m["Voltage"] + assert isinstance(after_del_flat, Metric) + assert after_del_flat.value is None + + # B) Delete a (conn, meas) key + del m[(2, "Current.Import")] + assert "Current.Import" not in m[2] + # Accessing again creates a fresh Metric(None, None) + after_del_tuple = m[(2, "Current.Import")] + assert isinstance(after_del_tuple, Metric) + assert after_del_tuple.value is None + # Remaining key for connector 2 is still there + assert "Voltage" in m[2] + + # C) Delete an entire connector map + del m[2] + assert 2 not in m + # Accessing m[2] recreates an empty mapping via top-level defaultdict + recreated = m[2] + assert isinstance(recreated, dict) + assert 2 in m and recreated == {} + + # D) Deleting a missing flat key raises KeyError and does not create it + with pytest.raises(KeyError): + del m["DoesNotExist"] + assert "DoesNotExist" not in m + + # E) Deleting a missing (conn, meas) raises KeyError, but creates the connector id + with pytest.raises(KeyError): + del m[(3, "NoSuchMeas")] + assert 3 in m and m[3] == {} + + # F) Deleting a non-existent connector id raises KeyError and does not create it + with pytest.raises(KeyError): + del m[42] + assert 42 not in m + + +def test_get_returns_default_when_inner_is_plain_dict(): + """Ensure get() returns provided default if inner mapping is a plain dict that raises KeyError.""" + m = _ConnectorAwareMetrics() + + # Replace connector 1 map with a plain dict (no defaultdict semantics) + m[1] = {"Voltage": M(230.0, "V")} + + # Missing measurand under connector 1 -> __getitem__ would raise KeyError, + # so get() must return the supplied default (and not insert anything). + default_metric = M(99.0, "V") + got = m.get((1, "Missing"), default_metric) + assert got is default_metric + + # Confirm we didn't insert the missing key as a side effect + assert "Missing" not in m[1] + # And tuple __contains__ is still False + assert (1, "Missing") not in m + + +def test_contains_tuple_semantics_true_false_and_missing_connector(): + """Exercise __contains__ for (connector, measurand) tuples.""" + m = _ConnectorAwareMetrics() + + # Seed values on different connectors + m[(2, "Voltage")] = M(231.0, "V") + m[(3, "Current.Import")] = M(6.0, "A") + + # Present tuple -> True + assert (2, "Voltage") in m + assert (3, "Current.Import") in m + + # Wrong measurand on existing connector -> False + assert (2, "Current.Import") not in m + assert (3, "Voltage") not in m + + # Missing connector entirely -> False (uses .get(conn, {}) in __contains__) + assert 99 not in m # connector 99 doesn't exist yet + assert (99, "Voltage") not in m # tuple contains should be False as well + + # After creating empty map for 99 (via direct access), measurand still absent -> False + _ = m[99] # creates empty mapping for connector 99 + assert (99, "Voltage") not in m From 77d81ad35c66d836a5e3ef04ce792d3366d45674 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Sat, 23 Aug 2025 11:50:36 +0000 Subject: [PATCH 10/15] Test fixes. CodeRabbit suggestions. --- custom_components/ocpp/chargepoint.py | 5 +- tests/test_charge_point_v16.py | 75 ++++++++++++++++++++++----- tests/test_connector_aware_metrics.py | 22 +++----- 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index bf501756..abd0f74a 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -169,10 +169,9 @@ def __len__(self): return len(self._by_conn[0]) def get(self, key, default=None): - try: + if key in self: return self[key] - except KeyError: - return default + return default def keys(self): return self._by_conn[0].keys() diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index b3e1d1fa..2087d163 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -1133,6 +1133,8 @@ async def test_on_meter_values_paths_v16( try: # Boot (enough for the CS to register the CPID). await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cpid = cs.charge_points[cp_id].settings.cpid # 1) Start a transaction so the helper for "main meter" won't block. @@ -1209,6 +1211,8 @@ async def test_on_meter_values_paths_v16( with contextlib.suppress(asyncio.CancelledError): await cp_task + await ws.close() + @pytest.mark.timeout(10) @pytest.mark.parametrize( @@ -1328,7 +1332,7 @@ def fake_get_ha_metric(name: str, connector_id: int | None = None): await ws.close() -@pytest.mark.timeout(5) +@pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9012, "cp_id": "CP_1_monconn", "cms": "cms_monconn"}], @@ -1352,9 +1356,13 @@ async def test_monitor_connection_timeout_branch( srv_cp = cs.charge_points[cp_id] - # Patch asyncio in the module under test. from custom_components.ocpp import chargepoint as cp_mod + async def noop_task(_coro): + return None + + monkeypatch.setattr(srv_cp.hass, "async_create_task", noop_task, raising=True) + async def fast_sleep(_): return None # skip the initial sleep(10) and interval sleeps @@ -1364,13 +1372,6 @@ async def fast_sleep(_): # second wait_for raises TimeoutError -> hits the except branch calls = {"n": 0} - class _NeverFinishes: - def __await__(self): - fut = asyncio.get_event_loop().create_future() - return fut.__await__() - - calls = {"n": 0} - async def fake_wait_for(awaitable, timeout): calls["n"] += 1 if inspect.iscoroutine(awaitable): @@ -1392,15 +1393,39 @@ def __await__(self): srv_cp.cs_settings.websocket_ping_timeout = 0.01 srv_cp.cs_settings.websocket_ping_tries = 0 # => > tries -> raise + srv_cp.post_connect_success = True + + async def noop(): + return None + + monkeypatch.setattr(srv_cp, "post_connect", noop, raising=True) + monkeypatch.setattr(srv_cp, "set_availability", noop, raising=True) + with pytest.raises(TimeoutError): await srv_cp.monitor_connection() assert calls["n"] >= 2 # both wait_for calls were exercised - # Cleanup + # Cleanup (deterministic) + # 1) Close websocket first to stop further I/O. + await ws.close() + + # 2) Give the loop a tick so pending send/recv tasks notice the close. + await asyncio.sleep(0) + # 3) Cancel any helper tasks you spawned on the client (defensive). + for t in list(getattr(cp, "_tasks", [])): + t.cancel() + with contextlib.suppress( + asyncio.CancelledError, websockets.exceptions.ConnectionClosedOK + ): + await t + + # 4) Now cancel the client's main OCPP task. cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): + with contextlib.suppress( + asyncio.CancelledError, websockets.exceptions.ConnectionClosedOK + ): await cp_task @@ -1433,6 +1458,7 @@ async def test_api_get_extra_attr_paths( cp_task = asyncio.create_task(cp.start()) # One Boot is enough to associate the CP id in CS await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) # Grab server-side CP and seed metrics directly cp_srv = cs.charge_points[cp_id] @@ -1461,11 +1487,18 @@ async def test_api_get_extra_attr_paths( assert attrs_fallback == {"custom": "c1", "context": "Override"} # Clean up + for t in list(getattr(cp, "_tasks", [])): + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t cp_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cp_task + with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): + await ws.close() + @pytest.mark.timeout(20) @pytest.mark.parametrize( @@ -1656,7 +1689,7 @@ async def boom(_req): await ws.close() -@pytest.mark.timeout(10) +@pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9018, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], @@ -1703,12 +1736,20 @@ async def test_api_get_unit_fallback_to_later_connectors( assert unit == "kW" # Cleanup + for t in list(getattr(cp, "_tasks", [])): + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + cp_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cp_task + with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): + await ws.close() -@pytest.mark.timeout(10) + +@pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", [ @@ -1774,10 +1815,18 @@ async def test_api_get_extra_attr_fallback_to_later_connectors( assert got == expected # Cleanup + for t in list(getattr(cp, "_tasks", [])): + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + cp_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cp_task + with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): + await ws.close() + class ChargePoint(cpclass): """Representation of real client Charge Point.""" diff --git a/tests/test_connector_aware_metrics.py b/tests/test_connector_aware_metrics.py index e8eccfea..e39cebfd 100644 --- a/tests/test_connector_aware_metrics.py +++ b/tests/test_connector_aware_metrics.py @@ -140,14 +140,13 @@ def test_get_variants_and_contains_behavior(): assert got.value == 230.0 assert "Voltage" in m - # 2) get() missing flat key -> defaultdict creates Metric(None, None); default is ignored + # 2) get() missing flat key -> default is returned default_metric = M(0.0, "V") got_default = m.get("Nope", default_metric) assert isinstance(got_default, Metric) - assert got_default is not default_metric - assert got_default.value is None - # Key is now present due to defaultdict insertion - assert "Nope" in m + assert got_default is default_metric + assert got_default.value == 0.0 + assert "V" not in m # 3) get() existing tuple key got_c2 = m.get((2, "Voltage")) @@ -155,18 +154,13 @@ def test_get_variants_and_contains_behavior(): assert got_c2.value == 231.0 assert 2 in m - # 4) get() missing tuple key -> also inserts a Metric(None, None) + # 4) get() missing tuple key -> also inserts default, not the missing key missing_default = M(7.0) got_missing = m.get((2, "Missing"), missing_default) assert isinstance(got_missing, Metric) - assert got_missing is not missing_default - assert got_missing.value is None - assert 2 in m and "Missing" in m[2] - - # 5) get() on a brand new connector id returns its (empty) dict and creates it - conn99 = m.get(99) - assert isinstance(conn99, dict) - assert 99 in m + assert got_missing is missing_default + assert got_missing.value == 7.0 + assert 2 in m and "Missing" not in m[2] def test_delitem_all_paths_and_errors(): From 4ec2f3369c0740394eb8613238bd9aeda156eaa4 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Sun, 24 Aug 2025 18:02:53 +0000 Subject: [PATCH 11/15] Fix tests. --- tests/test_charge_point_v16.py | 642 +++++++++----------------- tests/test_charge_point_v201_multi.py | 4 +- 2 files changed, 233 insertions(+), 413 deletions(-) diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 2087d163..e10b76cd 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -3,7 +3,6 @@ import asyncio import contextlib from datetime import datetime, UTC # timedelta, -import inspect import logging import re @@ -12,9 +11,9 @@ from homeassistant.exceptions import HomeAssistantError import websockets -from custom_components.ocpp.chargepoint import Metric as M from custom_components.ocpp.api import CentralSystem from custom_components.ocpp.button import BUTTONS +from custom_components.ocpp.chargepoint import Metric as M from custom_components.ocpp.const import ( DOMAIN as OCPP_DOMAIN, CONF_CPIDS, @@ -648,7 +647,6 @@ async def test_cms_responses_errors_v16( ) -# @pytest.mark.skip(reason="skip") @pytest.mark.timeout(20) # Set timeout for this test @pytest.mark.parametrize( "setup_config_entry", @@ -668,44 +666,32 @@ async def test_cms_responses_normal_multiple_connectors_v16( cs = setup_config_entry num_connectors = 2 + # test ocpp messages sent from charger to cms async with websockets.connect( f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.5", "ocpp1.6"], ) as ws: + # use a different id for debugging cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=num_connectors) - cp_task = asyncio.create_task(cp.start()) - phase1 = [ - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_security_event(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_status_for_all_connectors(), - cp.send_start_transaction(12345), - ] - for conn_id in range(1, num_connectors + 1): - phase1.extend( - [ - cp.send_meter_err_phases(connector_id=conn_id), - cp.send_meter_line_voltage(connector_id=conn_id), - cp.send_meter_periodic_data(connector_id=conn_id), - ] - ) - - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(asyncio.gather(*phase1), timeout=10) - + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + await cp.send_boot_notification() + await cp.send_authorize() + await cp.send_heartbeat() + await cp.send_status_notification() + await cp.send_security_event() + await cp.send_firmware_status() + await cp.send_data_transfer() + await cp.send_start_transaction(12345) + await cp.send_meter_err_phases() + await cp.send_meter_line_voltage() + await cp.send_meter_periodic_data() + # add delay to allow meter data to be processed await cp.send_stop_transaction(1) - await asyncio.sleep(0.05) - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() cpid = cs.charge_points[cp_id].settings.cpid @@ -740,145 +726,6 @@ async def test_cms_responses_normal_multiple_connectors_v16( ) -# @pytest.mark.skip(reason="skip") -@pytest.mark.timeout(20) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9008, "cp_id": "CP_1_diag_dt", "cms": "cms_diag_dt"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_1_diag_dt"]) -@pytest.mark.parametrize("port", [9008]) -async def test_get_diagnostics_and_data_transfer_v16( - hass, socket_enabled, cp_id, port, setup_config_entry, caplog -): - """Ensure HA services trigger correct OCPP 1.6 calls with expected payload. - - including DataTransfer rejected path and get_diagnostics error/feature branches. - """ - - cs: CentralSystem = setup_config_entry - - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", - subprotocols=["ocpp1.6"], - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - - # Bring charger to ready state (boot + post_connect) - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - # Resolve HA device id (cpid) - cpid = cs.charge_points[cp_id].settings.cpid - - # --- get_diagnostics: happy path with valid URL --- - upload_url = "https://example.test/diag" - await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_get_diagnostics.value, - service_data={"devid": cpid, "upload_url": upload_url}, - blocking=True, - ) - - # --- data_transfer: Accepted path --- - vendor_id = "VendorX" - message_id = "Msg42" - payload = '{"hello":"world"}' - await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_data_transfer.value, - service_data={ - "devid": cpid, - "vendor_id": vendor_id, - "message_id": message_id, - "data": payload, - }, - blocking=True, - ) - - # Give event loop a tick to flush ws calls - await asyncio.sleep(0.05) - - # Assert CP handlers received expected fields (as captured by the fake CP) - assert cp.last_diag_location == upload_url - assert cp.last_data_transfer == (vendor_id, message_id, payload) - # If your fake CP stores status, assert it was Accepted - if hasattr(cp, "last_data_transfer_status"): - from ocpp.v16.enums import DataTransferStatus - - assert cp.last_data_transfer_status == DataTransferStatus.accepted - - # --- data_transfer: Rejected path (flip cp.accept -> False) --- - cp.accept = False - await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_data_transfer.value, - service_data={ - "devid": cpid, - "vendor_id": "VendorX", - "message_id": "MsgRejected", - "data": "nope", - }, - blocking=True, - ) - await asyncio.sleep(0.05) - if hasattr(cp, "last_data_transfer_status"): - from ocpp.v16.enums import DataTransferStatus - - assert cp.last_data_transfer_status == DataTransferStatus.rejected - - # --- get_diagnostics: invalid URL triggers vol.MultipleInvalid warning --- - caplog.clear() - caplog.set_level(logging.WARNING) - await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_get_diagnostics.value, - service_data={"devid": cpid, "upload_url": "not-a-valid-url"}, - blocking=True, - ) - assert any( - "Failed to parse url" in rec.message for rec in caplog.records - ), "Expected warning for invalid diagnostics upload_url not found" - - # --- get_diagnostics: FW profile NOT supported branch --- - # Simulate that FirmwareManagement profile is not supported by the CP - cpobj = cs.charge_points[cp_id] - original_features = getattr(cpobj, "_attr_supported_features", None) - - # Try to blank out features regardless of type (set/list/tuple/int) - try: - tp = type(original_features) - if isinstance(original_features, set | list | tuple): - new_val = tp() # empty same container type - else: - new_val = 0 # fall back to "no features" - setattr(cpobj, "_attr_supported_features", new_val) - except Exception: - setattr(cpobj, "_attr_supported_features", 0) - - # Valid URL, but without FW support the handler should skip/return gracefully - await hass.services.async_call( - OCPP_DOMAIN, - csvcs.service_get_diagnostics.value, - service_data={"devid": cpid, "upload_url": "https://example.com/diag2"}, - blocking=True, - ) - - # Restore original features to avoid impacting other tests - if original_features is not None: - setattr(cpobj, "_attr_supported_features", original_features) - - # Cleanup - - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - - await ws.close() - - # @pytest.mark.skip(reason="skip") @pytest.mark.timeout(20) @pytest.mark.parametrize( @@ -923,12 +770,19 @@ async def test_clear_profile_v16(hass, socket_enabled, cp_id, port, setup_config assert isinstance(cp.last_clear_profile_kwargs, dict) cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() +async def set_report_session_energyreport( + cs: CentralSystem, cp_id: str, should_report: bool +): + """Set report session energy report True/False.""" + cs.charge_points[cp_id]._charger_reports_session_energy = should_report + + +set_report_session_energyreport.__test__ = False + + # @pytest.mark.skip(reason="skip") @pytest.mark.timeout(20) @pytest.mark.parametrize( @@ -944,54 +798,6 @@ async def test_stop_transaction_paths_v16( """Exercise all branches of ocppv16.on_stop_transaction.""" cs: CentralSystem = setup_config_entry - # - # SCENARIO C: _charger_reports_session_energy = False -> compute from meter_stop - meter_start - # - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - # Disable "charger reports session energy" branch - cs.charge_points[cp_id]._charger_reports_session_energy = False - - # Start a transaction with known meter_start (Wh); server lagrar meter_start som kWh - await cp.send_start_transaction(meter_start=12345) # 12.345 kWh på servern - - # Stop with meter_stop=54321 (→ 54.321 kWh) - await cp.send_stop_transaction(delay=0) - - cpid = cs.charge_points[cp_id].settings.cpid - - # Expect session = 54.321 - 12.345 = 41.976 kWh - sess = float(cs.get_metric(cpid, "Energy.Session")) - assert round(sess, 3) == round(54.321 - 12.345, 3) - assert cs.get_unit(cpid, "Energy.Session") == "kWh" - - # After stop, these measurands must be zeroed - for meas in [ - "Current.Import", - "Power.Active.Import", - "Power.Reactive.Import", - "Current.Export", - "Power.Active.Export", - "Power.Reactive.Export", - ]: - assert float(cs.get_metric(cpid, meas)) == 0.0 - - # Optional: stop reason captured - assert cs.get_metric(cpid, "Stop.Reason") is not None - - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - - await ws.close() - # # SCENARIO A: _charger_reports_session_energy = True and SessionEnergy is None # Use last Energy.Active.Import.Register to populate SessionEnergy. @@ -1026,9 +832,6 @@ async def test_stop_transaction_paths_v16( assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() # @@ -1059,9 +862,6 @@ async def test_stop_transaction_paths_v16( assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() # @@ -1095,8 +895,48 @@ async def test_stop_transaction_paths_v16( assert round(sess, 3) == 7.777 # unchanged cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task + await ws.close() + + # + # SCENARIO C: _charger_reports_session_energy = False -> compute from meter_stop - meter_start + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_boot_notification(), + cp.send_start_transaction(12345), + set_report_session_energyreport(cs, cp_id, False), + cp.send_stop_transaction(1), + ), + timeout=8, + ) + await ws.close() + + cpid = cs.charge_points[cp_id].settings.cpid + + # Expect session = 54.321 - 12.345 = 41.976 kWh + sess = float(cs.get_metric(cpid, "Energy.Session")) + assert round(sess, 3) == round(54.321 - 12.345, 3) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" + + # After stop, these measurands must be zeroed + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert float(cs.get_metric(cpid, meas)) == 0.0 + + # Optional: stop reason captured + assert cs.get_metric(cpid, "Stop.Reason") is not None await ws.close() @@ -1208,9 +1048,6 @@ async def test_on_meter_values_paths_v16( finally: cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() @@ -1326,109 +1163,9 @@ def fake_get_ha_metric(name: str, connector_id: int | None = None): assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() -@pytest.mark.timeout(20) -@pytest.mark.parametrize( - "setup_config_entry", - [{"port": 9012, "cp_id": "CP_1_monconn", "cms": "cms_monconn"}], - indirect=True, -) -@pytest.mark.parametrize("cp_id", ["CP_1_monconn"]) -@pytest.mark.parametrize("port", [9012]) -async def test_monitor_connection_timeout_branch( - hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch -): - """Exercise TimeoutError branch in chargepoint.monitor_connection and ensure it raises after exceeded tries.""" - cs: CentralSystem = setup_config_entry - - async with websockets.connect( - f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] - ) as ws: - cp = ChargePoint(f"{cp_id}_client", ws) - cp_task = asyncio.create_task(cp.start()) - await cp.send_boot_notification() - await wait_ready(cs.charge_points[cp_id]) - - srv_cp = cs.charge_points[cp_id] - - from custom_components.ocpp import chargepoint as cp_mod - - async def noop_task(_coro): - return None - - monkeypatch.setattr(srv_cp.hass, "async_create_task", noop_task, raising=True) - - async def fast_sleep(_): - return None # skip the initial sleep(10) and interval sleeps - - monkeypatch.setattr(cp_mod.asyncio, "sleep", fast_sleep, raising=True) - - # First wait_for returns a never-finishing "pong waiter", - # second wait_for raises TimeoutError -> hits the except branch - calls = {"n": 0} - - async def fake_wait_for(awaitable, timeout): - calls["n"] += 1 - if inspect.iscoroutine(awaitable): - awaitable.close() - if calls["n"] == 1: - - class _NeverFinishes: - def __await__(self): - fut = asyncio.get_event_loop().create_future() - return fut.__await__() - - return _NeverFinishes() - raise TimeoutError - - monkeypatch.setattr(cp_mod.asyncio, "wait_for", fake_wait_for, raising=True) - - # Make the code raise on first timeout - srv_cp.cs_settings.websocket_ping_interval = 0.0 - srv_cp.cs_settings.websocket_ping_timeout = 0.01 - srv_cp.cs_settings.websocket_ping_tries = 0 # => > tries -> raise - - srv_cp.post_connect_success = True - - async def noop(): - return None - - monkeypatch.setattr(srv_cp, "post_connect", noop, raising=True) - monkeypatch.setattr(srv_cp, "set_availability", noop, raising=True) - - with pytest.raises(TimeoutError): - await srv_cp.monitor_connection() - - assert calls["n"] >= 2 # both wait_for calls were exercised - - # Cleanup (deterministic) - # 1) Close websocket first to stop further I/O. - await ws.close() - - # 2) Give the loop a tick so pending send/recv tasks notice the close. - await asyncio.sleep(0) - - # 3) Cancel any helper tasks you spawned on the client (defensive). - for t in list(getattr(cp, "_tasks", [])): - t.cancel() - with contextlib.suppress( - asyncio.CancelledError, websockets.exceptions.ConnectionClosedOK - ): - await t - - # 4) Now cancel the client's main OCPP task. - cp_task.cancel() - with contextlib.suppress( - asyncio.CancelledError, websockets.exceptions.ConnectionClosedOK - ): - await cp_task - - @pytest.mark.timeout(10) @pytest.mark.parametrize( "setup_config_entry", @@ -1487,17 +1224,8 @@ async def test_api_get_extra_attr_paths( assert attrs_fallback == {"custom": "c1", "context": "Override"} # Clean up - for t in list(getattr(cp, "_tasks", [])): - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - - with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): - await ws.close() + await ws.close() @pytest.mark.timeout(20) @@ -1545,9 +1273,6 @@ async def test_update_firmware_supported_valid_url_v16( ) cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() @@ -1590,9 +1315,6 @@ async def test_update_firmware_supported_invalid_url_v16( assert getattr(cp, "last_update_firmware", None) is None cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() @@ -1635,9 +1357,6 @@ async def test_update_firmware_not_supported_v16( assert getattr(cp, "last_update_firmware", None) is None cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() @@ -1683,9 +1402,6 @@ async def boom(_req): assert getattr(cp, "last_update_firmware", None) is None cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - await ws.close() @@ -1736,17 +1452,8 @@ async def test_api_get_unit_fallback_to_later_connectors( assert unit == "kW" # Cleanup - for t in list(getattr(cp, "_tasks", [])): - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t - cp_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cp_task - - with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): - await ws.close() + await ws.close() @pytest.mark.timeout(20) @@ -1815,17 +1522,137 @@ async def test_api_get_extra_attr_fallback_to_later_connectors( assert got == expected # Cleanup - for t in list(getattr(cp, "_tasks", [])): - t.cancel() - with contextlib.suppress(asyncio.CancelledError): - await t + cp_task.cancel() + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9008, "cp_id": "CP_1_diag_dt", "cms": "cms_diag_dt"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_diag_dt"]) +@pytest.mark.parametrize("port", [9008]) +async def test_get_diagnostics_and_data_transfer_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Ensure HA services trigger correct OCPP 1.6 calls with expected payload. + + including DataTransfer rejected path and get_diagnostics error/feature branches. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + # Bring charger to ready state (boot + post_connect) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Resolve HA device id (cpid) + cpid = cs.charge_points[cp_id].settings.cpid + + # --- get_diagnostics: happy path with valid URL --- + upload_url = "https://example.test/diag" + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": upload_url}, + blocking=True, + ) + # --- data_transfer: Accepted path --- + vendor_id = "VendorX" + message_id = "Msg42" + payload = '{"hello":"world"}' + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": vendor_id, + "message_id": message_id, + "data": payload, + }, + blocking=True, + ) + + # Give event loop a tick to flush ws calls + await asyncio.sleep(0.05) + + # Assert CP handlers received expected fields (as captured by the fake CP) + assert cp.last_diag_location == upload_url + assert cp.last_data_transfer == (vendor_id, message_id, payload) + + # --- data_transfer: Rejected path (flip cp.accept -> False) --- + cp.accept = False + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": "VendorX", + "message_id": "MsgRejected", + "data": "nope", + }, + blocking=True, + ) + await asyncio.sleep(0.05) + + # --- get_diagnostics: invalid URL triggers vol.MultipleInvalid warning --- + caplog.clear() + caplog.set_level(logging.WARNING) + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "not-a-valid-url"}, + blocking=True, + ) + assert any( + "Failed to parse url" in rec.message for rec in caplog.records + ), "Expected warning for invalid diagnostics upload_url not found" + + # --- get_diagnostics: FW profile NOT supported branch --- + # Simulate that FirmwareManagement profile is not supported by the CP + cpobj = cs.charge_points[cp_id] + original_features = getattr(cpobj, "_attr_supported_features", None) + + # Try to blank out features regardless of type (set/list/tuple/int) + try: + tp = type(original_features) + if isinstance(original_features, set | list | tuple): + new_val = tp() # empty same container type + else: + new_val = 0 # fall back to "no features" + setattr(cpobj, "_attr_supported_features", new_val) + except Exception: + setattr(cpobj, "_attr_supported_features", 0) + + # Valid URL, but without FW support the handler should skip/return gracefully + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "https://example.com/diag2"}, + blocking=True, + ) + + # Restore original features to avoid impacting other tests + if original_features is not None: + setattr(cpobj, "_attr_supported_features", original_features) + + # Cleanup cp_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cp_task - with contextlib.suppress(websockets.exceptions.ConnectionClosedOK): - await ws.close() + await ws.close() class ChargePoint(cpclass): @@ -2006,35 +1833,13 @@ def on_clear_charging_profile(self, **kwargs): return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) @on(Action.trigger_message) - def on_trigger_message(self, requested_message, **kwargs): + def on_trigger_message(self, **kwargs): """Handle trigger message request.""" - if not self.accept: + if self.accept is True: + return call_result.TriggerMessage(TriggerMessageStatus.accepted) + else: return call_result.TriggerMessage(TriggerMessageStatus.rejected) - resp = call_result.TriggerMessage(TriggerMessageStatus.accepted) - - try: - from ocpp.v16.enums import ( - MessageTrigger, - ) - - connector_id = kwargs.get("connector_id") - if requested_message == MessageTrigger.status_notification: - if connector_id in (None, 0): - task = asyncio.create_task(self.send_status_for_all_connectors()) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - else: - task = asyncio.create_task( - self.send_status_notification(connector_id) - ) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - except Exception: - pass - - return resp - @on(Action.update_firmware) def on_update_firmware(self, **kwargs): """Handle update firmware request.""" @@ -2116,31 +1921,44 @@ async def send_start_transaction(self, meter_start: int = 12345): self.active_transactionId = resp.transaction_id assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value - async def send_status_notification(self, connector_id: int = 0): - """Send one StatusNotification for a specific connector.""" - # Connector 0 = CP-level - if connector_id == 0: - status = ChargePointStatus.suspended_ev - elif connector_id == 1: - status = ChargePointStatus.charging - else: - status = ChargePointStatus.available - + async def send_status_notification(self): + """Send a status notification.""" request = call.StatusNotification( - connector_id=connector_id, + connector_id=0, error_code=ChargePointErrorCode.no_error, - status=status, + status=ChargePointStatus.suspended_ev, timestamp=datetime.now(tz=UTC).isoformat(), info="Test info", vendor_id="The Mobility House", vendor_error_code="Test error", ) - await self.call(request) + resp = await self.call(request) + request = call.StatusNotification( + connector_id=1, + error_code=ChargePointErrorCode.no_error, + status=ChargePointStatus.charging, + timestamp=datetime.now(tz=UTC).isoformat(), + info="Test info", + vendor_id="The Mobility House", + vendor_error_code="Test error", + ) + resp = await self.call(request) + request = call.StatusNotification( + connector_id=2, + error_code=ChargePointErrorCode.no_error, + status=ChargePointStatus.available, + timestamp=datetime.now(tz=UTC).isoformat(), + info="Test info", + vendor_id="The Mobility House", + vendor_error_code="Available", + ) + resp = await self.call(request) + + assert resp is not None async def send_status_for_all_connectors(self): - """Send StatusNotification for 0..no_connectors.""" - for cid in range(0, max(1, self.no_connectors) + 1): - await self.send_status_notification(cid) + """Send StatusNotification for all connectors.""" + await self.send_status_notification() async def send_meter_periodic_data(self, connector_id: int = 1): """Send periodic meter data notification for a given connector.""" diff --git a/tests/test_charge_point_v201_multi.py b/tests/test_charge_point_v201_multi.py index 9ca09e7b..f5902950 100644 --- a/tests/test_charge_point_v201_multi.py +++ b/tests/test_charge_point_v201_multi.py @@ -63,12 +63,14 @@ def __init__(self, cp_id, ws): super().__init__(cp_id, ws) self.inventory_done = asyncio.Event() self.last_start_evse_id = None + self._tasks: set[asyncio.Task] = set() @on(Action.get_base_report) async def on_get_base_report(self, request_id: int, report_base: str, **kwargs): """Get base report.""" assert report_base in (ReportBaseEnumType.full_inventory, "FullInventory") - asyncio.create_task(self._send_full_inventory(request_id)) # noqa: RUF006 + task = asyncio.create_task(self._send_full_inventory(request_id)) + self._tasks.add(task) return call_result.GetBaseReport( GenericDeviceModelStatusEnumType.accepted.value ) From 175e610ab6cef939aff5a890c220f1d400467baf Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Sun, 24 Aug 2025 19:13:17 +0000 Subject: [PATCH 12/15] Split stop transaction test in separate tests. --- tests/test_charge_point_v16.py | 130 ++++++++++++++++++++++++++------- 1 file changed, 105 insertions(+), 25 deletions(-) diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index e10b76cd..54a01307 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -380,26 +380,27 @@ async def test_cms_responses_normal_v16( ) as ws: # use a different id for debugging cp = ChargePoint(f"{cp_id}_client", ws) - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_security_event(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(12345), - cp.send_meter_err_phases(), - cp.send_meter_line_voltage(), - cp.send_meter_periodic_data(), - # add delay to allow meter data to be processed - cp.send_stop_transaction(1), - ), - timeout=8, - ) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + await cp.send_boot_notification() + await cp.send_authorize() + await cp.send_heartbeat() + await cp.send_status_notification() + await cp.send_security_event() + await cp.send_firmware_status() + await cp.send_data_transfer() + await cp.send_start_transaction(12345) + await cp.send_meter_err_phases() + await cp.send_meter_line_voltage() + await cp.send_meter_periodic_data() + # add delay to allow meter data to be processed + await cp.send_stop_transaction(1) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() cpid = cs.charge_points[cp_id].settings.cpid @@ -485,6 +486,8 @@ async def test_cms_responses_actions_v16( timeout=10, ) cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() # cpid set in cs after websocket connection @@ -638,6 +641,8 @@ async def test_cms_responses_errors_v16( ) await cs.charge_points[cp_id].stop() cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() # test services when charger is unavailable @@ -650,7 +655,7 @@ async def test_cms_responses_errors_v16( @pytest.mark.timeout(20) # Set timeout for this test @pytest.mark.parametrize( "setup_config_entry", - [{"port": 9007, "cp_id": "CP_1_norm_mc", "cms": "cms_norm", "num_connectors": 2}], + [{"port": 9007, "cp_id": "CP_1_norm_mc", "cms": "cms_norm"}], indirect=True, ) @pytest.mark.parametrize("cp_id", ["CP_1_norm_mc"]) @@ -692,6 +697,8 @@ async def test_cms_responses_normal_multiple_connectors_v16( await cp.send_stop_transaction(1) cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() cpid = cs.charge_points[cp_id].settings.cpid @@ -770,6 +777,8 @@ async def test_clear_profile_v16(hass, socket_enabled, cp_id, port, setup_config assert isinstance(cp.last_clear_profile_kwargs, dict) cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -792,7 +801,7 @@ async def set_report_session_energyreport( ) @pytest.mark.parametrize("cp_id", ["CP_1_stop_paths"]) @pytest.mark.parametrize("port", [9010]) -async def test_stop_transaction_paths_v16( +async def test_stop_transaction_paths_v16_a( hass, socket_enabled, cp_id, port, setup_config_entry ): """Exercise all branches of ocppv16.on_stop_transaction.""" @@ -832,8 +841,26 @@ async def test_stop_transaction_paths_v16( assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9021, "cp_id": "CP_1_stop_paths_a1", "cms": "cms_stop_paths_a1"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_a1"]) +@pytest.mark.parametrize("port", [9021]) +async def test_stop_transaction_paths_v16_a1( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + # # SCENARIO A (variant): charger reports session energy AND last EAIR already kWh. # @@ -862,8 +889,26 @@ async def test_stop_transaction_paths_v16( assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9022, "cp_id": "CP_1_stop_paths_b", "cms": "cms_stop_paths_b"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_b"]) +@pytest.mark.parametrize("port", [9022]) +async def test_stop_transaction_paths_v16_b( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + # # SCENARIO B: charger reports session energy BUT SessionEnergy already set → do not overwrite. # @@ -895,8 +940,26 @@ async def test_stop_transaction_paths_v16( assert round(sess, 3) == 7.777 # unchanged cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9023, "cp_id": "CP_1_stop_paths_c", "cms": "cms_stop_paths_c"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_c"]) +@pytest.mark.parametrize("port", [9023]) +async def test_stop_transaction_paths_v16_c( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + # # SCENARIO C: _charger_reports_session_energy = False -> compute from meter_stop - meter_start # @@ -1048,6 +1111,8 @@ async def test_on_meter_values_paths_v16( finally: cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1163,6 +1228,8 @@ def fake_get_ha_metric(name: str, connector_id: int | None = None): assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1225,6 +1292,8 @@ async def test_api_get_extra_attr_paths( # Clean up cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1273,6 +1342,8 @@ async def test_update_firmware_supported_valid_url_v16( ) cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1315,6 +1386,8 @@ async def test_update_firmware_supported_invalid_url_v16( assert getattr(cp, "last_update_firmware", None) is None cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1357,6 +1430,8 @@ async def test_update_firmware_not_supported_v16( assert getattr(cp, "last_update_firmware", None) is None cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1402,17 +1477,19 @@ async def boom(_req): assert getattr(cp, "last_update_firmware", None) is None cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", - [{"port": 9018, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], + [{"port": 9020, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], indirect=True, ) @pytest.mark.parametrize("cp_id", ["CP_1_unit_fallback"]) -@pytest.mark.parametrize("port", [9018]) +@pytest.mark.parametrize("port", [9020]) async def test_api_get_unit_fallback_to_later_connectors( hass, socket_enabled, cp_id, port, setup_config_entry ): @@ -1453,6 +1530,8 @@ async def test_api_get_unit_fallback_to_later_connectors( # Cleanup cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1523,6 +1602,8 @@ async def test_api_get_extra_attr_fallback_to_later_connectors( # Cleanup cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task await ws.close() @@ -1651,7 +1732,6 @@ async def test_get_diagnostics_and_data_transfer_v16( cp_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cp_task - await ws.close() From d6ed8e900a83a275b60842cb11adcdcde8dd400c Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Wed, 27 Aug 2025 10:40:24 +0000 Subject: [PATCH 13/15] Change entity id to include charger. Add session sensors to connectors. Clear charger-level entities after connector creation. Add num_connector changes to reload logic. Remove not used functions from chargepoint.py. Revert to original config flow due to automatic discovery of number of connectors. Add more tests. --- custom_components/ocpp/button.py | 40 +- custom_components/ocpp/chargepoint.py | 77 +- custom_components/ocpp/config_flow.py | 7 +- custom_components/ocpp/number.py | 34 +- custom_components/ocpp/ocppv16.py | 56 +- custom_components/ocpp/sensor.py | 49 +- custom_components/ocpp/switch.py | 43 +- custom_components/ocpp/translations/de.json | 3 +- custom_components/ocpp/translations/en.json | 3 +- custom_components/ocpp/translations/es.json | 3 +- .../ocpp/translations/i-default.json | 3 +- custom_components/ocpp/translations/nl.json | 3 +- tests/test_api_paths.py | 413 +++++++ tests/test_charge_point_core.py | 330 +++++ tests/test_charge_point_v16.py | 1093 ++++++++++++++++- tests/test_config_flow.py | 6 +- tests/test_sensor.py | 60 +- 17 files changed, 2053 insertions(+), 170 deletions(-) create mode 100644 tests/test_api_paths.py create mode 100644 tests/test_charge_point_core.py diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index 4a5f1ff1..f3e53cbc 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -11,10 +11,17 @@ ButtonEntity, ButtonEntityDescription, ) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo, EntityCategory from .api import CentralSystem -from .const import CONF_CPID, CONF_CPIDS, CONF_NUM_CONNECTORS, DOMAIN +from .const import ( + CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DOMAIN, +) from .enums import HAChargerServices @@ -50,12 +57,32 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the Button platform.""" central_system: CentralSystem = hass.data[DOMAIN][entry.entry_id] entities: list[ChargePointButton] = [] + ent_reg = er.async_get(hass) for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + if num_connectors > 1: + for desc in BUTTONS: + if not desc.per_connector: + continue + uid_flat = ".".join([BUTTON_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(BUTTON_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) for desc in BUTTONS: if desc.per_connector: @@ -97,7 +124,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointButton(ButtonEntity): """Individual button for charge point.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppButtonDescription def __init__( @@ -122,7 +149,7 @@ def __init__( if self.connector_id: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"Connector {self.connector_id}", + name=f"{cpid} Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: @@ -130,6 +157,11 @@ def __init__( identifiers={(DOMAIN, cpid)}, name=cpid, ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{BUTTON_DOMAIN}.{object_id}" @property def available(self) -> bool: diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index abd0f74a..f5b3d05f 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -26,7 +26,6 @@ from ocpp.v16 import call as callv16 from ocpp.v16 import call_result as call_resultv16 from ocpp.v16.enums import ( - UnitOfMeasure, AuthorizationStatus, Measurand, Phase, @@ -53,10 +52,12 @@ CONF_DEFAULT_AUTH_STATUS, CONF_ID_TAG, CONF_MONITORED_VARIABLES, + CONF_NUM_CONNECTORS, CONF_CPIDS, CONFIG, DATA_UPDATED, DEFAULT_ENERGY_UNIT, + DEFAULT_NUM_CONNECTORS, DEFAULT_POWER_UNIT, DEFAULT_MEASURAND, DOMAIN, @@ -276,15 +277,22 @@ def __init__( # Init standard metrics for connector 0 self._metrics[(0, cdet.identifier.value)].value = id - self._metrics[(0, csess.session_time.value)].unit = TIME_MINUTES - self._metrics[(0, csess.session_energy.value)].unit = UnitOfMeasure.kwh.value - self._metrics[(0, csess.meter_start.value)].unit = UnitOfMeasure.kwh.value self._metrics[(0, cstat.reconnects.value)].value = 0 self._attr_supported_features = prof.NONE alphabet = string.ascii_uppercase + string.digits self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) - self.num_connectors: int = 1 + self.num_connectors: int = DEFAULT_NUM_CONNECTORS + + def _init_connector_slots(self, conn_id: int) -> None: + """Ensure connector-scoped metrics exist and carry the right units.""" + _ = self._metrics[(conn_id, cstat.status_connector.value)] + _ = self._metrics[(conn_id, cstat.error_code_connector.value)] + _ = self._metrics[(conn_id, csess.transaction_id.value)] + + self._metrics[(conn_id, csess.session_time.value)].unit = TIME_MINUTES + self._metrics[(conn_id, csess.session_energy.value)].unit = HA_ENERGY_UNIT + self._metrics[(conn_id, csess.meter_start.value)].unit = HA_ENERGY_UNIT async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" @@ -320,21 +328,20 @@ async def post_connect(self): num_connectors: int = await self.get_number_of_connectors() self.num_connectors = num_connectors for conn in range(1, self.num_connectors + 1): - _ = self._metrics[(conn, cstat.status_connector.value)] - _ = self._metrics[(conn, cstat.error_code_connector.value)] - _ = self._metrics[(conn, csess.session_energy.value)] - _ = self._metrics[(conn, csess.meter_start.value)] - _ = self._metrics[(conn, csess.transaction_id.value)] - self._metrics[(0, cdet.connectors.value)].value = num_connectors + self._init_connector_slots(conn) + self._metrics[(0, cdet.connectors.value)].value = self.num_connectors await self.get_heartbeat_interval() accepted_measurands: str = await self.get_supported_measurands() updated_entry = {**self.entry.data} for i in range(len(updated_entry[CONF_CPIDS])): if self.id in updated_entry[CONF_CPIDS][i]: - updated_entry[CONF_CPIDS][i][self.id][CONF_MONITORED_VARIABLES] = ( - accepted_measurands - ) + s = updated_entry[CONF_CPIDS][i][self.id] + if s.get(CONF_MONITORED_VARIABLES) != accepted_measurands or s.get( + CONF_NUM_CONNECTORS + ) != int(self.num_connectors): + s[CONF_MONITORED_VARIABLES] = accepted_measurands + s[CONF_NUM_CONNECTORS] = int(self.num_connectors) break # if an entry differs this will unload/reload and stop/restart the central system/websocket self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) @@ -342,7 +349,7 @@ async def post_connect(self): await self.set_standard_configuration() self.post_connect_success = True - _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") + _LOGGER.debug("'%s' post connection setup completed successfully", self.id) # nice to have, but not needed for integration to function # and can cause issues with some chargers @@ -822,22 +829,6 @@ def supported_features(self) -> int: """Flag of Ocpp features that are supported.""" return self._attr_supported_features - def get_metric(self, measurand: str, connector_id: int = 0): - """Return last known value for given measurand.""" - val = self._metrics[(connector_id, measurand)].value - if val is not None: - return val - - if connector_id and connector_id > 0: - if measurand == cstat.status_connector.value: - agg = self._metrics[(0, cstat.status_connector.value)] - return agg.extra_attr.get(connector_id, agg.value) - if measurand == cstat.error_code_connector.value: - agg = self._metrics[(0, cstat.error_code_connector.value)] - return agg.extra_attr.get(connector_id, agg.value) - - return None - def get_ha_metric(self, measurand: str, connector_id: int | None = None): """Return last known value in HA for given measurand, or None if not available.""" base = self.settings.cpid.lower() @@ -855,30 +846,6 @@ def get_ha_metric(self, measurand: str, connector_id: int | None = None): return st.state return None - def get_extra_attr(self, measurand: str, connector_id: int = 0): - """Return extra attributes for given measurand (per connector).""" - attrs = self._metrics[(connector_id, measurand)].extra_attr - if attrs: - return attrs - - if connector_id and connector_id > 0: - if measurand in ( - cstat.status_connector.value, - cstat.error_code_connector.value, - ): - agg = self._metrics[(0, measurand)] - if connector_id in agg.extra_attr: - return {connector_id: agg.extra_attr[connector_id]} - return {} - - def get_unit(self, measurand: str, connector_id: int = 0): - """Return unit of given measurand.""" - return self._metrics[(connector_id, measurand)].unit - - def get_ha_unit(self, measurand: str, connector_id: int = 0): - """Return HA unit of given measurand.""" - return self._metrics[(connector_id, measurand)].ha_unit - async def notify_ha(self, msg: str, title: str = "Ocpp integration"): """Notify user via HA web frontend.""" await self.hass.services.async_call( diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 255fb4b6..67e6c2c5 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -20,7 +20,6 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, - CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -40,7 +39,6 @@ DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, - DEFAULT_NUM_CONNECTORS, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -93,9 +91,6 @@ vol.Required( CONF_FORCE_SMART_CHARGING, default=DEFAULT_FORCE_SMART_CHARGING ): bool, - vol.Required(CONF_NUM_CONNECTORS, default=DEFAULT_NUM_CONNECTORS): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), } ) @@ -111,7 +106,7 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" VERSION = 2 - MINOR_VERSION = 1 + MINOR_VERSION = 0 CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH def __init__(self): diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 9f13bac6..86d2bd94 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -13,6 +13,7 @@ ) from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -24,6 +25,7 @@ CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_MAX_CURRENT, + DEFAULT_NUM_CONNECTORS, DOMAIN, ICON, ) @@ -57,10 +59,31 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the number platform.""" central_system = hass.data[DOMAIN][entry.entry_id] entities: list[ChargePointNumber] = [] + ent_reg = er.async_get(hass) + for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + if num_connectors > 1: + for desc in NUMBERS: + uid_flat = ".".join([NUMBER_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(NUMBER_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + for desc in NUMBERS: if desc.key == "maximum_current": max_cur = float( @@ -120,7 +143,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointNumber(RestoreNumber, NumberEntity): """Individual slider for setting charge rate.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppNumberDescription def __init__( @@ -150,7 +173,7 @@ def __init__( if self.connector_id: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"Connector {self.connector_id}", + name=f"{cpid} Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: @@ -158,6 +181,11 @@ def __init__( identifiers={(DOMAIN, cpid)}, name=cpid, ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{NUMBER_DOMAIN}.{object_id}" self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py index f0076ea3..f1076723 100644 --- a/custom_components/ocpp/ocppv16.py +++ b/custom_components/ocpp/ocppv16.py @@ -111,12 +111,44 @@ def _profile_ids_for_connector(self, conn_id: int) -> tuple[int, int]: async def get_number_of_connectors(self) -> int: """Return number of connectors on this charger.""" - val = await self.get_configuration(ckey.number_of_connectors.value) + resp = None + try: - n = int(val) - except (TypeError, ValueError): - n = 1 # fallback - return max(1, n) + req = call.GetConfiguration(key=["NumberOfConnectors"]) + resp = await self.call(req) + except Exception: + resp = None + + cfg = None + if resp is not None: + cfg = getattr(resp, "configuration_key", None) + + if ( + cfg is None + and isinstance(resp, list | tuple) + and len(resp) >= 3 + and isinstance(resp[2], dict) + ): + cfg = resp[2].get("configurationKey") or resp[2].get( + "configuration_key" + ) + + if cfg: + for kv in cfg: + k = getattr(kv, "key", None) + v = getattr(kv, "value", None) + if k is None and isinstance(kv, dict): + k = kv.get("key") + v = kv.get("value") + if k == "NumberOfConnectors" and v not in (None, ""): + try: + n = int(str(v).strip()) + if n > 0: + return n + except (ValueError, TypeError): + pass + + return 1 async def get_heartbeat_interval(self): """Retrieve heartbeat interval from the charger and store it.""" @@ -744,7 +776,9 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): self._metrics[(connector_id, csess.session_time.value)].value = round( (int(time.time()) - tx_start) / 60 ) - self._metrics[(connector_id, csess.session_time.value)].unit = "min" + self._metrics[ + (connector_id, csess.session_time.value) + ].unit = UnitOfTime.MINUTES # Update Energy.Session ONLY from EAIR in this message if txId exists and matches if tx_has_id and transaction_matches: @@ -793,14 +827,12 @@ def on_status_notification(self, connector_id, error_code, status, **kwargs): """Handle a status notification.""" if connector_id == 0 or connector_id is None: - self._metrics[0][cstat.status.value].value = status - self._metrics[0][cstat.error_code.value].value = error_code + self._metrics[(0, cstat.status.value)].value = status + self._metrics[(0, cstat.error_code.value)].value = error_code else: + self._metrics[(connector_id, cstat.status_connector.value)].value = status self._metrics[ - (connector_id or 0, cstat.status_connector.value) - ].value = status - self._metrics[ - (connector_id or 0, cstat.error_code_connector.value) + (connector_id, cstat.error_code_connector.value) ].value = error_code if status in ( diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index a373af85..5155855c 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -14,6 +14,7 @@ ) from homeassistant.const import CONF_MONITORED_VARIABLES from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory @@ -24,11 +25,12 @@ CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_CLASS_UNITS_HA, + DEFAULT_NUM_CONNECTORS, DOMAIN, ICON, Measurand, ) -from .enums import HAChargerDetails, HAChargerStatuses +from .enums import HAChargerDetails, HAChargerSession, HAChargerStatuses @dataclass @@ -42,11 +44,24 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the sensor platform.""" central_system = hass.data[DOMAIN][entry.entry_id] entities: list[ChargePointMetric] = [] + ent_reg = er.async_get(hass) + # setup all chargers added to config for charger in entry.data[CONF_CPIDS]: cp_id_settings = list(charger.values())[0] cpid = cp_id_settings[CONF_CPID] - num_connectors = int(cp_id_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break configured = [ m.strip() @@ -76,6 +91,10 @@ async def async_setup_entry(hass, entry, async_add_devices): CONNECTOR_ONLY = measurands + [ HAChargerStatuses.status_connector.value, HAChargerStatuses.error_code_connector.value, + HAChargerSession.transaction_id.value, + HAChargerSession.session_time.value, + HAChargerSession.session_energy.value, + HAChargerSession.meter_start.value, ] def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: @@ -87,6 +106,22 @@ def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: entity_category=EntityCategory.DIAGNOSTIC if cat_diag else None, ) + def _uid(cpid: str, key: str, connector_id: int | None) -> str: + """Mirror ChargePointMetric unique_id construction.""" + key = key.lower() + parts = [DOMAIN, cpid, key, SENSOR_DOMAIN] + if connector_id is not None: + parts.insert(2, f"conn{connector_id}") + return ".".join(parts) + + if num_connectors > 1: + for metric in CONNECTOR_ONLY: + uid = _uid(cpid, metric, connector_id=None) + stale_eid = ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, uid) + if stale_eid: + # Remove the old entity so it doesn't linger as 'unavailable' + ent_reg.async_remove(stale_eid) + # Root/charger-entities for metric in CHARGER_ONLY: entities.append( @@ -143,7 +178,7 @@ def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: class ChargePointMetric(RestoreSensor, SensorEntity): """Individual sensor for charge point metrics.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppSensorDescription def __init__( @@ -171,7 +206,7 @@ def __init__( if self.connector_id is not None: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"Connector {self.connector_id}", + name=f"{cpid} Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: @@ -180,6 +215,11 @@ def __init__( name=cpid, ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{SENSOR_DOMAIN}.{object_id}" self._attr_icon = ICON self._attr_native_unit_of_measurement = None @@ -220,6 +260,7 @@ def state_class(self): ] or self.metric in [ HAChargerStatuses.latency_ping.value, HAChargerStatuses.latency_pong.value, + HAChargerSession.session_time.value, ]: state_class = SensorStateClass.MEASUREMENT diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index 13afd2f4..ba8054aa 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -10,11 +10,19 @@ SwitchEntity, SwitchEntityDescription, ) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem -from .const import CONF_CPID, CONF_CPIDS, CONF_NUM_CONNECTORS, DOMAIN, ICON +from .const import ( + CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DOMAIN, + ICON, +) from .enums import HAChargerServices, HAChargerStatuses @@ -66,13 +74,35 @@ async def async_setup_entry(hass, entry, async_add_devices): """Configure the switch platform.""" central_system = hass.data[DOMAIN][entry.entry_id] entities: list[ChargePointSwitch] = [] + ent_reg = er.async_get(hass) for charger in entry.data[CONF_CPIDS]: cp_settings = list(charger.values())[0] cpid = cp_settings[CONF_CPID] - num_connectors = int(cp_settings.get(CONF_NUM_CONNECTORS, 1) or 1) + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break flatten_single = num_connectors == 1 + if num_connectors > 1: + for desc in SWITCHES: + if not desc.per_connector: + continue + # unique_id used when flattened: "..." + uid_flat = ".".join([SWITCH_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + for desc in SWITCHES: if desc.per_connector: for conn_id in range(1, num_connectors + 1): @@ -102,7 +132,7 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointSwitch(SwitchEntity): """Individual switch for charge point.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppSwitchDescription def __init__( @@ -129,7 +159,7 @@ def __init__( if self.connector_id and not self._flatten_single: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, - name=f"Connector {self.connector_id}", + name=f"{cpid} Connector {self.connector_id}", via_device=(DOMAIN, cpid), ) else: @@ -137,6 +167,11 @@ def __init__( identifiers={(DOMAIN, cpid)}, name=cpid, ) + if self.connector_id is not None and not flatten_single: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{SWITCH_DOMAIN}.{object_id}" @property def available(self) -> bool: diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index c60e0861..8d87f7dd 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -27,8 +27,7 @@ "idle_interval": "Abtastintervall Leerlauf (Sekunden)", "skip_schema_validation": "Überspringe OCPP-Schemavalidierung", "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil", - "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte", - "num_connectors": "Anzahl der Anschlüsse pro Ladestation" + "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte" } }, "measurands": { diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 54534c71..36a8562d 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -27,8 +27,7 @@ "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", "skip_schema_validation": "Skip OCPP schema validation", - "force_smart_charging": "Force Smart Charging feature profile", - "num_connectors": "Number of connectors per charger" + "force_smart_charging": "Force Smart Charging feature profile" } }, "measurands": { diff --git a/custom_components/ocpp/translations/es.json b/custom_components/ocpp/translations/es.json index 21493511..f40ebdc0 100644 --- a/custom_components/ocpp/translations/es.json +++ b/custom_components/ocpp/translations/es.json @@ -22,8 +22,7 @@ "meter_interval": "Intervalo de mediciones (segundos)", "idle_interval": "Intervalo de muestreo del cargador en reposo (segundos)", "skip_schema_validation": "Omitir validación esquema OCPP", - "force_smart_charging": "Forzar perfil de función Smart Charging", - "num_connectors": "Número de conectores por punto de carga" + "force_smart_charging": "Forzar perfil de función Smart Charging" } }, "measurands": { diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index 15ed4777..e01afa80 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -27,8 +27,7 @@ "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", "skip_schema_validation": "Skip OCPP schema validation", - "force_smart_charging": "Force Smart Charging feature profile", - "num_connectors": "Number of connectors per charger" + "force_smart_charging": "Force Smart Charging feature profile" } }, "measurands": { diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index e1d8da16..ce4cc92c 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -22,8 +22,7 @@ "max_current": "Maximale laadstroom", "meter_interval": "Meetinterval (secondes)", "skip_schema_validation": "Skip OCPP schema validation", - "force_smart_charging": "Functieprofiel Smart Charging forceren", - "num_connectors": "Aantal connectoren per laadpunt" + "force_smart_charging": "Functieprofiel Smart Charging forceren" } }, "measurands": { diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py new file mode 100644 index 00000000..3e32d4da --- /dev/null +++ b/tests/test_api_paths.py @@ -0,0 +1,413 @@ +"""Test exceptions paths in api.py.""" + +import contextlib +from types import SimpleNamespace + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from websockets import NegotiationError + +from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.enums import ( + HAChargerServices as csvcs, + HAChargerStatuses as cstat, +) +from custom_components.ocpp.chargepoint import Metric as M +from custom_components.ocpp.chargepoint import SetVariableResult + +from .test_charge_point_v16 import MOCK_CONFIG_DATA + + +class DummyCP: + """Minimal fake ChargePoint for exercising CentralSystem API paths.""" + + def __init__(self, *, status=STATE_OK, num_connectors=3, supported_features=0b101): + """Initialize.""" + self.status = status + self.num_connectors = num_connectors + self.supported_features = supported_features + self._metrics = {} + # service call sinks + self.calls = [] + + # ---- services the API calls into ---- + async def set_charge_rate(self, **kw): + """Set charge rate.""" + self.calls.append(("set_charge_rate", kw)) + return True + + async def set_availability(self, state, connector_id=None): + """Set availability.""" + self.calls.append( + ("set_availability", {"state": state, "connector_id": connector_id}) + ) + return True + + async def start_transaction(self, connector_id=None): + """Start transaction.""" + self.calls.append(("start_transaction", {"connector_id": connector_id})) + return True + + async def stop_transaction(self): + """Stop transaction.""" + self.calls.append(("stop_transaction", {})) + return True + + async def reset(self): + """Reset.""" + self.calls.append(("reset", {})) + return True + + async def unlock(self, connector_id=None): + """Unlock.""" + self.calls.append(("unlock", {"connector_id": connector_id})) + return True + + async def trigger_custom_message(self, requested_message): + """Trigger custom message.""" + self.calls.append( + ("trigger_custom_message", {"requested_message": requested_message}) + ) + return True + + async def clear_profile(self): + """Clear profile.""" + self.calls.append(("clear_profile", {})) + return True + + async def update_firmware(self, url, delay): + """Update firmware.""" + self.calls.append(("update_firmware", {"url": url, "delay": delay})) + return True + + async def get_diagnostics(self, url): + """Get diagnostics.""" + self.calls.append(("get_diagnostics", {"url": url})) + return True + + async def data_transfer(self, vendor, message, data): + """Handle data transfer.""" + self.calls.append( + ("data_transfer", {"vendor": vendor, "message": message, "data": data}) + ) + return True + + async def configure(self, key, value): + """Configure.""" + self.calls.append(("configure", {"key": key, "value": value})) + # alternate responses by key to cover both branches + return ( + SetVariableResult.reboot_required + if key == "needs_reboot" + else SetVariableResult.accepted + ) + + async def get_configuration(self, key): + """Get configuration.""" + self.calls.append(("get_configuration", {"key": key})) + return f"value-for:{key}" + + +def _install_dummy_cp( + cs: CentralSystem, *, cpid="test_cpid", cp_id="CP_DUMMY", **kw +) -> DummyCP: + cp = DummyCP(**kw) + cs.charge_points[cp_id] = cp + cs.cpids[cpid] = cp_id + return cp + + +@pytest.mark.asyncio +async def test_select_subprotocol_variants(hass): + """Test select subprotocol variants.""" + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + + # client offers none -> None + assert cs.select_subprotocol(None, []) is None + + # overlap -> pick shared + shared = cs.subprotocols[0] + assert cs.select_subprotocol(None, [shared, "other"]) == shared + + with pytest.raises(NegotiationError): + cs.select_subprotocol(None, ["nope1", "nope2"]) + + +@pytest.mark.asyncio +async def test_get_metric_all_fallbacks(hass): + """Test all fallbacks in get_metric.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs, num_connectors=3) + + meas = "Voltage" + # 1) explicit connector + cp._metrics[(2, meas)] = M(230.0, "V") + assert cs.get_metric("test_cpid", meas, connector_id=2) == 230.0 + + # 2) charger level (0) + cp._metrics[(0, meas)] = M(231.0, "V") + assert cs.get_metric("test_cpid", meas) == 231.0 + + # 3) flat legacy key + cp._metrics[meas] = M(232.0, "V") + # delete (0,measurand) so flat is used + cp._metrics.pop((0, meas), None) + assert cs.get_metric("test_cpid", meas) == 232.0 + + # 4) fallback connector 1 + cp._metrics.pop(meas, None) + cp._metrics[(1, meas)] = M(233.0, "V") + assert cs.get_metric("test_cpid", meas) == 233.0 + + # 5) scan 2..N + # del_metric: remove via (0, meas) and flat fallback + + # Make sure earlier fallbacks don't win + for k in [(0, meas), (1, meas), (2, meas)]: + if k in cp._metrics: + cp._metrics[k].value = None + + # Also remove/neutralize the legacy flat key if present + with contextlib.suppress(KeyError): + cp._metrics.pop(meas) + + # Now seed the value only on connector 3 + cp._metrics[(3, meas)] = cp._metrics.get((3, meas), M(None, None)) + cp._metrics[(3, meas)].value = 234.0 + cp._metrics[(3, meas)].unit = "V" + + # Ensure the CS thinks there are at least 3 connectors + srv = cs.charge_points[cs.cpids["test_cpid"]] + srv.num_connectors = max(getattr(srv, "num_connectors", 1) or 1, 3) + + assert cs.get_metric("test_cpid", meas) == 234.0 + + +@pytest.mark.asyncio +async def test_get_units_and_attrs_fallbacks(hass): + """Test fallbacks in get_units and get_extra_attrs.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs, num_connectors=3) + + meas = "Power.Active.Import" + # units via (3, meas) + cp._metrics[(3, meas)] = M(10.0, "W") + cp._metrics[(3, meas)].__dict__["_ha_unit"] = "W" + cp._metrics[(3, meas)].extra_attr = {"ctx": "Sample.Periodic"} + + # ensure earlier probes are empty/missing so it scans to c>=2 + assert cs.get_unit("test_cpid", meas) == "W" + assert cs.get_ha_unit("test_cpid", meas) == "W" + assert cs.get_extra_attr("test_cpid", meas) == {"ctx": "Sample.Periodic"} + + # explicit connector wins + cp._metrics[(1, meas)] = M(11.0, "kW") + cp._metrics[(3, meas)].__dict__["_ha_unit"] = "kW" + cp._metrics[(1, meas)].extra_attr = {"src": "conn1"} + assert cs.get_unit("test_cpid", meas, connector_id=1) == "kW" + assert cs.get_ha_unit("test_cpid", meas, connector_id=1) == "kW" + assert cs.get_extra_attr("test_cpid", meas, connector_id=1) == {"src": "conn1"} + + +@pytest.mark.asyncio +async def test_get_available_paths(hass): + """Test paths in get_available.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + # charger unavailable by status for connector 0 + cp = _install_dummy_cp(cs, status=STATE_UNAVAILABLE) + assert cs.get_available("test_cpid", connector_id=0) is False + + # specific connector via per-connector metric + meas = cstat.status_connector.value + cp._metrics[(1, meas)] = M("Charging", None) + assert cs.get_available("test_cpid", connector_id=1) is True + + # via flat extra_attr aggregator + cp2 = _install_dummy_cp(cs, cpid="agg", cp_id="CP_AGG", status=STATE_OK) + flat = M("Available", None) + flat.extra_attr = {2: "Finishing"} + cp2._metrics[meas] = flat + assert cs.get_available("agg", connector_id=2) is True + + # fall back to charger status if no info + assert cs.get_available("agg", connector_id=3) is True # charger STATE_OK + + +@pytest.mark.asyncio +async def test_supported_features_and_device_info(hass): + """Test supported features and device info.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs) + assert cs.get_supported_features("test_cpid") == cp.supported_features + assert cs.get_supported_features("unknown") == 0 + assert cs.device_info() == {"identifiers": {(DOMAIN, cs.id)}} + + +@pytest.mark.asyncio +async def test_setters_when_missing_and_present(hass): + """Test set_charger_state various conditions.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + # missing -> False + assert await cs.set_max_charge_rate_amps("missing", 10.0) is False + + # present -> routes and returns True + cp = _install_dummy_cp(cs) + assert await cs.set_max_charge_rate_amps("test_cpid", 16.0, connector_id=2) is True + assert ("set_charge_rate", {"limit_amps": 16.0, "conn_id": 2}) in cp.calls + + # set_charger_state branches + await cs.set_charger_state( + "test_cpid", csvcs.service_availability.name, True, connector_id=1 + ) + await cs.set_charger_state( + "test_cpid", csvcs.service_charge_start.name, connector_id=2 + ) + await cs.set_charger_state("test_cpid", csvcs.service_charge_stop.name) + await cs.set_charger_state("test_cpid", csvcs.service_reset.name) + await cs.set_charger_state("test_cpid", csvcs.service_unlock.name, connector_id=3) + kinds = [ + k + for k, _ in cp.calls + if k + in { + "set_availability", + "start_transaction", + "stop_transaction", + "reset", + "unlock", + } + ] + assert set(kinds) == { + "set_availability", + "start_transaction", + "stop_transaction", + "reset", + "unlock", + } + + +@pytest.mark.asyncio +async def test_check_charger_available_decorator_and_services(hass): + """Test the check_charger_available and services when cp not available.""" + + # 1) CentralSystem without websocket + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + + # 2) Register two CP: one OK and one UNAVAILABLE + _install_dummy_cp(cs, cpid="ok", cp_id="CP_OK", status=STATE_OK) + _install_dummy_cp(cs, cpid="bad", cp_id="CP_BAD", status=STATE_UNAVAILABLE) + + # 3) Minimal hass.data-structure (some handlers read config) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault("config", {}) + + # 4) Unavailable -> should throw HomeAssistantError + with pytest.raises(HomeAssistantError): + await cs.handle_clear_profile( + SimpleNamespace(data={"devid": "bad"}), + ) + + # 5) Available -> handlers reach CP methods without exception + await cs.handle_trigger_custom_message( + SimpleNamespace( + data={"devid": "ok", "requested_message": "StatusNotification"} + ), + ) + await cs.handle_clear_profile( + SimpleNamespace(data={"devid": "ok"}), + ) + await cs.handle_update_firmware( + SimpleNamespace( + data={"devid": "ok", "firmware_url": "http://x/fw.bin", "delay_hours": 2} + ), + ) + await cs.handle_get_diagnostics( + SimpleNamespace(data={"devid": "ok", "upload_url": "http://u/diag"}), + ) + await cs.handle_data_transfer( + SimpleNamespace( + data={"devid": "ok", "vendor_id": "V", "message_id": "M", "data": "D"} + ), + ) + + # 6) set_charge_rate – test all three variants + await cs.handle_set_charge_rate( + SimpleNamespace( + data={"devid": "ok", "custom_profile": "{'foo': 1, 'bar': 'x'}"} + ), + ) + await cs.handle_set_charge_rate( + SimpleNamespace(data={"devid": "ok", "limit_watts": 3500, "conn_id": 1}), + ) + await cs.handle_set_charge_rate( + SimpleNamespace(data={"devid": "ok", "limit_amps": 10.5}), + ) + + # 7) configure + get_configuration – check return format + resp = await cs.handle_configure( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "needs_reboot", "value": "1"}), + ) + assert resp == {"reboot_required": True} + + resp = await cs.handle_configure( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "just_apply", "value": "x"}), + ) + assert resp == {"reboot_required": False} + + resp = await cs.handle_get_configuration( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "Foo"}), + ) + assert resp == {"value": "value-for:Foo"} + + +def test_del_metric_variants(hass): + """Test the del_metric function.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cpid = "test_cpid" + cp = _install_dummy_cp(cs, cpid=cpid, num_connectors=3) + + # --- Case A: connector-scoped metric exists -> set to None + meas_conn = "Voltage" + cp._metrics[(1, meas_conn)] = M(230.0, "V") + # sanity + assert cs.get_metric(cpid, meas_conn, connector_id=1) == 230.0 + + cs.del_metric(cpid, meas_conn, connector_id=1) + assert cs.get_metric(cpid, meas_conn, connector_id=1) is None + + # --- Case B: (0, meas) missing => fallback to legacy flat key when conn==0 + meas_flat = "Power.Active.Import" + if (0, meas_flat) in cp._metrics: + del cp._metrics[(0, meas_flat)] + cp._metrics[meas_flat] = M(123.0, "W") + assert cs.get_metric(cpid, meas_flat) == 123.0 + + cs.del_metric(cpid, meas_flat, connector_id=0) + assert cs.get_metric(cpid, meas_flat) is None + assert cp._metrics[meas_flat].value is None + + # --- Case C: unknown cpid -> returns None, no exception + assert cs.del_metric("unknown_cpid", "Voltage") is None diff --git a/tests/test_charge_point_core.py b/tests/test_charge_point_core.py new file mode 100644 index 00000000..951fc39b --- /dev/null +++ b/tests/test_charge_point_core.py @@ -0,0 +1,330 @@ +"""Test various chargepoint core functions/exceptions.""" + +import asyncio +import math +from types import SimpleNamespace +from unittest.mock import patch +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from websockets.protocol import State + +from homeassistant.setup import async_setup_component + +from custom_components.ocpp.chargepoint import ( + ChargePoint, + OcppVersion, + Metric, + _ConnectorAwareMetrics as CAM, + MeasurandValue, +) +from custom_components.ocpp.const import ( + DOMAIN, + CentralSystemSettings, + ChargerSystemSettings, + DEFAULT_ENERGY_UNIT, + DEFAULT_POWER_UNIT, + HA_ENERGY_UNIT, + HA_POWER_UNIT, +) +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + HAChargerSession as csess, +) +from ocpp.messages import CallError +from ocpp.charge_point import ChargePoint as LibCP +from ocpp.exceptions import NotImplementedError as OcppNotImplementedError + +from .const import CONF_SSL_CERTFILE_PATH, CONF_SSL_KEYFILE_PATH + + +# ----------------------------- +# Helpers to build a CP instance +# ----------------------------- +def _mk_entry_data(): + return { + "host": "127.0.0.1", + "port": 0, + "csid": "cs", + "cpids": [{"CP_A": {"cpid": "test_cpid"}}], + "subprotocols": ["ocpp1.6"], + "websocket_close_timeout": 5, + "ssl": False, + # required ping fields: + "websocket_ping_interval": 0.0, + "websocket_ping_timeout": 0.01, + "websocket_ping_tries": 0, + "ssl_certfile_path": CONF_SSL_CERTFILE_PATH, + "ssl_keyfile_path": CONF_SSL_KEYFILE_PATH, + } + + +def _mk_cp(hass, *, version=OcppVersion.V201): + entry = MockConfigEntry(domain=DOMAIN, data=_mk_entry_data()) + centr = CentralSystemSettings(**entry.data) + chg = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + # Minimal fake connection + conn = SimpleNamespace(state=State.CLOSED, close=lambda: asyncio.sleep(0)) + cp = ChargePoint("CP_A", conn, version, hass, entry, centr, chg) + cp._metrics[(0, csess.meter_start.value)].value = None + return cp + + +def test_connector_aware_metrics_core(): + """Test _ConnectorAwareMetrics API.""" + m = CAM() + + # set/get flat + m["Voltage"] = Metric(230.0, "V") + assert isinstance(m["Voltage"], Metric) + assert m["Voltage"].value == 230.0 + + # set/get per connector + m[(2, "Voltage")] = Metric(231.0, "V") + assert m[(2, "Voltage")].value == 231.0 + + # connector mapping view + assert isinstance(m[2], dict) + assert "Voltage" in m[2] + + # __contains__ for tuple/int/str keys + assert "Voltage" in m + assert (2, "Voltage") in m + assert 2 in m + + # type checks + with pytest.raises(TypeError): + m[(3, "X")] = ("not", "metric") + with pytest.raises(TypeError): + m[3] = Metric(1, "A") + + +@pytest.mark.asyncio +async def test_get_specific_response_raises_callerror(hass, monkeypatch): + """Test _get_specific_response “unsilence” of CallError.""" + cp = _mk_cp(hass) + + async def fake_super(self, unique_id, timeout): + # Simulate that the lib returns a CallError object + # (which is normally "silenced" in the lib). + return CallError(unique_id, "SomeError", "details") + + # Patch the lib's _get_specific_response so that our wrapper is hit + monkeypatch.setattr(LibCP, "_get_specific_response", fake_super, raising=True) + + with pytest.raises(Exception) as ei: + await cp._get_specific_response("uid-1", 1) + assert "SomeError" in str(ei.value) + + +@pytest.mark.asyncio +async def test_async_update_device_info_updates_metrics_and_registry(hass): + """Test async_update_device_info.""" + await async_setup_component(hass, "device_tracker", {}) + + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="e1", title="e1") + entry.add_to_hass(hass) + await hass.async_block_till_done() + + central = CentralSystemSettings( + csid="cs", + host="127.0.0.1", + port=9999, + subprotocols=["ocpp1.6"], + ssl=False, + ssl_certfile_path="", + ssl_keyfile_path="", + websocket_close_timeout=1, + websocket_ping_interval=0.1, + websocket_ping_timeout=0.1, + websocket_ping_tries=0, + ) + charger = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + + class DummyConn: + """Dummy connection.""" + + state = None + + cp = ChargePoint( + id="CP_ID", + connection=DummyConn(), + version=OcppVersion.V201, + hass=hass, + entry=entry, + central=central, + charger=charger, + ) + + await cp.async_update_device_info( + serial="SER123", + vendor="Acme", + model="Model X", + firmware_version="1.2.3", + ) + + assert cp._metrics[(0, cdet.model.value)].value == "Model X" + assert cp._metrics[(0, cdet.vendor.value)].value == "Acme" + assert cp._metrics[(0, cdet.firmware_version.value)].value == "1.2.3" + assert cp._metrics[(0, cdet.serial.value)].value == "SER123" + + from homeassistant.helpers import device_registry + + dr = device_registry.async_get(hass) + dev = dr.async_get_device({(DOMAIN, "CP_ID"), (DOMAIN, "test_cpid")}) + assert dev is not None + assert dev.manufacturer == "Acme" + assert dev.model == "Model X" + assert dev.sw_version == "1.2.3" + + +def test_get_ha_metric_prefers_exact_entity(hass): + """Test get_ha_metric lookup logic.""" + cp = _mk_cp(hass) + # Seed states + hass.states.async_set("sensor.test_cpid_voltage", "n/a") + hass.states.async_set("sensor.test_cpid_connector_1_voltage", "229.5") + + # With connector_id=1 we should resolve the child entity + assert cp.get_ha_metric("Voltage", connector_id=1) == "229.5" + # With connector_id=None -> root entity + assert cp.get_ha_metric("Voltage", connector_id=None) == "n/a" + + +def _mv(measurand, value, phase=None, unit=None, context=None, location=None): + return MeasurandValue(measurand, value, phase, unit, context, location) + + +def test_process_phases_voltage_and_current_branches(hass): + """Test process_phases: l-l → l-n conversion, summation and unit normalization.""" + cp = _mk_cp(hass) + + # Voltage line-to-line values (should average and divide by sqrt(3)) + bucket = [ + _mv("Voltage", 400.0, phase="L1-L2", unit="V"), + _mv("Voltage", 399.0, phase="L2-L3", unit="V"), + _mv("Voltage", 401.0, phase="L3-L1", unit="V"), + ] + cp.process_phases(bucket, connector_id=1) + v_ln = cp._metrics[(1, "Voltage")].value + assert pytest.approx(v_ln, rel=1e-3) == (400.0 + 399.0 + 401.0) / 3 / math.sqrt(3) + assert cp._metrics[(1, "Voltage")].unit == "V" + + # Power.Active.Import in W should become kW when aggregated + bucket2 = [ + _mv("Power.Active.Import", 1000.0, phase="L1", unit=DEFAULT_POWER_UNIT), + _mv("Power.Active.Import", 2000.0, phase="L2", unit=DEFAULT_POWER_UNIT), + _mv("Power.Active.Import", 3000.0, phase="L3", unit=DEFAULT_POWER_UNIT), + ] + cp.process_phases(bucket2, connector_id=2) + p_kw = cp._metrics[(2, "Power.Active.Import")].value + assert p_kw == (1000 + 2000 + 3000) / 1000 # -> 6 kW + assert cp._metrics[(2, "Power.Active.Import")].unit == HA_POWER_UNIT + + +def test_get_energy_kwh_and_session_derive(hass): + """Test get_energy_kwh + process_measurands path (EAIR Wh → kWh, derive Energy.Session).""" + cp = _mk_cp(hass, version=OcppVersion.V201) # != 1.6 to enable session derive + + # Starting meter (kWh) + cp._metrics[(1, csess.meter_start.value)].value = 10.0 + cp._metrics[(1, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + # Send EAIR in Wh (should normalize to kWh) + mv = _mv( + "Energy.Active.Import.Register", 10500.0, unit=DEFAULT_ENERGY_UNIT + ) # 10.5 kWh + cp.process_measurands([[mv]], is_transaction=True, connector_id=1) + + # EAIR normalized + assert cp._metrics[(1, "Energy.Active.Import.Register")].value == 10.5 + assert cp._metrics[(1, "Energy.Active.Import.Register")].unit == HA_ENERGY_UNIT + + # Session energy derived = EAIR - meter_start + assert cp._metrics[(1, csess.session_energy.value)].value == pytest.approx( + 0.5, 1e-12 + ) + assert cp._metrics[(1, csess.session_energy.value)].unit == HA_ENERGY_UNIT + + +@pytest.mark.asyncio +async def test_handle_call_wraps_notimplementederror_and_sends(hass): + """Test _handle_call Path: NotImplementedError → _send(...).""" + central = CentralSystemSettings( + csid="cs", + host="127.0.0.1", + port=9999, + subprotocols=["ocpp1.6"], + ssl=False, + ssl_certfile_path="", + ssl_keyfile_path="", + websocket_close_timeout=1, + websocket_ping_interval=0.1, + websocket_ping_timeout=0.1, + websocket_ping_tries=0, + ) + charger = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + + conn = SimpleNamespace(state=State.OPEN, close=lambda: None) + + cp = ChargePoint( + "CP_ID", + conn, + OcppVersion.V201, + hass, + SimpleNamespace(entry_id="e1", data={}), + central, + charger, + ) + + # Patch the PARENT _handle_call to raise the OCPP NotImplementedError + async def parent_raises(self, msg): + raise OcppNotImplementedError("nope") + + sent = {} + + async def fake_send(payload): + sent["payload"] = payload + + class DummyMsg: + """Dummy message class.""" + + def create_call_error(self, exc): + """Create call error.""" + assert isinstance(exc, OcppNotImplementedError) + return SimpleNamespace(to_json=lambda: "ERR_JSON") + + with ( + patch.object(LibCP, "_handle_call", parent_raises, create=True), + patch.object(cp, "_send", fake_send), + ): + # Wrapper should CATCH the OCPP NotImplementedError and send CallError JSON + await cp._handle_call(DummyMsg()) + + assert sent.get("payload") == "ERR_JSON" diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 54a01307..3352878f 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -3,8 +3,11 @@ import asyncio import contextlib from datetime import datetime, UTC # timedelta, +import inspect import logging import re +import time +from types import SimpleNamespace import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -18,15 +21,22 @@ DOMAIN as OCPP_DOMAIN, CONF_CPIDS, CONF_CPID, + CONF_NUM_CONNECTORS, CONF_PORT, + DEFAULT_ENERGY_UNIT, + DEFAULT_MEASURAND, + HA_ENERGY_UNIT, ) from custom_components.ocpp.enums import ( ConfigurationKey, HAChargerServices as csvcs, + HAChargerStatuses as cstat, + HAChargerSession as csess, Profiles as prof, ) from custom_components.ocpp.number import NUMBERS from custom_components.ocpp.switch import SWITCHES +from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP from ocpp.routing import on from ocpp.v16 import ChargePoint as cpclass, call, call_result from ocpp.v16.enums import ( @@ -41,6 +51,7 @@ DataTransferStatus, DiagnosticsStatus, FirmwareStatus, + Measurand, RegistrationStatus, RemoteStartStopStatus, ResetStatus, @@ -61,6 +72,7 @@ wait_ready, ) + SERVICES = [ csvcs.service_update_firmware, csvcs.service_configure, @@ -83,6 +95,39 @@ ] +async def wait_for_num_connectors( + hass, cp_id: str, expected: int, timeout: float = 5.0 +): + """Wait until server side CP has num_connectors == expected. + + Returns the actual CentralSystem instance (after possible reload). + """ + deadline = time.monotonic() + timeout + last_seen = None + + while time.monotonic() < deadline: + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + cs = hass.data[OCPP_DOMAIN][entry.entry_id] + + srv = cs.charge_points.get(cp_id) + if srv is not None: + last_seen = getattr(srv, "num_connectors", None) + if last_seen == expected: + return cs + + for item in entry.data.get(CONF_CPIDS, []): + if isinstance(item, dict) and cp_id in item: + last_seen = item[cp_id].get(CONF_NUM_CONNECTORS) + if last_seen == expected: + return cs + + await asyncio.sleep(0.05) + + raise AssertionError( + f"num_connectors never became {expected} (last seen: {last_seen})" + ) + + async def test_switches(hass, cpid, socket_enabled): """Test switch operations.""" for switch in SWITCHES: @@ -682,6 +727,7 @@ async def test_cms_responses_normal_multiple_connectors_v16( await cp.send_boot_notification() await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=num_connectors) await cp.send_boot_notification() await cp.send_authorize() await cp.send_heartbeat() @@ -967,62 +1013,56 @@ async def test_stop_transaction_paths_v16_c( f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] ) as ws: cp = ChargePoint(f"{cp_id}_client", ws) - with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_start_transaction(12345), - set_report_session_energyreport(cs, cp_id, False), - cp.send_stop_transaction(1), - ), - timeout=8, - ) - await ws.close() + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) - cpid = cs.charge_points[cp_id].settings.cpid + await cp.send_start_transaction(12345) + await set_report_session_energyreport(cs, cp_id, False) + await cp.send_stop_transaction(1) - # Expect session = 54.321 - 12.345 = 41.976 kWh - sess = float(cs.get_metric(cpid, "Energy.Session")) - assert round(sess, 3) == round(54.321 - 12.345, 3) - assert cs.get_unit(cpid, "Energy.Session") == "kWh" - - # After stop, these measurands must be zeroed - for meas in [ - "Current.Import", - "Power.Active.Import", - "Power.Reactive.Import", - "Current.Export", - "Power.Active.Export", - "Power.Reactive.Export", - ]: - assert float(cs.get_metric(cpid, meas)) == 0.0 - - # Optional: stop reason captured - assert cs.get_metric(cpid, "Stop.Reason") is not None + cpid = cs.charge_points[cp_id].settings.cpid - await ws.close() + # Expect session = 54.321 - 12.345 = 41.976 kWh + sess = float(cs.get_metric(cpid, "Energy.Session")) + assert round(sess, 3) == round(54.321 - 12.345, 3) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" + + # After stop, these measurands must be zeroed + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert float(cs.get_metric(cpid, meas)) == 0.0 + + # Optional: stop reason captured + assert cs.get_metric(cpid, "Stop.Reason") is not None + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() # @pytest.mark.skip(reason="skip") @pytest.mark.timeout(30) @pytest.mark.parametrize( "setup_config_entry", - [{"port": 9011, "cp_id": "CP_1_meter_paths", "cms": "cms_meter_paths"}], + [{"port": 9061, "cp_id": "CP_1_meter_paths", "cms": "cms_meter_paths"}], indirect=True, ) @pytest.mark.parametrize("cp_id", ["CP_1_meter_paths"]) -@pytest.mark.parametrize("port", [9011]) +@pytest.mark.parametrize("port", [9061]) async def test_on_meter_values_paths_v16( hass, socket_enabled, cp_id, port, setup_config_entry ): - """Exercise important branches of ocppv16.on_meter_values. - - - Main meter (EAIR) without transaction_id -> connector 0 (kWh) - - Restore meter_start/transaction_id when missing - - With transaction_id and match -> update Energy.Session - - Empty strings for other measurands -> coerced to 0.0 - """ + """Exercise important branches of ocppv16.on_meter_values, deterministically.""" cs: CentralSystem = setup_config_entry async with websockets.connect( @@ -1031,24 +1071,39 @@ async def test_on_meter_values_paths_v16( ) as ws: cp = ChargePoint(f"{cp_id}_client", ws) - # Keep the OCPP task running in the background. cp_task = asyncio.create_task(cp.start()) try: - # Boot (enough for the CS to register the CPID). await cp.send_boot_notification() await wait_ready(cs.charge_points[cp_id]) - cpid = cs.charge_points[cp_id].settings.cpid + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # 1) Start a transaction with meter_start = 10000 Wh (10.0 kWh) + await cp.send_start_transaction(meter_start=10000) + + async def _wait_until(cond, timeout=2.0, step=0.01): + import time + + end = time.monotonic() + timeout + while time.monotonic() < end: + if cond(): + return True + await asyncio.sleep(step) + return False - # 1) Start a transaction so the helper for "main meter" won't block. - await cp.send_start_transaction(meter_start=10000) # Wh (10 kWh) - # Give CS a tick to persist state. - await asyncio.sleep(0.1) - active_tx = cs.charge_points[cp_id].active_transaction_id + assert await _wait_until( + lambda: ( + srv._metrics[(1, "Energy.Meter.Start")].value == 10.0 + and (srv.active_transaction_id or 0) != 0 + ), + timeout=2.0, + ), "Server never persisted meter_start=10.0 and active_transaction_id" + + active_tx = srv.active_transaction_id assert active_tx != 0 - # 2) MAIN METER (no transaction_id): updates aggregate connector (0) in kWh - # Note: helper waits for active tx, but still omits transaction_id in the message. + # 2) MAIN METER without tx id -> updates connector 0 in kWh await cp.send_main_meter_clock_data() agg_eair = float( cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=0) @@ -1059,16 +1114,13 @@ async def test_on_meter_values_paths_v16( == "kWh" ) - # 3) Force-loss: clear meter_start and transaction_id; provide last EAIR to restore from. - m = cs.charge_points[cp_id]._metrics - m[(1, "Energy.Meter.Start")].value = None - m[(1, "Transaction.Id")].value = None - m[(1, "Energy.Active.Import.Register")].value = 12.5 - m[(1, "Energy.Active.Import.Register")].unit = "kWh" + # 3) Set meter_start to 12.5 kWh + m = srv._metrics + m[(1, "Energy.Meter.Start")].value = 12.5 + m[(1, "Energy.Meter.Start")].unit = "kWh" + m[(1, "Transaction.Id")].value = active_tx - # 4) Send MeterValues WITH transaction_id and include: - # - EAIR = 15000 Wh (-> 15.0 kWh) - # - Power.Active.Import = "" (should coerce to 0.0) + # 4) Send MV with tx id and EAIR=15000 Wh (15.0 kWh) + empty PAI -> 0.0 mv = call.MeterValues( connector_id=1, transaction_id=active_tx, @@ -1096,16 +1148,16 @@ async def test_on_meter_values_paths_v16( resp = await cp.call(mv) assert resp is not None - # meter_start restored from last EAIR on connector 1 -> 12.5 kWh; session = 15.0 - 12.5 = 2.5 kWh + # meter_start reset from 12.5 kWh → session = 15.0 - 12.5 = 2.5 kWh sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) assert sess == pytest.approx(2.5, rel=1e-6) assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" - # Empty-string coerced to 0.0 + # Empty string → 0.0 pai = float(cs.get_metric(cpid, "Power.Active.Import", connector_id=1)) assert pai == 0.0 - # Transaction id restored/kept + # Tx id reset tx_restored = int(cs.get_metric(cpid, "Transaction.Id", connector_id=1)) assert tx_restored == active_tx @@ -1260,7 +1312,6 @@ async def test_api_get_extra_attr_paths( # Start a minimal CP so CS creates/keeps the server-side object cp = ChargePoint(f"{cp_id}_client", ws) cp_task = asyncio.create_task(cp.start()) - # One Boot is enough to associate the CP id in CS await cp.send_boot_notification() await wait_ready(cs.charge_points[cp_id]) @@ -1506,6 +1557,7 @@ async def test_api_get_unit_fallback_to_later_connectors( # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) await cp.send_boot_notification() await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=3) srv = cs.charge_points[cp_id] cpid = srv.settings.cpid @@ -1568,6 +1620,7 @@ async def test_api_get_extra_attr_fallback_to_later_connectors( # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) await cp.send_boot_notification() await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=3) srv = cs.charge_points[cp_id] cpid = srv.settings.cpid @@ -1735,6 +1788,918 @@ async def test_get_diagnostics_and_data_transfer_v16( await ws.close() +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9024, "cp_id": "CP_1_monconn", "cms": "cms_monconn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_monconn"]) +@pytest.mark.parametrize("port", [9024]) +async def test_monitor_connection_timeout_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise TimeoutError branch in chargepoint.monitor_connection and ensure it raises after exceeded tries.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + + from custom_components.ocpp import chargepoint as cp_mod + + async def noop_task(_coro): + return None + + monkeypatch.setattr(srv_cp.hass, "async_create_task", noop_task, raising=True) + + async def fast_sleep(_): + return None # skip the initial sleep(10) and interval sleeps + + monkeypatch.setattr(cp_mod.asyncio, "sleep", fast_sleep, raising=True) + + # First wait_for returns a never-finishing "pong waiter", + # second wait_for raises TimeoutError -> hits the except branch + calls = {"n": 0} + + async def fake_wait_for(awaitable, timeout): + calls["n"] += 1 + if inspect.iscoroutine(awaitable): + awaitable.close() + if calls["n"] == 1: + + class _NeverFinishes: + def __await__(self): + fut = asyncio.get_event_loop().create_future() + return fut.__await__() + + return _NeverFinishes() + raise TimeoutError + + monkeypatch.setattr(cp_mod.asyncio, "wait_for", fake_wait_for, raising=True) + + # Make the code raise on first timeout + srv_cp.cs_settings.websocket_ping_interval = 0.0 + srv_cp.cs_settings.websocket_ping_timeout = 0.01 + srv_cp.cs_settings.websocket_ping_tries = 0 # => > tries -> raise + + srv_cp.post_connect_success = True + + async def noop(): + return None + + monkeypatch.setattr(srv_cp, "post_connect", noop, raising=True) + monkeypatch.setattr(srv_cp, "set_availability", noop, raising=True) + + with pytest.raises(TimeoutError): + await srv_cp.monitor_connection() + + assert calls["n"] >= 2 # both wait_for calls were exercised + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9025, "cp_id": "CP_1_authlist", "cms": "cms_authlist"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_authlist"]) +@pytest.mark.parametrize("port", [9025]) +async def test_get_authorization_status_with_auth_list( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise ChargePoint.get_authorization_status() when an auth_list is configured.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.const import ( + DOMAIN, + CONFIG, + CONF_DEFAULT_AUTH_STATUS, + CONF_AUTH_LIST, + CONF_ID_TAG, + CONF_AUTH_STATUS, + ) + + # Start a minimal client so the server-side CP is registered. + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # We only needed a boot to register the CP; close the socket cleanly. + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + srv_cp = cs.charge_points[cp_id] + + # Configure default + auth_list in HA config dict + hass.data[DOMAIN][CONFIG][CONF_DEFAULT_AUTH_STATUS] = ( + AuthorizationStatus.blocked.value + ) + hass.data[DOMAIN][CONFIG][CONF_AUTH_LIST] = [ + { + CONF_ID_TAG: "TAG_PRESENT", + CONF_AUTH_STATUS: AuthorizationStatus.expired.value, + }, + {CONF_ID_TAG: "TAG_NO_STATUS"}, # should fall back to default + ] + + # 1) Early return path: remote id tag + srv_cp._remote_id_tag = "REMOTE123" + assert ( + srv_cp.get_authorization_status("REMOTE123") + == AuthorizationStatus.accepted.value + ) + + # 2) Match in auth_list with explicit status + assert ( + srv_cp.get_authorization_status("TAG_PRESENT") + == AuthorizationStatus.expired.value + ) + + # 3) Match in auth_list without explicit status -> default + assert ( + srv_cp.get_authorization_status("TAG_NO_STATUS") + == AuthorizationStatus.blocked.value + ) + + # 4) Not found in auth_list -> default + assert ( + srv_cp.get_authorization_status("UNKNOWN") == AuthorizationStatus.blocked.value + ) + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9026, + "cp_id": "CP_1_sess_single", + "cms": "cms_sess_single", + "num_connectors": 1, + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_sess_single"]) +@pytest.mark.parametrize("port", [9026]) +async def test_session_metrics_single_connector_backward_compat( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Single-connector: connector_id=None should transparently read connector 1 session metrics.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=1) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Server-side handle + CPID + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Seed connector 1 session value directly + meas = "Energy.Session" + srv._metrics[(1, meas)] = srv._metrics.get((1, meas), M(None, None)) + srv._metrics[(1, meas)].value = 3.2 + srv._metrics[(1, meas)].unit = "kWh" + + # Backward-compat read: connector_id=None must resolve to connector 1 for single-connector + val_none = cs.get_metric(cpid, measurand=meas, connector_id=None) + assert val_none == 3.2 + + # Cleanly close the socket + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9027, + "cp_id": "CP_1_sess_multi", + "cms": "cms_sess_multi", + "num_connectors": 2, + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_sess_multi"]) +@pytest.mark.parametrize("port", [9027]) +async def test_session_metrics_multi_connector_isolated( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Multi-connector: values on connector 1 and 2 are distinct.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=2) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + meas = "Energy.Session" + # Seed distinct values per connector + for conn, val in [(1, 1.0), (2, 2.0)]: + srv._metrics[(conn, meas)] = srv._metrics.get((conn, meas), M(None, None)) + srv._metrics[(conn, meas)].value = val + srv._metrics[(conn, meas)].unit = "kWh" + + # Verify isolation + assert cs.get_metric(cpid, measurand=meas, connector_id=1) == 1.0 + assert cs.get_metric(cpid, measurand=meas, connector_id=2) == 2.0 + + # Cleanly close the socket + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9071, "cp_id": "CP_ST_SU", "cms": "cms_st_su"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_ST_SU"]) +@pytest.mark.parametrize("port", [9071]) +async def test_start_transaction_accept_and_reject( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """start_transaction returns True on accepted, False on reject and notifies HA.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] # server-side CP + + # 1) Accepted -> True + async def call_ok(req): + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.start_transaction(connector_id=2) + assert ok is True + + # 2) Rejected -> False and notify_ha called + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append((msg, title)) + return True + + async def call_bad(req): + return SimpleNamespace(status=RemoteStartStopStatus.rejected) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_bad, raising=True) + bad = await srv_cp.start_transaction(connector_id=1) + assert bad is False + assert notes and "Start transaction failed" in notes[0][0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9072, "cp_id": "CP_STOP", "cms": "cms_stop"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_STOP"]) +@pytest.mark.parametrize("port", [9072]) +async def test_stop_transaction_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """stop_transaction: early True when no active tx; accepted True; reject False + notify.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Case A: no active tx anywhere -> returns True without calling cp + srv_cp.active_transaction_id = 0 + # ocppv16 uses _active_tx dict; ensure it's empty/falsey + setattr(srv_cp, "_active_tx", {}) # or defaultdict if lib uses that + called = {"n": 0} + + async def should_not_call(_req): + called["n"] += 1 + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", should_not_call, raising=True) + early = await srv_cp.stop_transaction() + assert early is True + assert called["n"] == 0 # verify we didn't call into charger + + # Case B: active tx id present -> accepted -> True + srv_cp.active_transaction_id = 42 + + async def call_ok(req): + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.stop_transaction() + assert ok is True + + # Case C: active tx but reject -> False and notify_ha + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append(msg) + return True + + async def call_bad(req): + return SimpleNamespace(status=RemoteStartStopStatus.rejected) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_bad, raising=True) + srv_cp.active_transaction_id = 99 + bad = await srv_cp.stop_transaction() + assert bad is False + assert notes and "Stop transaction failed" in notes[0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9073, "cp_id": "CP_UNLOCK", "cms": "cms_unlock"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_UNLOCK"]) +@pytest.mark.parametrize("port", [9073]) +async def test_unlock_accept_and_fail( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """unlock: unlocked -> True; otherwise False + notify.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Success + async def call_ok(req): + return SimpleNamespace(status=UnlockStatus.unlocked) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.unlock(connector_id=2) + assert ok is True + + # Failure → notify + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append(msg) + return True + + async def call_fail(req): + # pick a non-success status + return SimpleNamespace(status=UnlockStatus.unlock_failed) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_fail, raising=True) + bad = await srv_cp.unlock(connector_id=1) + assert bad is False + assert notes and "Unlock failed" in notes[0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9074, "cp_id": "CP_NUM_CONN", "cms": "cms_num_conn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_NUM_CONN"]) +@pytest.mark.parametrize("port", [9074]) +async def test_get_number_of_connectors_variants( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise all branches of get_number_of_connectors().""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Case A: valid configurationKey with correct value + async def call_good(req): + return SimpleNamespace( + configuration_key=[ + SimpleNamespace(key="NumberOfConnectors", value="3") + ] + ) + + monkeypatch.setattr(srv_cp, "call", call_good) + n = await srv_cp.get_number_of_connectors() + assert n == 3 + + # Case B: resp is list[tuple] with dict inside ("configurationKey") + async def call_tuple(req): + return [ + "ignored", + "ignored", + {"configurationKey": [{"key": "NumberOfConnectors", "value": "4"}]}, + ] + + monkeypatch.setattr(srv_cp, "call", call_tuple) + n = await srv_cp.get_number_of_connectors() + assert n == 4 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9076, "cp_id": "CP_diag", "cms": "cms_diag"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_diag"]) +@pytest.mark.parametrize("port", [9076]) +async def test_on_diagnostics_status_notification( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test on_diagnostics_status. + + - replies with DiagnosticsStatusNotification + - schedules notify_ha with expected message + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp: ServerCP = cs.charge_points[cp_id] + + captured = {"called": 0, "msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + # record the message; return True like the real notifier + captured["msg"] = msg + return True + + def fake_async_create_task(coro): + # actually schedule the coroutine so fake_notify runs + captured["called"] += 1 + return asyncio.create_task(coro) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr( + srv_cp.hass, "async_create_task", fake_async_create_task, raising=True + ) + + # trigger server handler + req = call.DiagnosticsStatusNotification(status="Uploaded") + resp = await cp.call(req) + assert resp is not None # server replied + + # ensure notify_ha ran and message content is correct + # give the task a tick to run + await asyncio.sleep(0) + assert captured["called"] == 1 + assert captured["msg"] == "Diagnostics upload status: Uploaded" + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(15) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9077, "cp_id": "CP_stop_hdl", "cms": "cms_stop_hdl"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_stop_hdl"]) +@pytest.mark.parametrize("port", [9077]) +async def test_on_stop_transaction_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test ocppv16.on_stop_transaction. + + 1) Normal routed call (valid payload) with unknown tx -> falls back to conn=1 and + exception on meter_start only. + 2) Direct handler call to cover the exception path on meter_stop (string) + and the EAIR-derived branch’s conversion error. + Also verify currents/powers are zeroed and HA update is scheduled. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # Minimal client to start the protocol task and register the CP + cli = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cli.start()) + spawned_tasks: list[asyncio.Task] = [] + scheduled = {"n": 0} + + try: + await cli.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv: ServerCP = cs.charge_points[cp_id] + + # Keep HA quiet; count scheduled updates instead of running them + scheduled = {"n": 0} + + def fake_async_create_task(target, *args, **kwargs): + """Intercept HA task scheduling. + + - If the target is the cp.update(...) coroutine, close it so it never runs + - Otherwise, create a real asyncio task so nothing else in the loop breaks. + """ + scheduled["n"] += 1 + + if inspect.iscoroutine(target): + co = getattr(target, "cr_code", None) + name = getattr(co, "co_name", "") if co else "" + + if name == "update": + target.close() + t = asyncio.create_task(asyncio.sleep(0)) + spawned_tasks.append(t) + return t + + t = asyncio.create_task(target) + spawned_tasks.append(t) + return t + + t = asyncio.create_task(asyncio.sleep(0)) + spawned_tasks.append(t) + return t + + monkeypatch.setattr( + srv.hass, "async_create_task", fake_async_create_task, raising=True + ) + + # Ensure connector 1 metrics exist + _ = srv._metrics[(1, cstat.stop_reason.value)] + _ = srv._metrics[(1, csess.meter_start.value)] + _ = srv._metrics[(1, DEFAULT_MEASURAND)] + _ = srv._metrics[(1, csess.session_energy.value)] + for m in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + _ = srv._metrics[(1, m)] + + # ------------------------------------------------------------------ + # (A) Routed normal call: unknown tx -> conn is None path; make meter_start + # non-numeric to hit that exception (meter_stop remains valid int). + # ------------------------------------------------------------------ + unknown_tx = 999_001 + srv._active_tx = {} # ensures lookup fails -> fallback to conn=1 + srv.active_transaction_id = 0 + + # Force meter_start conversion failure + srv._metrics[(1, csess.meter_start.value)].value = "not-a-number" + + stop_req = call.StopTransaction( + transaction_id=unknown_tx, + meter_stop=12345, + timestamp="2024-01-01T00:00:00Z", + reason="Local", + ) + stop_resp = await cli.call(stop_req) + assert isinstance(stop_resp, call_result.StopTransaction) + + # Session energy is derived from meter_stop (12.345 kWh) minus + # meter_start (conversion failed -> 0.0) = 12.345 + val = srv._metrics[(1, csess.session_energy.value)].value + unit = srv._metrics[(1, csess.session_energy.value)].unit + assert val == pytest.approx(12.345, rel=1e-6) + assert unit == HA_ENERGY_UNIT + + # Zeroing of currents/powers + for m in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + assert srv._metrics[(1, m)].value == 0 + + assert scheduled["n"] >= 1 # update(...) scheduled + + # ------------------------------------------------------------------ + # (B) Direct handler call to cover: + # - meter_stop conversion exception (string) + # - EAIR-based branch with conversion error + # ------------------------------------------------------------------ + # Prepare connector 2 + _ = srv._metrics[(2, DEFAULT_MEASURAND)] + _ = srv._metrics[(2, csess.session_energy.value)] + _ = srv._metrics[(2, csess.meter_start.value)] + + # Choose EAIR-based route + srv._charger_reports_session_energy = True + # No precomputed session value so handler tries to derive from last EAIR + srv._metrics[(2, csess.session_energy.value)].value = None + srv._metrics[(2, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT + # Make EAIR non-convertible to float -> triggers exception inside EAIR branch + srv._metrics[(2, DEFAULT_MEASURAND)].value = "NaN-err" + + # Map tx to connector 2 (so conn is found and not None) + known_tx = 222_333 + srv._active_tx = {2: known_tx} + srv.active_transaction_id = known_tx + + # Call handler directly to bypass OCPP schema and send bad meter_stop + # NOTE: This is intentional to exercise the internal try/except on meter_stop. + direct_resp = srv.on_stop_transaction( + meter_stop="bad-int", # triggers exception -> 0.0 if fallback path used + timestamp="2024-01-01T00:00:01Z", + transaction_id=known_tx, + reason="Local", + ) + assert isinstance(direct_resp, call_result.StopTransaction) + + # EAIR conversion failed; code swallows the exception and leaves session possibly unset + assert srv._metrics[(2, csess.session_energy.value)].value in (None,) + + # Currents/powers should be zeroed on connector 2 as well + for m in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + _ = srv._metrics[(2, m)] + assert srv._metrics[(2, m)].value == 0 + + finally: + for t in spawned_tasks: + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9082, "cp_id": "CP_stop_eair_wh", "cms": "cms_stop_eair_wh"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_stop_eair_wh"]) +@pytest.mark.parametrize("port", [9082]) +async def test_on_stop_transaction_eair_unit_wh( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test on_stop_transaction EAIR branch with last_unit == Wh and last_eair set. + + Covers the branch where eair_kwh = float(last_eair) / 1000.0. + """ + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cli = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cli.start()) + + try: + await cli.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv: ServerCP = cs.charge_points[cp_id] + + # Prepare connector 1 metrics + _ = srv._metrics[(1, csess.session_energy.value)] + _ = srv._metrics[(1, DEFAULT_MEASURAND)] + _ = srv._metrics[(1, csess.meter_start.value)] + + # Force EAIR branch + srv._charger_reports_session_energy = True + srv._metrics[(1, csess.session_energy.value)].value = None + srv._metrics[(1, DEFAULT_MEASURAND)].unit = DEFAULT_ENERGY_UNIT + # Here: set a Wh value to trigger the branch + srv._metrics[(1, DEFAULT_MEASURAND)].value = 12345 # Wh = 12.345 kWh + + # Map tx → connector 1 + tx_id = 222 + srv._active_tx = {1: tx_id} + srv.active_transaction_id = tx_id + + # Prevent lingering post_connect job during teardown + srv.post_connect_success = True + + async def _noop(): # don't start background work in tests + return None + + monkeypatch.setattr(srv, "post_connect", _noop, raising=True) + + def _schedule(target, *args, **kwargs): + # Always schedule the coroutine; ignore HA's optional args (name/eager_start) + return asyncio.create_task(target) + + # Patch both the server CP’s hass and the root hass to be safe + monkeypatch.setattr(srv.hass, "async_create_task", _schedule, raising=True) + monkeypatch.setattr(hass, "async_create_task", _schedule, raising=True) + + # Call handler directly + resp = srv.on_stop_transaction( + meter_stop=99999, # ignored in EAIR branch + timestamp="2024-01-01T00:00:01Z", + transaction_id=tx_id, + reason="Local", + ) + assert isinstance(resp, call_result.StopTransaction) + + # Session energy should now be set to 12.345 kWh + val = srv._metrics[(1, csess.session_energy.value)].value + unit = srv._metrics[(1, csess.session_energy.value)].unit + assert val == pytest.approx(12.345, rel=1e-6) + assert unit == HA_ENERGY_UNIT + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9083, "cp_id": "CP_stop_eair_kwh", "cms": "cms_stop_eair_kwh"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_stop_eair_kwh"]) +@pytest.mark.parametrize("port", [9083]) +async def test_on_stop_transaction_eair_unit_kwh( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """EAIR branch where last_unit == kWh and last_eair has a value. + + Verifies that session energy is copied as-is (already in kWh), + and avoids warnings by scheduling the HA update and disabling post_connect. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cli = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cli.start()) + + try: + # Boot so the server registers this CP + await cli.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv: ServerCP = cs.charge_points[cp_id] + + # Prevent lingering post_connect job during teardown + srv.post_connect_success = True + + async def _noop(): # don't start background work in tests + return None + + monkeypatch.setattr(srv, "post_connect", _noop, raising=True) + + def _schedule(target, *args, **kwargs): + # Always schedule the coroutine; ignore HA's optional args (name/eager_start) + return asyncio.create_task(target) + + # Patch both the server CP’s hass and the root hass to be safe + monkeypatch.setattr(srv.hass, "async_create_task", _schedule, raising=True) + monkeypatch.setattr(hass, "async_create_task", _schedule, raising=True) + + # Prepare connector 1 metrics for the EAIR branch + _ = srv._metrics[(1, csess.session_energy.value)] + _ = srv._metrics[(1, DEFAULT_MEASURAND)] + _ = srv._metrics[(1, csess.meter_start.value)] + + srv._charger_reports_session_energy = True + srv._metrics[(1, csess.session_energy.value)].value = None + srv._metrics[(1, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT + srv._metrics[(1, DEFAULT_MEASURAND)].value = 12.345 # already kWh + + # Map tx → connector 1 so the handler resolves conn=1 + tx_id = 333 + srv._active_tx = {1: tx_id} + srv.active_transaction_id = tx_id + + # Call handler directly to exercise the branch + resp = srv.on_stop_transaction( + meter_stop=99999, # ignored in EAIR branch + timestamp="2024-01-01T00:00:01Z", + transaction_id=tx_id, + reason="Local", + ) + assert isinstance(resp, call_result.StopTransaction) + + # Expect the EAIR value to be copied to session energy (kWh) + val = srv._metrics[(1, csess.session_energy.value)].value + unit = srv._metrics[(1, csess.session_energy.value)].unit + assert val == pytest.approx(12.345, rel=1e-6) + assert unit == HA_ENERGY_UNIT + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + class ChargePoint(cpclass): """Representation of real client Charge Point.""" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index ea80fd95..2f69e6cf 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant.data_entry_flow import InvalidData import pytest -from custom_components.ocpp.const import DEFAULT_NUM_CONNECTORS, DOMAIN +from custom_components.ocpp.const import DOMAIN from .const import ( MOCK_CONFIG_CS, @@ -15,7 +15,6 @@ MOCK_CONFIG_FLOW, CONF_CPIDS, CONF_MONITORED_VARIABLES_AUTOCONFIG, - CONF_NUM_CONNECTORS, DEFAULT_MONITORED_VARIABLES, ) @@ -117,9 +116,6 @@ async def test_successful_discovery_flow(hass, bypass_get_data): flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( False ) - flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = ( - DEFAULT_NUM_CONNECTORS - ) assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] assert entry.data == flow_output diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 67d4ee56..928a0abd 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -4,14 +4,15 @@ import websockets from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN - from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.components.sensor.const import ( SensorDeviceClass, SensorStateClass, ATTR_STATE_CLASS, ) + +from custom_components.ocpp.const import CONF_NUM_CONNECTORS, DOMAIN as OCPP_DOMAIN + from .const import ( MOCK_CONFIG_DATA, CONF_CPIDS, @@ -48,7 +49,7 @@ async def test_sensor(hass, socket_enabled): async with websockets.connect( f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", subprotocols=["ocpp1.6"], - ): + ) as ws: # Wait for setup to complete await asyncio.sleep(1) # Test reactive power sensor @@ -62,4 +63,57 @@ async def test_sensor(hass, socket_enabled): assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) is None + await ws.close() + + await remove_configuration(hass, config_entry) + + +async def test_sensor_entities_per_connector_created(hass, socket_enabled): + """Create separate entities per connector when num_connectors=2.""" + + cp_id = "CP_1_sens_mc" + cpid = "test_cpid_sens_mc" + + data = MOCK_CONFIG_DATA.copy() + cp_data = MOCK_CONFIG_CP_APPEND.copy() + cp_data[CONF_CPID] = cpid + cp_data[CONF_NUM_CONNECTORS] = 2 # ensure two connectors up front + data[CONF_CPIDS].append({cp_id: cp_data}) + data[CONF_PORT] = 9050 + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=data, + entry_id="test_cms_sens_mc", + title="test_cms_sens_mc", + version=2, + minor_version=0, + ) + + await create_configuration(hass, config_entry) + + # Open a ws once to trigger platform setup; entities are created during setup_entry + async with websockets.connect( + f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # Give HA a tick to register entities + await asyncio.sleep(0.5) + + # Per-connector entities should include in the entity_id + s1 = hass.states.get(f"sensor.{cpid}_connector_1_status_connector") + s2 = hass.states.get(f"sensor.{cpid}_connector_2_status_connector") + assert s1 is not None, "missing sensor for connector 1" + assert s2 is not None, "missing sensor for connector 2" + + # There must not be any entity for a non-existent connector 3 + s3 = hass.states.get(f"sensor.{cpid}_connector_3_status_connector") + assert s3 is None, "unexpected sensor for connector 3" + + # Root-level sensor still includes + root = hass.states.get(f"sensor.{cpid}_connectors") + assert root is not None, "missing root-level 'connectors' sensor" + + await ws.close() + await remove_configuration(hass, config_entry) From d1e7abe2e5208108cf4936a7386c48fb085deee9 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Wed, 27 Aug 2025 11:37:26 +0000 Subject: [PATCH 14/15] Change port on test to fix crash. --- tests/test_charge_point_v201.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py index 1e7d4421..5b37cb69 100644 --- a/tests/test_charge_point_v201.py +++ b/tests/test_charge_point_v201.py @@ -1235,7 +1235,7 @@ async def test_cms_responses_v201(hass, socket_enabled): config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" - config_data[CONF_PORT] = 9010 + config_data[CONF_PORT] = 9080 config_entry = MockConfigEntry( domain=OCPP_DOMAIN, From fdf3b966ce1623919723a1bed2cfaaabfe787f35 Mon Sep 17 00:00:00 2001 From: Jan Thunqvist Date: Fri, 5 Sep 2025 07:24:17 +0000 Subject: [PATCH 15/15] Add missing sensors. Convert features value in sensor. Add num_connectors to config entry. Migration to config 2.1. --- custom_components/ocpp/__init__.py | 32 +++++++++++++++++++++++++++ custom_components/ocpp/chargepoint.py | 4 +++- custom_components/ocpp/config_flow.py | 16 ++++++++++++-- custom_components/ocpp/enums.py | 6 +++++ custom_components/ocpp/ocppv201.py | 7 +++--- custom_components/ocpp/sensor.py | 15 +++++++++++++ tests/test_charge_point_v16.py | 6 ++--- tests/test_config_flow.py | 10 ++++++++- tests/test_init.py | 2 +- 9 files changed, 86 insertions(+), 12 deletions(-) diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index 5c7bf1fe..c07323c2 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -25,6 +25,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_SKIP_SCHEMA_VALIDATION, CONF_FORCE_SMART_CHARGING, CONF_HOST, @@ -44,6 +45,7 @@ DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_FORCE_SMART_CHARGING, DEFAULT_HOST, @@ -192,6 +194,36 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry): config_entry, data=new_data, minor_version=0, version=2 ) + if config_entry.version == 2 and config_entry.minor_version == 0: + data = {**config_entry.data} + cpids = data.get(CONF_CPIDS, []) + + changed = False + for idx, cp_map in enumerate(cpids): + if not isinstance(cp_map, dict) or not cp_map: + continue + cp_id, cp_data = next(iter(cp_map.items())) + if CONF_NUM_CONNECTORS not in cp_data: + cp_data = {**cp_data, CONF_NUM_CONNECTORS: DEFAULT_NUM_CONNECTORS} + cpids[idx] = {cp_id: cp_data} + changed = True + + if changed: + data[CONF_CPIDS] = cpids + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + minor_version=1, + ) + else: + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + minor_version=1, + ) + _LOGGER.info( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py index f5b3d05f..df6ad74c 100644 --- a/custom_components/ocpp/chargepoint.py +++ b/custom_components/ocpp/chargepoint.py @@ -318,7 +318,9 @@ async def fetch_supported_features(self): """Get supported features.""" self._attr_supported_features = await self.get_supported_features() self._metrics[(0, cdet.features.value)].value = self._attr_supported_features - _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) + _LOGGER.debug( + "Feature profiles returned: %s", self._attr_supported_features.labels() + ) async def post_connect(self): """Logic to be executed right after a charger connects.""" diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 67e6c2c5..4556c305 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -20,6 +20,7 @@ CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -39,6 +40,7 @@ DEFAULT_METER_INTERVAL, DEFAULT_MONITORED_VARIABLES, DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -106,7 +108,7 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" VERSION = 2 - MINOR_VERSION = 0 + MINOR_VERSION = 1 CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH def __init__(self): @@ -115,6 +117,7 @@ def __init__(self): self._cp_id: str self._entry: ConfigEntry self._measurands: str = "" + self._detected_num_connectors: int = DEFAULT_NUM_CONNECTORS async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle user central system initiated configuration.""" @@ -141,6 +144,10 @@ async def async_step_integration_discovery( self._cp_id = discovery_info["cp_id"] self._data = {**self._entry.data} + self._detected_num_connectors = discovery_info.get( + CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS + ) + await self.async_set_unique_id(self._cp_id) # Abort the flow if a config entry with the same unique ID exists self._abort_if_unique_id_configured() @@ -155,7 +162,12 @@ async def async_step_cp_user( if user_input is not None: # Don't allow duplicate cpids to be used self._async_abort_entries_match({CONF_CPID: user_input[CONF_CPID]}) - self._data[CONF_CPIDS].append({self._cp_id: user_input}) + + cp_data = { + **user_input, + CONF_NUM_CONNECTORS: self._detected_num_connectors, + } + self._data[CONF_CPIDS].append({self._cp_id: cp_data}) if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( DEFAULT_MONITORED_VARIABLES diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index 2e2aa405..9a206f60 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -73,6 +73,12 @@ class Profiles(IntFlag): REM = auto() # RemoteTrigger AUTH = auto() # LocalAuthListManagement + def labels(self): + """Get labels for profiles.""" + if self == Profiles.NONE: + return "NONE" + return "|".join([p.name for p in Profiles if p & self]) + class OcppMisc(str, Enum): """Miscellaneous strings used in ocpp v1.6 responses.""" diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py index 6454e3a8..65d28e05 100644 --- a/custom_components/ocpp/ocppv201.py +++ b/custom_components/ocpp/ocppv201.py @@ -1,19 +1,19 @@ """Representation of a OCPP 2.0.1 or 2.1 charging station.""" import asyncio +import contextlib from datetime import datetime, UTC from dataclasses import dataclass, field import logging -import ocpp.exceptions -from ocpp.exceptions import OCPPError - from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError, HomeAssistantError from websockets.asyncio.server import ServerConnection +import ocpp.exceptions +from ocpp.exceptions import OCPPError from ocpp.routing import on from ocpp.v201 import call, call_result from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 @@ -57,7 +57,6 @@ DOMAIN, HA_ENERGY_UNIT, ) -import contextlib _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.INFO) diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index 5155855c..186b2d8a 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -74,10 +74,13 @@ async def async_setup_entry(hass, entry, async_add_devices): CHARGER_ONLY = [ HAChargerStatuses.status.value, HAChargerStatuses.error_code.value, + HAChargerStatuses.firmware_status.value, HAChargerStatuses.heartbeat.value, + HAChargerStatuses.id_tag.value, HAChargerStatuses.latency_ping.value, HAChargerStatuses.latency_pong.value, HAChargerStatuses.reconnects.value, + HAChargerDetails.identifier.value, HAChargerDetails.vendor.value, HAChargerDetails.model.value, HAChargerDetails.serial.value, @@ -86,11 +89,13 @@ async def async_setup_entry(hass, entry, async_add_devices): HAChargerDetails.connectors.value, HAChargerDetails.config_response.value, HAChargerDetails.data_response.value, + HAChargerDetails.data_transfer.value, ] CONNECTOR_ONLY = measurands + [ HAChargerStatuses.status_connector.value, HAChargerStatuses.error_code_connector.value, + HAChargerStatuses.stop_reason.value, HAChargerSession.transaction_id.value, HAChargerSession.session_time.value, HAChargerSession.session_energy.value, @@ -305,6 +310,16 @@ def native_value(self): value = self.central_system.get_metric( self.cpid, self.metric, self.connector_id ) + + # Special case for features - show profiles as labels from IntFlag + if self.metric == HAChargerDetails.features.value and value is not None: + if hasattr(value, "labels"): + self._attr_native_value = value.labels() + else: + self._attr_native_value = str(value) + + return self._attr_native_value + if value is not None: self._attr_native_value = value return self._attr_native_value diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py index 3352878f..c2d8c945 100644 --- a/tests/test_charge_point_v16.py +++ b/tests/test_charge_point_v16.py @@ -697,7 +697,7 @@ async def test_cms_responses_errors_v16( ) -@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.timeout(40) # Set timeout for this test @pytest.mark.parametrize( "setup_config_entry", [{"port": 9007, "cp_id": "CP_1_norm_mc", "cms": "cms_norm"}], @@ -1533,7 +1533,7 @@ async def boom(_req): await ws.close() -@pytest.mark.timeout(20) +@pytest.mark.timeout(40) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9020, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], @@ -2534,7 +2534,7 @@ def fake_async_create_task(target, *args, **kwargs): await ws.close() -@pytest.mark.timeout(10) +@pytest.mark.timeout(20) @pytest.mark.parametrize( "setup_config_entry", [{"port": 9082, "cp_id": "CP_stop_eair_wh", "cms": "cms_stop_eair_wh"}], diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 2f69e6cf..c27539b5 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -7,7 +7,11 @@ from homeassistant.data_entry_flow import InvalidData import pytest -from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.const import ( + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DOMAIN, +) from .const import ( MOCK_CONFIG_CS, @@ -116,6 +120,10 @@ async def test_successful_discovery_flow(hass, bypass_get_data): flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( False ) + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = ( + DEFAULT_NUM_CONNECTORS + ) + assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] assert entry.data == flow_output diff --git a/tests/test_init.py b/tests/test_init.py index f7557c91..b5b63b7d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -122,7 +122,7 @@ async def test_migration_entry( assert config_entry.data.keys() == MOCK_CONFIG_DATA.keys() # check versions match assert config_entry.version == 2 - assert config_entry.minor_version == 0 + assert config_entry.minor_version == 1 # Unload the entry and verify that the data has been removed assert await hass.config_entries.async_remove(config_entry.entry_id)