arthexis 0.1.22__py3-none-any.whl → 0.1.24__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.22.dist-info → arthexis-0.1.24.dist-info}/METADATA +6 -5
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/RECORD +26 -26
- config/settings.py +4 -0
- core/admin.py +200 -16
- core/models.py +878 -118
- core/release.py +0 -5
- core/tasks.py +25 -0
- core/tests.py +29 -1
- core/user_data.py +42 -2
- core/views.py +33 -26
- nodes/admin.py +153 -132
- nodes/models.py +9 -1
- nodes/tests.py +106 -81
- nodes/urls.py +6 -0
- nodes/views.py +620 -48
- ocpp/admin.py +543 -166
- ocpp/models.py +57 -2
- ocpp/tasks.py +336 -1
- ocpp/tests.py +123 -0
- ocpp/views.py +19 -3
- pages/tests.py +25 -6
- pages/urls.py +5 -0
- pages/views.py +117 -11
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.24.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,7 +32,13 @@ 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.transactions_io import export_transactions
|
|
41
|
+
from asgiref.sync import async_to_sync
|
|
34
42
|
|
|
35
43
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
36
44
|
|
|
@@ -43,24 +51,68 @@ PROXY_TOKEN_TIMEOUT = 300
|
|
|
43
51
|
PROXY_CACHE_PREFIX = "nodes:proxy-session:"
|
|
44
52
|
|
|
45
53
|
|
|
46
|
-
def _load_signed_node(
|
|
54
|
+
def _load_signed_node(
|
|
55
|
+
request,
|
|
56
|
+
requester_id: str,
|
|
57
|
+
*,
|
|
58
|
+
mac_address: str | None = None,
|
|
59
|
+
public_key: str | None = None,
|
|
60
|
+
):
|
|
47
61
|
signature = request.headers.get("X-Signature")
|
|
48
62
|
if not signature:
|
|
49
63
|
return None, JsonResponse({"detail": "signature required"}, status=403)
|
|
50
|
-
node = Node.objects.filter(uuid=requester_id).first()
|
|
51
|
-
if not node or not node.public_key:
|
|
52
|
-
return None, JsonResponse({"detail": "unknown requester"}, status=403)
|
|
53
64
|
try:
|
|
54
|
-
|
|
55
|
-
public_key.verify(
|
|
56
|
-
base64.b64decode(signature),
|
|
57
|
-
request.body,
|
|
58
|
-
padding.PKCS1v15(),
|
|
59
|
-
hashes.SHA256(),
|
|
60
|
-
)
|
|
65
|
+
signature_bytes = base64.b64decode(signature)
|
|
61
66
|
except Exception:
|
|
62
67
|
return None, JsonResponse({"detail": "invalid signature"}, status=403)
|
|
63
|
-
|
|
68
|
+
|
|
69
|
+
candidates: list[Node] = []
|
|
70
|
+
seen: set[int] = set()
|
|
71
|
+
|
|
72
|
+
lookup_values: list[tuple[str, str]] = []
|
|
73
|
+
if requester_id:
|
|
74
|
+
lookup_values.append(("uuid", requester_id))
|
|
75
|
+
if mac_address:
|
|
76
|
+
lookup_values.append(("mac_address__iexact", mac_address))
|
|
77
|
+
if public_key:
|
|
78
|
+
lookup_values.append(("public_key", public_key))
|
|
79
|
+
|
|
80
|
+
for field, value in lookup_values:
|
|
81
|
+
node = Node.objects.filter(**{field: value}).first()
|
|
82
|
+
if not node or not node.public_key:
|
|
83
|
+
continue
|
|
84
|
+
if node.pk is not None and node.pk in seen:
|
|
85
|
+
continue
|
|
86
|
+
if node.pk is not None:
|
|
87
|
+
seen.add(node.pk)
|
|
88
|
+
candidates.append(node)
|
|
89
|
+
|
|
90
|
+
if not candidates:
|
|
91
|
+
return None, JsonResponse({"detail": "unknown requester"}, status=403)
|
|
92
|
+
|
|
93
|
+
for node in candidates:
|
|
94
|
+
try:
|
|
95
|
+
loaded_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
96
|
+
loaded_key.verify(
|
|
97
|
+
signature_bytes,
|
|
98
|
+
request.body,
|
|
99
|
+
padding.PKCS1v15(),
|
|
100
|
+
hashes.SHA256(),
|
|
101
|
+
)
|
|
102
|
+
except Exception:
|
|
103
|
+
continue
|
|
104
|
+
return node, None
|
|
105
|
+
|
|
106
|
+
return None, JsonResponse({"detail": "invalid signature"}, status=403)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _clean_requester_hint(value, *, strip: bool = True) -> str | None:
|
|
110
|
+
if not isinstance(value, str):
|
|
111
|
+
return None
|
|
112
|
+
cleaned = value.strip() if strip else value
|
|
113
|
+
if not cleaned:
|
|
114
|
+
return None
|
|
115
|
+
return cleaned
|
|
64
116
|
|
|
65
117
|
|
|
66
118
|
def _sanitize_proxy_target(target: str | None, request) -> str:
|
|
@@ -123,6 +175,96 @@ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
|
|
|
123
175
|
user.user_permissions.set(perm_objs)
|
|
124
176
|
|
|
125
177
|
|
|
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
|
+
def _normalize_requested_chargers(values) -> list[tuple[str, int | None, object]]:
|
|
238
|
+
if not isinstance(values, list):
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
normalized: list[tuple[str, int | None, object]] = []
|
|
242
|
+
for entry in values:
|
|
243
|
+
if not isinstance(entry, Mapping):
|
|
244
|
+
continue
|
|
245
|
+
serial = Charger.normalize_serial(entry.get("charger_id"))
|
|
246
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
247
|
+
continue
|
|
248
|
+
connector = entry.get("connector_id")
|
|
249
|
+
if connector in ("", None):
|
|
250
|
+
connector_value = None
|
|
251
|
+
elif isinstance(connector, int):
|
|
252
|
+
connector_value = connector
|
|
253
|
+
else:
|
|
254
|
+
try:
|
|
255
|
+
connector_value = int(str(connector))
|
|
256
|
+
except (TypeError, ValueError):
|
|
257
|
+
connector_value = None
|
|
258
|
+
since_raw = entry.get("since")
|
|
259
|
+
since_dt = None
|
|
260
|
+
if isinstance(since_raw, str):
|
|
261
|
+
since_dt = parse_datetime(since_raw)
|
|
262
|
+
if since_dt is not None and timezone.is_naive(since_dt):
|
|
263
|
+
since_dt = timezone.make_aware(since_dt, timezone.get_current_timezone())
|
|
264
|
+
normalized.append((serial, connector_value, since_dt))
|
|
265
|
+
return normalized
|
|
266
|
+
|
|
267
|
+
|
|
126
268
|
def _get_client_ip(request):
|
|
127
269
|
"""Return the client IP from the request headers."""
|
|
128
270
|
|
|
@@ -589,26 +731,21 @@ def export_rfids(request):
|
|
|
589
731
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
590
732
|
|
|
591
733
|
requester = payload.get("requester")
|
|
592
|
-
signature = request.headers.get("X-Signature")
|
|
593
734
|
if not requester:
|
|
594
735
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
595
|
-
if not signature:
|
|
596
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
597
736
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
except Exception:
|
|
611
|
-
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
737
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
738
|
+
requester_public_key = _clean_requester_hint(
|
|
739
|
+
payload.get("requester_public_key"), strip=False
|
|
740
|
+
)
|
|
741
|
+
node, error_response = _load_signed_node(
|
|
742
|
+
request,
|
|
743
|
+
requester,
|
|
744
|
+
mac_address=requester_mac,
|
|
745
|
+
public_key=requester_public_key,
|
|
746
|
+
)
|
|
747
|
+
if error_response is not None:
|
|
748
|
+
return error_response
|
|
612
749
|
|
|
613
750
|
tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
|
|
614
751
|
|
|
@@ -628,26 +765,21 @@ def import_rfids(request):
|
|
|
628
765
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
629
766
|
|
|
630
767
|
requester = payload.get("requester")
|
|
631
|
-
signature = request.headers.get("X-Signature")
|
|
632
768
|
if not requester:
|
|
633
769
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
634
|
-
if not signature:
|
|
635
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
636
|
-
|
|
637
|
-
node = Node.objects.filter(uuid=requester).first()
|
|
638
|
-
if not node or not node.public_key:
|
|
639
|
-
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
640
770
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
771
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
772
|
+
requester_public_key = _clean_requester_hint(
|
|
773
|
+
payload.get("requester_public_key"), strip=False
|
|
774
|
+
)
|
|
775
|
+
node, error_response = _load_signed_node(
|
|
776
|
+
request,
|
|
777
|
+
requester,
|
|
778
|
+
mac_address=requester_mac,
|
|
779
|
+
public_key=requester_public_key,
|
|
780
|
+
)
|
|
781
|
+
if error_response is not None:
|
|
782
|
+
return error_response
|
|
651
783
|
|
|
652
784
|
rfids = payload.get("rfids", [])
|
|
653
785
|
if not isinstance(rfids, list):
|
|
@@ -688,6 +820,428 @@ def import_rfids(request):
|
|
|
688
820
|
)
|
|
689
821
|
|
|
690
822
|
|
|
823
|
+
@csrf_exempt
|
|
824
|
+
def network_chargers(request):
|
|
825
|
+
"""Return serialized charger information for trusted peers."""
|
|
826
|
+
|
|
827
|
+
if request.method != "POST":
|
|
828
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
829
|
+
|
|
830
|
+
try:
|
|
831
|
+
body = json.loads(request.body.decode() or "{}")
|
|
832
|
+
except json.JSONDecodeError:
|
|
833
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
834
|
+
|
|
835
|
+
requester = body.get("requester")
|
|
836
|
+
if not requester:
|
|
837
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
838
|
+
|
|
839
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
840
|
+
requester_public_key = _clean_requester_hint(
|
|
841
|
+
body.get("requester_public_key"), strip=False
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
node, error_response = _load_signed_node(
|
|
845
|
+
request,
|
|
846
|
+
requester,
|
|
847
|
+
mac_address=requester_mac,
|
|
848
|
+
public_key=requester_public_key,
|
|
849
|
+
)
|
|
850
|
+
if error_response is not None:
|
|
851
|
+
return error_response
|
|
852
|
+
|
|
853
|
+
requested = _normalize_requested_chargers(body.get("chargers") or [])
|
|
854
|
+
|
|
855
|
+
qs = Charger.objects.all()
|
|
856
|
+
local_node = Node.get_local()
|
|
857
|
+
if local_node:
|
|
858
|
+
qs = qs.filter(Q(node_origin=local_node) | Q(node_origin__isnull=True))
|
|
859
|
+
|
|
860
|
+
if requested:
|
|
861
|
+
filters = Q()
|
|
862
|
+
for serial, connector_value, _ in requested:
|
|
863
|
+
if connector_value is None:
|
|
864
|
+
filters |= Q(charger_id=serial, connector_id__isnull=True)
|
|
865
|
+
else:
|
|
866
|
+
filters |= Q(charger_id=serial, connector_id=connector_value)
|
|
867
|
+
qs = qs.filter(filters)
|
|
868
|
+
|
|
869
|
+
chargers = [_serialize_charger_for_network(charger) for charger in qs]
|
|
870
|
+
|
|
871
|
+
include_transactions = bool(body.get("include_transactions"))
|
|
872
|
+
response_data: dict[str, object] = {"chargers": chargers}
|
|
873
|
+
|
|
874
|
+
if include_transactions:
|
|
875
|
+
serials = [serial for serial, _, _ in requested] or list(
|
|
876
|
+
{charger["charger_id"] for charger in chargers}
|
|
877
|
+
)
|
|
878
|
+
since_values = [since for _, _, since in requested if since]
|
|
879
|
+
start = min(since_values) if since_values else None
|
|
880
|
+
tx_payload = export_transactions(start=start, chargers=serials or None)
|
|
881
|
+
response_data["transactions"] = tx_payload
|
|
882
|
+
|
|
883
|
+
return JsonResponse(response_data)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _require_local_origin(charger: Charger) -> bool:
|
|
887
|
+
local = Node.get_local()
|
|
888
|
+
if not local:
|
|
889
|
+
return charger.node_origin_id is None
|
|
890
|
+
if charger.node_origin_id is None:
|
|
891
|
+
return True
|
|
892
|
+
return charger.node_origin_id == local.pk
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _send_trigger_status(
|
|
896
|
+
charger: Charger, payload: Mapping | None = None
|
|
897
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
898
|
+
connector_value = charger.connector_id
|
|
899
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
900
|
+
if ws is None:
|
|
901
|
+
return False, "no active connection", {}
|
|
902
|
+
payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
|
|
903
|
+
if connector_value is not None:
|
|
904
|
+
payload["connectorId"] = connector_value
|
|
905
|
+
message_id = uuid.uuid4().hex
|
|
906
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
907
|
+
try:
|
|
908
|
+
async_to_sync(ws.send)(msg)
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
return False, f"failed to send TriggerMessage ({exc})", {}
|
|
911
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
912
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
913
|
+
store.register_pending_call(
|
|
914
|
+
message_id,
|
|
915
|
+
{
|
|
916
|
+
"action": "TriggerMessage",
|
|
917
|
+
"charger_id": charger.charger_id,
|
|
918
|
+
"connector_id": connector_value,
|
|
919
|
+
"log_key": log_key,
|
|
920
|
+
"trigger_target": "StatusNotification",
|
|
921
|
+
"trigger_connector": connector_value,
|
|
922
|
+
"requested_at": timezone.now(),
|
|
923
|
+
},
|
|
924
|
+
)
|
|
925
|
+
store.schedule_call_timeout(
|
|
926
|
+
message_id,
|
|
927
|
+
timeout=5.0,
|
|
928
|
+
action="TriggerMessage",
|
|
929
|
+
log_key=log_key,
|
|
930
|
+
message="TriggerMessage StatusNotification timed out",
|
|
931
|
+
)
|
|
932
|
+
return True, "requested status update", {}
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _send_get_configuration(
|
|
936
|
+
charger: Charger, payload: Mapping | None = None
|
|
937
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
938
|
+
connector_value = charger.connector_id
|
|
939
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
940
|
+
if ws is None:
|
|
941
|
+
return False, "no active connection", {}
|
|
942
|
+
message_id = uuid.uuid4().hex
|
|
943
|
+
msg = json.dumps([2, message_id, "GetConfiguration", {}])
|
|
944
|
+
try:
|
|
945
|
+
async_to_sync(ws.send)(msg)
|
|
946
|
+
except Exception as exc:
|
|
947
|
+
return False, f"failed to send GetConfiguration ({exc})", {}
|
|
948
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
949
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
950
|
+
store.register_pending_call(
|
|
951
|
+
message_id,
|
|
952
|
+
{
|
|
953
|
+
"action": "GetConfiguration",
|
|
954
|
+
"charger_id": charger.charger_id,
|
|
955
|
+
"connector_id": connector_value,
|
|
956
|
+
"log_key": log_key,
|
|
957
|
+
"requested_at": timezone.now(),
|
|
958
|
+
},
|
|
959
|
+
)
|
|
960
|
+
store.schedule_call_timeout(
|
|
961
|
+
message_id,
|
|
962
|
+
timeout=5.0,
|
|
963
|
+
action="GetConfiguration",
|
|
964
|
+
log_key=log_key,
|
|
965
|
+
message=(
|
|
966
|
+
"GetConfiguration timed out: charger did not respond"
|
|
967
|
+
" (operation may not be supported)"
|
|
968
|
+
),
|
|
969
|
+
)
|
|
970
|
+
return True, "requested configuration update", {}
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _send_reset(
|
|
974
|
+
charger: Charger, payload: Mapping | None = None
|
|
975
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
976
|
+
connector_value = charger.connector_id
|
|
977
|
+
tx = store.get_transaction(charger.charger_id, connector_value)
|
|
978
|
+
if tx:
|
|
979
|
+
return False, "active session in progress", {}
|
|
980
|
+
message_id = uuid.uuid4().hex
|
|
981
|
+
reset_type = None
|
|
982
|
+
if payload:
|
|
983
|
+
reset_type = payload.get("reset_type")
|
|
984
|
+
msg = json.dumps(
|
|
985
|
+
[2, message_id, "Reset", {"type": (reset_type or "Soft")}]
|
|
986
|
+
)
|
|
987
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
988
|
+
if ws is None:
|
|
989
|
+
return False, "no active connection", {}
|
|
990
|
+
try:
|
|
991
|
+
async_to_sync(ws.send)(msg)
|
|
992
|
+
except Exception as exc:
|
|
993
|
+
return False, f"failed to send Reset ({exc})", {}
|
|
994
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
995
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
996
|
+
store.register_pending_call(
|
|
997
|
+
message_id,
|
|
998
|
+
{
|
|
999
|
+
"action": "Reset",
|
|
1000
|
+
"charger_id": charger.charger_id,
|
|
1001
|
+
"connector_id": connector_value,
|
|
1002
|
+
"log_key": log_key,
|
|
1003
|
+
"requested_at": timezone.now(),
|
|
1004
|
+
},
|
|
1005
|
+
)
|
|
1006
|
+
store.schedule_call_timeout(
|
|
1007
|
+
message_id,
|
|
1008
|
+
timeout=5.0,
|
|
1009
|
+
action="Reset",
|
|
1010
|
+
log_key=log_key,
|
|
1011
|
+
message="Reset timed out: charger did not respond",
|
|
1012
|
+
)
|
|
1013
|
+
return True, "reset requested", {}
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _toggle_rfid(
|
|
1017
|
+
charger: Charger, payload: Mapping | None = None
|
|
1018
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1019
|
+
enable = None
|
|
1020
|
+
if payload is not None:
|
|
1021
|
+
enable = payload.get("enable")
|
|
1022
|
+
if isinstance(enable, str):
|
|
1023
|
+
enable = enable.lower() in {"1", "true", "yes", "on"}
|
|
1024
|
+
elif isinstance(enable, (int, bool)):
|
|
1025
|
+
enable = bool(enable)
|
|
1026
|
+
if enable is None:
|
|
1027
|
+
enable = not charger.require_rfid
|
|
1028
|
+
enable_bool = bool(enable)
|
|
1029
|
+
Charger.objects.filter(pk=charger.pk).update(require_rfid=enable_bool)
|
|
1030
|
+
charger.require_rfid = enable_bool
|
|
1031
|
+
detail = "RFID authentication enabled" if enable_bool else "RFID authentication disabled"
|
|
1032
|
+
return True, detail, {"require_rfid": enable_bool}
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _change_availability_remote(
|
|
1036
|
+
charger: Charger, payload: Mapping | None = None
|
|
1037
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1038
|
+
availability_type = None
|
|
1039
|
+
if payload is not None:
|
|
1040
|
+
availability_type = payload.get("availability_type")
|
|
1041
|
+
availability_label = str(availability_type or "").strip()
|
|
1042
|
+
if availability_label not in {"Operative", "Inoperative"}:
|
|
1043
|
+
return False, "invalid availability type", {}
|
|
1044
|
+
connector_value = charger.connector_id
|
|
1045
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1046
|
+
if ws is None:
|
|
1047
|
+
return False, "no active connection", {}
|
|
1048
|
+
connector_id = connector_value if connector_value is not None else 0
|
|
1049
|
+
message_id = uuid.uuid4().hex
|
|
1050
|
+
msg = json.dumps(
|
|
1051
|
+
[
|
|
1052
|
+
2,
|
|
1053
|
+
message_id,
|
|
1054
|
+
"ChangeAvailability",
|
|
1055
|
+
{"connectorId": connector_id, "type": availability_label},
|
|
1056
|
+
]
|
|
1057
|
+
)
|
|
1058
|
+
try:
|
|
1059
|
+
async_to_sync(ws.send)(msg)
|
|
1060
|
+
except Exception as exc:
|
|
1061
|
+
return False, f"failed to send ChangeAvailability ({exc})", {}
|
|
1062
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1063
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1064
|
+
timestamp = timezone.now()
|
|
1065
|
+
store.register_pending_call(
|
|
1066
|
+
message_id,
|
|
1067
|
+
{
|
|
1068
|
+
"action": "ChangeAvailability",
|
|
1069
|
+
"charger_id": charger.charger_id,
|
|
1070
|
+
"connector_id": connector_value,
|
|
1071
|
+
"availability_type": availability_label,
|
|
1072
|
+
"requested_at": timestamp,
|
|
1073
|
+
},
|
|
1074
|
+
)
|
|
1075
|
+
updates = {
|
|
1076
|
+
"availability_requested_state": availability_label,
|
|
1077
|
+
"availability_requested_at": timestamp,
|
|
1078
|
+
"availability_request_status": "",
|
|
1079
|
+
"availability_request_status_at": None,
|
|
1080
|
+
"availability_request_details": "",
|
|
1081
|
+
}
|
|
1082
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1083
|
+
for field, value in updates.items():
|
|
1084
|
+
setattr(charger, field, value)
|
|
1085
|
+
return True, f"requested ChangeAvailability {availability_label}", updates
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _set_availability_state_remote(
|
|
1089
|
+
charger: Charger, payload: Mapping | None = None
|
|
1090
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1091
|
+
availability_state = None
|
|
1092
|
+
if payload is not None:
|
|
1093
|
+
availability_state = payload.get("availability_state")
|
|
1094
|
+
availability_label = str(availability_state or "").strip()
|
|
1095
|
+
if availability_label not in {"Operative", "Inoperative"}:
|
|
1096
|
+
return False, "invalid availability state", {}
|
|
1097
|
+
timestamp = timezone.now()
|
|
1098
|
+
updates = {
|
|
1099
|
+
"availability_state": availability_label,
|
|
1100
|
+
"availability_state_updated_at": timestamp,
|
|
1101
|
+
}
|
|
1102
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1103
|
+
for field, value in updates.items():
|
|
1104
|
+
setattr(charger, field, value)
|
|
1105
|
+
return True, f"availability marked {availability_label}", updates
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _remote_stop_transaction_remote(
|
|
1109
|
+
charger: Charger, payload: Mapping | None = None
|
|
1110
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1111
|
+
connector_value = charger.connector_id
|
|
1112
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1113
|
+
if ws is None:
|
|
1114
|
+
return False, "no active connection", {}
|
|
1115
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
1116
|
+
if tx_obj is None:
|
|
1117
|
+
return False, "no active transaction", {}
|
|
1118
|
+
message_id = uuid.uuid4().hex
|
|
1119
|
+
msg = json.dumps(
|
|
1120
|
+
[
|
|
1121
|
+
2,
|
|
1122
|
+
message_id,
|
|
1123
|
+
"RemoteStopTransaction",
|
|
1124
|
+
{"transactionId": tx_obj.pk},
|
|
1125
|
+
]
|
|
1126
|
+
)
|
|
1127
|
+
try:
|
|
1128
|
+
async_to_sync(ws.send)(msg)
|
|
1129
|
+
except Exception as exc:
|
|
1130
|
+
return False, f"failed to send RemoteStopTransaction ({exc})", {}
|
|
1131
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1132
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1133
|
+
store.register_pending_call(
|
|
1134
|
+
message_id,
|
|
1135
|
+
{
|
|
1136
|
+
"action": "RemoteStopTransaction",
|
|
1137
|
+
"charger_id": charger.charger_id,
|
|
1138
|
+
"connector_id": connector_value,
|
|
1139
|
+
"transaction_id": tx_obj.pk,
|
|
1140
|
+
"log_key": log_key,
|
|
1141
|
+
"requested_at": timezone.now(),
|
|
1142
|
+
},
|
|
1143
|
+
)
|
|
1144
|
+
return True, "remote stop requested", {}
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
REMOTE_ACTIONS = {
|
|
1148
|
+
"trigger-status": _send_trigger_status,
|
|
1149
|
+
"get-configuration": _send_get_configuration,
|
|
1150
|
+
"reset": _send_reset,
|
|
1151
|
+
"toggle-rfid": _toggle_rfid,
|
|
1152
|
+
"change-availability": _change_availability_remote,
|
|
1153
|
+
"set-availability-state": _set_availability_state_remote,
|
|
1154
|
+
"remote-stop": _remote_stop_transaction_remote,
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
@csrf_exempt
|
|
1159
|
+
def network_charger_action(request):
|
|
1160
|
+
"""Execute remote admin actions on behalf of trusted nodes."""
|
|
1161
|
+
|
|
1162
|
+
if request.method != "POST":
|
|
1163
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
1164
|
+
|
|
1165
|
+
try:
|
|
1166
|
+
body = json.loads(request.body.decode() or "{}")
|
|
1167
|
+
except json.JSONDecodeError:
|
|
1168
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
1169
|
+
|
|
1170
|
+
requester = body.get("requester")
|
|
1171
|
+
if not requester:
|
|
1172
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
1173
|
+
|
|
1174
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
1175
|
+
requester_public_key = _clean_requester_hint(
|
|
1176
|
+
body.get("requester_public_key"), strip=False
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
node, error_response = _load_signed_node(
|
|
1180
|
+
request,
|
|
1181
|
+
requester,
|
|
1182
|
+
mac_address=requester_mac,
|
|
1183
|
+
public_key=requester_public_key,
|
|
1184
|
+
)
|
|
1185
|
+
if error_response is not None:
|
|
1186
|
+
return error_response
|
|
1187
|
+
|
|
1188
|
+
serial = Charger.normalize_serial(body.get("charger_id"))
|
|
1189
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
1190
|
+
return JsonResponse({"detail": "invalid charger"}, status=400)
|
|
1191
|
+
|
|
1192
|
+
connector = body.get("connector_id")
|
|
1193
|
+
if connector in ("", None):
|
|
1194
|
+
connector_value = None
|
|
1195
|
+
elif isinstance(connector, int):
|
|
1196
|
+
connector_value = connector
|
|
1197
|
+
else:
|
|
1198
|
+
try:
|
|
1199
|
+
connector_value = int(str(connector))
|
|
1200
|
+
except (TypeError, ValueError):
|
|
1201
|
+
return JsonResponse({"detail": "invalid connector"}, status=400)
|
|
1202
|
+
|
|
1203
|
+
charger = Charger.objects.filter(
|
|
1204
|
+
charger_id=serial, connector_id=connector_value
|
|
1205
|
+
).first()
|
|
1206
|
+
if not charger:
|
|
1207
|
+
return JsonResponse({"detail": "charger not found"}, status=404)
|
|
1208
|
+
|
|
1209
|
+
if not charger.allow_remote:
|
|
1210
|
+
return JsonResponse({"detail": "remote actions disabled"}, status=403)
|
|
1211
|
+
|
|
1212
|
+
if not _require_local_origin(charger):
|
|
1213
|
+
return JsonResponse({"detail": "charger is not managed by this node"}, status=403)
|
|
1214
|
+
|
|
1215
|
+
authorized_node_ids = {
|
|
1216
|
+
pk for pk in (charger.manager_node_id, charger.node_origin_id) if pk
|
|
1217
|
+
}
|
|
1218
|
+
if authorized_node_ids and node and node.pk not in authorized_node_ids:
|
|
1219
|
+
return JsonResponse(
|
|
1220
|
+
{"detail": "requester does not manage this charger"}, status=403
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
action = body.get("action")
|
|
1224
|
+
handler = REMOTE_ACTIONS.get(action or "")
|
|
1225
|
+
if handler is None:
|
|
1226
|
+
return JsonResponse({"detail": "unsupported action"}, status=400)
|
|
1227
|
+
|
|
1228
|
+
success, message, updates = handler(charger, body)
|
|
1229
|
+
|
|
1230
|
+
status_code = 200 if success else 409
|
|
1231
|
+
status_label = "ok" if success else "error"
|
|
1232
|
+
serialized_updates: dict[str, object] = {}
|
|
1233
|
+
if isinstance(updates, Mapping):
|
|
1234
|
+
for key, value in updates.items():
|
|
1235
|
+
if hasattr(value, "isoformat"):
|
|
1236
|
+
serialized_updates[key] = value.isoformat()
|
|
1237
|
+
else:
|
|
1238
|
+
serialized_updates[key] = value
|
|
1239
|
+
return JsonResponse(
|
|
1240
|
+
{"status": status_label, "detail": message, "updates": serialized_updates},
|
|
1241
|
+
status=status_code,
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
|
|
691
1245
|
@csrf_exempt
|
|
692
1246
|
def proxy_session(request):
|
|
693
1247
|
"""Create a proxy login session for a remote administrator."""
|
|
@@ -704,7 +1258,16 @@ def proxy_session(request):
|
|
|
704
1258
|
if not requester:
|
|
705
1259
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
706
1260
|
|
|
707
|
-
|
|
1261
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
1262
|
+
requester_public_key = _clean_requester_hint(
|
|
1263
|
+
payload.get("requester_public_key"), strip=False
|
|
1264
|
+
)
|
|
1265
|
+
node, error_response = _load_signed_node(
|
|
1266
|
+
request,
|
|
1267
|
+
requester,
|
|
1268
|
+
mac_address=requester_mac,
|
|
1269
|
+
public_key=requester_public_key,
|
|
1270
|
+
)
|
|
708
1271
|
if error_response is not None:
|
|
709
1272
|
return error_response
|
|
710
1273
|
|
|
@@ -844,7 +1407,16 @@ def proxy_execute(request):
|
|
|
844
1407
|
if not requester:
|
|
845
1408
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
846
1409
|
|
|
847
|
-
|
|
1410
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
1411
|
+
requester_public_key = _clean_requester_hint(
|
|
1412
|
+
payload.get("requester_public_key"), strip=False
|
|
1413
|
+
)
|
|
1414
|
+
node, error_response = _load_signed_node(
|
|
1415
|
+
request,
|
|
1416
|
+
requester,
|
|
1417
|
+
mac_address=requester_mac,
|
|
1418
|
+
public_key=requester_public_key,
|
|
1419
|
+
)
|
|
848
1420
|
if error_response is not None:
|
|
849
1421
|
return error_response
|
|
850
1422
|
|