arthexis 0.1.19__py3-none-any.whl → 0.1.20__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.
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/METADATA +3 -3
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/RECORD +38 -38
- core/admin.py +142 -1
- core/backends.py +8 -2
- core/environment.py +221 -4
- core/models.py +124 -25
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/tasks.py +24 -1
- core/tests.py +1 -0
- core/views.py +70 -36
- nodes/admin.py +133 -1
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +532 -15
- nodes/urls.py +4 -0
- nodes/views.py +500 -95
- ocpp/admin.py +101 -3
- ocpp/consumers.py +106 -9
- ocpp/models.py +83 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +100 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +101 -28
- pages/context_processors.py +15 -9
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -38
- pages/urls.py +1 -0
- pages/views.py +108 -8
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -16,6 +16,7 @@ from asgiref.sync import async_to_sync
|
|
|
16
16
|
|
|
17
17
|
from .models import (
|
|
18
18
|
Charger,
|
|
19
|
+
ChargerConfiguration,
|
|
19
20
|
Simulator,
|
|
20
21
|
MeterValue,
|
|
21
22
|
Transaction,
|
|
@@ -108,6 +109,82 @@ class LogViewAdminMixin:
|
|
|
108
109
|
return TemplateResponse(request, self.log_template_name, context)
|
|
109
110
|
|
|
110
111
|
|
|
112
|
+
@admin.register(ChargerConfiguration)
|
|
113
|
+
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
114
|
+
list_display = ("charger_identifier", "connector_display", "created_at")
|
|
115
|
+
list_filter = ("connector_id",)
|
|
116
|
+
search_fields = ("charger_identifier",)
|
|
117
|
+
readonly_fields = (
|
|
118
|
+
"charger_identifier",
|
|
119
|
+
"connector_id",
|
|
120
|
+
"created_at",
|
|
121
|
+
"updated_at",
|
|
122
|
+
"linked_chargers",
|
|
123
|
+
"configuration_keys_display",
|
|
124
|
+
"unknown_keys_display",
|
|
125
|
+
"raw_payload_display",
|
|
126
|
+
)
|
|
127
|
+
fieldsets = (
|
|
128
|
+
(
|
|
129
|
+
None,
|
|
130
|
+
{
|
|
131
|
+
"fields": (
|
|
132
|
+
"charger_identifier",
|
|
133
|
+
"connector_id",
|
|
134
|
+
"linked_chargers",
|
|
135
|
+
"created_at",
|
|
136
|
+
"updated_at",
|
|
137
|
+
)
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
(
|
|
141
|
+
"Payload",
|
|
142
|
+
{
|
|
143
|
+
"fields": (
|
|
144
|
+
"configuration_keys_display",
|
|
145
|
+
"unknown_keys_display",
|
|
146
|
+
"raw_payload_display",
|
|
147
|
+
)
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@admin.display(description="Connector")
|
|
153
|
+
def connector_display(self, obj):
|
|
154
|
+
if obj.connector_id is None:
|
|
155
|
+
return "All"
|
|
156
|
+
return obj.connector_id
|
|
157
|
+
|
|
158
|
+
@admin.display(description="Linked charge points")
|
|
159
|
+
def linked_chargers(self, obj):
|
|
160
|
+
if obj.pk is None:
|
|
161
|
+
return ""
|
|
162
|
+
linked = [charger.identity_slug() for charger in obj.chargers.all()]
|
|
163
|
+
if not linked:
|
|
164
|
+
return "-"
|
|
165
|
+
return ", ".join(sorted(linked))
|
|
166
|
+
|
|
167
|
+
def _render_json(self, data):
|
|
168
|
+
from django.utils.html import format_html
|
|
169
|
+
|
|
170
|
+
if not data:
|
|
171
|
+
return "-"
|
|
172
|
+
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
|
173
|
+
return format_html("<pre>{}</pre>", formatted)
|
|
174
|
+
|
|
175
|
+
@admin.display(description="configurationKey")
|
|
176
|
+
def configuration_keys_display(self, obj):
|
|
177
|
+
return self._render_json(obj.configuration_keys)
|
|
178
|
+
|
|
179
|
+
@admin.display(description="unknownKey")
|
|
180
|
+
def unknown_keys_display(self, obj):
|
|
181
|
+
return self._render_json(obj.unknown_keys)
|
|
182
|
+
|
|
183
|
+
@admin.display(description="Raw payload")
|
|
184
|
+
def raw_payload_display(self, obj):
|
|
185
|
+
return self._render_json(obj.raw_payload)
|
|
186
|
+
|
|
187
|
+
|
|
111
188
|
@admin.register(Location)
|
|
112
189
|
class LocationAdmin(EntityModelAdmin):
|
|
113
190
|
form = LocationAdminForm
|
|
@@ -207,7 +284,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
207
284
|
),
|
|
208
285
|
(
|
|
209
286
|
"Configuration",
|
|
210
|
-
{"fields": ("public_display", "require_rfid")},
|
|
287
|
+
{"fields": ("public_display", "require_rfid", "configuration")},
|
|
211
288
|
),
|
|
212
289
|
(
|
|
213
290
|
"References",
|
|
@@ -236,11 +313,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
236
313
|
"availability_request_status",
|
|
237
314
|
"availability_request_status_at",
|
|
238
315
|
"availability_request_details",
|
|
316
|
+
"configuration",
|
|
239
317
|
)
|
|
240
318
|
list_display = (
|
|
241
|
-
"
|
|
319
|
+
"display_name_with_fallback",
|
|
242
320
|
"connector_number",
|
|
243
|
-
"
|
|
321
|
+
"serial_number_display",
|
|
244
322
|
"require_rfid_display",
|
|
245
323
|
"public_display",
|
|
246
324
|
"last_heartbeat",
|
|
@@ -349,6 +427,18 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
349
427
|
return True
|
|
350
428
|
return False
|
|
351
429
|
|
|
430
|
+
@admin.display(description="Display Name", ordering="display_name")
|
|
431
|
+
def display_name_with_fallback(self, obj):
|
|
432
|
+
if obj.display_name:
|
|
433
|
+
return obj.display_name
|
|
434
|
+
if obj.location:
|
|
435
|
+
return obj.location.name
|
|
436
|
+
return obj.charger_id
|
|
437
|
+
|
|
438
|
+
@admin.display(description="Serial Number", ordering="charger_id")
|
|
439
|
+
def serial_number_display(self, obj):
|
|
440
|
+
return obj.charger_id
|
|
441
|
+
|
|
352
442
|
def location_name(self, obj):
|
|
353
443
|
return obj.location.name if obj.location else ""
|
|
354
444
|
|
|
@@ -933,8 +1023,10 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
933
1023
|
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
934
1024
|
list_display = (
|
|
935
1025
|
"charger",
|
|
1026
|
+
"connector_number",
|
|
936
1027
|
"account",
|
|
937
1028
|
"rfid",
|
|
1029
|
+
"vid",
|
|
938
1030
|
"meter_start",
|
|
939
1031
|
"meter_stop",
|
|
940
1032
|
"start_time",
|
|
@@ -946,6 +1038,12 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
946
1038
|
date_hierarchy = "start_time"
|
|
947
1039
|
inlines = [MeterValueInline]
|
|
948
1040
|
|
|
1041
|
+
def connector_number(self, obj):
|
|
1042
|
+
return obj.connector_id or ""
|
|
1043
|
+
|
|
1044
|
+
connector_number.short_description = "#"
|
|
1045
|
+
connector_number.admin_order_field = "connector_id"
|
|
1046
|
+
|
|
949
1047
|
def get_urls(self):
|
|
950
1048
|
urls = super().get_urls()
|
|
951
1049
|
custom = [
|
ocpp/consumers.py
CHANGED
|
@@ -20,7 +20,13 @@ from config.offline import requires_network
|
|
|
20
20
|
from . import store
|
|
21
21
|
from decimal import Decimal
|
|
22
22
|
from django.utils.dateparse import parse_datetime
|
|
23
|
-
from .models import
|
|
23
|
+
from .models import (
|
|
24
|
+
Transaction,
|
|
25
|
+
Charger,
|
|
26
|
+
ChargerConfiguration,
|
|
27
|
+
MeterValue,
|
|
28
|
+
DataTransferMessage,
|
|
29
|
+
)
|
|
24
30
|
from .reference_utils import host_is_local_loopback
|
|
25
31
|
from .evcs_discovery import (
|
|
26
32
|
DEFAULT_CONSOLE_PORT,
|
|
@@ -138,6 +144,18 @@ def _parse_ocpp_timestamp(value) -> datetime | None:
|
|
|
138
144
|
return timestamp
|
|
139
145
|
|
|
140
146
|
|
|
147
|
+
def _extract_vehicle_identifier(payload: dict) -> tuple[str, str]:
|
|
148
|
+
"""Return normalized VID and VIN values from an OCPP message payload."""
|
|
149
|
+
|
|
150
|
+
raw_vid = payload.get("vid")
|
|
151
|
+
vid_value = str(raw_vid).strip() if raw_vid is not None else ""
|
|
152
|
+
raw_vin = payload.get("vin")
|
|
153
|
+
vin_value = str(raw_vin).strip() if raw_vin is not None else ""
|
|
154
|
+
if not vid_value and vin_value:
|
|
155
|
+
vid_value = vin_value
|
|
156
|
+
return vid_value, vin_value
|
|
157
|
+
|
|
158
|
+
|
|
141
159
|
class SinkConsumer(AsyncWebsocketConsumer):
|
|
142
160
|
"""Accept any message without validation."""
|
|
143
161
|
|
|
@@ -283,11 +301,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
283
301
|
"""Return the energy account for the provided RFID if valid."""
|
|
284
302
|
if not id_tag:
|
|
285
303
|
return None
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
)
|
|
290
|
-
|
|
304
|
+
|
|
305
|
+
def _resolve() -> EnergyAccount | None:
|
|
306
|
+
matches = CoreRFID.matching_queryset(id_tag).filter(allowed=True)
|
|
307
|
+
if not matches.exists():
|
|
308
|
+
return None
|
|
309
|
+
return (
|
|
310
|
+
EnergyAccount.objects.filter(rfids__in=matches)
|
|
311
|
+
.distinct()
|
|
312
|
+
.first()
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return await database_sync_to_async(_resolve)()
|
|
291
316
|
|
|
292
317
|
async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
|
|
293
318
|
"""Ensure an RFID record exists and update its last seen timestamp."""
|
|
@@ -726,6 +751,53 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
726
751
|
task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
|
|
727
752
|
self._consumption_task = task
|
|
728
753
|
|
|
754
|
+
def _persist_configuration_result(
|
|
755
|
+
self, payload: dict, connector_hint: int | str | None
|
|
756
|
+
) -> ChargerConfiguration | None:
|
|
757
|
+
if not isinstance(payload, dict):
|
|
758
|
+
return None
|
|
759
|
+
|
|
760
|
+
connector_value: int | None = None
|
|
761
|
+
if connector_hint not in (None, ""):
|
|
762
|
+
try:
|
|
763
|
+
connector_value = int(connector_hint)
|
|
764
|
+
except (TypeError, ValueError):
|
|
765
|
+
connector_value = None
|
|
766
|
+
|
|
767
|
+
normalized_entries: list[dict[str, object]] = []
|
|
768
|
+
for entry in payload.get("configurationKey") or []:
|
|
769
|
+
if not isinstance(entry, dict):
|
|
770
|
+
continue
|
|
771
|
+
key = str(entry.get("key") or "")
|
|
772
|
+
normalized: dict[str, object] = {"key": key}
|
|
773
|
+
if "value" in entry:
|
|
774
|
+
normalized["value"] = entry.get("value")
|
|
775
|
+
normalized["readonly"] = bool(entry.get("readonly"))
|
|
776
|
+
normalized_entries.append(normalized)
|
|
777
|
+
|
|
778
|
+
unknown_values: list[str] = []
|
|
779
|
+
for value in payload.get("unknownKey") or []:
|
|
780
|
+
if value is None:
|
|
781
|
+
continue
|
|
782
|
+
unknown_values.append(str(value))
|
|
783
|
+
|
|
784
|
+
try:
|
|
785
|
+
raw_payload = json.loads(json.dumps(payload, ensure_ascii=False))
|
|
786
|
+
except (TypeError, ValueError):
|
|
787
|
+
raw_payload = payload
|
|
788
|
+
|
|
789
|
+
configuration = ChargerConfiguration.objects.create(
|
|
790
|
+
charger_identifier=self.charger_id,
|
|
791
|
+
connector_id=connector_value,
|
|
792
|
+
configuration_keys=normalized_entries,
|
|
793
|
+
unknown_keys=unknown_values,
|
|
794
|
+
raw_payload=raw_payload,
|
|
795
|
+
)
|
|
796
|
+
Charger.objects.filter(charger_id=self.charger_id).update(
|
|
797
|
+
configuration=configuration
|
|
798
|
+
)
|
|
799
|
+
return configuration
|
|
800
|
+
|
|
729
801
|
async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
|
|
730
802
|
metadata = store.pop_pending_call(message_id)
|
|
731
803
|
if not metadata:
|
|
@@ -787,6 +859,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
787
859
|
f"GetConfiguration result: {payload_text}",
|
|
788
860
|
log_type="charger",
|
|
789
861
|
)
|
|
862
|
+
configuration = await database_sync_to_async(
|
|
863
|
+
self._persist_configuration_result
|
|
864
|
+
)(payload_data, metadata.get("connector_id"))
|
|
865
|
+
if configuration:
|
|
866
|
+
if self.charger and self.charger.charger_id == self.charger_id:
|
|
867
|
+
self.charger.configuration = configuration
|
|
868
|
+
if (
|
|
869
|
+
self.aggregate_charger
|
|
870
|
+
and self.aggregate_charger.charger_id == self.charger_id
|
|
871
|
+
):
|
|
872
|
+
self.aggregate_charger.configuration = configuration
|
|
790
873
|
store.record_pending_call_result(
|
|
791
874
|
message_id,
|
|
792
875
|
metadata=metadata,
|
|
@@ -1338,8 +1421,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1338
1421
|
reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
|
|
1339
1422
|
now = timezone.now()
|
|
1340
1423
|
self.charger.last_heartbeat = now
|
|
1424
|
+
if (
|
|
1425
|
+
self.aggregate_charger
|
|
1426
|
+
and self.aggregate_charger is not self.charger
|
|
1427
|
+
):
|
|
1428
|
+
self.aggregate_charger.last_heartbeat = now
|
|
1341
1429
|
await database_sync_to_async(
|
|
1342
|
-
Charger.objects.filter(
|
|
1430
|
+
Charger.objects.filter(charger_id=self.charger_id).update
|
|
1343
1431
|
)(last_heartbeat=now)
|
|
1344
1432
|
elif action == "StatusNotification":
|
|
1345
1433
|
await self._assign_connector(payload.get("connectorId"))
|
|
@@ -1538,11 +1626,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1538
1626
|
self._log_unlinked_rfid(tag.rfid)
|
|
1539
1627
|
start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1540
1628
|
received_start = timezone.now()
|
|
1629
|
+
vid_value, vin_value = _extract_vehicle_identifier(payload)
|
|
1541
1630
|
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
1542
1631
|
charger=self.charger,
|
|
1543
1632
|
account=account,
|
|
1544
1633
|
rfid=(id_tag or ""),
|
|
1545
|
-
|
|
1634
|
+
vid=vid_value,
|
|
1635
|
+
vin=vin_value,
|
|
1546
1636
|
connector_id=payload.get("connectorId"),
|
|
1547
1637
|
meter_start=payload.get("meterStart"),
|
|
1548
1638
|
start_time=start_timestamp or received_start,
|
|
@@ -1568,6 +1658,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1568
1658
|
)()
|
|
1569
1659
|
if not tx_obj and tx_id is not None:
|
|
1570
1660
|
received_start = timezone.now()
|
|
1661
|
+
vid_value, vin_value = _extract_vehicle_identifier(payload)
|
|
1571
1662
|
tx_obj = await database_sync_to_async(Transaction.objects.create)(
|
|
1572
1663
|
pk=tx_id,
|
|
1573
1664
|
charger=self.charger,
|
|
@@ -1575,12 +1666,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1575
1666
|
received_start_time=received_start,
|
|
1576
1667
|
meter_start=payload.get("meterStart")
|
|
1577
1668
|
or payload.get("meterStop"),
|
|
1578
|
-
|
|
1669
|
+
vid=vid_value,
|
|
1670
|
+
vin=vin_value,
|
|
1579
1671
|
)
|
|
1580
1672
|
if tx_obj:
|
|
1581
1673
|
stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
|
|
1582
1674
|
received_stop = timezone.now()
|
|
1583
1675
|
tx_obj.meter_stop = payload.get("meterStop")
|
|
1676
|
+
vid_value, vin_value = _extract_vehicle_identifier(payload)
|
|
1677
|
+
if vid_value:
|
|
1678
|
+
tx_obj.vid = vid_value
|
|
1679
|
+
if vin_value:
|
|
1680
|
+
tx_obj.vin = vin_value
|
|
1584
1681
|
tx_obj.stop_time = stop_timestamp or received_stop
|
|
1585
1682
|
tx_obj.received_stop_time = received_stop
|
|
1586
1683
|
await database_sync_to_async(tx_obj.save)()
|
ocpp/models.py
CHANGED
|
@@ -204,6 +204,16 @@ class Charger(Entity):
|
|
|
204
204
|
related_name="chargers",
|
|
205
205
|
)
|
|
206
206
|
last_path = models.CharField(max_length=255, blank=True)
|
|
207
|
+
configuration = models.ForeignKey(
|
|
208
|
+
"ChargerConfiguration",
|
|
209
|
+
null=True,
|
|
210
|
+
blank=True,
|
|
211
|
+
on_delete=models.SET_NULL,
|
|
212
|
+
related_name="chargers",
|
|
213
|
+
help_text=_(
|
|
214
|
+
"Latest GetConfiguration response received from this charge point."
|
|
215
|
+
),
|
|
216
|
+
)
|
|
207
217
|
manager_node = models.ForeignKey(
|
|
208
218
|
"nodes.Node",
|
|
209
219
|
on_delete=models.SET_NULL,
|
|
@@ -585,6 +595,51 @@ class Charger(Entity):
|
|
|
585
595
|
super().delete(*args, **kwargs)
|
|
586
596
|
|
|
587
597
|
|
|
598
|
+
class ChargerConfiguration(models.Model):
|
|
599
|
+
"""Persisted configuration package returned by a charge point."""
|
|
600
|
+
|
|
601
|
+
charger_identifier = models.CharField(_("Serial Number"), max_length=100)
|
|
602
|
+
connector_id = models.PositiveIntegerField(
|
|
603
|
+
_("Connector ID"),
|
|
604
|
+
null=True,
|
|
605
|
+
blank=True,
|
|
606
|
+
help_text=_("Connector that returned this configuration (if specified)."),
|
|
607
|
+
)
|
|
608
|
+
configuration_keys = models.JSONField(
|
|
609
|
+
default=list,
|
|
610
|
+
blank=True,
|
|
611
|
+
help_text=_("Entries from the configurationKey list."),
|
|
612
|
+
)
|
|
613
|
+
unknown_keys = models.JSONField(
|
|
614
|
+
default=list,
|
|
615
|
+
blank=True,
|
|
616
|
+
help_text=_("Keys returned in the unknownKey list."),
|
|
617
|
+
)
|
|
618
|
+
raw_payload = models.JSONField(
|
|
619
|
+
default=dict,
|
|
620
|
+
blank=True,
|
|
621
|
+
help_text=_("Raw payload returned by the GetConfiguration call."),
|
|
622
|
+
)
|
|
623
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
624
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
625
|
+
|
|
626
|
+
class Meta:
|
|
627
|
+
ordering = ["-created_at"]
|
|
628
|
+
verbose_name = _("CP Configuration")
|
|
629
|
+
verbose_name_plural = _("CP Configurations")
|
|
630
|
+
|
|
631
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
632
|
+
connector = (
|
|
633
|
+
_("connector %(number)s") % {"number": self.connector_id}
|
|
634
|
+
if self.connector_id is not None
|
|
635
|
+
else _("all connectors")
|
|
636
|
+
)
|
|
637
|
+
return _("%(serial)s configuration (%(connector)s)") % {
|
|
638
|
+
"serial": self.charger_identifier,
|
|
639
|
+
"connector": connector,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
588
643
|
class Transaction(Entity):
|
|
589
644
|
"""Charging session data stored for each charger."""
|
|
590
645
|
|
|
@@ -599,7 +654,18 @@ class Transaction(Entity):
|
|
|
599
654
|
blank=True,
|
|
600
655
|
verbose_name=_("RFID"),
|
|
601
656
|
)
|
|
602
|
-
|
|
657
|
+
vid = models.CharField(
|
|
658
|
+
max_length=64,
|
|
659
|
+
blank=True,
|
|
660
|
+
default="",
|
|
661
|
+
verbose_name=_("VID"),
|
|
662
|
+
help_text=_("Vehicle identifier reported by the charger."),
|
|
663
|
+
)
|
|
664
|
+
vin = models.CharField(
|
|
665
|
+
max_length=17,
|
|
666
|
+
blank=True,
|
|
667
|
+
help_text=_("Deprecated. Use VID instead."),
|
|
668
|
+
)
|
|
603
669
|
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
604
670
|
meter_start = models.IntegerField(null=True, blank=True)
|
|
605
671
|
meter_stop = models.IntegerField(null=True, blank=True)
|
|
@@ -645,6 +711,22 @@ class Transaction(Entity):
|
|
|
645
711
|
verbose_name = _("Transaction")
|
|
646
712
|
verbose_name_plural = _("CP Transactions")
|
|
647
713
|
|
|
714
|
+
@property
|
|
715
|
+
def vehicle_identifier(self) -> str:
|
|
716
|
+
"""Return the preferred vehicle identifier for this transaction."""
|
|
717
|
+
|
|
718
|
+
return (self.vid or self.vin or "").strip()
|
|
719
|
+
|
|
720
|
+
@property
|
|
721
|
+
def vehicle_identifier_source(self) -> str:
|
|
722
|
+
"""Return which field supplies :pyattr:`vehicle_identifier`."""
|
|
723
|
+
|
|
724
|
+
if (self.vid or "").strip():
|
|
725
|
+
return "vid"
|
|
726
|
+
if (self.vin or "").strip():
|
|
727
|
+
return "vin"
|
|
728
|
+
return ""
|
|
729
|
+
|
|
648
730
|
@property
|
|
649
731
|
def kw(self) -> float:
|
|
650
732
|
"""Return consumed energy in kW for this session."""
|
ocpp/tasks.py
CHANGED
|
@@ -149,6 +149,10 @@ def send_daily_session_report() -> int:
|
|
|
149
149
|
lines.append(f" Account: {account}")
|
|
150
150
|
if transaction.rfid:
|
|
151
151
|
lines.append(f" RFID: {transaction.rfid}")
|
|
152
|
+
identifier = transaction.vehicle_identifier
|
|
153
|
+
if identifier:
|
|
154
|
+
label = "VID" if transaction.vehicle_identifier_source == "vid" else "VIN"
|
|
155
|
+
lines.append(f" {label}: {identifier}")
|
|
152
156
|
if connector:
|
|
153
157
|
lines.append(f" {connector}")
|
|
154
158
|
lines.append(
|
ocpp/test_export_import.py
CHANGED
ocpp/test_rfid.py
CHANGED
|
@@ -771,7 +771,9 @@ class RFIDLandingTests(TestCase):
|
|
|
771
771
|
app = Application.objects.create(name="Ocpp")
|
|
772
772
|
module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
|
|
773
773
|
module.create_landings()
|
|
774
|
-
self.assertTrue(
|
|
774
|
+
self.assertTrue(
|
|
775
|
+
module.landings.filter(path="/ocpp/rfid/validator/").exists()
|
|
776
|
+
)
|
|
775
777
|
|
|
776
778
|
|
|
777
779
|
class ScannerTemplateTests(TestCase):
|
ocpp/tests.py
CHANGED
|
@@ -61,13 +61,14 @@ from config.asgi import application
|
|
|
61
61
|
from .models import (
|
|
62
62
|
Transaction,
|
|
63
63
|
Charger,
|
|
64
|
+
ChargerConfiguration,
|
|
64
65
|
Simulator,
|
|
65
66
|
MeterReading,
|
|
66
67
|
Location,
|
|
67
68
|
DataTransferMessage,
|
|
68
69
|
)
|
|
69
70
|
from .consumers import CSMSConsumer
|
|
70
|
-
from .views import dispatch_action
|
|
71
|
+
from .views import dispatch_action, _transaction_rfid_details
|
|
71
72
|
from .status_display import STATUS_BADGE_MAP
|
|
72
73
|
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
73
74
|
from . import store
|
|
@@ -714,6 +715,13 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
714
715
|
connected, _ = await communicator.connect()
|
|
715
716
|
self.assertTrue(connected)
|
|
716
717
|
|
|
718
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
719
|
+
charger_id="CFGRES", connector_id=1
|
|
720
|
+
)
|
|
721
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
722
|
+
charger_id="CFGRES", connector_id=2
|
|
723
|
+
)
|
|
724
|
+
|
|
717
725
|
message_id = "cfg-result"
|
|
718
726
|
payload = {
|
|
719
727
|
"configurationKey": [
|
|
@@ -744,6 +752,31 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
744
752
|
)
|
|
745
753
|
self.assertNotIn(message_id, store.pending_calls)
|
|
746
754
|
|
|
755
|
+
configuration = await database_sync_to_async(
|
|
756
|
+
lambda: ChargerConfiguration.objects.order_by("-created_at").first()
|
|
757
|
+
)()
|
|
758
|
+
self.assertIsNotNone(configuration)
|
|
759
|
+
self.assertEqual(configuration.charger_identifier, "CFGRES")
|
|
760
|
+
self.assertEqual(
|
|
761
|
+
configuration.configuration_keys,
|
|
762
|
+
[
|
|
763
|
+
{
|
|
764
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
765
|
+
"value": "false",
|
|
766
|
+
"readonly": True,
|
|
767
|
+
}
|
|
768
|
+
],
|
|
769
|
+
)
|
|
770
|
+
self.assertEqual(configuration.unknown_keys, [])
|
|
771
|
+
config_ids = await database_sync_to_async(
|
|
772
|
+
lambda: set(
|
|
773
|
+
Charger.objects.filter(charger_id="CFGRES").values_list(
|
|
774
|
+
"configuration_id", flat=True
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
)()
|
|
778
|
+
self.assertEqual(config_ids, {configuration.pk})
|
|
779
|
+
|
|
747
780
|
await communicator.disconnect()
|
|
748
781
|
store.clear_log(log_key, log_type="charger")
|
|
749
782
|
store.clear_log(pending_key, log_type="charger")
|
|
@@ -1109,7 +1142,7 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1109
1142
|
|
|
1110
1143
|
await communicator.disconnect()
|
|
1111
1144
|
|
|
1112
|
-
async def
|
|
1145
|
+
async def test_vid_populated_from_vin(self):
|
|
1113
1146
|
await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
|
|
1114
1147
|
communicator = WebsocketCommunicator(application, "/VINREC/")
|
|
1115
1148
|
connected, _ = await communicator.connect()
|
|
@@ -1124,7 +1157,29 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1124
1157
|
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1125
1158
|
pk=tx_id, charger__charger_id="VINREC"
|
|
1126
1159
|
)
|
|
1127
|
-
self.assertEqual(tx.
|
|
1160
|
+
self.assertEqual(tx.vid, "WP0ZZZ11111111111")
|
|
1161
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
|
|
1162
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
1163
|
+
|
|
1164
|
+
await communicator.disconnect()
|
|
1165
|
+
|
|
1166
|
+
async def test_vid_recorded(self):
|
|
1167
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
|
|
1168
|
+
communicator = WebsocketCommunicator(application, "/VIDREC/")
|
|
1169
|
+
connected, _ = await communicator.connect()
|
|
1170
|
+
self.assertTrue(connected)
|
|
1171
|
+
|
|
1172
|
+
await communicator.send_json_to(
|
|
1173
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
|
|
1174
|
+
)
|
|
1175
|
+
response = await communicator.receive_json_from()
|
|
1176
|
+
tx_id = response[2]["transactionId"]
|
|
1177
|
+
|
|
1178
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1179
|
+
pk=tx_id, charger__charger_id="VIDREC"
|
|
1180
|
+
)
|
|
1181
|
+
self.assertEqual(tx.vid, "VID123456")
|
|
1182
|
+
self.assertEqual(tx.rfid, "")
|
|
1128
1183
|
|
|
1129
1184
|
await communicator.disconnect()
|
|
1130
1185
|
|
|
@@ -2126,12 +2181,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
2126
2181
|
@skip("Navigation links unavailable in test environment")
|
|
2127
2182
|
def test_simulator_app_link_in_nav(self):
|
|
2128
2183
|
resp = self.client.get(reverse("pages:index"))
|
|
2129
|
-
self.assertContains(resp, "/ocpp/")
|
|
2130
|
-
self.assertNotContains(resp, "/ocpp/simulator/")
|
|
2184
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2185
|
+
self.assertNotContains(resp, "/ocpp/evcs/simulator/")
|
|
2131
2186
|
self.client.force_login(self.user)
|
|
2132
2187
|
resp = self.client.get(reverse("pages:index"))
|
|
2133
|
-
self.assertContains(resp, "/ocpp/")
|
|
2134
|
-
self.assertContains(resp, "/ocpp/simulator/")
|
|
2188
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2189
|
+
self.assertContains(resp, "/ocpp/evcs/simulator/")
|
|
2135
2190
|
|
|
2136
2191
|
def test_cp_simulator_redirects_to_login(self):
|
|
2137
2192
|
response = self.client.get(reverse("cp-simulator"))
|
|
@@ -3212,8 +3267,7 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
3212
3267
|
self.assertIsNotNone(aggregate.last_heartbeat)
|
|
3213
3268
|
if previous_heartbeat:
|
|
3214
3269
|
self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
|
|
3215
|
-
|
|
3216
|
-
self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
|
|
3270
|
+
self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
|
|
3217
3271
|
|
|
3218
3272
|
await communicator.disconnect()
|
|
3219
3273
|
|
|
@@ -4009,6 +4063,43 @@ class TransactionKwTests(TestCase):
|
|
|
4009
4063
|
self.assertEqual(tx.kw, 0.0)
|
|
4010
4064
|
|
|
4011
4065
|
|
|
4066
|
+
class TransactionIdentifierTests(TestCase):
|
|
4067
|
+
def test_vehicle_identifier_prefers_vid(self):
|
|
4068
|
+
charger = Charger.objects.create(charger_id="VIDPREF")
|
|
4069
|
+
tx = Transaction.objects.create(
|
|
4070
|
+
charger=charger,
|
|
4071
|
+
start_time=timezone.now(),
|
|
4072
|
+
vid="VID-123",
|
|
4073
|
+
vin="VIN-456",
|
|
4074
|
+
)
|
|
4075
|
+
self.assertEqual(tx.vehicle_identifier, "VID-123")
|
|
4076
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
4077
|
+
|
|
4078
|
+
def test_vehicle_identifier_falls_back_to_vin(self):
|
|
4079
|
+
charger = Charger.objects.create(charger_id="VINONLY")
|
|
4080
|
+
tx = Transaction.objects.create(
|
|
4081
|
+
charger=charger,
|
|
4082
|
+
start_time=timezone.now(),
|
|
4083
|
+
vin="WP0ZZZ00000000001",
|
|
4084
|
+
)
|
|
4085
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
|
|
4086
|
+
self.assertEqual(tx.vehicle_identifier_source, "vin")
|
|
4087
|
+
|
|
4088
|
+
def test_transaction_rfid_details_handles_vin(self):
|
|
4089
|
+
charger = Charger.objects.create(charger_id="VINDET")
|
|
4090
|
+
tx = Transaction.objects.create(
|
|
4091
|
+
charger=charger,
|
|
4092
|
+
start_time=timezone.now(),
|
|
4093
|
+
vin="WAUZZZ00000000002",
|
|
4094
|
+
)
|
|
4095
|
+
details = _transaction_rfid_details(tx, cache={})
|
|
4096
|
+
self.assertIsNotNone(details)
|
|
4097
|
+
assert details is not None # for type checkers
|
|
4098
|
+
self.assertEqual(details["value"], "WAUZZZ00000000002")
|
|
4099
|
+
self.assertEqual(details["display_label"], "VIN")
|
|
4100
|
+
self.assertEqual(details["type"], "vin")
|
|
4101
|
+
|
|
4102
|
+
|
|
4012
4103
|
class DispatchActionViewTests(TestCase):
|
|
4013
4104
|
def setUp(self):
|
|
4014
4105
|
self.client = Client()
|
ocpp/transactions_io.py
CHANGED
|
@@ -46,6 +46,7 @@ def export_transactions(
|
|
|
46
46
|
"charger": tx.charger.charger_id if tx.charger else None,
|
|
47
47
|
"account": tx.account_id,
|
|
48
48
|
"rfid": tx.rfid,
|
|
49
|
+
"vid": tx.vehicle_identifier,
|
|
49
50
|
"vin": tx.vin,
|
|
50
51
|
"meter_start": tx.meter_start,
|
|
51
52
|
"meter_stop": tx.meter_stop,
|
|
@@ -144,11 +145,18 @@ def import_transactions(data: dict) -> int:
|
|
|
144
145
|
except ValidationError:
|
|
145
146
|
continue
|
|
146
147
|
charger_map[serial] = charger
|
|
148
|
+
vid_value = tx.get("vid")
|
|
149
|
+
vin_value = tx.get("vin")
|
|
150
|
+
vid_text = str(vid_value).strip() if vid_value is not None else ""
|
|
151
|
+
vin_text = str(vin_value).strip() if vin_value is not None else ""
|
|
152
|
+
if not vid_text and vin_text:
|
|
153
|
+
vid_text = vin_text
|
|
147
154
|
transaction = Transaction.objects.create(
|
|
148
155
|
charger=charger,
|
|
149
156
|
account_id=tx.get("account"),
|
|
150
157
|
rfid=tx.get("rfid", ""),
|
|
151
|
-
|
|
158
|
+
vid=vid_text,
|
|
159
|
+
vin=vin_text,
|
|
152
160
|
meter_start=tx.get("meter_start"),
|
|
153
161
|
meter_stop=tx.get("meter_stop"),
|
|
154
162
|
voltage_start=tx.get("voltage_start"),
|