arthexis 0.1.12__py3-none-any.whl → 0.1.13__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/urls.py CHANGED
@@ -9,5 +9,6 @@ urlpatterns = [
9
9
  path("screenshot/", views.capture, name="node-screenshot"),
10
10
  path("net-message/", views.net_message, name="net-message"),
11
11
  path("last-message/", views.last_net_message, name="last-net-message"),
12
+ path("rfid/export/", views.export_rfids, name="node-rfid-export"),
12
13
  path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
13
14
  ]
nodes/views.py CHANGED
@@ -8,6 +8,7 @@ from django.http.request import split_domain_port
8
8
  from django.views.decorators.csrf import csrf_exempt
9
9
  from django.shortcuts import get_object_or_404
10
10
  from django.conf import settings
11
+ from django.urls import reverse
11
12
  from pathlib import Path
12
13
  from django.utils.cache import patch_vary_headers
13
14
 
@@ -16,7 +17,15 @@ from utils.api import api_login_required
16
17
  from cryptography.hazmat.primitives import serialization, hashes
17
18
  from cryptography.hazmat.primitives.asymmetric import padding
18
19
 
19
- from .models import Node, NetMessage, NodeRole, node_information_updated
20
+ from core.models import RFID
21
+
22
+ from .models import (
23
+ Node,
24
+ NetMessage,
25
+ NodeFeature,
26
+ NodeRole,
27
+ node_information_updated,
28
+ )
20
29
  from .utils import capture_screenshot, save_screenshot
21
30
 
22
31
 
@@ -80,6 +89,25 @@ def _get_host_ip(request) -> str:
80
89
  return domain
81
90
 
82
91
 
92
+ def _get_host_domain(request) -> str:
93
+ """Return the domain from the host header when it isn't an IP."""
94
+
95
+ try:
96
+ host = request.get_host()
97
+ except Exception: # pragma: no cover - defensive
98
+ return ""
99
+ if not host:
100
+ return ""
101
+ domain, _ = split_domain_port(host)
102
+ if not domain:
103
+ return ""
104
+ try:
105
+ ipaddress.ip_address(domain)
106
+ except ValueError:
107
+ return domain
108
+ return ""
109
+
110
+
83
111
  def _get_advertised_address(request, node) -> str:
84
112
  """Return the best address for the client to reach this node."""
85
113
 
@@ -119,9 +147,19 @@ def node_info(request):
119
147
  node, _ = Node.register_current()
120
148
 
121
149
  token = request.GET.get("token", "")
122
- address = _get_advertised_address(request, node)
150
+ host_domain = _get_host_domain(request)
151
+ advertised_address = _get_advertised_address(request, node)
152
+ if host_domain:
153
+ hostname = host_domain
154
+ if advertised_address and advertised_address != node.address:
155
+ address = advertised_address
156
+ else:
157
+ address = host_domain
158
+ else:
159
+ hostname = node.hostname
160
+ address = advertised_address
123
161
  data = {
124
- "hostname": node.hostname,
162
+ "hostname": hostname,
125
163
  "address": address,
126
164
  "port": node.port,
127
165
  "mac_address": node.mac_address,
@@ -167,6 +205,33 @@ def _add_cors_headers(request, response):
167
205
  return response
168
206
 
169
207
 
208
+ def _node_display_name(node: Node) -> str:
209
+ """Return a human-friendly name for ``node`` suitable for messaging."""
210
+
211
+ for attr in ("hostname", "public_endpoint", "address"):
212
+ value = getattr(node, attr, "") or ""
213
+ value = value.strip()
214
+ if value:
215
+ return value
216
+ identifier = getattr(node, "pk", None)
217
+ return str(identifier or node)
218
+
219
+
220
+ def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
221
+ """Emit a network message when the visitor node links to a host."""
222
+
223
+ if relation != Node.Relation.UPSTREAM:
224
+ return
225
+
226
+ local_node = Node.get_local()
227
+ if not local_node:
228
+ return
229
+
230
+ visitor_name = _node_display_name(local_node)
231
+ host_name = _node_display_name(new_node)
232
+ NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
233
+
234
+
170
235
  @csrf_exempt
171
236
  def register_node(request):
172
237
  """Register or update a node from POSTed JSON data."""
@@ -321,6 +386,8 @@ def register_node(request):
321
386
  request=request,
322
387
  )
323
388
 
389
+ _announce_visitor_join(node, relation_value)
390
+
324
391
  response = JsonResponse({"id": node.id})
325
392
  return _add_cors_headers(request, response)
326
393
 
@@ -340,6 +407,64 @@ def capture(request):
340
407
  return JsonResponse({"screenshot": str(path), "node": node_id})
341
408
 
342
409
 
410
+ @csrf_exempt
411
+ def export_rfids(request):
412
+ """Return serialized RFID records for authenticated peers."""
413
+
414
+ if request.method != "POST":
415
+ return JsonResponse({"detail": "POST required"}, status=405)
416
+
417
+ try:
418
+ payload = json.loads(request.body.decode() or "{}")
419
+ except json.JSONDecodeError:
420
+ return JsonResponse({"detail": "invalid json"}, status=400)
421
+
422
+ requester = payload.get("requester")
423
+ signature = request.headers.get("X-Signature")
424
+ if not requester:
425
+ return JsonResponse({"detail": "requester required"}, status=400)
426
+ if not signature:
427
+ return JsonResponse({"detail": "signature required"}, status=403)
428
+
429
+ node = Node.objects.filter(uuid=requester).first()
430
+ if not node or not node.public_key:
431
+ return JsonResponse({"detail": "unknown requester"}, status=403)
432
+
433
+ try:
434
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
435
+ public_key.verify(
436
+ base64.b64decode(signature),
437
+ request.body,
438
+ padding.PKCS1v15(),
439
+ hashes.SHA256(),
440
+ )
441
+ except Exception:
442
+ return JsonResponse({"detail": "invalid signature"}, status=403)
443
+
444
+ tags = []
445
+ for tag in RFID.objects.all().order_by("label_id"):
446
+ tags.append(
447
+ {
448
+ "rfid": tag.rfid,
449
+ "custom_label": tag.custom_label,
450
+ "key_a": tag.key_a,
451
+ "key_b": tag.key_b,
452
+ "data": tag.data,
453
+ "key_a_verified": tag.key_a_verified,
454
+ "key_b_verified": tag.key_b_verified,
455
+ "allowed": tag.allowed,
456
+ "color": tag.color,
457
+ "kind": tag.kind,
458
+ "released": tag.released,
459
+ "last_seen_on": tag.last_seen_on.isoformat()
460
+ if tag.last_seen_on
461
+ else None,
462
+ }
463
+ )
464
+
465
+ return JsonResponse({"rfids": tags})
466
+
467
+
343
468
  @csrf_exempt
344
469
  @api_login_required
345
470
  def public_node_endpoint(request, endpoint):
@@ -409,6 +534,25 @@ def net_message(request):
409
534
  reach_role = None
410
535
  if reach_name:
411
536
  reach_role = NodeRole.objects.filter(name=reach_name).first()
537
+ filter_node_uuid = data.get("filter_node")
538
+ filter_node = None
539
+ if filter_node_uuid:
540
+ filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
541
+ filter_feature_slug = data.get("filter_node_feature")
542
+ filter_feature = None
543
+ if filter_feature_slug:
544
+ filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
545
+ filter_role_name = data.get("filter_node_role")
546
+ filter_role = None
547
+ if filter_role_name:
548
+ filter_role = NodeRole.objects.filter(name=filter_role_name).first()
549
+ filter_relation_value = data.get("filter_current_relation")
550
+ filter_relation = ""
551
+ if filter_relation_value:
552
+ relation = Node.normalize_relation(filter_relation_value)
553
+ filter_relation = relation.value if relation else ""
554
+ filter_installed_version = (data.get("filter_installed_version") or "")[:20]
555
+ filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
412
556
  seen = data.get("seen", [])
413
557
  origin_id = data.get("origin")
414
558
  origin_node = None
@@ -425,6 +569,12 @@ def net_message(request):
425
569
  "body": body[:256],
426
570
  "reach": reach_role,
427
571
  "node_origin": origin_node,
572
+ "filter_node": filter_node,
573
+ "filter_node_feature": filter_feature,
574
+ "filter_node_role": filter_role,
575
+ "filter_current_relation": filter_relation,
576
+ "filter_installed_version": filter_installed_version,
577
+ "filter_installed_revision": filter_installed_revision,
428
578
  },
429
579
  )
430
580
  if not created:
@@ -437,6 +587,18 @@ def net_message(request):
437
587
  if msg.node_origin_id is None and origin_node:
438
588
  msg.node_origin = origin_node
439
589
  update_fields.append("node_origin")
590
+ field_updates = {
591
+ "filter_node": filter_node,
592
+ "filter_node_feature": filter_feature,
593
+ "filter_node_role": filter_role,
594
+ "filter_current_relation": filter_relation,
595
+ "filter_installed_version": filter_installed_version,
596
+ "filter_installed_revision": filter_installed_revision,
597
+ }
598
+ for field, value in field_updates.items():
599
+ if getattr(msg, field) != value:
600
+ setattr(msg, field, value)
601
+ update_fields.append(field)
440
602
  msg.save(update_fields=update_fields)
441
603
  msg.propagate(seen=seen)
442
604
  return JsonResponse({"status": "propagated", "complete": msg.complete})
@@ -447,5 +609,11 @@ def last_net_message(request):
447
609
 
448
610
  msg = NetMessage.objects.order_by("-created").first()
449
611
  if not msg:
450
- return JsonResponse({"subject": "", "body": ""})
451
- return JsonResponse({"subject": msg.subject, "body": msg.body})
612
+ return JsonResponse({"subject": "", "body": "", "admin_url": ""})
613
+ return JsonResponse(
614
+ {
615
+ "subject": msg.subject,
616
+ "body": msg.body,
617
+ "admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
618
+ }
619
+ )
ocpp/admin.py CHANGED
@@ -28,6 +28,8 @@ from .transactions_io import (
28
28
  export_transactions,
29
29
  import_transactions as import_transactions_data,
30
30
  )
31
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
32
+ from core.admin import SaveBeforeChangeAction
31
33
  from core.user_data import EntityModelAdmin
32
34
 
33
35
 
@@ -255,6 +257,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
255
257
  "change_availability_inoperative",
256
258
  "set_availability_state_operative",
257
259
  "set_availability_state_inoperative",
260
+ "remote_stop_transaction",
261
+ "reset_chargers",
258
262
  "delete_selected",
259
263
  ]
260
264
 
@@ -314,10 +318,35 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
314
318
  args=[obj.charger_id, obj.connector_slug],
315
319
  )
316
320
  label = (obj.last_status or "status").strip() or "status"
321
+ status_key = label.lower()
322
+ error_code = (obj.last_error_code or "").strip().lower()
323
+ if (
324
+ self._has_active_session(obj)
325
+ and error_code in ERROR_OK_VALUES
326
+ and (status_key not in STATUS_BADGE_MAP or status_key == "available")
327
+ ):
328
+ label = STATUS_BADGE_MAP["charging"][0]
317
329
  return format_html('<a href="{}" target="_blank">{}</a>', url, label)
318
330
 
319
331
  status_link.short_description = "Status"
320
332
 
333
+ def _has_active_session(self, charger: Charger) -> bool:
334
+ """Return whether ``charger`` currently has an active session."""
335
+
336
+ if store.get_transaction(charger.charger_id, charger.connector_id):
337
+ return True
338
+ if charger.connector_id is not None:
339
+ return False
340
+ sibling_connectors = (
341
+ Charger.objects.filter(charger_id=charger.charger_id)
342
+ .exclude(pk=charger.pk)
343
+ .values_list("connector_id", flat=True)
344
+ )
345
+ for connector_id in sibling_connectors:
346
+ if store.get_transaction(charger.charger_id, connector_id):
347
+ return True
348
+ return False
349
+
321
350
  def location_name(self, obj):
322
351
  return obj.location.name if obj.location else ""
323
352
 
@@ -475,6 +504,112 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
475
504
  def set_availability_state_inoperative(self, request, queryset):
476
505
  self._set_availability_state(request, queryset, "Inoperative")
477
506
 
507
+ @admin.action(description="Remote stop active transaction")
508
+ def remote_stop_transaction(self, request, queryset):
509
+ stopped = 0
510
+ for charger in queryset:
511
+ connector_value = charger.connector_id
512
+ ws = store.get_connection(charger.charger_id, connector_value)
513
+ if ws is None:
514
+ self.message_user(
515
+ request,
516
+ f"{charger}: no active connection",
517
+ level=messages.ERROR,
518
+ )
519
+ continue
520
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
521
+ if tx_obj is None:
522
+ self.message_user(
523
+ request,
524
+ f"{charger}: no active transaction",
525
+ level=messages.ERROR,
526
+ )
527
+ continue
528
+ message_id = uuid.uuid4().hex
529
+ payload = {"transactionId": tx_obj.pk}
530
+ msg = json.dumps([
531
+ 2,
532
+ message_id,
533
+ "RemoteStopTransaction",
534
+ payload,
535
+ ])
536
+ try:
537
+ async_to_sync(ws.send)(msg)
538
+ except Exception as exc: # pragma: no cover - network error
539
+ self.message_user(
540
+ request,
541
+ f"{charger}: failed to send RemoteStopTransaction ({exc})",
542
+ level=messages.ERROR,
543
+ )
544
+ continue
545
+ log_key = store.identity_key(charger.charger_id, connector_value)
546
+ store.add_log(log_key, f"< {msg}", log_type="charger")
547
+ store.register_pending_call(
548
+ message_id,
549
+ {
550
+ "action": "RemoteStopTransaction",
551
+ "charger_id": charger.charger_id,
552
+ "connector_id": connector_value,
553
+ "transaction_id": tx_obj.pk,
554
+ "log_key": log_key,
555
+ "requested_at": timezone.now(),
556
+ },
557
+ )
558
+ stopped += 1
559
+ if stopped:
560
+ self.message_user(
561
+ request,
562
+ f"Sent RemoteStopTransaction to {stopped} charger(s)",
563
+ )
564
+
565
+ @admin.action(description="Reset charger (soft)")
566
+ def reset_chargers(self, request, queryset):
567
+ reset = 0
568
+ for charger in queryset:
569
+ connector_value = charger.connector_id
570
+ ws = store.get_connection(charger.charger_id, connector_value)
571
+ if ws is None:
572
+ self.message_user(
573
+ request,
574
+ f"{charger}: no active connection",
575
+ level=messages.ERROR,
576
+ )
577
+ continue
578
+ message_id = uuid.uuid4().hex
579
+ msg = json.dumps([
580
+ 2,
581
+ message_id,
582
+ "Reset",
583
+ {"type": "Soft"},
584
+ ])
585
+ try:
586
+ async_to_sync(ws.send)(msg)
587
+ except Exception as exc: # pragma: no cover - network error
588
+ self.message_user(
589
+ request,
590
+ f"{charger}: failed to send Reset ({exc})",
591
+ level=messages.ERROR,
592
+ )
593
+ continue
594
+ log_key = store.identity_key(charger.charger_id, connector_value)
595
+ store.add_log(log_key, f"< {msg}", log_type="charger")
596
+ store.register_pending_call(
597
+ message_id,
598
+ {
599
+ "action": "Reset",
600
+ "charger_id": charger.charger_id,
601
+ "connector_id": connector_value,
602
+ "log_key": log_key,
603
+ "requested_at": timezone.now(),
604
+ },
605
+ )
606
+ reset += 1
607
+ if reset:
608
+ self.message_user(
609
+ request,
610
+ f"Sent Reset to {reset} charger(s)",
611
+ )
612
+
478
613
  def delete_queryset(self, request, queryset):
479
614
  for obj in queryset:
480
615
  obj.delete()
@@ -494,7 +629,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
494
629
 
495
630
 
496
631
  @admin.register(Simulator)
497
- class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
632
+ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
498
633
  list_display = (
499
634
  "name",
500
635
  "cp_path",
@@ -538,6 +673,7 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
538
673
  ),
539
674
  )
540
675
  actions = ("start_simulator", "stop_simulator", "send_open_door")
676
+ change_actions = ["start_simulator_action", "stop_simulator_action"]
541
677
 
542
678
  log_type = "simulator"
543
679
 
@@ -650,6 +786,14 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
650
786
 
651
787
  stop_simulator.short_description = "Stop selected simulators"
652
788
 
789
+ def start_simulator_action(self, request, obj):
790
+ queryset = type(obj).objects.filter(pk=obj.pk)
791
+ self.start_simulator(request, queryset)
792
+
793
+ def stop_simulator_action(self, request, obj):
794
+ queryset = type(obj).objects.filter(pk=obj.pk)
795
+ self.stop_simulator(request, queryset)
796
+
653
797
  def log_link(self, obj):
654
798
  from django.utils.html import format_html
655
799
  from django.urls import reverse
@@ -694,7 +838,7 @@ class TransactionAdmin(EntityModelAdmin):
694
838
  "stop_time",
695
839
  "kw",
696
840
  )
697
- readonly_fields = ("kw",)
841
+ readonly_fields = ("kw", "received_start_time", "received_stop_time")
698
842
  list_filter = ("charger", "account")
699
843
  date_hierarchy = "start_time"
700
844
  inlines = [MeterValueInline]
ocpp/consumers.py CHANGED
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  import asyncio
6
6
  import inspect
7
7
  import json
8
+ from urllib.parse import parse_qs
8
9
  from django.utils import timezone
9
10
  from core.models import EnergyAccount, Reference, RFID as CoreRFID
10
11
  from nodes.models import NetMessage
@@ -31,6 +32,18 @@ from .evcs_discovery import (
31
32
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
32
33
 
33
34
 
35
+ # Query parameter keys that may contain the charge point serial. Keys are
36
+ # matched case-insensitively and trimmed before use.
37
+ SERIAL_QUERY_PARAM_NAMES = (
38
+ "cid",
39
+ "chargepointid",
40
+ "charge_point_id",
41
+ "chargeboxid",
42
+ "charge_box_id",
43
+ "chargerid",
44
+ )
45
+
46
+
34
47
  def _parse_ip(value: str | None):
35
48
  """Return an :mod:`ipaddress` object for the provided value, if valid."""
36
49
 
@@ -105,6 +118,22 @@ def _resolve_client_ip(scope: dict) -> str | None:
105
118
  return fallback
106
119
 
107
120
 
121
+ def _parse_ocpp_timestamp(value) -> datetime | None:
122
+ """Return an aware :class:`~datetime.datetime` for OCPP timestamps."""
123
+
124
+ if not value:
125
+ return None
126
+ if isinstance(value, datetime):
127
+ timestamp = value
128
+ else:
129
+ timestamp = parse_datetime(str(value))
130
+ if not timestamp:
131
+ return None
132
+ if timezone.is_naive(timestamp):
133
+ timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
134
+ return timestamp
135
+
136
+
108
137
  class SinkConsumer(AsyncWebsocketConsumer):
109
138
  """Accept any message without validation."""
110
139
 
@@ -137,15 +166,53 @@ class CSMSConsumer(AsyncWebsocketConsumer):
137
166
 
138
167
  consumption_update_interval = 300
139
168
 
169
+ def _extract_serial_identifier(self) -> str:
170
+ """Return the charge point serial from the query string or path."""
171
+
172
+ self.serial_source = None
173
+ query_bytes = self.scope.get("query_string") or b""
174
+ self._raw_query_string = query_bytes.decode("utf-8", "ignore") if query_bytes else ""
175
+ if query_bytes:
176
+ try:
177
+ parsed = parse_qs(
178
+ self._raw_query_string,
179
+ keep_blank_values=False,
180
+ )
181
+ except Exception:
182
+ parsed = {}
183
+ if parsed:
184
+ normalized = {
185
+ key.lower(): values for key, values in parsed.items() if values
186
+ }
187
+ for candidate in SERIAL_QUERY_PARAM_NAMES:
188
+ values = normalized.get(candidate)
189
+ if not values:
190
+ continue
191
+ for value in values:
192
+ if not value:
193
+ continue
194
+ trimmed = value.strip()
195
+ if trimmed:
196
+ return trimmed
197
+
198
+ return self.scope["url_route"]["kwargs"].get("cid", "")
199
+
140
200
  @requires_network
141
201
  async def connect(self):
142
- raw_serial = self.scope["url_route"]["kwargs"].get("cid", "")
202
+ raw_serial = self._extract_serial_identifier()
143
203
  try:
144
204
  self.charger_id = Charger.validate_serial(raw_serial)
145
205
  except ValidationError as exc:
146
206
  serial = Charger.normalize_serial(raw_serial)
147
207
  store_key = store.pending_key(serial)
148
208
  message = exc.messages[0] if exc.messages else "Invalid Serial Number"
209
+ details: list[str] = []
210
+ if getattr(self, "serial_source", None):
211
+ details.append(f"serial_source={self.serial_source}")
212
+ if getattr(self, "_raw_query_string", ""):
213
+ details.append(f"query_string={self._raw_query_string!r}")
214
+ if details:
215
+ message = f"{message} ({'; '.join(details)})"
149
216
  store.add_log(
150
217
  store_key,
151
218
  f"Rejected connection: {message}",
@@ -218,6 +285,37 @@ class CSMSConsumer(AsyncWebsocketConsumer):
218
285
  ).first
219
286
  )()
220
287
 
288
+ async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
289
+ """Ensure an RFID record exists and update its last seen timestamp."""
290
+
291
+ if not id_tag:
292
+ return None
293
+
294
+ normalized = id_tag.upper()
295
+
296
+ def _ensure() -> CoreRFID:
297
+ now = timezone.now()
298
+ tag, created = CoreRFID.objects.get_or_create(
299
+ rfid=normalized,
300
+ defaults={"allowed": False, "last_seen_on": now},
301
+ )
302
+ if created:
303
+ updates = []
304
+ if tag.allowed:
305
+ tag.allowed = False
306
+ updates.append("allowed")
307
+ if tag.last_seen_on != now:
308
+ tag.last_seen_on = now
309
+ updates.append("last_seen_on")
310
+ if updates:
311
+ tag.save(update_fields=updates)
312
+ else:
313
+ tag.last_seen_on = now
314
+ tag.save(update_fields=["last_seen_on"])
315
+ return tag
316
+
317
+ return await database_sync_to_async(_ensure)()
318
+
221
319
  async def _assign_connector(self, connector: int | str | None) -> None:
222
320
  """Ensure ``self.charger`` matches the provided connector id."""
223
321
  if connector in (None, "", "-"):
@@ -1133,6 +1231,12 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1133
1231
  )(**update_kwargs)
1134
1232
  _update_instance(self.aggregate_charger)
1135
1233
  _update_instance(self.charger)
1234
+ if connector_value is not None and status.lower() == "available":
1235
+ tx_obj = store.transactions.pop(self.store_key, None)
1236
+ if tx_obj:
1237
+ await self._cancel_consumption_message()
1238
+ store.end_session_log(self.store_key)
1239
+ store.stop_session_lock()
1136
1240
  store.add_log(
1137
1241
  self.store_key,
1138
1242
  f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
@@ -1145,7 +1249,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1145
1249
  )
1146
1250
  reply_payload = {}
1147
1251
  elif action == "Authorize":
1148
- account = await self._get_account(payload.get("idTag"))
1252
+ id_tag = payload.get("idTag")
1253
+ account = await self._get_account(id_tag)
1149
1254
  if self.charger.require_rfid:
1150
1255
  status = (
1151
1256
  "Accepted"
@@ -1154,6 +1259,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1154
1259
  else "Invalid"
1155
1260
  )
1156
1261
  else:
1262
+ await self._ensure_rfid_seen(id_tag)
1157
1263
  status = "Accepted"
1158
1264
  reply_payload = {"idTagInfo": {"status": status}}
1159
1265
  elif action == "MeterValues":
@@ -1227,9 +1333,12 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1227
1333
  id_tag = payload.get("idTag")
1228
1334
  account = await self._get_account(id_tag)
1229
1335
  if id_tag:
1230
- await database_sync_to_async(CoreRFID.objects.get_or_create)(
1231
- rfid=id_tag.upper()
1232
- )
1336
+ if self.charger.require_rfid:
1337
+ await database_sync_to_async(CoreRFID.objects.get_or_create)(
1338
+ rfid=id_tag.upper()
1339
+ )
1340
+ else:
1341
+ await self._ensure_rfid_seen(id_tag)
1233
1342
  await self._assign_connector(payload.get("connectorId"))
1234
1343
  if self.charger.require_rfid:
1235
1344
  authorized = (
@@ -1239,6 +1348,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1239
1348
  else:
1240
1349
  authorized = True
1241
1350
  if authorized:
1351
+ start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1352
+ received_start = timezone.now()
1242
1353
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1243
1354
  charger=self.charger,
1244
1355
  account=account,
@@ -1246,7 +1357,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1246
1357
  vin=(payload.get("vin") or ""),
1247
1358
  connector_id=payload.get("connectorId"),
1248
1359
  meter_start=payload.get("meterStart"),
1249
- start_time=timezone.now(),
1360
+ start_time=start_timestamp or received_start,
1361
+ received_start_time=received_start,
1250
1362
  )
1251
1363
  store.transactions[self.store_key] = tx_obj
1252
1364
  store.start_session_log(self.store_key, tx_obj.pk)
@@ -1267,17 +1379,22 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1267
1379
  Transaction.objects.filter(pk=tx_id, charger=self.charger).first
1268
1380
  )()
1269
1381
  if not tx_obj and tx_id is not None:
1382
+ received_start = timezone.now()
1270
1383
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1271
1384
  pk=tx_id,
1272
1385
  charger=self.charger,
1273
- start_time=timezone.now(),
1386
+ start_time=received_start,
1387
+ received_start_time=received_start,
1274
1388
  meter_start=payload.get("meterStart")
1275
1389
  or payload.get("meterStop"),
1276
1390
  vin=(payload.get("vin") or ""),
1277
1391
  )
1278
1392
  if tx_obj:
1393
+ stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1394
+ received_stop = timezone.now()
1279
1395
  tx_obj.meter_stop = payload.get("meterStop")
1280
- tx_obj.stop_time = timezone.now()
1396
+ tx_obj.stop_time = stop_timestamp or received_stop
1397
+ tx_obj.received_stop_time = received_stop
1281
1398
  await database_sync_to_async(tx_obj.save)()
1282
1399
  await self._update_consumption_message(tx_obj.pk)
1283
1400
  await self._cancel_consumption_message()