arthexis 0.1.24__py3-none-any.whl → 0.1.25__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.24.dist-info → arthexis-0.1.25.dist-info}/METADATA +35 -14
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/RECORD +30 -29
- config/settings.py +6 -3
- config/urls.py +2 -0
- core/admin.py +1 -186
- core/backends.py +3 -1
- core/models.py +74 -8
- core/system.py +67 -2
- core/views.py +0 -3
- nodes/admin.py +444 -251
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +211 -1
- nodes/urls.py +5 -0
- nodes/utils.py +9 -2
- nodes/views.py +128 -80
- ocpp/admin.py +190 -2
- ocpp/consumers.py +98 -0
- ocpp/models.py +271 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +108 -267
- ocpp/tests.py +179 -0
- ocpp/views.py +2 -0
- pages/middleware.py +3 -2
- pages/tests.py +40 -0
- pages/utils.py +70 -0
- pages/views.py +4 -2
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.24.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
nodes/views.py
CHANGED
|
@@ -37,12 +37,23 @@ from django.db.models import Q
|
|
|
37
37
|
from core.models import RFID
|
|
38
38
|
from ocpp import store
|
|
39
39
|
from ocpp.models import Charger
|
|
40
|
+
from ocpp.network import (
|
|
41
|
+
apply_remote_charger_payload,
|
|
42
|
+
serialize_charger_for_network,
|
|
43
|
+
sync_transactions_payload,
|
|
44
|
+
)
|
|
40
45
|
from ocpp.transactions_io import export_transactions
|
|
41
46
|
from asgiref.sync import async_to_sync
|
|
42
47
|
|
|
43
48
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
44
49
|
|
|
45
|
-
from .models import
|
|
50
|
+
from .models import (
|
|
51
|
+
Node,
|
|
52
|
+
NetMessage,
|
|
53
|
+
PendingNetMessage,
|
|
54
|
+
NodeRole,
|
|
55
|
+
node_information_updated,
|
|
56
|
+
)
|
|
46
57
|
from .utils import capture_screenshot, save_screenshot
|
|
47
58
|
|
|
48
59
|
|
|
@@ -175,65 +186,6 @@ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
|
|
|
175
186
|
user.user_permissions.set(perm_objs)
|
|
176
187
|
|
|
177
188
|
|
|
178
|
-
def _serialize_charger_for_network(charger: Charger) -> dict[str, object]:
|
|
179
|
-
simple_fields = [
|
|
180
|
-
"display_name",
|
|
181
|
-
"language",
|
|
182
|
-
"public_display",
|
|
183
|
-
"require_rfid",
|
|
184
|
-
"firmware_status",
|
|
185
|
-
"firmware_status_info",
|
|
186
|
-
"last_status",
|
|
187
|
-
"last_error_code",
|
|
188
|
-
"last_status_vendor_info",
|
|
189
|
-
"availability_state",
|
|
190
|
-
"availability_requested_state",
|
|
191
|
-
"availability_request_status",
|
|
192
|
-
"availability_request_details",
|
|
193
|
-
"temperature",
|
|
194
|
-
"temperature_unit",
|
|
195
|
-
"diagnostics_status",
|
|
196
|
-
"diagnostics_location",
|
|
197
|
-
]
|
|
198
|
-
datetime_fields = [
|
|
199
|
-
"firmware_timestamp",
|
|
200
|
-
"last_heartbeat",
|
|
201
|
-
"availability_state_updated_at",
|
|
202
|
-
"availability_requested_at",
|
|
203
|
-
"availability_request_status_at",
|
|
204
|
-
"diagnostics_timestamp",
|
|
205
|
-
"last_status_timestamp",
|
|
206
|
-
"last_online_at",
|
|
207
|
-
]
|
|
208
|
-
|
|
209
|
-
data: dict[str, object] = {
|
|
210
|
-
"charger_id": charger.charger_id,
|
|
211
|
-
"connector_id": charger.connector_id,
|
|
212
|
-
"allow_remote": charger.allow_remote,
|
|
213
|
-
"export_transactions": charger.export_transactions,
|
|
214
|
-
"last_meter_values": charger.last_meter_values or {},
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
for field in simple_fields:
|
|
218
|
-
data[field] = getattr(charger, field)
|
|
219
|
-
|
|
220
|
-
for field in datetime_fields:
|
|
221
|
-
value = getattr(charger, field)
|
|
222
|
-
data[field] = value.isoformat() if value else None
|
|
223
|
-
|
|
224
|
-
if charger.location:
|
|
225
|
-
location = charger.location
|
|
226
|
-
data["location"] = {
|
|
227
|
-
"name": location.name,
|
|
228
|
-
"latitude": location.latitude,
|
|
229
|
-
"longitude": location.longitude,
|
|
230
|
-
"zone": location.zone,
|
|
231
|
-
"contract_type": location.contract_type,
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return data
|
|
235
|
-
|
|
236
|
-
|
|
237
189
|
def _normalize_requested_chargers(values) -> list[tuple[str, int | None, object]]:
|
|
238
190
|
if not isinstance(values, list):
|
|
239
191
|
return []
|
|
@@ -410,7 +362,7 @@ def _get_advertised_address(request, node) -> str:
|
|
|
410
362
|
host_ip = _get_host_ip(request)
|
|
411
363
|
if host_ip:
|
|
412
364
|
return host_ip
|
|
413
|
-
return node.address
|
|
365
|
+
return node.get_primary_contact() or node.address or node.hostname
|
|
414
366
|
|
|
415
367
|
|
|
416
368
|
@api_login_required
|
|
@@ -420,7 +372,10 @@ def node_list(request):
|
|
|
420
372
|
nodes = [
|
|
421
373
|
{
|
|
422
374
|
"hostname": node.hostname,
|
|
375
|
+
"network_hostname": node.network_hostname,
|
|
423
376
|
"address": node.address,
|
|
377
|
+
"ipv4_address": node.ipv4_address,
|
|
378
|
+
"ipv6_address": node.ipv6_address,
|
|
424
379
|
"port": node.port,
|
|
425
380
|
"last_seen": node.last_seen,
|
|
426
381
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
@@ -448,21 +403,22 @@ def node_info(request):
|
|
|
448
403
|
advertised_port = host_port
|
|
449
404
|
if host_domain:
|
|
450
405
|
hostname = host_domain
|
|
451
|
-
|
|
452
|
-
address = advertised_address
|
|
453
|
-
else:
|
|
454
|
-
address = host_domain
|
|
406
|
+
address = advertised_address or host_domain
|
|
455
407
|
else:
|
|
456
408
|
hostname = node.hostname
|
|
457
|
-
address = advertised_address
|
|
409
|
+
address = advertised_address or node.address or node.network_hostname or ""
|
|
458
410
|
data = {
|
|
459
411
|
"hostname": hostname,
|
|
412
|
+
"network_hostname": node.network_hostname,
|
|
460
413
|
"address": address,
|
|
414
|
+
"ipv4_address": node.ipv4_address,
|
|
415
|
+
"ipv6_address": node.ipv6_address,
|
|
461
416
|
"port": advertised_port,
|
|
462
417
|
"mac_address": node.mac_address,
|
|
463
418
|
"public_key": node.public_key,
|
|
464
419
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
465
420
|
"role": node.role.name if node.role_id else "",
|
|
421
|
+
"contact_hosts": node.get_remote_host_candidates(),
|
|
466
422
|
}
|
|
467
423
|
|
|
468
424
|
if token:
|
|
@@ -506,7 +462,14 @@ def _add_cors_headers(request, response):
|
|
|
506
462
|
def _node_display_name(node: Node) -> str:
|
|
507
463
|
"""Return a human-friendly name for ``node`` suitable for messaging."""
|
|
508
464
|
|
|
509
|
-
for attr in (
|
|
465
|
+
for attr in (
|
|
466
|
+
"hostname",
|
|
467
|
+
"network_hostname",
|
|
468
|
+
"public_endpoint",
|
|
469
|
+
"address",
|
|
470
|
+
"ipv6_address",
|
|
471
|
+
"ipv4_address",
|
|
472
|
+
):
|
|
510
473
|
value = getattr(node, attr, "") or ""
|
|
511
474
|
value = value.strip()
|
|
512
475
|
if value:
|
|
@@ -558,10 +521,13 @@ def register_node(request):
|
|
|
558
521
|
else:
|
|
559
522
|
features = data.get("features")
|
|
560
523
|
|
|
561
|
-
hostname = data.get("hostname")
|
|
562
|
-
address = data.get("address")
|
|
524
|
+
hostname = (data.get("hostname") or "").strip()
|
|
525
|
+
address = (data.get("address") or "").strip()
|
|
526
|
+
network_hostname = (data.get("network_hostname") or "").strip()
|
|
527
|
+
ipv4_address = (data.get("ipv4_address") or "").strip()
|
|
528
|
+
ipv6_address = (data.get("ipv6_address") or "").strip()
|
|
563
529
|
port = data.get("port", 8000)
|
|
564
|
-
mac_address = data.get("mac_address")
|
|
530
|
+
mac_address = (data.get("mac_address") or "").strip()
|
|
565
531
|
public_key = data.get("public_key")
|
|
566
532
|
token = data.get("token")
|
|
567
533
|
signature = data.get("signature")
|
|
@@ -577,12 +543,27 @@ def register_node(request):
|
|
|
577
543
|
Node.normalize_relation(raw_relation) if relation_present else None
|
|
578
544
|
)
|
|
579
545
|
|
|
580
|
-
if not hostname or not
|
|
546
|
+
if not hostname or not mac_address:
|
|
547
|
+
response = JsonResponse(
|
|
548
|
+
{"detail": "hostname and mac_address required"}, status=400
|
|
549
|
+
)
|
|
550
|
+
return _add_cors_headers(request, response)
|
|
551
|
+
|
|
552
|
+
if not any([address, network_hostname, ipv4_address, ipv6_address]):
|
|
581
553
|
response = JsonResponse(
|
|
582
|
-
{
|
|
554
|
+
{
|
|
555
|
+
"detail": "at least one of address, network_hostname, "
|
|
556
|
+
"ipv4_address or ipv6_address must be provided",
|
|
557
|
+
},
|
|
558
|
+
status=400,
|
|
583
559
|
)
|
|
584
560
|
return _add_cors_headers(request, response)
|
|
585
561
|
|
|
562
|
+
try:
|
|
563
|
+
port = int(port)
|
|
564
|
+
except (TypeError, ValueError):
|
|
565
|
+
port = 8000
|
|
566
|
+
|
|
586
567
|
verified = False
|
|
587
568
|
if public_key and token and signature:
|
|
588
569
|
try:
|
|
@@ -603,9 +584,15 @@ def register_node(request):
|
|
|
603
584
|
return _add_cors_headers(request, response)
|
|
604
585
|
|
|
605
586
|
mac_address = mac_address.lower()
|
|
587
|
+
address_value = address or None
|
|
588
|
+
ipv4_value = ipv4_address or None
|
|
589
|
+
ipv6_value = ipv6_address or None
|
|
606
590
|
defaults = {
|
|
607
591
|
"hostname": hostname,
|
|
608
|
-
"
|
|
592
|
+
"network_hostname": network_hostname,
|
|
593
|
+
"address": address_value,
|
|
594
|
+
"ipv4_address": ipv4_value,
|
|
595
|
+
"ipv6_address": ipv6_value,
|
|
609
596
|
"port": port,
|
|
610
597
|
}
|
|
611
598
|
role_name = str(data.get("role") or data.get("role_name") or "").strip()
|
|
@@ -630,10 +617,18 @@ def register_node(request):
|
|
|
630
617
|
if not created:
|
|
631
618
|
previous_version = (node.installed_version or "").strip()
|
|
632
619
|
previous_revision = (node.installed_revision or "").strip()
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
620
|
+
update_fields = []
|
|
621
|
+
for field, value in (
|
|
622
|
+
("hostname", hostname),
|
|
623
|
+
("network_hostname", network_hostname),
|
|
624
|
+
("address", address_value),
|
|
625
|
+
("ipv4_address", ipv4_value),
|
|
626
|
+
("ipv6_address", ipv6_value),
|
|
627
|
+
("port", port),
|
|
628
|
+
):
|
|
629
|
+
if getattr(node, field) != value:
|
|
630
|
+
setattr(node, field, value)
|
|
631
|
+
update_fields.append(field)
|
|
637
632
|
if verified:
|
|
638
633
|
node.public_key = public_key
|
|
639
634
|
update_fields.append("public_key")
|
|
@@ -651,7 +646,8 @@ def register_node(request):
|
|
|
651
646
|
if desired_role and node.role_id != desired_role.id:
|
|
652
647
|
node.role = desired_role
|
|
653
648
|
update_fields.append("role")
|
|
654
|
-
|
|
649
|
+
if update_fields:
|
|
650
|
+
node.save(update_fields=update_fields)
|
|
655
651
|
current_version = (node.installed_version or "").strip()
|
|
656
652
|
current_revision = (node.installed_revision or "").strip()
|
|
657
653
|
node_information_updated.send(
|
|
@@ -866,7 +862,7 @@ def network_chargers(request):
|
|
|
866
862
|
filters |= Q(charger_id=serial, connector_id=connector_value)
|
|
867
863
|
qs = qs.filter(filters)
|
|
868
864
|
|
|
869
|
-
chargers = [
|
|
865
|
+
chargers = [serialize_charger_for_network(charger) for charger in qs]
|
|
870
866
|
|
|
871
867
|
include_transactions = bool(body.get("include_transactions"))
|
|
872
868
|
response_data: dict[str, object] = {"chargers": chargers}
|
|
@@ -883,6 +879,55 @@ def network_chargers(request):
|
|
|
883
879
|
return JsonResponse(response_data)
|
|
884
880
|
|
|
885
881
|
|
|
882
|
+
@csrf_exempt
|
|
883
|
+
def forward_chargers(request):
|
|
884
|
+
"""Receive forwarded charger metadata and transactions from trusted peers."""
|
|
885
|
+
|
|
886
|
+
if request.method != "POST":
|
|
887
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
888
|
+
|
|
889
|
+
try:
|
|
890
|
+
body = json.loads(request.body.decode() or "{}")
|
|
891
|
+
except json.JSONDecodeError:
|
|
892
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
893
|
+
|
|
894
|
+
requester = body.get("requester")
|
|
895
|
+
if not requester:
|
|
896
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
897
|
+
|
|
898
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
899
|
+
requester_public_key = _clean_requester_hint(
|
|
900
|
+
body.get("requester_public_key"), strip=False
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
node, error_response = _load_signed_node(
|
|
904
|
+
request,
|
|
905
|
+
requester,
|
|
906
|
+
mac_address=requester_mac,
|
|
907
|
+
public_key=requester_public_key,
|
|
908
|
+
)
|
|
909
|
+
if error_response is not None:
|
|
910
|
+
return error_response
|
|
911
|
+
|
|
912
|
+
processed = 0
|
|
913
|
+
chargers_payload = body.get("chargers", [])
|
|
914
|
+
if not isinstance(chargers_payload, list):
|
|
915
|
+
chargers_payload = []
|
|
916
|
+
for entry in chargers_payload:
|
|
917
|
+
if not isinstance(entry, Mapping):
|
|
918
|
+
continue
|
|
919
|
+
charger = apply_remote_charger_payload(node, entry)
|
|
920
|
+
if charger:
|
|
921
|
+
processed += 1
|
|
922
|
+
|
|
923
|
+
imported = 0
|
|
924
|
+
transactions_payload = body.get("transactions")
|
|
925
|
+
if isinstance(transactions_payload, Mapping):
|
|
926
|
+
imported = sync_transactions_payload(transactions_payload)
|
|
927
|
+
|
|
928
|
+
return JsonResponse({"status": "ok", "chargers": processed, "transactions": imported})
|
|
929
|
+
|
|
930
|
+
|
|
886
931
|
def _require_local_origin(charger: Charger) -> bool:
|
|
887
932
|
local = Node.get_local()
|
|
888
933
|
if not local:
|
|
@@ -1561,7 +1606,10 @@ def public_node_endpoint(request, endpoint):
|
|
|
1561
1606
|
if request.method == "GET":
|
|
1562
1607
|
data = {
|
|
1563
1608
|
"hostname": node.hostname,
|
|
1564
|
-
"
|
|
1609
|
+
"network_hostname": node.network_hostname,
|
|
1610
|
+
"address": node.address or node.get_primary_contact(),
|
|
1611
|
+
"ipv4_address": node.ipv4_address,
|
|
1612
|
+
"ipv6_address": node.ipv6_address,
|
|
1565
1613
|
"port": node.port,
|
|
1566
1614
|
"badge_color": node.badge_color,
|
|
1567
1615
|
"last_seen": node.last_seen,
|
ocpp/admin.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
from django.shortcuts import redirect
|
|
11
11
|
from django.utils import formats, timezone, translation
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
12
13
|
from django.utils.dateparse import parse_datetime
|
|
13
14
|
from django.utils.html import format_html
|
|
14
15
|
from django.urls import path
|
|
@@ -21,6 +22,8 @@ import requests
|
|
|
21
22
|
from requests import RequestException
|
|
22
23
|
from cryptography.hazmat.primitives import hashes
|
|
23
24
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
25
|
+
from django.db import transaction
|
|
26
|
+
from django.core.exceptions import ValidationError
|
|
24
27
|
|
|
25
28
|
from .models import (
|
|
26
29
|
Charger,
|
|
@@ -30,6 +33,7 @@ from .models import (
|
|
|
30
33
|
Transaction,
|
|
31
34
|
Location,
|
|
32
35
|
DataTransferMessage,
|
|
36
|
+
CPReservation,
|
|
33
37
|
)
|
|
34
38
|
from .simulator import ChargePointSimulator
|
|
35
39
|
from . import store
|
|
@@ -74,6 +78,43 @@ class TransactionImportForm(forms.Form):
|
|
|
74
78
|
file = forms.FileField()
|
|
75
79
|
|
|
76
80
|
|
|
81
|
+
class CPReservationForm(forms.ModelForm):
|
|
82
|
+
class Meta:
|
|
83
|
+
model = CPReservation
|
|
84
|
+
fields = [
|
|
85
|
+
"location",
|
|
86
|
+
"account",
|
|
87
|
+
"rfid",
|
|
88
|
+
"id_tag",
|
|
89
|
+
"start_time",
|
|
90
|
+
"duration_minutes",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def clean(self):
|
|
94
|
+
cleaned = super().clean()
|
|
95
|
+
instance = self.instance
|
|
96
|
+
for field in self.Meta.fields:
|
|
97
|
+
if field in cleaned:
|
|
98
|
+
setattr(instance, field, cleaned[field])
|
|
99
|
+
try:
|
|
100
|
+
instance.allocate_connector(force=bool(instance.pk))
|
|
101
|
+
except ValidationError as exc:
|
|
102
|
+
if exc.message_dict:
|
|
103
|
+
for field, errors in exc.message_dict.items():
|
|
104
|
+
for error in errors:
|
|
105
|
+
self.add_error(field, error)
|
|
106
|
+
raise forms.ValidationError(
|
|
107
|
+
_("Unable to allocate a connector for the selected time window.")
|
|
108
|
+
)
|
|
109
|
+
raise forms.ValidationError(exc.messages or [str(exc)])
|
|
110
|
+
if not instance.id_tag_value:
|
|
111
|
+
message = _("Select an RFID or provide an idTag for the reservation.")
|
|
112
|
+
self.add_error("id_tag", message)
|
|
113
|
+
self.add_error("rfid", message)
|
|
114
|
+
raise forms.ValidationError(message)
|
|
115
|
+
return cleaned
|
|
116
|
+
|
|
117
|
+
|
|
77
118
|
class LogViewAdminMixin:
|
|
78
119
|
"""Mixin providing an admin view to display charger or simulator logs."""
|
|
79
120
|
|
|
@@ -218,6 +259,7 @@ class LocationAdmin(EntityModelAdmin):
|
|
|
218
259
|
form = LocationAdminForm
|
|
219
260
|
list_display = ("name", "zone", "contract_type", "latitude", "longitude")
|
|
220
261
|
change_form_template = "admin/ocpp/location/change_form.html"
|
|
262
|
+
search_fields = ("name",)
|
|
221
263
|
|
|
222
264
|
|
|
223
265
|
@admin.register(DataTransferMessage)
|
|
@@ -258,6 +300,130 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
|
|
|
258
300
|
)
|
|
259
301
|
|
|
260
302
|
|
|
303
|
+
@admin.register(CPReservation)
|
|
304
|
+
class CPReservationAdmin(EntityModelAdmin):
|
|
305
|
+
form = CPReservationForm
|
|
306
|
+
list_display = (
|
|
307
|
+
"location",
|
|
308
|
+
"connector_side_display",
|
|
309
|
+
"start_time",
|
|
310
|
+
"end_time_display",
|
|
311
|
+
"account",
|
|
312
|
+
"id_tag_display",
|
|
313
|
+
"evcs_status",
|
|
314
|
+
"evcs_confirmed",
|
|
315
|
+
)
|
|
316
|
+
list_filter = ("location", "evcs_confirmed")
|
|
317
|
+
search_fields = (
|
|
318
|
+
"location__name",
|
|
319
|
+
"connector__charger_id",
|
|
320
|
+
"connector__display_name",
|
|
321
|
+
"account__name",
|
|
322
|
+
"id_tag",
|
|
323
|
+
"rfid__rfid",
|
|
324
|
+
)
|
|
325
|
+
date_hierarchy = "start_time"
|
|
326
|
+
ordering = ("-start_time",)
|
|
327
|
+
autocomplete_fields = ("location", "account", "rfid")
|
|
328
|
+
readonly_fields = (
|
|
329
|
+
"connector_identity",
|
|
330
|
+
"connector_side_display",
|
|
331
|
+
"evcs_status",
|
|
332
|
+
"evcs_error",
|
|
333
|
+
"evcs_confirmed",
|
|
334
|
+
"evcs_confirmed_at",
|
|
335
|
+
"ocpp_message_id",
|
|
336
|
+
"created_on",
|
|
337
|
+
"updated_on",
|
|
338
|
+
)
|
|
339
|
+
fieldsets = (
|
|
340
|
+
(
|
|
341
|
+
None,
|
|
342
|
+
{
|
|
343
|
+
"fields": (
|
|
344
|
+
"location",
|
|
345
|
+
"account",
|
|
346
|
+
"rfid",
|
|
347
|
+
"id_tag",
|
|
348
|
+
"start_time",
|
|
349
|
+
"duration_minutes",
|
|
350
|
+
)
|
|
351
|
+
},
|
|
352
|
+
),
|
|
353
|
+
(
|
|
354
|
+
_("Assigned connector"),
|
|
355
|
+
{"fields": ("connector_identity", "connector_side_display")},
|
|
356
|
+
),
|
|
357
|
+
(
|
|
358
|
+
_("EVCS response"),
|
|
359
|
+
{
|
|
360
|
+
"fields": (
|
|
361
|
+
"evcs_confirmed",
|
|
362
|
+
"evcs_status",
|
|
363
|
+
"evcs_confirmed_at",
|
|
364
|
+
"evcs_error",
|
|
365
|
+
"ocpp_message_id",
|
|
366
|
+
)
|
|
367
|
+
},
|
|
368
|
+
),
|
|
369
|
+
(
|
|
370
|
+
_("Metadata"),
|
|
371
|
+
{"fields": ("created_on", "updated_on")},
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def save_model(self, request, obj, form, change):
|
|
376
|
+
trigger_fields = {
|
|
377
|
+
"start_time",
|
|
378
|
+
"duration_minutes",
|
|
379
|
+
"location",
|
|
380
|
+
"id_tag",
|
|
381
|
+
"rfid",
|
|
382
|
+
"account",
|
|
383
|
+
}
|
|
384
|
+
changed_data = set(getattr(form, "changed_data", []))
|
|
385
|
+
should_send = not change or bool(trigger_fields.intersection(changed_data))
|
|
386
|
+
with transaction.atomic():
|
|
387
|
+
super().save_model(request, obj, form, change)
|
|
388
|
+
if should_send:
|
|
389
|
+
try:
|
|
390
|
+
obj.send_reservation_request()
|
|
391
|
+
except ValidationError as exc:
|
|
392
|
+
raise ValidationError(exc.message_dict or exc.messages or str(exc))
|
|
393
|
+
else:
|
|
394
|
+
self.message_user(
|
|
395
|
+
request,
|
|
396
|
+
_("Reservation request sent to %(connector)s.")
|
|
397
|
+
% {"connector": self.connector_identity(obj)},
|
|
398
|
+
messages.SUCCESS,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@admin.display(description=_("Connector"), ordering="connector__connector_id")
|
|
402
|
+
def connector_side_display(self, obj):
|
|
403
|
+
return obj.connector_label or "-"
|
|
404
|
+
|
|
405
|
+
@admin.display(description=_("Connector identity"))
|
|
406
|
+
def connector_identity(self, obj):
|
|
407
|
+
if obj.connector_id:
|
|
408
|
+
return obj.connector.identity_slug()
|
|
409
|
+
return "-"
|
|
410
|
+
|
|
411
|
+
@admin.display(description=_("End time"))
|
|
412
|
+
def end_time_display(self, obj):
|
|
413
|
+
try:
|
|
414
|
+
value = timezone.localtime(obj.end_time)
|
|
415
|
+
except Exception:
|
|
416
|
+
value = obj.end_time
|
|
417
|
+
if not value:
|
|
418
|
+
return "-"
|
|
419
|
+
return formats.date_format(value, "DATETIME_FORMAT")
|
|
420
|
+
|
|
421
|
+
@admin.display(description=_("Id tag"))
|
|
422
|
+
def id_tag_display(self, obj):
|
|
423
|
+
value = obj.id_tag_value
|
|
424
|
+
return value or "-"
|
|
425
|
+
|
|
426
|
+
|
|
261
427
|
@admin.register(Charger)
|
|
262
428
|
class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
263
429
|
_REMOTE_DATETIME_FIELDS = {
|
|
@@ -327,6 +493,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
327
493
|
"fields": (
|
|
328
494
|
"node_origin",
|
|
329
495
|
"manager_node",
|
|
496
|
+
"forwarded_to",
|
|
497
|
+
"forwarding_watermark",
|
|
330
498
|
"allow_remote",
|
|
331
499
|
"export_transactions",
|
|
332
500
|
"last_online_at",
|
|
@@ -361,6 +529,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
361
529
|
"availability_request_status_at",
|
|
362
530
|
"availability_request_details",
|
|
363
531
|
"configuration",
|
|
532
|
+
"forwarded_to",
|
|
533
|
+
"forwarding_watermark",
|
|
364
534
|
"last_online_at",
|
|
365
535
|
)
|
|
366
536
|
list_display = (
|
|
@@ -429,7 +599,15 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
429
599
|
)
|
|
430
600
|
return False, {}
|
|
431
601
|
origin = charger.node_origin
|
|
432
|
-
if not origin.
|
|
602
|
+
if not origin.port:
|
|
603
|
+
self.message_user(
|
|
604
|
+
request,
|
|
605
|
+
f"{charger}: remote node port is not configured.",
|
|
606
|
+
level=messages.ERROR,
|
|
607
|
+
)
|
|
608
|
+
return False, {}
|
|
609
|
+
|
|
610
|
+
if not origin.get_remote_host_candidates():
|
|
433
611
|
self.message_user(
|
|
434
612
|
request,
|
|
435
613
|
f"{charger}: remote node connection details are incomplete.",
|
|
@@ -465,7 +643,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
465
643
|
)
|
|
466
644
|
return False, {}
|
|
467
645
|
|
|
468
|
-
url =
|
|
646
|
+
url = next(
|
|
647
|
+
origin.iter_remote_urls("/nodes/network/chargers/action/"),
|
|
648
|
+
"",
|
|
649
|
+
)
|
|
650
|
+
if not url:
|
|
651
|
+
self.message_user(
|
|
652
|
+
request,
|
|
653
|
+
f"{charger}: no reachable hosts were reported for the remote node.",
|
|
654
|
+
level=messages.ERROR,
|
|
655
|
+
)
|
|
656
|
+
return False, {}
|
|
469
657
|
try:
|
|
470
658
|
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
471
659
|
except RequestException as exc:
|
ocpp/consumers.py
CHANGED
|
@@ -26,6 +26,7 @@ from .models import (
|
|
|
26
26
|
ChargerConfiguration,
|
|
27
27
|
MeterValue,
|
|
28
28
|
DataTransferMessage,
|
|
29
|
+
CPReservation,
|
|
29
30
|
)
|
|
30
31
|
from .reference_utils import host_is_local_loopback
|
|
31
32
|
from .evcs_discovery import (
|
|
@@ -903,6 +904,43 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
903
904
|
payload=payload_data,
|
|
904
905
|
)
|
|
905
906
|
return
|
|
907
|
+
if action == "ReserveNow":
|
|
908
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
909
|
+
message = "ReserveNow result"
|
|
910
|
+
if status_value:
|
|
911
|
+
message += f": status={status_value}"
|
|
912
|
+
store.add_log(log_key, message, log_type="charger")
|
|
913
|
+
|
|
914
|
+
reservation_pk = metadata.get("reservation_pk")
|
|
915
|
+
|
|
916
|
+
def _apply():
|
|
917
|
+
if not reservation_pk:
|
|
918
|
+
return
|
|
919
|
+
reservation = CPReservation.objects.filter(pk=reservation_pk).first()
|
|
920
|
+
if not reservation:
|
|
921
|
+
return
|
|
922
|
+
reservation.evcs_status = status_value
|
|
923
|
+
reservation.evcs_error = ""
|
|
924
|
+
confirmed = status_value.casefold() == "accepted"
|
|
925
|
+
reservation.evcs_confirmed = confirmed
|
|
926
|
+
reservation.evcs_confirmed_at = timezone.now() if confirmed else None
|
|
927
|
+
reservation.save(
|
|
928
|
+
update_fields=[
|
|
929
|
+
"evcs_status",
|
|
930
|
+
"evcs_error",
|
|
931
|
+
"evcs_confirmed",
|
|
932
|
+
"evcs_confirmed_at",
|
|
933
|
+
"updated_on",
|
|
934
|
+
]
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
await database_sync_to_async(_apply)()
|
|
938
|
+
store.record_pending_call_result(
|
|
939
|
+
message_id,
|
|
940
|
+
metadata=metadata,
|
|
941
|
+
payload=payload_data,
|
|
942
|
+
)
|
|
943
|
+
return
|
|
906
944
|
if action == "RemoteStartTransaction":
|
|
907
945
|
status_value = str(payload_data.get("status") or "").strip()
|
|
908
946
|
message = "RemoteStartTransaction result"
|
|
@@ -1084,6 +1122,66 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
1084
1122
|
error_details=details,
|
|
1085
1123
|
)
|
|
1086
1124
|
return
|
|
1125
|
+
if action == "ReserveNow":
|
|
1126
|
+
parts: list[str] = []
|
|
1127
|
+
code_text = (error_code or "").strip() if error_code else ""
|
|
1128
|
+
if code_text:
|
|
1129
|
+
parts.append(f"code={code_text}")
|
|
1130
|
+
description_text = (description or "").strip() if description else ""
|
|
1131
|
+
if description_text:
|
|
1132
|
+
parts.append(f"description={description_text}")
|
|
1133
|
+
details_text = ""
|
|
1134
|
+
if details:
|
|
1135
|
+
try:
|
|
1136
|
+
details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
|
1137
|
+
except TypeError:
|
|
1138
|
+
details_text = str(details)
|
|
1139
|
+
if details_text:
|
|
1140
|
+
parts.append(f"details={details_text}")
|
|
1141
|
+
message = "ReserveNow error"
|
|
1142
|
+
if parts:
|
|
1143
|
+
message += ": " + ", ".join(parts)
|
|
1144
|
+
store.add_log(log_key, message, log_type="charger")
|
|
1145
|
+
|
|
1146
|
+
reservation_pk = metadata.get("reservation_pk")
|
|
1147
|
+
|
|
1148
|
+
def _apply():
|
|
1149
|
+
if not reservation_pk:
|
|
1150
|
+
return
|
|
1151
|
+
reservation = CPReservation.objects.filter(pk=reservation_pk).first()
|
|
1152
|
+
if not reservation:
|
|
1153
|
+
return
|
|
1154
|
+
summary_parts = []
|
|
1155
|
+
if code_text:
|
|
1156
|
+
summary_parts.append(code_text)
|
|
1157
|
+
if description_text:
|
|
1158
|
+
summary_parts.append(description_text)
|
|
1159
|
+
if details_text:
|
|
1160
|
+
summary_parts.append(details_text)
|
|
1161
|
+
reservation.evcs_status = ""
|
|
1162
|
+
reservation.evcs_error = "; ".join(summary_parts)
|
|
1163
|
+
reservation.evcs_confirmed = False
|
|
1164
|
+
reservation.evcs_confirmed_at = None
|
|
1165
|
+
reservation.save(
|
|
1166
|
+
update_fields=[
|
|
1167
|
+
"evcs_status",
|
|
1168
|
+
"evcs_error",
|
|
1169
|
+
"evcs_confirmed",
|
|
1170
|
+
"evcs_confirmed_at",
|
|
1171
|
+
"updated_on",
|
|
1172
|
+
]
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
await database_sync_to_async(_apply)()
|
|
1176
|
+
store.record_pending_call_result(
|
|
1177
|
+
message_id,
|
|
1178
|
+
metadata=metadata,
|
|
1179
|
+
success=False,
|
|
1180
|
+
error_code=error_code,
|
|
1181
|
+
error_description=description,
|
|
1182
|
+
error_details=details,
|
|
1183
|
+
)
|
|
1184
|
+
return
|
|
1087
1185
|
if action == "RemoteStartTransaction":
|
|
1088
1186
|
message = "RemoteStartTransaction error"
|
|
1089
1187
|
if error_code:
|