arthexis 0.1.18__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.18.dist-info → arthexis-0.1.20.dist-info}/METADATA +39 -12
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/RECORD +44 -44
- config/settings.py +1 -5
- 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/system.py +125 -0
- core/tasks.py +24 -23
- core/tests.py +1 -0
- core/views.py +105 -40
- nodes/admin.py +134 -3
- nodes/models.py +310 -69
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +573 -48
- nodes/urls.py +4 -1
- nodes/views.py +498 -106
- ocpp/admin.py +124 -5
- ocpp/consumers.py +106 -9
- ocpp/models.py +90 -1
- ocpp/store.py +6 -4
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +114 -10
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +166 -40
- pages/admin.py +63 -10
- pages/context_processors.py +26 -9
- pages/defaults.py +1 -1
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/module_defaults.py +5 -5
- pages/tests.py +280 -65
- pages/urls.py +3 -1
- pages/views.py +176 -29
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -6,7 +6,7 @@ from datetime import timedelta
|
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
from django.shortcuts import redirect
|
|
9
|
-
from django.utils import timezone
|
|
9
|
+
from django.utils import formats, timezone, translation
|
|
10
10
|
from django.urls import path
|
|
11
11
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
12
|
from django.template.response import TemplateResponse
|
|
@@ -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
|
|
@@ -163,6 +240,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
163
240
|
"charger_id",
|
|
164
241
|
"display_name",
|
|
165
242
|
"connector_id",
|
|
243
|
+
"language",
|
|
166
244
|
"location",
|
|
167
245
|
"last_path",
|
|
168
246
|
"last_heartbeat",
|
|
@@ -206,7 +284,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
206
284
|
),
|
|
207
285
|
(
|
|
208
286
|
"Configuration",
|
|
209
|
-
{"fields": ("public_display", "require_rfid")},
|
|
287
|
+
{"fields": ("public_display", "require_rfid", "configuration")},
|
|
210
288
|
),
|
|
211
289
|
(
|
|
212
290
|
"References",
|
|
@@ -235,11 +313,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
235
313
|
"availability_request_status",
|
|
236
314
|
"availability_request_status_at",
|
|
237
315
|
"availability_request_details",
|
|
316
|
+
"configuration",
|
|
238
317
|
)
|
|
239
318
|
list_display = (
|
|
240
|
-
"
|
|
319
|
+
"display_name_with_fallback",
|
|
241
320
|
"connector_number",
|
|
242
|
-
"
|
|
321
|
+
"serial_number_display",
|
|
243
322
|
"require_rfid_display",
|
|
244
323
|
"public_display",
|
|
245
324
|
"last_heartbeat",
|
|
@@ -348,6 +427,18 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
348
427
|
return True
|
|
349
428
|
return False
|
|
350
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
|
+
|
|
351
442
|
def location_name(self, obj):
|
|
352
443
|
return obj.location.name if obj.location else ""
|
|
353
444
|
|
|
@@ -719,7 +810,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
719
810
|
"ws_port",
|
|
720
811
|
"ws_url",
|
|
721
812
|
"interval",
|
|
722
|
-
"
|
|
813
|
+
"kw_max_display",
|
|
723
814
|
"running",
|
|
724
815
|
"log_link",
|
|
725
816
|
)
|
|
@@ -759,6 +850,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
759
850
|
|
|
760
851
|
log_type = "simulator"
|
|
761
852
|
|
|
853
|
+
@admin.display(description="kW Max", ordering="kw_max")
|
|
854
|
+
def kw_max_display(self, obj):
|
|
855
|
+
"""Display ``kw_max`` with a dot decimal separator for Spanish locales."""
|
|
856
|
+
|
|
857
|
+
language = translation.get_language() or ""
|
|
858
|
+
if language.startswith("es"):
|
|
859
|
+
return formats.number_format(
|
|
860
|
+
obj.kw_max,
|
|
861
|
+
decimal_pos=2,
|
|
862
|
+
use_l10n=False,
|
|
863
|
+
force_grouping=False,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
return formats.number_format(
|
|
867
|
+
obj.kw_max,
|
|
868
|
+
decimal_pos=2,
|
|
869
|
+
use_l10n=True,
|
|
870
|
+
force_grouping=False,
|
|
871
|
+
)
|
|
872
|
+
|
|
762
873
|
def save_model(self, request, obj, form, change):
|
|
763
874
|
previous_door_open = False
|
|
764
875
|
if change and obj.pk:
|
|
@@ -912,8 +1023,10 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
912
1023
|
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
913
1024
|
list_display = (
|
|
914
1025
|
"charger",
|
|
1026
|
+
"connector_number",
|
|
915
1027
|
"account",
|
|
916
1028
|
"rfid",
|
|
1029
|
+
"vid",
|
|
917
1030
|
"meter_start",
|
|
918
1031
|
"meter_stop",
|
|
919
1032
|
"start_time",
|
|
@@ -925,6 +1038,12 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
925
1038
|
date_hierarchy = "start_time"
|
|
926
1039
|
inlines = [MeterValueInline]
|
|
927
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
|
+
|
|
928
1047
|
def get_urls(self):
|
|
929
1048
|
urls = super().get_urls()
|
|
930
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
|
@@ -82,6 +82,13 @@ class Charger(Entity):
|
|
|
82
82
|
default=True,
|
|
83
83
|
help_text="Display this charger on the public status dashboard.",
|
|
84
84
|
)
|
|
85
|
+
language = models.CharField(
|
|
86
|
+
_("Language"),
|
|
87
|
+
max_length=12,
|
|
88
|
+
choices=settings.LANGUAGES,
|
|
89
|
+
default="es",
|
|
90
|
+
help_text=_("Preferred language for the public landing page."),
|
|
91
|
+
)
|
|
85
92
|
require_rfid = models.BooleanField(
|
|
86
93
|
_("Require RFID Authorization"),
|
|
87
94
|
default=False,
|
|
@@ -197,6 +204,16 @@ class Charger(Entity):
|
|
|
197
204
|
related_name="chargers",
|
|
198
205
|
)
|
|
199
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
|
+
)
|
|
200
217
|
manager_node = models.ForeignKey(
|
|
201
218
|
"nodes.Node",
|
|
202
219
|
on_delete=models.SET_NULL,
|
|
@@ -578,6 +595,51 @@ class Charger(Entity):
|
|
|
578
595
|
super().delete(*args, **kwargs)
|
|
579
596
|
|
|
580
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
|
+
|
|
581
643
|
class Transaction(Entity):
|
|
582
644
|
"""Charging session data stored for each charger."""
|
|
583
645
|
|
|
@@ -592,7 +654,18 @@ class Transaction(Entity):
|
|
|
592
654
|
blank=True,
|
|
593
655
|
verbose_name=_("RFID"),
|
|
594
656
|
)
|
|
595
|
-
|
|
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
|
+
)
|
|
596
669
|
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
597
670
|
meter_start = models.IntegerField(null=True, blank=True)
|
|
598
671
|
meter_stop = models.IntegerField(null=True, blank=True)
|
|
@@ -638,6 +711,22 @@ class Transaction(Entity):
|
|
|
638
711
|
verbose_name = _("Transaction")
|
|
639
712
|
verbose_name_plural = _("CP Transactions")
|
|
640
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
|
+
|
|
641
730
|
@property
|
|
642
731
|
def kw(self) -> float:
|
|
643
732
|
"""Return consumed energy in kW for this session."""
|
ocpp/store.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from datetime import datetime
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
7
|
import json
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import re
|
|
@@ -427,7 +427,7 @@ def _file_path(cid: str, log_type: str = "charger") -> Path:
|
|
|
427
427
|
def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
|
|
428
428
|
"""Append a timestamped log entry for the given id and log type."""
|
|
429
429
|
|
|
430
|
-
timestamp = datetime.
|
|
430
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
431
431
|
entry = f"{timestamp} {entry}"
|
|
432
432
|
|
|
433
433
|
store = logs[log_type]
|
|
@@ -454,7 +454,7 @@ def start_session_log(cid: str, tx_id: int) -> None:
|
|
|
454
454
|
|
|
455
455
|
history[cid] = {
|
|
456
456
|
"transaction": tx_id,
|
|
457
|
-
"start": datetime.
|
|
457
|
+
"start": datetime.now(timezone.utc),
|
|
458
458
|
"messages": [],
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -467,7 +467,9 @@ def add_session_message(cid: str, message: str) -> None:
|
|
|
467
467
|
return
|
|
468
468
|
sess["messages"].append(
|
|
469
469
|
{
|
|
470
|
-
"timestamp": datetime.
|
|
470
|
+
"timestamp": datetime.now(timezone.utc)
|
|
471
|
+
.isoformat()
|
|
472
|
+
.replace("+00:00", "Z"),
|
|
471
473
|
"message": message,
|
|
472
474
|
}
|
|
473
475
|
)
|
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):
|