arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/consumers.py CHANGED
@@ -3,8 +3,10 @@ import ipaddress
3
3
  import re
4
4
  from datetime import datetime
5
5
  import asyncio
6
+ from collections import deque
6
7
  import inspect
7
8
  import json
9
+ import logging
8
10
  from urllib.parse import parse_qs
9
11
  from django.utils import timezone
10
12
  from core.models import EnergyAccount, Reference, RFID as CoreRFID
@@ -19,7 +21,16 @@ from config.offline import requires_network
19
21
  from . import store
20
22
  from decimal import Decimal
21
23
  from django.utils.dateparse import parse_datetime
22
- from .models import Transaction, Charger, MeterValue, DataTransferMessage
24
+ from .models import (
25
+ Transaction,
26
+ Charger,
27
+ ChargerConfiguration,
28
+ MeterValue,
29
+ DataTransferMessage,
30
+ CPReservation,
31
+ CPFirmwareDeployment,
32
+ RFIDSessionAttempt,
33
+ )
23
34
  from .reference_utils import host_is_local_loopback
24
35
  from .evcs_discovery import (
25
36
  DEFAULT_CONSOLE_PORT,
@@ -32,6 +43,9 @@ from .evcs_discovery import (
32
43
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
44
 
34
45
 
46
+ logger = logging.getLogger(__name__)
47
+
48
+
35
49
  # Query parameter keys that may contain the charge point serial. Keys are
36
50
  # matched case-insensitively and trimmed before use.
37
51
  SERIAL_QUERY_PARAM_NAMES = (
@@ -134,6 +148,18 @@ def _parse_ocpp_timestamp(value) -> datetime | None:
134
148
  return timestamp
135
149
 
136
150
 
151
+ def _extract_vehicle_identifier(payload: dict) -> tuple[str, str]:
152
+ """Return normalized VID and VIN values from an OCPP message payload."""
153
+
154
+ raw_vid = payload.get("vid")
155
+ vid_value = str(raw_vid).strip() if raw_vid is not None else ""
156
+ raw_vin = payload.get("vin")
157
+ vin_value = str(raw_vin).strip() if raw_vin is not None else ""
158
+ if not vid_value and vin_value:
159
+ vid_value = vin_value
160
+ return vid_value, vin_value
161
+
162
+
137
163
  class SinkConsumer(AsyncWebsocketConsumer):
138
164
  """Accept any message without validation."""
139
165
 
@@ -194,8 +220,20 @@ class CSMSConsumer(AsyncWebsocketConsumer):
194
220
  trimmed = value.strip()
195
221
  if trimmed:
196
222
  return trimmed
197
-
198
- return self.scope["url_route"]["kwargs"].get("cid", "")
223
+
224
+ serial = self.scope["url_route"]["kwargs"].get("cid", "")
225
+ if serial:
226
+ return serial
227
+
228
+ path = (self.scope.get("path") or "").strip("/")
229
+ if not path:
230
+ return ""
231
+
232
+ segments = [segment for segment in path.split("/") if segment]
233
+ if not segments:
234
+ return ""
235
+
236
+ return segments[-1]
199
237
 
200
238
  @requires_network
201
239
  async def connect(self):
@@ -253,7 +291,9 @@ class CSMSConsumer(AsyncWebsocketConsumer):
253
291
  log_type="charger",
254
292
  )
255
293
  store.connections[self.store_key] = self
256
- store.logs["charger"].setdefault(self.store_key, [])
294
+ store.logs["charger"].setdefault(
295
+ self.store_key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
296
+ )
257
297
  self.charger, created = await database_sync_to_async(
258
298
  Charger.objects.get_or_create
259
299
  )(
@@ -279,11 +319,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
279
319
  """Return the energy account for the provided RFID if valid."""
280
320
  if not id_tag:
281
321
  return None
282
- return await database_sync_to_async(
283
- EnergyAccount.objects.filter(
284
- rfids__rfid=id_tag.upper(), rfids__allowed=True
285
- ).first
286
- )()
322
+
323
+ def _resolve() -> EnergyAccount | None:
324
+ matches = CoreRFID.matching_queryset(id_tag).filter(allowed=True)
325
+ if not matches.exists():
326
+ return None
327
+ return (
328
+ EnergyAccount.objects.filter(rfids__in=matches)
329
+ .distinct()
330
+ .first()
331
+ )
332
+
333
+ return await database_sync_to_async(_resolve)()
287
334
 
288
335
  async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
289
336
  """Ensure an RFID record exists and update its last seen timestamp."""
@@ -300,15 +347,58 @@ class CSMSConsumer(AsyncWebsocketConsumer):
300
347
  if not tag.allowed:
301
348
  tag.allowed = True
302
349
  updates.append("allowed")
350
+ if not tag.released:
351
+ tag.released = True
352
+ updates.append("released")
303
353
  if tag.last_seen_on != now:
304
354
  tag.last_seen_on = now
305
355
  updates.append("last_seen_on")
306
356
  if updates:
307
- tag.save(update_fields=updates)
357
+ tag.save(update_fields=sorted(set(updates)))
308
358
  return tag
309
359
 
310
360
  return await database_sync_to_async(_ensure)()
311
361
 
362
+ def _log_unlinked_rfid(self, rfid: str) -> None:
363
+ """Record a warning when an RFID is authorized without an account."""
364
+
365
+ message = (
366
+ f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
367
+ )
368
+ logger.warning(message)
369
+ store.add_log(
370
+ store.pending_key(self.charger_id),
371
+ message,
372
+ log_type="charger",
373
+ )
374
+
375
+ async def _record_rfid_attempt(
376
+ self,
377
+ *,
378
+ rfid: str,
379
+ status: RFIDSessionAttempt.Status,
380
+ account: EnergyAccount | None,
381
+ transaction: Transaction | None = None,
382
+ ) -> None:
383
+ """Persist RFID session attempt metadata for reporting."""
384
+
385
+ normalized = (rfid or "").strip().upper()
386
+ if not normalized:
387
+ return
388
+
389
+ charger = self.charger
390
+
391
+ def _create_attempt() -> None:
392
+ RFIDSessionAttempt.objects.create(
393
+ charger=charger,
394
+ rfid=normalized,
395
+ status=status,
396
+ account=account,
397
+ transaction=transaction,
398
+ )
399
+
400
+ await database_sync_to_async(_create_attempt)()
401
+
312
402
  async def _assign_connector(self, connector: int | str | None) -> None:
313
403
  """Ensure ``self.charger`` matches the provided connector id."""
314
404
  if connector in (None, "", "-"):
@@ -345,7 +435,9 @@ class CSMSConsumer(AsyncWebsocketConsumer):
345
435
  await existing_consumer.close()
346
436
  store.reassign_identity(previous_key, new_key)
347
437
  store.connections[new_key] = self
348
- store.logs["charger"].setdefault(new_key, [])
438
+ store.logs["charger"].setdefault(
439
+ new_key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
440
+ )
349
441
  aggregate_name = await sync_to_async(
350
442
  lambda: self.charger.name or self.charger.charger_id
351
443
  )()
@@ -414,7 +506,9 @@ class CSMSConsumer(AsyncWebsocketConsumer):
414
506
  await existing_consumer.close()
415
507
  store.reassign_identity(previous_key, new_key)
416
508
  store.connections[new_key] = self
417
- store.logs["charger"].setdefault(new_key, [])
509
+ store.logs["charger"].setdefault(
510
+ new_key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
511
+ )
418
512
  connector_name = await sync_to_async(
419
513
  lambda: self.charger.name or self.charger.charger_id
420
514
  )()
@@ -613,6 +707,23 @@ class CSMSConsumer(AsyncWebsocketConsumer):
613
707
  target.firmware_status_info = status_info
614
708
  target.firmware_timestamp = timestamp
615
709
 
710
+ def _update_deployments(ids: list[int]) -> None:
711
+ deployments = list(
712
+ CPFirmwareDeployment.objects.filter(
713
+ charger_id__in=ids, completed_at__isnull=True
714
+ )
715
+ )
716
+ payload = {"status": status, "statusInfo": status_info}
717
+ for deployment in deployments:
718
+ deployment.mark_status(
719
+ status,
720
+ status_info,
721
+ timestamp,
722
+ response=payload,
723
+ )
724
+
725
+ await database_sync_to_async(_update_deployments)([target.pk for target in targets])
726
+
616
727
  async def _cancel_consumption_message(self) -> None:
617
728
  """Stop any scheduled consumption message updates."""
618
729
 
@@ -709,6 +820,136 @@ class CSMSConsumer(AsyncWebsocketConsumer):
709
820
  task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
710
821
  self._consumption_task = task
711
822
 
823
+ def _persist_configuration_result(
824
+ self, payload: dict, connector_hint: int | str | None
825
+ ) -> ChargerConfiguration | None:
826
+ if not isinstance(payload, dict):
827
+ return None
828
+
829
+ connector_value: int | None = None
830
+ if connector_hint not in (None, ""):
831
+ try:
832
+ connector_value = int(connector_hint)
833
+ except (TypeError, ValueError):
834
+ connector_value = None
835
+
836
+ normalized_entries: list[dict[str, object]] = []
837
+ for entry in payload.get("configurationKey") or []:
838
+ if not isinstance(entry, dict):
839
+ continue
840
+ key = str(entry.get("key") or "")
841
+ normalized: dict[str, object] = {"key": key}
842
+ if "value" in entry:
843
+ normalized["value"] = entry.get("value")
844
+ normalized["readonly"] = bool(entry.get("readonly"))
845
+ normalized_entries.append(normalized)
846
+
847
+ unknown_values: list[str] = []
848
+ for value in payload.get("unknownKey") or []:
849
+ if value is None:
850
+ continue
851
+ unknown_values.append(str(value))
852
+
853
+ try:
854
+ raw_payload = json.loads(json.dumps(payload, ensure_ascii=False))
855
+ except (TypeError, ValueError):
856
+ raw_payload = payload
857
+
858
+ configuration = ChargerConfiguration.objects.create(
859
+ charger_identifier=self.charger_id,
860
+ connector_id=connector_value,
861
+ unknown_keys=unknown_values,
862
+ evcs_snapshot_at=timezone.now(),
863
+ raw_payload=raw_payload,
864
+ )
865
+ configuration.replace_configuration_keys(normalized_entries)
866
+ Charger.objects.filter(charger_id=self.charger_id).update(
867
+ configuration=configuration
868
+ )
869
+ return configuration
870
+
871
+ def _apply_change_configuration_snapshot(
872
+ self,
873
+ key: str,
874
+ value: str | None,
875
+ connector_hint: int | str | None,
876
+ ) -> ChargerConfiguration:
877
+ connector_value: int | None = None
878
+ if connector_hint not in (None, ""):
879
+ try:
880
+ connector_value = int(connector_hint)
881
+ except (TypeError, ValueError):
882
+ connector_value = None
883
+
884
+ queryset = ChargerConfiguration.objects.filter(
885
+ charger_identifier=self.charger_id
886
+ )
887
+ if connector_value is None:
888
+ queryset = queryset.filter(connector_id__isnull=True)
889
+ else:
890
+ queryset = queryset.filter(connector_id=connector_value)
891
+
892
+ configuration = queryset.order_by("-created_at").first()
893
+ if configuration is None:
894
+ configuration = ChargerConfiguration.objects.create(
895
+ charger_identifier=self.charger_id,
896
+ connector_id=connector_value,
897
+ unknown_keys=[],
898
+ evcs_snapshot_at=timezone.now(),
899
+ raw_payload={},
900
+ )
901
+
902
+ entries = configuration.configuration_keys
903
+ updated = False
904
+ for entry in entries:
905
+ if entry.get("key") == key:
906
+ updated = True
907
+ if value is None:
908
+ entry.pop("value", None)
909
+ else:
910
+ entry["value"] = value
911
+ if not updated:
912
+ new_entry: dict[str, object] = {"key": key, "readonly": False}
913
+ if value is not None:
914
+ new_entry["value"] = value
915
+ entries.append(new_entry)
916
+
917
+ configuration.replace_configuration_keys(entries)
918
+
919
+ raw_payload = configuration.raw_payload or {}
920
+ if not isinstance(raw_payload, dict):
921
+ raw_payload = {}
922
+ else:
923
+ raw_payload = dict(raw_payload)
924
+
925
+ payload_entries: list[dict[str, object]] = []
926
+ seen = False
927
+ for item in raw_payload.get("configurationKey", []):
928
+ if not isinstance(item, dict):
929
+ continue
930
+ entry_copy = dict(item)
931
+ if str(entry_copy.get("key") or "") == key:
932
+ if value is None:
933
+ entry_copy.pop("value", None)
934
+ else:
935
+ entry_copy["value"] = value
936
+ seen = True
937
+ payload_entries.append(entry_copy)
938
+ if not seen:
939
+ payload_entry: dict[str, object] = {"key": key}
940
+ if value is not None:
941
+ payload_entry["value"] = value
942
+ payload_entries.append(payload_entry)
943
+
944
+ raw_payload["configurationKey"] = payload_entries
945
+ configuration.raw_payload = raw_payload
946
+ configuration.evcs_snapshot_at = timezone.now()
947
+ configuration.save(update_fields=["raw_payload", "evcs_snapshot_at", "updated_at"])
948
+ Charger.objects.filter(charger_id=self.charger_id).update(
949
+ configuration=configuration
950
+ )
951
+ return configuration
952
+
712
953
  async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
713
954
  metadata = store.pop_pending_call(message_id)
714
955
  if not metadata:
@@ -718,6 +959,46 @@ class CSMSConsumer(AsyncWebsocketConsumer):
718
959
  action = metadata.get("action")
719
960
  log_key = metadata.get("log_key") or self.store_key
720
961
  payload_data = payload if isinstance(payload, dict) else {}
962
+ if action == "ChangeConfiguration":
963
+ key_value = str(metadata.get("key") or "").strip()
964
+ status_value = str(payload_data.get("status") or "").strip()
965
+ stored_value = metadata.get("value")
966
+ parts: list[str] = []
967
+ if status_value:
968
+ parts.append(f"status={status_value}")
969
+ if key_value:
970
+ parts.append(f"key={key_value}")
971
+ if stored_value is not None:
972
+ parts.append(f"value={stored_value}")
973
+ message = "ChangeConfiguration result"
974
+ if parts:
975
+ message += ": " + ", ".join(parts)
976
+ store.add_log(log_key, message, log_type="charger")
977
+ if status_value.casefold() in {"accepted", "rebootrequired"} and key_value:
978
+ connector_hint = metadata.get("connector_id")
979
+
980
+ def _apply() -> ChargerConfiguration:
981
+ return self._apply_change_configuration_snapshot(
982
+ key_value,
983
+ stored_value if isinstance(stored_value, str) else None,
984
+ connector_hint,
985
+ )
986
+
987
+ configuration = await database_sync_to_async(_apply)()
988
+ if configuration:
989
+ if self.charger and self.charger.charger_id == self.charger_id:
990
+ self.charger.configuration = configuration
991
+ if (
992
+ self.aggregate_charger
993
+ and self.aggregate_charger.charger_id == self.charger_id
994
+ ):
995
+ self.aggregate_charger.configuration = configuration
996
+ store.record_pending_call_result(
997
+ message_id,
998
+ metadata=metadata,
999
+ payload=payload_data,
1000
+ )
1001
+ return
721
1002
  if action == "DataTransfer":
722
1003
  message_pk = metadata.get("message_pk")
723
1004
  if not message_pk:
@@ -751,6 +1032,97 @@ class CSMSConsumer(AsyncWebsocketConsumer):
751
1032
  ]
752
1033
  )
753
1034
 
1035
+ await database_sync_to_async(_apply)()
1036
+ store.record_pending_call_result(
1037
+ message_id,
1038
+ metadata=metadata,
1039
+ payload=payload_data,
1040
+ )
1041
+ return
1042
+ if action == "SendLocalList":
1043
+ status_value = str(payload_data.get("status") or "").strip()
1044
+ version_candidate = (
1045
+ payload_data.get("currentLocalListVersion")
1046
+ or payload_data.get("listVersion")
1047
+ or metadata.get("list_version")
1048
+ )
1049
+ message = "SendLocalList result"
1050
+ if status_value:
1051
+ message += f": status={status_value}"
1052
+ if version_candidate is not None:
1053
+ message += f", version={version_candidate}"
1054
+ store.add_log(log_key, message, log_type="charger")
1055
+ version_int = None
1056
+ if version_candidate is not None:
1057
+ try:
1058
+ version_int = int(version_candidate)
1059
+ except (TypeError, ValueError):
1060
+ version_int = None
1061
+ await self._update_local_authorization_state(version_int)
1062
+ store.record_pending_call_result(
1063
+ message_id,
1064
+ metadata=metadata,
1065
+ payload=payload_data,
1066
+ )
1067
+ return
1068
+ if action == "GetLocalListVersion":
1069
+ version_candidate = payload_data.get("listVersion")
1070
+ processed = 0
1071
+ auth_list = payload_data.get("localAuthorizationList")
1072
+ if isinstance(auth_list, list):
1073
+ processed = await self._apply_local_authorization_entries(auth_list)
1074
+ message = "GetLocalListVersion result"
1075
+ if version_candidate is not None:
1076
+ message += f": version={version_candidate}"
1077
+ if processed:
1078
+ message += f", entries={processed}"
1079
+ store.add_log(log_key, message, log_type="charger")
1080
+ version_int = None
1081
+ if version_candidate is not None:
1082
+ try:
1083
+ version_int = int(version_candidate)
1084
+ except (TypeError, ValueError):
1085
+ version_int = None
1086
+ await self._update_local_authorization_state(version_int)
1087
+ store.record_pending_call_result(
1088
+ message_id,
1089
+ metadata=metadata,
1090
+ payload=payload_data,
1091
+ )
1092
+ return
1093
+ if action == "ClearCache":
1094
+ status_value = str(payload_data.get("status") or "").strip()
1095
+ message = "ClearCache result"
1096
+ if status_value:
1097
+ message += f": status={status_value}"
1098
+ store.add_log(log_key, message, log_type="charger")
1099
+ version_int = 0 if status_value == "Accepted" else None
1100
+ await self._update_local_authorization_state(version_int)
1101
+ store.record_pending_call_result(
1102
+ message_id,
1103
+ metadata=metadata,
1104
+ payload=payload_data,
1105
+ )
1106
+ return
1107
+ if action == "UpdateFirmware":
1108
+ deployment_pk = metadata.get("deployment_pk")
1109
+
1110
+ def _apply():
1111
+ if not deployment_pk:
1112
+ return
1113
+ deployment = CPFirmwareDeployment.objects.filter(
1114
+ pk=deployment_pk
1115
+ ).first()
1116
+ if not deployment:
1117
+ return
1118
+ status_value = str(payload_data.get("status") or "").strip() or "Accepted"
1119
+ deployment.mark_status(
1120
+ status_value,
1121
+ "",
1122
+ timezone.now(),
1123
+ response=payload_data,
1124
+ )
1125
+
754
1126
  await database_sync_to_async(_apply)()
755
1127
  store.record_pending_call_result(
756
1128
  message_id,
@@ -770,6 +1142,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
770
1142
  f"GetConfiguration result: {payload_text}",
771
1143
  log_type="charger",
772
1144
  )
1145
+ configuration = await database_sync_to_async(
1146
+ self._persist_configuration_result
1147
+ )(payload_data, metadata.get("connector_id"))
1148
+ if configuration:
1149
+ if self.charger and self.charger.charger_id == self.charger_id:
1150
+ self.charger.configuration = configuration
1151
+ if (
1152
+ self.aggregate_charger
1153
+ and self.aggregate_charger.charger_id == self.charger_id
1154
+ ):
1155
+ self.aggregate_charger.configuration = configuration
773
1156
  store.record_pending_call_result(
774
1157
  message_id,
775
1158
  metadata=metadata,
@@ -802,6 +1185,79 @@ class CSMSConsumer(AsyncWebsocketConsumer):
802
1185
  payload=payload_data,
803
1186
  )
804
1187
  return
1188
+ if action == "ReserveNow":
1189
+ status_value = str(payload_data.get("status") or "").strip()
1190
+ message = "ReserveNow result"
1191
+ if status_value:
1192
+ message += f": status={status_value}"
1193
+ store.add_log(log_key, message, log_type="charger")
1194
+
1195
+ reservation_pk = metadata.get("reservation_pk")
1196
+
1197
+ def _apply():
1198
+ if not reservation_pk:
1199
+ return
1200
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1201
+ if not reservation:
1202
+ return
1203
+ reservation.evcs_status = status_value
1204
+ reservation.evcs_error = ""
1205
+ confirmed = status_value.casefold() == "accepted"
1206
+ reservation.evcs_confirmed = confirmed
1207
+ reservation.evcs_confirmed_at = timezone.now() if confirmed else None
1208
+ reservation.save(
1209
+ update_fields=[
1210
+ "evcs_status",
1211
+ "evcs_error",
1212
+ "evcs_confirmed",
1213
+ "evcs_confirmed_at",
1214
+ "updated_on",
1215
+ ]
1216
+ )
1217
+
1218
+ await database_sync_to_async(_apply)()
1219
+ store.record_pending_call_result(
1220
+ message_id,
1221
+ metadata=metadata,
1222
+ payload=payload_data,
1223
+ )
1224
+ return
1225
+ if action == "CancelReservation":
1226
+ status_value = str(payload_data.get("status") or "").strip()
1227
+ message = "CancelReservation result"
1228
+ if status_value:
1229
+ message += f": status={status_value}"
1230
+ store.add_log(log_key, message, log_type="charger")
1231
+
1232
+ reservation_pk = metadata.get("reservation_pk")
1233
+
1234
+ def _apply():
1235
+ if not reservation_pk:
1236
+ return
1237
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1238
+ if not reservation:
1239
+ return
1240
+ reservation.evcs_status = status_value
1241
+ reservation.evcs_error = ""
1242
+ reservation.evcs_confirmed = False
1243
+ reservation.evcs_confirmed_at = None
1244
+ reservation.save(
1245
+ update_fields=[
1246
+ "evcs_status",
1247
+ "evcs_error",
1248
+ "evcs_confirmed",
1249
+ "evcs_confirmed_at",
1250
+ "updated_on",
1251
+ ]
1252
+ )
1253
+
1254
+ await database_sync_to_async(_apply)()
1255
+ store.record_pending_call_result(
1256
+ message_id,
1257
+ metadata=metadata,
1258
+ payload=payload_data,
1259
+ )
1260
+ return
805
1261
  if action == "RemoteStartTransaction":
806
1262
  status_value = str(payload_data.get("status") or "").strip()
807
1263
  message = "RemoteStartTransaction result"
@@ -876,6 +1332,36 @@ class CSMSConsumer(AsyncWebsocketConsumer):
876
1332
  return
877
1333
  action = metadata.get("action")
878
1334
  log_key = metadata.get("log_key") or self.store_key
1335
+ if action == "ChangeConfiguration":
1336
+ key_value = str(metadata.get("key") or "").strip()
1337
+ parts: list[str] = []
1338
+ if key_value:
1339
+ parts.append(f"key={key_value}")
1340
+ if error_code:
1341
+ parts.append(f"code={str(error_code).strip()}")
1342
+ if description:
1343
+ parts.append(f"description={str(description).strip()}")
1344
+ if details:
1345
+ try:
1346
+ parts.append(
1347
+ "details="
1348
+ + json.dumps(details, sort_keys=True, ensure_ascii=False)
1349
+ )
1350
+ except TypeError:
1351
+ parts.append(f"details={details}")
1352
+ message = "ChangeConfiguration error"
1353
+ if parts:
1354
+ message += ": " + ", ".join(parts)
1355
+ store.add_log(log_key, message, log_type="charger")
1356
+ store.record_pending_call_result(
1357
+ message_id,
1358
+ metadata=metadata,
1359
+ success=False,
1360
+ error_code=error_code,
1361
+ error_description=description,
1362
+ error_details=details,
1363
+ )
1364
+ return
879
1365
  if action == "DataTransfer":
880
1366
  message_pk = metadata.get("message_pk")
881
1367
  if not message_pk:
@@ -922,6 +1408,35 @@ class CSMSConsumer(AsyncWebsocketConsumer):
922
1408
  error_details=details,
923
1409
  )
924
1410
  return
1411
+ if action == "ClearCache":
1412
+ parts: list[str] = []
1413
+ code_text = (error_code or "").strip()
1414
+ if code_text:
1415
+ parts.append(f"code={code_text}")
1416
+ description_text = (description or "").strip()
1417
+ if description_text:
1418
+ parts.append(f"description={description_text}")
1419
+ if details:
1420
+ try:
1421
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
1422
+ except TypeError:
1423
+ details_text = str(details)
1424
+ if details_text:
1425
+ parts.append(f"details={details_text}")
1426
+ message = "ClearCache error"
1427
+ if parts:
1428
+ message += ": " + ", ".join(parts)
1429
+ store.add_log(log_key, message, log_type="charger")
1430
+ await self._update_local_authorization_state(None)
1431
+ store.record_pending_call_result(
1432
+ message_id,
1433
+ metadata=metadata,
1434
+ success=False,
1435
+ error_code=error_code,
1436
+ error_description=description,
1437
+ error_details=details,
1438
+ )
1439
+ return
925
1440
  if action == "GetConfiguration":
926
1441
  parts: list[str] = []
927
1442
  code_text = (error_code or "").strip()
@@ -983,6 +1498,173 @@ class CSMSConsumer(AsyncWebsocketConsumer):
983
1498
  error_details=details,
984
1499
  )
985
1500
  return
1501
+ if action == "UpdateFirmware":
1502
+ deployment_pk = metadata.get("deployment_pk")
1503
+
1504
+ def _apply():
1505
+ if not deployment_pk:
1506
+ return
1507
+ deployment = CPFirmwareDeployment.objects.filter(
1508
+ pk=deployment_pk
1509
+ ).first()
1510
+ if not deployment:
1511
+ return
1512
+ parts: list[str] = []
1513
+ if error_code:
1514
+ parts.append(f"code={str(error_code).strip()}")
1515
+ if description:
1516
+ parts.append(f"description={str(description).strip()}")
1517
+ if details:
1518
+ try:
1519
+ details_text = json.dumps(
1520
+ details, sort_keys=True, ensure_ascii=False
1521
+ )
1522
+ except TypeError:
1523
+ details_text = str(details)
1524
+ if details_text:
1525
+ parts.append(f"details={details_text}")
1526
+ message = "UpdateFirmware error"
1527
+ if parts:
1528
+ message += ": " + ", ".join(parts)
1529
+ deployment.mark_status(
1530
+ "Error",
1531
+ message,
1532
+ timezone.now(),
1533
+ response=details or {},
1534
+ )
1535
+ deployment.completed_at = timezone.now()
1536
+ deployment.save(update_fields=["completed_at", "updated_at"])
1537
+
1538
+ await database_sync_to_async(_apply)()
1539
+ store.record_pending_call_result(
1540
+ message_id,
1541
+ metadata=metadata,
1542
+ success=False,
1543
+ error_code=error_code,
1544
+ error_description=description,
1545
+ error_details=details,
1546
+ )
1547
+ return
1548
+ if action == "ReserveNow":
1549
+ parts: list[str] = []
1550
+ code_text = (error_code or "").strip() if error_code else ""
1551
+ if code_text:
1552
+ parts.append(f"code={code_text}")
1553
+ description_text = (description or "").strip() if description else ""
1554
+ if description_text:
1555
+ parts.append(f"description={description_text}")
1556
+ details_text = ""
1557
+ if details:
1558
+ try:
1559
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
1560
+ except TypeError:
1561
+ details_text = str(details)
1562
+ if details_text:
1563
+ parts.append(f"details={details_text}")
1564
+ message = "ReserveNow error"
1565
+ if parts:
1566
+ message += ": " + ", ".join(parts)
1567
+ store.add_log(log_key, message, log_type="charger")
1568
+
1569
+ reservation_pk = metadata.get("reservation_pk")
1570
+
1571
+ def _apply():
1572
+ if not reservation_pk:
1573
+ return
1574
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1575
+ if not reservation:
1576
+ return
1577
+ summary_parts = []
1578
+ if code_text:
1579
+ summary_parts.append(code_text)
1580
+ if description_text:
1581
+ summary_parts.append(description_text)
1582
+ if details_text:
1583
+ summary_parts.append(details_text)
1584
+ reservation.evcs_status = ""
1585
+ reservation.evcs_error = "; ".join(summary_parts)
1586
+ reservation.evcs_confirmed = False
1587
+ reservation.evcs_confirmed_at = None
1588
+ reservation.save(
1589
+ update_fields=[
1590
+ "evcs_status",
1591
+ "evcs_error",
1592
+ "evcs_confirmed",
1593
+ "evcs_confirmed_at",
1594
+ "updated_on",
1595
+ ]
1596
+ )
1597
+
1598
+ await database_sync_to_async(_apply)()
1599
+ store.record_pending_call_result(
1600
+ message_id,
1601
+ metadata=metadata,
1602
+ success=False,
1603
+ error_code=error_code,
1604
+ error_description=description,
1605
+ error_details=details,
1606
+ )
1607
+ return
1608
+ if action == "CancelReservation":
1609
+ parts: list[str] = []
1610
+ code_text = (error_code or "").strip() if error_code else ""
1611
+ if code_text:
1612
+ parts.append(f"code={code_text}")
1613
+ description_text = (description or "").strip() if description else ""
1614
+ if description_text:
1615
+ parts.append(f"description={description_text}")
1616
+ details_text = ""
1617
+ if details:
1618
+ try:
1619
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
1620
+ except TypeError:
1621
+ details_text = str(details)
1622
+ if details_text:
1623
+ parts.append(f"details={details_text}")
1624
+ message = "CancelReservation error"
1625
+ if parts:
1626
+ message += ": " + ", ".join(parts)
1627
+ store.add_log(log_key, message, log_type="charger")
1628
+
1629
+ reservation_pk = metadata.get("reservation_pk")
1630
+
1631
+ def _apply():
1632
+ if not reservation_pk:
1633
+ return
1634
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1635
+ if not reservation:
1636
+ return
1637
+ summary_parts = []
1638
+ if code_text:
1639
+ summary_parts.append(code_text)
1640
+ if description_text:
1641
+ summary_parts.append(description_text)
1642
+ if details_text:
1643
+ summary_parts.append(details_text)
1644
+ reservation.evcs_status = ""
1645
+ reservation.evcs_error = "; ".join(summary_parts)
1646
+ reservation.evcs_confirmed = False
1647
+ reservation.evcs_confirmed_at = None
1648
+ reservation.save(
1649
+ update_fields=[
1650
+ "evcs_status",
1651
+ "evcs_error",
1652
+ "evcs_confirmed",
1653
+ "evcs_confirmed_at",
1654
+ "updated_on",
1655
+ ]
1656
+ )
1657
+
1658
+ await database_sync_to_async(_apply)()
1659
+ store.record_pending_call_result(
1660
+ message_id,
1661
+ metadata=metadata,
1662
+ success=False,
1663
+ error_code=error_code,
1664
+ error_description=description,
1665
+ error_details=details,
1666
+ )
1667
+ return
986
1668
  if action == "RemoteStartTransaction":
987
1669
  message = "RemoteStartTransaction error"
988
1670
  if error_code:
@@ -1217,6 +1899,77 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1217
1899
 
1218
1900
  await database_sync_to_async(_apply)()
1219
1901
 
1902
+ async def _update_local_authorization_state(self, version: int | None) -> None:
1903
+ """Persist the reported local authorization list version."""
1904
+
1905
+ timestamp = timezone.now()
1906
+
1907
+ def _apply() -> None:
1908
+ updates: dict[str, object] = {"local_auth_list_updated_at": timestamp}
1909
+ if version is not None:
1910
+ updates["local_auth_list_version"] = int(version)
1911
+
1912
+ targets: list[Charger] = []
1913
+ if self.charger and getattr(self.charger, "pk", None):
1914
+ targets.append(self.charger)
1915
+ aggregate = self.aggregate_charger
1916
+ if (
1917
+ aggregate
1918
+ and getattr(aggregate, "pk", None)
1919
+ and not any(target.pk == aggregate.pk for target in targets if target.pk)
1920
+ ):
1921
+ targets.append(aggregate)
1922
+
1923
+ if not targets:
1924
+ return
1925
+
1926
+ for target in targets:
1927
+ Charger.objects.filter(pk=target.pk).update(**updates)
1928
+ for field, value in updates.items():
1929
+ setattr(target, field, value)
1930
+
1931
+ await database_sync_to_async(_apply)()
1932
+
1933
+ async def _apply_local_authorization_entries(
1934
+ self, entries: list[dict[str, object]]
1935
+ ) -> int:
1936
+ """Create or update RFID records from a local authorization list."""
1937
+
1938
+ def _apply() -> int:
1939
+ processed = 0
1940
+ now = timezone.now()
1941
+ for entry in entries:
1942
+ if not isinstance(entry, dict):
1943
+ continue
1944
+ id_tag = entry.get("idTag")
1945
+ id_tag_text = str(id_tag or "").strip().upper()
1946
+ if not id_tag_text:
1947
+ continue
1948
+ info = entry.get("idTagInfo")
1949
+ status_value = ""
1950
+ if isinstance(info, dict):
1951
+ status_value = str(info.get("status") or "").strip()
1952
+ status_key = status_value.lower()
1953
+ allowed_flag = status_key in {"", "accepted", "concurrenttx"}
1954
+ defaults = {"allowed": allowed_flag, "released": allowed_flag}
1955
+ tag, _ = CoreRFID.update_or_create_from_code(id_tag_text, defaults)
1956
+ updates: set[str] = set()
1957
+ if tag.allowed != allowed_flag:
1958
+ tag.allowed = allowed_flag
1959
+ updates.add("allowed")
1960
+ if tag.released != allowed_flag:
1961
+ tag.released = allowed_flag
1962
+ updates.add("released")
1963
+ if tag.last_seen_on != now:
1964
+ tag.last_seen_on = now
1965
+ updates.add("last_seen_on")
1966
+ if updates:
1967
+ tag.save(update_fields=sorted(updates))
1968
+ processed += 1
1969
+ return processed
1970
+
1971
+ return await database_sync_to_async(_apply)()
1972
+
1220
1973
  async def _update_availability_state(
1221
1974
  self,
1222
1975
  state: str,
@@ -1321,8 +2074,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1321
2074
  reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
1322
2075
  now = timezone.now()
1323
2076
  self.charger.last_heartbeat = now
2077
+ if (
2078
+ self.aggregate_charger
2079
+ and self.aggregate_charger is not self.charger
2080
+ ):
2081
+ self.aggregate_charger.last_heartbeat = now
1324
2082
  await database_sync_to_async(
1325
- Charger.objects.filter(pk=self.charger.pk).update
2083
+ Charger.objects.filter(charger_id=self.charger_id).update
1326
2084
  )(last_heartbeat=now)
1327
2085
  elif action == "StatusNotification":
1328
2086
  await self._assign_connector(payload.get("connectorId"))
@@ -1395,13 +2153,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1395
2153
  elif action == "Authorize":
1396
2154
  id_tag = payload.get("idTag")
1397
2155
  account = await self._get_account(id_tag)
2156
+ status = "Invalid"
1398
2157
  if self.charger.require_rfid:
1399
- status = (
1400
- "Accepted"
1401
- if account
1402
- and await database_sync_to_async(account.can_authorize)()
1403
- else "Invalid"
1404
- )
2158
+ tag = None
2159
+ tag_created = False
2160
+ if id_tag:
2161
+ tag, tag_created = await database_sync_to_async(
2162
+ CoreRFID.register_scan
2163
+ )(id_tag)
2164
+ if account:
2165
+ if await database_sync_to_async(account.can_authorize)():
2166
+ status = "Accepted"
2167
+ elif (
2168
+ id_tag
2169
+ and tag
2170
+ and not tag_created
2171
+ and tag.allowed
2172
+ ):
2173
+ status = "Accepted"
2174
+ self._log_unlinked_rfid(tag.rfid)
1405
2175
  else:
1406
2176
  await self._ensure_rfid_seen(id_tag)
1407
2177
  status = "Accepted"
@@ -1475,30 +2245,47 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1475
2245
  reply_payload = {}
1476
2246
  elif action == "StartTransaction":
1477
2247
  id_tag = payload.get("idTag")
1478
- account = await self._get_account(id_tag)
2248
+ tag = None
2249
+ tag_created = False
1479
2250
  if id_tag:
1480
- if self.charger.require_rfid:
1481
- await database_sync_to_async(CoreRFID.register_scan)(
1482
- id_tag.upper()
1483
- )
1484
- else:
1485
- await self._ensure_rfid_seen(id_tag)
2251
+ tag, tag_created = await database_sync_to_async(
2252
+ CoreRFID.register_scan
2253
+ )(id_tag)
2254
+ account = await self._get_account(id_tag)
2255
+ if id_tag and not self.charger.require_rfid:
2256
+ seen_tag = await self._ensure_rfid_seen(id_tag)
2257
+ if seen_tag:
2258
+ tag = seen_tag
1486
2259
  await self._assign_connector(payload.get("connectorId"))
2260
+ authorized = True
2261
+ authorized_via_tag = False
1487
2262
  if self.charger.require_rfid:
1488
- authorized = (
1489
- account is not None
1490
- and await database_sync_to_async(account.can_authorize)()
1491
- )
1492
- else:
1493
- authorized = True
2263
+ if account is not None:
2264
+ authorized = await database_sync_to_async(
2265
+ account.can_authorize
2266
+ )()
2267
+ elif (
2268
+ id_tag
2269
+ and tag
2270
+ and not tag_created
2271
+ and getattr(tag, "allowed", False)
2272
+ ):
2273
+ authorized = True
2274
+ authorized_via_tag = True
2275
+ else:
2276
+ authorized = False
1494
2277
  if authorized:
2278
+ if authorized_via_tag and tag:
2279
+ self._log_unlinked_rfid(tag.rfid)
1495
2280
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1496
2281
  received_start = timezone.now()
2282
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1497
2283
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1498
2284
  charger=self.charger,
1499
2285
  account=account,
1500
2286
  rfid=(id_tag or ""),
1501
- vin=(payload.get("vin") or ""),
2287
+ vid=vid_value,
2288
+ vin=vin_value,
1502
2289
  connector_id=payload.get("connectorId"),
1503
2290
  meter_start=payload.get("meterStart"),
1504
2291
  start_time=start_timestamp or received_start,
@@ -1509,12 +2296,23 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1509
2296
  store.start_session_lock()
1510
2297
  store.add_session_message(self.store_key, text_data)
1511
2298
  await self._start_consumption_updates(tx_obj)
2299
+ await self._record_rfid_attempt(
2300
+ rfid=id_tag or "",
2301
+ status=RFIDSessionAttempt.Status.ACCEPTED,
2302
+ account=account,
2303
+ transaction=tx_obj,
2304
+ )
1512
2305
  reply_payload = {
1513
2306
  "transactionId": tx_obj.pk,
1514
2307
  "idTagInfo": {"status": "Accepted"},
1515
2308
  }
1516
2309
  else:
1517
2310
  reply_payload = {"idTagInfo": {"status": "Invalid"}}
2311
+ await self._record_rfid_attempt(
2312
+ rfid=id_tag or "",
2313
+ status=RFIDSessionAttempt.Status.REJECTED,
2314
+ account=account,
2315
+ )
1518
2316
  elif action == "StopTransaction":
1519
2317
  tx_id = payload.get("transactionId")
1520
2318
  tx_obj = store.transactions.pop(self.store_key, None)
@@ -1524,6 +2322,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1524
2322
  )()
1525
2323
  if not tx_obj and tx_id is not None:
1526
2324
  received_start = timezone.now()
2325
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1527
2326
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1528
2327
  pk=tx_id,
1529
2328
  charger=self.charger,
@@ -1531,12 +2330,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1531
2330
  received_start_time=received_start,
1532
2331
  meter_start=payload.get("meterStart")
1533
2332
  or payload.get("meterStop"),
1534
- vin=(payload.get("vin") or ""),
2333
+ vid=vid_value,
2334
+ vin=vin_value,
1535
2335
  )
1536
2336
  if tx_obj:
1537
2337
  stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1538
2338
  received_stop = timezone.now()
1539
2339
  tx_obj.meter_stop = payload.get("meterStop")
2340
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
2341
+ if vid_value:
2342
+ tx_obj.vid = vid_value
2343
+ if vin_value:
2344
+ tx_obj.vin = vin_value
1540
2345
  tx_obj.stop_time = stop_timestamp or received_stop
1541
2346
  tx_obj.received_stop_time = received_stop
1542
2347
  await database_sync_to_async(tx_obj.save)()