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.

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(request, requester_id: str):
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
- public_key = serialization.load_pem_public_key(node.public_key.encode())
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
- return node, None
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
- node = Node.objects.filter(uuid=requester).first()
599
- if not node or not node.public_key:
600
- return JsonResponse({"detail": "unknown requester"}, status=403)
601
-
602
- try:
603
- public_key = serialization.load_pem_public_key(node.public_key.encode())
604
- public_key.verify(
605
- base64.b64decode(signature),
606
- request.body,
607
- padding.PKCS1v15(),
608
- hashes.SHA256(),
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
- try:
642
- public_key = serialization.load_pem_public_key(node.public_key.encode())
643
- public_key.verify(
644
- base64.b64decode(signature),
645
- request.body,
646
- padding.PKCS1v15(),
647
- hashes.SHA256(),
648
- )
649
- except Exception:
650
- return JsonResponse({"detail": "invalid signature"}, status=403)
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
- node, error_response = _load_signed_node(request, requester)
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
- node, error_response = _load_signed_node(request, requester)
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