arthexis 0.1.12__py3-none-any.whl → 0.1.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

ocpp/tests.py CHANGED
@@ -1,14 +1,32 @@
1
1
  import os
2
+ import sys
3
+ import time
4
+ from importlib import util as importlib_util
5
+ from pathlib import Path
6
+ from types import ModuleType
2
7
 
3
8
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
9
 
5
- import time
6
-
7
- import tests.conftest # noqa: F401
10
+ try: # pragma: no cover - exercised via test command imports
11
+ import tests.conftest as tests_conftest # type: ignore[import-not-found]
12
+ except ModuleNotFoundError: # pragma: no cover - fallback for pytest importlib mode
13
+ tests_dir = Path(__file__).resolve().parents[1] / "tests"
14
+ spec = importlib_util.spec_from_file_location(
15
+ "tests.conftest", tests_dir / "conftest.py"
16
+ )
17
+ if spec is None or spec.loader is None: # pragma: no cover - defensive
18
+ raise
19
+ tests_conftest = importlib_util.module_from_spec(spec)
20
+ package = sys.modules.setdefault("tests", ModuleType("tests"))
21
+ package.__path__ = [str(tests_dir)]
22
+ sys.modules.setdefault("tests.conftest", tests_conftest)
23
+ spec.loader.exec_module(tests_conftest)
24
+ else:
25
+ sys.modules.setdefault("tests.conftest", tests_conftest)
8
26
 
9
27
  import django
10
28
 
11
- django.setup = tests.conftest._original_setup
29
+ django.setup = tests_conftest._original_setup
12
30
  django.setup()
13
31
 
14
32
  from asgiref.testing import ApplicationCommunicator
@@ -30,6 +48,7 @@ from django.contrib.auth import get_user_model
30
48
  from django.urls import reverse
31
49
  from django.utils import timezone
32
50
  from django.utils.dateparse import parse_datetime
51
+ from django.utils.encoding import force_str
33
52
  from django.utils.translation import override, gettext as _
34
53
  from django.contrib.sites.models import Site
35
54
  from django.core.exceptions import ValidationError
@@ -48,6 +67,7 @@ from .models import (
48
67
  )
49
68
  from .consumers import CSMSConsumer
50
69
  from .views import dispatch_action
70
+ from .status_display import STATUS_BADGE_MAP
51
71
  from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
52
72
  from . import store
53
73
  from django.db.models.deletion import ProtectedError
@@ -55,10 +75,10 @@ from decimal import Decimal
55
75
  import json
56
76
  import websockets
57
77
  import asyncio
58
- from pathlib import Path
59
78
  from .simulator import SimulatorConfig, ChargePointSimulator
79
+ from .evcs import simulate, SimulatorState, _simulators
60
80
  import re
61
- from datetime import datetime, timedelta
81
+ from datetime import datetime, timedelta, timezone as dt_timezone
62
82
  from .tasks import purge_meter_readings
63
83
  from django.db import close_old_connections
64
84
  from django.db.utils import OperationalError
@@ -249,6 +269,39 @@ class CSMSConsumerTests(TransactionTestCase):
249
269
 
250
270
  await communicator.disconnect()
251
271
 
272
+ async def test_rejected_connection_logs_query_string(self):
273
+ raw_serial = "<charger_id>"
274
+ query_string = "chargeboxid=%3Ccharger_id%3E"
275
+ pending_key = store.pending_key(Charger.normalize_serial(raw_serial))
276
+ store.ip_connections.clear()
277
+ store.clear_log(pending_key, log_type="charger")
278
+
279
+ communicator = ClientWebsocketCommunicator(
280
+ application, f"/?{query_string}"
281
+ )
282
+
283
+ try:
284
+ connected = await communicator.connect()
285
+ self.assertEqual(connected, (False, 4003))
286
+
287
+ log_entries = store.get_logs(pending_key, log_type="charger")
288
+ self.assertTrue(
289
+ any(
290
+ "Rejected connection:" in entry and query_string in entry
291
+ for entry in log_entries
292
+ ),
293
+ log_entries,
294
+ )
295
+ finally:
296
+ store.ip_connections.clear()
297
+ store.clear_log(pending_key, log_type="charger")
298
+ lower_key = pending_key.lower()
299
+ for key in list(store.logs["charger"].keys()):
300
+ if key.lower() == lower_key:
301
+ store.logs["charger"].pop(key, None)
302
+ with suppress(Exception):
303
+ await communicator.disconnect()
304
+
252
305
  async def test_transaction_saved(self):
253
306
  communicator = WebsocketCommunicator(application, "/TEST/")
254
307
  connected, _ = await communicator.connect()
@@ -288,9 +341,47 @@ class CSMSConsumerTests(TransactionTestCase):
288
341
 
289
342
  await communicator.disconnect()
290
343
 
344
+ def test_status_notification_available_clears_active_session(self):
345
+ aggregate = Charger.objects.create(charger_id="STATUSCLR")
346
+ connector = Charger.objects.create(
347
+ charger_id="STATUSCLR",
348
+ connector_id=2,
349
+ )
350
+ tx = Transaction.objects.create(
351
+ charger=connector,
352
+ meter_start=10,
353
+ start_time=timezone.now(),
354
+ )
355
+ store_key = store.identity_key("STATUSCLR", 2)
356
+ store.transactions[store_key] = tx
357
+ consumer = CSMSConsumer()
358
+ consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
359
+ consumer.charger_id = "STATUSCLR"
360
+ consumer.store_key = store_key
361
+ consumer.connector_value = 2
362
+ consumer.charger = connector
363
+ consumer.aggregate_charger = aggregate
364
+ consumer._consumption_task = None
365
+ consumer._consumption_message_uuid = None
366
+ consumer.send = AsyncMock()
367
+ payload = {
368
+ "connectorId": 2,
369
+ "status": "Available",
370
+ "errorCode": "NoError",
371
+ "transactionId": tx.pk,
372
+ }
373
+ try:
374
+ with patch.object(consumer, "_assign_connector", new=AsyncMock()):
375
+ async_to_sync(consumer.receive)(
376
+ text_data=json.dumps([2, "1", "StatusNotification", payload])
377
+ )
378
+ finally:
379
+ store.transactions.pop(store_key, None)
380
+ self.assertNotIn(store_key, store.transactions)
381
+
291
382
  async def test_rfid_recorded(self):
292
383
  await database_sync_to_async(Charger.objects.create)(charger_id="RFIDREC")
293
- communicator = WebsocketCommunicator(application, "/RFIDREC/")
384
+ communicator = WebsocketCommunicator(application, "/RFIDREC/?cid=RFIDREC")
294
385
  connected, _ = await communicator.connect()
295
386
  self.assertTrue(connected)
296
387
 
@@ -305,6 +396,82 @@ class CSMSConsumerTests(TransactionTestCase):
305
396
  )
306
397
  self.assertEqual(tx.rfid, "TAG123")
307
398
 
399
+ tag = await database_sync_to_async(RFID.objects.get)(rfid="TAG123")
400
+ self.assertFalse(tag.allowed)
401
+ self.assertIsNotNone(tag.last_seen_on)
402
+
403
+ await communicator.disconnect()
404
+
405
+ async def test_start_transaction_uses_payload_timestamp(self):
406
+ communicator = WebsocketCommunicator(application, "/STAMPED/")
407
+ connected, _ = await communicator.connect()
408
+ self.assertTrue(connected)
409
+
410
+ start_ts = datetime(2025, 9, 26, 18, 1, tzinfo=dt_timezone.utc)
411
+ before = timezone.now()
412
+ await communicator.send_json_to(
413
+ [
414
+ 2,
415
+ "1",
416
+ "StartTransaction",
417
+ {"meterStart": 5, "timestamp": start_ts.isoformat()},
418
+ ]
419
+ )
420
+ response = await communicator.receive_json_from()
421
+ after = timezone.now()
422
+ tx_id = response[2]["transactionId"]
423
+
424
+ tx = await database_sync_to_async(Transaction.objects.get)(
425
+ pk=tx_id, charger__charger_id="STAMPED"
426
+ )
427
+ self.assertEqual(tx.start_time, start_ts)
428
+ self.assertIsNotNone(tx.received_start_time)
429
+ self.assertGreaterEqual(tx.received_start_time, before)
430
+ self.assertLessEqual(tx.received_start_time, after)
431
+
432
+ await communicator.disconnect()
433
+
434
+ async def test_stop_transaction_uses_payload_timestamp(self):
435
+ communicator = WebsocketCommunicator(application, "/STOPSTAMP/")
436
+ connected, _ = await communicator.connect()
437
+ self.assertTrue(connected)
438
+
439
+ await communicator.send_json_to(
440
+ [
441
+ 2,
442
+ "1",
443
+ "StartTransaction",
444
+ {"meterStart": 10},
445
+ ]
446
+ )
447
+ response = await communicator.receive_json_from()
448
+ tx_id = response[2]["transactionId"]
449
+
450
+ stop_ts = datetime(2025, 9, 26, 18, 5, tzinfo=dt_timezone.utc)
451
+ before = timezone.now()
452
+ await communicator.send_json_to(
453
+ [
454
+ 2,
455
+ "2",
456
+ "StopTransaction",
457
+ {
458
+ "transactionId": tx_id,
459
+ "meterStop": 20,
460
+ "timestamp": stop_ts.isoformat(),
461
+ },
462
+ ]
463
+ )
464
+ await communicator.receive_json_from()
465
+ after = timezone.now()
466
+
467
+ tx = await database_sync_to_async(Transaction.objects.get)(
468
+ pk=tx_id, charger__charger_id="STOPSTAMP"
469
+ )
470
+ self.assertEqual(tx.stop_time, stop_ts)
471
+ self.assertIsNotNone(tx.received_stop_time)
472
+ self.assertGreaterEqual(tx.received_stop_time, before)
473
+ self.assertLessEqual(tx.received_stop_time, after)
474
+
308
475
  await communicator.disconnect()
309
476
 
310
477
  async def test_start_transaction_sends_net_message(self):
@@ -376,6 +543,63 @@ class CSMSConsumerTests(TransactionTestCase):
376
543
  self.assertTrue(message_mock.save.called)
377
544
  self.assertTrue(message_mock.propagate.called)
378
545
 
546
+ async def test_assign_connector_promotes_pending_connection(self):
547
+ serial = "ASSIGNPROMOTE"
548
+ path = f"/{serial}/"
549
+ pending_key = store.pending_key(serial)
550
+ new_key = store.identity_key(serial, 1)
551
+ aggregate_key = store.identity_key(serial, None)
552
+
553
+ store.connections.pop(pending_key, None)
554
+ store.connections.pop(new_key, None)
555
+ store.logs["charger"].pop(new_key, None)
556
+ store.log_names["charger"].pop(new_key, None)
557
+ store.log_names["charger"].pop(aggregate_key, None)
558
+
559
+ aggregate = await database_sync_to_async(Charger.objects.create)(
560
+ charger_id=serial,
561
+ connector_id=None,
562
+ )
563
+
564
+ consumer = CSMSConsumer()
565
+ consumer.scope = {"path": path, "headers": [], "client": ("127.0.0.1", 1234)}
566
+ consumer.charger_id = serial
567
+ consumer.store_key = pending_key
568
+ consumer.connector_value = None
569
+ consumer.client_ip = "127.0.0.1"
570
+ consumer.charger = aggregate
571
+ consumer.aggregate_charger = aggregate
572
+
573
+ store.connections[pending_key] = consumer
574
+
575
+ try:
576
+ with patch.object(Charger, "refresh_manager_node", autospec=True) as mock_refresh:
577
+ mock_refresh.return_value = None
578
+ await consumer._assign_connector(1)
579
+
580
+ self.assertEqual(consumer.store_key, new_key)
581
+ self.assertNotIn(pending_key, store.connections)
582
+
583
+ connector = await database_sync_to_async(Charger.objects.get)(
584
+ charger_id=serial,
585
+ connector_id=1,
586
+ )
587
+ self.assertEqual(consumer.charger.pk, connector.pk)
588
+ self.assertEqual(consumer.charger.connector_id, 1)
589
+ self.assertIsNone(consumer.aggregate_charger.connector_id)
590
+
591
+ self.assertIn(new_key, store.log_names["charger"])
592
+ self.assertIn(aggregate_key, store.log_names["charger"])
593
+
594
+ self.assertNotIn(pending_key, store.connections)
595
+ finally:
596
+ store.connections.pop(new_key, None)
597
+ store.connections.pop(pending_key, None)
598
+ store.logs["charger"].pop(new_key, None)
599
+ store.log_names["charger"].pop(new_key, None)
600
+ store.log_names["charger"].pop(aggregate_key, None)
601
+ await database_sync_to_async(Charger.objects.filter(charger_id=serial).delete)()
602
+
379
603
  async def test_change_availability_result_updates_model(self):
380
604
  store.pending_calls.clear()
381
605
  communicator = WebsocketCommunicator(application, "/AVAILRES/")
@@ -734,14 +958,9 @@ class CSMSConsumerTests(TransactionTestCase):
734
958
  self.assertEqual(detail_payload["firmwareStatus"], "Installing")
735
959
  self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
736
960
  self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
737
- self.assertIn('id="firmware-status">Installing<', html)
738
- self.assertIn('id="firmware-status-info">Applying patch<', html)
739
- match = re.search(
740
- r'id="firmware-timestamp"[^>]*data-iso="([^"]+)"', html
741
- )
742
- self.assertIsNotNone(match)
743
- parsed_iso = datetime.fromisoformat(match.group(1))
744
- self.assertAlmostEqual(parsed_iso.timestamp(), ts.timestamp(), places=3)
961
+ self.assertNotIn('id="firmware-status"', html)
962
+ self.assertNotIn('id="firmware-status-info"', html)
963
+ self.assertNotIn('id="firmware-timestamp"', html)
745
964
 
746
965
  matching = [
747
966
  item
@@ -1223,7 +1442,11 @@ class CSMSConsumerTests(TransactionTestCase):
1223
1442
  self.assertContains(status_resp, "background-color: #dc3545")
1224
1443
 
1225
1444
  aggregate_status = self.client.get(reverse("charger-status", args=[serial]))
1226
- self.assertContains(aggregate_status, "Reported status")
1445
+ self.assertContains(
1446
+ aggregate_status,
1447
+ f"Serial Number: {serial}",
1448
+ )
1449
+ self.assertNotContains(aggregate_status, 'id="last-status-raw"')
1227
1450
  self.assertContains(aggregate_status, "Info: Relay malfunction")
1228
1451
 
1229
1452
  page_resp = self.client.get(reverse("charger-page", args=[serial]))
@@ -1598,6 +1821,111 @@ class ChargerLandingTests(TestCase):
1598
1821
  self.assertContains(resp, "progress-bar")
1599
1822
  store.transactions.pop(key, None)
1600
1823
 
1824
+ def test_public_page_overrides_available_status_when_charging(self):
1825
+ charger = Charger.objects.create(
1826
+ charger_id="STATEPUB",
1827
+ last_status="Available",
1828
+ )
1829
+ tx = Transaction.objects.create(
1830
+ charger=charger,
1831
+ meter_start=1000,
1832
+ start_time=timezone.now(),
1833
+ )
1834
+ key = store.identity_key(charger.charger_id, charger.connector_id)
1835
+ store.transactions[key] = tx
1836
+ try:
1837
+ response = self.client.get(reverse("charger-page", args=["STATEPUB"]))
1838
+ self.assertEqual(response.status_code, 200)
1839
+ self.assertContains(
1840
+ response,
1841
+ 'class="badge" style="background-color: #198754;">Charging</span>',
1842
+ )
1843
+ finally:
1844
+ store.transactions.pop(key, None)
1845
+
1846
+ def test_admin_status_overrides_available_status_when_charging(self):
1847
+ charger = Charger.objects.create(
1848
+ charger_id="STATEADM",
1849
+ last_status="Available",
1850
+ )
1851
+ tx = Transaction.objects.create(
1852
+ charger=charger,
1853
+ meter_start=1000,
1854
+ start_time=timezone.now(),
1855
+ )
1856
+ key = store.identity_key(charger.charger_id, charger.connector_id)
1857
+ store.transactions[key] = tx
1858
+ try:
1859
+ response = self.client.get(reverse("charger-status", args=["STATEADM"]))
1860
+ self.assertEqual(response.status_code, 200)
1861
+ self.assertContains(response, 'id="charger-state">Charging</strong>')
1862
+ self.assertContains(
1863
+ response,
1864
+ 'style="width:20px;height:20px;background-color: #198754;"',
1865
+ )
1866
+ finally:
1867
+ store.transactions.pop(key, None)
1868
+
1869
+ def test_public_status_shows_rfid_link_for_known_tag(self):
1870
+ aggregate = Charger.objects.create(charger_id="PUBRFID")
1871
+ connector = Charger.objects.create(
1872
+ charger_id="PUBRFID",
1873
+ connector_id=1,
1874
+ )
1875
+ tx = Transaction.objects.create(
1876
+ charger=connector,
1877
+ meter_start=1000,
1878
+ start_time=timezone.now(),
1879
+ rfid="TAGLINK",
1880
+ )
1881
+ key = store.identity_key(connector.charger_id, connector.connector_id)
1882
+ store.transactions[key] = tx
1883
+ tag = RFID.objects.create(rfid="TAGLINK")
1884
+ admin_url = reverse("admin:core_rfid_change", args=[tag.pk])
1885
+ try:
1886
+ response = self.client.get(
1887
+ reverse(
1888
+ "charger-page-connector",
1889
+ args=[connector.charger_id, connector.connector_slug],
1890
+ )
1891
+ )
1892
+ self.assertContains(response, admin_url)
1893
+ self.assertContains(response, "TAGLINK")
1894
+
1895
+ overview = self.client.get(reverse("charger-page", args=[aggregate.charger_id]))
1896
+ self.assertContains(overview, admin_url)
1897
+ finally:
1898
+ store.transactions.pop(key, None)
1899
+
1900
+ def test_public_status_shows_rfid_text_when_unknown(self):
1901
+ Charger.objects.create(charger_id="PUBTEXT")
1902
+ connector = Charger.objects.create(
1903
+ charger_id="PUBTEXT",
1904
+ connector_id=1,
1905
+ )
1906
+ tx = Transaction.objects.create(
1907
+ charger=connector,
1908
+ meter_start=1000,
1909
+ start_time=timezone.now(),
1910
+ rfid="TAGNONE",
1911
+ )
1912
+ key = store.identity_key(connector.charger_id, connector.connector_id)
1913
+ store.transactions[key] = tx
1914
+ try:
1915
+ response = self.client.get(
1916
+ reverse(
1917
+ "charger-page-connector",
1918
+ args=[connector.charger_id, connector.connector_slug],
1919
+ )
1920
+ )
1921
+ self.assertContains(response, "TAGNONE")
1922
+ self.assertNotContains(response, "TAGNONE</a>")
1923
+
1924
+ overview = self.client.get(reverse("charger-page", args=[connector.charger_id]))
1925
+ self.assertContains(overview, "TAGNONE")
1926
+ finally:
1927
+ store.transactions.pop(key, None)
1928
+
1601
1929
  def test_display_name_used_on_public_pages(self):
1602
1930
  charger = Charger.objects.create(
1603
1931
  charger_id="NAMED",
@@ -1743,6 +2071,25 @@ class ChargerAdminTests(TestCase):
1743
2071
  log_url = reverse("admin:ocpp_charger_log", args=[charger.pk])
1744
2072
  self.assertContains(resp, log_url)
1745
2073
 
2074
+ def test_admin_status_overrides_available_when_active_session(self):
2075
+ charger = Charger.objects.create(
2076
+ charger_id="ADMINCHARGE",
2077
+ last_status="Available",
2078
+ )
2079
+ tx = Transaction.objects.create(
2080
+ charger=charger,
2081
+ start_time=timezone.now(),
2082
+ )
2083
+ key = store.identity_key(charger.charger_id, charger.connector_id)
2084
+ store.transactions[key] = tx
2085
+ try:
2086
+ url = reverse("admin:ocpp_charger_changelist")
2087
+ resp = self.client.get(url)
2088
+ charging_label = force_str(STATUS_BADGE_MAP["charging"][0])
2089
+ self.assertContains(resp, f">{charging_label}<")
2090
+ finally:
2091
+ store.transactions.pop(key, None)
2092
+
1746
2093
  def test_admin_log_view_displays_entries(self):
1747
2094
  charger = Charger.objects.create(charger_id="LOG2")
1748
2095
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
@@ -1925,6 +2272,95 @@ class ChargerAdminTests(TestCase):
1925
2272
  store.clear_log(log_key, log_type="charger")
1926
2273
  store.clear_log(pending_key, log_type="charger")
1927
2274
 
2275
+ def test_remote_stop_action_dispatches_request(self):
2276
+ charger = Charger.objects.create(charger_id="STOPME", connector_id=1)
2277
+ ws = DummyWebSocket()
2278
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
2279
+ pending_key = store.pending_key(charger.charger_id)
2280
+ store.clear_log(log_key, log_type="charger")
2281
+ store.clear_log(pending_key, log_type="charger")
2282
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2283
+ tx = Transaction.objects.create(
2284
+ charger=charger,
2285
+ start_time=timezone.now(),
2286
+ )
2287
+ tx_key = store.identity_key(charger.charger_id, charger.connector_id)
2288
+ store.transactions[tx_key] = tx
2289
+ store.pending_calls.clear()
2290
+ try:
2291
+ url = reverse("admin:ocpp_charger_changelist")
2292
+ response = self.client.post(
2293
+ url,
2294
+ {
2295
+ "action": "remote_stop_transaction",
2296
+ "_selected_action": [charger.pk],
2297
+ },
2298
+ follow=True,
2299
+ )
2300
+ self.assertEqual(response.status_code, 200)
2301
+ self.assertEqual(len(ws.sent), 1)
2302
+ frame = json.loads(ws.sent[0])
2303
+ self.assertEqual(frame[0], 2)
2304
+ self.assertEqual(frame[2], "RemoteStopTransaction")
2305
+ self.assertIn("transactionId", frame[3])
2306
+ self.assertEqual(frame[3]["transactionId"], tx.pk)
2307
+ self.assertIn(frame[1], store.pending_calls)
2308
+ metadata = store.pending_calls[frame[1]]
2309
+ self.assertEqual(metadata.get("action"), "RemoteStopTransaction")
2310
+ self.assertEqual(metadata.get("charger_id"), charger.charger_id)
2311
+ self.assertEqual(metadata.get("connector_id"), charger.connector_id)
2312
+ self.assertEqual(metadata.get("transaction_id"), tx.pk)
2313
+ self.assertEqual(metadata.get("log_key"), log_key)
2314
+ log_entries = store.get_logs(log_key, log_type="charger")
2315
+ self.assertTrue(
2316
+ any("RemoteStopTransaction" in entry for entry in log_entries)
2317
+ )
2318
+ finally:
2319
+ store.pop_connection(charger.charger_id, charger.connector_id)
2320
+ store.pending_calls.clear()
2321
+ store.transactions.pop(tx_key, None)
2322
+ store.clear_log(log_key, log_type="charger")
2323
+ store.clear_log(pending_key, log_type="charger")
2324
+
2325
+ def test_reset_action_dispatches_request(self):
2326
+ charger = Charger.objects.create(charger_id="RESETME", connector_id=1)
2327
+ ws = DummyWebSocket()
2328
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
2329
+ pending_key = store.pending_key(charger.charger_id)
2330
+ store.clear_log(log_key, log_type="charger")
2331
+ store.clear_log(pending_key, log_type="charger")
2332
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2333
+ store.pending_calls.clear()
2334
+ try:
2335
+ url = reverse("admin:ocpp_charger_changelist")
2336
+ response = self.client.post(
2337
+ url,
2338
+ {
2339
+ "action": "reset_chargers",
2340
+ "_selected_action": [charger.pk],
2341
+ },
2342
+ follow=True,
2343
+ )
2344
+ self.assertEqual(response.status_code, 200)
2345
+ self.assertEqual(len(ws.sent), 1)
2346
+ frame = json.loads(ws.sent[0])
2347
+ self.assertEqual(frame[0], 2)
2348
+ self.assertEqual(frame[2], "Reset")
2349
+ self.assertEqual(frame[3], {"type": "Soft"})
2350
+ self.assertIn(frame[1], store.pending_calls)
2351
+ metadata = store.pending_calls[frame[1]]
2352
+ self.assertEqual(metadata.get("action"), "Reset")
2353
+ self.assertEqual(metadata.get("charger_id"), charger.charger_id)
2354
+ self.assertEqual(metadata.get("connector_id"), charger.connector_id)
2355
+ self.assertEqual(metadata.get("log_key"), log_key)
2356
+ log_entries = store.get_logs(log_key, log_type="charger")
2357
+ self.assertTrue(any("Reset" in entry for entry in log_entries))
2358
+ finally:
2359
+ store.pop_connection(charger.charger_id, charger.connector_id)
2360
+ store.pending_calls.clear()
2361
+ store.clear_log(log_key, log_type="charger")
2362
+ store.clear_log(pending_key, log_type="charger")
2363
+
1928
2364
 
1929
2365
  class LocationAdminTests(TestCase):
1930
2366
  def setUp(self):
@@ -2027,6 +2463,20 @@ class SimulatorAdminTests(TransactionTestCase):
2027
2463
  mock_start.assert_called_once()
2028
2464
  store.simulators.clear()
2029
2465
 
2466
+ @patch("ocpp.admin.ChargePointSimulator.start")
2467
+ def test_start_simulator_change_action(self, mock_start):
2468
+ sim = Simulator.objects.create(name="SIMCHG", cp_path="SIMCHG")
2469
+ mock_start.return_value = (True, "Started", "/tmp/log")
2470
+ resp = self._post_simulator_change(
2471
+ sim,
2472
+ _action="start_simulator_action",
2473
+ )
2474
+ self.assertEqual(resp.status_code, 200)
2475
+ self.assertContains(resp, "View Log")
2476
+ self.assertContains(resp, "/tmp/log")
2477
+ mock_start.assert_called_once()
2478
+ store.simulators.clear()
2479
+
2030
2480
  def test_admin_shows_ws_url(self):
2031
2481
  sim = Simulator.objects.create(
2032
2482
  name="SIM2", cp_path="SIMY", host="h", ws_port=1111
@@ -2087,6 +2537,20 @@ class SimulatorAdminTests(TransactionTestCase):
2087
2537
  self.assertTrue(mock_get_loop.called)
2088
2538
  self.assertNotIn(sim.pk, store.simulators)
2089
2539
 
2540
+ @patch("ocpp.admin.asyncio.get_running_loop", side_effect=RuntimeError)
2541
+ def test_stop_simulator_change_action(self, mock_get_loop):
2542
+ sim = Simulator.objects.create(name="SIMCHGSTOP", cp_path="SIMCHGSTOP")
2543
+ stopper = SimpleNamespace(stop=AsyncMock())
2544
+ store.simulators[sim.pk] = stopper
2545
+ resp = self._post_simulator_change(
2546
+ sim,
2547
+ _action="stop_simulator_action",
2548
+ )
2549
+ self.assertEqual(resp.status_code, 200)
2550
+ stopper.stop.assert_awaited_once()
2551
+ self.assertTrue(mock_get_loop.called)
2552
+ self.assertNotIn(sim.pk, store.simulators)
2553
+
2090
2554
  def test_as_config_includes_custom_fields(self):
2091
2555
  sim = Simulator.objects.create(
2092
2556
  name="SIM3",
@@ -2171,6 +2635,104 @@ class SimulatorAdminTests(TransactionTestCase):
2171
2635
 
2172
2636
  await communicator.disconnect()
2173
2637
 
2638
+ async def test_query_string_cid_supported(self):
2639
+ communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
2640
+ connected, _ = await communicator.connect()
2641
+ self.assertTrue(connected)
2642
+
2643
+ await communicator.disconnect()
2644
+
2645
+ charger = await database_sync_to_async(Charger.objects.get)(charger_id="QSERIAL")
2646
+ self.assertEqual(charger.last_path, "/")
2647
+
2648
+ async def test_query_string_charge_point_id_supported(self):
2649
+ communicator = WebsocketCommunicator(
2650
+ application, "/?chargePointId=QCHARGE"
2651
+ )
2652
+ connected, _ = await communicator.connect()
2653
+ self.assertTrue(connected)
2654
+
2655
+ await communicator.disconnect()
2656
+
2657
+ charger = await database_sync_to_async(Charger.objects.get)(charger_id="QCHARGE")
2658
+ self.assertEqual(charger.last_path, "/")
2659
+
2660
+ async def test_query_string_charge_box_id_supported(self):
2661
+ communicator = WebsocketCommunicator(
2662
+ application, "/?chargeBoxId=QBOX"
2663
+ )
2664
+ connected, _ = await communicator.connect()
2665
+ self.assertTrue(connected)
2666
+
2667
+ await communicator.disconnect()
2668
+
2669
+ charger = await database_sync_to_async(Charger.objects.get)(charger_id="QBOX")
2670
+ self.assertEqual(charger.last_path, "/")
2671
+
2672
+ async def test_query_string_charge_box_id_case_insensitive(self):
2673
+ communicator = WebsocketCommunicator(
2674
+ application, "/?CHARGEBOXID=CaseSense"
2675
+ )
2676
+ connected, _ = await communicator.connect()
2677
+ self.assertTrue(connected)
2678
+
2679
+ await communicator.disconnect()
2680
+
2681
+ charger = await database_sync_to_async(Charger.objects.get)(
2682
+ charger_id="CaseSense"
2683
+ )
2684
+ self.assertEqual(charger.last_path, "/")
2685
+
2686
+ async def test_query_string_charge_box_id_snake_case_supported(self):
2687
+ communicator = WebsocketCommunicator(
2688
+ application, "/?charge_box_id=SnakeCase"
2689
+ )
2690
+ connected, _ = await communicator.connect()
2691
+ self.assertTrue(connected)
2692
+
2693
+ await communicator.disconnect()
2694
+
2695
+ charger = await database_sync_to_async(Charger.objects.get)(
2696
+ charger_id="SnakeCase"
2697
+ )
2698
+ self.assertEqual(charger.last_path, "/")
2699
+
2700
+ async def test_query_string_charge_box_id_strips_whitespace(self):
2701
+ communicator = WebsocketCommunicator(
2702
+ application, "/?chargeBoxId=%20Trimmed%20Value%20"
2703
+ )
2704
+ connected, _ = await communicator.connect()
2705
+ self.assertTrue(connected)
2706
+
2707
+ await communicator.disconnect()
2708
+
2709
+ charger = await database_sync_to_async(Charger.objects.get)(
2710
+ charger_id="Trimmed Value"
2711
+ )
2712
+ self.assertEqual(charger.last_path, "/")
2713
+
2714
+ async def test_query_string_cid_overrides_path_segment(self):
2715
+ communicator = WebsocketCommunicator(application, "/ocpp?cid=QSEGOVR")
2716
+ connected, _ = await communicator.connect()
2717
+ self.assertTrue(connected)
2718
+
2719
+ await communicator.disconnect()
2720
+
2721
+ charger = await database_sync_to_async(Charger.objects.get)(charger_id="QSEGOVR")
2722
+ self.assertEqual(charger.last_path, "/ocpp")
2723
+
2724
+ async def test_query_string_charge_point_id_overrides_path_segment(self):
2725
+ communicator = WebsocketCommunicator(
2726
+ application, "/ocpp?chargePointId=QPSEG"
2727
+ )
2728
+ connected, _ = await communicator.connect()
2729
+ self.assertTrue(connected)
2730
+
2731
+ await communicator.disconnect()
2732
+
2733
+ charger = await database_sync_to_async(Charger.objects.get)(charger_id="QPSEG")
2734
+ self.assertEqual(charger.last_path, "/ocpp")
2735
+
2174
2736
  async def test_nested_path_accepted_and_recorded(self):
2175
2737
  communicator = WebsocketCommunicator(application, "/foo/NEST/")
2176
2738
  connected, _ = await communicator.connect()
@@ -2790,6 +3352,32 @@ class ChargePointSimulatorTests(TransactionTestCase):
2790
3352
  self.assertIn("unknownKey", payload)
2791
3353
  self.assertEqual(payload["unknownKey"], ["GhostKey"])
2792
3354
 
3355
+ async def test_unknown_action_returns_call_error(self):
3356
+ cfg = SimulatorConfig(cp_path="SIM-CALL-ERROR/")
3357
+ sim = ChargePointSimulator(cfg)
3358
+ sent: list[list[object]] = []
3359
+
3360
+ async def send(msg: str):
3361
+ sent.append(json.loads(msg))
3362
+
3363
+ async def recv(): # pragma: no cover - should not be called
3364
+ raise AssertionError("recv should not be called for unsupported actions")
3365
+
3366
+ handled = await sim._handle_csms_call(
3367
+ [2, "msg-1", "Reset", {"type": "Soft"}],
3368
+ send,
3369
+ recv,
3370
+ )
3371
+
3372
+ self.assertTrue(handled)
3373
+ self.assertEqual(len(sent), 1)
3374
+ frame = sent[0]
3375
+ self.assertEqual(frame[0], 4)
3376
+ self.assertEqual(frame[1], "msg-1")
3377
+ self.assertEqual(frame[2], "NotSupported")
3378
+ self.assertIn("Reset", frame[3])
3379
+ self.assertIsInstance(frame[4], dict)
3380
+
2793
3381
  async def test_trigger_message_heartbeat_follow_up(self):
2794
3382
  cfg = SimulatorConfig()
2795
3383
  sim = ChargePointSimulator(cfg)
@@ -3039,6 +3627,27 @@ class DispatchActionViewTests(TestCase):
3039
3627
  self.assertFalse(store.pending_calls)
3040
3628
 
3041
3629
 
3630
+ class SimulatorStateMappingTests(TestCase):
3631
+ def tearDown(self):
3632
+ _simulators[1] = SimulatorState()
3633
+ _simulators[2] = SimulatorState()
3634
+
3635
+ def test_simulate_uses_requested_state(self):
3636
+ calls = []
3637
+
3638
+ async def fake(cp_idx, *args, sim_state=None, **kwargs):
3639
+ calls.append(sim_state)
3640
+ if sim_state is not None:
3641
+ sim_state.running = False
3642
+
3643
+ with patch("ocpp.evcs.simulate_cp", new=fake):
3644
+ coro = simulate(cp=2, daemon=True, threads=1)
3645
+ asyncio.run(coro)
3646
+
3647
+ self.assertEqual(len(calls), 1)
3648
+ self.assertIs(calls[0], _simulators[2])
3649
+
3650
+
3042
3651
  class ChargerStatusViewTests(TestCase):
3043
3652
  def setUp(self):
3044
3653
  self.client = Client()