arthexis 0.1.23__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.23.dist-info → arthexis-0.1.25.dist-info}/METADATA +39 -18
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/RECORD +31 -30
- config/settings.py +7 -0
- config/urls.py +2 -0
- core/admin.py +140 -213
- core/backends.py +3 -1
- core/models.py +612 -207
- core/system.py +67 -2
- core/tasks.py +25 -0
- core/views.py +0 -3
- nodes/admin.py +465 -292
- nodes/models.py +299 -23
- nodes/tasks.py +13 -16
- nodes/tests.py +291 -130
- nodes/urls.py +11 -0
- nodes/utils.py +9 -2
- nodes/views.py +588 -20
- ocpp/admin.py +729 -175
- ocpp/consumers.py +98 -0
- ocpp/models.py +299 -0
- ocpp/network.py +398 -0
- ocpp/tasks.py +177 -1
- 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 +64 -32
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.25.dist-info}/top_level.txt +0 -0
nodes/views.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import re
|
|
5
5
|
import secrets
|
|
6
6
|
import socket
|
|
7
|
+
import uuid
|
|
7
8
|
from collections.abc import Mapping
|
|
8
9
|
from datetime import timedelta
|
|
9
10
|
|
|
@@ -19,6 +20,7 @@ from django.http.request import split_domain_port
|
|
|
19
20
|
from django.shortcuts import get_object_or_404, redirect
|
|
20
21
|
from django.urls import reverse
|
|
21
22
|
from django.utils import timezone
|
|
23
|
+
from django.utils.dateparse import parse_datetime
|
|
22
24
|
from django.utils.cache import patch_vary_headers
|
|
23
25
|
from django.utils.http import url_has_allowed_host_and_scheme
|
|
24
26
|
from django.views.decorators.csrf import csrf_exempt
|
|
@@ -30,11 +32,28 @@ from utils.api import api_login_required
|
|
|
30
32
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
31
33
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
32
34
|
|
|
35
|
+
from django.db.models import Q
|
|
36
|
+
|
|
33
37
|
from core.models import RFID
|
|
38
|
+
from ocpp import store
|
|
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
|
+
)
|
|
45
|
+
from ocpp.transactions_io import export_transactions
|
|
46
|
+
from asgiref.sync import async_to_sync
|
|
34
47
|
|
|
35
48
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
36
49
|
|
|
37
|
-
from .models import
|
|
50
|
+
from .models import (
|
|
51
|
+
Node,
|
|
52
|
+
NetMessage,
|
|
53
|
+
PendingNetMessage,
|
|
54
|
+
NodeRole,
|
|
55
|
+
node_information_updated,
|
|
56
|
+
)
|
|
38
57
|
from .utils import capture_screenshot, save_screenshot
|
|
39
58
|
|
|
40
59
|
|
|
@@ -167,6 +186,37 @@ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
|
|
|
167
186
|
user.user_permissions.set(perm_objs)
|
|
168
187
|
|
|
169
188
|
|
|
189
|
+
def _normalize_requested_chargers(values) -> list[tuple[str, int | None, object]]:
|
|
190
|
+
if not isinstance(values, list):
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
normalized: list[tuple[str, int | None, object]] = []
|
|
194
|
+
for entry in values:
|
|
195
|
+
if not isinstance(entry, Mapping):
|
|
196
|
+
continue
|
|
197
|
+
serial = Charger.normalize_serial(entry.get("charger_id"))
|
|
198
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
199
|
+
continue
|
|
200
|
+
connector = entry.get("connector_id")
|
|
201
|
+
if connector in ("", None):
|
|
202
|
+
connector_value = None
|
|
203
|
+
elif isinstance(connector, int):
|
|
204
|
+
connector_value = connector
|
|
205
|
+
else:
|
|
206
|
+
try:
|
|
207
|
+
connector_value = int(str(connector))
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
connector_value = None
|
|
210
|
+
since_raw = entry.get("since")
|
|
211
|
+
since_dt = None
|
|
212
|
+
if isinstance(since_raw, str):
|
|
213
|
+
since_dt = parse_datetime(since_raw)
|
|
214
|
+
if since_dt is not None and timezone.is_naive(since_dt):
|
|
215
|
+
since_dt = timezone.make_aware(since_dt, timezone.get_current_timezone())
|
|
216
|
+
normalized.append((serial, connector_value, since_dt))
|
|
217
|
+
return normalized
|
|
218
|
+
|
|
219
|
+
|
|
170
220
|
def _get_client_ip(request):
|
|
171
221
|
"""Return the client IP from the request headers."""
|
|
172
222
|
|
|
@@ -312,7 +362,7 @@ def _get_advertised_address(request, node) -> str:
|
|
|
312
362
|
host_ip = _get_host_ip(request)
|
|
313
363
|
if host_ip:
|
|
314
364
|
return host_ip
|
|
315
|
-
return node.address
|
|
365
|
+
return node.get_primary_contact() or node.address or node.hostname
|
|
316
366
|
|
|
317
367
|
|
|
318
368
|
@api_login_required
|
|
@@ -322,7 +372,10 @@ def node_list(request):
|
|
|
322
372
|
nodes = [
|
|
323
373
|
{
|
|
324
374
|
"hostname": node.hostname,
|
|
375
|
+
"network_hostname": node.network_hostname,
|
|
325
376
|
"address": node.address,
|
|
377
|
+
"ipv4_address": node.ipv4_address,
|
|
378
|
+
"ipv6_address": node.ipv6_address,
|
|
326
379
|
"port": node.port,
|
|
327
380
|
"last_seen": node.last_seen,
|
|
328
381
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
@@ -350,21 +403,22 @@ def node_info(request):
|
|
|
350
403
|
advertised_port = host_port
|
|
351
404
|
if host_domain:
|
|
352
405
|
hostname = host_domain
|
|
353
|
-
|
|
354
|
-
address = advertised_address
|
|
355
|
-
else:
|
|
356
|
-
address = host_domain
|
|
406
|
+
address = advertised_address or host_domain
|
|
357
407
|
else:
|
|
358
408
|
hostname = node.hostname
|
|
359
|
-
address = advertised_address
|
|
409
|
+
address = advertised_address or node.address or node.network_hostname or ""
|
|
360
410
|
data = {
|
|
361
411
|
"hostname": hostname,
|
|
412
|
+
"network_hostname": node.network_hostname,
|
|
362
413
|
"address": address,
|
|
414
|
+
"ipv4_address": node.ipv4_address,
|
|
415
|
+
"ipv6_address": node.ipv6_address,
|
|
363
416
|
"port": advertised_port,
|
|
364
417
|
"mac_address": node.mac_address,
|
|
365
418
|
"public_key": node.public_key,
|
|
366
419
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
367
420
|
"role": node.role.name if node.role_id else "",
|
|
421
|
+
"contact_hosts": node.get_remote_host_candidates(),
|
|
368
422
|
}
|
|
369
423
|
|
|
370
424
|
if token:
|
|
@@ -408,7 +462,14 @@ def _add_cors_headers(request, response):
|
|
|
408
462
|
def _node_display_name(node: Node) -> str:
|
|
409
463
|
"""Return a human-friendly name for ``node`` suitable for messaging."""
|
|
410
464
|
|
|
411
|
-
for attr in (
|
|
465
|
+
for attr in (
|
|
466
|
+
"hostname",
|
|
467
|
+
"network_hostname",
|
|
468
|
+
"public_endpoint",
|
|
469
|
+
"address",
|
|
470
|
+
"ipv6_address",
|
|
471
|
+
"ipv4_address",
|
|
472
|
+
):
|
|
412
473
|
value = getattr(node, attr, "") or ""
|
|
413
474
|
value = value.strip()
|
|
414
475
|
if value:
|
|
@@ -460,10 +521,13 @@ def register_node(request):
|
|
|
460
521
|
else:
|
|
461
522
|
features = data.get("features")
|
|
462
523
|
|
|
463
|
-
hostname = data.get("hostname")
|
|
464
|
-
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()
|
|
465
529
|
port = data.get("port", 8000)
|
|
466
|
-
mac_address = data.get("mac_address")
|
|
530
|
+
mac_address = (data.get("mac_address") or "").strip()
|
|
467
531
|
public_key = data.get("public_key")
|
|
468
532
|
token = data.get("token")
|
|
469
533
|
signature = data.get("signature")
|
|
@@ -479,12 +543,27 @@ def register_node(request):
|
|
|
479
543
|
Node.normalize_relation(raw_relation) if relation_present else None
|
|
480
544
|
)
|
|
481
545
|
|
|
482
|
-
if not hostname or not
|
|
546
|
+
if not hostname or not mac_address:
|
|
483
547
|
response = JsonResponse(
|
|
484
|
-
{"detail": "hostname
|
|
548
|
+
{"detail": "hostname and mac_address required"}, status=400
|
|
485
549
|
)
|
|
486
550
|
return _add_cors_headers(request, response)
|
|
487
551
|
|
|
552
|
+
if not any([address, network_hostname, ipv4_address, ipv6_address]):
|
|
553
|
+
response = JsonResponse(
|
|
554
|
+
{
|
|
555
|
+
"detail": "at least one of address, network_hostname, "
|
|
556
|
+
"ipv4_address or ipv6_address must be provided",
|
|
557
|
+
},
|
|
558
|
+
status=400,
|
|
559
|
+
)
|
|
560
|
+
return _add_cors_headers(request, response)
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
port = int(port)
|
|
564
|
+
except (TypeError, ValueError):
|
|
565
|
+
port = 8000
|
|
566
|
+
|
|
488
567
|
verified = False
|
|
489
568
|
if public_key and token and signature:
|
|
490
569
|
try:
|
|
@@ -505,9 +584,15 @@ def register_node(request):
|
|
|
505
584
|
return _add_cors_headers(request, response)
|
|
506
585
|
|
|
507
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
|
|
508
590
|
defaults = {
|
|
509
591
|
"hostname": hostname,
|
|
510
|
-
"
|
|
592
|
+
"network_hostname": network_hostname,
|
|
593
|
+
"address": address_value,
|
|
594
|
+
"ipv4_address": ipv4_value,
|
|
595
|
+
"ipv6_address": ipv6_value,
|
|
511
596
|
"port": port,
|
|
512
597
|
}
|
|
513
598
|
role_name = str(data.get("role") or data.get("role_name") or "").strip()
|
|
@@ -532,10 +617,18 @@ def register_node(request):
|
|
|
532
617
|
if not created:
|
|
533
618
|
previous_version = (node.installed_version or "").strip()
|
|
534
619
|
previous_revision = (node.installed_revision or "").strip()
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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)
|
|
539
632
|
if verified:
|
|
540
633
|
node.public_key = public_key
|
|
541
634
|
update_fields.append("public_key")
|
|
@@ -553,7 +646,8 @@ def register_node(request):
|
|
|
553
646
|
if desired_role and node.role_id != desired_role.id:
|
|
554
647
|
node.role = desired_role
|
|
555
648
|
update_fields.append("role")
|
|
556
|
-
|
|
649
|
+
if update_fields:
|
|
650
|
+
node.save(update_fields=update_fields)
|
|
557
651
|
current_version = (node.installed_version or "").strip()
|
|
558
652
|
current_revision = (node.installed_revision or "").strip()
|
|
559
653
|
node_information_updated.send(
|
|
@@ -722,6 +816,477 @@ def import_rfids(request):
|
|
|
722
816
|
)
|
|
723
817
|
|
|
724
818
|
|
|
819
|
+
@csrf_exempt
|
|
820
|
+
def network_chargers(request):
|
|
821
|
+
"""Return serialized charger information for trusted peers."""
|
|
822
|
+
|
|
823
|
+
if request.method != "POST":
|
|
824
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
body = json.loads(request.body.decode() or "{}")
|
|
828
|
+
except json.JSONDecodeError:
|
|
829
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
830
|
+
|
|
831
|
+
requester = body.get("requester")
|
|
832
|
+
if not requester:
|
|
833
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
834
|
+
|
|
835
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
836
|
+
requester_public_key = _clean_requester_hint(
|
|
837
|
+
body.get("requester_public_key"), strip=False
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
node, error_response = _load_signed_node(
|
|
841
|
+
request,
|
|
842
|
+
requester,
|
|
843
|
+
mac_address=requester_mac,
|
|
844
|
+
public_key=requester_public_key,
|
|
845
|
+
)
|
|
846
|
+
if error_response is not None:
|
|
847
|
+
return error_response
|
|
848
|
+
|
|
849
|
+
requested = _normalize_requested_chargers(body.get("chargers") or [])
|
|
850
|
+
|
|
851
|
+
qs = Charger.objects.all()
|
|
852
|
+
local_node = Node.get_local()
|
|
853
|
+
if local_node:
|
|
854
|
+
qs = qs.filter(Q(node_origin=local_node) | Q(node_origin__isnull=True))
|
|
855
|
+
|
|
856
|
+
if requested:
|
|
857
|
+
filters = Q()
|
|
858
|
+
for serial, connector_value, _ in requested:
|
|
859
|
+
if connector_value is None:
|
|
860
|
+
filters |= Q(charger_id=serial, connector_id__isnull=True)
|
|
861
|
+
else:
|
|
862
|
+
filters |= Q(charger_id=serial, connector_id=connector_value)
|
|
863
|
+
qs = qs.filter(filters)
|
|
864
|
+
|
|
865
|
+
chargers = [serialize_charger_for_network(charger) for charger in qs]
|
|
866
|
+
|
|
867
|
+
include_transactions = bool(body.get("include_transactions"))
|
|
868
|
+
response_data: dict[str, object] = {"chargers": chargers}
|
|
869
|
+
|
|
870
|
+
if include_transactions:
|
|
871
|
+
serials = [serial for serial, _, _ in requested] or list(
|
|
872
|
+
{charger["charger_id"] for charger in chargers}
|
|
873
|
+
)
|
|
874
|
+
since_values = [since for _, _, since in requested if since]
|
|
875
|
+
start = min(since_values) if since_values else None
|
|
876
|
+
tx_payload = export_transactions(start=start, chargers=serials or None)
|
|
877
|
+
response_data["transactions"] = tx_payload
|
|
878
|
+
|
|
879
|
+
return JsonResponse(response_data)
|
|
880
|
+
|
|
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
|
+
|
|
931
|
+
def _require_local_origin(charger: Charger) -> bool:
|
|
932
|
+
local = Node.get_local()
|
|
933
|
+
if not local:
|
|
934
|
+
return charger.node_origin_id is None
|
|
935
|
+
if charger.node_origin_id is None:
|
|
936
|
+
return True
|
|
937
|
+
return charger.node_origin_id == local.pk
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _send_trigger_status(
|
|
941
|
+
charger: Charger, payload: Mapping | None = None
|
|
942
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
943
|
+
connector_value = charger.connector_id
|
|
944
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
945
|
+
if ws is None:
|
|
946
|
+
return False, "no active connection", {}
|
|
947
|
+
payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
|
|
948
|
+
if connector_value is not None:
|
|
949
|
+
payload["connectorId"] = connector_value
|
|
950
|
+
message_id = uuid.uuid4().hex
|
|
951
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
952
|
+
try:
|
|
953
|
+
async_to_sync(ws.send)(msg)
|
|
954
|
+
except Exception as exc:
|
|
955
|
+
return False, f"failed to send TriggerMessage ({exc})", {}
|
|
956
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
957
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
958
|
+
store.register_pending_call(
|
|
959
|
+
message_id,
|
|
960
|
+
{
|
|
961
|
+
"action": "TriggerMessage",
|
|
962
|
+
"charger_id": charger.charger_id,
|
|
963
|
+
"connector_id": connector_value,
|
|
964
|
+
"log_key": log_key,
|
|
965
|
+
"trigger_target": "StatusNotification",
|
|
966
|
+
"trigger_connector": connector_value,
|
|
967
|
+
"requested_at": timezone.now(),
|
|
968
|
+
},
|
|
969
|
+
)
|
|
970
|
+
store.schedule_call_timeout(
|
|
971
|
+
message_id,
|
|
972
|
+
timeout=5.0,
|
|
973
|
+
action="TriggerMessage",
|
|
974
|
+
log_key=log_key,
|
|
975
|
+
message="TriggerMessage StatusNotification timed out",
|
|
976
|
+
)
|
|
977
|
+
return True, "requested status update", {}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _send_get_configuration(
|
|
981
|
+
charger: Charger, payload: Mapping | None = None
|
|
982
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
983
|
+
connector_value = charger.connector_id
|
|
984
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
985
|
+
if ws is None:
|
|
986
|
+
return False, "no active connection", {}
|
|
987
|
+
message_id = uuid.uuid4().hex
|
|
988
|
+
msg = json.dumps([2, message_id, "GetConfiguration", {}])
|
|
989
|
+
try:
|
|
990
|
+
async_to_sync(ws.send)(msg)
|
|
991
|
+
except Exception as exc:
|
|
992
|
+
return False, f"failed to send GetConfiguration ({exc})", {}
|
|
993
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
994
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
995
|
+
store.register_pending_call(
|
|
996
|
+
message_id,
|
|
997
|
+
{
|
|
998
|
+
"action": "GetConfiguration",
|
|
999
|
+
"charger_id": charger.charger_id,
|
|
1000
|
+
"connector_id": connector_value,
|
|
1001
|
+
"log_key": log_key,
|
|
1002
|
+
"requested_at": timezone.now(),
|
|
1003
|
+
},
|
|
1004
|
+
)
|
|
1005
|
+
store.schedule_call_timeout(
|
|
1006
|
+
message_id,
|
|
1007
|
+
timeout=5.0,
|
|
1008
|
+
action="GetConfiguration",
|
|
1009
|
+
log_key=log_key,
|
|
1010
|
+
message=(
|
|
1011
|
+
"GetConfiguration timed out: charger did not respond"
|
|
1012
|
+
" (operation may not be supported)"
|
|
1013
|
+
),
|
|
1014
|
+
)
|
|
1015
|
+
return True, "requested configuration update", {}
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _send_reset(
|
|
1019
|
+
charger: Charger, payload: Mapping | None = None
|
|
1020
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1021
|
+
connector_value = charger.connector_id
|
|
1022
|
+
tx = store.get_transaction(charger.charger_id, connector_value)
|
|
1023
|
+
if tx:
|
|
1024
|
+
return False, "active session in progress", {}
|
|
1025
|
+
message_id = uuid.uuid4().hex
|
|
1026
|
+
reset_type = None
|
|
1027
|
+
if payload:
|
|
1028
|
+
reset_type = payload.get("reset_type")
|
|
1029
|
+
msg = json.dumps(
|
|
1030
|
+
[2, message_id, "Reset", {"type": (reset_type or "Soft")}]
|
|
1031
|
+
)
|
|
1032
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1033
|
+
if ws is None:
|
|
1034
|
+
return False, "no active connection", {}
|
|
1035
|
+
try:
|
|
1036
|
+
async_to_sync(ws.send)(msg)
|
|
1037
|
+
except Exception as exc:
|
|
1038
|
+
return False, f"failed to send Reset ({exc})", {}
|
|
1039
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1040
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1041
|
+
store.register_pending_call(
|
|
1042
|
+
message_id,
|
|
1043
|
+
{
|
|
1044
|
+
"action": "Reset",
|
|
1045
|
+
"charger_id": charger.charger_id,
|
|
1046
|
+
"connector_id": connector_value,
|
|
1047
|
+
"log_key": log_key,
|
|
1048
|
+
"requested_at": timezone.now(),
|
|
1049
|
+
},
|
|
1050
|
+
)
|
|
1051
|
+
store.schedule_call_timeout(
|
|
1052
|
+
message_id,
|
|
1053
|
+
timeout=5.0,
|
|
1054
|
+
action="Reset",
|
|
1055
|
+
log_key=log_key,
|
|
1056
|
+
message="Reset timed out: charger did not respond",
|
|
1057
|
+
)
|
|
1058
|
+
return True, "reset requested", {}
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _toggle_rfid(
|
|
1062
|
+
charger: Charger, payload: Mapping | None = None
|
|
1063
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1064
|
+
enable = None
|
|
1065
|
+
if payload is not None:
|
|
1066
|
+
enable = payload.get("enable")
|
|
1067
|
+
if isinstance(enable, str):
|
|
1068
|
+
enable = enable.lower() in {"1", "true", "yes", "on"}
|
|
1069
|
+
elif isinstance(enable, (int, bool)):
|
|
1070
|
+
enable = bool(enable)
|
|
1071
|
+
if enable is None:
|
|
1072
|
+
enable = not charger.require_rfid
|
|
1073
|
+
enable_bool = bool(enable)
|
|
1074
|
+
Charger.objects.filter(pk=charger.pk).update(require_rfid=enable_bool)
|
|
1075
|
+
charger.require_rfid = enable_bool
|
|
1076
|
+
detail = "RFID authentication enabled" if enable_bool else "RFID authentication disabled"
|
|
1077
|
+
return True, detail, {"require_rfid": enable_bool}
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _change_availability_remote(
|
|
1081
|
+
charger: Charger, payload: Mapping | None = None
|
|
1082
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1083
|
+
availability_type = None
|
|
1084
|
+
if payload is not None:
|
|
1085
|
+
availability_type = payload.get("availability_type")
|
|
1086
|
+
availability_label = str(availability_type or "").strip()
|
|
1087
|
+
if availability_label not in {"Operative", "Inoperative"}:
|
|
1088
|
+
return False, "invalid availability type", {}
|
|
1089
|
+
connector_value = charger.connector_id
|
|
1090
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1091
|
+
if ws is None:
|
|
1092
|
+
return False, "no active connection", {}
|
|
1093
|
+
connector_id = connector_value if connector_value is not None else 0
|
|
1094
|
+
message_id = uuid.uuid4().hex
|
|
1095
|
+
msg = json.dumps(
|
|
1096
|
+
[
|
|
1097
|
+
2,
|
|
1098
|
+
message_id,
|
|
1099
|
+
"ChangeAvailability",
|
|
1100
|
+
{"connectorId": connector_id, "type": availability_label},
|
|
1101
|
+
]
|
|
1102
|
+
)
|
|
1103
|
+
try:
|
|
1104
|
+
async_to_sync(ws.send)(msg)
|
|
1105
|
+
except Exception as exc:
|
|
1106
|
+
return False, f"failed to send ChangeAvailability ({exc})", {}
|
|
1107
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1108
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1109
|
+
timestamp = timezone.now()
|
|
1110
|
+
store.register_pending_call(
|
|
1111
|
+
message_id,
|
|
1112
|
+
{
|
|
1113
|
+
"action": "ChangeAvailability",
|
|
1114
|
+
"charger_id": charger.charger_id,
|
|
1115
|
+
"connector_id": connector_value,
|
|
1116
|
+
"availability_type": availability_label,
|
|
1117
|
+
"requested_at": timestamp,
|
|
1118
|
+
},
|
|
1119
|
+
)
|
|
1120
|
+
updates = {
|
|
1121
|
+
"availability_requested_state": availability_label,
|
|
1122
|
+
"availability_requested_at": timestamp,
|
|
1123
|
+
"availability_request_status": "",
|
|
1124
|
+
"availability_request_status_at": None,
|
|
1125
|
+
"availability_request_details": "",
|
|
1126
|
+
}
|
|
1127
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1128
|
+
for field, value in updates.items():
|
|
1129
|
+
setattr(charger, field, value)
|
|
1130
|
+
return True, f"requested ChangeAvailability {availability_label}", updates
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _set_availability_state_remote(
|
|
1134
|
+
charger: Charger, payload: Mapping | None = None
|
|
1135
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1136
|
+
availability_state = None
|
|
1137
|
+
if payload is not None:
|
|
1138
|
+
availability_state = payload.get("availability_state")
|
|
1139
|
+
availability_label = str(availability_state or "").strip()
|
|
1140
|
+
if availability_label not in {"Operative", "Inoperative"}:
|
|
1141
|
+
return False, "invalid availability state", {}
|
|
1142
|
+
timestamp = timezone.now()
|
|
1143
|
+
updates = {
|
|
1144
|
+
"availability_state": availability_label,
|
|
1145
|
+
"availability_state_updated_at": timestamp,
|
|
1146
|
+
}
|
|
1147
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1148
|
+
for field, value in updates.items():
|
|
1149
|
+
setattr(charger, field, value)
|
|
1150
|
+
return True, f"availability marked {availability_label}", updates
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _remote_stop_transaction_remote(
|
|
1154
|
+
charger: Charger, payload: Mapping | None = None
|
|
1155
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1156
|
+
connector_value = charger.connector_id
|
|
1157
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1158
|
+
if ws is None:
|
|
1159
|
+
return False, "no active connection", {}
|
|
1160
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
1161
|
+
if tx_obj is None:
|
|
1162
|
+
return False, "no active transaction", {}
|
|
1163
|
+
message_id = uuid.uuid4().hex
|
|
1164
|
+
msg = json.dumps(
|
|
1165
|
+
[
|
|
1166
|
+
2,
|
|
1167
|
+
message_id,
|
|
1168
|
+
"RemoteStopTransaction",
|
|
1169
|
+
{"transactionId": tx_obj.pk},
|
|
1170
|
+
]
|
|
1171
|
+
)
|
|
1172
|
+
try:
|
|
1173
|
+
async_to_sync(ws.send)(msg)
|
|
1174
|
+
except Exception as exc:
|
|
1175
|
+
return False, f"failed to send RemoteStopTransaction ({exc})", {}
|
|
1176
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1177
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1178
|
+
store.register_pending_call(
|
|
1179
|
+
message_id,
|
|
1180
|
+
{
|
|
1181
|
+
"action": "RemoteStopTransaction",
|
|
1182
|
+
"charger_id": charger.charger_id,
|
|
1183
|
+
"connector_id": connector_value,
|
|
1184
|
+
"transaction_id": tx_obj.pk,
|
|
1185
|
+
"log_key": log_key,
|
|
1186
|
+
"requested_at": timezone.now(),
|
|
1187
|
+
},
|
|
1188
|
+
)
|
|
1189
|
+
return True, "remote stop requested", {}
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
REMOTE_ACTIONS = {
|
|
1193
|
+
"trigger-status": _send_trigger_status,
|
|
1194
|
+
"get-configuration": _send_get_configuration,
|
|
1195
|
+
"reset": _send_reset,
|
|
1196
|
+
"toggle-rfid": _toggle_rfid,
|
|
1197
|
+
"change-availability": _change_availability_remote,
|
|
1198
|
+
"set-availability-state": _set_availability_state_remote,
|
|
1199
|
+
"remote-stop": _remote_stop_transaction_remote,
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
@csrf_exempt
|
|
1204
|
+
def network_charger_action(request):
|
|
1205
|
+
"""Execute remote admin actions on behalf of trusted nodes."""
|
|
1206
|
+
|
|
1207
|
+
if request.method != "POST":
|
|
1208
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
1209
|
+
|
|
1210
|
+
try:
|
|
1211
|
+
body = json.loads(request.body.decode() or "{}")
|
|
1212
|
+
except json.JSONDecodeError:
|
|
1213
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
1214
|
+
|
|
1215
|
+
requester = body.get("requester")
|
|
1216
|
+
if not requester:
|
|
1217
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
1218
|
+
|
|
1219
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
1220
|
+
requester_public_key = _clean_requester_hint(
|
|
1221
|
+
body.get("requester_public_key"), strip=False
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
node, error_response = _load_signed_node(
|
|
1225
|
+
request,
|
|
1226
|
+
requester,
|
|
1227
|
+
mac_address=requester_mac,
|
|
1228
|
+
public_key=requester_public_key,
|
|
1229
|
+
)
|
|
1230
|
+
if error_response is not None:
|
|
1231
|
+
return error_response
|
|
1232
|
+
|
|
1233
|
+
serial = Charger.normalize_serial(body.get("charger_id"))
|
|
1234
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
1235
|
+
return JsonResponse({"detail": "invalid charger"}, status=400)
|
|
1236
|
+
|
|
1237
|
+
connector = body.get("connector_id")
|
|
1238
|
+
if connector in ("", None):
|
|
1239
|
+
connector_value = None
|
|
1240
|
+
elif isinstance(connector, int):
|
|
1241
|
+
connector_value = connector
|
|
1242
|
+
else:
|
|
1243
|
+
try:
|
|
1244
|
+
connector_value = int(str(connector))
|
|
1245
|
+
except (TypeError, ValueError):
|
|
1246
|
+
return JsonResponse({"detail": "invalid connector"}, status=400)
|
|
1247
|
+
|
|
1248
|
+
charger = Charger.objects.filter(
|
|
1249
|
+
charger_id=serial, connector_id=connector_value
|
|
1250
|
+
).first()
|
|
1251
|
+
if not charger:
|
|
1252
|
+
return JsonResponse({"detail": "charger not found"}, status=404)
|
|
1253
|
+
|
|
1254
|
+
if not charger.allow_remote:
|
|
1255
|
+
return JsonResponse({"detail": "remote actions disabled"}, status=403)
|
|
1256
|
+
|
|
1257
|
+
if not _require_local_origin(charger):
|
|
1258
|
+
return JsonResponse({"detail": "charger is not managed by this node"}, status=403)
|
|
1259
|
+
|
|
1260
|
+
authorized_node_ids = {
|
|
1261
|
+
pk for pk in (charger.manager_node_id, charger.node_origin_id) if pk
|
|
1262
|
+
}
|
|
1263
|
+
if authorized_node_ids and node and node.pk not in authorized_node_ids:
|
|
1264
|
+
return JsonResponse(
|
|
1265
|
+
{"detail": "requester does not manage this charger"}, status=403
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
action = body.get("action")
|
|
1269
|
+
handler = REMOTE_ACTIONS.get(action or "")
|
|
1270
|
+
if handler is None:
|
|
1271
|
+
return JsonResponse({"detail": "unsupported action"}, status=400)
|
|
1272
|
+
|
|
1273
|
+
success, message, updates = handler(charger, body)
|
|
1274
|
+
|
|
1275
|
+
status_code = 200 if success else 409
|
|
1276
|
+
status_label = "ok" if success else "error"
|
|
1277
|
+
serialized_updates: dict[str, object] = {}
|
|
1278
|
+
if isinstance(updates, Mapping):
|
|
1279
|
+
for key, value in updates.items():
|
|
1280
|
+
if hasattr(value, "isoformat"):
|
|
1281
|
+
serialized_updates[key] = value.isoformat()
|
|
1282
|
+
else:
|
|
1283
|
+
serialized_updates[key] = value
|
|
1284
|
+
return JsonResponse(
|
|
1285
|
+
{"status": status_label, "detail": message, "updates": serialized_updates},
|
|
1286
|
+
status=status_code,
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
|
|
725
1290
|
@csrf_exempt
|
|
726
1291
|
def proxy_session(request):
|
|
727
1292
|
"""Create a proxy login session for a remote administrator."""
|
|
@@ -1041,7 +1606,10 @@ def public_node_endpoint(request, endpoint):
|
|
|
1041
1606
|
if request.method == "GET":
|
|
1042
1607
|
data = {
|
|
1043
1608
|
"hostname": node.hostname,
|
|
1044
|
-
"
|
|
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,
|
|
1045
1613
|
"port": node.port,
|
|
1046
1614
|
"badge_color": node.badge_color,
|
|
1047
1615
|
"last_seen": node.last_seen,
|