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.

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."""