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/tasks.py CHANGED
@@ -1,21 +1,174 @@
1
+ import json
1
2
  import logging
3
+ import uuid
4
+ from dataclasses import dataclass
2
5
  from datetime import date, datetime, time, timedelta
3
6
  from pathlib import Path
7
+ from typing import Iterable
4
8
 
9
+ from asgiref.sync import async_to_sync
5
10
  from celery import shared_task
6
11
  from django.conf import settings
7
12
  from django.contrib.auth import get_user_model
13
+ from django.db.models import Q, Prefetch
8
14
  from django.utils import timezone
9
- from django.db.models import Q
15
+ from urllib.parse import quote, urlsplit, urlunsplit
16
+ from websocket import WebSocketException, create_connection
10
17
 
11
18
  from core import mailer
12
19
  from nodes.models import Node
13
20
 
14
- from .models import MeterValue, Transaction
15
-
21
+ from . import store
22
+ from .models import Charger, MeterValue, Transaction
16
23
  logger = logging.getLogger(__name__)
17
24
 
18
25
 
26
+ @dataclass
27
+ class ForwardingSession:
28
+ """Active websocket forwarding session for a charge point."""
29
+
30
+ charger_pk: int
31
+ node_id: int | None
32
+ url: str
33
+ connection: object
34
+ connected_at: datetime
35
+
36
+ @property
37
+ def is_connected(self) -> bool:
38
+ return bool(getattr(self.connection, "connected", False))
39
+
40
+
41
+ _FORWARDING_SESSIONS: dict[int, ForwardingSession] = {}
42
+
43
+ def _candidate_forwarding_urls(node: Node, charger: Charger) -> Iterable[str]:
44
+ """Yield websocket URLs suitable for forwarding ``charger`` via ``node``."""
45
+
46
+ charger_id = (charger.charger_id or "").strip()
47
+ if not charger_id:
48
+ return []
49
+
50
+ encoded_id = quote(charger_id, safe="")
51
+ for base in node.iter_remote_urls("/"):
52
+ if not base:
53
+ continue
54
+ parsed = urlsplit(base)
55
+ if parsed.scheme not in {"http", "https"}:
56
+ continue
57
+ scheme = "wss" if parsed.scheme == "https" else "ws"
58
+ base_path = parsed.path.rstrip("/")
59
+ for prefix in ("", "/ws"):
60
+ path = f"{base_path}{prefix}/{encoded_id}".replace("//", "/")
61
+ if not path.startswith("/"):
62
+ path = f"/{path}"
63
+ yield urlunsplit((scheme, parsed.netloc, path, "", ""))
64
+
65
+
66
+ def _close_forwarding_session(session: ForwardingSession) -> None:
67
+ """Close the websocket connection associated with ``session`` if open."""
68
+
69
+ connection = session.connection
70
+ if connection is None:
71
+ return
72
+ try:
73
+ connection.close()
74
+ except Exception: # pragma: no cover - best effort close
75
+ pass
76
+
77
+
78
+ @shared_task
79
+ def check_charge_point_configuration(charger_pk: int) -> bool:
80
+ """Request the latest configuration from a connected charge point."""
81
+
82
+ try:
83
+ charger = Charger.objects.get(pk=charger_pk)
84
+ except Charger.DoesNotExist:
85
+ logger.warning(
86
+ "Unable to request configuration for missing charger %s",
87
+ charger_pk,
88
+ )
89
+ return False
90
+
91
+ connector_value = charger.connector_id
92
+ if connector_value is not None:
93
+ logger.debug(
94
+ "Skipping charger %s: connector %s is not eligible for automatic configuration checks",
95
+ charger.charger_id,
96
+ connector_value,
97
+ )
98
+ return False
99
+
100
+ ws = store.get_connection(charger.charger_id, connector_value)
101
+ if ws is None:
102
+ logger.info(
103
+ "Charge point %s is not connected; configuration request skipped",
104
+ charger.charger_id,
105
+ )
106
+ return False
107
+
108
+ message_id = uuid.uuid4().hex
109
+ payload: dict[str, object] = {}
110
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
111
+
112
+ try:
113
+ async_to_sync(ws.send)(msg)
114
+ except Exception as exc: # pragma: no cover - network error
115
+ logger.warning(
116
+ "Failed to send GetConfiguration to %s (%s)",
117
+ charger.charger_id,
118
+ exc,
119
+ )
120
+ return False
121
+
122
+ log_key = store.identity_key(charger.charger_id, connector_value)
123
+ store.add_log(log_key, f"< {msg}", log_type="charger")
124
+ store.register_pending_call(
125
+ message_id,
126
+ {
127
+ "action": "GetConfiguration",
128
+ "charger_id": charger.charger_id,
129
+ "connector_id": connector_value,
130
+ "log_key": log_key,
131
+ "requested_at": timezone.now(),
132
+ },
133
+ )
134
+ store.schedule_call_timeout(
135
+ message_id,
136
+ timeout=5.0,
137
+ action="GetConfiguration",
138
+ log_key=log_key,
139
+ message=(
140
+ "GetConfiguration timed out: charger did not respond"
141
+ " (operation may not be supported)"
142
+ ),
143
+ )
144
+ logger.info(
145
+ "Requested configuration from charge point %s",
146
+ charger.charger_id,
147
+ )
148
+ return True
149
+
150
+
151
+ @shared_task
152
+ def schedule_daily_charge_point_configuration_checks() -> int:
153
+ """Dispatch configuration requests for eligible charge points."""
154
+
155
+ charger_ids = list(
156
+ Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
157
+ )
158
+ if not charger_ids:
159
+ logger.debug("No eligible charge points available for configuration check")
160
+ return 0
161
+
162
+ scheduled = 0
163
+ for charger_pk in charger_ids:
164
+ check_charge_point_configuration.delay(charger_pk)
165
+ scheduled += 1
166
+ logger.info(
167
+ "Scheduled configuration checks for %s charge point(s)", scheduled
168
+ )
169
+ return scheduled
170
+
171
+
19
172
  @shared_task
20
173
  def purge_meter_values() -> int:
21
174
  """Delete meter values older than 7 days.
@@ -37,6 +190,101 @@ def purge_meter_values() -> int:
37
190
  purge_meter_readings = purge_meter_values
38
191
 
39
192
 
193
+ @shared_task(rate_limit="1/10m")
194
+ def push_forwarded_charge_points() -> int:
195
+ """Ensure websocket connections exist for forwarded charge points."""
196
+
197
+ local = Node.get_local()
198
+ if not local:
199
+ logger.debug("Forwarding skipped: local node not registered")
200
+ return 0
201
+
202
+ chargers_qs = (
203
+ Charger.objects.filter(export_transactions=True, forwarded_to__isnull=False)
204
+ .select_related("forwarded_to", "node_origin")
205
+ .order_by("pk")
206
+ )
207
+
208
+ node_filter = Q(node_origin__isnull=True)
209
+ if local.pk:
210
+ node_filter |= Q(node_origin=local)
211
+
212
+ chargers = list(chargers_qs.filter(node_filter))
213
+ active_ids = {charger.pk for charger in chargers}
214
+
215
+ # Close sessions that no longer map to active forwarded chargers.
216
+ for pk in list(_FORWARDING_SESSIONS.keys()):
217
+ if pk not in active_ids:
218
+ session = _FORWARDING_SESSIONS.pop(pk)
219
+ _close_forwarding_session(session)
220
+
221
+ if not chargers:
222
+ return 0
223
+
224
+ connected = 0
225
+
226
+ for charger in chargers:
227
+ target = charger.forwarded_to
228
+ if not target:
229
+ continue
230
+ if local.pk and target.pk == local.pk:
231
+ continue
232
+
233
+ existing = _FORWARDING_SESSIONS.get(charger.pk)
234
+ if existing and existing.node_id == getattr(target, "pk", None):
235
+ if existing.is_connected:
236
+ continue
237
+ _close_forwarding_session(existing)
238
+ _FORWARDING_SESSIONS.pop(charger.pk, None)
239
+
240
+ for url in _candidate_forwarding_urls(target, charger):
241
+ try:
242
+ connection = create_connection(
243
+ url,
244
+ timeout=5,
245
+ subprotocols=["ocpp1.6"],
246
+ )
247
+ except (WebSocketException, OSError) as exc:
248
+ logger.warning(
249
+ "Websocket forwarding connection to %s via %s failed: %s",
250
+ target,
251
+ url,
252
+ exc,
253
+ )
254
+ continue
255
+
256
+ session = ForwardingSession(
257
+ charger_pk=charger.pk,
258
+ node_id=getattr(target, "pk", None),
259
+ url=url,
260
+ connection=connection,
261
+ connected_at=timezone.now(),
262
+ )
263
+ _FORWARDING_SESSIONS[charger.pk] = session
264
+ Charger.objects.filter(pk=charger.pk).update(
265
+ forwarding_watermark=session.connected_at
266
+ )
267
+ connected += 1
268
+ logger.info(
269
+ "Established forwarding websocket for charger %s to %s via %s",
270
+ charger.charger_id,
271
+ target,
272
+ url,
273
+ )
274
+ break
275
+ else:
276
+ logger.warning(
277
+ "Unable to establish forwarding websocket for charger %s",
278
+ charger.charger_id or charger.pk,
279
+ )
280
+
281
+ return connected
282
+
283
+
284
+ # Backwards compatibility alias for legacy schedules
285
+ sync_remote_chargers = push_forwarded_charge_points
286
+
287
+
40
288
  def _resolve_report_window() -> tuple[datetime, datetime, date]:
41
289
  """Return the start/end datetimes for today's reporting window."""
42
290
 
@@ -113,9 +361,15 @@ def send_daily_session_report() -> int:
113
361
  return 0
114
362
 
115
363
  start, end, today = _resolve_report_window()
364
+ meter_value_prefetch = Prefetch(
365
+ "meter_values",
366
+ queryset=MeterValue.objects.filter(energy__isnull=False).order_by("timestamp"),
367
+ to_attr="prefetched_meter_values",
368
+ )
116
369
  transactions = list(
117
370
  Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
118
371
  .select_related("charger", "account")
372
+ .prefetch_related(meter_value_prefetch)
119
373
  .order_by("start_time")
120
374
  )
121
375
  if not transactions:
@@ -149,6 +403,10 @@ def send_daily_session_report() -> int:
149
403
  lines.append(f" Account: {account}")
150
404
  if transaction.rfid:
151
405
  lines.append(f" RFID: {transaction.rfid}")
406
+ identifier = transaction.vehicle_identifier
407
+ if identifier:
408
+ label = "VID" if transaction.vehicle_identifier_source == "vid" else "VIN"
409
+ lines.append(f" {label}: {identifier}")
152
410
  if connector:
153
411
  lines.append(f" {connector}")
154
412
  lines.append(
@@ -111,6 +111,7 @@ class TransactionAdminExportImportTests(TestCase):
111
111
  "charger": "C9",
112
112
  "account": None,
113
113
  "rfid": "",
114
+ "vid": "",
114
115
  "vin": "",
115
116
  "meter_start": 0,
116
117
  "meter_stop": 0,
ocpp/test_rfid.py CHANGED
@@ -131,7 +131,7 @@ class ScanNextViewTests(TestCase):
131
131
  self.assertEqual(
132
132
  resp.json(), {"rfid": "ABCD1234", "label_id": 1, "created": False}
133
133
  )
134
- mock_validate.assert_called_once_with("ABCD1234", kind=None)
134
+ mock_validate.assert_called_once_with("ABCD1234", kind=None, endianness=None)
135
135
 
136
136
  @patch("config.middleware.Node.get_local", return_value=None)
137
137
  @patch("config.middleware.get_site")
@@ -342,16 +342,20 @@ class ValidateRfidValueTests(SimpleTestCase):
342
342
  tag.released = False
343
343
  tag.reference = None
344
344
  tag.kind = RFID.CLASSIC
345
+ tag.endianness = RFID.BIG_ENDIAN
345
346
  mock_register.return_value = (tag, True)
346
347
 
347
348
  result = validate_rfid_value("abcd1234")
348
349
 
349
- mock_register.assert_called_once_with("ABCD1234", kind=None)
350
+ mock_register.assert_called_once_with(
351
+ "ABCD1234", kind=None, endianness=RFID.BIG_ENDIAN
352
+ )
350
353
  tag.save.assert_called_once_with(update_fields=["last_seen_on"])
351
354
  self.assertIs(tag.last_seen_on, fake_now)
352
355
  mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
353
356
  self.assertTrue(result["created"])
354
357
  self.assertEqual(result["rfid"], "ABCD1234")
358
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
355
359
 
356
360
  @patch("ocpp.rfid.reader.timezone.now")
357
361
  @patch("ocpp.rfid.reader.notify_async")
@@ -367,11 +371,14 @@ class ValidateRfidValueTests(SimpleTestCase):
367
371
  tag.released = True
368
372
  tag.reference = None
369
373
  tag.kind = RFID.CLASSIC
374
+ tag.endianness = RFID.BIG_ENDIAN
370
375
  mock_register.return_value = (tag, False)
371
376
 
372
377
  result = validate_rfid_value("abcd", kind=RFID.NTAG215)
373
378
 
374
- mock_register.assert_called_once_with("ABCD", kind=RFID.NTAG215)
379
+ mock_register.assert_called_once_with(
380
+ "ABCD", kind=RFID.NTAG215, endianness=RFID.BIG_ENDIAN
381
+ )
375
382
  tag.save.assert_called_once_with(update_fields=["kind", "last_seen_on"])
376
383
  self.assertIs(tag.last_seen_on, fake_now)
377
384
  self.assertEqual(tag.kind, RFID.NTAG215)
@@ -379,6 +386,36 @@ class ValidateRfidValueTests(SimpleTestCase):
379
386
  self.assertFalse(result["allowed"])
380
387
  self.assertFalse(result["created"])
381
388
  self.assertEqual(result["kind"], RFID.NTAG215)
389
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
390
+
391
+ @patch("ocpp.rfid.reader.timezone.now")
392
+ @patch("ocpp.rfid.reader.notify_async")
393
+ @patch("ocpp.rfid.reader.RFID.register_scan")
394
+ def test_registers_little_endian_value(
395
+ self, mock_register, mock_notify, mock_now
396
+ ):
397
+ fake_now = object()
398
+ mock_now.return_value = fake_now
399
+ tag = MagicMock()
400
+ tag.pk = 7
401
+ tag.label_id = 7
402
+ tag.allowed = True
403
+ tag.color = "B"
404
+ tag.released = False
405
+ tag.reference = None
406
+ tag.kind = RFID.CLASSIC
407
+ tag.endianness = RFID.LITTLE_ENDIAN
408
+ mock_register.return_value = (tag, True)
409
+
410
+ result = validate_rfid_value("A1B2C3D4", endianness=RFID.LITTLE_ENDIAN)
411
+
412
+ mock_register.assert_called_once_with(
413
+ "D4C3B2A1", kind=None, endianness=RFID.LITTLE_ENDIAN
414
+ )
415
+ tag.save.assert_called_once_with(update_fields=["last_seen_on"])
416
+ self.assertEqual(result["rfid"], "D4C3B2A1")
417
+ self.assertEqual(result["endianness"], RFID.LITTLE_ENDIAN)
418
+ mock_notify.assert_called_once()
382
419
 
383
420
  def test_rejects_invalid_value(self):
384
421
  result = validate_rfid_value("invalid!")
@@ -412,6 +449,7 @@ class ValidateRfidValueTests(SimpleTestCase):
412
449
  tag.released = False
413
450
  tag.reference = None
414
451
  tag.kind = RFID.CLASSIC
452
+ tag.endianness = RFID.BIG_ENDIAN
415
453
  mock_register.return_value = (tag, False)
416
454
  mock_run.return_value = types.SimpleNamespace(
417
455
  returncode=0, stdout="ok\n", stderr=""
@@ -427,6 +465,7 @@ class ValidateRfidValueTests(SimpleTestCase):
427
465
  env = run_kwargs.get("env", {})
428
466
  self.assertEqual(env.get("RFID_VALUE"), "ABCD1234")
429
467
  self.assertEqual(env.get("RFID_LABEL_ID"), "1")
468
+ self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
430
469
  mock_popen.assert_not_called()
431
470
  mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
432
471
  tag.save.assert_called_once_with(update_fields=["last_seen_on"])
@@ -437,6 +476,7 @@ class ValidateRfidValueTests(SimpleTestCase):
437
476
  self.assertEqual(output.get("stderr"), "")
438
477
  self.assertEqual(output.get("returncode"), 0)
439
478
  self.assertEqual(output.get("error"), "")
479
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
440
480
 
441
481
  @patch("ocpp.rfid.reader.timezone.now")
442
482
  @patch("ocpp.rfid.reader.notify_async")
@@ -457,6 +497,7 @@ class ValidateRfidValueTests(SimpleTestCase):
457
497
  tag.released = False
458
498
  tag.reference = None
459
499
  tag.kind = RFID.CLASSIC
500
+ tag.endianness = RFID.BIG_ENDIAN
460
501
  mock_register.return_value = (tag, False)
461
502
  mock_run.return_value = types.SimpleNamespace(
462
503
  returncode=1, stdout="", stderr="failure"
@@ -476,6 +517,77 @@ class ValidateRfidValueTests(SimpleTestCase):
476
517
  self.assertEqual(output.get("stderr"), "failure")
477
518
  self.assertEqual(output.get("error"), "")
478
519
  mock_popen.assert_not_called()
520
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
521
+
522
+ @patch("ocpp.rfid.reader.timezone.now")
523
+ @patch("ocpp.rfid.reader.notify_async")
524
+ @patch("ocpp.rfid.reader.subprocess.Popen")
525
+ @patch("ocpp.rfid.reader.subprocess.run")
526
+ @patch("ocpp.rfid.reader.RFID.register_scan")
527
+ def test_external_command_strips_trailing_percent_tokens(
528
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
529
+ ):
530
+ mock_now.return_value = timezone.now()
531
+ tag = MagicMock()
532
+ tag.pk = 3
533
+ tag.label_id = 3
534
+ tag.allowed = True
535
+ tag.external_command = "echo weird"
536
+ tag.color = "Y"
537
+ tag.released = False
538
+ tag.reference = None
539
+ tag.kind = RFID.CLASSIC
540
+ tag.endianness = RFID.BIG_ENDIAN
541
+ mock_register.return_value = (tag, False)
542
+ mock_run.return_value = types.SimpleNamespace(
543
+ returncode=0,
544
+ stdout="first %\nsecond 50%\r\nthird % %\n",
545
+ stderr="oops %\n",
546
+ )
547
+
548
+ result = validate_rfid_value("abc3")
549
+
550
+ output = result.get("command_output")
551
+ self.assertIsNotNone(output)
552
+ self.assertEqual(
553
+ output.get("stdout"), "first\nsecond 50%\r\nthird\n"
554
+ )
555
+ self.assertEqual(output.get("stderr"), "oops\n")
556
+ self.assertEqual(output.get("returncode"), 0)
557
+ self.assertEqual(output.get("error"), "")
558
+ mock_popen.assert_not_called()
559
+
560
+ @patch("ocpp.rfid.reader.timezone.now")
561
+ @patch("ocpp.rfid.reader.notify_async")
562
+ @patch("ocpp.rfid.reader.subprocess.Popen")
563
+ @patch("ocpp.rfid.reader.subprocess.run")
564
+ @patch("ocpp.rfid.reader.RFID.register_scan")
565
+ def test_external_command_error_strips_trailing_percent_tokens(
566
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
567
+ ):
568
+ mock_now.return_value = timezone.now()
569
+ tag = MagicMock()
570
+ tag.pk = 4
571
+ tag.label_id = 4
572
+ tag.allowed = True
573
+ tag.external_command = "echo boom"
574
+ tag.color = "R"
575
+ tag.released = False
576
+ tag.reference = None
577
+ tag.kind = RFID.CLASSIC
578
+ tag.endianness = RFID.BIG_ENDIAN
579
+ mock_register.return_value = (tag, False)
580
+ mock_run.side_effect = RuntimeError("bad % %")
581
+
582
+ result = validate_rfid_value("abcd")
583
+
584
+ output = result.get("command_output")
585
+ self.assertIsInstance(output, dict)
586
+ self.assertEqual(output.get("stdout"), "")
587
+ self.assertEqual(output.get("stderr"), "")
588
+ self.assertEqual(output.get("error"), "bad")
589
+ self.assertFalse(result["allowed"])
590
+ mock_popen.assert_not_called()
479
591
 
480
592
  @patch("ocpp.rfid.reader.timezone.now")
481
593
  @patch("ocpp.rfid.reader.notify_async")
@@ -497,6 +609,7 @@ class ValidateRfidValueTests(SimpleTestCase):
497
609
  tag.released = False
498
610
  tag.reference = None
499
611
  tag.kind = RFID.CLASSIC
612
+ tag.endianness = RFID.BIG_ENDIAN
500
613
  mock_register.return_value = (tag, False)
501
614
  result = validate_rfid_value("abcdef")
502
615
 
@@ -507,10 +620,12 @@ class ValidateRfidValueTests(SimpleTestCase):
507
620
  env = kwargs.get("env", {})
508
621
  self.assertEqual(env.get("RFID_VALUE"), "ABCDEF")
509
622
  self.assertEqual(env.get("RFID_LABEL_ID"), "3")
623
+ self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
510
624
  self.assertIs(kwargs.get("stdout"), subprocess.DEVNULL)
511
625
  self.assertIs(kwargs.get("stderr"), subprocess.DEVNULL)
512
626
  self.assertTrue(result["allowed"])
513
627
  mock_notify.assert_called_once_with("RFID 3 OK", "ABCDEF B")
628
+ self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
514
629
 
515
630
 
516
631
  class CardTypeDetectionTests(TestCase):
@@ -592,11 +707,12 @@ class RFIDLastSeenTests(TestCase):
592
707
 
593
708
  class RFIDDetectionScriptTests(SimpleTestCase):
594
709
  @patch("ocpp.rfid.detect._ensure_django")
710
+ @patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
595
711
  @patch(
596
712
  "ocpp.rfid.irq_wiring_check.check_irq_pin",
597
713
  return_value={"irq_pin": DEFAULT_IRQ_PIN},
598
714
  )
599
- def test_detect_scanner_success(self, mock_check, _mock_setup):
715
+ def test_detect_scanner_success(self, mock_check, _mock_lock, _mock_setup):
600
716
  result = detect_scanner()
601
717
  self.assertEqual(
602
718
  result,
@@ -608,16 +724,34 @@ class RFIDDetectionScriptTests(SimpleTestCase):
608
724
  mock_check.assert_called_once()
609
725
 
610
726
  @patch("ocpp.rfid.detect._ensure_django")
727
+ @patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
611
728
  @patch(
612
729
  "ocpp.rfid.irq_wiring_check.check_irq_pin",
613
730
  return_value={"error": "no scanner detected"},
614
731
  )
615
- def test_detect_scanner_failure(self, mock_check, _mock_setup):
732
+ def test_detect_scanner_failure(self, mock_check, _mock_lock, _mock_setup):
616
733
  result = detect_scanner()
617
734
  self.assertFalse(result["detected"])
618
735
  self.assertEqual(result["reason"], "no scanner detected")
619
736
  mock_check.assert_called_once()
620
737
 
738
+ @patch("ocpp.rfid.detect._ensure_django")
739
+ @patch(
740
+ "ocpp.rfid.detect._lockfile_status",
741
+ return_value=(True, Path("/locks/rfid.lck")),
742
+ )
743
+ @patch(
744
+ "ocpp.rfid.irq_wiring_check.check_irq_pin",
745
+ return_value={"error": "no scanner detected"},
746
+ )
747
+ def test_detect_scanner_assumed_with_lock(self, mock_check, _mock_lock, _mock_setup):
748
+ result = detect_scanner()
749
+ self.assertTrue(result["detected"])
750
+ self.assertTrue(result["assumed"])
751
+ self.assertEqual(result["reason"], "no scanner detected")
752
+ self.assertEqual(result["lockfile"], "/locks/rfid.lck")
753
+ mock_check.assert_called_once()
754
+
621
755
  @patch(
622
756
  "ocpp.rfid.detect.detect_scanner",
623
757
  return_value={"detected": True, "irq_pin": DEFAULT_IRQ_PIN},
@@ -642,6 +776,58 @@ class RFIDDetectionScriptTests(SimpleTestCase):
642
776
  self.assertIn("missing hardware", buffer.getvalue())
643
777
  mock_detect.assert_called_once()
644
778
 
779
+ @patch(
780
+ "ocpp.rfid.detect.detect_scanner",
781
+ return_value={
782
+ "detected": True,
783
+ "assumed": True,
784
+ "reason": "no scanner detected",
785
+ "lockfile": "/locks/rfid.lck",
786
+ },
787
+ )
788
+ def test_detect_main_assumed_output(self, mock_detect):
789
+ buffer = io.StringIO()
790
+ with patch("sys.stdout", new=buffer):
791
+ exit_code = detect_main([])
792
+ self.assertEqual(exit_code, 0)
793
+ self.assertIn("assumed active", buffer.getvalue())
794
+ self.assertIn("/locks/rfid.lck", buffer.getvalue())
795
+ mock_detect.assert_called_once()
796
+
797
+
798
+ class RFIDLockFileUsageTests(SimpleTestCase):
799
+ @patch("ocpp.rfid.background_reader.is_configured", return_value=True)
800
+ def test_queue_result_marks_lock(self, _mock_config):
801
+ with patch(
802
+ "ocpp.rfid.background_reader._tag_queue.get",
803
+ return_value={"rfid": "ABC"},
804
+ ) as mock_get, patch(
805
+ "ocpp.rfid.background_reader._mark_scanner_used"
806
+ ) as mock_mark:
807
+ result = background_reader.get_next_tag()
808
+ self.assertEqual(result["rfid"], "ABC")
809
+ mock_get.assert_called_once()
810
+ mock_mark.assert_called_once()
811
+
812
+ @patch("ocpp.rfid.background_reader.is_configured", return_value=True)
813
+ def test_direct_read_marks_lock(self, _mock_config):
814
+ with (
815
+ patch(
816
+ "ocpp.rfid.background_reader._tag_queue.get",
817
+ side_effect=background_reader.queue.Empty,
818
+ ) as mock_get,
819
+ patch(
820
+ "ocpp.rfid.reader.read_rfid",
821
+ return_value={"rfid": "XYZ"},
822
+ ) as mock_read,
823
+ patch("ocpp.rfid.background_reader._mark_scanner_used") as mock_mark,
824
+ ):
825
+ result = background_reader.get_next_tag()
826
+ self.assertEqual(result["rfid"], "XYZ")
827
+ mock_get.assert_called_once()
828
+ mock_read.assert_called_once()
829
+ mock_mark.assert_called_once()
830
+
645
831
 
646
832
  class RFIDLandingTests(TestCase):
647
833
  def test_scanner_view_registered_as_landing(self):
@@ -656,7 +842,9 @@ class RFIDLandingTests(TestCase):
656
842
  app = Application.objects.create(name="Ocpp")
657
843
  module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
658
844
  module.create_landings()
659
- self.assertTrue(module.landings.filter(path="/ocpp/rfid/").exists())
845
+ self.assertTrue(
846
+ module.landings.filter(path="/ocpp/rfid/validator/").exists()
847
+ )
660
848
 
661
849
 
662
850
  class ScannerTemplateTests(TestCase):