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.

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 Node, NetMessage, PendingNetMessage, node_information_updated
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
- if advertised_address and advertised_address != node.address:
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 ("hostname", "public_endpoint", "address"):
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 address or not mac_address:
546
+ if not hostname or not mac_address:
483
547
  response = JsonResponse(
484
- {"detail": "hostname, address and mac_address required"}, status=400
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
- "address": address,
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
- node.hostname = hostname
536
- node.address = address
537
- node.port = port
538
- update_fields = ["hostname", "address", "port"]
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
- node.save(update_fields=update_fields)
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
- "address": node.address,
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,