From f66f167c6ace09e9a8a5abbb10c0db700602d351 Mon Sep 17 00:00:00 2001 From: drc38 <20024196+drc38@users.noreply.github.com> Date: Sat, 17 Jul 2021 03:41:56 +1200 Subject: [PATCH] ocpp: significant repo update (#75) --- .github/workflows/tests.yaml | 1 + custom_components/ocpp/api.py | 438 ++++++++++++++++++--------- custom_components/ocpp/enums.py | 142 +++++++++ custom_components/ocpp/services.yaml | 88 ++++-- setup.cfg | 2 +- tests/conftest.py | 4 +- tests/const.py | 9 + tests/test_charge_point.py | 430 ++++++++++++++++++++++++-- tests/test_switch.py | 4 +- 9 files changed, 919 insertions(+), 199 deletions(-) create mode 100644 custom_components/ocpp/enums.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 64fcf8b3..76e59c0d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -82,4 +82,5 @@ jobs: --durations=10 \ -n auto \ -p no:sugar \ + -rA \ tests diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 2fdf25d9..9f48e202 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -30,6 +30,7 @@ ConfigurationStatus, DataTransferStatus, Measurand, + MessageTrigger, RegistrationStatus, RemoteStartStopStatus, ResetStatus, @@ -56,21 +57,45 @@ DEFAULT_POWER_UNIT, DEFAULT_SUBPROTOCOL, DOMAIN, - FEATURE_PROFILE_FW, - FEATURE_PROFILE_REMOTE, - FEATURE_PROFILE_SMART, HA_ENERGY_UNIT, HA_POWER_UNIT, - SERVICE_AVAILABILITY, - SERVICE_CHARGE_START, - SERVICE_CHARGE_STOP, - SERVICE_RESET, - SERVICE_UNLOCK, +) +from .enums import ( + ConfigurationKey as ckey, + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, ) _LOGGER: logging.Logger = logging.getLogger(__package__) logging.getLogger(DOMAIN).setLevel(logging.DEBUG) +SCR_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("limit_amps"): int, + vol.Optional("limit_watts"): int, + } +) +UFW_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("firmware_url"): str, + vol.Optional("delay_hours"): int, + } +) +CONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("ocpp_key"): str, + vol.Required("value"): str, + } +) +GCONF_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Required("ocpp_key"): str, + } +) + class CentralSystem: """Server for handling OCPP connections.""" @@ -111,11 +136,11 @@ async def on_connect(self, websocket, path: str): _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") cp = ChargePoint(cp_id, websocket, self.hass, self.entry, self) self.charge_points[self.cpid] = cp - await cp.start() + await self.charge_points[self.cpid].start() else: _LOGGER.info(f"Charger {cp_id} reconnected to {self.host}:{self.port}.") cp = self.charge_points[self.cpid] - await cp.reconnect(websocket) + await self.charge_points[self.cpid].reconnect(websocket) except Exception as e: _LOGGER.info(f"Exception occurred:\n{e}") finally: @@ -138,15 +163,15 @@ async def set_charger_state( ): """Carry out requested service/state change on connected charger.""" if cp_id in self.charge_points: - if service_name == SERVICE_AVAILABILITY: + if service_name == csvcs.service_availability.name: resp = await self.charge_points[cp_id].set_availability(state) - if service_name == SERVICE_CHARGE_START: + if service_name == csvcs.service_charge_start.name: resp = await self.charge_points[cp_id].start_transaction() - if service_name == SERVICE_CHARGE_STOP: + if service_name == csvcs.service_charge_stop.name: resp = await self.charge_points[cp_id].stop_transaction() - if service_name == SERVICE_RESET: + if service_name == csvcs.service_reset.name: resp = await self.charge_points[cp_id].reset() - if service_name == SERVICE_UNLOCK: + if service_name == csvcs.service_unlock.name: resp = await self.charge_points[cp_id].unlock() else: resp = False @@ -186,49 +211,126 @@ def __init__( self._features_supported = {} self.preparing = asyncio.Event() self._transactionId = 0 - self._metrics["ID"] = id - self._units["Session.Time"] = TIME_MINUTES - self._units["Session.Energy"] = UnitOfMeasure.kwh.value - self._units["Meter.Start"] = UnitOfMeasure.kwh.value + self._metrics[cdet.identifier.value] = id + self._units[csess.session_time.value] = TIME_MINUTES + self._units[csess.session_energy.value] = UnitOfMeasure.kwh.value + self._units[csess.meter_start.value] = UnitOfMeasure.kwh.value 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.""" + await self.clear_profile() + + async def handle_set_charge_rate(call): + """Handle the set charge rate service call.""" + lim_A = call.data.get("limit_amps") + lim_W = call.data.get("limit_watts") + if lim_A is not None and lim_W is not None: + await self.set_charge_rate(lim_A, lim_W) + elif lim_A is not None: + await self.set_charge_rate(limit_amps=lim_A) + elif lim_W is not None: + await self.set_charge_rate(limit_watts=lim_W) + else: + await self.set_charge_rate() + + async def handle_update_firmware(call): + """Handle the firmware update service call.""" + 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.""" + key = call.data.get("ocpp_key") + value = call.data.get("value") + for line in ckey: + if key == line.value: + await self.configure(key, value) + return + _LOGGER.error("Ocpp key not supported: %s ", key) + + async def handle_get_configuration(call): + """Handle the get configuration service call.""" + key = call.data.get("ocpp_key") + for line in ckey: + if key == line.value: + await self.get_configuration(key) + return + _LOGGER.error("Ocpp key not supported: %s ", key) + try: await self.get_supported_features() - if FEATURE_PROFILE_REMOTE in self._features_supported: + if om.feature_profile_remote.value in self._features_supported: await self.trigger_boot_notification() await self.trigger_status_notification() await self.become_operative() - await self.get_configuration("HeartbeatInterval") - await self.configure("WebSocketPingInterval", "60") + await self.get_configuration(ckey.heartbeat_interval.value) + await self.configure(ckey.web_socket_ping_interval.value, "60") await self.configure( - "MeterValuesSampledData", + ckey.meter_values_sampled_data.value, self.entry.data[CONF_MONITORED_VARIABLES], ) await self.configure( - "MeterValueSampleInterval", str(self.entry.data[CONF_METER_INTERVAL]) + ckey.meter_value_sample_interval.value, + str(self.entry.data[CONF_METER_INTERVAL]), ) # await self.configure( # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) # ) - resp = await self.get_configuration("NumberOfConnectors") - self._metrics["Connectors"] = resp.configuration_key[0]["value"] + resp = await self.get_configuration(ckey.number_of_connectors.value) + self._metrics[cdet.connectors.value] = resp.configuration_key[0]["value"] # 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, + ) + if om.feature_profile_smart.value in self._features_supported: + 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, + SCR_SERVICE_DATA_SCHEMA, + ) + if om.feature_profile_firmware.value in self._features_supported: + self.hass.services.async_register( + DOMAIN, + csvcs.service_update_firmware.value, + handle_update_firmware, + UFW_SERVICE_DATA_SCHEMA, + ) except (NotImplementedError) as e: _LOGGER.error("Configuration of the charger failed: %s", e) async def get_supported_features(self): """Get supported features.""" - req = call.GetConfigurationPayload(key=["SupportedFeatureProfiles"]) + req = call.GetConfigurationPayload(key=[ckey.supported_feature_profiles.value]) resp = await self.call(req) for key_value in resp.configuration_key: self._features_supported = key_value["value"] - self._metrics["Features"] = self._features_supported - _LOGGER.debug("SupportedFeatureProfiles: %s", self._features_supported) + self._metrics[cdet.features.value] = self._features_supported + _LOGGER.debug("Supported feature profiles: %s", self._features_supported) async def trigger_boot_notification(self): """Trigger a boot notification.""" - req = call.TriggerMessagePayload(requested_message="BootNotification") + req = call.TriggerMessagePayload( + requested_message=MessageTrigger.boot_notification + ) resp = await self.call(req) if resp.status == TriggerMessageStatus.accepted: return True @@ -238,7 +340,9 @@ async def trigger_boot_notification(self): async def trigger_status_notification(self): """Trigger a status notification.""" - req = call.TriggerMessagePayload(requested_message="StatusNotification") + req = call.TriggerMessagePayload( + requested_message=MessageTrigger.status_notification + ) resp = await self.call(req) if resp.status == TriggerMessageStatus.accepted: return True @@ -252,7 +356,7 @@ async def become_operative(self): return resp async def clear_profile(self): - """Clear profile.""" + """Clear all charging profiles.""" req = call.ClearChargingProfilePayload() resp = await self.call(req) if resp.status == ClearChargingProfileStatus.accepted: @@ -263,31 +367,38 @@ async def clear_profile(self): async def set_charge_rate(self, limit_amps: int = 32, limit_watts: int = 22000): """Set a charging profile with defined limit.""" - if FEATURE_PROFILE_SMART in self._features_supported: + if om.feature_profile_smart.value in self._features_supported: resp = await self.get_configuration( - "ChargingScheduleAllowedChargingRateUnit" + ckey.charging_schedule_allowed_charging_rate_unit.value ) _LOGGER.debug( "Charger supports setting the following units: %s", resp.configuration_key[0]["value"], ) - _LOGGER.debug("If more than one unit supported default unit is amps") - if "current" in resp.configuration_key[0]["value"].lower(): + _LOGGER.debug("If more than one unit supported default unit is Amps") + if om.current.value in resp.configuration_key[0]["value"]: lim = limit_amps - units = ChargingRateUnitType.amps + units = ChargingRateUnitType.amps.value else: lim = limit_watts - units = ChargingRateUnitType.watts + units = ChargingRateUnitType.watts.value + resp = await self.get_configuration( + ckey.charge_profile_max_stack_level.value + ) + stack_level = int(resp.configuration_key[0]["value"]) + req = call.SetChargingProfilePayload( connector_id=0, cs_charging_profiles={ - "chargingProfileId": 8, - "stackLevel": 999, - "chargingProfileKind": ChargingProfileKindType.relative, - "chargingProfilePurpose": ChargingProfilePurposeType.tx_profile, - "chargingSchedule": { - "chargingRateUnit": units, - "chargingSchedulePeriod": [{"startPeriod": 0, "limit": lim}], + 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} + ], }, }, ) @@ -308,9 +419,9 @@ async def set_availability(self, state: bool = True): await self.stop_transaction() """ change availability """ if state is True: - typ = AvailabilityType.operative + typ = AvailabilityType.operative.value else: - typ = AvailabilityType.inoperative + typ = AvailabilityType.inoperative.value req = call.ChangeAvailabilityPayload(connector_id=0, type=typ) resp = await self.call(req) @@ -323,41 +434,47 @@ async def set_availability(self, state: bool = True): async def start_transaction(self, limit_amps: int = 32, limit_watts: int = 22000): """Start a Transaction.""" """Check if authorisation enabled, if it is disable it before remote start""" - resp = await self.get_configuration("AuthorizeRemoteTxRequests") + resp = await self.get_configuration(ckey.authorize_remote_tx_requests.value) if resp.configuration_key[0]["value"].lower() == "true": - await self.configure("AuthorizeRemoteTxRequests", "false") - if FEATURE_PROFILE_SMART in self._features_supported: + await self.configure(ckey.authorize_remote_tx_requests.value, "false") + if om.feature_profile_smart.value in self._features_supported: resp = await self.get_configuration( - "ChargingScheduleAllowedChargingRateUnit" + ckey.charging_schedule_allowed_charging_rate_unit.value ) _LOGGER.debug( "Charger supports setting the following units: %s", resp.configuration_key[0]["value"], ) - _LOGGER.debug("If more than one unit supported default unit is amps") - if "current" in resp.configuration_key[0]["value"].lower(): + _LOGGER.debug("If more than one unit supported default unit is Amps") + if om.current.value in resp.configuration_key[0]["value"]: lim = limit_amps - units = ChargingRateUnitType.amps + units = ChargingRateUnitType.amps.value else: lim = limit_watts - units = ChargingRateUnitType.watts + units = ChargingRateUnitType.watts.value + resp = await self.get_configuration( + ckey.charge_profile_max_stack_level.value + ) + stack_level = int(resp.configuration_key[0]["value"]) req = call.RemoteStartTransactionPayload( connector_id=1, - id_tag=self._metrics["ID"], + id_tag=self._metrics[cdet.identifier.value], charging_profile={ - "chargingProfileId": 1, - "stackLevel": 999, - "chargingProfileKind": ChargingProfileKindType.relative, - "chargingProfilePurpose": ChargingProfilePurposeType.tx_profile, - "chargingSchedule": { - "chargingRateUnit": units, - "chargingSchedulePeriod": [{"startPeriod": 0, "limit": lim}], + om.charging_profile_id.value: 1, + om.stack_level.value: stack_level, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_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: req = call.RemoteStartTransactionPayload( - connector_id=1, id_tag=self._metrics["ID"] + connector_id=1, id_tag=self._metrics[cdet.identifier.value] ) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: @@ -368,6 +485,8 @@ async def start_transaction(self, limit_amps: int = 32, limit_watts: int = 22000 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""" req = call.RemoteStopTransactionPayload(transaction_id=self._transactionId) resp = await self.call(req) if resp.status == RemoteStartStopStatus.accepted: @@ -400,11 +519,10 @@ 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 FEATURE_PROFILE_FW in self._features_supported: + if om.feature_profile_firmware.value in self._features_supported: schema = vol.Schema(vol.Url()) try: url = schema(firmware_url) - raise AssertionError("Multiple invalid not raised") except vol.MultipleInvalid as e: _LOGGER.debug("Failed to parse url: %s", e) update_time = ( @@ -449,14 +567,17 @@ async def configure(self, key: str, value: str): if key_value["key"] == key and key_value["value"] == value: return - if key_value.get("readonly", False): + if key_value.get(om.readonly.name, False): _LOGGER.warning("%s is a read only setting", key) req = call.ChangeConfigurationPayload(key=key, value=value) resp = await self.call(req) - if resp.status in [ConfigurationStatus.rejected, "NotSupported"]: + if resp.status in [ + ConfigurationStatus.rejected, + ConfigurationStatus.not_supported, + ]: _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) if resp.status == ConfigurationStatus.reboot_required: @@ -493,74 +614,86 @@ async def reconnect(self, connection): self._connection = connection await self.start() + 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) + + dr = await device_registry.async_get_registry(self.hass) + + serial = boot_info.get(om.charge_point_serial_number.name, None) + + identifiers = {(DOMAIN, self.central.cpid), (DOMAIN, self.id)} + if serial is not None: + identifiers.add((DOMAIN, serial)) + + dr.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), + sw_version=boot_info.get(om.firmware_version.name, None), + ) + @on(Action.MeterValues) def on_meter_values(self, connector_id: int, meter_value: Dict, **kwargs): """Request handler for MeterValues Calls.""" for bucket in meter_value: for sampled_value in bucket["sampled_value"]: - if "measurand" in sampled_value: - self._metrics[sampled_value["measurand"]] = sampled_value["value"] - self._metrics[sampled_value["measurand"]] = round( - float(self._metrics[sampled_value["measurand"]]), 1 + if om.measurand.value in sampled_value: + self._metrics[sampled_value[om.measurand.value]] = sampled_value[ + "value" + ] + self._metrics[sampled_value[om.measurand.value]] = round( + float(self._metrics[sampled_value[om.measurand.value]]), 1 ) if "unit" in sampled_value: - self._units[sampled_value["measurand"]] = sampled_value["unit"] + self._units[sampled_value[om.measurand.value]] = sampled_value[ + "unit" + ] if ( - self._units[sampled_value["measurand"]] + self._units[sampled_value[om.measurand.value]] == DEFAULT_POWER_UNIT ): - self._metrics[sampled_value["measurand"]] = ( - float(self._metrics[sampled_value["measurand"]]) / 1000 + self._metrics[sampled_value[om.measurand.value]] = ( + float(self._metrics[sampled_value[om.measurand.value]]) + / 1000 ) - self._units[sampled_value["measurand"]] = HA_POWER_UNIT + self._units[ + sampled_value[om.measurand.value] + ] = HA_POWER_UNIT if ( - self._units[sampled_value["measurand"]] + self._units[sampled_value[om.measurand.value]] == DEFAULT_ENERGY_UNIT ): - self._metrics[sampled_value["measurand"]] = ( - float(self._metrics[sampled_value["measurand"]]) / 1000 + self._metrics[sampled_value[om.measurand.value]] = ( + float(self._metrics[sampled_value[om.measurand.value]]) + / 1000 ) - self._units[sampled_value["measurand"]] = HA_ENERGY_UNIT + self._units[ + sampled_value[om.measurand.value] + ] = HA_ENERGY_UNIT if len(sampled_value.keys()) == 1: # for backwards compatibility self._metrics[DEFAULT_MEASURAND] = sampled_value["value"] self._units[DEFAULT_MEASURAND] = DEFAULT_ENERGY_UNIT - if "Meter.Start" not in self._metrics: - self._metrics["Meter.Start"] = self._metrics[DEFAULT_MEASURAND] - if "Transaction.Id" not in self._metrics: - self._metrics["Transaction.Id"] = kwargs.get("transaction_id") - self._transactionId = kwargs.get("transaction_id") - self._metrics["Session.Time"] = round( - (int(time.time()) - float(self._metrics["Transaction.Id"])) / 60 + if csess.meter_start.value not in self._metrics: + self._metrics[csess.meter_start.value] = self._metrics[DEFAULT_MEASURAND] + if csess.transaction_id.value not in self._metrics: + self._metrics[csess.transaction_id.value] = kwargs.get( + om.transaction_id.name + ) + self._transactionId = kwargs.get(om.transaction_id.name) + self._metrics[csess.session_time.value] = round( + (int(time.time()) - float(self._metrics[csess.transaction_id.value])) / 60 ) - self._metrics["Session.Energy"] = round( + self._metrics[csess.session_energy.value] = round( float(self._metrics[DEFAULT_MEASURAND]) - - float(self._metrics["Meter.Start"]), + - float(self._metrics[csess.meter_start.value]), 1, ) return call_result.MeterValuesPayload() - async def async_update_device_info(self, boot_info: dict): - """Update device info asynchronuously.""" - - _LOGGER.debug("Updating device info %s: %s", self.id, boot_info) - - dr = await device_registry.async_get_registry(self.hass) - - serial = boot_info.get("charge_point_serial_number", None) - - identifiers = {(DOMAIN, self.id)} - if serial is not None: - identifiers.add((DOMAIN, serial)) - - dr.async_get_or_create( - config_entry_id=self.entry.entry_id, - identifiers=identifiers, - name=self.id, - manufacturer=boot_info.get("charge_point_vendor", None), - model=boot_info.get("charge_point_model", None), - sw_version=boot_info.get("firmware_version", None), - ) - @on(Action.BootNotification) def on_boot_notification(self, **kwargs): """Handle a boot notification.""" @@ -568,92 +701,97 @@ def on_boot_notification(self, **kwargs): _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) # update metrics - self._metrics["Model"] = kwargs.get("charge_point_model", None) - self._metrics["Vendor"] = kwargs.get("charge_point_vendor", None) - self._metrics["FW.Version"] = kwargs.get("firmware_version", None) - self._metrics["Serial"] = kwargs.get("charge_point_serial_number", None) + self._metrics[cdet.model.value] = kwargs.get(om.charge_point_model.name, None) + self._metrics[cdet.vendor.value] = kwargs.get(om.charge_point_vendor.name, None) + self._metrics[cdet.firmware_version.value] = kwargs.get( + om.firmware_version.name, None + ) + self._metrics[cdet.serial.value] = kwargs.get( + om.charge_point_serial_number.name, None + ) asyncio.create_task(self.async_update_device_info(kwargs)) return call_result.BootNotificationPayload( current_time=datetime.now(tz=timezone.utc).isoformat(), interval=30, - status=RegistrationStatus.accepted, + status=RegistrationStatus.accepted.value, ) @on(Action.StatusNotification) def on_status_notification(self, connector_id, error_code, status, **kwargs): """Handle a status notification.""" - self._metrics["Status"] = status + self._metrics[cstat.status.value] = status if ( - status == ChargePointStatus.suspended_ev - or status == ChargePointStatus.suspended_evse + status == ChargePointStatus.suspended_ev.value + or status == ChargePointStatus.suspended_evse.value ): - if Measurand.current_import in self._metrics: - self._metrics[Measurand.current_import] = 0 - if Measurand.power_active_import in self._metrics: - self._metrics[Measurand.power_active_import] = 0 - if Measurand.power_reactive_import in self._metrics: - self._metrics[Measurand.power_reactive_import] = 0 - self._metrics["Error.Code"] = error_code + if Measurand.current_import.value in self._metrics: + self._metrics[Measurand.current_import.value] = 0 + if Measurand.power_active_import.value in self._metrics: + self._metrics[Measurand.power_active_import.value] = 0 + if Measurand.power_reactive_import.value in self._metrics: + self._metrics[Measurand.power_reactive_import.value] = 0 + self._metrics[cstat.error_code.value] = error_code return call_result.StatusNotificationPayload() @on(Action.FirmwareStatusNotification) - def on_firmware_status(self, fwstatus, **kwargs): - """Handle formware status notification.""" - self._metrics["FW.Status"] = fwstatus + def on_firmware_status(self, status, **kwargs): + """Handle firmware status notification.""" + self._metrics[cstat.firmware_status.value] = status return call_result.FirmwareStatusNotificationPayload() @on(Action.Authorize) def on_authorize(self, id_tag, **kwargs): - """Handle a Authorization request.""" + """Handle an Authorization request.""" return call_result.AuthorizePayload( - id_tag_info={"status": AuthorizationStatus.accepted} + id_tag_info={om.status.value: AuthorizationStatus.accepted.value} ) @on(Action.StartTransaction) def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): """Handle a Start Transaction request.""" self._transactionId = int(time.time()) - self._metrics["Stop.Reason"] = "" - self._metrics["Transaction.Id"] = self._transactionId - self._metrics["Meter.Start"] = int(meter_start) / 1000 + self._metrics[cstat.stop_reason.value] = "" + self._metrics[csess.transaction_id.value] = self._transactionId + self._metrics[csess.meter_start.value] = int(meter_start) / 1000 return call_result.StartTransactionPayload( - id_tag_info={"status": AuthorizationStatus.accepted}, + id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, transaction_id=self._transactionId, ) @on(Action.StopTransaction) def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): """Stop the current transaction.""" - self._metrics["Stop.Reason"] = kwargs.get("reason", None) + self._metrics[cstat.stop_reason.value] = kwargs.get(om.reason.name, None) - if "Meter.Start" in self._metrics: - self._metrics["Session.Energy"] = round( - int(meter_stop) / 1000 - float(self._metrics["Meter.Start"]), 1 + if csess.meter_start.value in self._metrics: + self._metrics[csess.session_energy.value] = round( + int(meter_stop) / 1000 - float(self._metrics[csess.meter_start.value]), + 1, ) - if Measurand.current_import in self._metrics: - self._metrics[Measurand.current_import] = 0 - if Measurand.power_active_import in self._metrics: - self._metrics[Measurand.power_active_import] = 0 - if Measurand.power_reactive_import in self._metrics: - self._metrics[Measurand.power_reactive_import] = 0 + if Measurand.current_import.value in self._metrics: + self._metrics[Measurand.current_import.value] = 0 + if Measurand.power_active_import.value in self._metrics: + self._metrics[Measurand.power_active_import.value] = 0 + if Measurand.power_reactive_import.value in self._metrics: + self._metrics[Measurand.power_reactive_import.value] = 0 return call_result.StopTransactionPayload( - id_tag_info={"status": AuthorizationStatus.accepted} + id_tag_info={om.status.value: AuthorizationStatus.accepted.value} ) @on(Action.DataTransfer) def on_data_transfer(self, vendor_id, **kwargs): """Handle a Data transfer request.""" _LOGGER.debug("Datatransfer received from %s: %s", self.id, kwargs) - return call_result.DataTransferPayload(status=DataTransferStatus.accepted) + return call_result.DataTransferPayload(status=DataTransferStatus.accepted.value) @on(Action.Heartbeat) def on_heartbeat(self, **kwargs): """Handle a Heartbeat.""" now = datetime.now(tz=timezone.utc).isoformat() - self._metrics["Heartbeat"] = now - self._units["Heartbeat"] = "time" + self._metrics[cstat.heartbeat.value] = now + self._units[cstat.heartbeat.value] = "time" return call_result.HeartbeatPayload(current_time=now) def get_metric(self, measurand: str): diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py new file mode 100644 index 00000000..f17108c8 --- /dev/null +++ b/custom_components/ocpp/enums.py @@ -0,0 +1,142 @@ +"""Additional enumerated values to use in home assistant.""" +from enum import Enum + + +class HAChargerServices(str, Enum): + """Charger status conditions to report in home assistant.""" + + """For HA service reference use .name for function to call use .value""" + + service_charge_start = "start_transaction" + service_charge_stop = "stop_transaction" + service_availability = "availability" + service_set_charge_rate = "set_charge_rate" + service_reset = "reset" + service_unlock = "unlock" + service_update_firmware = "update_firmware" + service_configure = "configure" + service_get_configuration = "get_configuration" + service_clear_profile = "clear_profile" + + +class HAChargerStatuses(str, Enum): + """Charger status conditions to report in home assistant.""" + + status = "Status" + heartbeat = "Heartbeat" + error_code = "Error.Code" + stop_reason = "Stop.Reason" + firmware_status = "FW.Status" + + +class HAChargerDetails(str, Enum): + """Charger nameplate information to report in home assistant.""" + + identifier = "ID" + model = "Model" + vendor = "Vendor" + serial = "Serial" + firmware_version = "FW.Version" + features = "Features" + connectors = "Connectors" + + +class HAChargerSession(str, Enum): + """Charger session information to report in home assistant.""" + + transaction_id = "Transaction.Id" + session_time = "Session.Time" # in min + session_energy = "Session.Energy" # in kWh + meter_start = "Meter.Start" # in kWh + + +class OcppMisc(str, Enum): + """Miscellaneous strings used in ocpp v1.6 responses.""" + + """For pythonic version use .name (eg with kwargs) for ocpp json use .value""" + + limit = "limit" + measurand = "measurand" + reason = "reason" + readonly = "readonly" + status = "status" + transaction_id = "transactionId" + charge_point_serial_number = "chargePointSerialNumber" + charge_point_vendor = "chargePointVendor" + charge_point_model = "chargePointModel" + firmware_version = "firmwareVersion" + charging_profile_id = "chargingProfileId" + stack_level = "stackLevel" + charging_profile_kind = "chargingProfileKind" + charging_profile_purpose = "chargingProfilePurpose" + charging_schedule = "chargingSchedule" + charging_rate_unit = "chargingRateUnit" + charging_schedule_period = "chargingSchedulePeriod" + start_period = "startPeriod" + feature_profile_core = "Core" + feature_profile_firmware = "FirmwareManagement" + feature_profile_smart = "SmartCharging" + feature_profile_reservation = "Reservation" + feature_profile_remote = "RemoteTrigger" + feature_profile_auth = "LocalAuthListManagement" + + # for use with Smart Charging + current = "Current" + power = "Power" + + +class ConfigurationKey(str, Enum): + """Configuration Key Names.""" + + # 9.1 Core Profile + allow_offline_tx_for_unknown_id = "AllowOfflineTxForUnknownId" + authorization_cache_enabled = "AuthorizationCacheEnabled" + authorize_remote_tx_requests = "AuthorizeRemoteTxRequests" + blink_repeat = "BlinkRepeat" + clock_aligned_data_interval = "ClockAlignedDataInterval" + connection_time_out = "ConnectionTimeOut" + connector_phase_rotation = "ConnectorPhaseRotation" + connector_phase_rotation_max_length = "ConnectorPhaseRotationMaxLength" + get_configuration_max_keys = "GetConfigurationMaxKeys" + heartbeat_interval = "HeartbeatInterval" + light_intensity = "LightIntensity" + local_authorize_offline = "LocalAuthorizeOffline" + local_pre_authorize = "LocalPreAuthorize" + max_energy_on_invalid_id = "MaxEnergyOnInvalidId" + meter_values_aligned_data = "MeterValuesAlignedData" + meter_values_aligned_data_max_length = "MeterValuesAlignedDataMaxLength" + meter_values_sampled_data = "MeterValuesSampledData" + meter_values_sampled_data_max_length = "MeterValuesSampledDataMaxLength" + meter_value_sample_interval = "MeterValueSampleInterval" + minimum_status_duration = "MinimumStatusDuration" + number_of_connectors = "NumberOfConnectors" + reset_retries = "ResetRetries" + stop_transaction_on_ev_side_disconnect = "StopTransactionOnEVSideDisconnect" + stop_transaction_on_invalid_id = "StopTransactionOnInvalidId" + stop_txn_aligned_data = "StopTxnAlignedData" + stop_txn_aligned_data_max_length = "StopTxnAlignedDataMaxLength" + stop_txn_sampled_data = "StopTxnSampledData" + stop_txn_sampled_data_max_length = "StopTxnSampledDataMaxLength" + supported_feature_profiles = "SupportedFeatureProfiles" + supported_feature_profiles_max_length = "SupportedFeatureProfilesMaxLength" + transaction_message_attempts = "TransactionMessageAttempts" + transaction_message_retry_interval = "TransactionMessageRetryInterval" + unlock_connector_on_ev_side_disconnect = "UnlockConnectorOnEVSideDisconnect" + web_socket_ping_interval = "WebSocketPingInterval" + + # 9.2 Local Auth List Management Profile + local_auth_list_enabled = "LocalAuthListEnabled" + local_auth_list_max_length = "LocalAuthListMaxLength" + send_local_list_max_length = "SendLocalListMaxLength" + + # 9.3 Reservation Profile + reserve_connector_zero_supported = "ReserveConnectorZeroSupported" + + # 9.4 Smart Charging Profile + charge_profile_max_stack_level = "ChargeProfileMaxStackLevel" + charging_schedule_allowed_charging_rate_unit = ( + "ChargingScheduleAllowedChargingRateUnit" + ) + charging_schedule_max_periods = "ChargingScheduleMaxPeriods" + connector_switch_3to1_phase_supported = "ConnectorSwitch3to1PhaseSupported" + max_charging_profiles_installed = "MaxChargingProfilesInstalled" diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index 4ca01ae1..28a3d798 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -1,29 +1,83 @@ # Service ID -#start_charge: +set_charge_rate: # Service name as shown in UI -# name: Start charging + name: Set maximum charge rate # Description of the service -# description: Starts a charge session + description: Sets the maximum charge rate in Amps or Watts (dependent on charger support) # If the service accepts entity IDs, target allows the user to specify entities by entity, device, or area. If `target` is specified, `entity_id` should not be defined in the `fields` map. By default it shows only targets matching entities from the same domain as the service, but if further customization is required, target supports the entity, device, and area selectors (https://www.home-assistant.io/docs/blueprint/selectors/). Entity selector parameters will automatically be applied to device and area, and device selector parameters will automatically be applied to area. -# target: + #target: + # entity: + # integration: ocpp # Different fields that your service accepts -# limit: + fields: + # Key of the field + limit_amps: # Field name as shown in UI -# name: Charge limit + name: Limit (A) # Description of the field -# description: Maximum charge rate in W + description: Maximum charge rate in Amps # Whether or not field is required (default = false) -# required: false + required: false # Advanced fields are only shown when the advanced mode is enabled for the user (default = false) -# advanced: false + advanced: true # Example value that can be passed for this field -# example: "1500" + example: 16 # The default field value -# default: "22000" + default: 32 + limit_watts: + name: Limit (W) + description: Maximum charge rate in Watts + required: false + advanced: true + example: 1500 + default: 22000 -#stop_charge: -# name: Stop charging -# description: Stops a charge session in progress -# target: -# entity: -# integration: ocpp \ No newline at end of file +clear_profile: + name: Clear charging profiles + description: Clears all charging profiles (limits) set (dependent on charger support) + +update_firmware: + name: Update charger firmware + description: Specify server to download firmware and time to delay updating (dependent on charger support) + fields: + firmware_url: + name: Url of firmware + description: Full url of firmware file (http or https) + required: true + advanced: true + example: "http://www.charger.com/firmware.bin" + delay_hours: + name: Delay hours + description: Hours to delay charger update + required: false + advanced: true + example: 12 + default: 0 + +configure: + name: Configure charger features + description: Change supported Ocpp v1.6 configuration values + fields: + ocpp_key: + name: Configuration key name + description: Write-enabled key name supported + required: true + advanced: true + example: "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 + fields: + ocpp_key: + name: Configuration key name + description: Key name supported + required: true + advanced: true + example: "WebSocketPingInterval" diff --git a/setup.cfg b/setup.cfg index 4cf607eb..10346614 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,4 +41,4 @@ branch = False [coverage:report] show_missing = true -fail_under = 100 +fail_under = 75 diff --git a/tests/conftest.py b/tests/conftest.py index 9db0eadd..8b281dc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,7 @@ def skip_notifications_fixture(): def bypass_get_data_fixture(): """Skip calls to get data from API.""" # with patch("custom_components.ocpp.ocppApiClient.async_get_data"): - # yield + yield # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful @@ -42,4 +42,4 @@ def error_get_data_fixture(): # "custom_components.ocpp.ocppApiClient.async_get_data", # side_effect=Exception, # ): - # yield + yield diff --git a/tests/const.py b/tests/const.py index 10c4e571..99da6f3e 100644 --- a/tests/const.py +++ b/tests/const.py @@ -48,4 +48,13 @@ CONF_METER_INTERVAL: 60, CONF_MONITORED_VARIABLES: "Current.Export,Current.Import,Current.Offered,Energy.Active.Export.Register,Energy.Active.Import.Register,Energy.Reactive.Export.Register,Energy.Reactive.Import.Register,Energy.Active.Export.Interval,Energy.Active.Import.Interval,Energy.Reactive.Export.Interval,Energy.Reactive.Import.Interval,Frequency,Power.Active.Export,Power.Active.Import,Power.Factor,Power.Offered,Power.Reactive.Export,Power.Reactive.Import,RPM,SoC,Temperature,Voltage", } +# separate entry for switch so tests can run concurrently +MOCK_CONFIG_SWITCH = { + CONF_HOST: "0.0.0.0", + CONF_PORT: 9001, + CONF_CPID: "test_cpid_2", + CONF_CSID: "test_csid_2", + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: "Current.Export,Current.Import,Current.Offered,Energy.Active.Export.Register,Energy.Active.Import.Register,Energy.Reactive.Export.Register,Energy.Reactive.Import.Register,Energy.Active.Export.Interval,Energy.Active.Import.Interval,Energy.Reactive.Export.Interval,Energy.Reactive.Import.Interval,Frequency,Power.Active.Export,Power.Active.Import,Power.Factor,Power.Offered,Power.Reactive.Export,Power.Reactive.Import,RPM,SoC,Temperature,Voltage", +} DEFAULT_NAME = "test" diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py index 0b268b08..f841a615 100644 --- a/tests/test_charge_point.py +++ b/tests/test_charge_point.py @@ -1,49 +1,425 @@ """Implement a test by a simulating a chargepoint.""" import asyncio -import logging +from datetime import datetime, timezone # timedelta, +from pytest_homeassistant_custom_component.common import MockConfigEntry import websockets -from ocpp.v16 import ChargePoint as cp, call -from ocpp.v16.enums import RegistrationStatus +from custom_components.ocpp import async_setup_entry, async_unload_entry +from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.enums import ConfigurationKey +from ocpp.routing import on +from ocpp.v16 import ChargePoint as cpclass, call, call_result +from ocpp.v16.enums import ( + Action, + AuthorizationStatus, + AvailabilityStatus, + ChargePointErrorCode, + ChargePointStatus, + ChargingProfileStatus, + ClearChargingProfileStatus, + ConfigurationStatus, + DataTransferStatus, + FirmwareStatus, + RegistrationStatus, + RemoteStartStopStatus, + ResetStatus, + TriggerMessageStatus, + UnlockStatus, +) -logging.basicConfig(level=logging.INFO) +from .const import MOCK_CONFIG_DATA -class ChargePoint(cp): - """Representation of Charge Point.""" +async def test_cms_responses(hass): + """Test central system responses to a charger.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms" + ) + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + cs = hass.data[DOMAIN][config_entry.entry_id] + + async with websockets.connect( + "ws://localhost:9000/CP_1", + subprotocols=["ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint("CP_1_test", ws) + try: + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_boot_notification(), + cp.send_authorize(), + cp.send_heartbeat(), + cp.send_status_notification(), + cp.send_firmware_status(), + cp.send_data_transfer(), + cp.send_start_transaction(), + cp.send_meter_data(), + cp.send_stop_transaction(), + cs.charge_points["test_cpid"].start_transaction(), + cs.charge_points["test_cpid"].reset(), + cs.charge_points["test_cpid"].set_charge_rate(), + cs.charge_points["test_cpid"].clear_profile(), + cs.charge_points["test_cpid"].update_firmware( + "http://www.charger.com/file.bin" + ), + cs.charge_points["test_cpid"].unlock(), + ), + timeout=7, + ) + except asyncio.TimeoutError: + pass + assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int( + 1305570 / 1000 + ) + assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" + await async_unload_entry(hass, config_entry) + await hass.async_block_till_done() + + +class ChargePoint(cpclass): + """Representation of real client Charge Point.""" + + def __init__(self, id, connection, response_timeout=30): + """Init extra variables for testing.""" + super().__init__(id, connection) + self._transactionId = 0 + + @on(Action.GetConfiguration) + def on_get_configuration(self, key, **kwargs): + """Handle a get configuration requests.""" + if key[0] == ConfigurationKey.supported_feature_profiles.value: + return call_result.GetConfigurationPayload( + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Core,FirmwareManagement,SmartCharging", + } + ] + ) + if key[0] == ConfigurationKey.heartbeat_interval.value: + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] + ) + if key[0] == ConfigurationKey.number_of_connectors.value: + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": "1"}] + ) + if key[0] == ConfigurationKey.web_socket_ping_interval.value: + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": "60"}] + ) + if key[0] == ConfigurationKey.meter_values_sampled_data.value: + return call_result.GetConfigurationPayload( + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Energy.Active.Import.Register", + } + ] + ) + if key[0] == ConfigurationKey.meter_value_sample_interval.value: + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": "60"}] + ) + if ( + key[0] + == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value + ): + return call_result.GetConfigurationPayload( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "Current"} + ] + ) + if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": "false"}] + ) + if key[0] == ConfigurationKey.charge_profile_max_stack_level.value: + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] + ) + return call_result.GetConfigurationPayload( + configuration_key=[{"key": key[0], "readonly": False, "value": ""}] + ) + + @on(Action.ChangeConfiguration) + def on_change_configuration(self, **kwargs): + """Handle a get configuration request.""" + return call_result.ChangeConfigurationPayload(ConfigurationStatus.accepted) + + @on(Action.ChangeAvailability) + def on_change_availability(self, **kwargs): + """Handle change availability request.""" + return call_result.ChangeAvailabilityPayload(AvailabilityStatus.accepted) + + @on(Action.UnlockConnector) + def on_unlock_connector(self, **kwargs): + """Handle unlock request.""" + return call_result.UnlockConnectorPayload(UnlockStatus.unlocked) + + @on(Action.Reset) + def on_reset(self, **kwargs): + """Handle change availability request.""" + return call_result.ResetPayload(ResetStatus.accepted) + + @on(Action.RemoteStartTransaction) + def on_remote_start_transaction(self, **kwargs): + """Handle remote start request.""" + return call_result.RemoteStartTransactionPayload(RemoteStartStopStatus.accepted) + + @on(Action.SetChargingProfile) + def on_set_charging_profile(self, **kwargs): + """Handle set charging profile request.""" + return call_result.SetChargingProfilePayload(ChargingProfileStatus.accepted) + + @on(Action.ClearChargingProfile) + def on_clear_charging_profile(self, **kwargs): + """Handle clear charging profile request.""" + return call_result.ClearChargingProfilePayload( + ClearChargingProfileStatus.accepted + ) + + @on(Action.TriggerMessage) + def on_trigger_message(self, **kwargs): + """Handle trigger message request.""" + return call_result.TriggerMessagePayload(TriggerMessageStatus.accepted) + + @on(Action.UpdateFirmware) + def on_update_firmware(self, **kwargs): + """Handle update firmware request.""" + return call_result.UpdateFirmwarePayload() async def send_boot_notification(self): """Send a boot notification.""" request = call.BootNotificationPayload( charge_point_model="Optimus", charge_point_vendor="The Mobility House" ) + resp = await self.call(request) + assert resp.status == RegistrationStatus.accepted - response = await self.call(request) + async def send_heartbeat(self): + """Send a heartbeat.""" + request = call.HeartbeatPayload() + resp = await self.call(request) + assert len(resp.current_time) > 0 - if response.status == RegistrationStatus.accepted: - print("Connected to central system.") + async def send_authorize(self): + """Send an authorize request.""" + request = call.AuthorizePayload(id_tag="test_cp") + resp = await self.call(request) + assert resp.id_tag_info["status"] == AuthorizationStatus.accepted + async def send_firmware_status(self): + """Send a firmware status notification.""" + request = call.FirmwareStatusNotificationPayload( + status=FirmwareStatus.downloaded + ) + await self.call(request) -async def main(): - """Start at main entry point.""" - async with websockets.connect( - "ws://localhost:9000/CP_1", subprotocols=["ocpp1.6"] - ) as ws: + async def send_data_transfer(self): + """Send a data transfer.""" + request = call.DataTransferPayload( + vendor_id="The Mobility House", + message_id="Test123", + data="Test data transfer", + ) + resp = await self.call(request) + assert resp.status == DataTransferStatus.accepted - cp = ChargePoint("CP_1", ws) + async def send_start_transaction(self): + """Send a start transaction notification.""" + request = call.StartTransactionPayload( + connector_id=1, + id_tag="test_cp", + meter_start=12345, + timestamp=datetime.now(tz=timezone.utc).isoformat(), + ) + resp = await self.call(request) + self._transactionId = resp.transaction_id + assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value - await asyncio.gather(cp.start(), cp.send_boot_notification()) + async def send_status_notification(self): + """Send a status notification.""" + request = call.StatusNotificationPayload( + connector_id=1, + error_code=ChargePointErrorCode.no_error, + status=ChargePointStatus.charging, + timestamp=datetime.now(tz=timezone.utc).isoformat(), + info="Test info", + vendor_id="The Mobility House", + vendor_error_code="Test error", + ) + await self.call(request) + # check an error is not thrown? + async def send_meter_data(self): + """Send meter data notification.""" + request = call.MeterValuesPayload( + connector_id=1, + transaction_id=self._transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "value": "1305570.000", + "context": "Sample.Periodic", + "measurand": "Energy.Active.Import.Register", + "location": "Outlet", + "unit": "Wh", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L1", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L2", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L3", + }, + { + "value": "16.000", + "context": "Sample.Periodic", + "measurand": "Current.Offered", + "location": "Outlet", + "unit": "A", + }, + { + "value": "50.010", + "context": "Sample.Periodic", + "measurand": "Frequency", + "location": "Outlet", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + "phase": "L1", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + "phase": "L2", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + "phase": "L3", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Factor", + "location": "Outlet", + }, + { + "value": "38.500", + "context": "Sample.Periodic", + "measurand": "Temperature", + "location": "Body", + "unit": "Celsius", + }, + { + "value": "228.000", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L1-N", + }, + { + "value": "227.000", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L2-N", + }, + { + "value": "229.300", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L3-N", + }, + { + "value": "395.900", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L1-L2", + }, + { + "value": "396.300", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L2-L3", + }, + { + "value": "398.900", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L3-L1", + }, + ], + } + ], + ) + await self.call(request) + # check an error is not thrown? -if __name__ == "__main__": - try: - # asyncio.run() is used when running this example with Python 3.7 and - # higher. - asyncio.run(main()) - except AttributeError: - # For Python 3.6 a bit more code is required to run the main() task on - # an event loop. - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) - loop.close() + async def send_stop_transaction(self): + """Send a stop transaction notification.""" + request = call.StopTransactionPayload( + meter_stop=54321, + timestamp=datetime.now(tz=timezone.utc).isoformat(), + transaction_id=self._transactionId, + reason="EVDisconnected", + id_tag="test_cp", + ) + resp = await self.call(request) + assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value diff --git a/tests/test_switch.py b/tests/test_switch.py index 3e1c6032..03408812 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -8,7 +8,7 @@ from custom_components.ocpp import async_setup_entry from custom_components.ocpp.const import DOMAIN # SWITCH -from .const import MOCK_CONFIG_DATA +from .const import MOCK_CONFIG_SWITCH # from .const import DEFAULT_NAME @@ -17,7 +17,7 @@ async def test_switch_services(hass): """Test switch services.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test" + domain=DOMAIN, data=MOCK_CONFIG_SWITCH, entry_id="test_switch" ) assert await async_setup_entry(hass, config_entry) await hass.async_block_till_done()