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.
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/RECORD +37 -34
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +42 -76
- config/settings_helpers.py +109 -0
- core/admin.py +47 -10
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +182 -59
- core/release.py +38 -20
- core/tests.py +11 -1
- core/views.py +47 -12
- core/widgets.py +43 -0
- nodes/admin.py +277 -14
- nodes/apps.py +15 -0
- nodes/models.py +224 -43
- nodes/tests.py +629 -10
- nodes/urls.py +1 -0
- nodes/views.py +173 -5
- ocpp/admin.py +146 -2
- ocpp/consumers.py +125 -8
- ocpp/evcs.py +7 -94
- ocpp/models.py +2 -0
- ocpp/routing.py +4 -2
- ocpp/simulator.py +29 -8
- ocpp/status_display.py +26 -0
- ocpp/tests.py +625 -16
- ocpp/transactions_io.py +10 -0
- ocpp/views.py +122 -22
- pages/admin.py +3 -0
- pages/forms.py +30 -1
- pages/tests.py +118 -1
- pages/views.py +12 -4
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.12.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 =
|
|
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.
|
|
738
|
-
self.
|
|
739
|
-
|
|
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(
|
|
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()
|