arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.9.dist-info → arthexis-0.1.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
ocpp/store.py
CHANGED
|
@@ -14,12 +14,15 @@ IDENTITY_SEPARATOR = "#"
|
|
|
14
14
|
AGGREGATE_SLUG = "all"
|
|
15
15
|
PENDING_SLUG = "pending"
|
|
16
16
|
|
|
17
|
+
MAX_CONNECTIONS_PER_IP = 2
|
|
18
|
+
|
|
17
19
|
connections: dict[str, object] = {}
|
|
18
20
|
transactions: dict[str, object] = {}
|
|
19
21
|
logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
|
|
20
22
|
# store per charger session logs before they are flushed to disk
|
|
21
23
|
history: dict[str, dict[str, object]] = {}
|
|
22
24
|
simulators = {}
|
|
25
|
+
ip_connections: dict[str, set[object]] = {}
|
|
23
26
|
|
|
24
27
|
# mapping of charger id / cp_path to friendly names used for log files
|
|
25
28
|
log_names: dict[str, dict[str, str]] = {"charger": {}, "simulator": {}}
|
|
@@ -51,6 +54,33 @@ def identity_key(serial: str, connector: int | str | None) -> str:
|
|
|
51
54
|
return f"{serial}{IDENTITY_SEPARATOR}{connector_slug(connector)}"
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def register_ip_connection(ip: str | None, consumer: object) -> bool:
|
|
58
|
+
"""Track a websocket connection for the provided client IP."""
|
|
59
|
+
|
|
60
|
+
if not ip:
|
|
61
|
+
return True
|
|
62
|
+
conns = ip_connections.setdefault(ip, set())
|
|
63
|
+
if consumer in conns:
|
|
64
|
+
return True
|
|
65
|
+
if len(conns) >= MAX_CONNECTIONS_PER_IP:
|
|
66
|
+
return False
|
|
67
|
+
conns.add(consumer)
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def release_ip_connection(ip: str | None, consumer: object) -> None:
|
|
72
|
+
"""Remove a websocket connection from the active client registry."""
|
|
73
|
+
|
|
74
|
+
if not ip:
|
|
75
|
+
return
|
|
76
|
+
conns = ip_connections.get(ip)
|
|
77
|
+
if not conns:
|
|
78
|
+
return
|
|
79
|
+
conns.discard(consumer)
|
|
80
|
+
if not conns:
|
|
81
|
+
ip_connections.pop(ip, None)
|
|
82
|
+
|
|
83
|
+
|
|
54
84
|
def pending_key(serial: str) -> str:
|
|
55
85
|
"""Return the key used before a connector id has been negotiated."""
|
|
56
86
|
|
ocpp/tests.py
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
|
+
|
|
5
|
+
import django
|
|
6
|
+
|
|
7
|
+
django.setup()
|
|
8
|
+
|
|
1
9
|
from asgiref.testing import ApplicationCommunicator
|
|
2
10
|
from channels.testing import WebsocketCommunicator
|
|
3
11
|
from channels.db import database_sync_to_async
|
|
4
12
|
from asgiref.sync import async_to_sync
|
|
5
13
|
from django.test import Client, TransactionTestCase, TestCase, override_settings
|
|
6
14
|
from unittest import skip
|
|
7
|
-
from
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
from unittest.mock import patch, Mock
|
|
8
18
|
from django.contrib.auth import get_user_model
|
|
9
19
|
from django.urls import reverse
|
|
10
20
|
from django.utils import timezone
|
|
@@ -17,8 +27,8 @@ from nodes.models import Node, NodeRole
|
|
|
17
27
|
from config.asgi import application
|
|
18
28
|
|
|
19
29
|
from .models import Transaction, Charger, Simulator, MeterReading, Location
|
|
20
|
-
from
|
|
21
|
-
from core.models import RFID
|
|
30
|
+
from .consumers import CSMSConsumer
|
|
31
|
+
from core.models import EnergyAccount, EnergyCredit, Reference, RFID
|
|
22
32
|
from . import store
|
|
23
33
|
from django.db.models.deletion import ProtectedError
|
|
24
34
|
from decimal import Decimal
|
|
@@ -251,11 +261,83 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
251
261
|
|
|
252
262
|
mock_broadcast.assert_called_once()
|
|
253
263
|
_, kwargs = mock_broadcast.call_args
|
|
254
|
-
self.assertEqual(kwargs["subject"], "
|
|
255
|
-
|
|
256
|
-
self.
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
self.assertEqual(kwargs["subject"], "NETMSG")
|
|
265
|
+
body = kwargs["body"]
|
|
266
|
+
self.assertRegex(body, r"^\d+\.\d kWh \d{2}:\d{2}$")
|
|
267
|
+
|
|
268
|
+
async def test_consumption_message_updates_existing_entry(self):
|
|
269
|
+
original_interval = CSMSConsumer.consumption_update_interval
|
|
270
|
+
CSMSConsumer.consumption_update_interval = 0.01
|
|
271
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="UPDATEMSG")
|
|
272
|
+
communicator = WebsocketCommunicator(application, "/UPDATEMSG/")
|
|
273
|
+
connected, _ = await communicator.connect()
|
|
274
|
+
self.assertTrue(connected)
|
|
275
|
+
|
|
276
|
+
message_mock = Mock()
|
|
277
|
+
message_mock.uuid = "mock-uuid"
|
|
278
|
+
message_mock.save = Mock()
|
|
279
|
+
message_mock.propagate = Mock()
|
|
280
|
+
|
|
281
|
+
filter_mock = Mock()
|
|
282
|
+
filter_mock.first.return_value = message_mock
|
|
283
|
+
|
|
284
|
+
broadcast_result = SimpleNamespace(uuid="mock-uuid")
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
with patch(
|
|
288
|
+
"nodes.models.NetMessage.broadcast", return_value=broadcast_result
|
|
289
|
+
) as mock_broadcast, patch(
|
|
290
|
+
"nodes.models.NetMessage.objects.filter", return_value=filter_mock
|
|
291
|
+
):
|
|
292
|
+
await communicator.send_json_to(
|
|
293
|
+
[2, "1", "StartTransaction", {"meterStart": 1}]
|
|
294
|
+
)
|
|
295
|
+
await communicator.receive_json_from()
|
|
296
|
+
mock_broadcast.assert_called_once()
|
|
297
|
+
await asyncio.sleep(0.05)
|
|
298
|
+
await communicator.disconnect()
|
|
299
|
+
finally:
|
|
300
|
+
CSMSConsumer.consumption_update_interval = original_interval
|
|
301
|
+
with suppress(Exception):
|
|
302
|
+
await communicator.disconnect()
|
|
303
|
+
|
|
304
|
+
self.assertTrue(message_mock.save.called)
|
|
305
|
+
self.assertTrue(message_mock.propagate.called)
|
|
306
|
+
|
|
307
|
+
async def test_consumption_message_final_update_on_disconnect(self):
|
|
308
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="FINALMSG")
|
|
309
|
+
communicator = WebsocketCommunicator(application, "/FINALMSG/")
|
|
310
|
+
connected, _ = await communicator.connect()
|
|
311
|
+
self.assertTrue(connected)
|
|
312
|
+
|
|
313
|
+
message_mock = Mock()
|
|
314
|
+
message_mock.uuid = "mock-uuid"
|
|
315
|
+
message_mock.save = Mock()
|
|
316
|
+
message_mock.propagate = Mock()
|
|
317
|
+
|
|
318
|
+
filter_mock = Mock()
|
|
319
|
+
filter_mock.first.return_value = message_mock
|
|
320
|
+
|
|
321
|
+
broadcast_result = SimpleNamespace(uuid="mock-uuid")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
with patch(
|
|
325
|
+
"nodes.models.NetMessage.broadcast", return_value=broadcast_result
|
|
326
|
+
) as mock_broadcast, patch(
|
|
327
|
+
"nodes.models.NetMessage.objects.filter", return_value=filter_mock
|
|
328
|
+
):
|
|
329
|
+
await communicator.send_json_to(
|
|
330
|
+
[2, "1", "StartTransaction", {"meterStart": 1}]
|
|
331
|
+
)
|
|
332
|
+
await communicator.receive_json_from()
|
|
333
|
+
mock_broadcast.assert_called_once()
|
|
334
|
+
await communicator.disconnect()
|
|
335
|
+
finally:
|
|
336
|
+
with suppress(Exception):
|
|
337
|
+
await communicator.disconnect()
|
|
338
|
+
|
|
339
|
+
self.assertTrue(message_mock.save.called)
|
|
340
|
+
self.assertTrue(message_mock.propagate.called)
|
|
259
341
|
|
|
260
342
|
async def test_rfid_unbound_instance_created(self):
|
|
261
343
|
await database_sync_to_async(Charger.objects.create)(charger_id="NEWRFID")
|
|
@@ -529,6 +611,62 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
529
611
|
self.assertIn(2, connectors)
|
|
530
612
|
self.assertIn(None, connectors)
|
|
531
613
|
|
|
614
|
+
async def test_console_reference_created_for_aggregate_connector(self):
|
|
615
|
+
communicator = ClientWebsocketCommunicator(
|
|
616
|
+
application,
|
|
617
|
+
"/CONREF/",
|
|
618
|
+
client=("203.0.113.5", 12345),
|
|
619
|
+
)
|
|
620
|
+
connected, _ = await communicator.connect()
|
|
621
|
+
self.assertTrue(connected)
|
|
622
|
+
|
|
623
|
+
await communicator.send_json_to([2, "1", "BootNotification", {}])
|
|
624
|
+
await communicator.receive_json_from()
|
|
625
|
+
|
|
626
|
+
reference = await database_sync_to_async(
|
|
627
|
+
lambda: Reference.objects.get(alt_text="CONREF Console")
|
|
628
|
+
)()
|
|
629
|
+
self.assertEqual(reference.value, "http://203.0.113.5:8900")
|
|
630
|
+
self.assertTrue(reference.show_in_header)
|
|
631
|
+
|
|
632
|
+
await communicator.send_json_to(
|
|
633
|
+
[
|
|
634
|
+
2,
|
|
635
|
+
"2",
|
|
636
|
+
"StatusNotification",
|
|
637
|
+
{"connectorId": 1, "status": "Available"},
|
|
638
|
+
]
|
|
639
|
+
)
|
|
640
|
+
await communicator.receive_json_from()
|
|
641
|
+
|
|
642
|
+
count = await database_sync_to_async(
|
|
643
|
+
lambda: Reference.objects.filter(alt_text="CONREF Console").count()
|
|
644
|
+
)()
|
|
645
|
+
self.assertEqual(count, 1)
|
|
646
|
+
|
|
647
|
+
await communicator.disconnect()
|
|
648
|
+
|
|
649
|
+
async def test_console_reference_uses_forwarded_for_header(self):
|
|
650
|
+
communicator = ClientWebsocketCommunicator(
|
|
651
|
+
application,
|
|
652
|
+
"/FORWARDED/",
|
|
653
|
+
client=("127.0.0.1", 23456),
|
|
654
|
+
headers=[(b"x-forwarded-for", b"198.51.100.75, 127.0.0.1")],
|
|
655
|
+
)
|
|
656
|
+
connected, _ = await communicator.connect()
|
|
657
|
+
self.assertTrue(connected)
|
|
658
|
+
self.assertIn("198.51.100.75", store.ip_connections)
|
|
659
|
+
|
|
660
|
+
await communicator.send_json_to([2, "1", "BootNotification", {}])
|
|
661
|
+
await communicator.receive_json_from()
|
|
662
|
+
|
|
663
|
+
reference = await database_sync_to_async(
|
|
664
|
+
lambda: Reference.objects.get(alt_text="FORWARDED Console")
|
|
665
|
+
)()
|
|
666
|
+
self.assertEqual(reference.value, "http://198.51.100.75:8900")
|
|
667
|
+
|
|
668
|
+
await communicator.disconnect()
|
|
669
|
+
|
|
532
670
|
async def test_transaction_created_from_meter_values(self):
|
|
533
671
|
communicator = WebsocketCommunicator(application, "/NOSTART/")
|
|
534
672
|
connected, _ = await communicator.connect()
|
|
@@ -914,6 +1052,78 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
914
1052
|
store.transactions.pop(key2, None)
|
|
915
1053
|
|
|
916
1054
|
|
|
1055
|
+
async def test_rate_limit_blocks_third_connection(self):
|
|
1056
|
+
store.ip_connections.clear()
|
|
1057
|
+
ip = "203.0.113.10"
|
|
1058
|
+
communicator1 = ClientWebsocketCommunicator(
|
|
1059
|
+
application, "/IPLIMIT1/", client=(ip, 1001)
|
|
1060
|
+
)
|
|
1061
|
+
communicator2 = ClientWebsocketCommunicator(
|
|
1062
|
+
application, "/IPLIMIT2/", client=(ip, 1002)
|
|
1063
|
+
)
|
|
1064
|
+
communicator3 = ClientWebsocketCommunicator(
|
|
1065
|
+
application, "/IPLIMIT3/", client=(ip, 1003)
|
|
1066
|
+
)
|
|
1067
|
+
other = ClientWebsocketCommunicator(
|
|
1068
|
+
application, "/OTHERIP/", client=("198.51.100.5", 2001)
|
|
1069
|
+
)
|
|
1070
|
+
connected1 = connected2 = connected_other = False
|
|
1071
|
+
try:
|
|
1072
|
+
connected1, _ = await communicator1.connect()
|
|
1073
|
+
self.assertTrue(connected1)
|
|
1074
|
+
connected2, _ = await communicator2.connect()
|
|
1075
|
+
self.assertTrue(connected2)
|
|
1076
|
+
connected3, code = await communicator3.connect()
|
|
1077
|
+
self.assertFalse(connected3)
|
|
1078
|
+
self.assertEqual(code, 4003)
|
|
1079
|
+
connected_other, _ = await other.connect()
|
|
1080
|
+
self.assertTrue(connected_other)
|
|
1081
|
+
finally:
|
|
1082
|
+
if connected1:
|
|
1083
|
+
await communicator1.disconnect()
|
|
1084
|
+
if connected2:
|
|
1085
|
+
await communicator2.disconnect()
|
|
1086
|
+
if connected_other:
|
|
1087
|
+
await other.disconnect()
|
|
1088
|
+
|
|
1089
|
+
async def test_rate_limit_allows_reconnect_after_disconnect(self):
|
|
1090
|
+
store.ip_connections.clear()
|
|
1091
|
+
ip = "203.0.113.20"
|
|
1092
|
+
communicator1 = ClientWebsocketCommunicator(
|
|
1093
|
+
application, "/LIMITRESET1/", client=(ip, 3001)
|
|
1094
|
+
)
|
|
1095
|
+
communicator2 = ClientWebsocketCommunicator(
|
|
1096
|
+
application, "/LIMITRESET2/", client=(ip, 3002)
|
|
1097
|
+
)
|
|
1098
|
+
communicator3 = ClientWebsocketCommunicator(
|
|
1099
|
+
application, "/LIMITRESET3/", client=(ip, 3003)
|
|
1100
|
+
)
|
|
1101
|
+
communicator3_retry = None
|
|
1102
|
+
connected1 = connected2 = connected3_retry = False
|
|
1103
|
+
try:
|
|
1104
|
+
connected1, _ = await communicator1.connect()
|
|
1105
|
+
self.assertTrue(connected1)
|
|
1106
|
+
connected2, _ = await communicator2.connect()
|
|
1107
|
+
self.assertTrue(connected2)
|
|
1108
|
+
connected3, code = await communicator3.connect()
|
|
1109
|
+
self.assertFalse(connected3)
|
|
1110
|
+
self.assertEqual(code, 4003)
|
|
1111
|
+
await communicator1.disconnect()
|
|
1112
|
+
connected1 = False
|
|
1113
|
+
communicator3_retry = ClientWebsocketCommunicator(
|
|
1114
|
+
application, "/LIMITRESET4/", client=(ip, 3004)
|
|
1115
|
+
)
|
|
1116
|
+
connected3_retry, _ = await communicator3_retry.connect()
|
|
1117
|
+
self.assertTrue(connected3_retry)
|
|
1118
|
+
finally:
|
|
1119
|
+
if connected1:
|
|
1120
|
+
await communicator1.disconnect()
|
|
1121
|
+
if connected2:
|
|
1122
|
+
await communicator2.disconnect()
|
|
1123
|
+
if connected3_retry and communicator3_retry is not None:
|
|
1124
|
+
await communicator3_retry.disconnect()
|
|
1125
|
+
|
|
1126
|
+
|
|
917
1127
|
class ChargerLandingTests(TestCase):
|
|
918
1128
|
def setUp(self):
|
|
919
1129
|
self.client = Client()
|
|
@@ -1281,6 +1491,42 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1281
1491
|
resp = self.client.get(url)
|
|
1282
1492
|
self.assertContains(resp, "ws://h:1111/SIMY/")
|
|
1283
1493
|
|
|
1494
|
+
def test_admin_ws_url_without_port(self):
|
|
1495
|
+
sim = Simulator.objects.create(
|
|
1496
|
+
name="SIMNP", cp_path="SIMNP", host="h", ws_port=None
|
|
1497
|
+
)
|
|
1498
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
1499
|
+
resp = self.client.get(url)
|
|
1500
|
+
self.assertContains(resp, "ws://h/SIMNP/")
|
|
1501
|
+
|
|
1502
|
+
def test_send_open_door_action_requires_running_simulator(self):
|
|
1503
|
+
sim = Simulator.objects.create(name="SIMDO", cp_path="SIMDO")
|
|
1504
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
1505
|
+
resp = self.client.post(
|
|
1506
|
+
url,
|
|
1507
|
+
{"action": "send_open_door", "_selected_action": [sim.pk]},
|
|
1508
|
+
follow=True,
|
|
1509
|
+
)
|
|
1510
|
+
self.assertEqual(resp.status_code, 200)
|
|
1511
|
+
self.assertContains(resp, "simulator is not running")
|
|
1512
|
+
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1513
|
+
|
|
1514
|
+
def test_send_open_door_action_triggers_simulator(self):
|
|
1515
|
+
sim = Simulator.objects.create(name="SIMTRIG", cp_path="SIMTRIG")
|
|
1516
|
+
stub = SimpleNamespace(trigger_door_open=Mock())
|
|
1517
|
+
store.simulators[sim.pk] = stub
|
|
1518
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
1519
|
+
resp = self.client.post(
|
|
1520
|
+
url,
|
|
1521
|
+
{"action": "send_open_door", "_selected_action": [sim.pk]},
|
|
1522
|
+
follow=True,
|
|
1523
|
+
)
|
|
1524
|
+
self.assertEqual(resp.status_code, 200)
|
|
1525
|
+
stub.trigger_door_open.assert_called_once()
|
|
1526
|
+
self.assertContains(resp, "DoorOpen status notification sent")
|
|
1527
|
+
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1528
|
+
store.simulators.pop(sim.pk, None)
|
|
1529
|
+
|
|
1284
1530
|
def test_as_config_includes_custom_fields(self):
|
|
1285
1531
|
sim = Simulator.objects.create(
|
|
1286
1532
|
name="SIM3",
|
|
@@ -1298,6 +1544,45 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
1298
1544
|
self.assertEqual(cfg.pre_charge_delay, 5)
|
|
1299
1545
|
self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
|
|
1300
1546
|
|
|
1547
|
+
def _post_simulator_change(self, sim: Simulator, **overrides):
|
|
1548
|
+
url = reverse("admin:ocpp_simulator_change", args=[sim.pk])
|
|
1549
|
+
data = {
|
|
1550
|
+
"name": sim.name,
|
|
1551
|
+
"cp_path": sim.cp_path,
|
|
1552
|
+
"host": sim.host,
|
|
1553
|
+
"ws_port": sim.ws_port or "",
|
|
1554
|
+
"rfid": sim.rfid,
|
|
1555
|
+
"duration": sim.duration,
|
|
1556
|
+
"interval": sim.interval,
|
|
1557
|
+
"pre_charge_delay": sim.pre_charge_delay,
|
|
1558
|
+
"kw_max": sim.kw_max,
|
|
1559
|
+
"repeat": "on" if sim.repeat else "",
|
|
1560
|
+
"username": sim.username,
|
|
1561
|
+
"password": sim.password,
|
|
1562
|
+
"door_open": "on" if overrides.get("door_open", False) else "",
|
|
1563
|
+
"_save": "Save",
|
|
1564
|
+
}
|
|
1565
|
+
data.update(overrides)
|
|
1566
|
+
return self.client.post(url, data, follow=True)
|
|
1567
|
+
|
|
1568
|
+
def test_save_model_triggers_door_open(self):
|
|
1569
|
+
sim = Simulator.objects.create(name="SIMSAVE", cp_path="SIMSAVE")
|
|
1570
|
+
stub = SimpleNamespace(trigger_door_open=Mock())
|
|
1571
|
+
store.simulators[sim.pk] = stub
|
|
1572
|
+
resp = self._post_simulator_change(sim, door_open="on")
|
|
1573
|
+
self.assertEqual(resp.status_code, 200)
|
|
1574
|
+
stub.trigger_door_open.assert_called_once()
|
|
1575
|
+
self.assertContains(resp, "DoorOpen status notification sent")
|
|
1576
|
+
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1577
|
+
store.simulators.pop(sim.pk, None)
|
|
1578
|
+
|
|
1579
|
+
def test_save_model_reports_error_when_not_running(self):
|
|
1580
|
+
sim = Simulator.objects.create(name="SIMERR", cp_path="SIMERR")
|
|
1581
|
+
resp = self._post_simulator_change(sim, door_open="on")
|
|
1582
|
+
self.assertEqual(resp.status_code, 200)
|
|
1583
|
+
self.assertContains(resp, "simulator is not running")
|
|
1584
|
+
self.assertFalse(Simulator.objects.get(pk=sim.pk).door_open)
|
|
1585
|
+
|
|
1301
1586
|
async def test_unknown_charger_auto_registered(self):
|
|
1302
1587
|
communicator = WebsocketCommunicator(application, "/NEWCHG/")
|
|
1303
1588
|
connected, _ = await communicator.connect()
|
|
@@ -1781,6 +2066,80 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
1781
2066
|
server.close()
|
|
1782
2067
|
await server.wait_closed()
|
|
1783
2068
|
|
|
2069
|
+
async def test_door_open_event_sends_notifications(self):
|
|
2070
|
+
status_payloads = []
|
|
2071
|
+
|
|
2072
|
+
async def handler(ws):
|
|
2073
|
+
async for msg in ws:
|
|
2074
|
+
data = json.loads(msg)
|
|
2075
|
+
action = data[2]
|
|
2076
|
+
if action == "BootNotification":
|
|
2077
|
+
await ws.send(
|
|
2078
|
+
json.dumps(
|
|
2079
|
+
[
|
|
2080
|
+
3,
|
|
2081
|
+
data[1],
|
|
2082
|
+
{"status": "Accepted", "currentTime": "2024-01-01T00:00:00Z"},
|
|
2083
|
+
]
|
|
2084
|
+
)
|
|
2085
|
+
)
|
|
2086
|
+
elif action == "Authorize":
|
|
2087
|
+
await ws.send(json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}]))
|
|
2088
|
+
elif action == "StatusNotification":
|
|
2089
|
+
status_payloads.append(data[3])
|
|
2090
|
+
await ws.send(json.dumps([3, data[1], {}]))
|
|
2091
|
+
elif action == "StartTransaction":
|
|
2092
|
+
await ws.send(
|
|
2093
|
+
json.dumps(
|
|
2094
|
+
[
|
|
2095
|
+
3,
|
|
2096
|
+
data[1],
|
|
2097
|
+
{"transactionId": 1, "idTagInfo": {"status": "Accepted"}},
|
|
2098
|
+
]
|
|
2099
|
+
)
|
|
2100
|
+
)
|
|
2101
|
+
elif action == "MeterValues":
|
|
2102
|
+
await ws.send(json.dumps([3, data[1], {}]))
|
|
2103
|
+
elif action == "StopTransaction":
|
|
2104
|
+
await ws.send(json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}]))
|
|
2105
|
+
break
|
|
2106
|
+
|
|
2107
|
+
server = await websockets.serve(
|
|
2108
|
+
handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
|
|
2109
|
+
)
|
|
2110
|
+
port = server.sockets[0].getsockname()[1]
|
|
2111
|
+
|
|
2112
|
+
cfg = SimulatorConfig(
|
|
2113
|
+
host="127.0.0.1",
|
|
2114
|
+
ws_port=port,
|
|
2115
|
+
cp_path="SIMDOOR/",
|
|
2116
|
+
duration=0.2,
|
|
2117
|
+
interval=0.05,
|
|
2118
|
+
pre_charge_delay=0.0,
|
|
2119
|
+
)
|
|
2120
|
+
sim = ChargePointSimulator(cfg)
|
|
2121
|
+
sim.trigger_door_open()
|
|
2122
|
+
try:
|
|
2123
|
+
await sim._run_session()
|
|
2124
|
+
finally:
|
|
2125
|
+
server.close()
|
|
2126
|
+
await server.wait_closed()
|
|
2127
|
+
store.clear_log(cfg.cp_path, log_type="simulator")
|
|
2128
|
+
|
|
2129
|
+
door_open_messages = [p for p in status_payloads if p.get("errorCode") == "DoorOpen"]
|
|
2130
|
+
door_closed_messages = [p for p in status_payloads if p.get("errorCode") == "NoError"]
|
|
2131
|
+
self.assertTrue(door_open_messages)
|
|
2132
|
+
self.assertTrue(door_closed_messages)
|
|
2133
|
+
first_open = next(
|
|
2134
|
+
idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "DoorOpen"
|
|
2135
|
+
)
|
|
2136
|
+
first_close = next(
|
|
2137
|
+
idx for idx, payload in enumerate(status_payloads) if payload.get("errorCode") == "NoError"
|
|
2138
|
+
)
|
|
2139
|
+
self.assertLess(first_open, first_close)
|
|
2140
|
+
self.assertEqual(door_open_messages[0].get("status"), "Faulted")
|
|
2141
|
+
self.assertEqual(door_closed_messages[0].get("status"), "Available")
|
|
2142
|
+
|
|
1784
2143
|
|
|
1785
2144
|
class PurgeMeterReadingsTaskTests(TestCase):
|
|
1786
2145
|
def test_purge_old_meter_readings(self):
|
|
@@ -2294,3 +2653,20 @@ class LiveUpdateViewTests(TestCase):
|
|
|
2294
2653
|
resp = self.client.get(reverse("cp-simulator"))
|
|
2295
2654
|
self.assertEqual(resp.context["request"].live_update_interval, 5)
|
|
2296
2655
|
self.assertContains(resp, "setInterval(() => location.reload()")
|
|
2656
|
+
|
|
2657
|
+
def test_dashboard_hides_private_chargers(self):
|
|
2658
|
+
public = Charger.objects.create(charger_id="PUBLICCP")
|
|
2659
|
+
private = Charger.objects.create(
|
|
2660
|
+
charger_id="PRIVATECP", public_display=False
|
|
2661
|
+
)
|
|
2662
|
+
|
|
2663
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
2664
|
+
chargers = [item["charger"] for item in resp.context["chargers"]]
|
|
2665
|
+
self.assertIn(public, chargers)
|
|
2666
|
+
self.assertNotIn(private, chargers)
|
|
2667
|
+
|
|
2668
|
+
list_response = self.client.get(reverse("charger-list"))
|
|
2669
|
+
payload = list_response.json()
|
|
2670
|
+
ids = [item["charger_id"] for item in payload["chargers"]]
|
|
2671
|
+
self.assertIn(public.charger_id, ids)
|
|
2672
|
+
self.assertNotIn(private.charger_id, ids)
|