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.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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 =
|
|
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 =
|
|
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:
|