arthexis 0.1.26__py3-none-any.whl → 0.1.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

ocpp/models.py CHANGED
@@ -1,14 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import binascii
4
+ import hashlib
1
5
  import json
6
+ import os
2
7
  import re
8
+ import secrets
3
9
  import socket
4
10
  import uuid
5
- from datetime import timedelta
11
+ from datetime import datetime, timedelta
6
12
  from decimal import Decimal, InvalidOperation
7
13
 
8
14
  from django.conf import settings
9
15
  from django.contrib.sites.models import Site
10
16
  from django.db import models
11
- from django.db.models import Q
17
+ from django.db.models import DecimalField, OuterRef, Prefetch, Q, Subquery
12
18
  from django.core.exceptions import ValidationError
13
19
  from django.urls import reverse
14
20
  from django.utils.translation import gettext_lazy as _
@@ -239,6 +245,18 @@ class Charger(Entity):
239
245
  "Latest GetConfiguration response received from this charge point."
240
246
  ),
241
247
  )
248
+ local_auth_list_version = models.PositiveIntegerField(
249
+ _("Local list version"),
250
+ null=True,
251
+ blank=True,
252
+ help_text=_("Last RFID list version acknowledged by the charge point."),
253
+ )
254
+ local_auth_list_updated_at = models.DateTimeField(
255
+ _("Local list updated at"),
256
+ null=True,
257
+ blank=True,
258
+ help_text=_("When the charge point reported or accepted the RFID list."),
259
+ )
242
260
  node_origin = models.ForeignKey(
243
261
  "nodes.Node",
244
262
  on_delete=models.SET_NULL,
@@ -644,22 +662,7 @@ class Charger(Entity):
644
662
  def _total_kw_single(self, store_module) -> float:
645
663
  """Return total kW for this specific charger identity."""
646
664
 
647
- tx_active = None
648
- if self.connector_id is not None:
649
- tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
650
- qs = self.transactions.all()
651
- if tx_active and tx_active.pk is not None:
652
- qs = qs.exclude(pk=tx_active.pk)
653
- total = 0.0
654
- for tx in qs:
655
- kw = tx.kw
656
- if kw:
657
- total += kw
658
- if tx_active:
659
- kw = tx_active.kw
660
- if kw:
661
- total += kw
662
- return total
665
+ return self._total_kw_range_single(store_module)
663
666
 
664
667
  def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
665
668
  """Return total kW for a date range for this charger."""
@@ -675,9 +678,10 @@ class Charger(Entity):
675
678
  qs = qs.filter(start_time__lt=end)
676
679
  if tx_active and tx_active.pk is not None:
677
680
  qs = qs.exclude(pk=tx_active.pk)
681
+ qs = annotate_transaction_energy_bounds(qs)
678
682
 
679
683
  total = 0.0
680
- for tx in qs:
684
+ for tx in qs.iterator():
681
685
  kw = tx.kw
682
686
  if kw:
683
687
  total += kw
@@ -726,6 +730,43 @@ class Charger(Entity):
726
730
  super().delete(*args, **kwargs)
727
731
 
728
732
 
733
+ class ConfigurationKey(models.Model):
734
+ """Single configurationKey entry from a GetConfiguration payload."""
735
+
736
+ configuration = models.ForeignKey(
737
+ "ChargerConfiguration",
738
+ on_delete=models.CASCADE,
739
+ related_name="configuration_entries",
740
+ )
741
+ position = models.PositiveIntegerField(default=0)
742
+ key = models.CharField(max_length=255)
743
+ readonly = models.BooleanField(default=False)
744
+ has_value = models.BooleanField(default=False)
745
+ value = models.JSONField(null=True, blank=True)
746
+ extra_data = models.JSONField(default=dict, blank=True)
747
+
748
+ class Meta:
749
+ ordering = ["position", "id"]
750
+ verbose_name = _("Configuration Key")
751
+ verbose_name_plural = _("Configuration Keys")
752
+
753
+ def __str__(self) -> str: # pragma: no cover - simple representation
754
+ return self.key
755
+
756
+ def as_dict(self) -> dict[str, object]:
757
+ data: dict[str, object] = {"key": self.key, "readonly": self.readonly}
758
+ if self.has_value:
759
+ data["value"] = self.value
760
+ if self.extra_data:
761
+ data.update(self.extra_data)
762
+ return data
763
+
764
+
765
+ class ChargerConfigurationManager(models.Manager):
766
+ def get_queryset(self):
767
+ return super().get_queryset().prefetch_related("configuration_entries")
768
+
769
+
729
770
  class ChargerConfiguration(models.Model):
730
771
  """Persisted configuration package returned by a charge point."""
731
772
 
@@ -736,11 +777,6 @@ class ChargerConfiguration(models.Model):
736
777
  blank=True,
737
778
  help_text=_("Connector that returned this configuration (if specified)."),
738
779
  )
739
- configuration_keys = models.JSONField(
740
- default=list,
741
- blank=True,
742
- help_text=_("Entries from the configurationKey list."),
743
- )
744
780
  unknown_keys = models.JSONField(
745
781
  default=list,
746
782
  blank=True,
@@ -762,6 +798,8 @@ class ChargerConfiguration(models.Model):
762
798
  created_at = models.DateTimeField(auto_now_add=True)
763
799
  updated_at = models.DateTimeField(auto_now=True)
764
800
 
801
+ objects = ChargerConfigurationManager()
802
+
765
803
  class Meta:
766
804
  ordering = ["-created_at"]
767
805
  verbose_name = _("CP Configuration")
@@ -778,6 +816,54 @@ class ChargerConfiguration(models.Model):
778
816
  "connector": connector,
779
817
  }
780
818
 
819
+ @property
820
+ def configuration_keys(self) -> list[dict[str, object]]:
821
+ return [entry.as_dict() for entry in self.configuration_entries.all()]
822
+
823
+ def replace_configuration_keys(self, entries: list[dict[str, object]] | None) -> None:
824
+ ConfigurationKey.objects.filter(configuration=self).delete()
825
+ if not entries:
826
+ if hasattr(self, "_prefetched_objects_cache"):
827
+ self._prefetched_objects_cache.pop("configuration_entries", None)
828
+ return
829
+
830
+ key_objects: list[ConfigurationKey] = []
831
+ for position, entry in enumerate(entries):
832
+ if not isinstance(entry, dict):
833
+ continue
834
+ key_text = str(entry.get("key") or "").strip()
835
+ if not key_text:
836
+ continue
837
+ readonly = bool(entry.get("readonly"))
838
+ has_value = "value" in entry
839
+ value = entry.get("value") if has_value else None
840
+ extras = {
841
+ field_key: field_value
842
+ for field_key, field_value in entry.items()
843
+ if field_key not in {"key", "readonly", "value"}
844
+ }
845
+ key_objects.append(
846
+ ConfigurationKey(
847
+ configuration=self,
848
+ position=position,
849
+ key=key_text,
850
+ readonly=readonly,
851
+ has_value=has_value,
852
+ value=value,
853
+ extra_data=extras,
854
+ )
855
+ )
856
+ created_keys = ConfigurationKey.objects.bulk_create(key_objects)
857
+ if hasattr(self, "_prefetched_objects_cache"):
858
+ if created_keys:
859
+ refreshed = list(
860
+ ConfigurationKey.objects.filter(configuration=self)
861
+ .order_by("position", "id")
862
+ )
863
+ self._prefetched_objects_cache["configuration_entries"] = refreshed
864
+ else:
865
+ self._prefetched_objects_cache.pop("configuration_entries", None)
866
+
781
867
 
782
868
  class Transaction(Entity):
783
869
  """Charging session data stored for each charger."""
@@ -881,17 +967,63 @@ class Transaction(Entity):
881
967
  if self.meter_stop is not None:
882
968
  end_val = float(self.meter_stop) / 1000.0
883
969
 
884
- readings = list(
885
- self.meter_values.filter(energy__isnull=False).order_by("timestamp")
886
- )
887
- if readings:
970
+ def _coerce(value):
971
+ if value in {None, ""}:
972
+ return None
973
+ try:
974
+ return float(value)
975
+ except (TypeError, ValueError, InvalidOperation):
976
+ return None
977
+
978
+ if start_val is None:
979
+ annotated_start = getattr(self, "meter_energy_start", None)
980
+ if annotated_start is None:
981
+ annotated_start = getattr(self, "report_meter_energy_start", None)
982
+ start_val = _coerce(annotated_start)
983
+
984
+ if end_val is None:
985
+ annotated_end = getattr(self, "meter_energy_end", None)
986
+ if annotated_end is None:
987
+ annotated_end = getattr(self, "report_meter_energy_end", None)
988
+ end_val = _coerce(annotated_end)
989
+
990
+ readings: list[MeterValue] | None = None
991
+ if start_val is None or end_val is None:
992
+ if hasattr(self, "prefetched_meter_values"):
993
+ readings = [
994
+ reading
995
+ for reading in getattr(self, "prefetched_meter_values")
996
+ if getattr(reading, "energy", None) is not None
997
+ ]
998
+ else:
999
+ cache = getattr(self, "_prefetched_objects_cache", None)
1000
+ if cache and "meter_values" in cache:
1001
+ readings = [
1002
+ reading
1003
+ for reading in cache["meter_values"]
1004
+ if getattr(reading, "energy", None) is not None
1005
+ ]
1006
+
1007
+ if readings is not None:
1008
+ readings.sort(key=lambda reading: reading.timestamp)
1009
+
1010
+ if readings is not None and readings:
888
1011
  if start_val is None:
889
- start_val = float(readings[0].energy or 0)
890
- # Always use the latest available reading for the end value when a
891
- # stop meter has not been recorded yet. This allows active
892
- # transactions to report totals using their most recent reading.
1012
+ start_val = _coerce(readings[0].energy)
893
1013
  if end_val is None:
894
- end_val = float(readings[-1].energy or 0)
1014
+ end_val = _coerce(readings[-1].energy)
1015
+ elif start_val is None or end_val is None:
1016
+ readings_qs = self.meter_values.filter(energy__isnull=False).order_by(
1017
+ "timestamp"
1018
+ )
1019
+ if start_val is None:
1020
+ first_energy = readings_qs.values_list("energy", flat=True).first()
1021
+ start_val = _coerce(first_energy)
1022
+ if end_val is None:
1023
+ last_energy = readings_qs.order_by("-timestamp").values_list(
1024
+ "energy", flat=True
1025
+ ).first()
1026
+ end_val = _coerce(last_energy)
895
1027
 
896
1028
  if start_val is None or end_val is None:
897
1029
  return 0.0
@@ -900,6 +1032,49 @@ class Transaction(Entity):
900
1032
  return max(total, 0.0)
901
1033
 
902
1034
 
1035
+ class RFIDSessionAttempt(Entity):
1036
+ """Record RFID authorisation attempts received via StartTransaction."""
1037
+
1038
+ class Status(models.TextChoices):
1039
+ ACCEPTED = "accepted", _("Accepted")
1040
+ REJECTED = "rejected", _("Rejected")
1041
+
1042
+ charger = models.ForeignKey(
1043
+ "Charger",
1044
+ on_delete=models.CASCADE,
1045
+ related_name="rfid_attempts",
1046
+ null=True,
1047
+ blank=True,
1048
+ )
1049
+ rfid = models.CharField(_("RFID"), max_length=255, blank=True)
1050
+ status = models.CharField(max_length=16, choices=Status.choices)
1051
+ attempted_at = models.DateTimeField(auto_now_add=True)
1052
+ account = models.ForeignKey(
1053
+ EnergyAccount,
1054
+ on_delete=models.SET_NULL,
1055
+ null=True,
1056
+ blank=True,
1057
+ related_name="rfid_attempts",
1058
+ )
1059
+ transaction = models.ForeignKey(
1060
+ "Transaction",
1061
+ on_delete=models.SET_NULL,
1062
+ null=True,
1063
+ blank=True,
1064
+ related_name="rfid_attempts",
1065
+ )
1066
+
1067
+ class Meta:
1068
+ ordering = ["-attempted_at"]
1069
+ verbose_name = _("RFID Session Attempt")
1070
+ verbose_name_plural = _("RFID Session Attempts")
1071
+
1072
+ def __str__(self) -> str: # pragma: no cover - simple representation
1073
+ status = self.get_status_display() or ""
1074
+ tag = self.rfid or "-"
1075
+ return f"{tag} ({status})"
1076
+
1077
+
903
1078
  class MeterValue(Entity):
904
1079
  """Parsed meter values reported by chargers."""
905
1080
 
@@ -988,6 +1163,28 @@ class MeterReading(MeterValue):
988
1163
  verbose_name_plural = _("Meter Values")
989
1164
 
990
1165
 
1166
+ def annotate_transaction_energy_bounds(
1167
+ queryset, *, start_field: str = "meter_energy_start", end_field: str = "meter_energy_end"
1168
+ ):
1169
+ """Annotate transactions with their earliest and latest energy readings."""
1170
+
1171
+ energy_qs = MeterValue.objects.filter(
1172
+ transaction=OuterRef("pk"), energy__isnull=False
1173
+ )
1174
+ start_subquery = energy_qs.order_by("timestamp").values("energy")[:1]
1175
+ end_subquery = energy_qs.order_by("-timestamp").values("energy")[:1]
1176
+
1177
+ annotations = {
1178
+ start_field: Subquery(
1179
+ start_subquery, output_field=DecimalField(max_digits=12, decimal_places=3)
1180
+ ),
1181
+ end_field: Subquery(
1182
+ end_subquery, output_field=DecimalField(max_digits=12, decimal_places=3)
1183
+ ),
1184
+ }
1185
+ return queryset.annotate(**annotations)
1186
+
1187
+
991
1188
  class Simulator(Entity):
992
1189
  """Preconfigured simulator that can be started from the admin."""
993
1190
 
@@ -1134,6 +1331,219 @@ class DataTransferMessage(models.Model):
1134
1331
  return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
1135
1332
 
1136
1333
 
1334
+ class CPFirmware(Entity):
1335
+ """Persisted firmware packages associated with charge points."""
1336
+
1337
+ class Source(models.TextChoices):
1338
+ DOWNLOAD = "download", _("Downloaded")
1339
+ UPLOAD = "upload", _("Uploaded")
1340
+
1341
+ name = models.CharField(_("Name"), max_length=200, blank=True)
1342
+ description = models.TextField(_("Description"), blank=True)
1343
+ source = models.CharField(
1344
+ max_length=16,
1345
+ choices=Source.choices,
1346
+ default=Source.DOWNLOAD,
1347
+ verbose_name=_("Source"),
1348
+ )
1349
+ source_node = models.ForeignKey(
1350
+ Node,
1351
+ on_delete=models.SET_NULL,
1352
+ null=True,
1353
+ blank=True,
1354
+ related_name="downloaded_firmware",
1355
+ verbose_name=_("Source node"),
1356
+ )
1357
+ source_charger = models.ForeignKey(
1358
+ "Charger",
1359
+ on_delete=models.SET_NULL,
1360
+ null=True,
1361
+ blank=True,
1362
+ related_name="downloaded_firmware",
1363
+ verbose_name=_("Source charge point"),
1364
+ )
1365
+ content_type = models.CharField(
1366
+ _("Content type"),
1367
+ max_length=100,
1368
+ default="application/octet-stream",
1369
+ blank=True,
1370
+ )
1371
+ filename = models.CharField(_("Filename"), max_length=255, blank=True)
1372
+ payload_json = models.JSONField(null=True, blank=True)
1373
+ payload_binary = models.BinaryField(null=True, blank=True)
1374
+ payload_encoding = models.CharField(_("Encoding"), max_length=32, blank=True)
1375
+ payload_size = models.PositiveIntegerField(_("Payload size"), default=0)
1376
+ checksum = models.CharField(_("Checksum"), max_length=128, blank=True)
1377
+ metadata = models.JSONField(default=dict, blank=True)
1378
+ download_vendor_id = models.CharField(
1379
+ _("Vendor ID"), max_length=255, blank=True
1380
+ )
1381
+ download_message_id = models.CharField(
1382
+ _("Message ID"), max_length=64, blank=True
1383
+ )
1384
+ downloaded_at = models.DateTimeField(_("Downloaded at"), null=True, blank=True)
1385
+ created_at = models.DateTimeField(auto_now_add=True)
1386
+ updated_at = models.DateTimeField(auto_now=True)
1387
+
1388
+ class Meta:
1389
+ ordering = ["-created_at"]
1390
+ verbose_name = _("CP Firmware")
1391
+ verbose_name_plural = _("CP Firmware")
1392
+
1393
+ def __str__(self) -> str: # pragma: no cover - simple representation
1394
+ label = self.name or self.filename or ""
1395
+ if label:
1396
+ return label
1397
+ return f"Firmware #{self.pk}" if self.pk else "Firmware"
1398
+
1399
+ def save(self, *args, **kwargs):
1400
+ if self.filename:
1401
+ self.filename = os.path.basename(self.filename)
1402
+ payload_bytes = self.get_payload_bytes()
1403
+ self.payload_size = len(payload_bytes)
1404
+ if payload_bytes:
1405
+ self.checksum = hashlib.sha256(payload_bytes).hexdigest()
1406
+ elif not self.checksum:
1407
+ self.checksum = ""
1408
+ if not self.content_type:
1409
+ if self.payload_binary:
1410
+ self.content_type = "application/octet-stream"
1411
+ elif self.payload_json is not None:
1412
+ self.content_type = "application/json"
1413
+ if not self.payload_encoding:
1414
+ self.payload_encoding = ""
1415
+ super().save(*args, **kwargs)
1416
+
1417
+ def get_payload_bytes(self) -> bytes:
1418
+ if self.payload_binary:
1419
+ return bytes(self.payload_binary)
1420
+ if self.payload_json is not None:
1421
+ try:
1422
+ return json.dumps(
1423
+ self.payload_json,
1424
+ ensure_ascii=False,
1425
+ separators=(",", ":"),
1426
+ sort_keys=True,
1427
+ ).encode("utf-8")
1428
+ except (TypeError, ValueError):
1429
+ return str(self.payload_json).encode("utf-8")
1430
+ return b""
1431
+
1432
+ @property
1433
+ def has_binary(self) -> bool:
1434
+ return bool(self.payload_binary)
1435
+
1436
+ @property
1437
+ def has_json(self) -> bool:
1438
+ return self.payload_json is not None
1439
+
1440
+
1441
+ class CPFirmwareDeployment(Entity):
1442
+ """Track firmware rollout attempts for specific charge points."""
1443
+
1444
+ TERMINAL_STATUSES = {"Installed", "InstallationFailed", "DownloadFailed"}
1445
+
1446
+ firmware = models.ForeignKey(
1447
+ CPFirmware,
1448
+ on_delete=models.CASCADE,
1449
+ related_name="deployments",
1450
+ verbose_name=_("Firmware"),
1451
+ )
1452
+ charger = models.ForeignKey(
1453
+ "Charger",
1454
+ on_delete=models.PROTECT,
1455
+ related_name="firmware_deployments",
1456
+ verbose_name=_("Charge point"),
1457
+ )
1458
+ node = models.ForeignKey(
1459
+ Node,
1460
+ on_delete=models.SET_NULL,
1461
+ null=True,
1462
+ blank=True,
1463
+ related_name="firmware_deployments",
1464
+ verbose_name=_("Node"),
1465
+ )
1466
+ ocpp_message_id = models.CharField(
1467
+ _("OCPP message ID"), max_length=64, blank=True
1468
+ )
1469
+ status = models.CharField(_("Status"), max_length=32, blank=True)
1470
+ status_info = models.CharField(_("Status details"), max_length=255, blank=True)
1471
+ status_timestamp = models.DateTimeField(_("Status timestamp"), null=True, blank=True)
1472
+ requested_at = models.DateTimeField(_("Requested at"), auto_now_add=True)
1473
+ completed_at = models.DateTimeField(_("Completed at"), null=True, blank=True)
1474
+ retrieve_date = models.DateTimeField(_("Retrieve date"), null=True, blank=True)
1475
+ retry_count = models.PositiveIntegerField(_("Retries"), default=0)
1476
+ retry_interval = models.PositiveIntegerField(
1477
+ _("Retry interval (seconds)"), default=0
1478
+ )
1479
+ download_token = models.CharField(_("Download token"), max_length=64, blank=True)
1480
+ download_token_expires_at = models.DateTimeField(
1481
+ _("Token expires at"), null=True, blank=True
1482
+ )
1483
+ downloaded_at = models.DateTimeField(_("Downloaded at"), null=True, blank=True)
1484
+ request_payload = models.JSONField(default=dict, blank=True)
1485
+ response_payload = models.JSONField(default=dict, blank=True)
1486
+ created_at = models.DateTimeField(auto_now_add=True)
1487
+ updated_at = models.DateTimeField(auto_now=True)
1488
+
1489
+ class Meta:
1490
+ ordering = ["-requested_at"]
1491
+ verbose_name = _("CP Firmware Deployment")
1492
+ verbose_name_plural = _("CP Firmware Deployments")
1493
+ indexes = [
1494
+ models.Index(fields=["ocpp_message_id"]),
1495
+ models.Index(fields=["download_token"]),
1496
+ ]
1497
+
1498
+ def __str__(self) -> str: # pragma: no cover - simple representation
1499
+ return f"{self.firmware} → {self.charger}" if self.pk else "Firmware Deployment"
1500
+
1501
+ def issue_download_token(self, *, lifetime: timedelta | None = None) -> str:
1502
+ if lifetime is None:
1503
+ lifetime = timedelta(hours=1)
1504
+ deadline = timezone.now() + lifetime
1505
+ token = secrets.token_urlsafe(24)
1506
+ while type(self).all_objects.filter(download_token=token).exists():
1507
+ token = secrets.token_urlsafe(24)
1508
+ self.download_token = token
1509
+ self.download_token_expires_at = deadline
1510
+ self.save(
1511
+ update_fields=["download_token", "download_token_expires_at", "updated_at"]
1512
+ )
1513
+ return token
1514
+
1515
+ def mark_status(
1516
+ self,
1517
+ status: str,
1518
+ info: str = "",
1519
+ timestamp: datetime | None = None,
1520
+ *,
1521
+ response: dict | None = None,
1522
+ ) -> None:
1523
+ timestamp_value = timestamp or timezone.now()
1524
+ self.status = status
1525
+ self.status_info = info
1526
+ self.status_timestamp = timestamp_value
1527
+ if response is not None:
1528
+ self.response_payload = response
1529
+ if status in self.TERMINAL_STATUSES and not self.completed_at:
1530
+ self.completed_at = timezone.now()
1531
+ self.save(
1532
+ update_fields=[
1533
+ "status",
1534
+ "status_info",
1535
+ "status_timestamp",
1536
+ "response_payload",
1537
+ "completed_at",
1538
+ "updated_at",
1539
+ ]
1540
+ )
1541
+
1542
+ @property
1543
+ def is_terminal(self) -> bool:
1544
+ return self.status in self.TERMINAL_STATUSES and bool(self.completed_at)
1545
+
1546
+
1137
1547
  class CPReservation(Entity):
1138
1548
  """Track connector reservations dispatched to an EVCS."""
1139
1549
 
@@ -1384,6 +1794,62 @@ class CPReservation(Entity):
1384
1794
  )
1385
1795
  return message_id
1386
1796
 
1797
+ def send_cancel_request(self) -> str:
1798
+ """Dispatch a CancelReservation request for this reservation."""
1799
+
1800
+ if not self.pk:
1801
+ raise ValidationError(_("Save the reservation before sending it to the EVCS."))
1802
+ connector = self.connector
1803
+ if connector is None or connector.connector_id is None:
1804
+ raise ValidationError(_("Unable to determine which connector to cancel."))
1805
+ connection = store.get_connection(connector.charger_id, connector.connector_id)
1806
+ if connection is None:
1807
+ raise ValidationError(
1808
+ _("The selected charge point is not currently connected to the system.")
1809
+ )
1810
+
1811
+ message_id = uuid.uuid4().hex
1812
+ payload = {"reservationId": self.pk}
1813
+ frame = json.dumps([2, message_id, "CancelReservation", payload])
1814
+
1815
+ log_key = store.identity_key(connector.charger_id, connector.connector_id)
1816
+ store.add_log(
1817
+ log_key,
1818
+ f"CancelReservation request: reservation={self.pk}",
1819
+ log_type="charger",
1820
+ )
1821
+ async_to_sync(connection.send)(frame)
1822
+
1823
+ metadata = {
1824
+ "action": "CancelReservation",
1825
+ "charger_id": connector.charger_id,
1826
+ "connector_id": connector.connector_id,
1827
+ "log_key": log_key,
1828
+ "reservation_pk": self.pk,
1829
+ "requested_at": timezone.now(),
1830
+ }
1831
+ store.register_pending_call(message_id, metadata)
1832
+ store.schedule_call_timeout(
1833
+ message_id, action="CancelReservation", log_key=log_key
1834
+ )
1835
+
1836
+ self.ocpp_message_id = message_id
1837
+ self.evcs_status = ""
1838
+ self.evcs_error = ""
1839
+ self.evcs_confirmed = False
1840
+ self.evcs_confirmed_at = None
1841
+ super().save(
1842
+ update_fields=[
1843
+ "ocpp_message_id",
1844
+ "evcs_status",
1845
+ "evcs_error",
1846
+ "evcs_confirmed",
1847
+ "evcs_confirmed_at",
1848
+ "updated_on",
1849
+ ]
1850
+ )
1851
+ return message_id
1852
+
1387
1853
 
1388
1854
  class RFID(CoreRFID):
1389
1855
  class Meta: