arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.21.dist-info}/METADATA +5 -6
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/RECORD +42 -44
- config/asgi.py +1 -15
- config/settings.py +0 -26
- config/urls.py +0 -1
- core/admin.py +143 -234
- core/apps.py +0 -6
- core/backends.py +8 -2
- core/environment.py +240 -4
- core/models.py +132 -102
- 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 +2 -7
- core/views.py +70 -132
- nodes/admin.py +162 -7
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +581 -15
- nodes/urls.py +4 -0
- nodes/views.py +560 -96
- ocpp/admin.py +144 -4
- ocpp/consumers.py +106 -9
- ocpp/models.py +131 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +183 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +186 -31
- pages/context_processors.py +15 -21
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -79
- pages/urls.py +1 -1
- pages/views.py +108 -13
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.21.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -2,11 +2,12 @@ from django.contrib import admin, messages
|
|
|
2
2
|
from django import forms
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
-
from datetime import timedelta
|
|
5
|
+
from datetime import datetime, time, timedelta
|
|
6
6
|
import json
|
|
7
7
|
|
|
8
8
|
from django.shortcuts import redirect
|
|
9
9
|
from django.utils import formats, timezone, translation
|
|
10
|
+
from django.utils.html import format_html
|
|
10
11
|
from django.urls import path
|
|
11
12
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
13
|
from django.template.response import TemplateResponse
|
|
@@ -16,6 +17,7 @@ from asgiref.sync import async_to_sync
|
|
|
16
17
|
|
|
17
18
|
from .models import (
|
|
18
19
|
Charger,
|
|
20
|
+
ChargerConfiguration,
|
|
19
21
|
Simulator,
|
|
20
22
|
MeterValue,
|
|
21
23
|
Transaction,
|
|
@@ -108,6 +110,82 @@ class LogViewAdminMixin:
|
|
|
108
110
|
return TemplateResponse(request, self.log_template_name, context)
|
|
109
111
|
|
|
110
112
|
|
|
113
|
+
@admin.register(ChargerConfiguration)
|
|
114
|
+
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
115
|
+
list_display = ("charger_identifier", "connector_display", "created_at")
|
|
116
|
+
list_filter = ("connector_id",)
|
|
117
|
+
search_fields = ("charger_identifier",)
|
|
118
|
+
readonly_fields = (
|
|
119
|
+
"charger_identifier",
|
|
120
|
+
"connector_id",
|
|
121
|
+
"created_at",
|
|
122
|
+
"updated_at",
|
|
123
|
+
"linked_chargers",
|
|
124
|
+
"configuration_keys_display",
|
|
125
|
+
"unknown_keys_display",
|
|
126
|
+
"raw_payload_display",
|
|
127
|
+
)
|
|
128
|
+
fieldsets = (
|
|
129
|
+
(
|
|
130
|
+
None,
|
|
131
|
+
{
|
|
132
|
+
"fields": (
|
|
133
|
+
"charger_identifier",
|
|
134
|
+
"connector_id",
|
|
135
|
+
"linked_chargers",
|
|
136
|
+
"created_at",
|
|
137
|
+
"updated_at",
|
|
138
|
+
)
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
(
|
|
142
|
+
"Payload",
|
|
143
|
+
{
|
|
144
|
+
"fields": (
|
|
145
|
+
"configuration_keys_display",
|
|
146
|
+
"unknown_keys_display",
|
|
147
|
+
"raw_payload_display",
|
|
148
|
+
)
|
|
149
|
+
},
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@admin.display(description="Connector")
|
|
154
|
+
def connector_display(self, obj):
|
|
155
|
+
if obj.connector_id is None:
|
|
156
|
+
return "All"
|
|
157
|
+
return obj.connector_id
|
|
158
|
+
|
|
159
|
+
@admin.display(description="Linked charge points")
|
|
160
|
+
def linked_chargers(self, obj):
|
|
161
|
+
if obj.pk is None:
|
|
162
|
+
return ""
|
|
163
|
+
linked = [charger.identity_slug() for charger in obj.chargers.all()]
|
|
164
|
+
if not linked:
|
|
165
|
+
return "-"
|
|
166
|
+
return ", ".join(sorted(linked))
|
|
167
|
+
|
|
168
|
+
def _render_json(self, data):
|
|
169
|
+
from django.utils.html import format_html
|
|
170
|
+
|
|
171
|
+
if not data:
|
|
172
|
+
return "-"
|
|
173
|
+
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
|
174
|
+
return format_html("<pre>{}</pre>", formatted)
|
|
175
|
+
|
|
176
|
+
@admin.display(description="configurationKey")
|
|
177
|
+
def configuration_keys_display(self, obj):
|
|
178
|
+
return self._render_json(obj.configuration_keys)
|
|
179
|
+
|
|
180
|
+
@admin.display(description="unknownKey")
|
|
181
|
+
def unknown_keys_display(self, obj):
|
|
182
|
+
return self._render_json(obj.unknown_keys)
|
|
183
|
+
|
|
184
|
+
@admin.display(description="Raw payload")
|
|
185
|
+
def raw_payload_display(self, obj):
|
|
186
|
+
return self._render_json(obj.raw_payload)
|
|
187
|
+
|
|
188
|
+
|
|
111
189
|
@admin.register(Location)
|
|
112
190
|
class LocationAdmin(EntityModelAdmin):
|
|
113
191
|
form = LocationAdminForm
|
|
@@ -207,7 +285,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
207
285
|
),
|
|
208
286
|
(
|
|
209
287
|
"Configuration",
|
|
210
|
-
{"fields": ("public_display", "require_rfid")},
|
|
288
|
+
{"fields": ("public_display", "require_rfid", "configuration")},
|
|
211
289
|
),
|
|
212
290
|
(
|
|
213
291
|
"References",
|
|
@@ -236,11 +314,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
236
314
|
"availability_request_status",
|
|
237
315
|
"availability_request_status_at",
|
|
238
316
|
"availability_request_details",
|
|
317
|
+
"configuration",
|
|
239
318
|
)
|
|
240
319
|
list_display = (
|
|
241
|
-
"
|
|
320
|
+
"display_name_with_fallback",
|
|
242
321
|
"connector_number",
|
|
243
|
-
"
|
|
322
|
+
"charger_name_display",
|
|
244
323
|
"require_rfid_display",
|
|
245
324
|
"public_display",
|
|
246
325
|
"last_heartbeat",
|
|
@@ -349,6 +428,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
349
428
|
return True
|
|
350
429
|
return False
|
|
351
430
|
|
|
431
|
+
@admin.display(description="Display Name", ordering="display_name")
|
|
432
|
+
def display_name_with_fallback(self, obj):
|
|
433
|
+
return self._charger_display_name(obj)
|
|
434
|
+
|
|
435
|
+
@admin.display(description="Charger", ordering="display_name")
|
|
436
|
+
def charger_name_display(self, obj):
|
|
437
|
+
return self._charger_display_name(obj)
|
|
438
|
+
|
|
439
|
+
def _charger_display_name(self, obj):
|
|
440
|
+
if obj.display_name:
|
|
441
|
+
return obj.display_name
|
|
442
|
+
if obj.location:
|
|
443
|
+
return obj.location.name
|
|
444
|
+
return obj.charger_id
|
|
445
|
+
|
|
352
446
|
def location_name(self, obj):
|
|
353
447
|
return obj.location.name if obj.location else ""
|
|
354
448
|
|
|
@@ -710,6 +804,44 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
710
804
|
|
|
711
805
|
session_kw.short_description = "Session kW"
|
|
712
806
|
|
|
807
|
+
def changelist_view(self, request, extra_context=None):
|
|
808
|
+
response = super().changelist_view(request, extra_context=extra_context)
|
|
809
|
+
if hasattr(response, "context_data"):
|
|
810
|
+
cl = response.context_data.get("cl")
|
|
811
|
+
if cl is not None:
|
|
812
|
+
response.context_data.update(
|
|
813
|
+
self._charger_quick_stats_context(cl.queryset)
|
|
814
|
+
)
|
|
815
|
+
return response
|
|
816
|
+
|
|
817
|
+
def _charger_quick_stats_context(self, queryset):
|
|
818
|
+
chargers = list(queryset)
|
|
819
|
+
stats = {"total_kw": 0.0, "today_kw": 0.0}
|
|
820
|
+
if not chargers:
|
|
821
|
+
return {"charger_quick_stats": stats}
|
|
822
|
+
|
|
823
|
+
parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
|
|
824
|
+
start, end = self._today_range()
|
|
825
|
+
|
|
826
|
+
for charger in chargers:
|
|
827
|
+
include_totals = True
|
|
828
|
+
if charger.connector_id is not None and charger.charger_id in parent_ids:
|
|
829
|
+
include_totals = False
|
|
830
|
+
if include_totals:
|
|
831
|
+
stats["total_kw"] += charger.total_kw
|
|
832
|
+
stats["today_kw"] += charger.total_kw_for_range(start, end)
|
|
833
|
+
|
|
834
|
+
stats = {key: round(value, 2) for key, value in stats.items()}
|
|
835
|
+
return {"charger_quick_stats": stats}
|
|
836
|
+
|
|
837
|
+
def _today_range(self):
|
|
838
|
+
today = timezone.localdate()
|
|
839
|
+
start = datetime.combine(today, time.min)
|
|
840
|
+
if timezone.is_naive(start):
|
|
841
|
+
start = timezone.make_aware(start, timezone.get_current_timezone())
|
|
842
|
+
end = start + timedelta(days=1)
|
|
843
|
+
return start, end
|
|
844
|
+
|
|
713
845
|
|
|
714
846
|
@admin.register(Simulator)
|
|
715
847
|
class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
|
|
@@ -933,8 +1065,10 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
933
1065
|
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
934
1066
|
list_display = (
|
|
935
1067
|
"charger",
|
|
1068
|
+
"connector_number",
|
|
936
1069
|
"account",
|
|
937
1070
|
"rfid",
|
|
1071
|
+
"vid",
|
|
938
1072
|
"meter_start",
|
|
939
1073
|
"meter_stop",
|
|
940
1074
|
"start_time",
|
|
@@ -946,6 +1080,12 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
946
1080
|
date_hierarchy = "start_time"
|
|
947
1081
|
inlines = [MeterValueInline]
|
|
948
1082
|
|
|
1083
|
+
def connector_number(self, obj):
|
|
1084
|
+
return obj.connector_id or ""
|
|
1085
|
+
|
|
1086
|
+
connector_number.short_description = "#"
|
|
1087
|
+
connector_number.admin_order_field = "connector_id"
|
|
1088
|
+
|
|
949
1089
|
def get_urls(self):
|
|
950
1090
|
urls = super().get_urls()
|
|
951
1091
|
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,
|
|
@@ -534,6 +544,20 @@ class Charger(Entity):
|
|
|
534
544
|
return qs
|
|
535
545
|
return qs.filter(pk=self.pk)
|
|
536
546
|
|
|
547
|
+
def total_kw_for_range(
|
|
548
|
+
self,
|
|
549
|
+
start=None,
|
|
550
|
+
end=None,
|
|
551
|
+
) -> float:
|
|
552
|
+
"""Return total energy delivered within ``start``/``end`` window."""
|
|
553
|
+
|
|
554
|
+
from . import store
|
|
555
|
+
|
|
556
|
+
total = 0.0
|
|
557
|
+
for charger in self._target_chargers():
|
|
558
|
+
total += charger._total_kw_range_single(store, start, end)
|
|
559
|
+
return total
|
|
560
|
+
|
|
537
561
|
def _total_kw_single(self, store_module) -> float:
|
|
538
562
|
"""Return total kW for this specific charger identity."""
|
|
539
563
|
|
|
@@ -554,6 +578,40 @@ class Charger(Entity):
|
|
|
554
578
|
total += kw
|
|
555
579
|
return total
|
|
556
580
|
|
|
581
|
+
def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
|
|
582
|
+
"""Return total kW for a date range for this charger."""
|
|
583
|
+
|
|
584
|
+
tx_active = None
|
|
585
|
+
if self.connector_id is not None:
|
|
586
|
+
tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
|
|
587
|
+
|
|
588
|
+
qs = self.transactions.all()
|
|
589
|
+
if start is not None:
|
|
590
|
+
qs = qs.filter(start_time__gte=start)
|
|
591
|
+
if end is not None:
|
|
592
|
+
qs = qs.filter(start_time__lt=end)
|
|
593
|
+
if tx_active and tx_active.pk is not None:
|
|
594
|
+
qs = qs.exclude(pk=tx_active.pk)
|
|
595
|
+
|
|
596
|
+
total = 0.0
|
|
597
|
+
for tx in qs:
|
|
598
|
+
kw = tx.kw
|
|
599
|
+
if kw:
|
|
600
|
+
total += kw
|
|
601
|
+
|
|
602
|
+
if tx_active:
|
|
603
|
+
start_time = getattr(tx_active, "start_time", None)
|
|
604
|
+
include = True
|
|
605
|
+
if start is not None and start_time and start_time < start:
|
|
606
|
+
include = False
|
|
607
|
+
if end is not None and start_time and start_time >= end:
|
|
608
|
+
include = False
|
|
609
|
+
if include:
|
|
610
|
+
kw = tx_active.kw
|
|
611
|
+
if kw:
|
|
612
|
+
total += kw
|
|
613
|
+
return total
|
|
614
|
+
|
|
557
615
|
def purge(self):
|
|
558
616
|
from . import store
|
|
559
617
|
|
|
@@ -585,6 +643,51 @@ class Charger(Entity):
|
|
|
585
643
|
super().delete(*args, **kwargs)
|
|
586
644
|
|
|
587
645
|
|
|
646
|
+
class ChargerConfiguration(models.Model):
|
|
647
|
+
"""Persisted configuration package returned by a charge point."""
|
|
648
|
+
|
|
649
|
+
charger_identifier = models.CharField(_("Serial Number"), max_length=100)
|
|
650
|
+
connector_id = models.PositiveIntegerField(
|
|
651
|
+
_("Connector ID"),
|
|
652
|
+
null=True,
|
|
653
|
+
blank=True,
|
|
654
|
+
help_text=_("Connector that returned this configuration (if specified)."),
|
|
655
|
+
)
|
|
656
|
+
configuration_keys = models.JSONField(
|
|
657
|
+
default=list,
|
|
658
|
+
blank=True,
|
|
659
|
+
help_text=_("Entries from the configurationKey list."),
|
|
660
|
+
)
|
|
661
|
+
unknown_keys = models.JSONField(
|
|
662
|
+
default=list,
|
|
663
|
+
blank=True,
|
|
664
|
+
help_text=_("Keys returned in the unknownKey list."),
|
|
665
|
+
)
|
|
666
|
+
raw_payload = models.JSONField(
|
|
667
|
+
default=dict,
|
|
668
|
+
blank=True,
|
|
669
|
+
help_text=_("Raw payload returned by the GetConfiguration call."),
|
|
670
|
+
)
|
|
671
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
672
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
673
|
+
|
|
674
|
+
class Meta:
|
|
675
|
+
ordering = ["-created_at"]
|
|
676
|
+
verbose_name = _("CP Configuration")
|
|
677
|
+
verbose_name_plural = _("CP Configurations")
|
|
678
|
+
|
|
679
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
680
|
+
connector = (
|
|
681
|
+
_("connector %(number)s") % {"number": self.connector_id}
|
|
682
|
+
if self.connector_id is not None
|
|
683
|
+
else _("all connectors")
|
|
684
|
+
)
|
|
685
|
+
return _("%(serial)s configuration (%(connector)s)") % {
|
|
686
|
+
"serial": self.charger_identifier,
|
|
687
|
+
"connector": connector,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
588
691
|
class Transaction(Entity):
|
|
589
692
|
"""Charging session data stored for each charger."""
|
|
590
693
|
|
|
@@ -599,7 +702,18 @@ class Transaction(Entity):
|
|
|
599
702
|
blank=True,
|
|
600
703
|
verbose_name=_("RFID"),
|
|
601
704
|
)
|
|
602
|
-
|
|
705
|
+
vid = models.CharField(
|
|
706
|
+
max_length=64,
|
|
707
|
+
blank=True,
|
|
708
|
+
default="",
|
|
709
|
+
verbose_name=_("VID"),
|
|
710
|
+
help_text=_("Vehicle identifier reported by the charger."),
|
|
711
|
+
)
|
|
712
|
+
vin = models.CharField(
|
|
713
|
+
max_length=17,
|
|
714
|
+
blank=True,
|
|
715
|
+
help_text=_("Deprecated. Use VID instead."),
|
|
716
|
+
)
|
|
603
717
|
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
604
718
|
meter_start = models.IntegerField(null=True, blank=True)
|
|
605
719
|
meter_stop = models.IntegerField(null=True, blank=True)
|
|
@@ -645,6 +759,22 @@ class Transaction(Entity):
|
|
|
645
759
|
verbose_name = _("Transaction")
|
|
646
760
|
verbose_name_plural = _("CP Transactions")
|
|
647
761
|
|
|
762
|
+
@property
|
|
763
|
+
def vehicle_identifier(self) -> str:
|
|
764
|
+
"""Return the preferred vehicle identifier for this transaction."""
|
|
765
|
+
|
|
766
|
+
return (self.vid or self.vin or "").strip()
|
|
767
|
+
|
|
768
|
+
@property
|
|
769
|
+
def vehicle_identifier_source(self) -> str:
|
|
770
|
+
"""Return which field supplies :pyattr:`vehicle_identifier`."""
|
|
771
|
+
|
|
772
|
+
if (self.vid or "").strip():
|
|
773
|
+
return "vid"
|
|
774
|
+
if (self.vin or "").strip():
|
|
775
|
+
return "vin"
|
|
776
|
+
return ""
|
|
777
|
+
|
|
648
778
|
@property
|
|
649
779
|
def kw(self) -> float:
|
|
650
780
|
"""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):
|