arthexis 0.1.23__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.23.dist-info → arthexis-0.1.24.dist-info}/METADATA +5 -5
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/RECORD +17 -17
- config/settings.py +4 -0
- core/admin.py +139 -27
- core/models.py +543 -204
- core/tasks.py +25 -0
- nodes/admin.py +152 -172
- nodes/tests.py +80 -129
- nodes/urls.py +6 -0
- nodes/views.py +520 -0
- ocpp/admin.py +541 -175
- ocpp/models.py +28 -0
- ocpp/tasks.py +336 -1
- pages/views.py +60 -30
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.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
|
|
|
@@ -167,6 +175,96 @@ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
|
|
|
167
175
|
user.user_permissions.set(perm_objs)
|
|
168
176
|
|
|
169
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
|
+
|
|
170
268
|
def _get_client_ip(request):
|
|
171
269
|
"""Return the client IP from the request headers."""
|
|
172
270
|
|
|
@@ -722,6 +820,428 @@ def import_rfids(request):
|
|
|
722
820
|
)
|
|
723
821
|
|
|
724
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
|
+
|
|
725
1245
|
@csrf_exempt
|
|
726
1246
|
def proxy_session(request):
|
|
727
1247
|
"""Create a proxy login session for a remote administrator."""
|