diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 49ad4eda..9d23c8b9 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1,69 +1,25 @@ -"""Representation of a OCCP Entities.""" +"""Representation of a OCPP Entities.""" from __future__ import annotations -import asyncio -from collections import defaultdict -from datetime import datetime, timedelta, UTC -import json import logging -from math import sqrt -import secrets import ssl -import string -import time -from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTime +from homeassistant.const import STATE_OK from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_component, entity_registry -import homeassistant.helpers.config_validation as cv -from ocpp.exceptions import NotImplementedError -from ocpp.messages import CallError -from ocpp.routing import on -from ocpp.v16 import ChargePoint as cp, call, call_result -from ocpp.v16.enums import ( - Action, - AuthorizationStatus, - AvailabilityStatus, - AvailabilityType, - ChargePointStatus, - ChargingProfileKindType, - ChargingProfilePurposeType, - ChargingProfileStatus, - ChargingRateUnitType, - ClearChargingProfileStatus, - ConfigurationStatus, - DataTransferStatus, - Measurand, - MessageTrigger, - Phase, - RegistrationStatus, - RemoteStartStopStatus, - ResetStatus, - ResetType, - TriggerMessageStatus, - UnitOfMeasure, - UnlockStatus, -) -import voluptuous as vol +from websockets import Subprotocol import websockets.protocol import websockets.server +from .chargepoint import CentralSystemSettings +from .ocppv16 import ChargePoint as ChargePointv16 +from .ocppv201 import ChargePoint as ChargePointv201 + from .const import ( - CONF_AUTH_LIST, - CONF_AUTH_STATUS, CONF_CPID, CONF_CSID, - CONF_DEFAULT_AUTH_STATUS, - CONF_FORCE_SMART_CHARGING, CONF_HOST, - CONF_ID_TAG, - CONF_IDLE_INTERVAL, - CONF_METER_INTERVAL, - CONF_MONITORED_VARIABLES, - CONF_MONITORED_VARIABLES_AUTOCONFIG, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -74,18 +30,10 @@ CONF_WEBSOCKET_PING_INTERVAL, CONF_WEBSOCKET_PING_TIMEOUT, CONF_WEBSOCKET_PING_TRIES, - CONFIG, DEFAULT_CPID, DEFAULT_CSID, - DEFAULT_ENERGY_UNIT, - DEFAULT_FORCE_SMART_CHARGING, DEFAULT_HOST, - DEFAULT_IDLE_INTERVAL, - DEFAULT_MEASURAND, - DEFAULT_METER_INTERVAL, - DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, DEFAULT_PORT, - DEFAULT_POWER_UNIT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, DEFAULT_SSL_CERTFILE_PATH, @@ -96,18 +44,10 @@ DEFAULT_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TRIES, DOMAIN, - HA_ENERGY_UNIT, - HA_POWER_UNIT, - UNITS_OCCP_TO_HA, + OCPP_2_0, ) from .enums import ( - ConfigurationKey as ckey, - HAChargerDetails as cdet, HAChargerServices as csvcs, - HAChargerSession as csess, - HAChargerStatuses as cstat, - OcppMisc as om, - Profiles as prof, ) _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -116,46 +56,6 @@ # logging.getLogger("asyncio").setLevel(logging.DEBUG) # logging.getLogger("websockets").setLevel(logging.DEBUG) -TIME_MINUTES = UnitOfTime.MINUTES - -UFW_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("firmware_url"): cv.string, - vol.Optional("delay_hours"): cv.positive_int, - } -) -CONF_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("ocpp_key"): cv.string, - vol.Required("value"): cv.string, - } -) -GCONF_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("ocpp_key"): cv.string, - } -) -GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("upload_url"): cv.string, - } -) -TRANS_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Required("vendor_id"): cv.string, - vol.Optional("message_id"): cv.string, - vol.Optional("data"): cv.string, - } -) -CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( - { - vol.Optional("limit_amps"): cv.positive_float, - vol.Optional("limit_watts"): cv.positive_int, - vol.Optional("conn_id"): cv.positive_int, - vol.Optional("custom_profile"): vol.Any(cv.string, dict), - } -) - class CentralSystem: """Server for handling OCPP connections.""" @@ -166,22 +66,28 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): self.entry = entry self.host = entry.data.get(CONF_HOST, DEFAULT_HOST) self.port = entry.data.get(CONF_PORT, DEFAULT_PORT) - self.csid = entry.data.get(CONF_CSID, DEFAULT_CSID) - self.cpid = entry.data.get(CONF_CPID, DEFAULT_CPID) - self.websocket_close_timeout = entry.data.get( + + self.settings = CentralSystemSettings() + self.settings.csid = entry.data.get(CONF_CSID, DEFAULT_CSID) + self.settings.cpid = entry.data.get(CONF_CPID, DEFAULT_CPID) + + self.settings.websocket_close_timeout = entry.data.get( CONF_WEBSOCKET_CLOSE_TIMEOUT, DEFAULT_WEBSOCKET_CLOSE_TIMEOUT ) - self.websocket_ping_tries = entry.data.get( + self.settings.websocket_ping_tries = entry.data.get( CONF_WEBSOCKET_PING_TRIES, DEFAULT_WEBSOCKET_PING_TRIES ) - self.websocket_ping_interval = entry.data.get( + self.settings.websocket_ping_interval = entry.data.get( CONF_WEBSOCKET_PING_INTERVAL, DEFAULT_WEBSOCKET_PING_INTERVAL ) - self.websocket_ping_timeout = entry.data.get( + self.settings.websocket_ping_timeout = entry.data.get( CONF_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TIMEOUT ) + self.settings.config = entry.data - self.subprotocol = entry.data.get(CONF_SUBPROTOCOL, DEFAULT_SUBPROTOCOL) + self.subprotocols: list[Subprotocol] = entry.data.get( + CONF_SUBPROTOCOL, DEFAULT_SUBPROTOCOL + ).split(",") self._server = None self.config = entry.data self.id = entry.entry_id @@ -210,10 +116,10 @@ async def create(hass: HomeAssistant, entry: ConfigEntry): self.on_connect, self.host, self.port, - subprotocols=[self.subprotocol], + subprotocols=self.subprotocols, ping_interval=None, # ping interval is not used here, because we send pings mamually in ChargePoint.monitor_connection() ping_timeout=None, - close_timeout=self.websocket_close_timeout, + close_timeout=self.settings.websocket_close_timeout, ssl=self.ssl_context, ) self._server = server @@ -241,14 +147,21 @@ async def on_connect(self, websocket: websockets.server.WebSocketServerProtocol) _LOGGER.info(f"Charger websocket path={websocket.path}") cp_id = websocket.path.strip("/") cp_id = cp_id[cp_id.rfind("/") + 1 :] - if self.cpid not in self.charge_points: + if self.settings.cpid not in self.charge_points: _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") - charge_point = ChargePoint(cp_id, websocket, self.hass, self.entry, self) - self.charge_points[self.cpid] = charge_point + if websocket.subprotocol and websocket.subprotocol.startswith(OCPP_2_0): + charge_point = ChargePointv201( + cp_id, websocket, self.hass, self.entry, self.settings + ) + else: + charge_point = ChargePointv16( + cp_id, websocket, self.hass, self.entry, self.settings + ) + self.charge_points[self.settings.cpid] = charge_point await charge_point.start() else: _LOGGER.info(f"Charger {cp_id} reconnected to {self.host}:{self.port}.") - charge_point: ChargePoint = self.charge_points[self.cpid] + charge_point = self.charge_points[self.settings.cpid] await charge_point.reconnect(websocket) _LOGGER.info(f"Charger {cp_id} disconnected from {self.host}:{self.port}.") @@ -318,1297 +231,8 @@ async def set_charger_state( resp = await self.charge_points[cp_id].unlock() return resp - async def update(self, cp_id: str): - """Update sensors values in HA.""" - er = entity_registry.async_get(self.hass) - dr = device_registry.async_get(self.hass) - identifiers = {(DOMAIN, cp_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) - ) - def device_info(self): """Return device information.""" return { "identifiers": {(DOMAIN, self.id)}, } - - -class ChargePoint(cp): - """Server side representation of a charger.""" - - def __init__( - self, - id: str, - connection: websockets.server.WebSocketServerProtocol, - hass: HomeAssistant, - entry: ConfigEntry, - central: CentralSystem, - interval_meter_metrics: int = 10, - skip_schema_validation: bool = False, - ): - """Instantiate a ChargePoint.""" - - super().__init__(id, connection) - - for action in self.route_map: - self.route_map[action]["_skip_schema_validation"] = skip_schema_validation - - self.interval_meter_metrics = interval_meter_metrics - self.hass = hass - self.entry = entry - self.central = central - self.status = "init" - # Indicates if the charger requires a reboot to apply new - # configuration. - self._requires_reboot = False - self.preparing = asyncio.Event() - self.active_transaction_id: int = 0 - self.triggered_boot_notification = False - self.received_boot_notification = False - 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 - self._attr_supported_features = prof.NONE - self._metrics[cstat.reconnects.value].value: int = 0 - alphabet = string.ascii_uppercase + string.digits - self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) - - async def post_connect(self): - """Logic to be executed right after a charger connects.""" - - # Define custom service handles for charge point - async def handle_clear_profile(call): - """Handle the clear profile service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - await self.clear_profile() - - async def handle_update_firmware(call): - """Handle the firmware update service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("firmware_url") - delay = int(call.data.get("delay_hours", 0)) - await self.update_firmware(url, delay) - - async def handle_configure(call): - """Handle the configure service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - value = call.data.get("value") - await self.configure(key, value) - - async def handle_get_configuration(call): - """Handle the get configuration service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - await self.get_configuration(key) - - async def handle_get_diagnostics(call): - """Handle the get get diagnostics service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("upload_url") - await self.get_diagnostics(url) - - async def handle_data_transfer(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - vendor = call.data.get("vendor_id") - message = call.data.get("message_id", "") - data = call.data.get("data", "") - await self.data_transfer(vendor, message, data) - - async def handle_set_charge_rate(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - amps = call.data.get("limit_amps", None) - watts = call.data.get("limit_watts", None) - id = call.data.get("conn_id", 0) - custom_profile = call.data.get("custom_profile", None) - if custom_profile is not None: - if type(custom_profile) is str: - custom_profile = custom_profile.replace("'", '"') - custom_profile = json.loads(custom_profile) - await self.set_charge_rate(profile=custom_profile, conn_id=id) - elif watts is not None: - await self.set_charge_rate(limit_watts=watts, conn_id=id) - elif amps is not None: - await self.set_charge_rate(limit_amps=amps, conn_id=id) - - try: - self.status = STATE_OK - await asyncio.sleep(2) - await self.get_supported_features() - resp = await self.get_configuration(ckey.number_of_connectors.value) - self._metrics[cdet.connectors.value].value = resp - await self.get_configuration(ckey.heartbeat_interval.value) - - all_measurands = self.entry.data.get( - CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND - ) - autodetect_measurands = self.entry.data.get( - CONF_MONITORED_VARIABLES_AUTOCONFIG, - DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, - ) - - key = ckey.meter_values_sampled_data.value - - if autodetect_measurands: - accepted_measurands = [] - cfg_ok = [ - ConfigurationStatus.accepted, - ConfigurationStatus.reboot_required, - ] - - for measurand in all_measurands.split(","): - _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") - req = call.ChangeConfiguration(key=key, value=measurand) - resp = await self.call(req) - if resp.status in cfg_ok: - _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") - accepted_measurands.append(measurand) - - accepted_measurands = ",".join(accepted_measurands) - else: - accepted_measurands = all_measurands - - # Quirk: - # Workaround for a bug on chargers that have invalid MeterValuesSampledData - # configuration and reboot while the server requests MeterValuesSampledData. - # By setting the configuration directly without checking current configuration - # as done when calling self.configure, the server avoids charger reboot. - # Corresponding issue: https://github.com/lbbrhzn/ocpp/issues/1275 - if len(accepted_measurands) > 0: - req = call.ChangeConfiguration(key=key, value=accepted_measurands) - resp = await self.call(req) - _LOGGER.debug( - f"'{self.id}' measurands set manually to {accepted_measurands}" - ) - - chgr_measurands = await self.get_configuration(key) - - if len(accepted_measurands) > 0: - _LOGGER.debug( - f"'{self.id}' allowed measurands: '{accepted_measurands}'" - ) - await self.configure(key, accepted_measurands) - else: - _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") - _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") - - updated_entry = {**self.entry.data} - updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands - self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) - - await self.configure( - ckey.meter_value_sample_interval.value, - str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), - ) - await self.configure( - ckey.clock_aligned_data_interval.value, - str(self.entry.data.get(CONF_IDLE_INTERVAL, DEFAULT_IDLE_INTERVAL)), - ) - # await self.configure( - # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) - # ) - # await self.start_transaction() - - # Register custom services with home assistant - self.hass.services.async_register( - DOMAIN, - csvcs.service_configure.value, - handle_configure, - CONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_configuration.value, - handle_get_configuration, - GCONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_data_transfer.value, - handle_data_transfer, - TRANS_SERVICE_DATA_SCHEMA, - ) - if prof.SMART in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_set_charge_rate.value, - handle_set_charge_rate, - CHRGR_SERVICE_DATA_SCHEMA, - ) - if prof.FW in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, - csvcs.service_update_firmware.value, - handle_update_firmware, - UFW_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_diagnostics.value, - handle_get_diagnostics, - GDIAG_SERVICE_DATA_SCHEMA, - ) - self.post_connect_success = True - _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") - - # nice to have, but not needed for integration to function - # and can cause issues with some chargers - await self.configure(ckey.web_socket_ping_interval.value, "60") - await self.set_availability() - if prof.REM in self._attr_supported_features: - if self.received_boot_notification is False: - await self.trigger_boot_notification() - await self.trigger_status_notification() - except NotImplementedError as e: - _LOGGER.error("Configuration of the charger failed: %s", e) - - async def get_supported_features(self): - """Get supported features.""" - req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) - resp = await self.call(req) - try: - feature_list = (resp.configuration_key[0][om.value.value]).split(",") - except (IndexError, KeyError, TypeError): - feature_list = [""] - if feature_list[0] == "": - _LOGGER.warning("No feature profiles detected, defaulting to Core") - await self.notify_ha("No feature profiles detected, defaulting to Core") - feature_list = [om.feature_profile_core.value] - if self.central.config.get( - CONF_FORCE_SMART_CHARGING, DEFAULT_FORCE_SMART_CHARGING - ): - _LOGGER.warning("Force Smart Charging feature profile") - self._attr_supported_features |= prof.SMART - for item in feature_list: - item = item.strip().replace(" ", "") - if item == om.feature_profile_core.value: - self._attr_supported_features |= prof.CORE - elif item == om.feature_profile_firmware.value: - self._attr_supported_features |= prof.FW - elif item == om.feature_profile_smart.value: - self._attr_supported_features |= prof.SMART - elif item == om.feature_profile_reservation.value: - self._attr_supported_features |= prof.RES - elif item == om.feature_profile_remote.value: - self._attr_supported_features |= prof.REM - elif item == om.feature_profile_auth.value: - self._attr_supported_features |= prof.AUTH - else: - _LOGGER.warning("Unknown feature profile detected ignoring: %s", item) - await self.notify_ha( - f"Warning: Unknown feature profile detected ignoring {item}" - ) - self._metrics[cdet.features.value].value = self._attr_supported_features - _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) - - async def trigger_boot_notification(self): - """Trigger a boot notification.""" - req = call.TriggerMessage(requested_message=MessageTrigger.boot_notification) - resp = await self.call(req) - if resp.status == TriggerMessageStatus.accepted: - self.triggered_boot_notification = True - return True - else: - self.triggered_boot_notification = False - _LOGGER.warning("Failed with response: %s", resp.status) - return False - - async def trigger_status_notification(self): - """Trigger status notifications for all connectors.""" - return_value = True - nof_connectors = int(self._metrics[cdet.connectors.value].value) - for id in range(0, nof_connectors + 1): - _LOGGER.debug(f"trigger status notification for connector={id}") - req = call.TriggerMessage( - requested_message=MessageTrigger.status_notification, - connector_id=int(id), - ) - resp = await self.call(req) - if resp.status != TriggerMessageStatus.accepted: - _LOGGER.warning("Failed with response: %s", resp.status) - return_value = False - return return_value - - async def clear_profile(self): - """Clear all charging profiles.""" - req = call.ClearChargingProfile() - resp = await self.call(req) - if resp.status == ClearChargingProfileStatus.accepted: - 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 - - async def set_charge_rate( - self, - limit_amps: int = 32, - limit_watts: int = 22000, - 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 - req = call.SetChargingProfile( - connector_id=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} - ], - }, - }, - ) - else: - _LOGGER.info("Smart charging is not supported by this charger") - return False - 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 <= - 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} - ], - }, - }, - ) - 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): - """Change availability.""" - if state is True: - typ = AvailabilityType.operative.value - else: - typ = AvailabilityType.inoperative.value - - req = call.ChangeAvailability(connector_id=0, type=typ) - resp = await self.call(req) - if resp.status == AvailabilityStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set availability failed with response {resp.status}" - ) - return False - - async def start_transaction(self): - """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) - resp = await self.call(req) - if resp.status == RemoteStartStopStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Start transaction failed with response {resp.status}" - ) - return False - - async def stop_transaction(self): - """Request remote stop of current transaction. - - Leaves charger in finishing state until unplugged. - Use reset() to make the charger available again for remote start - """ - if self.active_transaction_id == 0: - return True - req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) - resp = await self.call(req) - if resp.status == RemoteStartStopStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Stop transaction failed with response {resp.status}" - ) - return False - - async def reset(self, typ: str = ResetType.hard): - """Hard reset charger unless soft reset requested.""" - self._metrics[cstat.reconnects.value].value = 0 - req = call.Reset(typ) - resp = await self.call(req) - if resp.status == ResetStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha(f"Warning: Reset failed with response {resp.status}") - return False - - async def unlock(self, connector_id: int = 1): - """Unlock charger if requested.""" - req = call.UnlockConnector(connector_id) - resp = await self.call(req) - if resp.status == UnlockStatus.unlocked: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha(f"Warning: Unlock failed with response {resp.status}") - 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) - resp = await self.call(req) - _LOGGER.info("Response: %s", resp) - return True - else: - _LOGGER.warning("Charger does not support ocpp firmware updating") - 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: - 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) - resp = await self.call(req) - _LOGGER.info("Response: %s", resp) - return True - else: - _LOGGER.warning("Charger does not support ocpp diagnostics uploading") - return False - - async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): - """Request vendor specific data transfer from charger.""" - req = call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data) - resp = await self.call(req) - if resp.status == DataTransferStatus.accepted: - _LOGGER.info( - "Data transfer [vendorId(%s), messageId(%s), data(%s)] response: %s", - vendor_id, - message_id, - 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} - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Data transfer failed with response {resp.status}" - ) - return False - - async def get_configuration(self, key: str = ""): - """Get Configuration of charger for supported keys else return None.""" - if key == "": - req = call.GetConfiguration() - else: - req = call.GetConfiguration(key=[key]) - resp = await self.call(req) - 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} - return value - if resp.unknown_key: - _LOGGER.warning("Get Configuration returned unknown key for: %s", key) - await self.notify_ha(f"Warning: charger reports {key} is unknown") - return None - - async def configure(self, key: str, value: str): - """Configure charger by setting the key to target value. - - First the configuration key is read using GetConfiguration. The key's - value is compared with the target value. If the key is already set to - the correct value nothing is done. - - If the key has a different value a ChangeConfiguration request is issued. - - """ - req = call.GetConfiguration(key=[key]) - - resp = await self.call(req) - - if resp.unknown_key is not None: - if key in resp.unknown_key: - _LOGGER.warning("%s is unknown (not supported)", key) - return - - for key_value in resp.configuration_key: - # If the key already has the targeted value we don't need to set - # it. - if key_value[om.key.value] == key and key_value[om.value.value] == value: - return - - if key_value.get(om.readonly.name, False): - _LOGGER.warning("%s is a read only setting", key) - await self.notify_ha(f"Warning: {key} is read-only") - - req = call.ChangeConfiguration(key=key, value=value) - - resp = await self.call(req) - - if resp.status in [ - ConfigurationStatus.rejected, - ConfigurationStatus.not_supported, - ]: - _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) - await self.notify_ha( - f"Warning: charger reported {resp.status} while setting {key}={value}" - ) - - if resp.status == ConfigurationStatus.reboot_required: - self._requires_reboot = True - await self.notify_ha(f"A reboot is required to apply {key}={value}") - - async def _get_specific_response(self, unique_id, timeout): - # The ocpp library silences CallErrors by default. See - # https://github.com/mobilityhouse/ocpp/issues/104. - # This code 'unsilences' CallErrors by raising them as exception - # upon receiving. - resp = await super()._get_specific_response(unique_id, timeout) - - if isinstance(resp, CallError): - raise resp.to_exception() - - return resp - - 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" - connection = self._connection - timeout_counter = 0 - while connection.open: - try: - await asyncio.sleep(self.central.websocket_ping_interval) - time0 = time.perf_counter() - latency_ping = self.central.websocket_ping_timeout * 1000 - pong_waiter = await asyncio.wait_for( - connection.ping(), timeout=self.central.websocket_ping_timeout - ) - time1 = time.perf_counter() - latency_ping = round(time1 - time0, 3) * 1000 - latency_pong = self.central.websocket_ping_timeout * 1000 - await asyncio.wait_for( - pong_waiter, timeout=self.central.websocket_ping_timeout - ) - timeout_counter = 0 - time2 = time.perf_counter() - latency_pong = round(time2 - time1, 3) * 1000 - _LOGGER.debug( - f"Connection latency from '{self.central.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 - - except TimeoutError as timeout_exception: - _LOGGER.debug( - f"Connection latency from '{self.central.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 - timeout_counter += 1 - if timeout_counter > self.central.websocket_ping_tries: - _LOGGER.debug( - f"Connection to '{self.id}' timed out after '{self.central.websocket_ping_tries}' ping tries", - ) - raise timeout_exception - else: - continue - - async def _handle_call(self, msg): - try: - await super()._handle_call(msg) - except NotImplementedError as e: - response = msg.create_call_error(e).to_json() - await self._send(response) - - async def start(self): - """Start charge point.""" - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) - - async def run(self, tasks): - """Run a specified list of tasks.""" - self.tasks = [asyncio.ensure_future(task) for task in tasks] - try: - await asyncio.gather(*self.tasks) - except TimeoutError: - pass - except websockets.exceptions.WebSocketException as websocket_exception: - _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") - except Exception as other_exception: - _LOGGER.error( - f"Unexpected exception in connection to '{self.id}': '{other_exception}'", - exc_info=True, - ) - finally: - await self.stop() - - async def stop(self): - """Close connection and cancel ongoing tasks.""" - self.status = STATE_UNAVAILABLE - if self._connection.open: - _LOGGER.debug(f"Closing websocket to '{self.id}'") - await self._connection.close() - for task in self.tasks: - task.cancel() - - async def reconnect(self, connection: websockets.server.WebSocketServerProtocol): - """Reconnect charge point.""" - _LOGGER.debug(f"Reconnect websocket to {self.id}") - - await self.stop() - self.status = STATE_OK - self._connection = connection - self._metrics[cstat.reconnects.value].value += 1 - if self.post_connect_success is True: - await self.run([super().start(), self.monitor_connection()]) - else: - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) - - async def async_update_device_info(self, boot_info: dict): - """Update device info asynchronuously.""" - - _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) - identifiers = { - (DOMAIN, self.central.cpid), - (DOMAIN, self.id), - } - serial = boot_info.get(om.charge_point_serial_number.name, None) - if serial is not None: - identifiers.add((DOMAIN, serial)) - - registry = device_registry.async_get(self.hass) - registry.async_get_or_create( - config_entry_id=self.entry.entry_id, - identifiers=identifiers, - name=self.central.cpid, - manufacturer=boot_info.get(om.charge_point_vendor.name, None), - model=boot_info.get(om.charge_point_model.name, None), - suggested_area="Garage", - sw_version=boot_info.get(om.firmware_version.name, None), - ) - - def process_phases(self, data): - """Process phase data from meter values .""" - - def average_of_nonzero(values): - nonzero_values: list = [v for v in values if float(v) != 0.0] - nof_values: int = len(nonzero_values) - average = sum(nonzero_values) / nof_values if nof_values > 0 else 0 - return average - - measurand_data = {} - for item in data: - # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} - measurand = item.get(om.measurand.value, None) - phase = item.get(om.phase.value, None) - value = item.get(om.value.value, None) - unit = item.get(om.unit.value, None) - context = item.get(om.context.value, None) - # where an empty string is supplied convert to 0 - try: - value = float(value) - except ValueError: - value = 0 - if measurand is not None and phase is not None and unit is not None: - if measurand not in measurand_data: - 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 - - 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] - line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] - - for metric, phase_info in measurand_data.items(): - metric_value = None - if metric in [Measurand.voltage.value]: - if not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Line to neutral voltages are averaged - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_neutral_phases] - ) - elif not phase_info.keys().isdisjoint(line_to_line_phases): - # Line to line voltages are averaged and converted to line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_line_phases] - ) / sqrt(3) - elif not phase_info.keys().isdisjoint(line_phases): - # Workaround for chargers that don't follow engineering convention - # Assumes voltages are line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_phases] - ) - else: - if not phase_info.keys().isdisjoint(line_phases): - metric_value = sum( - phase_info.get(phase, 0) for phase in line_phases - ) - elif not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Workaround for some chargers that erroneously use line to neutral for current - metric_value = sum( - phase_info.get(phase, 0) for phase in line_to_neutral_phases - ) - - 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[metric].value = float(metric_value) / 1000 - self._metrics[metric].unit = HA_POWER_UNIT - elif metric_unit == DEFAULT_ENERGY_UNIT: - self._metrics[metric].value = float(metric_value) / 1000 - self._metrics[metric].unit = HA_ENERGY_UNIT - else: - self._metrics[metric].value = float(metric_value) - self._metrics[metric].unit = metric_unit - - @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) - - # 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: - value = self.get_ha_metric(csess.meter_start.value) - if value is None: - value = self._metrics[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: - value = self.get_ha_metric(csess.transaction_id.value) - if value is None: - value = kwargs.get(om.transaction_id.name) - 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 - - 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) - - for bucket in meter_value: - unprocessed = bucket[om.sampled_value.name] - processed_keys = [] - for idx, sampled_value in enumerate(bucket[om.sampled_value.name]): - measurand = sampled_value.get(om.measurand.value, None) - value = sampled_value.get(om.value.value, None) - 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) - # where an empty string is supplied convert to 0 - try: - value = float(value) - except ValueError: - value = 0 - - if len(sampled_value.keys()) == 1: # Backwards compatibility - measurand = DEFAULT_MEASURAND - unit = DEFAULT_ENERGY_UNIT - - if measurand == DEFAULT_MEASURAND and unit is None: - unit = DEFAULT_ENERGY_UNIT - - if self._metrics[csess.meter_start.value].value == 0: - # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. - self._charger_reports_session_energy = True - - if phase is None: - if unit == DEFAULT_POWER_UNIT: - self._metrics[measurand].value = value / 1000 - self._metrics[measurand].unit = HA_POWER_UNIT - elif ( - measurand == DEFAULT_MEASURAND - and self._charger_reports_session_energy - ): - if transaction_matches: - if unit == DEFAULT_ENERGY_UNIT: - value = value / 1000 - unit = HA_ENERGY_UNIT - 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 - else: - if unit == DEFAULT_ENERGY_UNIT: - value = value / 1000 - unit = HA_ENERGY_UNIT - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit - elif unit == DEFAULT_ENERGY_UNIT: - if transaction_matches: - self._metrics[measurand].value = value / 1000 - self._metrics[measurand].unit = HA_ENERGY_UNIT - else: - self._metrics[measurand].value = value - self._metrics[measurand].unit = unit - if location is not None: - self._metrics[measurand].extra_attr[om.location.value] = ( - location - ) - if context is not None: - self._metrics[measurand].extra_attr[om.context.value] = context - processed_keys.append(idx) - for idx in sorted(processed_keys, reverse=True): - unprocessed.pop(idx) - # _LOGGER.debug("Meter data not yet processed: %s", unprocessed) - if unprocessed is not None: - self.process_phases(unprocessed) - if transaction_matches: - self._metrics[csess.session_time.value].value = round( - ( - int(time.time()) - - float(self._metrics[csess.transaction_id.value].value) - ) - / 60 - ) - self._metrics[csess.session_time.value].unit = "min" - if ( - self._metrics[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[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.MeterValues() - - @on(Action.boot_notification) - def on_boot_notification(self, **kwargs): - """Handle a boot notification.""" - resp = call_result.BootNotification( - current_time=datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - interval=3600, - status=RegistrationStatus.accepted.value, - ) - self.received_boot_notification = True - _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) - # update metrics - self._metrics[cdet.model.value].value = kwargs.get( - om.charge_point_model.name, None - ) - self._metrics[cdet.vendor.value].value = kwargs.get( - om.charge_point_vendor.name, None - ) - self._metrics[cdet.firmware_version.value].value = kwargs.get( - om.firmware_version.name, None - ) - self._metrics[cdet.serial.value].value = kwargs.get( - om.charge_point_serial_number.name, None - ) - - self.hass.async_create_task(self.async_update_device_info(kwargs)) - self.hass.async_create_task(self.central.update(self.central.cpid)) - if self.triggered_boot_notification is False: - self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) - self.hass.async_create_task(self.post_connect()) - return resp - - @on(Action.status_notification) - 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.hass.async_create_task(self.central.update(self.central.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.hass.async_create_task(self.central.update(self.central.cpid)) - self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) - return call_result.FirmwareStatusNotification() - - @on(Action.diagnostics_status_notification) - def on_diagnostics_status(self, status, **kwargs): - """Handle diagnostics status notification.""" - _LOGGER.info("Diagnostics upload status: %s", status) - self.hass.async_create_task( - self.notify_ha(f"Diagnostics upload status: {status}") - ) - return call_result.DiagnosticsStatusNotification() - - @on(Action.security_event_notification) - def on_security_event(self, type, timestamp, **kwargs): - """Handle security event notification.""" - _LOGGER.info( - "Security event notification received: %s at %s [techinfo: %s]", - type, - timestamp, - kwargs.get(om.tech_info.name, "none"), - ) - self.hass.async_create_task( - self.notify_ha(f"Security event notification received: {type}") - ) - return call_result.SecurityEventNotification() - - def get_authorization_status(self, id_tag): - """Get the authorization status for an 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( - CONF_DEFAULT_AUTH_STATUS, AuthorizationStatus.accepted.value - ) - # get the authorization list - auth_list = config.get(CONF_AUTH_LIST, {}) - # search for the entry, based on the id_tag - auth_status = None - for auth_entry in auth_list: - id_entry = auth_entry.get(CONF_ID_TAG, None) - if id_tag == id_entry: - # get the authorization status, use the default if not configured - auth_status = auth_entry.get(CONF_AUTH_STATUS, default_auth_status) - _LOGGER.debug( - f"id_tag='{id_tag}' found in auth_list, authorization_status='{auth_status}'" - ) - break - - if auth_status is None: - auth_status = default_auth_status - _LOGGER.debug( - f"id_tag='{id_tag}' not found in auth_list, default authorization_status='{auth_status}'" - ) - return auth_status - - @on(Action.authorize) - def on_authorize(self, id_tag, **kwargs): - """Handle an Authorization request.""" - self._metrics[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}) - - @on(Action.start_transaction) - def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): - """Handle a Start Transaction request.""" - - 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 - result = call_result.StartTransaction( - id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, - transaction_id=self.active_transaction_id, - ) - else: - result = call_result.StartTransaction( - id_tag_info={om.status.value: auth_status}, transaction_id=0 - ) - self.hass.async_create_task(self.central.update(self.central.cpid)) - return result - - @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: - _LOGGER.error( - "Stop transaction received for unknown transaction id=%i", - transaction_id, - ) - self.active_transaction_id = 0 - self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) - if ( - self._metrics[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 - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StopTransaction( - id_tag_info={om.status.value: AuthorizationStatus.accepted.value} - ) - - @on(Action.data_transfer) - 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} - 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.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) - - @property - def supported_features(self) -> int: - """Flag of Ocpp features that are supported.""" - return self._attr_supported_features - - def get_metric(self, measurand: str): - """Return last known value for given measurand.""" - return self._metrics[measurand].value - - def get_ha_metric(self, measurand: str): - """Return last known value in HA for given measurand.""" - entity_id = "sensor." + "_".join( - [self.central.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 - - def get_extra_attr(self, measurand: str): - """Return last known extra attributes for given measurand.""" - return self._metrics[measurand].extra_attr - - def get_unit(self, measurand: str): - """Return unit of given measurand.""" - return self._metrics[measurand].unit - - def get_ha_unit(self, measurand: str): - """Return home assistant unit of given measurand.""" - return self._metrics[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( - PN_DOMAIN, - "create", - service_data={ - "title": title, - "message": msg, - }, - blocking=False, - ) - return True - - -class Metric: - """Metric class.""" - - def __init__(self, value, unit): - """Initialize a Metric.""" - self._value = value - self._unit = unit - self._extra_attr = {} - - @property - def value(self): - """Get the value of the metric.""" - return self._value - - @value.setter - def value(self, value): - """Set the value of the metric.""" - self._value = value - - @property - def unit(self): - """Get the unit of the metric.""" - return self._unit - - @unit.setter - def unit(self, unit: str): - """Set the unit of the metric.""" - self._unit = unit - - @property - def ha_unit(self): - """Get the home assistant unit of the metric.""" - return UNITS_OCCP_TO_HA.get(self._unit, self._unit) - - @property - def extra_attr(self): - """Get the extra attributes of the metric.""" - return self._extra_attr - - @extra_attr.setter - def extra_attr(self, extra_attr: dict): - """Set the unit of the metric.""" - self._extra_attr = extra_attr diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py new file mode 100644 index 00000000..2655fd11 --- /dev/null +++ b/custom_components/ocpp/chargepoint.py @@ -0,0 +1,851 @@ +"""Common classes for charge points of all OCPP versions.""" + +import asyncio +from collections import defaultdict +from dataclasses import dataclass +from enum import Enum +import json +import logging +from math import sqrt +import secrets +import string +import time +from types import MappingProxyType +from typing import Any + +from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +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 +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +import websockets.server + +from ocpp.charge_point import ChargePoint as cp +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 +from ocpp.v201 import call as callv201 +from ocpp.v201 import call_result as call_resultv201 +from ocpp.messages import CallError +from ocpp.exceptions import NotImplementedError + +from .enums import ( + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, + Profiles as prof, +) + +from .const import ( + CONF_AUTH_LIST, + CONF_AUTH_STATUS, + CONF_DEFAULT_AUTH_STATUS, + CONF_ID_TAG, + CONF_MONITORED_VARIABLES, + CONFIG, + DEFAULT_ENERGY_UNIT, + DEFAULT_POWER_UNIT, + DEFAULT_MEASURAND, + DOMAIN, + HA_ENERGY_UNIT, + HA_POWER_UNIT, + UNITS_OCCP_TO_HA, +) + +UFW_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("firmware_url"): cv.string, + vol.Optional("delay_hours"): cv.positive_int, + } +) +CONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("ocpp_key"): cv.string, + vol.Required("value"): cv.string, + } +) +GCONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("ocpp_key"): cv.string, + } +) +GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("upload_url"): cv.string, + } +) +TRANS_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("vendor_id"): cv.string, + vol.Optional("message_id"): cv.string, + vol.Optional("data"): cv.string, + } +) +CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("limit_amps"): cv.positive_float, + vol.Optional("limit_watts"): cv.positive_int, + vol.Optional("conn_id"): cv.positive_int, + vol.Optional("custom_profile"): vol.Any(cv.string, dict), + } +) + +TIME_MINUTES = UnitOfTime.MINUTES +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + + +class CentralSystemSettings: + """A subset of CentralSystem properties needed by a ChargePoint.""" + + websocket_close_timeout: int + websocket_ping_interval: int + websocket_ping_timeout: int + websocket_ping_tries: int + csid: str + cpid: str + config: MappingProxyType[str, Any] + + +class Metric: + """Metric class.""" + + def __init__(self, value, unit): + """Initialize a Metric.""" + self._value = value + self._unit = unit + self._extra_attr = {} + + @property + def value(self): + """Get the value of the metric.""" + return self._value + + @value.setter + def value(self, value): + """Set the value of the metric.""" + self._value = value + + @property + def unit(self): + """Get the unit of the metric.""" + return self._unit + + @unit.setter + def unit(self, unit: str): + """Set the unit of the metric.""" + self._unit = unit + + @property + def ha_unit(self): + """Get the home assistant unit of the metric.""" + return UNITS_OCCP_TO_HA.get(self._unit, self._unit) + + @property + def extra_attr(self): + """Get the extra attributes of the metric.""" + return self._extra_attr + + @extra_attr.setter + def extra_attr(self, extra_attr: dict): + """Set the unit of the metric.""" + self._extra_attr = extra_attr + + +class OcppVersion(str, Enum): + """OCPP version choice.""" + + V16 = "1.6" + V201 = "2.0.1" + + +class SetVariableResult(Enum): + """A response to successful SetVariable call.""" + + accepted = 0 + reboot_required = 1 + + +@dataclass +class MeasurandValue: + """Version-independent representation of a measurand.""" + + measurand: str + value: float + phase: str | None + unit: str | None + context: str | None + location: str | None + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + def __init__( + self, + id, + connection, + version: OcppVersion, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + interval_meter_metrics: int, + skip_schema_validation: bool, + ): + """Instantiate a ChargePoint.""" + + super().__init__(id, connection, 10) + if version == OcppVersion.V16: + self._call = callv16 + self._call_result = call_resultv16 + self._ocpp_version = "1.6" + elif version == OcppVersion.V201: + self._call = callv201 + self._call_result = call_resultv201 + self._ocpp_version = "2.0.1" + + for action in self.route_map: + self.route_map[action]["_skip_schema_validation"] = skip_schema_validation + + self.interval_meter_metrics = interval_meter_metrics + self.hass = hass + self.entry = entry + self.central = central + self.status = "init" + # Indicates if the charger requires a reboot to apply new + # configuration. + self._requires_reboot = False + self.preparing = asyncio.Event() + self.active_transaction_id: int = 0 + self.triggered_boot_notification = False + self.received_boot_notification = False + 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 + 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)) + + async def get_number_of_connectors(self) -> int: + """Return number of connectors on this charger.""" + return 0 + + async def get_heartbeat_interval(self): + """Retrieve heartbeat interval from the charger and store it.""" + pass + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + return "" + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + pass + + def register_version_specific_services(self): + """Register HA services that differ depending on OCPP version.""" + pass + + async def get_supported_features(self) -> prof: + """Get features supported by the charger.""" + return prof.NONE + + 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 + _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) + + async def post_connect(self): + """Logic to be executed right after a charger connects.""" + + # Define custom service handles for charge point + async def handle_clear_profile(call): + """Handle the clear profile service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + await self.clear_profile() + + async def handle_update_firmware(call): + """Handle the firmware update service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + url = call.data.get("firmware_url") + delay = int(call.data.get("delay_hours", 0)) + await self.update_firmware(url, delay) + + async def handle_get_diagnostics(call): + """Handle the get get diagnostics service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + url = call.data.get("upload_url") + await self.get_diagnostics(url) + + async def handle_data_transfer(call): + """Handle the data transfer service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + vendor = call.data.get("vendor_id") + message = call.data.get("message_id", "") + data = call.data.get("data", "") + await self.data_transfer(vendor, message, data) + + async def handle_set_charge_rate(call): + """Handle the data transfer service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + amps = call.data.get("limit_amps", None) + watts = call.data.get("limit_watts", None) + id = call.data.get("conn_id", 0) + custom_profile = call.data.get("custom_profile", None) + if custom_profile is not None: + if type(custom_profile) is str: + custom_profile = custom_profile.replace("'", '"') + custom_profile = json.loads(custom_profile) + await self.set_charge_rate(profile=custom_profile, conn_id=id) + elif watts is not None: + await self.set_charge_rate(limit_watts=watts, conn_id=id) + elif amps is not None: + await self.set_charge_rate(limit_amps=amps, conn_id=id) + + """Logic to be executed right after a charger connects.""" + + try: + self.status = STATE_OK + await asyncio.sleep(2) + await self.fetch_supported_features() + num_connectors: int = await self.get_number_of_connectors() + self._metrics[cdet.connectors.value].value = num_connectors + await self.get_heartbeat_interval() + + accepted_measurands: str = await self.get_supported_measurands() + updated_entry = {**self.entry.data} + updated_entry[CONF_MONITORED_VARIABLES] = accepted_measurands + self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) + + await self.set_standard_configuration() + + # Register custom services with home assistant + self.register_version_specific_services() + self.hass.services.async_register( + DOMAIN, + csvcs.service_data_transfer.value, + handle_data_transfer, + TRANS_SERVICE_DATA_SCHEMA, + ) + if prof.SMART in self._attr_supported_features: + self.hass.services.async_register( + DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_set_charge_rate.value, + handle_set_charge_rate, + CHRGR_SERVICE_DATA_SCHEMA, + ) + if prof.FW in self._attr_supported_features: + self.hass.services.async_register( + DOMAIN, + csvcs.service_update_firmware.value, + handle_update_firmware, + UFW_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_diagnostics.value, + handle_get_diagnostics, + GDIAG_SERVICE_DATA_SCHEMA, + ) + self.post_connect_success = True + _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") + + # nice to have, but not needed for integration to function + # and can cause issues with some chargers + await self.set_availability() + if prof.REM in self._attr_supported_features: + if self.received_boot_notification is False: + await self.trigger_boot_notification() + await self.trigger_status_notification() + except NotImplementedError as e: + _LOGGER.error("Configuration of the charger failed: %s", e) + + async def trigger_boot_notification(self): + """Trigger a boot notification.""" + pass + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + pass + + async def clear_profile(self): + """Clear all charging profiles.""" + pass + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit.""" + pass + + async def set_availability(self, state: bool = True) -> bool: + """Change availability.""" + return False + + async def start_transaction(self) -> bool: + """Remote start a transaction.""" + return False + + async def stop_transaction(self) -> bool: + """Request remote stop of current transaction. + + Leaves charger in finishing state until unplugged. + Use reset() to make the charger available again for remote start + """ + return False + + async def reset(self, typ: str | None = None) -> bool: + """Hard reset charger unless soft reset requested.""" + return False + + async def unlock(self, connector_id: int = 1) -> bool: + """Unlock charger if requested.""" + 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""" + pass + + async def get_diagnostics(self, upload_url: str): + """Upload diagnostic data to server from charger.""" + pass + + async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): + """Request vendor specific data transfer from charger.""" + pass + + async def get_configuration(self, key: str = "") -> str | None: + """Get Configuration of charger for supported keys else return None.""" + return None + + async def configure(self, key: str, value: str) -> SetVariableResult | None: + """Configure charger by setting the key to target value.""" + return None + + async def _get_specific_response(self, unique_id, timeout): + # The ocpp library silences CallErrors by default. See + # https://github.com/mobilityhouse/ocpp/issues/104. + # This code 'unsilences' CallErrors by raising them as exception + # upon receiving. + resp = await super()._get_specific_response(unique_id, timeout) + + if isinstance(resp, CallError): + raise resp.to_exception() + + return resp + + 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" + connection = self._connection + timeout_counter = 0 + while connection.open: + try: + await asyncio.sleep(self.central.websocket_ping_interval) + time0 = time.perf_counter() + latency_ping = self.central.websocket_ping_timeout * 1000 + pong_waiter = await asyncio.wait_for( + connection.ping(), timeout=self.central.websocket_ping_timeout + ) + time1 = time.perf_counter() + latency_ping = round(time1 - time0, 3) * 1000 + latency_pong = self.central.websocket_ping_timeout * 1000 + await asyncio.wait_for( + pong_waiter, timeout=self.central.websocket_ping_timeout + ) + timeout_counter = 0 + time2 = time.perf_counter() + latency_pong = round(time2 - time1, 3) * 1000 + _LOGGER.debug( + f"Connection latency from '{self.central.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 + + except TimeoutError as timeout_exception: + _LOGGER.debug( + f"Connection latency from '{self.central.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 + timeout_counter += 1 + if timeout_counter > self.central.websocket_ping_tries: + _LOGGER.debug( + f"Connection to '{self.id}' timed out after '{self.central.websocket_ping_tries}' ping tries", + ) + raise timeout_exception + else: + continue + + async def _handle_call(self, msg): + try: + await super()._handle_call(msg) + except NotImplementedError as e: + response = msg.create_call_error(e).to_json() + await self._send(response) + + async def start(self): + """Start charge point.""" + await self.run( + [super().start(), self.post_connect(), self.monitor_connection()] + ) + + async def run(self, tasks): + """Run a specified list of tasks.""" + self.tasks = [asyncio.ensure_future(task) for task in tasks] + try: + await asyncio.gather(*self.tasks) + except TimeoutError: + pass + except websockets.exceptions.WebSocketException as websocket_exception: + _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") + except Exception as other_exception: + _LOGGER.error( + f"Unexpected exception in connection to '{self.id}': '{other_exception}'", + exc_info=True, + ) + finally: + await self.stop() + + async def stop(self): + """Close connection and cancel ongoing tasks.""" + self.status = STATE_UNAVAILABLE + if self._connection.open: + _LOGGER.debug(f"Closing websocket to '{self.id}'") + await self._connection.close() + for task in self.tasks: + task.cancel() + + async def reconnect(self, connection: websockets.server.WebSocketServerProtocol): + """Reconnect charge point.""" + _LOGGER.debug(f"Reconnect websocket to {self.id}") + + await self.stop() + self.status = STATE_OK + self._connection = connection + self._metrics[cstat.reconnects.value].value += 1 + if self.post_connect_success is True: + await self.run([super().start(), self.monitor_connection()]) + else: + await self.run( + [super().start(), self.post_connect(), self.monitor_connection()] + ) + + async def async_update_device_info( + self, serial: str, vendor: str, model: str, firmware_version: str + ): + """Update device info asynchronuously.""" + + 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 + + identifiers = { + (DOMAIN, self.central.cpid), + (DOMAIN, self.id), + } + if serial is not None: + identifiers.add((DOMAIN, serial)) + + registry = device_registry.async_get(self.hass) + registry.async_get_or_create( + config_entry_id=self.entry.entry_id, + identifiers=identifiers, + name=self.central.cpid, + manufacturer=vendor, + model=model, + suggested_area="Garage", + sw_version=firmware_version, + ) + + def _register_boot_notification(self): + self.hass.async_create_task(self.update(self.central.cpid)) + if self.triggered_boot_notification is False: + self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) + self.hass.async_create_task(self.post_connect()) + + async def update(self, cp_id: str): + """Update sensors values in HA.""" + er = entity_registry.async_get(self.hass) + dr = device_registry.async_get(self.hass) + identifiers = {(DOMAIN, cp_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) + ) + + def get_authorization_status(self, id_tag): + """Get the authorization status for an 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( + CONF_DEFAULT_AUTH_STATUS, AuthorizationStatus.accepted.value + ) + # get the authorization list + auth_list = config.get(CONF_AUTH_LIST, {}) + # search for the entry, based on the id_tag + auth_status = None + for auth_entry in auth_list: + id_entry = auth_entry.get(CONF_ID_TAG, None) + if id_tag == id_entry: + # get the authorization status, use the default if not configured + auth_status = auth_entry.get(CONF_AUTH_STATUS, default_auth_status) + _LOGGER.debug( + f"id_tag='{id_tag}' found in auth_list, authorization_status='{auth_status}'" + ) + break + + if auth_status is None: + auth_status = default_auth_status + _LOGGER.debug( + f"id_tag='{id_tag}' not found in auth_list, default authorization_status='{auth_status}'" + ) + return auth_status + + def process_phases(self, data: list[MeasurandValue]): + """Process phase data from meter values .""" + + def average_of_nonzero(values): + nonzero_values: list = [v for v in values if v != 0.0] + nof_values: int = len(nonzero_values) + average = sum(nonzero_values) / nof_values if nof_values > 0 else 0 + return average + + measurand_data = {} + for item in data: + # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} + measurand = item.measurand + phase = item.phase + value = item.value + unit = item.unit + context = item.context + if measurand is not None and phase is not None and unit is not None: + if measurand not in measurand_data: + 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 + + 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] + line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] + + for metric, phase_info in measurand_data.items(): + metric_value = None + if metric in [Measurand.voltage.value]: + if not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Line to neutral voltages are averaged + metric_value = average_of_nonzero( + [phase_info.get(phase, 0) for phase in line_to_neutral_phases] + ) + elif not phase_info.keys().isdisjoint(line_to_line_phases): + # Line to line voltages are averaged and converted to line to neutral + metric_value = average_of_nonzero( + [phase_info.get(phase, 0) for phase in line_to_line_phases] + ) / sqrt(3) + elif not phase_info.keys().isdisjoint(line_phases): + # Workaround for chargers that don't follow engineering convention + # Assumes voltages are line to neutral + metric_value = average_of_nonzero( + [phase_info.get(phase, 0) for phase in line_phases] + ) + else: + if not phase_info.keys().isdisjoint(line_phases): + metric_value = sum( + phase_info.get(phase, 0) for phase in line_phases + ) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Workaround for some chargers that erroneously use line to neutral for current + metric_value = sum( + phase_info.get(phase, 0) for phase in line_to_neutral_phases + ) + + 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[metric].value = metric_value / 1000 + self._metrics[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 + else: + self._metrics[metric].value = metric_value + self._metrics[metric].unit = metric_unit + + @staticmethod + def get_energy_kwh(measurand_value: MeasurandValue) -> float: + """Convert energy value from charger to kWh.""" + if (measurand_value.unit == "Wh") or (measurand_value.unit is None): + return measurand_value.value / 1000 + return measurand_value.value + + def process_measurands( + self, meter_values: list[list[MeasurandValue]], is_transaction: bool + ): + """Process all value 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] + 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 + measurand = DEFAULT_MEASURAND + unit = DEFAULT_ENERGY_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) + unit = HA_ENERGY_UNIT + + if unit == DEFAULT_POWER_UNIT: + value = value / 1000 + unit = HA_POWER_UNIT + + if self._metrics[csess.meter_start.value].value == 0: + # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. + self._charger_reports_session_energy = True + + if phase is None: + if ( + measurand == DEFAULT_MEASURAND + and self._charger_reports_session_energy + ): + 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 + else: + self._metrics[measurand].value = value + self._metrics[measurand].unit = unit + else: + self._metrics[measurand].value = value + self._metrics[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 + ) + self._metrics[csess.session_energy.value].unit = unit + if location is not None: + self._metrics[measurand].extra_attr[om.location.value] = ( + location + ) + if context is not None: + self._metrics[measurand].extra_attr[om.context.value] = context + else: + unprocessed.append(sampled_value) + self.process_phases(unprocessed) + + @property + def supported_features(self) -> int: + """Flag of Ocpp features that are supported.""" + return self._attr_supported_features + + def get_metric(self, measurand: str): + """Return last known value for given measurand.""" + return self._metrics[measurand].value + + def get_ha_metric(self, measurand: str): + """Return last known value in HA for given measurand.""" + entity_id = "sensor." + "_".join( + [self.central.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 + + def get_extra_attr(self, measurand: str): + """Return last known extra attributes for given measurand.""" + return self._metrics[measurand].extra_attr + + def get_unit(self, measurand: str): + """Return unit of given measurand.""" + return self._metrics[measurand].unit + + def get_ha_unit(self, measurand: str): + """Return home assistant unit of given measurand.""" + return self._metrics[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( + PN_DOMAIN, + "create", + service_data={ + "title": title, + "message": msg, + }, + blocking=False, + ) + return True diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 74e3b2cf..3a924868 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -49,7 +49,8 @@ DEFAULT_SSL = False DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem") DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem") -DEFAULT_SUBPROTOCOL = "ocpp1.6" +DEFAULT_SUBPROTOCOL = "ocpp1.6,ocpp2.0.1" +OCPP_2_0 = "ocpp2.0" DEFAULT_METER_INTERVAL = 60 DEFAULT_IDLE_INTERVAL = 900 DEFAULT_WEBSOCKET_CLOSE_TIMEOUT = 10 diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index bc3b5ccb..8539ba8d 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -15,7 +15,9 @@ class HAChargerServices(str, Enum): service_unlock = "unlock" service_update_firmware = "update_firmware" service_configure = "configure" + service_configure_v201 = "configure_v201" service_get_configuration = "get_configuration" + service_get_configuration_v201 = "get_configuration_v201" service_get_diagnostics = "get_diagnostics" service_clear_profile = "clear_profile" service_data_transfer = "data_transfer" diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py new file mode 100644 index 00000000..4efc7569 --- /dev/null +++ b/custom_components/ocpp/ocppv16.py @@ -0,0 +1,834 @@ +"""Representation of a OCPP 1.6 charging station.""" + +from datetime import datetime, timedelta, UTC +import logging + +from homeassistant.const import STATE_UNAVAILABLE +import time + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import voluptuous as vol +import websockets.server + +from ocpp.routing import on +from ocpp.v16 import call, call_result +from ocpp.v16.enums import ( + Action, + AuthorizationStatus, + AvailabilityStatus, + AvailabilityType, + ChargePointStatus, + ChargingProfileKindType, + ChargingProfilePurposeType, + ChargingProfileStatus, + ChargingRateUnitType, + ClearChargingProfileStatus, + ConfigurationStatus, + DataTransferStatus, + Measurand, + MessageTrigger, + RegistrationStatus, + RemoteStartStopStatus, + ResetStatus, + ResetType, + TriggerMessageStatus, + UnlockStatus, +) + +from .chargepoint import CentralSystemSettings, OcppVersion, MeasurandValue +from .chargepoint import ChargePoint as cp +from .chargepoint import CONF_SERVICE_DATA_SCHEMA, GCONF_SERVICE_DATA_SCHEMA + +from .enums import ( + ConfigurationKey as ckey, + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, + Profiles as prof, +) + +from .const import ( + CONF_FORCE_SMART_CHARGING, + CONF_IDLE_INTERVAL, + CONF_METER_INTERVAL, + CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_FORCE_SMART_CHARGING, + DEFAULT_IDLE_INTERVAL, + DEFAULT_MEASURAND, + DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DOMAIN, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + def __init__( + self, + id: str, + connection: websockets.server.WebSocketServerProtocol, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + interval_meter_metrics: int = 10, + skip_schema_validation: bool = False, + ): + """Instantiate a ChargePoint.""" + + super().__init__( + id, + connection, + OcppVersion.V16, + hass, + entry, + central, + interval_meter_metrics, + skip_schema_validation, + ) + + async def get_number_of_connectors(self): + """Return number of connectors on this charger.""" + return await self.get_configuration(ckey.number_of_connectors.value) + + async def get_heartbeat_interval(self): + """Retrieve heartbeat interval from the charger and store it.""" + await self.get_configuration(ckey.heartbeat_interval.value) + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + all_measurands = self.entry.data.get( + CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND + ) + autodetect_measurands = self.entry.data.get( + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + ) + + key = ckey.meter_values_sampled_data.value + + if autodetect_measurands: + accepted_measurands = [] + cfg_ok = [ + ConfigurationStatus.accepted, + ConfigurationStatus.reboot_required, + ] + + for measurand in all_measurands.split(","): + _LOGGER.debug(f"'{self.id}' trying measurand: '{measurand}'") + req = call.ChangeConfiguration(key=key, value=measurand) + resp = await self.call(req) + if resp.status in cfg_ok: + _LOGGER.debug(f"'{self.id}' adding measurand: '{measurand}'") + accepted_measurands.append(measurand) + + accepted_measurands = ",".join(accepted_measurands) + else: + accepted_measurands = all_measurands + + # Quirk: + # Workaround for a bug on chargers that have invalid MeterValuesSampledData + # configuration and reboot while the server requests MeterValuesSampledData. + # By setting the configuration directly without checking current configuration + # as done when calling self.configure, the server avoids charger reboot. + # Corresponding issue: https://github.com/lbbrhzn/ocpp/issues/1275 + if len(accepted_measurands) > 0: + req = call.ChangeConfiguration(key=key, value=accepted_measurands) + resp = await self.call(req) + _LOGGER.debug( + f"'{self.id}' measurands set manually to {accepted_measurands}" + ) + + chgr_measurands = await self.get_configuration(key) + + if len(accepted_measurands) > 0: + _LOGGER.debug(f"'{self.id}' allowed measurands: '{accepted_measurands}'") + await self.configure(key, accepted_measurands) + else: + _LOGGER.debug(f"'{self.id}' measurands not configurable by integration") + _LOGGER.debug(f"'{self.id}' allowed measurands: '{chgr_measurands}'") + + return accepted_measurands + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + await self.configure( + ckey.meter_value_sample_interval.value, + str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), + ) + await self.configure( + ckey.clock_aligned_data_interval.value, + str(self.entry.data.get(CONF_IDLE_INTERVAL, DEFAULT_IDLE_INTERVAL)), + ) + # await self.configure( + # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) + # ) + # await self.start_transaction() + + def register_version_specific_services(self): + """Register HA services that differ depending on OCPP version.""" + + async def handle_configure(call): + """Handle the configure service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + key = call.data.get("ocpp_key") + value = call.data.get("value") + await self.configure(key, value) + + async def handle_get_configuration(call): + """Handle the get configuration service call.""" + if self.status == STATE_UNAVAILABLE: + _LOGGER.warning("%s charger is currently unavailable", self.id) + return + key = call.data.get("ocpp_key") + await self.get_configuration(key) + + self.hass.services.async_register( + DOMAIN, + csvcs.service_configure.value, + handle_configure, + CONF_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_configuration.value, + handle_get_configuration, + GCONF_SERVICE_DATA_SCHEMA, + ) + + async def get_supported_features(self) -> prof: + """Get features supported by the charger.""" + features = prof.NONE + req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) + resp = await self.call(req) + try: + feature_list = (resp.configuration_key[0][om.value.value]).split(",") + except (IndexError, KeyError, TypeError): + feature_list = [""] + if feature_list[0] == "": + _LOGGER.warning("No feature profiles detected, defaulting to Core") + await self.notify_ha("No feature profiles detected, defaulting to Core") + feature_list = [om.feature_profile_core.value] + + if self.central.config.get( + CONF_FORCE_SMART_CHARGING, DEFAULT_FORCE_SMART_CHARGING + ): + _LOGGER.warning("Force Smart Charging feature profile") + features |= prof.SMART + + for item in feature_list: + item = item.strip().replace(" ", "") + if item == om.feature_profile_core.value: + features |= prof.CORE + elif item == om.feature_profile_firmware.value: + features |= prof.FW + elif item == om.feature_profile_smart.value: + features |= prof.SMART + elif item == om.feature_profile_reservation.value: + features |= prof.RES + elif item == om.feature_profile_remote.value: + features |= prof.REM + elif item == om.feature_profile_auth.value: + features |= prof.AUTH + else: + _LOGGER.warning("Unknown feature profile detected ignoring: %s", item) + await self.notify_ha( + f"Warning: Unknown feature profile detected ignoring {item}" + ) + return features + + async def trigger_boot_notification(self): + """Trigger a boot notification.""" + req = call.TriggerMessage(requested_message=MessageTrigger.boot_notification) + resp = await self.call(req) + if resp.status == TriggerMessageStatus.accepted: + self.triggered_boot_notification = True + return True + else: + self.triggered_boot_notification = False + _LOGGER.warning("Failed with response: %s", resp.status) + return False + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + return_value = True + nof_connectors = int(self._metrics[cdet.connectors.value].value) + for id in range(0, nof_connectors + 1): + _LOGGER.debug(f"trigger status notification for connector={id}") + req = call.TriggerMessage( + requested_message=MessageTrigger.status_notification, + connector_id=int(id), + ) + resp = await self.call(req) + if resp.status != TriggerMessageStatus.accepted: + _LOGGER.warning("Failed with response: %s", resp.status) + return_value = False + return return_value + + async def clear_profile(self): + """Clear all charging profiles.""" + req = call.ClearChargingProfile() + resp = await self.call(req) + if resp.status == ClearChargingProfileStatus.accepted: + 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 + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + 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 + req = call.SetChargingProfile( + connector_id=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} + ], + }, + }, + ) + else: + _LOGGER.info("Smart charging is not supported by this charger") + return False + 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 <= + 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} + ], + }, + }, + ) + 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): + """Change availability.""" + if state is True: + typ = AvailabilityType.operative.value + else: + typ = AvailabilityType.inoperative.value + + req = call.ChangeAvailability(connector_id=0, type=typ) + resp = await self.call(req) + if resp.status == AvailabilityStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set availability failed with response {resp.status}" + ) + return False + + async def start_transaction(self): + """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) + resp = await self.call(req) + if resp.status == RemoteStartStopStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Start transaction failed with response {resp.status}" + ) + return False + + async def stop_transaction(self): + """Request remote stop of current transaction. + + Leaves charger in finishing state until unplugged. + Use reset() to make the charger available again for remote start + """ + if self.active_transaction_id == 0: + return True + req = call.RemoteStopTransaction(transaction_id=self.active_transaction_id) + resp = await self.call(req) + if resp.status == RemoteStartStopStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Stop transaction failed with response {resp.status}" + ) + return False + + async def reset(self, typ: str = ResetType.hard): + """Hard reset charger unless soft reset requested.""" + self._metrics[cstat.reconnects.value].value = 0 + req = call.Reset(typ) + resp = await self.call(req) + if resp.status == ResetStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha(f"Warning: Reset failed with response {resp.status}") + return False + + async def unlock(self, connector_id: int = 1): + """Unlock charger if requested.""" + req = call.UnlockConnector(connector_id) + resp = await self.call(req) + if resp.status == UnlockStatus.unlocked: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha(f"Warning: Unlock failed with response {resp.status}") + 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) + resp = await self.call(req) + _LOGGER.info("Response: %s", resp) + return True + else: + _LOGGER.warning("Charger does not support ocpp firmware updating") + 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: + 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) + resp = await self.call(req) + _LOGGER.info("Response: %s", resp) + return True + else: + _LOGGER.warning("Charger does not support ocpp diagnostics uploading") + return False + + async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): + """Request vendor specific data transfer from charger.""" + req = call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data) + resp = await self.call(req) + if resp.status == DataTransferStatus.accepted: + _LOGGER.info( + "Data transfer [vendorId(%s), messageId(%s), data(%s)] response: %s", + vendor_id, + message_id, + 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} + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Data transfer failed with response {resp.status}" + ) + return False + + async def get_configuration(self, key: str = ""): + """Get Configuration of charger for supported keys else return None.""" + if key == "": + req = call.GetConfiguration() + else: + req = call.GetConfiguration(key=[key]) + resp = await self.call(req) + 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} + return value + if resp.unknown_key: + _LOGGER.warning("Get Configuration returned unknown key for: %s", key) + await self.notify_ha(f"Warning: charger reports {key} is unknown") + return None + + async def configure(self, key: str, value: str): + """Configure charger by setting the key to target value. + + First the configuration key is read using GetConfiguration. The key's + value is compared with the target value. If the key is already set to + the correct value nothing is done. + + If the key has a different value a ChangeConfiguration request is issued. + + """ + req = call.GetConfiguration(key=[key]) + + resp = await self.call(req) + + if resp.unknown_key is not None: + if key in resp.unknown_key: + _LOGGER.warning("%s is unknown (not supported)", key) + return + + for key_value in resp.configuration_key: + # If the key already has the targeted value we don't need to set + # it. + if key_value[om.key.value] == key and key_value[om.value.value] == value: + return + + if key_value.get(om.readonly.name, False): + _LOGGER.warning("%s is a read only setting", key) + await self.notify_ha(f"Warning: {key} is read-only") + + req = call.ChangeConfiguration(key=key, value=value) + + resp = await self.call(req) + + if resp.status in [ + ConfigurationStatus.rejected, + ConfigurationStatus.not_supported, + ]: + _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) + await self.notify_ha( + f"Warning: charger reported {resp.status} while setting {key}={value}" + ) + + if resp.status == ConfigurationStatus.reboot_required: + self._requires_reboot = True + await self.notify_ha(f"A reboot is required to apply {key}={value}") + + async def async_update_device_info_v16(self, boot_info: dict): + """Update device info asynchronuously.""" + + _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) + await self.async_update_device_info( + boot_info.get(om.charge_point_serial_number.name, None), + boot_info.get(om.charge_point_vendor.name, None), + boot_info.get(om.charge_point_model.name, None), + boot_info.get(om.firmware_version.name, None), + ) + + @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) + + # 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: + value = self.get_ha_metric(csess.meter_start.value) + if value is None: + value = self._metrics[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: + value = self.get_ha_metric(csess.transaction_id.value) + if value is None: + value = kwargs.get(om.transaction_id.name) + 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 + + 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) + + 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) + # where an empty string is supplied convert to 0 + try: + value = float(value) + except ValueError: + value = 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) + ) + meter_values.append(measurands) + self.process_measurands(meter_values, transaction_matches) + + if transaction_matches: + self._metrics[csess.session_time.value].value = round( + ( + int(time.time()) + - float(self._metrics[csess.transaction_id.value].value) + ) + / 60 + ) + self._metrics[csess.session_time.value].unit = "min" + if ( + self._metrics[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[ + cstat.id_tag.name + ] = self._metrics[cstat.id_tag.value].value + self.hass.async_create_task(self.update(self.central.cpid)) + return call_result.MeterValues() + + @on(Action.boot_notification) + def on_boot_notification(self, **kwargs): + """Handle a boot notification.""" + resp = call_result.BootNotification( + current_time=datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + interval=3600, + status=RegistrationStatus.accepted.value, + ) + self.received_boot_notification = True + _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) + + self.hass.async_create_task(self.async_update_device_info_v16(kwargs)) + self._register_boot_notification() + return resp + + @on(Action.status_notification) + 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.hass.async_create_task(self.update(self.central.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.hass.async_create_task(self.update(self.central.cpid)) + self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) + return call_result.FirmwareStatusNotification() + + @on(Action.diagnostics_status_notification) + def on_diagnostics_status(self, status, **kwargs): + """Handle diagnostics status notification.""" + _LOGGER.info("Diagnostics upload status: %s", status) + self.hass.async_create_task( + self.notify_ha(f"Diagnostics upload status: {status}") + ) + return call_result.DiagnosticsStatusNotification() + + @on(Action.security_event_notification) + def on_security_event(self, type, timestamp, **kwargs): + """Handle security event notification.""" + _LOGGER.info( + "Security event notification received: %s at %s [techinfo: %s]", + type, + timestamp, + kwargs.get(om.tech_info.name, "none"), + ) + self.hass.async_create_task( + self.notify_ha(f"Security event notification received: {type}") + ) + return call_result.SecurityEventNotification() + + @on(Action.authorize) + def on_authorize(self, id_tag, **kwargs): + """Handle an Authorization request.""" + self._metrics[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}) + + @on(Action.start_transaction) + def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): + """Handle a Start Transaction request.""" + + 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 + result = call_result.StartTransaction( + id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, + transaction_id=self.active_transaction_id, + ) + else: + result = call_result.StartTransaction( + id_tag_info={om.status.value: auth_status}, transaction_id=0 + ) + self.hass.async_create_task(self.update(self.central.cpid)) + return result + + @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: + _LOGGER.error( + "Stop transaction received for unknown transaction id=%i", + transaction_id, + ) + self.active_transaction_id = 0 + self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) + if ( + self._metrics[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 + self.hass.async_create_task(self.update(self.central.cpid)) + return call_result.StopTransaction( + id_tag_info={om.status.value: AuthorizationStatus.accepted.value} + ) + + @on(Action.data_transfer) + 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} + 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.hass.async_create_task(self.update(self.central.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 new file mode 100644 index 00000000..f1ea7bd3 --- /dev/null +++ b/custom_components/ocpp/ocppv201.py @@ -0,0 +1,688 @@ +"""Representation of a OCPP 2.0.1 charging station.""" + +import asyncio +from datetime import datetime, UTC +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, SupportsResponse, ServiceResponse +from homeassistant.exceptions import ServiceValidationError, HomeAssistantError +import websockets.server + +from ocpp.routing import on +from ocpp.v201 import call, call_result +from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 +from ocpp.v201.enums import ( + ConnectorStatusType, + GetVariableStatusType, + IdTokenType, + MeasurandType, + OperationalStatusType, + ResetType, + ResetStatusType, + SetVariableStatusType, + AuthorizationStatusType, + TransactionEventType, + ReadingContextType, + RequestStartStopStatusType, + ChargingStateType, + ChargingProfilePurposeType, + ChargingRateUnitType, + ChargingProfileKindType, + ChargingProfileStatus, +) + +from .chargepoint import ( + CentralSystemSettings, + OcppVersion, + SetVariableResult, + MeasurandValue, +) +from .chargepoint import ChargePoint as cp +from .chargepoint import CONF_SERVICE_DATA_SCHEMA, GCONF_SERVICE_DATA_SCHEMA + +from .enums import Profiles + +from .enums import ( + HAChargerStatuses as cstat, + HAChargerServices as csvcs, + HAChargerSession as csess, +) + +from .const import ( + DEFAULT_METER_INTERVAL, + DOMAIN, + HA_ENERGY_UNIT, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) +logging.getLogger(DOMAIN).setLevel(logging.INFO) + + +class InventoryReport: + """Cached full inventory report for a charger.""" + + evse_count: int = 0 + connector_count: list[int] = [] + smart_charging_available: bool = False + reservation_available: bool = False + local_auth_available: bool = False + tx_updated_measurands: list[MeasurandType] = [] + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + _inventory: InventoryReport | None = None + _wait_inventory: asyncio.Event | None = None + _connector_status: list[list[ConnectorStatusType | None]] = [] + _tx_start_time: datetime | None = None + + def __init__( + self, + id: str, + connection: websockets.server.WebSocketServerProtocol, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + interval_meter_metrics: int = 10, + skip_schema_validation: bool = False, + ): + """Instantiate a ChargePoint.""" + + super().__init__( + id, + connection, + OcppVersion.V201, + hass, + entry, + central, + interval_meter_metrics, + skip_schema_validation, + ) + + async def async_update_device_info_v201(self, boot_info: dict): + """Update device info asynchronuously.""" + + _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) + await self.async_update_device_info( + boot_info.get("serial_number", None), + boot_info.get("vendor_name", None), + boot_info.get("model", None), + boot_info.get("firmware_version", None), + ) + + async def _get_inventory(self): + if self._inventory is not None: + return + self._wait_inventory = asyncio.Event() + req = call.GetBaseReport(1, "FullInventory") + resp: call_result.GetBaseReport | None = None + try: + resp: call_result.GetBaseReport = await self.call(req) + except ocpp.exceptions.NotImplementedError: + self._inventory = InventoryReport() + except OCPPError: + self._inventory = None + if (resp is not None) and (resp.status == "Accepted"): + await asyncio.wait_for(self._wait_inventory.wait(), self._response_timeout) + self._wait_inventory = None + + 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 + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + req = call.SetVariables( + [ + { + "component": {"name": "SampledDataCtrlr"}, + "variable": {"name": "TxUpdatedInterval"}, + "attribute_value": str(DEFAULT_METER_INTERVAL), + } + ] + ) + await self.call(req) + + def register_version_specific_services(self): + """Register HA services that differ depending on OCPP version.""" + + async def handle_configure(call) -> ServiceResponse: + """Handle the configure service call.""" + key = call.data.get("ocpp_key") + value = call.data.get("value") + result: SetVariableResult = await self.configure(key, value) + return {"reboot_required": result == SetVariableResult.reboot_required} + + async def handle_get_configuration(call) -> ServiceResponse: + """Handle the get configuration service call.""" + key = call.data.get("ocpp_key") + value = await self.get_configuration(key) + return {"value": value} + + self.hass.services.async_register( + DOMAIN, + csvcs.service_configure_v201.value, + handle_configure, + CONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_configuration_v201.value, + handle_get_configuration, + GCONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + await self._get_inventory() + if self._inventory: + measurands: str = ",".join( + measurand.value for measurand in self._inventory.tx_updated_measurands + ) + req = call.SetVariables( + [ + { + "component": {"name": "SampledDataCtrlr"}, + "variable": {"name": "TxUpdatedMeasurands"}, + "attribute_value": measurands, + } + ] + ) + await self.call(req) + return measurands + return "" + + async def get_supported_features(self) -> Profiles: + """Get comma-separated list of measurands supported by the charger.""" + await self._get_inventory() + features = Profiles.CORE + if self._inventory and self._inventory.smart_charging_available: + features |= Profiles.SMART + if self._inventory and self._inventory.reservation_available: + features |= Profiles.RES + if self._inventory and self._inventory.local_auth_available: + features |= Profiles.AUTH + + fw_req = call.UpdateFirmware( + 1, + { + "location": "dummy://dummy", + "retrieveDateTime": datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "signature": "☺", + }, + ) + try: + await self.call(fw_req) + features |= Profiles.FW + except OCPPError as e: + _LOGGER.info("Firmware update not supported: %s", e) + + trigger_req = call.TriggerMessage("StatusNotification") + try: + await self.call(trigger_req) + features |= Profiles.REM + except OCPPError as e: + _LOGGER.info("TriggerMessage not supported: %s", e) + + return features + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + if not self._inventory: + return + for evse_id in range(1, self._inventory.evse_count + 1): + for connector_id in range( + 1, self._inventory.connector_count[evse_id - 1] + 1 + ): + req = call.TriggerMessage( + "StatusNotification", + evse={"id": evse_id, "connector_id": connector_id}, + ) + await self.call(req) + + async def clear_profile(self): + """Clear all charging profiles.""" + req: call.ClearChargingProfile = call.ClearChargingProfile( + None, + { + "charging_profile_Purpose": ChargingProfilePurposeType.charging_station_max_profile.value + }, + ) + await self.call(req) + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit.""" + req: call.SetChargingProfile + if profile: + 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"] = ChargingRateUnitType.amps.value + elif limit_watts < 22000: + period["limit"] = limit_watts + schedule["charging_rate_unit"] = ChargingRateUnitType.watts.value + else: + await self.clear_profile() + return + + schedule["charging_schedule_period"] = [period] + req = call.SetChargingProfile( + 0, + { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [schedule], + }, + ) + + resp: call_result.SetChargingProfile = await self.call(req) + if resp.status != ChargingProfileStatus.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={ + "message": f"{str(resp.status)}: {str(resp.status_info)}" + }, + ) + + async def set_availability(self, state: bool = True): + """Change availability.""" + req: call.ChangeAvailability = call.ChangeAvailability( + OperationalStatusType.operative.value + if state + else OperationalStatusType.inoperative.value + ) + await self.call(req) + + async def start_transaction(self) -> bool: + """Remote start a transaction.""" + req: call.RequestStartTransaction = call.RequestStartTransaction( + id_token={ + "id_token": self._remote_id_tag, + "type": IdTokenType.central.value, + }, + remote_start_id=1, + ) + resp: call_result.RequestStartTransaction = await self.call(req) + return resp.status == RequestStartStopStatusType.accepted.value + + async def stop_transaction(self) -> bool: + """Request remote stop of current transaction.""" + req: call.RequestStopTransaction = call.RequestStopTransaction( + transaction_id=self._metrics[csess.transaction_id.value].value + ) + resp: call_result.RequestStopTransaction = await self.call(req) + return resp.status == RequestStartStopStatusType.accepted.value + + async def reset(self, typ: str = ""): + """Hard reset charger unless soft reset requested.""" + req: call.Reset = call.Reset(ResetType.immediate) + resp = await self.call(req) + if resp.status != ResetStatusType.accepted.value: + status_suffix: str = f": {resp.status_info}" if resp.status_info else "" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": resp.status + status_suffix}, + ) + + @staticmethod + def _parse_ocpp_key(key: str) -> tuple: + try: + [c, v] = key.split("/") + except ValueError: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_ocpp_key", + ) + [cname, paren, cinstance] = c.partition("(") + cinstance = cinstance.partition(")")[0] + [vname, paren, vinstance] = v.partition("(") + vinstance = vinstance.partition(")")[0] + component: dict = {"name": cname} + if cinstance: + component["instance"] = cinstance + variable: dict = {"name": vname} + if vinstance: + variable["instance"] = vinstance + return component, variable + + async def get_configuration(self, key: str = "") -> str | None: + """Get Configuration of charger for supported keys else return None.""" + component, variable = self._parse_ocpp_key(key) + req: call.GetVariables = call.GetVariables( + [{"component": component, "variable": variable}] + ) + try: + resp: call_result.GetVariables = await self.call(req) + except Exception as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": str(e)}, + ) + result: dict = resp.get_variable_result[0] + if result["attribute_status"] != GetVariableStatusType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_variables_error", + translation_placeholders={"message": str(result)}, + ) + return result["attribute_value"] + + async def configure(self, key: str, value: str) -> SetVariableResult: + """Configure charger by setting the key to target value.""" + component, variable = self._parse_ocpp_key(key) + req: call.SetVariables = call.SetVariables( + [{"component": component, "variable": variable, "attribute_value": value}] + ) + try: + resp: call_result.SetVariables = await self.call(req) + except Exception as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": str(e)}, + ) + result: dict = resp.set_variable_result[0] + if result["attribute_status"] == SetVariableStatusType.accepted: + return SetVariableResult.accepted + elif result["attribute_status"] == SetVariableStatusType.reboot_required: + return SetVariableResult.reboot_required + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={"message": str(result)}, + ) + + @on("BootNotification") + def on_boot_notification(self, charging_station, reason, **kwargs): + """Perform OCPP callback.""" + resp = call_result.BootNotification( + current_time=datetime.now(tz=UTC).isoformat(), + interval=10, + status="Accepted", + ) + + self.hass.async_create_task( + self.async_update_device_info_v201(charging_station) + ) + self._inventory = None + self._register_boot_notification() + return resp + + @on("Heartbeat") + def on_heartbeat(self, **kwargs): + """Perform OCPP callback.""" + 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 + + 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.hass.async_create_task(self.update(self.central.cpid)) + + @on("StatusNotification") + 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]) + ) + + evse: list[ConnectorStatusType] = self._connector_status[evse_id - 1] + evse[connector_id - 1] = ConnectorStatusType(connector_status) + evse_status: ConnectorStatusType | None = None + for status in evse: + if status is None: + evse_status = status + break + else: + evse_status = status + if status != ConnectorStatusType.available: + break + evse_status_v16: ChargePointStatusv16 | None + if evse_status is None: + evse_status_v16 = None + elif evse_status == ConnectorStatusType.available: + evse_status_v16 = ChargePointStatusv16.available + elif evse_status == ConnectorStatusType.faulted: + evse_status_v16 = ChargePointStatusv16.faulted + elif evse_status == ConnectorStatusType.unavailable: + evse_status_v16 = ChargePointStatusv16.unavailable + else: + evse_status_v16 = ChargePointStatusv16.preparing + + if evse_status_v16: + self._report_evse_status(evse_id, evse_status_v16) + + return call_result.StatusNotification() + + @on("FirmwareStatusNotification") + @on("MeterValues") + @on("LogStatusNotification") + @on("NotifyEvent") + def ack(self, **kwargs): + """Perform OCPP callback.""" + return call_result.StatusNotification() + + @on("NotifyReport") + def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): + """Perform OCPP callback.""" + 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", []) + for report_data in reports: + component: dict = report_data["component"] + variable: dict = report_data["variable"] + component_name = component["name"] + variable_name = variable["name"] + value: str | None = None + for attribute in report_data["variable_attribute"]: + if (("type" not in attribute) or (attribute["type"] == "Actual")) and ( + "value" in attribute + ): + value = attribute["value"] + break + bool_value: bool = value and (value.casefold() == "true".casefold()) + + if (component_name == "SmartChargingCtrlr") and ( + variable_name == "Available" + ): + self._inventory.smart_charging_available = bool_value + elif (component_name == "ReservationCtrlr") and ( + variable_name == "Available" + ): + self._inventory.reservation_available = bool_value + elif (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 ( + (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) + ): + characteristics: dict = report_data["variable_characteristics"] + values: str = characteristics.get("values_list", "") + self._inventory.tx_updated_measurands = [ + MeasurandType(s) for s in values.split(",") + ] + + if not kwargs.get("tbc", False): + self._wait_inventory.set() + return call_result.NotifyReport() + + @on("Authorize") + def on_authorize(self, id_token: dict, **kwargs): + """Perform OCPP callback.""" + status: str = AuthorizationStatusType.unknown.value + token_type: str = id_token["type"] + token: str = id_token["id_token"] + if ( + (token_type == IdTokenType.iso14443) + or (token_type == IdTokenType.iso15693) + or (token_type == IdTokenType.central) + ): + 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]): + converted_values: list[list[MeasurandValue]] = [] + for meter_value in meter_values: + measurands: list[MeasurandValue] = [] + for sampled_value in meter_value["sampled_value"]: + measurand: str = sampled_value.get( + "measurand", MeasurandType.energy_active_import_register.value + ) + value: float = sampled_value["value"] + context: str = sampled_value.get("context", None) + phase: str = sampled_value.get("phase", None) + location: str = sampled_value.get("location", None) + unit_struct: dict = sampled_value.get("unit_of_measure", {}) + unit: str = unit_struct.get("unit", None) + multiplier: int = unit_struct.get("multiplier", 0) + if multiplier != 0: + value *= pow(10, multiplier) + measurands.append( + MeasurandValue(measurand, value, phase, unit, context, location) + ) + converted_values.append(measurands) + + if (tx_event_type == TransactionEventType.started.value) or ( + (tx_event_type == TransactionEventType.updated.value) + and (self._metrics[csess.meter_start].value is None) + ): + energy_measurand = MeasurandType.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_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.process_measurands(converted_values, True) + + if tx_event_type == TransactionEventType.ended.value: + measurands_in_tx: set[str] = set() + tx_end_context = ReadingContextType.transaction_end.value + for meter_value in converted_values: + for measurand_item in meter_value: + if measurand_item.context == tx_end_context: + measurands_in_tx.add(measurand_item.measurand) + if self._inventory: + for measurand in self._inventory.tx_updated_measurands: + if ( + (measurand not in measurands_in_tx) + and (measurand in self._metrics) + and not measurand.startswith("Energy") + ): + self._metrics[measurand].value = 0 + + @on("TransactionEvent") + def on_transaction_event( + self, event_type, timestamp, trigger_reason, seq_no, transaction_info, **kwargs + ): + """Perform OCPP callback.""" + offline: bool = kwargs.get("offline", False) + meter_values: list[dict] = kwargs.get("meter_value", []) + self._set_meter_values(event_type, meter_values) + 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 == ChargingStateType.idle: + evse_status_v16 = ChargePointStatusv16.available + elif state == ChargingStateType.ev_connected: + evse_status_v16 = ChargePointStatusv16.preparing + elif state == ChargingStateType.suspended_evse: + evse_status_v16 = ChargePointStatusv16.suspended_evse + elif state == ChargingStateType.suspended_ev: + evse_status_v16 = ChargePointStatusv16.suspended_ev + elif state == ChargingStateType.charging: + evse_status_v16 = ChargePointStatusv16.charging + if evse_status_v16: + self._report_evse_status(evse_id, evse_status_v16) + + response = call_result.TransactionEvent() + id_token = kwargs.get("id_token") + if id_token: + response.id_token_info = {"status": AuthorizationStatusType.accepted} + id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] + self._metrics[cstat.id_tag.value].value = id_tag_string + + if event_type == TransactionEventType.started.value: + self._tx_start_time = 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 + 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 event_type == TransactionEventType.ended.value: + self._metrics[csess.transaction_id.value].value = "" + self._metrics[cstat.id_tag.value].value = "" + + if not offline: + self.hass.async_create_task(self.update(self.central.cpid)) + + return response diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index df857b0a..8c3e057f 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -84,9 +84,26 @@ configure: advanced: true example: "60" +configure_v201: + name: Configure charger features + description: Change supported Ocpp v2.0.1 configuration values + fields: + ocpp_key: + name: [()]/[()] + description: [()]/[()] + required: true + advanced: true + example: "OCPPCommCtrlr/WebSocketPingInterval" + value: + name: Key value + description: Value to write to key + required: true + advanced: true + example: "60" + get_configuration: name: Get configuration values for charger - description: Change supported Ocpp v1.6 configuration values + description: Get supported Ocpp v1.6 configuration values fields: ocpp_key: name: Configuration key name @@ -95,6 +112,17 @@ get_configuration: advanced: true example: "WebSocketPingInterval" +get_configuration_v201: + name: Get configuration values for charger + description: Get supported Ocpp v2.0.1 configuration values + fields: + ocpp_key: + name: [()]/[()] + description: [()]/[()] + required: true + advanced: true + example: "OCPPCommCtrlr/WebSocketPingInterval" + get_diagnostics: name: Request diagnostic data from charger description: Specify server url to upload diagnostic data to (dependent on charger support), supported transfer protocols can be requested by the configuration key SupportedFileTransferProtocols diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 0015e11b..d610bd07 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -60,5 +60,19 @@ "abort": { "single_instance_allowed": "Only a single instance is allowed." } + }, + "exceptions": { + "invalid_ocpp_key": { + "message": "Invalid OCPP key" + }, + "ocpp_call_error": { + "message": "OCPP call failed: {message}" + }, + "get_variables_error": { + "message": "Failed to get variable: {message}" + }, + "set_variables_error": { + "message": "Failed to set variable: {message}" + } } } \ No newline at end of file diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index 0015e11b..d610bd07 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -60,5 +60,19 @@ "abort": { "single_instance_allowed": "Only a single instance is allowed." } + }, + "exceptions": { + "invalid_ocpp_key": { + "message": "Invalid OCPP key" + }, + "ocpp_call_error": { + "message": "OCPP call failed: {message}" + }, + "get_variables_error": { + "message": "Failed to get variable: {message}" + }, + "set_variables_error": { + "message": "Failed to set variable: {message}" + } } } \ No newline at end of file diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py new file mode 100644 index 00000000..02bea1ef --- /dev/null +++ b/tests/charge_point_test.py @@ -0,0 +1,137 @@ +"""Implement common functions for simulating charge points of any OCPP version.""" + +import asyncio + +from homeassistant.core import HomeAssistant +from websockets import Subprotocol + +from custom_components.ocpp import CentralSystem +from .const import CONF_PORT +import contextlib +from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN +from custom_components.ocpp.enums import HAChargerServices as csvcs +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from ocpp.charge_point import ChargePoint +from pytest_homeassistant_custom_component.common import MockConfigEntry +from typing import Any +from collections.abc import Callable, Awaitable +import websockets + + +async def set_switch(hass: HomeAssistant, cs: CentralSystem, key: str, on: bool): + """Toggle a switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON if on else SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cs.settings.cpid}_{key}"}, + blocking=True, + ) + + +async def set_number(hass: HomeAssistant, cs: CentralSystem, key: str, value: int): + """Set a numeric slider.""" + await hass.services.async_call( + NUMBER_DOMAIN, + "set_value", + service_data={"value": str(value)}, + blocking=True, + target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cs.settings.cpid}_{key}"}, + ) + + +set_switch.__test__ = False + + +async def press_button(hass: HomeAssistant, cs: CentralSystem, key: str): + """Press a button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.{cs.settings.cpid}_{key}"}, + blocking=True, + ) + + +press_button.__test__ = False + + +async def create_configuration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> CentralSystem: + """Create an integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return hass.data[OCPP_DOMAIN][config_entry.entry_id] + + +create_configuration.__test__ = False + + +async def remove_configuration(hass: HomeAssistant, config_entry: MockConfigEntry): + """Remove an integration.""" + if entry := hass.config_entries.async_get_entry(config_entry.entry_id): + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + +remove_configuration.__test__ = False + + +async def wait_ready(hass: HomeAssistant): + """Wait until charge point is connected and initialised.""" + hass.services.async_remove(OCPP_DOMAIN, csvcs.service_data_transfer) + while not hass.services.has_service(OCPP_DOMAIN, csvcs.service_data_transfer): + await asyncio.sleep(0.1) + + +def _check_complete( + test_routine: Callable[[ChargePoint], Awaitable], +) -> Callable[[ChargePoint, list[bool]], Awaitable]: + async def extended_routine(cp: ChargePoint, completed: list[bool]): + await test_routine(cp) + completed.append(True) + + return extended_routine + + +async def run_charge_point_test( + config_entry: MockConfigEntry, + identity: str, + subprotocols: list[str] | None, + charge_point: Callable[[websockets.WebSocketClientProtocol], ChargePoint], + parallel_tests: list[Callable[[ChargePoint], Awaitable]], +) -> Any: + """Connect web socket client to the CSMS and run a number of tests in parallel.""" + completed: list[list[bool]] = [[] for _ in parallel_tests] + async with websockets.connect( + f"ws://127.0.0.1:{config_entry.data[CONF_PORT]}/{identity}", + subprotocols=[Subprotocol(s) for s in subprotocols] + if subprotocols is not None + else None, + ) as ws: + cp = charge_point(ws) + with contextlib.suppress(asyncio.TimeoutError): + test_results = [ + _check_complete(parallel_tests[i])(cp, completed[i]) + for i in range(len(parallel_tests)) + ] + await asyncio.wait_for( + asyncio.gather(*([cp.start()] + test_results)), + timeout=5, + ) + await ws.close() + for test_completed in completed: + assert test_completed == [True] + + +run_charge_point_test.__test__ = False diff --git a/tests/conftest.py b/tests/conftest.py index 390bc87b..4264d5c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ def skip_notifications_fixture(): with ( patch("homeassistant.components.persistent_notification.async_create"), patch("homeassistant.components.persistent_notification.async_dismiss"), - patch("custom_components.ocpp.api.ChargePoint.notify_ha"), + patch("custom_components.ocpp.chargepoint.ChargePoint.notify_ha"), ): yield diff --git a/tests/test_charge_point.py b/tests/test_charge_point_v16.py similarity index 94% rename from tests/test_charge_point.py rename to tests/test_charge_point_v16.py index dec050d8..4da5d22d 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point_v16.py @@ -1,17 +1,8 @@ -"""Implement a test by a simulating a chargepoint.""" +"""Implement a test by a simulating an OCPP 1.6 chargepoint.""" import asyncio from datetime import datetime, UTC # timedelta, -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.button.const import SERVICE_PRESS -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.const import ATTR_ENTITY_ID import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry import websockets @@ -43,46 +34,27 @@ ) from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2 +from .charge_point_test import set_switch, press_button, set_number import contextlib @pytest.mark.timeout(90) # Set timeout for this test -async def test_cms_responses(hass, socket_enabled): +async def test_cms_responses_v16(hass, socket_enabled): """Test central system responses to a charger.""" - async def test_switches(hass, socket_enabled): + async def test_switches(hass, cs, socket_enabled): """Test switch operations.""" for switch in SWITCHES: - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - service_data={ - ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test_cpid_{switch.key}" - }, - blocking=True, - ) - + await set_switch(hass, cs, switch.key, True) await asyncio.sleep(1) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - service_data={ - ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test_cpid_{switch.key}" - }, - blocking=True, - ) + await set_switch(hass, cs, switch.key, False) - async def test_buttons(hass, socket_enabled): + async def test_buttons(hass, cs, socket_enabled): """Test button operations.""" for button in BUTTONS: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.test_cpid_{button.key}"}, - blocking=True, - ) + await press_button(hass, cs, button.key) - async def test_services(hass, socket_enabled): + async def test_services(hass, cs, socket_enabled): """Test service operations.""" SERVICES = [ csvcs.service_update_firmware, @@ -142,13 +114,7 @@ async def test_services(hass, socket_enabled): for number in NUMBERS: # test setting value of number slider - await hass.services.async_call( - NUMBER_DOMAIN, - "set_value", - service_data={"value": "10"}, - blocking=True, - target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.test_cpid_{number.key}"}, - ) + await set_number(hass, cs, number.key, 10) # Test MOCK_CONFIG_DATA_2 if True: @@ -373,11 +339,11 @@ async def test_services(hass, socket_enabled): await asyncio.wait_for( asyncio.gather( cp.start(), - cs.charge_points[cs.cpid].trigger_boot_notification(), - cs.charge_points[cs.cpid].trigger_status_notification(), - test_switches(hass, socket_enabled), - test_services(hass, socket_enabled), - test_buttons(hass, socket_enabled), + cs.charge_points[cs.settings.cpid].trigger_boot_notification(), + cs.charge_points[cs.settings.cpid].trigger_status_notification(), + test_switches(hass, cs, socket_enabled), + test_services(hass, cs, socket_enabled), + test_buttons(hass, cs, socket_enabled), cp.send_meter_clock_data(), ), timeout=5, @@ -463,11 +429,11 @@ async def test_services(hass, socket_enabled): await asyncio.wait_for( asyncio.gather( cp.start(), - cs.charge_points[cs.cpid].trigger_boot_notification(), - cs.charge_points[cs.cpid].trigger_status_notification(), - test_switches(hass, socket_enabled), - test_services(hass, socket_enabled), - test_buttons(hass, socket_enabled), + cs.charge_points[cs.settings.cpid].trigger_boot_notification(), + cs.charge_points[cs.settings.cpid].trigger_status_notification(), + test_switches(hass, cs, socket_enabled), + test_services(hass, cs, socket_enabled), + test_buttons(hass, cs, socket_enabled), ), timeout=3, ) @@ -479,7 +445,7 @@ async def test_services(hass, socket_enabled): await asyncio.sleep(1) # test ping timeout, change cpid to start new connection - cs.cpid = "CP_3_test" + cs.settings.cpid = "CP_3_test" async with websockets.connect( "ws://127.0.0.1:9000/CP_3", subprotocols=["ocpp1.6"], @@ -491,7 +457,7 @@ async def test_services(hass, socket_enabled): # test services when charger is unavailable await asyncio.sleep(1) - await test_services(hass, socket_enabled) + await test_services(hass, cs, socket_enabled) if entry := hass.config_entries.async_get_entry(config_entry.entry_id): await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py new file mode 100644 index 00000000..cdea9634 --- /dev/null +++ b/tests/test_charge_point_v201.py @@ -0,0 +1,1252 @@ +"""Implement a test by a simulating an OCPP 2.0.1 chargepoint.""" + +import asyncio +from datetime import datetime, timedelta, UTC + +from homeassistant.core import HomeAssistant, ServiceResponse +from homeassistant.exceptions import HomeAssistantError +from ocpp.v16.enums import Measurand + +from custom_components.ocpp import CentralSystem +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + Profiles, +) +from .charge_point_test import ( + set_switch, + set_number, + press_button, + create_configuration, + run_charge_point_test, + remove_configuration, + wait_ready, +) +from .const import MOCK_CONFIG_DATA +from custom_components.ocpp.const import ( + DEFAULT_METER_INTERVAL, + DOMAIN as OCPP_DOMAIN, + CONF_PORT, + CONF_MONITORED_VARIABLES, + MEASURANDS, +) +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from ocpp.routing import on +import ocpp.exceptions +from ocpp.v201 import ChargePoint as cpclass, call, call_result +from ocpp.v201.datatypes import ( + ComponentType, + EVSEType, + GetVariableResultType, + SetVariableResultType, + VariableType, + VariableAttributeType, + VariableCharacteristicsType, + ReportDataType, +) +from ocpp.v201.enums import ( + Action, + AuthorizationStatusType, + BootReasonType, + ChangeAvailabilityStatusType, + ChargingProfileKindType, + ChargingProfilePurposeType, + ChargingProfileStatus, + ChargingRateUnitType, + ChargingStateType, + ClearChargingProfileStatusType, + ConnectorStatusType, + DataType, + FirmwareStatusType, + GenericDeviceModelStatusType, + GetVariableStatusType, + IdTokenType, + MeasurandType, + MutabilityType, + OperationalStatusType, + PhaseType, + ReadingContextType, + RegistrationStatusType, + ReportBaseType, + RequestStartStopStatusType, + ResetStatusType, + ResetType, + SetVariableStatusType, + ReasonType, + TransactionEventType, + MessageTriggerType, + TriggerMessageStatusType, + TriggerReasonType, + UpdateFirmwareStatusType, +) +from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 + + +supported_measurands = [ + measurand + for measurand in MEASURANDS + if (measurand != Measurand.rpm.value) and (measurand != Measurand.temperature.value) +] + + +class ChargePoint(cpclass): + """Representation of real client Charge Point.""" + + remote_starts: list[call.RequestStartTransaction] = [] + remote_stops: list[str] = [] + task: asyncio.Task | None = None + remote_start_tx_id: str = "remotestart" + operative: bool | None = None + tx_updated_interval: int | None = None + tx_updated_measurands: list[str] | None = None + tx_start_time: datetime | None = None + component_instance_used: str | None = None + variable_instance_used: str | None = None + charge_profiles_set: list[call.SetChargingProfile] = [] + charge_profiles_cleared: list[call.ClearChargingProfile] = [] + accept_reset: bool = True + resets: list[call.Reset] = [] + + @on(Action.GetBaseReport) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + assert report_base == ReportBaseType.full_inventory.value + self.task = asyncio.create_task(self._send_full_inventory(request_id)) + return call_result.GetBaseReport(GenericDeviceModelStatusType.accepted.value) + + @on(Action.RequestStartTransaction) + def _on_remote_start( + self, id_token: dict, remote_start_id: int, **kwargs + ) -> call_result.RequestStartTransaction: + self.remote_starts.append( + call.RequestStartTransaction(id_token, remote_start_id, *kwargs) + ) + self.task = asyncio.create_task( + self._start_transaction_remote_start(id_token, remote_start_id) + ) + return call_result.RequestStartTransaction( + RequestStartStopStatusType.accepted.value + ) + + @on(Action.RequestStopTransaction) + def _on_remote_stop(self, transaction_id: str, **kwargs): + assert transaction_id == self.remote_start_tx_id + self.remote_stops.append(transaction_id) + return call_result.RequestStopTransaction( + RequestStartStopStatusType.accepted.value + ) + + @on(Action.SetVariables) + def _on_set_variables(self, set_variable_data: list[dict], **kwargs): + result: list[SetVariableResultType] = [] + for input in set_variable_data: + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedInterval"} + ): + self.tx_updated_interval = int(input["attribute_value"]) + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedMeasurands"} + ): + self.tx_updated_measurands = input["attribute_value"].split(",") + + attr_result: SetVariableStatusType + if input["variable"] == {"name": "RebootRequired"}: + attr_result = SetVariableStatusType.reboot_required + elif input["variable"] == {"name": "BadVariable"}: + attr_result = SetVariableStatusType.unknown_variable + elif input["variable"] == {"name": "VeryBadVariable"}: + raise ocpp.exceptions.InternalError() + else: + attr_result = SetVariableStatusType.accepted + self.component_instance_used = input["component"].get("instance", None) + self.variable_instance_used = input["variable"].get("instance", None) + + result.append( + SetVariableResultType( + attr_result, + ComponentType(input["component"]["name"]), + VariableType(input["variable"]["name"]), + ) + ) + return call_result.SetVariables(result) + + @on(Action.GetVariables) + def _on_get_variables(self, get_variable_data: list[dict], **kwargs): + result: list[GetVariableResultType] = [] + for input in get_variable_data: + value: str | None = None + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedInterval"} + ): + value = str(self.tx_updated_interval) + elif input["variable"]["name"] == "TestInstance": + value = ( + input["component"]["instance"] + "," + input["variable"]["instance"] + ) + elif input["variable"] == {"name": "VeryBadVariable"}: + raise ocpp.exceptions.InternalError() + result.append( + GetVariableResultType( + GetVariableStatusType.accepted + if value is not None + else GetVariableStatusType.unknown_variable, + ComponentType(input["component"]["name"]), + VariableType(input["variable"]["name"]), + attribute_value=value, + ) + ) + return call_result.GetVariables(result) + + @on(Action.ChangeAvailability) + def _on_change_availability(self, operational_status: str, **kwargs): + if operational_status == OperationalStatusType.operative.value: + self.operative = True + elif operational_status == OperationalStatusType.inoperative.value: + self.operative = False + else: + assert False + return call_result.ChangeAvailability( + ChangeAvailabilityStatusType.accepted.value + ) + + @on(Action.SetChargingProfile) + def _on_set_charging_profile(self, evse_id: int, charging_profile: dict, **kwargs): + self.charge_profiles_set.append( + call.SetChargingProfile(evse_id, charging_profile) + ) + unit = charging_profile["charging_schedule"][0]["charging_rate_unit"] + limit = charging_profile["charging_schedule"][0]["charging_schedule_period"][0][ + "limit" + ] + if (unit == ChargingRateUnitType.amps.value) and (limit < 6): + return call_result.SetChargingProfile(ChargingProfileStatus.rejected.value) + return call_result.SetChargingProfile(ChargingProfileStatus.accepted.value) + + @on(Action.ClearChargingProfile) + def _on_clear_charging_profile(self, **kwargs): + self.charge_profiles_cleared.append( + call.ClearChargingProfile( + kwargs.get("charging_profile_id", None), + kwargs.get("charging_profile_criteria", None), + ) + ) + return call_result.ClearChargingProfile( + ClearChargingProfileStatusType.accepted.value + ) + + @on(Action.Reset) + def _on_reset(self, type: str, **kwargs): + self.resets.append(call.Reset(type, kwargs.get("evse_id", None))) + return call_result.Reset( + ResetStatusType.accepted.value + if self.accept_reset + else ResetStatusType.rejected.value + ) + + async def _start_transaction_remote_start( + self, id_token: dict, remote_start_id: int + ): + # As if AuthorizeRemoteStart is set + authorize_resp: call_result.Authorize = await self.call( + call.Authorize(id_token) + ) + assert ( + authorize_resp.id_token_info["status"] + == AuthorizationStatusType.accepted.value + ) + + self.tx_start_time = datetime.now(tz=UTC) + request = call.TransactionEvent( + TransactionEventType.started.value, + self.tx_start_time.isoformat(), + TriggerReasonType.remote_start.value, + 0, + transaction_info={ + "transaction_id": self.remote_start_tx_id, + "remote_start_id": remote_start_id, + }, + meter_value=[ + { + "timestamp": self.tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 0, + "measurand": Measurand.power_active_import.value, + "unit_of_measure": {"unit": "W"}, + }, + ], + }, + ], + id_token=id_token, + ) + await self.call(request) + + async def _send_full_inventory(self, request_id: int): + # Cannot send all at once because of a bug in python ocpp module + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 0, + [ + ReportDataType( + ComponentType("SmartChargingCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ) + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 1, + [ + ReportDataType( + ComponentType("ReservationCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 2, + [ + ReportDataType( + ComponentType("LocalAuthListCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 3, + [ + ReportDataType( + ComponentType("EVSE", evse=EVSEType(1)), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 4, + [ + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=1)), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 5, + [ + ReportDataType( + ComponentType("SampledDataCtrlr"), + VariableType("TxUpdatedMeasurands"), + [VariableAttributeType(value="", persistent=True)], + VariableCharacteristicsType( + DataType.member_list, + False, + values_list=",".join(supported_measurands), + ), + ), + ], + ) + ) + + +async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + cpid: str = cs.settings.cpid + + await set_switch(hass, cs, "charge_control", True) + assert len(cp.remote_starts) == 1 + assert cp.remote_starts[0].id_token == { + "id_token": cs.charge_points[cpid]._remote_id_tag, + "type": IdTokenType.central.value, + } + while cs.get_metric(cpid, csess.transaction_id.value) is None: + await asyncio.sleep(0.1) + assert cs.get_metric(cpid, csess.transaction_id.value) == cp.remote_start_tx_id + + tx_start_time = cp.tx_start_time + await cp.call( + call.StatusNotification( + tx_start_time.isoformat(), ConnectorStatusType.occupied, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.preparing + ) + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.cable_plugged_in.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + }, + ) + ) + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 2, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.charging.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 1.1, + "measurand": Measurand.current_import.value, + "phase": PhaseType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 2.2, + "measurand": Measurand.current_import.value, + "phase": PhaseType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 3.3, + "measurand": Measurand.current_import.value, + "phase": PhaseType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.1, + "measurand": Measurand.current_offered.value, + "phase": PhaseType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.2, + "measurand": Measurand.current_offered.value, + "phase": PhaseType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.3, + "measurand": Measurand.current_offered.value, + "phase": PhaseType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.energy_active_export_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 0.1, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh", "multiplier": 3}, + }, + { + "value": 0, + "measurand": Measurand.energy_reactive_export_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 0, + "measurand": Measurand.energy_reactive_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 50, + "measurand": Measurand.frequency.value, + "unit_of_measure": {"unit": "Hz"}, + }, + { + "value": 0, + "measurand": Measurand.power_active_export.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 1518, + "measurand": Measurand.power_active_import.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 8418, + "measurand": Measurand.power_offered.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 1, + "measurand": Measurand.power_factor.value, + }, + { + "value": 0, + "measurand": Measurand.power_reactive_export.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 0, + "measurand": Measurand.power_reactive_import.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 69, + "measurand": Measurand.soc.value, + "unit_of_measure": {"unit": "percent"}, + }, + { + "value": 229.9, + "measurand": Measurand.voltage.value, + "phase": PhaseType.l1_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + "value": 230, + "measurand": Measurand.voltage.value, + "phase": PhaseType.l2_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + "value": 230.4, + "measurand": Measurand.voltage.value, + "phase": PhaseType.l3_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + # Not among enabled measurands, will be ignored + "value": 1111, + "measurand": MeasurandType.energy_active_net.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.charging + ) + assert cs.get_metric(cpid, Measurand.current_export.value) == 0 + assert cs.get_metric(cpid, Measurand.current_import.value) == 6.6 + assert cs.get_metric(cpid, Measurand.current_offered.value) == 36.6 + assert cs.get_metric(cpid, Measurand.energy_active_export_register.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.1 + assert cs.get_metric(cpid, Measurand.energy_reactive_export_register.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_reactive_import_register.value) == 0 + assert cs.get_metric(cpid, Measurand.frequency.value) == 50 + assert cs.get_metric(cpid, Measurand.power_active_export.value) == 0 + assert cs.get_metric(cpid, Measurand.power_active_import.value) == 1.518 + assert cs.get_metric(cpid, Measurand.power_offered.value) == 8.418 + assert cs.get_metric(cpid, Measurand.power_reactive_export.value) == 0 + assert cs.get_metric(cpid, Measurand.power_reactive_import.value) == 0 + assert cs.get_metric(cpid, Measurand.soc.value) == 69 + assert cs.get_metric(cpid, Measurand.voltage.value) == 230.1 + assert cs.get_metric(cpid, csess.session_energy) == 0 + assert cs.get_metric(cpid, csess.session_time) == 0 + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + (tx_start_time + timedelta(seconds=60)).isoformat(), + TriggerReasonType.meter_value_periodic.value, + 3, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.charging.value, + }, + meter_value=[ + { + "timestamp": (tx_start_time + timedelta(seconds=60)).isoformat(), + "sampled_value": [ + { + "value": 256, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert cs.get_metric(cpid, csess.session_energy) == 0.156 + assert cs.get_metric(cpid, csess.session_time) == 1 + + await set_switch(hass, cs, "charge_control", False) + assert len(cp.remote_stops) == 1 + + await cp.call( + call.TransactionEvent( + TransactionEventType.ended.value, + (tx_start_time + timedelta(seconds=120)).isoformat(), + TriggerReasonType.remote_stop.value, + 4, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.ev_connected.value, + "stopped_reason": ReasonType.remote.value, + }, + meter_value=[ + { + "timestamp": (tx_start_time + timedelta(seconds=120)).isoformat(), + "sampled_value": [ + { + "value": 333, + "context": ReadingContextType.transaction_end, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert cs.get_metric(cpid, Measurand.current_import.value) == 0 + assert cs.get_metric(cpid, Measurand.current_offered.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.333 + assert cs.get_metric(cpid, Measurand.frequency.value) == 0 + assert cs.get_metric(cpid, Measurand.power_active_import.value) == 0 + assert cs.get_metric(cpid, Measurand.power_offered.value) == 0 + assert cs.get_metric(cpid, Measurand.power_reactive_import.value) == 0 + assert cs.get_metric(cpid, Measurand.soc.value) == 0 + assert cs.get_metric(cpid, Measurand.voltage.value) == 0 + assert cs.get_metric(cpid, csess.session_energy) == 0.233 + assert cs.get_metric(cpid, csess.session_time) == 2 + + # Now with energy reading in Started transaction event + await cp.call( + call.TransactionEvent( + TransactionEventType.started.value, + tx_start_time.isoformat(), + TriggerReasonType.cable_plugged_in.value, + 0, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.ev_connected.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 1000, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "kWh", "multiplier": -3}, + }, + ], + }, + ], + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.preparing + ) + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.charging.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 1234, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "kWh", "multiplier": -3}, + }, + ], + }, + ], + ) + ) + assert cs.get_metric(cpid, csess.session_energy) == 0.234 + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.suspended_ev.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.suspended_ev + ) + + await cp.call( + call.TransactionEvent( + TransactionEventType.updated.value, + tx_start_time.isoformat(), + TriggerReasonType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.suspended_evse.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.suspended_evse + ) + + await cp.call( + call.TransactionEvent( + TransactionEventType.ended.value, + tx_start_time.isoformat(), + TriggerReasonType.ev_communication_lost.value, + 2, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateType.idle.value, + "stopped_reason": ReasonType.ev_disconnected.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.available + ) + + +async def _set_variable( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint, key: str, value: str +) -> tuple[ServiceResponse, HomeAssistantError]: + response: ServiceResponse | None = None + error: HomeAssistantError | None = None + try: + response = await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_configure_v201, + service_data={"ocpp_key": key, "value": value}, + blocking=True, + return_response=True, + ) + except HomeAssistantError as e: + error = e + return response, error + + +async def _get_variable( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint, key: str +) -> tuple[ServiceResponse, HomeAssistantError]: + response: ServiceResponse | None = None + error: HomeAssistantError | None = None + try: + response = await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_configuration_v201, + service_data={"ocpp_key": key}, + blocking=True, + return_response=True, + ) + except HomeAssistantError as e: + error = e + return response, error + + +async def _test_services(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + service_response: ServiceResponse + error: HomeAssistantError + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/TxUpdatedInterval", "17" + ) + assert service_response == {"reboot_required": False} + assert cp.tx_updated_interval == 17 + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/RebootRequired", "17" + ) + assert service_response == {"reboot_required": True} + + service_response, error = await _set_variable( + hass, cs, cp, "TestComponent(CompInstance)/TestVariable(VarInstance)", "17" + ) + assert service_response == {"reboot_required": False} + assert cp.component_instance_used == "CompInstance" + assert cp.variable_instance_used == "VarInstance" + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/BadVariable", "17" + ) + assert error is not None + assert str(error).startswith("Failed to set variable") + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/VeryBadVariable", "17" + ) + assert error is not None + assert str(error).startswith("OCPP call failed: InternalError") + + service_response, error = await _set_variable( + hass, cs, cp, "does not compute", "17" + ) + assert error is not None + assert str(error) == "Invalid OCPP key" + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/TxUpdatedInterval" + ) + assert service_response == {"value": "17"} + + service_response, error = await _get_variable( + hass, cs, cp, "TestComponent(CompInstance)/TestInstance(VarInstance)" + ) + assert service_response == {"value": "CompInstance,VarInstance"} + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/BadVariale" + ) + assert error is not None + assert str(error).startswith("Failed to get variable") + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/VeryBadVariable" + ) + assert error is not None + assert str(error).startswith("OCPP call failed: InternalError") + + +async def _set_charge_rate_service( + hass: HomeAssistant, data: dict +) -> HomeAssistantError: + try: + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data=data, + blocking=True, + ) + except HomeAssistantError as e: + return e + return None + + +async def _test_charge_profiles( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint +): + error: HomeAssistantError = await _set_charge_rate_service( + hass, {"limit_watts": 3000} + ) + assert error is None + assert len(cp.charge_profiles_set) == 1 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 3000}], + "charging_rate_unit": ChargingRateUnitType.watts.value, + }, + ], + } + + error = await _set_charge_rate_service(hass, {"limit_amps": 16}) + assert error is None + assert len(cp.charge_profiles_set) == 2 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 16}], + "charging_rate_unit": ChargingRateUnitType.amps.value, + }, + ], + } + + error = await _set_charge_rate_service( + hass, + { + "custom_profile": """{ + 'id': 2, + 'stack_level': 1, + 'charging_profile_purpose': 'TxProfile', + 'charging_profile_kind': 'Relative', + 'charging_schedule': [{ + 'id': 1, + 'charging_rate_unit': 'A', + 'charging_schedule_period': [{'start_period': 0, 'limit': 6}] + }] + }""" + }, + ) + assert error is None + assert len(cp.charge_profiles_set) == 3 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 2, + "stack_level": 1, + "charging_profile_purpose": ChargingProfilePurposeType.tx_profile.value, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 6}], + "charging_rate_unit": ChargingRateUnitType.amps.value, + }, + ], + } + + await set_number(hass, cs, "maximum_current", 12) + assert len(cp.charge_profiles_set) == 4 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile.value, + "charging_profile_kind": ChargingProfileKindType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 12}], + "charging_rate_unit": ChargingRateUnitType.amps.value, + }, + ], + } + + error = await _set_charge_rate_service(hass, {"limit_amps": 5}) + assert error is not None + assert str(error).startswith("Failed to set variable: Rejected") + + assert len(cp.charge_profiles_cleared) == 0 + await set_number(hass, cs, "maximum_current", 32) + assert len(cp.charge_profiles_cleared) == 1 + assert cp.charge_profiles_cleared[-1].charging_profile_id is None + assert cp.charge_profiles_cleared[-1].charging_profile_criteria == { + "charging_profile_purpose": ChargingProfilePurposeType.charging_station_max_profile.value + } + + +async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + boot_res: call_result.BootNotification = await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonType.power_up.value, + ) + ) + assert boot_res.status == RegistrationStatusType.accepted.value + assert boot_res.status_info is None + datetime.fromisoformat(boot_res.current_time) + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusType.available, 1, 1 + ) + ) + + heartbeat_resp: call_result.Heartbeat = await cp.call(call.Heartbeat()) + datetime.fromisoformat(heartbeat_resp.current_time) + + await wait_ready(hass) + + # Junk report to be ignored + await cp.call(call.NotifyReport(2, datetime.now(tz=UTC).isoformat(), 0)) + + cpid: str = cs.settings.cpid + 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.features.value) + == Profiles.CORE | Profiles.SMART | Profiles.RES | Profiles.AUTH + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusType.available.value + ) + assert cp.tx_updated_interval == DEFAULT_METER_INTERVAL + assert cp.tx_updated_measurands == supported_measurands + + while cp.operative is None: + await asyncio.sleep(0.1) + assert cp.operative + + await _test_transaction(hass, cs, cp) + await _test_services(hass, cs, cp) + await _test_charge_profiles(hass, cs, cp) + + await press_button(hass, cs, "reset") + assert len(cp.resets) == 1 + assert cp.resets[0].type == ResetType.immediate.value + assert cp.resets[0].evse_id is None + + error: HomeAssistantError = None + cp.accept_reset = False + try: + await press_button(hass, cs, "reset") + except HomeAssistantError as e: + error = e + assert error is not None + assert str(error) == "OCPP call failed: Rejected" + + await set_switch(hass, cs, "availability", False) + assert not cp.operative + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusType.unavailable, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusType.unavailable.value + ) + + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusType.faulted, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusType.faulted.value + ) + + await cp.call(call.FirmwareStatusNotification(FirmwareStatusType.installed.value)) + + +class ChargePointAllFeatures(ChargePoint): + """A charge point which also supports UpdateFirmware and TriggerMessage.""" + + triggered_status_notification: list[EVSEType] = [] + + @on(Action.UpdateFirmware) + def _on_update_firmware(self, request_id: int, firmware: dict, **kwargs): + return call_result.UpdateFirmware(UpdateFirmwareStatusType.rejected.value) + + @on(Action.TriggerMessage) + def _on_trigger_message(self, requested_message: str, **kwargs): + if (requested_message == MessageTriggerType.status_notification) and ( + "evse" in kwargs + ): + self.triggered_status_notification.append( + EVSEType(kwargs["evse"]["id"], kwargs["evse"]["connector_id"]) + ) + return call_result.TriggerMessage(TriggerMessageStatusType.rejected.value) + + +async def _extra_features_test( + hass: HomeAssistant, + cs: CentralSystem, + cp: ChargePointAllFeatures, +): + await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonType.power_up.value, + ) + ) + await wait_ready(hass) + + assert ( + cs.get_metric(cs.settings.cpid, cdet.features.value) + == Profiles.CORE + | Profiles.SMART + | Profiles.RES + | Profiles.AUTH + | Profiles.FW + | Profiles.REM + ) + + while len(cp.triggered_status_notification) < 1: + await asyncio.sleep(0.1) + assert cp.triggered_status_notification[0].id == 1 + assert cp.triggered_status_notification[0].connector_id == 1 + + +class ChargePointReportUnsupported(ChargePointAllFeatures): + """A charge point which does not support GetBaseReport.""" + + @on(Action.GetBaseReport) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + raise ocpp.exceptions.NotImplementedError("This is not implemented") + + +class ChargePointReportFailing(ChargePointAllFeatures): + """A charge point which keeps failing GetBaseReport.""" + + @on(Action.GetBaseReport) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + raise ocpp.exceptions.InternalError("Test failure") + + +async def _unsupported_base_report_test( + hass: HomeAssistant, + cs: CentralSystem, + cp: ChargePoint, +): + await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonType.power_up.value, + ) + ) + await wait_ready(hass) + assert ( + cs.get_metric(cs.settings.cpid, cdet.features.value) + == Profiles.CORE | Profiles.REM | Profiles.FW + ) + + +@pytest.mark.timeout(90) +async def test_cms_responses_v201(hass, socket_enabled): + """Test central system responses to a charger.""" + + # Should not have to do this ideally, however web socket in the CSMS + # restarts if measurands reported by the charger differ from the list + # from the configuration, which a real charger can deal with but this + # test cannot + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_MONITORED_VARIABLES] = ",".join(supported_measurands) + + config_data[CONF_PORT] = 9010 + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" + ) + cs: CentralSystem = await create_configuration(hass, config_entry) + await run_charge_point_test( + config_entry, + "CP_2", + ["ocpp2.0.1"], + lambda ws: ChargePoint("CP_2_client", ws), + [lambda cp: _run_test(hass, cs, cp)], + ) + + await run_charge_point_test( + config_entry, + "CP_2_allfeatures", + ["ocpp2.0.1"], + lambda ws: ChargePointAllFeatures("CP_2_allfeatures_client", ws), + [lambda cp: _extra_features_test(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) + config_data[CONF_MONITORED_VARIABLES] = "" + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, data=config_data, entry_id="test_cms", title="test_cms" + ) + cs = await create_configuration(hass, config_entry) + + await run_charge_point_test( + config_entry, + "CP_2_noreport", + ["ocpp2.0.1"], + lambda ws: ChargePointReportUnsupported("CP_2_noreport_client", ws), + [lambda cp: _unsupported_base_report_test(hass, cs, cp)], + ) + + await run_charge_point_test( + config_entry, + "CP_2_report_fail", + ["ocpp2.0.1"], + lambda ws: ChargePointReportFailing("CP_2_report_fail_client", ws), + [lambda cp: _unsupported_base_report_test(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry)