arthexis 0.1.26__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.

ocpp/consumers.py CHANGED
@@ -3,6 +3,7 @@ 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
8
9
  import logging
@@ -27,6 +28,8 @@ from .models import (
27
28
  MeterValue,
28
29
  DataTransferMessage,
29
30
  CPReservation,
31
+ CPFirmwareDeployment,
32
+ RFIDSessionAttempt,
30
33
  )
31
34
  from .reference_utils import host_is_local_loopback
32
35
  from .evcs_discovery import (
@@ -217,8 +220,20 @@ class CSMSConsumer(AsyncWebsocketConsumer):
217
220
  trimmed = value.strip()
218
221
  if trimmed:
219
222
  return trimmed
220
-
221
- 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]
222
237
 
223
238
  @requires_network
224
239
  async def connect(self):
@@ -276,7 +291,9 @@ class CSMSConsumer(AsyncWebsocketConsumer):
276
291
  log_type="charger",
277
292
  )
278
293
  store.connections[self.store_key] = self
279
- 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
+ )
280
297
  self.charger, created = await database_sync_to_async(
281
298
  Charger.objects.get_or_create
282
299
  )(
@@ -330,11 +347,14 @@ class CSMSConsumer(AsyncWebsocketConsumer):
330
347
  if not tag.allowed:
331
348
  tag.allowed = True
332
349
  updates.append("allowed")
350
+ if not tag.released:
351
+ tag.released = True
352
+ updates.append("released")
333
353
  if tag.last_seen_on != now:
334
354
  tag.last_seen_on = now
335
355
  updates.append("last_seen_on")
336
356
  if updates:
337
- tag.save(update_fields=updates)
357
+ tag.save(update_fields=sorted(set(updates)))
338
358
  return tag
339
359
 
340
360
  return await database_sync_to_async(_ensure)()
@@ -352,6 +372,33 @@ class CSMSConsumer(AsyncWebsocketConsumer):
352
372
  log_type="charger",
353
373
  )
354
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
+
355
402
  async def _assign_connector(self, connector: int | str | None) -> None:
356
403
  """Ensure ``self.charger`` matches the provided connector id."""
357
404
  if connector in (None, "", "-"):
@@ -388,7 +435,9 @@ class CSMSConsumer(AsyncWebsocketConsumer):
388
435
  await existing_consumer.close()
389
436
  store.reassign_identity(previous_key, new_key)
390
437
  store.connections[new_key] = self
391
- store.logs["charger"].setdefault(new_key, [])
438
+ store.logs["charger"].setdefault(
439
+ new_key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
440
+ )
392
441
  aggregate_name = await sync_to_async(
393
442
  lambda: self.charger.name or self.charger.charger_id
394
443
  )()
@@ -457,7 +506,9 @@ class CSMSConsumer(AsyncWebsocketConsumer):
457
506
  await existing_consumer.close()
458
507
  store.reassign_identity(previous_key, new_key)
459
508
  store.connections[new_key] = self
460
- store.logs["charger"].setdefault(new_key, [])
509
+ store.logs["charger"].setdefault(
510
+ new_key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
511
+ )
461
512
  connector_name = await sync_to_async(
462
513
  lambda: self.charger.name or self.charger.charger_id
463
514
  )()
@@ -656,6 +707,23 @@ class CSMSConsumer(AsyncWebsocketConsumer):
656
707
  target.firmware_status_info = status_info
657
708
  target.firmware_timestamp = timestamp
658
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
+
659
727
  async def _cancel_consumption_message(self) -> None:
660
728
  """Stop any scheduled consumption message updates."""
661
729
 
@@ -790,11 +858,93 @@ class CSMSConsumer(AsyncWebsocketConsumer):
790
858
  configuration = ChargerConfiguration.objects.create(
791
859
  charger_identifier=self.charger_id,
792
860
  connector_id=connector_value,
793
- configuration_keys=normalized_entries,
794
861
  unknown_keys=unknown_values,
795
862
  evcs_snapshot_at=timezone.now(),
796
863
  raw_payload=raw_payload,
797
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"])
798
948
  Charger.objects.filter(charger_id=self.charger_id).update(
799
949
  configuration=configuration
800
950
  )
@@ -809,6 +959,46 @@ class CSMSConsumer(AsyncWebsocketConsumer):
809
959
  action = metadata.get("action")
810
960
  log_key = metadata.get("log_key") or self.store_key
811
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
812
1002
  if action == "DataTransfer":
813
1003
  message_pk = metadata.get("message_pk")
814
1004
  if not message_pk:
@@ -842,6 +1032,97 @@ class CSMSConsumer(AsyncWebsocketConsumer):
842
1032
  ]
843
1033
  )
844
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
+
845
1126
  await database_sync_to_async(_apply)()
846
1127
  store.record_pending_call_result(
847
1128
  message_id,
@@ -934,6 +1215,42 @@ class CSMSConsumer(AsyncWebsocketConsumer):
934
1215
  ]
935
1216
  )
936
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
+
937
1254
  await database_sync_to_async(_apply)()
938
1255
  store.record_pending_call_result(
939
1256
  message_id,
@@ -1015,6 +1332,36 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1015
1332
  return
1016
1333
  action = metadata.get("action")
1017
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
1018
1365
  if action == "DataTransfer":
1019
1366
  message_pk = metadata.get("message_pk")
1020
1367
  if not message_pk:
@@ -1061,6 +1408,35 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1061
1408
  error_details=details,
1062
1409
  )
1063
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
1064
1440
  if action == "GetConfiguration":
1065
1441
  parts: list[str] = []
1066
1442
  code_text = (error_code or "").strip()
@@ -1122,6 +1498,53 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1122
1498
  error_details=details,
1123
1499
  )
1124
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
1125
1548
  if action == "ReserveNow":
1126
1549
  parts: list[str] = []
1127
1550
  code_text = (error_code or "").strip() if error_code else ""
@@ -1145,6 +1568,66 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1145
1568
 
1146
1569
  reservation_pk = metadata.get("reservation_pk")
1147
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
+
1148
1631
  def _apply():
1149
1632
  if not reservation_pk:
1150
1633
  return
@@ -1416,6 +1899,77 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1416
1899
 
1417
1900
  await database_sync_to_async(_apply)()
1418
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
+
1419
1973
  async def _update_availability_state(
1420
1974
  self,
1421
1975
  state: str,
@@ -1742,12 +2296,23 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1742
2296
  store.start_session_lock()
1743
2297
  store.add_session_message(self.store_key, text_data)
1744
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
+ )
1745
2305
  reply_payload = {
1746
2306
  "transactionId": tx_obj.pk,
1747
2307
  "idTagInfo": {"status": "Accepted"},
1748
2308
  }
1749
2309
  else:
1750
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
+ )
1751
2316
  elif action == "StopTransaction":
1752
2317
  tx_id = payload.get("transactionId")
1753
2318
  tx_obj = store.transactions.pop(self.store_key, None)