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/tests.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  import sys
3
3
  import time
4
+ import tempfile
5
+ from collections import deque
4
6
  from importlib import util as importlib_util
5
7
  from pathlib import Path
6
8
  from types import ModuleType
@@ -63,13 +65,17 @@ from .models import (
63
65
  Transaction,
64
66
  Charger,
65
67
  ChargerConfiguration,
68
+ ConfigurationKey,
66
69
  Simulator,
67
70
  MeterReading,
71
+ MeterValue,
68
72
  Location,
69
73
  DataTransferMessage,
70
74
  CPReservation,
75
+ CPFirmware,
76
+ CPFirmwareDeployment,
71
77
  )
72
- from .admin import ChargerConfigurationAdmin
78
+ from .admin import ChargerConfigurationAdmin, ConfigurationKeyAdmin, ConfigurationKeyInline
73
79
  from .consumers import CSMSConsumer
74
80
  from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
75
81
  from .status_display import STATUS_BADGE_MAP
@@ -90,7 +96,8 @@ from .tasks import (
90
96
  check_charge_point_configuration,
91
97
  schedule_daily_charge_point_configuration_checks,
92
98
  )
93
- from django.db import close_old_connections
99
+ from django.db import close_old_connections, connection
100
+ from django.test.utils import CaptureQueriesContext
94
101
  from django.db.utils import OperationalError
95
102
  from urllib.parse import unquote, urlparse
96
103
 
@@ -394,6 +401,45 @@ class CPReservationTests(TransactionTestCase):
394
401
  self.assertIsNotNone(metadata)
395
402
  self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
396
403
 
404
+ def test_send_cancel_request_dispatches_frame(self):
405
+ start = timezone.now() + timedelta(hours=1)
406
+ reservation = CPReservation.objects.create(
407
+ location=self.location,
408
+ start_time=start,
409
+ duration_minutes=30,
410
+ id_tag="TAG010",
411
+ )
412
+
413
+ class DummyConnection:
414
+ def __init__(self):
415
+ self.sent: list[str] = []
416
+
417
+ async def send(self, message):
418
+ self.sent.append(message)
419
+
420
+ ws = DummyConnection()
421
+ store.set_connection(
422
+ reservation.connector.charger_id,
423
+ reservation.connector.connector_id,
424
+ ws,
425
+ )
426
+ self.addCleanup(
427
+ store.pop_connection,
428
+ reservation.connector.charger_id,
429
+ reservation.connector.connector_id,
430
+ )
431
+
432
+ message_id = reservation.send_cancel_request()
433
+ self.assertTrue(ws.sent)
434
+ frame = json.loads(ws.sent[0])
435
+ self.assertEqual(frame[0], 2)
436
+ self.assertEqual(frame[2], "CancelReservation")
437
+ self.assertEqual(frame[3]["reservationId"], reservation.pk)
438
+ metadata = store.pending_calls.get(message_id)
439
+ self.assertIsNotNone(metadata)
440
+ self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
441
+ self.assertEqual(metadata.get("action"), "CancelReservation")
442
+
397
443
  def test_call_result_marks_reservation_confirmed(self):
398
444
  start = timezone.now() + timedelta(hours=1)
399
445
  reservation = CPReservation.objects.create(
@@ -482,6 +528,95 @@ class CPReservationTests(TransactionTestCase):
482
528
  self.assertIsNone(reservation.evcs_confirmed_at)
483
529
  self.assertIn("Rejected", reservation.evcs_error or "")
484
530
 
531
+ def test_cancel_reservation_result_updates_status(self):
532
+ start = timezone.now() + timedelta(hours=1)
533
+ reservation = CPReservation.objects.create(
534
+ location=self.location,
535
+ start_time=start,
536
+ duration_minutes=45,
537
+ id_tag="TAG008",
538
+ )
539
+ log_key = store.identity_key(
540
+ reservation.connector.charger_id, reservation.connector.connector_id
541
+ )
542
+ message_id = "cancel-success"
543
+ store.register_pending_call(
544
+ message_id,
545
+ {
546
+ "action": "CancelReservation",
547
+ "charger_id": reservation.connector.charger_id,
548
+ "connector_id": reservation.connector.connector_id,
549
+ "log_key": log_key,
550
+ "reservation_pk": reservation.pk,
551
+ },
552
+ )
553
+
554
+ consumer = CSMSConsumer()
555
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
556
+ consumer.charger_id = reservation.connector.charger_id
557
+ consumer.store_key = log_key
558
+ consumer.connector_value = reservation.connector.connector_id
559
+ consumer.charger = reservation.connector
560
+ consumer.aggregate_charger = self.aggregate
561
+ consumer._consumption_task = None
562
+ consumer._consumption_message_uuid = None
563
+ consumer.send = AsyncMock()
564
+
565
+ async_to_sync(consumer._handle_call_result)(
566
+ message_id, {"status": "Accepted"}
567
+ )
568
+ reservation.refresh_from_db()
569
+ self.assertEqual(reservation.evcs_status, "Accepted")
570
+ self.assertFalse(reservation.evcs_confirmed)
571
+ self.assertIsNone(reservation.evcs_confirmed_at)
572
+ self.assertEqual(reservation.evcs_error, "")
573
+
574
+ def test_cancel_reservation_error_updates_status(self):
575
+ start = timezone.now() + timedelta(hours=1)
576
+ reservation = CPReservation.objects.create(
577
+ location=self.location,
578
+ start_time=start,
579
+ duration_minutes=45,
580
+ id_tag="TAG009",
581
+ )
582
+ log_key = store.identity_key(
583
+ reservation.connector.charger_id, reservation.connector.connector_id
584
+ )
585
+ message_id = "cancel-error"
586
+ store.register_pending_call(
587
+ message_id,
588
+ {
589
+ "action": "CancelReservation",
590
+ "charger_id": reservation.connector.charger_id,
591
+ "connector_id": reservation.connector.connector_id,
592
+ "log_key": log_key,
593
+ "reservation_pk": reservation.pk,
594
+ },
595
+ )
596
+
597
+ consumer = CSMSConsumer()
598
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
599
+ consumer.charger_id = reservation.connector.charger_id
600
+ consumer.store_key = log_key
601
+ consumer.connector_value = reservation.connector.connector_id
602
+ consumer.charger = reservation.connector
603
+ consumer.aggregate_charger = self.aggregate
604
+ consumer._consumption_task = None
605
+ consumer._consumption_message_uuid = None
606
+ consumer.send = AsyncMock()
607
+
608
+ async_to_sync(consumer._handle_call_error)(
609
+ message_id,
610
+ "Rejected",
611
+ "Connector busy",
612
+ {"reason": "occupied"},
613
+ )
614
+ reservation.refresh_from_db()
615
+ self.assertEqual(reservation.evcs_status, "")
616
+ self.assertFalse(reservation.evcs_confirmed)
617
+ self.assertIsNone(reservation.evcs_confirmed_at)
618
+ self.assertIn("Rejected", reservation.evcs_error or "")
619
+
485
620
 
486
621
  class ChargerUrlFallbackTests(TestCase):
487
622
  @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
@@ -532,6 +667,33 @@ class CSMSConsumerTests(TransactionTestCase):
532
667
  await asyncio.sleep(delay)
533
668
  raise
534
669
 
670
+ def _create_firmware_deployment(self, charger_id: str) -> int:
671
+ try:
672
+ charger = Charger.objects.get(charger_id=charger_id, connector_id=None)
673
+ except Charger.DoesNotExist:
674
+ charger = Charger.objects.create(charger_id=charger_id, connector_id=None)
675
+ firmware = CPFirmware.objects.create(
676
+ name=f"{charger_id} firmware",
677
+ filename=f"{charger_id}.bin",
678
+ payload_binary=b"firmware",
679
+ content_type="application/octet-stream",
680
+ source=CPFirmware.Source.DOWNLOAD,
681
+ is_user_data=True,
682
+ )
683
+ deployment = CPFirmwareDeployment.objects.create(
684
+ firmware=firmware,
685
+ charger=charger,
686
+ node=charger.node_origin,
687
+ ocpp_message_id=f"deploy-{charger_id}",
688
+ status="Pending",
689
+ status_info="",
690
+ status_timestamp=timezone.now(),
691
+ retrieve_date=timezone.now(),
692
+ request_payload={},
693
+ is_user_data=True,
694
+ )
695
+ return deployment.pk
696
+
535
697
  async def _send_status_notification(self, serial: str, payload: dict):
536
698
  communicator = WebsocketCommunicator(application, f"/{serial}/")
537
699
  connected, _ = await communicator.connect()
@@ -712,7 +874,9 @@ class CSMSConsumerTests(TransactionTestCase):
712
874
  store.logs.setdefault("charger", {})
713
875
  store.logs["charger"].clear()
714
876
  for key, entries in original_logs.items():
715
- store.logs["charger"][key] = list(entries)
877
+ store.logs["charger"][key] = deque(
878
+ entries, maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
879
+ )
716
880
  store.log_names.setdefault("charger", {})
717
881
  store.log_names["charger"].clear()
718
882
  store.log_names["charger"].update(original_log_names)
@@ -977,6 +1141,151 @@ class CSMSConsumerTests(TransactionTestCase):
977
1141
  self.assertEqual(charger.availability_requested_state, "Inoperative")
978
1142
  await communicator.disconnect()
979
1143
 
1144
+ async def test_clear_cache_result_updates_local_auth_version(self):
1145
+ store.pending_calls.clear()
1146
+ log_key = store.identity_key("CLEARCACHE", None)
1147
+ store.clear_log(log_key, log_type="charger")
1148
+ communicator = WebsocketCommunicator(application, "/CLEARCACHE/")
1149
+ connected, _ = await communicator.connect()
1150
+ self.assertTrue(connected)
1151
+
1152
+ await communicator.send_json_to(
1153
+ [
1154
+ 2,
1155
+ "boot",
1156
+ "BootNotification",
1157
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
1158
+ ]
1159
+ )
1160
+ await communicator.receive_json_from()
1161
+
1162
+ message_id = "cc-accepted"
1163
+ requested_at = timezone.now()
1164
+ store.register_pending_call(
1165
+ message_id,
1166
+ {
1167
+ "action": "ClearCache",
1168
+ "charger_id": "CLEARCACHE",
1169
+ "connector_id": None,
1170
+ "log_key": log_key,
1171
+ "requested_at": requested_at,
1172
+ },
1173
+ )
1174
+
1175
+ mock_update = AsyncMock()
1176
+ with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
1177
+ await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
1178
+ await asyncio.sleep(0.05)
1179
+
1180
+ mock_update.assert_awaited_with(0)
1181
+ result = store.wait_for_pending_call(message_id, timeout=0.1)
1182
+ self.assertIsNotNone(result)
1183
+ payload = result.get("payload") or {}
1184
+ self.assertEqual(payload.get("status"), "Accepted")
1185
+ log_entries = store.logs["charger"].get(log_key, [])
1186
+ self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
1187
+
1188
+ store.clear_log(log_key, log_type="charger")
1189
+ await communicator.disconnect()
1190
+
1191
+ async def test_clear_cache_rejection_updates_timestamp(self):
1192
+ store.pending_calls.clear()
1193
+ log_key = store.identity_key("CLEARCACHE-REJ", None)
1194
+ store.clear_log(log_key, log_type="charger")
1195
+ communicator = WebsocketCommunicator(application, "/CLEARCACHE-REJ/")
1196
+ connected, _ = await communicator.connect()
1197
+ self.assertTrue(connected)
1198
+
1199
+ await communicator.send_json_to(
1200
+ [
1201
+ 2,
1202
+ "boot",
1203
+ "BootNotification",
1204
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
1205
+ ]
1206
+ )
1207
+ await communicator.receive_json_from()
1208
+
1209
+ message_id = "cc-rejected"
1210
+ store.register_pending_call(
1211
+ message_id,
1212
+ {
1213
+ "action": "ClearCache",
1214
+ "charger_id": "CLEARCACHE-REJ",
1215
+ "connector_id": None,
1216
+ "log_key": log_key,
1217
+ },
1218
+ )
1219
+
1220
+ mock_update = AsyncMock()
1221
+ with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
1222
+ await communicator.send_json_to([3, message_id, {"status": "Rejected"}])
1223
+ await asyncio.sleep(0.05)
1224
+
1225
+ mock_update.assert_awaited_with(None)
1226
+ result = store.wait_for_pending_call(message_id, timeout=0.1)
1227
+ self.assertIsNotNone(result)
1228
+ payload = result.get("payload") or {}
1229
+ self.assertEqual(payload.get("status"), "Rejected")
1230
+ log_entries = store.logs["charger"].get(log_key, [])
1231
+ self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
1232
+
1233
+ store.clear_log(log_key, log_type="charger")
1234
+ await communicator.disconnect()
1235
+
1236
+ async def test_clear_cache_error_records_failure(self):
1237
+ store.pending_calls.clear()
1238
+ log_key = store.identity_key("CLEARCACHE-ERR", None)
1239
+ store.clear_log(log_key, log_type="charger")
1240
+ communicator = WebsocketCommunicator(application, "/CLEARCACHE-ERR/")
1241
+ connected, _ = await communicator.connect()
1242
+ self.assertTrue(connected)
1243
+
1244
+ await communicator.send_json_to(
1245
+ [
1246
+ 2,
1247
+ "boot",
1248
+ "BootNotification",
1249
+ {"chargePointVendor": "Test", "chargePointModel": "Model"},
1250
+ ]
1251
+ )
1252
+ await communicator.receive_json_from()
1253
+
1254
+ message_id = "cc-error"
1255
+ store.register_pending_call(
1256
+ message_id,
1257
+ {
1258
+ "action": "ClearCache",
1259
+ "charger_id": "CLEARCACHE-ERR",
1260
+ "connector_id": None,
1261
+ "log_key": log_key,
1262
+ },
1263
+ )
1264
+
1265
+ mock_update = AsyncMock()
1266
+ with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
1267
+ await communicator.send_json_to(
1268
+ [
1269
+ 4,
1270
+ message_id,
1271
+ "InternalError",
1272
+ "Failed",
1273
+ {"detail": "boom"},
1274
+ ]
1275
+ )
1276
+ await asyncio.sleep(0.05)
1277
+
1278
+ mock_update.assert_awaited_with(None)
1279
+ result = store.wait_for_pending_call(message_id, timeout=0.1)
1280
+ self.assertIsNotNone(result)
1281
+ self.assertFalse(result.get("success"))
1282
+ self.assertEqual(result.get("error_code"), "InternalError")
1283
+ log_entries = store.logs["charger"].get(log_key, [])
1284
+ self.assertTrue(any("ClearCache error" in entry for entry in log_entries))
1285
+
1286
+ store.clear_log(log_key, log_type="charger")
1287
+ await communicator.disconnect()
1288
+
980
1289
  async def test_get_configuration_result_logged(self):
981
1290
  store.pending_calls.clear()
982
1291
  pending_key = store.pending_key("CFGRES")
@@ -1040,6 +1349,30 @@ class CSMSConsumerTests(TransactionTestCase):
1040
1349
  }
1041
1350
  ],
1042
1351
  )
1352
+ key_rows = await database_sync_to_async(
1353
+ lambda: [
1354
+ {
1355
+ "key": item.key,
1356
+ "value": item.value,
1357
+ "readonly": item.readonly,
1358
+ "has_value": item.has_value,
1359
+ }
1360
+ for item in ConfigurationKey.objects.filter(
1361
+ configuration=configuration
1362
+ ).order_by("position", "id")
1363
+ ]
1364
+ )()
1365
+ self.assertEqual(
1366
+ key_rows,
1367
+ [
1368
+ {
1369
+ "key": "AllowOfflineTxForUnknownId",
1370
+ "value": "false",
1371
+ "readonly": True,
1372
+ "has_value": True,
1373
+ }
1374
+ ],
1375
+ )
1043
1376
  self.assertEqual(configuration.unknown_keys, [])
1044
1377
  config_ids = await database_sync_to_async(
1045
1378
  lambda: set(
@@ -1262,91 +1595,179 @@ class CSMSConsumerTests(TransactionTestCase):
1262
1595
  await communicator.disconnect()
1263
1596
 
1264
1597
  async def test_firmware_status_notification_updates_database_and_views(self):
1598
+ store.ip_connections.clear()
1599
+ limit = store.MAX_CONNECTIONS_PER_IP
1600
+ store.MAX_CONNECTIONS_PER_IP = 10
1265
1601
  communicator = WebsocketCommunicator(application, "/FWSTAT/")
1266
- connected, _ = await communicator.connect()
1267
- self.assertTrue(connected)
1602
+ try:
1603
+ connected, detail = await communicator.connect()
1604
+ self.assertTrue(connected, detail)
1268
1605
 
1269
- ts = timezone.now().replace(microsecond=0)
1270
- payload = {
1271
- "status": "Installing",
1272
- "statusInfo": "Applying patch",
1273
- "timestamp": ts.isoformat(),
1274
- }
1606
+ deployment_pk = await database_sync_to_async(
1607
+ self._create_firmware_deployment
1608
+ )("FWSTAT")
1609
+ ts = timezone.now().replace(microsecond=0)
1610
+ payload = {
1611
+ "status": "Installing",
1612
+ "statusInfo": "Applying patch",
1613
+ "timestamp": ts.isoformat(),
1614
+ }
1275
1615
 
1276
- await communicator.send_json_to(
1277
- [2, "1", "FirmwareStatusNotification", payload]
1278
- )
1279
- response = await communicator.receive_json_from()
1280
- self.assertEqual(response, [3, "1", {}])
1616
+ await communicator.send_json_to(
1617
+ [2, "1", "FirmwareStatusNotification", payload]
1618
+ )
1619
+ response = await communicator.receive_json_from()
1620
+ self.assertEqual(response, [3, "1", {}])
1281
1621
 
1282
- def _fetch_status():
1283
- charger = Charger.objects.get(charger_id="FWSTAT", connector_id=None)
1284
- return (
1285
- charger.firmware_status,
1286
- charger.firmware_status_info,
1287
- charger.firmware_timestamp,
1622
+ def _fetch_status():
1623
+ charger = Charger.objects.get(
1624
+ charger_id="FWSTAT", connector_id=None
1625
+ )
1626
+ return (
1627
+ charger.firmware_status,
1628
+ charger.firmware_status_info,
1629
+ charger.firmware_timestamp,
1630
+ )
1631
+
1632
+ status, info, recorded_ts = await database_sync_to_async(
1633
+ _fetch_status
1634
+ )()
1635
+ self.assertEqual(status, "Installing")
1636
+ self.assertEqual(info, "Applying patch")
1637
+ self.assertIsNotNone(recorded_ts)
1638
+ self.assertEqual(recorded_ts.replace(microsecond=0), ts)
1639
+
1640
+ def _fetch_deployment():
1641
+ return CPFirmwareDeployment.objects.get(pk=deployment_pk)
1642
+
1643
+ deployment = await database_sync_to_async(_fetch_deployment)()
1644
+ self.assertEqual(deployment.status, "Installing")
1645
+ self.assertEqual(deployment.status_info, "Applying patch")
1646
+ self.assertIsNotNone(deployment.status_timestamp)
1647
+ self.assertEqual(
1648
+ deployment.status_timestamp.replace(microsecond=0), ts
1649
+ )
1650
+
1651
+ log_entries = store.get_logs(
1652
+ store.identity_key("FWSTAT", None), log_type="charger"
1653
+ )
1654
+ self.assertTrue(
1655
+ any("FirmwareStatusNotification" in entry for entry in log_entries)
1288
1656
  )
1289
1657
 
1290
- status, info, recorded_ts = await database_sync_to_async(_fetch_status)()
1291
- self.assertEqual(status, "Installing")
1292
- self.assertEqual(info, "Applying patch")
1293
- self.assertIsNotNone(recorded_ts)
1294
- self.assertEqual(recorded_ts.replace(microsecond=0), ts)
1658
+ def _fetch_views():
1659
+ User = get_user_model()
1660
+ user = User.objects.create_user(username="fwstatus", password="pw")
1661
+ client = Client()
1662
+ client.force_login(user)
1663
+ detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
1664
+ status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
1665
+ list_response = client.get(reverse("charger-list"))
1666
+ return (
1667
+ detail.status_code,
1668
+ json.loads(detail.content.decode()),
1669
+ status_page.status_code,
1670
+ status_page.content.decode(),
1671
+ list_response.status_code,
1672
+ json.loads(list_response.content.decode()),
1673
+ )
1295
1674
 
1296
- log_entries = store.get_logs(store.identity_key("FWSTAT", None), log_type="charger")
1297
- self.assertTrue(
1298
- any("FirmwareStatusNotification" in entry for entry in log_entries)
1675
+ (
1676
+ detail_code,
1677
+ detail_payload,
1678
+ status_code,
1679
+ html,
1680
+ list_code,
1681
+ list_payload,
1682
+ ) = await database_sync_to_async(_fetch_views)()
1683
+ self.assertEqual(detail_code, 200)
1684
+ self.assertEqual(status_code, 200)
1685
+ self.assertEqual(list_code, 200)
1686
+ self.assertEqual(detail_payload["firmwareStatus"], "Installing")
1687
+ self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
1688
+ self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
1689
+ self.assertNotIn('id="firmware-status"', html)
1690
+ self.assertNotIn('id="firmware-status-info"', html)
1691
+ self.assertNotIn('id="firmware-timestamp"', html)
1692
+
1693
+ matching = [
1694
+ item
1695
+ for item in list_payload.get("chargers", [])
1696
+ if item["charger_id"] == "FWSTAT"
1697
+ and item["connector_id"] is None
1698
+ ]
1699
+ self.assertTrue(matching)
1700
+ self.assertEqual(matching[0]["firmwareStatus"], "Installing")
1701
+ self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
1702
+ list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
1703
+ self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
1704
+
1705
+ store.clear_log(
1706
+ store.identity_key("FWSTAT", None), log_type="charger"
1707
+ )
1708
+ finally:
1709
+ with suppress(Exception):
1710
+ await communicator.disconnect()
1711
+ store.MAX_CONNECTIONS_PER_IP = limit
1712
+
1713
+ async def test_update_firmware_call_result_updates_deployment(self):
1714
+ store.ip_connections.clear()
1715
+ limit = store.MAX_CONNECTIONS_PER_IP
1716
+ store.MAX_CONNECTIONS_PER_IP = 10
1717
+ charger = await database_sync_to_async(Charger.objects.create)(
1718
+ charger_id="UPFW", connector_id=None
1719
+ )
1720
+ firmware = await database_sync_to_async(CPFirmware.objects.create)(
1721
+ name="Update firmware",
1722
+ filename="update.bin",
1723
+ payload_binary=b"bin",
1724
+ content_type="application/octet-stream",
1725
+ source=CPFirmware.Source.UPLOAD,
1726
+ is_user_data=True,
1727
+ )
1728
+ deployment = await database_sync_to_async(CPFirmwareDeployment.objects.create)(
1729
+ firmware=firmware,
1730
+ charger=charger,
1731
+ node=charger.node_origin,
1732
+ ocpp_message_id="upfw-msg",
1733
+ status="Pending",
1734
+ status_info="",
1735
+ status_timestamp=timezone.now(),
1736
+ retrieve_date=timezone.now(),
1737
+ request_payload={},
1738
+ is_user_data=True,
1299
1739
  )
1300
1740
 
1301
- def _fetch_views():
1302
- User = get_user_model()
1303
- user = User.objects.create_user(username="fwstatus", password="pw")
1304
- client = Client()
1305
- client.force_login(user)
1306
- detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
1307
- status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
1308
- list_response = client.get(reverse("charger-list"))
1309
- return (
1310
- detail.status_code,
1311
- json.loads(detail.content.decode()),
1312
- status_page.status_code,
1313
- status_page.content.decode(),
1314
- list_response.status_code,
1315
- json.loads(list_response.content.decode()),
1316
- )
1741
+ message_id = "firmware-update"
1742
+ store.register_pending_call(
1743
+ message_id,
1744
+ {
1745
+ "action": "UpdateFirmware",
1746
+ "charger_id": "UPFW",
1747
+ "connector_id": None,
1748
+ "deployment_pk": deployment.pk,
1749
+ },
1750
+ )
1317
1751
 
1318
- (
1319
- detail_code,
1320
- detail_payload,
1321
- status_code,
1322
- html,
1323
- list_code,
1324
- list_payload,
1325
- ) = await database_sync_to_async(_fetch_views)()
1326
- self.assertEqual(detail_code, 200)
1327
- self.assertEqual(status_code, 200)
1328
- self.assertEqual(list_code, 200)
1329
- self.assertEqual(detail_payload["firmwareStatus"], "Installing")
1330
- self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
1331
- self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
1332
- self.assertNotIn('id="firmware-status"', html)
1333
- self.assertNotIn('id="firmware-status-info"', html)
1334
- self.assertNotIn('id="firmware-timestamp"', html)
1335
-
1336
- matching = [
1337
- item
1338
- for item in list_payload.get("chargers", [])
1339
- if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
1340
- ]
1341
- self.assertTrue(matching)
1342
- self.assertEqual(matching[0]["firmwareStatus"], "Installing")
1343
- self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
1344
- list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
1345
- self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
1752
+ communicator = WebsocketCommunicator(application, "/UPFW/")
1753
+ try:
1754
+ connected, detail = await communicator.connect()
1755
+ self.assertTrue(connected, detail)
1346
1756
 
1347
- store.clear_log(store.identity_key("FWSTAT", None), log_type="charger")
1757
+ await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
1348
1758
 
1349
- await communicator.disconnect()
1759
+ await asyncio.sleep(0.05)
1760
+
1761
+ updated = await database_sync_to_async(CPFirmwareDeployment.objects.get)(
1762
+ pk=deployment.pk
1763
+ )
1764
+ self.assertEqual(updated.status, "Accepted")
1765
+ self.assertIsNotNone(updated.status_timestamp)
1766
+ self.assertFalse(updated.completed_at)
1767
+ finally:
1768
+ with suppress(Exception):
1769
+ await communicator.disconnect()
1770
+ store.MAX_CONNECTIONS_PER_IP = limit
1350
1771
 
1351
1772
  async def test_firmware_status_notification_updates_connector_and_aggregate(
1352
1773
  self,
@@ -1941,6 +2362,35 @@ class CSMSConsumerTests(TransactionTestCase):
1941
2362
  data = json.loads(files[0].read_text())
1942
2363
  self.assertTrue(any("StartTransaction" in m["message"] for m in data))
1943
2364
 
2365
+ def test_session_log_buffer_bounded(self):
2366
+ cid = "BUFFER-LIMIT"
2367
+ session_dir = Path("logs") / "sessions" / cid
2368
+ if session_dir.exists():
2369
+ for file_path in session_dir.glob("*.json"):
2370
+ file_path.unlink()
2371
+
2372
+ tx_id = 999
2373
+ store.start_session_log(cid, tx_id)
2374
+ self.addCleanup(lambda: store.history.pop(cid, None))
2375
+
2376
+ try:
2377
+ metadata = store.history[cid]
2378
+ path = metadata["path"]
2379
+ message_count = store.SESSION_LOG_BUFFER_LIMIT * 3 + 5
2380
+ for idx in range(message_count):
2381
+ store.add_session_message(cid, f"message {idx}")
2382
+ buffer = metadata["buffer"]
2383
+ self.assertLessEqual(len(buffer), store.SESSION_LOG_BUFFER_LIMIT)
2384
+ finally:
2385
+ store.end_session_log(cid)
2386
+
2387
+ self.assertTrue(path.exists())
2388
+ try:
2389
+ payload = json.loads(path.read_text())
2390
+ self.assertEqual(len(payload), message_count)
2391
+ finally:
2392
+ path.unlink(missing_ok=True)
2393
+
1944
2394
  async def test_second_connection_closes_first(self):
1945
2395
  communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
1946
2396
  connected, _ = await communicator1.connect()
@@ -2923,6 +3373,101 @@ class ChargerAdminTests(TestCase):
2923
3373
  store.clear_log(log_key, log_type="charger")
2924
3374
  store.clear_log(pending_key, log_type="charger")
2925
3375
 
3376
+ def test_get_diagnostics_downloads_file_to_work_directory(self):
3377
+ charger = Charger.objects.create(
3378
+ charger_id="DIAGADMIN",
3379
+ diagnostics_location="https://example.com/diag.tar.gz",
3380
+ diagnostics_status="Uploaded",
3381
+ )
3382
+ fixed_now = datetime(2024, 1, 2, 3, 4, 5, tzinfo=dt_timezone.utc)
3383
+ with tempfile.TemporaryDirectory() as tempdir:
3384
+ base_path = Path(tempdir)
3385
+ response_mock = Mock()
3386
+ response_mock.status_code = 200
3387
+ response_mock.iter_content.return_value = [b"diagnostics"]
3388
+ response_mock.headers = {
3389
+ "Content-Disposition": 'attachment; filename="diagnostics.tar.gz"'
3390
+ }
3391
+ response_mock.close = Mock()
3392
+ with override_settings(BASE_DIR=base_path):
3393
+ with patch("ocpp.admin.requests.get", return_value=response_mock) as mock_get:
3394
+ with patch("ocpp.admin.timezone.now", return_value=fixed_now):
3395
+ url = reverse("admin:ocpp_charger_changelist")
3396
+ response = self.client.post(
3397
+ url,
3398
+ {
3399
+ "action": "get_diagnostics",
3400
+ "_selected_action": [charger.pk],
3401
+ },
3402
+ follow=True,
3403
+ )
3404
+ self.assertEqual(response.status_code, 200)
3405
+ work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
3406
+ self.assertTrue(work_dir.exists())
3407
+ files = list(work_dir.glob("*"))
3408
+ self.assertEqual(len(files), 1)
3409
+ saved_file = files[0]
3410
+ self.assertEqual(saved_file.read_bytes(), b"diagnostics")
3411
+ asset_path = saved_file.relative_to(base_path / "work" / "ocpp-admin").as_posix()
3412
+ asset_url = "http://testserver" + reverse(
3413
+ "pages:readme-asset", kwargs={"source": "work", "asset": asset_path}
3414
+ )
3415
+ self.assertContains(response, asset_url)
3416
+ self.assertContains(response, str(saved_file))
3417
+ mock_get.assert_called_once_with(
3418
+ "https://example.com/diag.tar.gz", stream=True, timeout=15
3419
+ )
3420
+ response_mock.close.assert_called_once()
3421
+
3422
+ def test_get_diagnostics_requires_location_reports_warning(self):
3423
+ charger = Charger.objects.create(charger_id="DIAGNOLOC")
3424
+ with tempfile.TemporaryDirectory() as tempdir:
3425
+ base_path = Path(tempdir)
3426
+ with override_settings(BASE_DIR=base_path):
3427
+ url = reverse("admin:ocpp_charger_changelist")
3428
+ response = self.client.post(
3429
+ url,
3430
+ {"action": "get_diagnostics", "_selected_action": [charger.pk]},
3431
+ follow=True,
3432
+ )
3433
+ self.assertEqual(response.status_code, 200)
3434
+ self.assertContains(response, "DIAGNOLOC: no diagnostics location reported.")
3435
+ work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
3436
+ self.assertTrue(work_dir.exists())
3437
+ self.assertFalse(list(work_dir.iterdir()))
3438
+
3439
+ def test_get_diagnostics_handles_download_error(self):
3440
+ charger = Charger.objects.create(
3441
+ charger_id="DIAGFAIL",
3442
+ diagnostics_location="https://example.com/diag.tar",
3443
+ )
3444
+ response_mock = Mock()
3445
+ response_mock.status_code = 500
3446
+ response_mock.iter_content.return_value = []
3447
+ response_mock.headers = {}
3448
+ response_mock.close = Mock()
3449
+ with tempfile.TemporaryDirectory() as tempdir:
3450
+ base_path = Path(tempdir)
3451
+ with override_settings(BASE_DIR=base_path):
3452
+ with patch("ocpp.admin.requests.get", return_value=response_mock):
3453
+ url = reverse("admin:ocpp_charger_changelist")
3454
+ response = self.client.post(
3455
+ url,
3456
+ {
3457
+ "action": "get_diagnostics",
3458
+ "_selected_action": [charger.pk],
3459
+ },
3460
+ follow=True,
3461
+ )
3462
+ self.assertEqual(response.status_code, 200)
3463
+ self.assertContains(
3464
+ response, "DIAGFAIL: Diagnostics download returned status 500."
3465
+ )
3466
+ work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
3467
+ self.assertTrue(work_dir.exists())
3468
+ self.assertFalse(list(work_dir.iterdir()))
3469
+ response_mock.close.assert_called_once()
3470
+
2926
3471
 
2927
3472
  class ChargerConfigurationAdminUnitTests(TestCase):
2928
3473
  def setUp(self):
@@ -2952,6 +3497,38 @@ class ChargerConfigurationAdminUnitTests(TestCase):
2952
3497
  configuration.refresh_from_db()
2953
3498
  self.assertIsNone(configuration.evcs_snapshot_at)
2954
3499
 
3500
+ def test_configuration_key_inline_readonly_helpers(self):
3501
+ configuration = ChargerConfiguration.objects.create(
3502
+ charger_identifier="CFG-INLINE"
3503
+ )
3504
+ configuration.replace_configuration_keys(
3505
+ [
3506
+ {
3507
+ "key": "HeartbeatInterval",
3508
+ "value": {"interval": 300},
3509
+ "readonly": True,
3510
+ "note": "Check",
3511
+ },
3512
+ {"key": "AuthorizeRemoteTxRequests", "readonly": False},
3513
+ ]
3514
+ )
3515
+ inline = ConfigurationKeyInline(ChargerConfiguration, self.admin.admin_site)
3516
+ entries = list(
3517
+ configuration.configuration_entries.order_by("position", "id")
3518
+ )
3519
+ self.assertEqual(len(entries), 2)
3520
+ self.assertIn("<pre>", inline.value_display(entries[0]))
3521
+ self.assertIn("\"note\"", inline.extra_display(entries[0]))
3522
+ self.assertEqual(inline.value_display(entries[1]), "-")
3523
+ self.assertEqual(inline.extra_display(entries[1]), "-")
3524
+ request = self.request_factory.get("/admin/ocpp/chargerconfiguration/")
3525
+ self.assertFalse(inline.has_add_permission(request, configuration))
3526
+
3527
+ def test_configuration_key_admin_hidden_from_index(self):
3528
+ key_admin = ConfigurationKeyAdmin(ConfigurationKey, AdminSite())
3529
+ perms = key_admin.get_model_perms(self.request_factory.get("/"))
3530
+ self.assertEqual(perms, {})
3531
+
2955
3532
 
2956
3533
  class ConfigurationTaskTests(TestCase):
2957
3534
  def tearDown(self):
@@ -4081,7 +4658,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
4081
4658
  cfg = SimulatorConfig(cp_path="SIMLOG/")
4082
4659
  sim = ChargePointSimulator(cfg)
4083
4660
  store.clear_log(cfg.cp_path, log_type="simulator")
4084
- store.logs["simulator"][cfg.cp_path] = []
4661
+ store.logs["simulator"][cfg.cp_path] = deque(
4662
+ maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
4663
+ )
4085
4664
  sent_frames: list[str] = []
4086
4665
 
4087
4666
  async def send(payload: str) -> None:
@@ -4422,6 +5001,28 @@ class DailySessionReportTaskTests(TestCase):
4422
5001
  except FileNotFoundError:
4423
5002
  pass
4424
5003
 
5004
+ def _create_transaction_with_reading(
5005
+ self,
5006
+ charger: Charger,
5007
+ start: datetime,
5008
+ energy: Decimal,
5009
+ ) -> Transaction:
5010
+ transaction = Transaction.objects.create(
5011
+ charger=charger,
5012
+ start_time=start,
5013
+ stop_time=start + timedelta(minutes=30),
5014
+ meter_start=0,
5015
+ connector_id=1,
5016
+ )
5017
+ MeterValue.objects.create(
5018
+ charger=charger,
5019
+ connector_id=1,
5020
+ transaction=transaction,
5021
+ timestamp=start + timedelta(minutes=15),
5022
+ energy=energy,
5023
+ )
5024
+ return transaction
5025
+
4425
5026
  def test_report_sends_email_when_sessions_exist(self):
4426
5027
  User = get_user_model()
4427
5028
  User.objects.create_superuser(
@@ -4475,6 +5076,41 @@ class DailySessionReportTaskTests(TestCase):
4475
5076
  self.assertEqual(count, 0)
4476
5077
  mock_send.assert_not_called()
4477
5078
 
5079
+ def test_report_query_count_constant(self):
5080
+ User = get_user_model()
5081
+ User.objects.create_superuser(
5082
+ username="report-admin", email="report-admin@example.com", password="pw"
5083
+ )
5084
+ charger = Charger.objects.create(charger_id="RPT-QUERY", display_name="Pod Q")
5085
+ base_start = timezone.now().replace(second=0, microsecond=0)
5086
+
5087
+ self._create_transaction_with_reading(
5088
+ charger, base_start, Decimal("1.2")
5089
+ )
5090
+
5091
+ with patch("core.mailer.can_send_email", return_value=True), patch(
5092
+ "core.mailer.send"
5093
+ ) as mock_send, CaptureQueriesContext(connection) as ctx_single:
5094
+ count_single = send_daily_session_report()
5095
+
5096
+ self.assertEqual(count_single, 1)
5097
+ mock_send.assert_called_once()
5098
+ single_query_count = len(ctx_single)
5099
+
5100
+ later_start = base_start + timedelta(hours=1)
5101
+ self._create_transaction_with_reading(
5102
+ charger, later_start, Decimal("3.4")
5103
+ )
5104
+
5105
+ with patch("core.mailer.can_send_email", return_value=True), patch(
5106
+ "core.mailer.send"
5107
+ ) as mock_send_multi, CaptureQueriesContext(connection) as ctx_multi:
5108
+ count_multi = send_daily_session_report()
5109
+
5110
+ self.assertEqual(count_multi, 2)
5111
+ mock_send_multi.assert_called_once()
5112
+ self.assertEqual(len(ctx_multi), single_query_count)
5113
+
4478
5114
 
4479
5115
  class TransactionKwTests(TestCase):
4480
5116
  def test_kw_sums_meter_readings(self):
@@ -4587,6 +5223,9 @@ class DispatchActionViewTests(TestCase):
4587
5223
  )
4588
5224
  self.mock_wait = self.wait_patch.start()
4589
5225
  self.addCleanup(self.wait_patch.stop)
5226
+ self.schedule_patch = patch("ocpp.views.store.schedule_call_timeout")
5227
+ self.mock_schedule = self.schedule_patch.start()
5228
+ self.addCleanup(self.schedule_patch.stop)
4590
5229
 
4591
5230
  def _close_loop(self):
4592
5231
  try:
@@ -4659,6 +5298,75 @@ class DispatchActionViewTests(TestCase):
4659
5298
  self.assertEqual(self.charger.availability_request_status, "")
4660
5299
  self.assertNotIn(frame[1], store.pending_calls)
4661
5300
 
5301
+ def test_clear_cache_dispatches_frame_and_schedules_timeout(self):
5302
+ with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
5303
+ response = self.client.post(
5304
+ self.url,
5305
+ data=json.dumps({"action": "clear_cache"}),
5306
+ content_type="application/json",
5307
+ )
5308
+ self.assertEqual(response.status_code, 200)
5309
+ self.loop.run_until_complete(asyncio.sleep(0))
5310
+ self.assertEqual(len(self.ws.sent), 1)
5311
+ frame = json.loads(self.ws.sent[0])
5312
+ self.assertEqual(frame[0], 2)
5313
+ self.assertEqual(frame[2], "ClearCache")
5314
+ self.assertEqual(frame[3], {})
5315
+ mock_timeout.assert_called_once()
5316
+ timeout_call = mock_timeout.call_args
5317
+ self.assertIsNotNone(timeout_call)
5318
+ self.assertEqual(timeout_call.args[0], frame[1])
5319
+ self.assertEqual(timeout_call.kwargs.get("action"), "ClearCache")
5320
+ log_entries = store.logs["charger"].get(self.log_key, [])
5321
+ self.assertTrue(any("ClearCache" in entry for entry in log_entries))
5322
+
5323
+ def test_clear_cache_allows_rejected_status(self):
5324
+ def wait_rejected(message_id, timeout=5.0):
5325
+ metadata = store.pending_calls.pop(message_id, None)
5326
+ store._pending_call_events.pop(message_id, None)
5327
+ store._pending_call_results.pop(message_id, None)
5328
+ return {
5329
+ "success": True,
5330
+ "payload": {"status": "Rejected"},
5331
+ "metadata": dict(metadata or {}),
5332
+ }
5333
+
5334
+ self.mock_wait.side_effect = wait_rejected
5335
+ with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
5336
+ response = self.client.post(
5337
+ self.url,
5338
+ data=json.dumps({"action": "clear_cache"}),
5339
+ content_type="application/json",
5340
+ )
5341
+ self.assertEqual(response.status_code, 200)
5342
+ payload = response.json()
5343
+ self.assertIn("sent", payload)
5344
+ self.loop.run_until_complete(asyncio.sleep(0))
5345
+ self.assertEqual(len(self.ws.sent), 1)
5346
+ frame = json.loads(self.ws.sent[0])
5347
+ self.assertEqual(frame[2], "ClearCache")
5348
+ mock_timeout.assert_called_once()
5349
+ self.mock_wait.side_effect = self._wait_success
5350
+
5351
+ def test_clear_cache_reports_timeout(self):
5352
+ def no_response(message_id, timeout=5.0):
5353
+ return None
5354
+
5355
+ self.mock_wait.side_effect = no_response
5356
+ with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
5357
+ response = self.client.post(
5358
+ self.url,
5359
+ data=json.dumps({"action": "clear_cache"}),
5360
+ content_type="application/json",
5361
+ )
5362
+ self.assertEqual(response.status_code, 504)
5363
+ detail = response.json().get("detail", "")
5364
+ self.assertIn("did not receive", detail)
5365
+ self.loop.run_until_complete(asyncio.sleep(0))
5366
+ self.assertEqual(len(self.ws.sent), 1)
5367
+ mock_timeout.assert_called_once()
5368
+ self.mock_wait.side_effect = self._wait_success
5369
+
4662
5370
  def test_remote_start_reports_rejection(self):
4663
5371
  def rejected(message_id, timeout=5.0):
4664
5372
  metadata = store.pending_calls.pop(message_id, None)
@@ -4721,6 +5429,118 @@ class DispatchActionViewTests(TestCase):
4721
5429
  self.assertIn("did not receive", detail)
4722
5430
  self.mock_wait.side_effect = self._wait_success
4723
5431
 
5432
+ def test_change_configuration_requires_key(self):
5433
+ self.mock_schedule.reset_mock()
5434
+ response = self.client.post(
5435
+ self.url,
5436
+ data=json.dumps({"action": "change_configuration"}),
5437
+ content_type="application/json",
5438
+ )
5439
+ self.assertEqual(response.status_code, 400)
5440
+ self.assertEqual(response.json().get("detail"), "key required")
5441
+ self.loop.run_until_complete(asyncio.sleep(0))
5442
+ self.assertEqual(self.ws.sent, [])
5443
+ self.mock_schedule.assert_not_called()
5444
+
5445
+ def test_change_configuration_rejects_invalid_value_type(self):
5446
+ self.mock_schedule.reset_mock()
5447
+ response = self.client.post(
5448
+ self.url,
5449
+ data=json.dumps(
5450
+ {
5451
+ "action": "change_configuration",
5452
+ "key": "HeartbeatInterval",
5453
+ "value": {"unexpected": "object"},
5454
+ }
5455
+ ),
5456
+ content_type="application/json",
5457
+ )
5458
+ self.assertEqual(response.status_code, 400)
5459
+ self.assertIn("value must", response.json().get("detail", ""))
5460
+ self.loop.run_until_complete(asyncio.sleep(0))
5461
+ self.assertEqual(self.ws.sent, [])
5462
+ self.mock_schedule.assert_not_called()
5463
+
5464
+ def test_change_configuration_dispatches_frame(self):
5465
+ self.mock_schedule.reset_mock()
5466
+ response = self.client.post(
5467
+ self.url,
5468
+ data=json.dumps(
5469
+ {
5470
+ "action": "change_configuration",
5471
+ "key": "HeartbeatInterval",
5472
+ "value": "120",
5473
+ }
5474
+ ),
5475
+ content_type="application/json",
5476
+ )
5477
+ self.assertEqual(response.status_code, 200)
5478
+ self.loop.run_until_complete(asyncio.sleep(0))
5479
+ self.assertEqual(len(self.ws.sent), 1)
5480
+ frame = json.loads(self.ws.sent[0])
5481
+ self.assertEqual(frame[0], 2)
5482
+ self.assertEqual(frame[2], "ChangeConfiguration")
5483
+ self.assertEqual(frame[3]["key"], "HeartbeatInterval")
5484
+ self.assertEqual(frame[3]["value"], "120")
5485
+ self.mock_schedule.assert_called_once()
5486
+ log_entries = store.logs["charger"].get(self.log_key, [])
5487
+ self.assertTrue(
5488
+ any("Requested configuration change" in entry for entry in log_entries)
5489
+ )
5490
+
5491
+ def test_change_configuration_reports_rejection(self):
5492
+ self.mock_schedule.reset_mock()
5493
+
5494
+ def rejected(message_id, timeout=5.0):
5495
+ metadata = store.pending_calls.pop(message_id, None)
5496
+ store._pending_call_events.pop(message_id, None)
5497
+ store._pending_call_results.pop(message_id, None)
5498
+ return {
5499
+ "success": True,
5500
+ "payload": {"status": "Rejected"},
5501
+ "metadata": dict(metadata or {}),
5502
+ }
5503
+
5504
+ self.mock_wait.side_effect = rejected
5505
+ response = self.client.post(
5506
+ self.url,
5507
+ data=json.dumps(
5508
+ {
5509
+ "action": "change_configuration",
5510
+ "key": "HeartbeatInterval",
5511
+ "value": "120",
5512
+ }
5513
+ ),
5514
+ content_type="application/json",
5515
+ )
5516
+ self.assertEqual(response.status_code, 400)
5517
+ detail = response.json().get("detail", "")
5518
+ self.assertIn("Rejected", detail)
5519
+ self.mock_wait.side_effect = self._wait_success
5520
+
5521
+ def test_change_configuration_reports_timeout(self):
5522
+ self.mock_schedule.reset_mock()
5523
+
5524
+ def no_response(message_id, timeout=5.0):
5525
+ return None
5526
+
5527
+ self.mock_wait.side_effect = no_response
5528
+ response = self.client.post(
5529
+ self.url,
5530
+ data=json.dumps(
5531
+ {
5532
+ "action": "change_configuration",
5533
+ "key": "HeartbeatInterval",
5534
+ "value": "120",
5535
+ }
5536
+ ),
5537
+ content_type="application/json",
5538
+ )
5539
+ self.assertEqual(response.status_code, 504)
5540
+ detail = response.json().get("detail", "")
5541
+ self.assertIn("did not receive", detail)
5542
+ self.mock_wait.side_effect = self._wait_success
5543
+
4724
5544
  def test_change_availability_requires_valid_type(self):
4725
5545
  response = self.client.post(
4726
5546
  self.url,
@@ -4728,6 +5548,9 @@ class DispatchActionViewTests(TestCase):
4728
5548
  content_type="application/json",
4729
5549
  )
4730
5550
  self.assertEqual(response.status_code, 400)
5551
+ self.assertEqual(
5552
+ response.json().get("detail"), "invalid availability type"
5553
+ )
4731
5554
  self.loop.run_until_complete(asyncio.sleep(0))
4732
5555
  self.assertEqual(self.ws.sent, [])
4733
5556
  self.assertFalse(store.pending_calls)
@@ -4857,11 +5680,14 @@ class ChargerStatusViewTests(TestCase):
4857
5680
  prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4858
5681
  return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4859
5682
 
4860
- store.logs["charger"][log_key] = [
4861
- build_entry(timedelta(days=2), "Available"),
4862
- build_entry(timedelta(days=1), "Charging"),
4863
- build_entry(timedelta(hours=12), "Available"),
4864
- ]
5683
+ store.logs["charger"][log_key] = deque(
5684
+ [
5685
+ build_entry(timedelta(days=2), "Available"),
5686
+ build_entry(timedelta(days=1), "Charging"),
5687
+ build_entry(timedelta(hours=12), "Available"),
5688
+ ],
5689
+ maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
5690
+ )
4865
5691
 
4866
5692
  data, _window = _usage_timeline(charger, [], now=fixed_now)
4867
5693
  self.assertEqual(len(data), 1)
@@ -4898,7 +5724,9 @@ class ChargerStatusViewTests(TestCase):
4898
5724
  }
4899
5725
  prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4900
5726
  key = store.identity_key(aggregate.charger_id, connector_id)
4901
- store.logs["charger"].setdefault(key, []).append(
5727
+ store.logs["charger"].setdefault(
5728
+ key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
5729
+ ).append(
4902
5730
  f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4903
5731
  )
4904
5732
 
@@ -4939,13 +5767,16 @@ class ChargerStatusViewTests(TestCase):
4939
5767
  return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4940
5768
 
4941
5769
  log_key = store.identity_key(charger.charger_id, charger.connector_id)
4942
- store.logs["charger"][log_key] = [
4943
- build_entry(timedelta(days=6, hours=12), "Available"),
4944
- build_entry(timedelta(days=5), "Available"),
4945
- build_entry(timedelta(days=3, hours=6), "Charging"),
4946
- build_entry(timedelta(days=2), "Charging"),
4947
- build_entry(timedelta(days=1), "Available"),
4948
- ]
5770
+ store.logs["charger"][log_key] = deque(
5771
+ [
5772
+ build_entry(timedelta(days=6, hours=12), "Available"),
5773
+ build_entry(timedelta(days=5), "Available"),
5774
+ build_entry(timedelta(days=3, hours=6), "Charging"),
5775
+ build_entry(timedelta(days=2), "Charging"),
5776
+ build_entry(timedelta(days=1), "Available"),
5777
+ ],
5778
+ maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
5779
+ )
4949
5780
 
4950
5781
  data, window = _usage_timeline(charger, [], now=fixed_now)
4951
5782
  self.assertIsNotNone(window)
@@ -4973,6 +5804,37 @@ class ChargerStatusViewTests(TestCase):
4973
5804
  self.assertContains(resp, "id=\"diagnostics-location\"")
4974
5805
  self.assertContains(resp, "https://example.com/report.tar")
4975
5806
 
5807
+ def test_firmware_download_serves_payload(self):
5808
+ charger = Charger.objects.create(charger_id="DLVIEW")
5809
+ firmware = CPFirmware.objects.create(
5810
+ name="Download",
5811
+ filename="download.bin",
5812
+ payload_binary=b"payload",
5813
+ content_type="application/octet-stream",
5814
+ source=CPFirmware.Source.DOWNLOAD,
5815
+ is_user_data=True,
5816
+ )
5817
+ deployment = CPFirmwareDeployment.objects.create(
5818
+ firmware=firmware,
5819
+ charger=charger,
5820
+ node=charger.node_origin,
5821
+ ocpp_message_id="dl-msg",
5822
+ status="Pending",
5823
+ status_info="",
5824
+ status_timestamp=timezone.now(),
5825
+ retrieve_date=timezone.now(),
5826
+ request_payload={},
5827
+ is_user_data=True,
5828
+ )
5829
+ token = deployment.issue_download_token(lifetime=timedelta(hours=1))
5830
+ response = self.client.get(
5831
+ reverse("cp-firmware-download", args=[deployment.pk, token])
5832
+ )
5833
+ self.assertEqual(response.status_code, 200)
5834
+ self.assertEqual(response.content, b"payload")
5835
+ deployment.refresh_from_db()
5836
+ self.assertIsNotNone(deployment.downloaded_at)
5837
+
4976
5838
  def test_connector_status_prefers_connector_diagnostics(self):
4977
5839
  aggregate = Charger.objects.create(
4978
5840
  charger_id="DIAGCONN",
@@ -5492,3 +6354,33 @@ class LiveUpdateViewTests(TestCase):
5492
6354
  reverse("charger-status", args=[restricted.charger_id])
5493
6355
  )
5494
6356
  self.assertEqual(group_denied.status_code, 404)
6357
+
6358
+
6359
+ class StoreLogBufferTests(TestCase):
6360
+ def test_add_log_enforces_in_memory_cap(self):
6361
+ cid = "BUFFER-CAP-TEST"
6362
+ log_type = "charger"
6363
+ store.clear_log(cid, log_type=log_type)
6364
+ self.addCleanup(lambda: store.clear_log(cid, log_type=log_type))
6365
+
6366
+ with patch("ocpp.store.MAX_IN_MEMORY_LOG_ENTRIES", 3):
6367
+ for index in range(6):
6368
+ store.add_log(cid, f"message {index}", log_type=log_type)
6369
+
6370
+ buffer = None
6371
+ lower = cid.lower()
6372
+ for key, entries in store.logs[log_type].items():
6373
+ if key.lower() == lower:
6374
+ buffer = entries
6375
+ break
6376
+
6377
+ self.assertIsNotNone(buffer, "Expected in-memory log buffer to be created")
6378
+ self.assertIsInstance(buffer, deque)
6379
+ self.assertEqual(len(buffer), 3)
6380
+ self.assertTrue(buffer[0].endswith("message 3"))
6381
+ self.assertTrue(buffer[-1].endswith("message 5"))
6382
+
6383
+ merged = store.get_logs(cid, log_type=log_type)
6384
+
6385
+ self.assertTrue(any(entry.endswith("message 5") for entry in merged))
6386
+ self.assertTrue(any(entry.endswith("message 4") for entry in merged))