arthexis 0.1.16__py3-none-any.whl → 0.1.18__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
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  import asyncio
6
6
  import inspect
7
7
  import json
8
+ import logging
8
9
  from urllib.parse import parse_qs
9
10
  from django.utils import timezone
10
11
  from core.models import EnergyAccount, Reference, RFID as CoreRFID
@@ -32,6 +33,9 @@ from .evcs_discovery import (
32
33
  FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
34
 
34
35
 
36
+ logger = logging.getLogger(__name__)
37
+
38
+
35
39
  # Query parameter keys that may contain the charge point serial. Keys are
36
40
  # matched case-insensitively and trimmed before use.
37
41
  SERIAL_QUERY_PARAM_NAMES = (
@@ -309,6 +313,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
309
313
 
310
314
  return await database_sync_to_async(_ensure)()
311
315
 
316
+ def _log_unlinked_rfid(self, rfid: str) -> None:
317
+ """Record a warning when an RFID is authorized without an account."""
318
+
319
+ message = (
320
+ f"Authorized RFID {rfid} on charger {self.charger_id} without linked energy account"
321
+ )
322
+ logger.warning(message)
323
+ store.add_log(
324
+ store.pending_key(self.charger_id),
325
+ message,
326
+ log_type="charger",
327
+ )
328
+
312
329
  async def _assign_connector(self, connector: int | str | None) -> None:
313
330
  """Ensure ``self.charger`` matches the provided connector id."""
314
331
  if connector in (None, "", "-"):
@@ -1395,13 +1412,25 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1395
1412
  elif action == "Authorize":
1396
1413
  id_tag = payload.get("idTag")
1397
1414
  account = await self._get_account(id_tag)
1415
+ status = "Invalid"
1398
1416
  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
- )
1417
+ tag = None
1418
+ tag_created = False
1419
+ if id_tag:
1420
+ tag, tag_created = await database_sync_to_async(
1421
+ CoreRFID.register_scan
1422
+ )(id_tag)
1423
+ if account:
1424
+ if await database_sync_to_async(account.can_authorize)():
1425
+ status = "Accepted"
1426
+ elif (
1427
+ id_tag
1428
+ and tag
1429
+ and not tag_created
1430
+ and tag.allowed
1431
+ ):
1432
+ status = "Accepted"
1433
+ self._log_unlinked_rfid(tag.rfid)
1405
1434
  else:
1406
1435
  await self._ensure_rfid_seen(id_tag)
1407
1436
  status = "Accepted"
@@ -1475,23 +1504,38 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1475
1504
  reply_payload = {}
1476
1505
  elif action == "StartTransaction":
1477
1506
  id_tag = payload.get("idTag")
1478
- account = await self._get_account(id_tag)
1507
+ tag = None
1508
+ tag_created = False
1479
1509
  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)
1510
+ tag, tag_created = await database_sync_to_async(
1511
+ CoreRFID.register_scan
1512
+ )(id_tag)
1513
+ account = await self._get_account(id_tag)
1514
+ if id_tag and not self.charger.require_rfid:
1515
+ seen_tag = await self._ensure_rfid_seen(id_tag)
1516
+ if seen_tag:
1517
+ tag = seen_tag
1486
1518
  await self._assign_connector(payload.get("connectorId"))
1519
+ authorized = True
1520
+ authorized_via_tag = False
1487
1521
  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
1522
+ if account is not None:
1523
+ authorized = await database_sync_to_async(
1524
+ account.can_authorize
1525
+ )()
1526
+ elif (
1527
+ id_tag
1528
+ and tag
1529
+ and not tag_created
1530
+ and getattr(tag, "allowed", False)
1531
+ ):
1532
+ authorized = True
1533
+ authorized_via_tag = True
1534
+ else:
1535
+ authorized = False
1494
1536
  if authorized:
1537
+ if authorized_via_tag and tag:
1538
+ self._log_unlinked_rfid(tag.rfid)
1495
1539
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1496
1540
  received_start = timezone.now()
1497
1541
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
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):
ocpp/tests.py CHANGED
@@ -1931,6 +1931,27 @@ class ChargerLandingTests(TestCase):
1931
1931
  finally:
1932
1932
  store.transactions.pop(key, None)
1933
1933
 
1934
+ def test_public_page_shows_available_when_status_stale(self):
1935
+ charger = Charger.objects.create(
1936
+ charger_id="STALEPUB",
1937
+ last_status="Charging",
1938
+ )
1939
+ response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
1940
+ self.assertEqual(response.status_code, 200)
1941
+ self.assertContains(
1942
+ response,
1943
+ 'style="background-color: #0d6efd; color: #fff;">Available</span>',
1944
+ )
1945
+
1946
+ def test_admin_status_shows_available_when_status_stale(self):
1947
+ charger = Charger.objects.create(
1948
+ charger_id="STALEADM",
1949
+ last_status="Charging",
1950
+ )
1951
+ response = self.client.get(reverse("charger-status", args=["STALEADM"]))
1952
+ self.assertEqual(response.status_code, 200)
1953
+ self.assertContains(response, 'id="charger-state">Available</strong>')
1954
+
1934
1955
  def test_public_status_shows_rfid_link_for_known_tag(self):
1935
1956
  aggregate = Charger.objects.create(charger_id="PUBRFID")
1936
1957
  connector = Charger.objects.create(
@@ -2129,6 +2150,32 @@ class ChargerAdminTests(TestCase):
2129
2150
  resp = self.client.get(url)
2130
2151
  self.assertNotContains(resp, charger.reference.image.url)
2131
2152
 
2153
+ def test_toggle_rfid_authentication_action_toggles_value(self):
2154
+ charger_requires = Charger.objects.create(
2155
+ charger_id="RFIDON", require_rfid=True
2156
+ )
2157
+ charger_optional = Charger.objects.create(
2158
+ charger_id="RFIDOFF", require_rfid=False
2159
+ )
2160
+ url = reverse("admin:ocpp_charger_changelist")
2161
+ response = self.client.post(
2162
+ url,
2163
+ {
2164
+ "action": "toggle_rfid_authentication",
2165
+ "_selected_action": [
2166
+ charger_requires.pk,
2167
+ charger_optional.pk,
2168
+ ],
2169
+ },
2170
+ follow=True,
2171
+ )
2172
+ self.assertEqual(response.status_code, 200)
2173
+ charger_requires.refresh_from_db()
2174
+ charger_optional.refresh_from_db()
2175
+ self.assertFalse(charger_requires.require_rfid)
2176
+ self.assertTrue(charger_optional.require_rfid)
2177
+ self.assertContains(response, "Updated RFID authentication")
2178
+
2132
2179
  def test_admin_lists_log_link(self):
2133
2180
  charger = Charger.objects.create(charger_id="LOG1")
2134
2181
  url = reverse("admin:ocpp_charger_changelist")
@@ -2155,6 +2202,48 @@ class ChargerAdminTests(TestCase):
2155
2202
  finally:
2156
2203
  store.transactions.pop(key, None)
2157
2204
 
2205
+ def test_admin_status_shows_available_when_status_stale(self):
2206
+ charger = Charger.objects.create(
2207
+ charger_id="ADMINSTALE",
2208
+ last_status="Charging",
2209
+ )
2210
+ url = reverse("admin:ocpp_charger_changelist")
2211
+ resp = self.client.get(url)
2212
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
2213
+ self.assertContains(resp, f">{available_label}<")
2214
+
2215
+ def test_recheck_charger_status_action_sends_trigger(self):
2216
+ charger = Charger.objects.create(charger_id="RECHECK1")
2217
+
2218
+ class DummyConnection:
2219
+ def __init__(self):
2220
+ self.sent: list[str] = []
2221
+
2222
+ async def send(self, message):
2223
+ self.sent.append(message)
2224
+
2225
+ ws = DummyConnection()
2226
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2227
+ try:
2228
+ url = reverse("admin:ocpp_charger_changelist")
2229
+ response = self.client.post(
2230
+ url,
2231
+ {
2232
+ "action": "recheck_charger_status",
2233
+ "index": 0,
2234
+ "select_across": 0,
2235
+ "_selected_action": [charger.pk],
2236
+ },
2237
+ follow=True,
2238
+ )
2239
+ self.assertEqual(response.status_code, 200)
2240
+ self.assertTrue(ws.sent)
2241
+ self.assertIn("TriggerMessage", ws.sent[0])
2242
+ self.assertContains(response, "Requested status update")
2243
+ finally:
2244
+ store.pop_connection(charger.charger_id, charger.connector_id)
2245
+ store.clear_pending_calls(charger.charger_id)
2246
+
2158
2247
  def test_admin_log_view_displays_entries(self):
2159
2248
  charger = Charger.objects.create(charger_id="LOG2")
2160
2249
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
@@ -2860,6 +2949,44 @@ class SimulatorAdminTests(TransactionTestCase):
2860
2949
 
2861
2950
  await communicator.disconnect()
2862
2951
 
2952
+ async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
2953
+ charger_id = "AUTHWARN"
2954
+ tag_value = "WARN01"
2955
+ await database_sync_to_async(Charger.objects.create)(
2956
+ charger_id=charger_id, require_rfid=True
2957
+ )
2958
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
2959
+
2960
+ pending_key = store.pending_key(charger_id)
2961
+ store.clear_log(pending_key, log_type="charger")
2962
+
2963
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
2964
+ connected, _ = await communicator.connect()
2965
+ self.assertTrue(connected)
2966
+
2967
+ message_id = "auth-unlinked"
2968
+ await communicator.send_json_to(
2969
+ [2, message_id, "Authorize", {"idTag": tag_value}]
2970
+ )
2971
+ response = await communicator.receive_json_from()
2972
+ self.assertEqual(response[0], 3)
2973
+ self.assertEqual(response[1], message_id)
2974
+ self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
2975
+
2976
+ log_entries = store.get_logs(pending_key, log_type="charger")
2977
+ self.assertTrue(
2978
+ any(
2979
+ "Authorized RFID" in entry
2980
+ and tag_value in entry
2981
+ and charger_id in entry
2982
+ for entry in log_entries
2983
+ ),
2984
+ log_entries,
2985
+ )
2986
+
2987
+ await communicator.disconnect()
2988
+ store.clear_log(pending_key, log_type="charger")
2989
+
2863
2990
  async def test_authorize_without_requirement_records_rfid(self):
2864
2991
  await database_sync_to_async(Charger.objects.create)(
2865
2992
  charger_id="AUTHOPT", require_rfid=False
@@ -2952,6 +3079,61 @@ class SimulatorAdminTests(TransactionTestCase):
2952
3079
  )
2953
3080
  self.assertEqual(tx.account_id, user.energy_account.id)
2954
3081
 
3082
+ async def test_start_transaction_allows_allowed_tag_without_account(self):
3083
+ charger_id = "STARTWARN"
3084
+ tag_value = "WARN02"
3085
+ await database_sync_to_async(Charger.objects.create)(
3086
+ charger_id=charger_id, require_rfid=True
3087
+ )
3088
+ await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
3089
+
3090
+ pending_key = store.pending_key(charger_id)
3091
+ store.clear_log(pending_key, log_type="charger")
3092
+
3093
+ communicator = WebsocketCommunicator(application, f"/{charger_id}/")
3094
+ connected, _ = await communicator.connect()
3095
+ self.assertTrue(connected)
3096
+
3097
+ start_payload = {
3098
+ "meterStart": 5,
3099
+ "idTag": tag_value,
3100
+ "connectorId": 1,
3101
+ }
3102
+ await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
3103
+ response = await communicator.receive_json_from()
3104
+ self.assertEqual(response[0], 3)
3105
+ self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
3106
+ tx_id = response[2]["transactionId"]
3107
+
3108
+ tx = await database_sync_to_async(Transaction.objects.get)(
3109
+ pk=tx_id, charger__charger_id=charger_id
3110
+ )
3111
+ self.assertIsNone(tx.account_id)
3112
+
3113
+ log_entries = store.get_logs(pending_key, log_type="charger")
3114
+ self.assertTrue(
3115
+ any(
3116
+ "Authorized RFID" in entry
3117
+ and tag_value in entry
3118
+ and charger_id in entry
3119
+ for entry in log_entries
3120
+ ),
3121
+ log_entries,
3122
+ )
3123
+
3124
+ await communicator.send_json_to(
3125
+ [
3126
+ 2,
3127
+ "stop-1",
3128
+ "StopTransaction",
3129
+ {"transactionId": tx_id, "meterStop": 6},
3130
+ ]
3131
+ )
3132
+ await communicator.receive_json_from()
3133
+
3134
+ await communicator.disconnect()
3135
+ store.clear_log(pending_key, log_type="charger")
3136
+
2955
3137
  async def test_status_fields_updated(self):
2956
3138
  communicator = WebsocketCommunicator(application, "/STAT/")
2957
3139
  connected, _ = await communicator.connect()
@@ -4442,6 +4624,49 @@ class LiveUpdateViewTests(TestCase):
4442
4624
  )
4443
4625
  self.assertEqual(aggregate_entry["state"], available_label)
4444
4626
 
4627
+ def test_dashboard_connector_treats_finishing_as_available_without_session(self):
4628
+ charger = Charger.objects.create(
4629
+ charger_id="FINISH-STATE",
4630
+ connector_id=1,
4631
+ last_status="Finishing",
4632
+ )
4633
+
4634
+ resp = self.client.get(reverse("ocpp-dashboard"))
4635
+ self.assertEqual(resp.status_code, 200)
4636
+ self.assertIsNotNone(resp.context)
4637
+ context = resp.context
4638
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
4639
+ entry = next(
4640
+ item
4641
+ for item in context["chargers"]
4642
+ if item["charger"].pk == charger.pk
4643
+ )
4644
+ self.assertEqual(entry["state"], available_label)
4645
+
4646
+ def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
4647
+ aggregate = Charger.objects.create(
4648
+ charger_id="FINISH-AGG",
4649
+ connector_id=None,
4650
+ last_status="Finishing",
4651
+ )
4652
+ Charger.objects.create(
4653
+ charger_id=aggregate.charger_id,
4654
+ connector_id=1,
4655
+ last_status="Finishing",
4656
+ )
4657
+
4658
+ resp = self.client.get(reverse("ocpp-dashboard"))
4659
+ self.assertEqual(resp.status_code, 200)
4660
+ self.assertIsNotNone(resp.context)
4661
+ context = resp.context
4662
+ available_label = force_str(STATUS_BADGE_MAP["available"][0])
4663
+ aggregate_entry = next(
4664
+ item
4665
+ for item in context["chargers"]
4666
+ if item["charger"].pk == aggregate.pk
4667
+ )
4668
+ self.assertEqual(aggregate_entry["state"], available_label)
4669
+
4445
4670
  def test_dashboard_aggregate_uses_connection_when_status_missing(self):
4446
4671
  aggregate = Charger.objects.create(
4447
4672
  charger_id="DASHAGG-CONN", last_status="Charging"
ocpp/views.py CHANGED
@@ -370,10 +370,6 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
370
370
  )
371
371
  statuses: list[str] = []
372
372
  for sibling in siblings:
373
- status_value = (sibling.last_status or "").strip()
374
- if status_value:
375
- statuses.append(status_value.casefold())
376
- continue
377
373
  tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
378
374
  if not tx_obj:
379
375
  tx_obj = (
@@ -381,9 +377,22 @@ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
381
377
  .order_by("-start_time")
382
378
  .first()
383
379
  )
384
- if _has_active_session(tx_obj):
380
+ has_session = _has_active_session(tx_obj)
381
+ status_value = (sibling.last_status or "").strip()
382
+ normalized_status = status_value.casefold() if status_value else ""
383
+ error_code_lower = (sibling.last_error_code or "").strip().lower()
384
+ if has_session:
385
385
  statuses.append("charging")
386
386
  continue
387
+ if (
388
+ normalized_status in {"charging", "finishing"}
389
+ and error_code_lower in ERROR_OK_VALUES
390
+ ):
391
+ statuses.append("available")
392
+ continue
393
+ if normalized_status:
394
+ statuses.append(normalized_status)
395
+ continue
387
396
  if store.is_connected(sibling.charger_id, sibling.connector_id):
388
397
  statuses.append("available")
389
398
 
@@ -424,6 +433,15 @@ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
424
433
  # while a session is active. Override the badge so the user can see
425
434
  # the charger is actually busy.
426
435
  label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
436
+ elif (
437
+ not has_session
438
+ and key in {"charging", "finishing"}
439
+ and error_code_lower in ERROR_OK_VALUES
440
+ ):
441
+ # Some chargers continue reporting "Charging" after a session ends.
442
+ # When no active transaction exists, surface the state as available
443
+ # so the UI reflects the actual behaviour at the site.
444
+ label, color = STATUS_BADGE_MAP.get("available", (_("Available"), "#0d6efd"))
427
445
  elif error_code and error_code_lower not in ERROR_OK_VALUES:
428
446
  label = _("%(status)s (%(error)s)") % {
429
447
  "status": label,
@@ -1191,17 +1209,38 @@ def charger_log_page(request, cid, connector=None):
1191
1209
  charger_id=cid
1192
1210
  )
1193
1211
  target_id = cid
1194
- log = store.get_logs(target_id, log_type=log_type)
1212
+ limit_options = [
1213
+ {"value": "10", "label": "10"},
1214
+ {"value": "20", "label": "20"},
1215
+ {"value": "40", "label": "40"},
1216
+ {"value": "100", "label": "100"},
1217
+ {"value": "all", "label": gettext("All")},
1218
+ ]
1219
+ allowed_values = [item["value"] for item in limit_options]
1220
+ limit_choice = request.GET.get("limit", "20")
1221
+ if limit_choice not in allowed_values:
1222
+ limit_choice = "20"
1223
+
1224
+ log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
1225
+ if limit_choice != "all":
1226
+ try:
1227
+ limit_value = int(limit_choice)
1228
+ except (TypeError, ValueError):
1229
+ limit_value = 20
1230
+ limit_choice = "20"
1231
+ log_entries = log_entries[-limit_value:]
1195
1232
  return render(
1196
1233
  request,
1197
1234
  "ocpp/charger_logs.html",
1198
1235
  {
1199
1236
  "charger": charger,
1200
- "log": log,
1237
+ "log": log_entries,
1201
1238
  "log_type": log_type,
1202
1239
  "connector_slug": connector_slug,
1203
1240
  "connector_links": connector_links,
1204
1241
  "status_url": status_url,
1242
+ "log_limit_options": limit_options,
1243
+ "log_limit_index": allowed_values.index(limit_choice),
1205
1244
  },
1206
1245
  )
1207
1246