arthexis 0.1.8__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.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
|
+
import os
|
|
1
2
|
|
|
3
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
4
|
+
|
|
5
|
+
import django
|
|
6
|
+
|
|
7
|
+
django.setup()
|
|
8
|
+
|
|
9
|
+
from asgiref.testing import ApplicationCommunicator
|
|
2
10
|
from channels.testing import WebsocketCommunicator
|
|
3
11
|
from channels.db import database_sync_to_async
|
|
4
|
-
from
|
|
12
|
+
from asgiref.sync import async_to_sync
|
|
13
|
+
from django.test import Client, TransactionTestCase, TestCase, override_settings
|
|
5
14
|
from unittest import skip
|
|
6
|
-
from
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
from unittest.mock import patch, Mock
|
|
7
18
|
from django.contrib.auth import get_user_model
|
|
8
19
|
from django.urls import reverse
|
|
9
20
|
from django.utils import timezone
|
|
21
|
+
from django.utils.dateparse import parse_datetime
|
|
22
|
+
from django.utils.translation import override, gettext as _
|
|
10
23
|
from django.contrib.sites.models import Site
|
|
11
24
|
from pages.models import Application, Module
|
|
12
25
|
from nodes.models import Node, NodeRole
|
|
@@ -14,8 +27,8 @@ from nodes.models import Node, NodeRole
|
|
|
14
27
|
from config.asgi import application
|
|
15
28
|
|
|
16
29
|
from .models import Transaction, Charger, Simulator, MeterReading, Location
|
|
17
|
-
from
|
|
18
|
-
from core.models import RFID
|
|
30
|
+
from .consumers import CSMSConsumer
|
|
31
|
+
from core.models import EnergyAccount, EnergyCredit, Reference, RFID
|
|
19
32
|
from . import store
|
|
20
33
|
from django.db.models.deletion import ProtectedError
|
|
21
34
|
from decimal import Decimal
|
|
@@ -25,13 +38,78 @@ import asyncio
|
|
|
25
38
|
from pathlib import Path
|
|
26
39
|
from .simulator import SimulatorConfig, ChargePointSimulator
|
|
27
40
|
import re
|
|
28
|
-
from datetime import timedelta
|
|
41
|
+
from datetime import datetime, timedelta
|
|
29
42
|
from .tasks import purge_meter_readings
|
|
43
|
+
from django.db import close_old_connections
|
|
44
|
+
from django.db.utils import OperationalError
|
|
45
|
+
from urllib.parse import unquote, urlparse
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClientWebsocketCommunicator(WebsocketCommunicator):
|
|
49
|
+
"""WebsocketCommunicator that injects a client address into the scope."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
application,
|
|
54
|
+
path,
|
|
55
|
+
*,
|
|
56
|
+
client=None,
|
|
57
|
+
headers=None,
|
|
58
|
+
subprotocols=None,
|
|
59
|
+
spec_version=None,
|
|
60
|
+
):
|
|
61
|
+
if not isinstance(path, str):
|
|
62
|
+
raise TypeError(f"Expected str, got {type(path)}")
|
|
63
|
+
parsed = urlparse(path)
|
|
64
|
+
scope = {
|
|
65
|
+
"type": "websocket",
|
|
66
|
+
"path": unquote(parsed.path),
|
|
67
|
+
"query_string": parsed.query.encode("utf-8"),
|
|
68
|
+
"headers": headers or [],
|
|
69
|
+
"subprotocols": subprotocols or [],
|
|
70
|
+
}
|
|
71
|
+
if client is not None:
|
|
72
|
+
scope["client"] = client
|
|
73
|
+
if spec_version:
|
|
74
|
+
scope["spec_version"] = spec_version
|
|
75
|
+
self.scope = scope
|
|
76
|
+
ApplicationCommunicator.__init__(self, application, self.scope)
|
|
77
|
+
self.response_headers = None
|
|
78
|
+
|
|
30
79
|
|
|
80
|
+
class DummyWebSocket:
|
|
81
|
+
"""Simple websocket stub that records payloads sent by the view."""
|
|
82
|
+
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self.sent: list[str] = []
|
|
85
|
+
|
|
86
|
+
async def send(self, message):
|
|
87
|
+
self.sent.append(message)
|
|
31
88
|
|
|
32
89
|
|
|
33
90
|
class ChargerFixtureTests(TestCase):
|
|
34
|
-
fixtures = [
|
|
91
|
+
fixtures = [
|
|
92
|
+
p.name
|
|
93
|
+
for p in (Path(__file__).resolve().parent / "fixtures").glob(
|
|
94
|
+
"initial_data__*.json"
|
|
95
|
+
)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def setUpTestData(cls):
|
|
100
|
+
location = Location.objects.create(name="Simulator")
|
|
101
|
+
Charger.objects.create(
|
|
102
|
+
charger_id="CP1",
|
|
103
|
+
connector_id=1,
|
|
104
|
+
location=location,
|
|
105
|
+
require_rfid=False,
|
|
106
|
+
)
|
|
107
|
+
Charger.objects.create(
|
|
108
|
+
charger_id="CP2",
|
|
109
|
+
connector_id=2,
|
|
110
|
+
location=location,
|
|
111
|
+
require_rfid=True,
|
|
112
|
+
)
|
|
35
113
|
|
|
36
114
|
def test_cp2_requires_rfid(self):
|
|
37
115
|
cp2 = Charger.objects.get(charger_id="CP2")
|
|
@@ -44,12 +122,26 @@ class ChargerFixtureTests(TestCase):
|
|
|
44
122
|
def test_charger_connector_ids(self):
|
|
45
123
|
cp1 = Charger.objects.get(charger_id="CP1")
|
|
46
124
|
cp2 = Charger.objects.get(charger_id="CP2")
|
|
47
|
-
self.assertEqual(cp1.connector_id,
|
|
48
|
-
self.assertEqual(cp2.connector_id,
|
|
125
|
+
self.assertEqual(cp1.connector_id, 1)
|
|
126
|
+
self.assertEqual(cp2.connector_id, 2)
|
|
49
127
|
self.assertEqual(cp1.name, "Simulator #1")
|
|
50
128
|
self.assertEqual(cp2.name, "Simulator #2")
|
|
51
129
|
|
|
52
130
|
|
|
131
|
+
class ChargerUrlFallbackTests(TestCase):
|
|
132
|
+
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
133
|
+
def test_reference_created_when_site_missing(self):
|
|
134
|
+
Site.objects.all().delete()
|
|
135
|
+
Site.objects.clear_cache()
|
|
136
|
+
|
|
137
|
+
charger = Charger.objects.create(charger_id="NO_SITE")
|
|
138
|
+
charger.refresh_from_db()
|
|
139
|
+
|
|
140
|
+
self.assertIsNotNone(charger.reference)
|
|
141
|
+
self.assertTrue(charger.reference.value.startswith("http://fallback.example"))
|
|
142
|
+
self.assertTrue(charger.reference.value.endswith("/c/NO_SITE/"))
|
|
143
|
+
|
|
144
|
+
|
|
53
145
|
class SinkConsumerTests(TransactionTestCase):
|
|
54
146
|
async def test_sink_replies(self):
|
|
55
147
|
communicator = WebsocketCommunicator(application, "/ws/sink/")
|
|
@@ -64,17 +156,40 @@ class SinkConsumerTests(TransactionTestCase):
|
|
|
64
156
|
|
|
65
157
|
|
|
66
158
|
class CSMSConsumerTests(TransactionTestCase):
|
|
159
|
+
async def _retry_db(self, func, attempts: int = 5, delay: float = 0.1):
|
|
160
|
+
"""Run a database function, retrying if the database is locked."""
|
|
161
|
+
for _ in range(attempts):
|
|
162
|
+
try:
|
|
163
|
+
return await database_sync_to_async(func)()
|
|
164
|
+
except OperationalError:
|
|
165
|
+
await database_sync_to_async(close_old_connections)()
|
|
166
|
+
await asyncio.sleep(delay)
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
async def _send_status_notification(self, serial: str, payload: dict):
|
|
170
|
+
communicator = WebsocketCommunicator(application, f"/{serial}/")
|
|
171
|
+
connected, _ = await communicator.connect()
|
|
172
|
+
self.assertTrue(connected)
|
|
173
|
+
|
|
174
|
+
await communicator.send_json_to([2, "1", "StatusNotification", payload])
|
|
175
|
+
response = await communicator.receive_json_from()
|
|
176
|
+
self.assertEqual(response, [3, "1", {}])
|
|
177
|
+
|
|
178
|
+
await communicator.disconnect()
|
|
179
|
+
|
|
67
180
|
async def test_transaction_saved(self):
|
|
68
181
|
communicator = WebsocketCommunicator(application, "/TEST/")
|
|
69
182
|
connected, _ = await communicator.connect()
|
|
70
183
|
self.assertTrue(connected)
|
|
71
184
|
|
|
72
|
-
await communicator.send_json_to(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
185
|
+
await communicator.send_json_to(
|
|
186
|
+
[
|
|
187
|
+
2,
|
|
188
|
+
"1",
|
|
189
|
+
"StartTransaction",
|
|
190
|
+
{"meterStart": 10, "connectorId": 3},
|
|
191
|
+
]
|
|
192
|
+
)
|
|
78
193
|
response = await communicator.receive_json_from()
|
|
79
194
|
tx_id = response[2]["transactionId"]
|
|
80
195
|
|
|
@@ -82,14 +197,17 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
82
197
|
pk=tx_id, charger__charger_id="TEST"
|
|
83
198
|
)
|
|
84
199
|
self.assertEqual(tx.meter_start, 10)
|
|
200
|
+
self.assertEqual(tx.connector_id, 3)
|
|
85
201
|
self.assertIsNone(tx.stop_time)
|
|
86
202
|
|
|
87
|
-
await communicator.send_json_to(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
203
|
+
await communicator.send_json_to(
|
|
204
|
+
[
|
|
205
|
+
2,
|
|
206
|
+
"2",
|
|
207
|
+
"StopTransaction",
|
|
208
|
+
{"transactionId": tx_id, "meterStop": 20},
|
|
209
|
+
]
|
|
210
|
+
)
|
|
93
211
|
await communicator.receive_json_from()
|
|
94
212
|
|
|
95
213
|
await database_sync_to_async(tx.refresh_from_db)()
|
|
@@ -117,6 +235,286 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
117
235
|
|
|
118
236
|
await communicator.disconnect()
|
|
119
237
|
|
|
238
|
+
async def test_start_transaction_sends_net_message(self):
|
|
239
|
+
location = await database_sync_to_async(Location.objects.create)(
|
|
240
|
+
name="Test Location"
|
|
241
|
+
)
|
|
242
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
243
|
+
charger_id="NETMSG", location=location
|
|
244
|
+
)
|
|
245
|
+
communicator = WebsocketCommunicator(application, "/NETMSG/")
|
|
246
|
+
connected, _ = await communicator.connect()
|
|
247
|
+
self.assertTrue(connected)
|
|
248
|
+
|
|
249
|
+
with patch("nodes.models.NetMessage.broadcast") as mock_broadcast:
|
|
250
|
+
await communicator.send_json_to(
|
|
251
|
+
[
|
|
252
|
+
2,
|
|
253
|
+
"1",
|
|
254
|
+
"StartTransaction",
|
|
255
|
+
{"meterStart": 1, "connectorId": 1},
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
await communicator.receive_json_from()
|
|
259
|
+
|
|
260
|
+
await communicator.disconnect()
|
|
261
|
+
|
|
262
|
+
mock_broadcast.assert_called_once()
|
|
263
|
+
_, kwargs = mock_broadcast.call_args
|
|
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)
|
|
341
|
+
|
|
342
|
+
async def test_rfid_unbound_instance_created(self):
|
|
343
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="NEWRFID")
|
|
344
|
+
communicator = WebsocketCommunicator(application, "/NEWRFID/")
|
|
345
|
+
connected, _ = await communicator.connect()
|
|
346
|
+
self.assertTrue(connected)
|
|
347
|
+
|
|
348
|
+
await communicator.send_json_to(
|
|
349
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "idTag": "TAG456"}]
|
|
350
|
+
)
|
|
351
|
+
await communicator.receive_json_from()
|
|
352
|
+
|
|
353
|
+
tag = await database_sync_to_async(RFID.objects.get)(rfid="TAG456")
|
|
354
|
+
count = await database_sync_to_async(tag.energy_accounts.count)()
|
|
355
|
+
self.assertEqual(count, 0)
|
|
356
|
+
|
|
357
|
+
await communicator.disconnect()
|
|
358
|
+
|
|
359
|
+
async def test_firmware_status_notification_updates_database_and_views(self):
|
|
360
|
+
communicator = WebsocketCommunicator(application, "/FWSTAT/")
|
|
361
|
+
connected, _ = await communicator.connect()
|
|
362
|
+
self.assertTrue(connected)
|
|
363
|
+
|
|
364
|
+
ts = timezone.now().replace(microsecond=0)
|
|
365
|
+
payload = {
|
|
366
|
+
"status": "Installing",
|
|
367
|
+
"statusInfo": "Applying patch",
|
|
368
|
+
"timestamp": ts.isoformat(),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await communicator.send_json_to(
|
|
372
|
+
[2, "1", "FirmwareStatusNotification", payload]
|
|
373
|
+
)
|
|
374
|
+
response = await communicator.receive_json_from()
|
|
375
|
+
self.assertEqual(response, [3, "1", {}])
|
|
376
|
+
|
|
377
|
+
def _fetch_status():
|
|
378
|
+
charger = Charger.objects.get(charger_id="FWSTAT", connector_id=None)
|
|
379
|
+
return (
|
|
380
|
+
charger.firmware_status,
|
|
381
|
+
charger.firmware_status_info,
|
|
382
|
+
charger.firmware_timestamp,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
status, info, recorded_ts = await database_sync_to_async(_fetch_status)()
|
|
386
|
+
self.assertEqual(status, "Installing")
|
|
387
|
+
self.assertEqual(info, "Applying patch")
|
|
388
|
+
self.assertIsNotNone(recorded_ts)
|
|
389
|
+
self.assertEqual(recorded_ts.replace(microsecond=0), ts)
|
|
390
|
+
|
|
391
|
+
log_entries = store.get_logs(store.identity_key("FWSTAT", None), log_type="charger")
|
|
392
|
+
self.assertTrue(
|
|
393
|
+
any("FirmwareStatusNotification" in entry for entry in log_entries)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _fetch_views():
|
|
397
|
+
User = get_user_model()
|
|
398
|
+
user = User.objects.create_user(username="fwstatus", password="pw")
|
|
399
|
+
client = Client()
|
|
400
|
+
client.force_login(user)
|
|
401
|
+
detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
|
|
402
|
+
status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
|
|
403
|
+
list_response = client.get(reverse("charger-list"))
|
|
404
|
+
return (
|
|
405
|
+
detail.status_code,
|
|
406
|
+
json.loads(detail.content.decode()),
|
|
407
|
+
status_page.status_code,
|
|
408
|
+
status_page.content.decode(),
|
|
409
|
+
list_response.status_code,
|
|
410
|
+
json.loads(list_response.content.decode()),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
(
|
|
414
|
+
detail_code,
|
|
415
|
+
detail_payload,
|
|
416
|
+
status_code,
|
|
417
|
+
html,
|
|
418
|
+
list_code,
|
|
419
|
+
list_payload,
|
|
420
|
+
) = await database_sync_to_async(_fetch_views)()
|
|
421
|
+
self.assertEqual(detail_code, 200)
|
|
422
|
+
self.assertEqual(status_code, 200)
|
|
423
|
+
self.assertEqual(list_code, 200)
|
|
424
|
+
self.assertEqual(detail_payload["firmwareStatus"], "Installing")
|
|
425
|
+
self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
|
|
426
|
+
self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
|
|
427
|
+
self.assertIn('id="firmware-status">Installing<', html)
|
|
428
|
+
self.assertIn('id="firmware-status-info">Applying patch<', html)
|
|
429
|
+
match = re.search(
|
|
430
|
+
r'id="firmware-timestamp"[^>]*data-iso="([^"]+)"', html
|
|
431
|
+
)
|
|
432
|
+
self.assertIsNotNone(match)
|
|
433
|
+
parsed_iso = datetime.fromisoformat(match.group(1))
|
|
434
|
+
self.assertAlmostEqual(parsed_iso.timestamp(), ts.timestamp(), places=3)
|
|
435
|
+
|
|
436
|
+
matching = [
|
|
437
|
+
item
|
|
438
|
+
for item in list_payload.get("chargers", [])
|
|
439
|
+
if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
|
|
440
|
+
]
|
|
441
|
+
self.assertTrue(matching)
|
|
442
|
+
self.assertEqual(matching[0]["firmwareStatus"], "Installing")
|
|
443
|
+
self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
|
|
444
|
+
list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
|
|
445
|
+
self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
|
|
446
|
+
|
|
447
|
+
store.clear_log(store.identity_key("FWSTAT", None), log_type="charger")
|
|
448
|
+
|
|
449
|
+
await communicator.disconnect()
|
|
450
|
+
|
|
451
|
+
async def test_firmware_status_notification_updates_connector_and_aggregate(
|
|
452
|
+
self,
|
|
453
|
+
):
|
|
454
|
+
communicator = WebsocketCommunicator(application, "/FWCONN/")
|
|
455
|
+
connected, _ = await communicator.connect()
|
|
456
|
+
self.assertTrue(connected)
|
|
457
|
+
|
|
458
|
+
await communicator.send_json_to(
|
|
459
|
+
[
|
|
460
|
+
2,
|
|
461
|
+
"1",
|
|
462
|
+
"FirmwareStatusNotification",
|
|
463
|
+
{"connectorId": 2, "status": "Downloaded"},
|
|
464
|
+
]
|
|
465
|
+
)
|
|
466
|
+
response = await communicator.receive_json_from()
|
|
467
|
+
self.assertEqual(response, [3, "1", {}])
|
|
468
|
+
|
|
469
|
+
def _fetch_chargers():
|
|
470
|
+
aggregate = Charger.objects.get(charger_id="FWCONN", connector_id=None)
|
|
471
|
+
connector = Charger.objects.get(charger_id="FWCONN", connector_id=2)
|
|
472
|
+
return (
|
|
473
|
+
aggregate.firmware_status,
|
|
474
|
+
aggregate.firmware_status_info,
|
|
475
|
+
aggregate.firmware_timestamp,
|
|
476
|
+
connector.firmware_status,
|
|
477
|
+
connector.firmware_status_info,
|
|
478
|
+
connector.firmware_timestamp,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
(
|
|
482
|
+
aggregate_status,
|
|
483
|
+
aggregate_info,
|
|
484
|
+
aggregate_ts,
|
|
485
|
+
connector_status,
|
|
486
|
+
connector_info,
|
|
487
|
+
connector_ts,
|
|
488
|
+
) = await database_sync_to_async(_fetch_chargers)()
|
|
489
|
+
|
|
490
|
+
self.assertEqual(aggregate_status, "Downloaded")
|
|
491
|
+
self.assertEqual(connector_status, "Downloaded")
|
|
492
|
+
self.assertEqual(aggregate_info, "")
|
|
493
|
+
self.assertEqual(connector_info, "")
|
|
494
|
+
self.assertIsNotNone(aggregate_ts)
|
|
495
|
+
self.assertIsNotNone(connector_ts)
|
|
496
|
+
self.assertAlmostEqual(
|
|
497
|
+
(connector_ts - aggregate_ts).total_seconds(), 0, delta=1.0
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
log_entries = store.get_logs(
|
|
501
|
+
store.identity_key("FWCONN", 2), log_type="charger"
|
|
502
|
+
)
|
|
503
|
+
self.assertTrue(
|
|
504
|
+
any("FirmwareStatusNotification" in entry for entry in log_entries)
|
|
505
|
+
)
|
|
506
|
+
log_entries_agg = store.get_logs(
|
|
507
|
+
store.identity_key("FWCONN", None), log_type="charger"
|
|
508
|
+
)
|
|
509
|
+
self.assertTrue(
|
|
510
|
+
any("FirmwareStatusNotification" in entry for entry in log_entries_agg)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
store.clear_log(store.identity_key("FWCONN", 2), log_type="charger")
|
|
514
|
+
store.clear_log(store.identity_key("FWCONN", None), log_type="charger")
|
|
515
|
+
|
|
516
|
+
await communicator.disconnect()
|
|
517
|
+
|
|
120
518
|
async def test_vin_recorded(self):
|
|
121
519
|
await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
|
|
122
520
|
communicator = WebsocketCommunicator(application, "/VINREC/")
|
|
@@ -153,8 +551,119 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
153
551
|
await communicator.send_json_to([2, "1", "MeterValues", payload])
|
|
154
552
|
await communicator.receive_json_from()
|
|
155
553
|
|
|
156
|
-
charger = await database_sync_to_async(Charger.objects.get)(
|
|
157
|
-
|
|
554
|
+
charger = await database_sync_to_async(Charger.objects.get)(
|
|
555
|
+
charger_id="NEWCID", connector_id=7
|
|
556
|
+
)
|
|
557
|
+
self.assertEqual(charger.connector_id, 7)
|
|
558
|
+
|
|
559
|
+
await communicator.disconnect()
|
|
560
|
+
|
|
561
|
+
async def test_new_charger_created_for_different_connector(self):
|
|
562
|
+
communicator = WebsocketCommunicator(application, "/DUPC/")
|
|
563
|
+
connected, _ = await communicator.connect()
|
|
564
|
+
self.assertTrue(connected)
|
|
565
|
+
|
|
566
|
+
payload1 = {
|
|
567
|
+
"connectorId": 1,
|
|
568
|
+
"meterValue": [
|
|
569
|
+
{
|
|
570
|
+
"timestamp": timezone.now().isoformat(),
|
|
571
|
+
"sampledValue": [{"value": "1"}],
|
|
572
|
+
}
|
|
573
|
+
],
|
|
574
|
+
}
|
|
575
|
+
await communicator.send_json_to([2, "1", "MeterValues", payload1])
|
|
576
|
+
await communicator.receive_json_from()
|
|
577
|
+
await communicator.disconnect()
|
|
578
|
+
await communicator.wait()
|
|
579
|
+
await database_sync_to_async(close_old_connections)()
|
|
580
|
+
|
|
581
|
+
communicator = WebsocketCommunicator(application, "/DUPC/")
|
|
582
|
+
connected, _ = await communicator.connect()
|
|
583
|
+
self.assertTrue(connected)
|
|
584
|
+
payload2 = {
|
|
585
|
+
"connectorId": 2,
|
|
586
|
+
"meterValue": [
|
|
587
|
+
{
|
|
588
|
+
"timestamp": timezone.now().isoformat(),
|
|
589
|
+
"sampledValue": [{"value": "1"}],
|
|
590
|
+
}
|
|
591
|
+
],
|
|
592
|
+
}
|
|
593
|
+
await communicator.send_json_to([2, "1", "MeterValues", payload2])
|
|
594
|
+
await communicator.receive_json_from()
|
|
595
|
+
await communicator.disconnect()
|
|
596
|
+
await communicator.wait()
|
|
597
|
+
await database_sync_to_async(close_old_connections)()
|
|
598
|
+
|
|
599
|
+
count = await self._retry_db(
|
|
600
|
+
lambda: Charger.objects.filter(charger_id="DUPC").count()
|
|
601
|
+
)
|
|
602
|
+
self.assertEqual(count, 3)
|
|
603
|
+
connectors = await self._retry_db(
|
|
604
|
+
lambda: list(
|
|
605
|
+
Charger.objects.filter(charger_id="DUPC").values_list(
|
|
606
|
+
"connector_id", flat=True
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
self.assertIn(1, connectors)
|
|
611
|
+
self.assertIn(2, connectors)
|
|
612
|
+
self.assertIn(None, connectors)
|
|
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")
|
|
158
667
|
|
|
159
668
|
await communicator.disconnect()
|
|
160
669
|
|
|
@@ -208,6 +717,58 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
208
717
|
|
|
209
718
|
await communicator.disconnect()
|
|
210
719
|
|
|
720
|
+
async def test_diagnostics_status_notification_updates_records(self):
|
|
721
|
+
communicator = WebsocketCommunicator(application, "/DIAGCP/")
|
|
722
|
+
connected, _ = await communicator.connect()
|
|
723
|
+
self.assertTrue(connected)
|
|
724
|
+
|
|
725
|
+
reported_at = timezone.now().replace(microsecond=0)
|
|
726
|
+
payload = {
|
|
727
|
+
"status": "Uploaded",
|
|
728
|
+
"connectorId": 5,
|
|
729
|
+
"uploadLocation": "https://example.com/diag.tar",
|
|
730
|
+
"timestamp": reported_at.isoformat(),
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
await communicator.send_json_to(
|
|
734
|
+
[2, "1", "DiagnosticsStatusNotification", payload]
|
|
735
|
+
)
|
|
736
|
+
response = await communicator.receive_json_from()
|
|
737
|
+
self.assertEqual(response[0], 3)
|
|
738
|
+
self.assertEqual(response[2], {})
|
|
739
|
+
|
|
740
|
+
def _fetch():
|
|
741
|
+
aggregate = Charger.objects.get(charger_id="DIAGCP", connector_id=None)
|
|
742
|
+
connector = Charger.objects.get(charger_id="DIAGCP", connector_id=5)
|
|
743
|
+
return aggregate, connector
|
|
744
|
+
|
|
745
|
+
aggregate, connector = await database_sync_to_async(_fetch)()
|
|
746
|
+
self.assertEqual(aggregate.diagnostics_status, "Uploaded")
|
|
747
|
+
self.assertEqual(connector.diagnostics_status, "Uploaded")
|
|
748
|
+
self.assertEqual(
|
|
749
|
+
aggregate.diagnostics_location, "https://example.com/diag.tar"
|
|
750
|
+
)
|
|
751
|
+
self.assertEqual(
|
|
752
|
+
connector.diagnostics_location, "https://example.com/diag.tar"
|
|
753
|
+
)
|
|
754
|
+
self.assertEqual(aggregate.diagnostics_timestamp, reported_at)
|
|
755
|
+
self.assertEqual(connector.diagnostics_timestamp, reported_at)
|
|
756
|
+
|
|
757
|
+
connector_logs = store.get_logs(
|
|
758
|
+
store.identity_key("DIAGCP", 5), log_type="charger"
|
|
759
|
+
)
|
|
760
|
+
aggregate_logs = store.get_logs(
|
|
761
|
+
store.identity_key("DIAGCP", None), log_type="charger"
|
|
762
|
+
)
|
|
763
|
+
self.assertTrue(
|
|
764
|
+
any("DiagnosticsStatusNotification" in entry for entry in connector_logs)
|
|
765
|
+
)
|
|
766
|
+
self.assertTrue(
|
|
767
|
+
any("DiagnosticsStatusNotification" in entry for entry in aggregate_logs)
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
await communicator.disconnect()
|
|
771
|
+
|
|
211
772
|
async def test_temperature_recorded(self):
|
|
212
773
|
charger = await database_sync_to_async(Charger.objects.create)(
|
|
213
774
|
charger_id="TEMP1"
|
|
@@ -245,6 +806,106 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
245
806
|
|
|
246
807
|
await communicator.disconnect()
|
|
247
808
|
|
|
809
|
+
def test_status_notification_updates_models_and_views(self):
|
|
810
|
+
serial = "STATUS-CP"
|
|
811
|
+
payload = {
|
|
812
|
+
"connectorId": 1,
|
|
813
|
+
"status": "Faulted",
|
|
814
|
+
"errorCode": "GroundFailure",
|
|
815
|
+
"info": "Relay malfunction",
|
|
816
|
+
"vendorId": "ACME",
|
|
817
|
+
"timestamp": "2024-01-01T12:34:56Z",
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async_to_sync(self._send_status_notification)(serial, payload)
|
|
821
|
+
|
|
822
|
+
expected_ts = parse_datetime(payload["timestamp"])
|
|
823
|
+
aggregate = Charger.objects.get(charger_id=serial, connector_id=None)
|
|
824
|
+
connector = Charger.objects.get(charger_id=serial, connector_id=1)
|
|
825
|
+
|
|
826
|
+
vendor_data = {"info": payload["info"], "vendorId": payload["vendorId"]}
|
|
827
|
+
self.assertEqual(aggregate.last_status, payload["status"])
|
|
828
|
+
self.assertEqual(aggregate.last_error_code, payload["errorCode"])
|
|
829
|
+
self.assertEqual(aggregate.last_status_vendor_info, vendor_data)
|
|
830
|
+
self.assertEqual(aggregate.last_status_timestamp, expected_ts)
|
|
831
|
+
self.assertEqual(connector.last_status, payload["status"])
|
|
832
|
+
self.assertEqual(connector.last_error_code, payload["errorCode"])
|
|
833
|
+
self.assertEqual(connector.last_status_vendor_info, vendor_data)
|
|
834
|
+
self.assertEqual(connector.last_status_timestamp, expected_ts)
|
|
835
|
+
|
|
836
|
+
connector_log = store.get_logs(
|
|
837
|
+
store.identity_key(serial, 1), log_type="charger"
|
|
838
|
+
)
|
|
839
|
+
self.assertTrue(
|
|
840
|
+
any("StatusNotification processed" in entry for entry in connector_log)
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
user = get_user_model().objects.create_user(
|
|
844
|
+
username="status", email="status@example.com", password="pwd"
|
|
845
|
+
)
|
|
846
|
+
self.client.force_login(user)
|
|
847
|
+
|
|
848
|
+
list_response = self.client.get(reverse("charger-list"))
|
|
849
|
+
self.assertEqual(list_response.status_code, 200)
|
|
850
|
+
chargers = list_response.json()["chargers"]
|
|
851
|
+
aggregate_entry = next(
|
|
852
|
+
item
|
|
853
|
+
for item in chargers
|
|
854
|
+
if item["charger_id"] == serial and item["connector_id"] is None
|
|
855
|
+
)
|
|
856
|
+
connector_entry = next(
|
|
857
|
+
item
|
|
858
|
+
for item in chargers
|
|
859
|
+
if item["charger_id"] == serial and item["connector_id"] == 1
|
|
860
|
+
)
|
|
861
|
+
expected_iso = expected_ts.isoformat()
|
|
862
|
+
self.assertEqual(aggregate_entry["lastStatus"], payload["status"])
|
|
863
|
+
self.assertEqual(aggregate_entry["lastErrorCode"], payload["errorCode"])
|
|
864
|
+
self.assertEqual(aggregate_entry["lastStatusVendorInfo"], vendor_data)
|
|
865
|
+
self.assertEqual(aggregate_entry["lastStatusTimestamp"], expected_iso)
|
|
866
|
+
self.assertEqual(aggregate_entry["status"], "Faulted (GroundFailure)")
|
|
867
|
+
self.assertEqual(aggregate_entry["statusColor"], "#dc3545")
|
|
868
|
+
self.assertEqual(connector_entry["lastStatus"], payload["status"])
|
|
869
|
+
self.assertEqual(connector_entry["lastErrorCode"], payload["errorCode"])
|
|
870
|
+
self.assertEqual(connector_entry["lastStatusVendorInfo"], vendor_data)
|
|
871
|
+
self.assertEqual(connector_entry["lastStatusTimestamp"], expected_iso)
|
|
872
|
+
self.assertEqual(connector_entry["status"], "Faulted (GroundFailure)")
|
|
873
|
+
self.assertEqual(connector_entry["statusColor"], "#dc3545")
|
|
874
|
+
|
|
875
|
+
detail_response = self.client.get(
|
|
876
|
+
reverse("charger-detail-connector", args=[serial, 1])
|
|
877
|
+
)
|
|
878
|
+
self.assertEqual(detail_response.status_code, 200)
|
|
879
|
+
detail_payload = detail_response.json()
|
|
880
|
+
self.assertEqual(detail_payload["lastStatus"], payload["status"])
|
|
881
|
+
self.assertEqual(detail_payload["lastErrorCode"], payload["errorCode"])
|
|
882
|
+
self.assertEqual(detail_payload["lastStatusVendorInfo"], vendor_data)
|
|
883
|
+
self.assertEqual(detail_payload["lastStatusTimestamp"], expected_iso)
|
|
884
|
+
self.assertEqual(detail_payload["status"], "Faulted (GroundFailure)")
|
|
885
|
+
self.assertEqual(detail_payload["statusColor"], "#dc3545")
|
|
886
|
+
|
|
887
|
+
status_resp = self.client.get(
|
|
888
|
+
reverse("charger-status-connector", args=[serial, "1"])
|
|
889
|
+
)
|
|
890
|
+
self.assertContains(status_resp, "Faulted (GroundFailure)")
|
|
891
|
+
self.assertContains(status_resp, "Error code: GroundFailure")
|
|
892
|
+
self.assertContains(status_resp, "Vendor: ACME")
|
|
893
|
+
self.assertContains(status_resp, "Info: Relay malfunction")
|
|
894
|
+
self.assertContains(status_resp, "background-color: #dc3545")
|
|
895
|
+
|
|
896
|
+
aggregate_status = self.client.get(reverse("charger-status", args=[serial]))
|
|
897
|
+
self.assertContains(aggregate_status, "Reported status")
|
|
898
|
+
self.assertContains(aggregate_status, "Info: Relay malfunction")
|
|
899
|
+
|
|
900
|
+
page_resp = self.client.get(reverse("charger-page", args=[serial]))
|
|
901
|
+
self.assertContains(page_resp, "Faulted (GroundFailure)")
|
|
902
|
+
self.assertContains(page_resp, "Vendor")
|
|
903
|
+
self.assertContains(page_resp, "Relay malfunction")
|
|
904
|
+
self.assertContains(page_resp, "background-color: #dc3545")
|
|
905
|
+
|
|
906
|
+
store.clear_log(store.identity_key(serial, 1), log_type="charger")
|
|
907
|
+
store.clear_log(store.identity_key(serial, None), log_type="charger")
|
|
908
|
+
|
|
248
909
|
async def test_message_logged_and_session_file_created(self):
|
|
249
910
|
cid = "LOGTEST1"
|
|
250
911
|
log_path = Path("logs") / f"charger.{cid}.log"
|
|
@@ -258,21 +919,25 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
258
919
|
connected, _ = await communicator.connect()
|
|
259
920
|
self.assertTrue(connected)
|
|
260
921
|
|
|
261
|
-
await communicator.send_json_to(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
922
|
+
await communicator.send_json_to(
|
|
923
|
+
[
|
|
924
|
+
2,
|
|
925
|
+
"1",
|
|
926
|
+
"StartTransaction",
|
|
927
|
+
{"meterStart": 1},
|
|
928
|
+
]
|
|
929
|
+
)
|
|
267
930
|
response = await communicator.receive_json_from()
|
|
268
931
|
tx_id = response[2]["transactionId"]
|
|
269
932
|
|
|
270
|
-
await communicator.send_json_to(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
933
|
+
await communicator.send_json_to(
|
|
934
|
+
[
|
|
935
|
+
2,
|
|
936
|
+
"2",
|
|
937
|
+
"StopTransaction",
|
|
938
|
+
{"transactionId": tx_id, "meterStop": 2},
|
|
939
|
+
]
|
|
940
|
+
)
|
|
276
941
|
await communicator.receive_json_from()
|
|
277
942
|
await communicator.disconnect()
|
|
278
943
|
|
|
@@ -313,12 +978,14 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
313
978
|
connected, _ = await communicator.connect()
|
|
314
979
|
self.assertTrue(connected)
|
|
315
980
|
|
|
316
|
-
await communicator.send_json_to(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
981
|
+
await communicator.send_json_to(
|
|
982
|
+
[
|
|
983
|
+
2,
|
|
984
|
+
"1",
|
|
985
|
+
"StartTransaction",
|
|
986
|
+
{"meterStart": 5},
|
|
987
|
+
]
|
|
988
|
+
)
|
|
322
989
|
await communicator.receive_json_from()
|
|
323
990
|
|
|
324
991
|
await communicator.disconnect()
|
|
@@ -333,7 +1000,8 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
333
1000
|
communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
|
|
334
1001
|
connected, _ = await communicator1.connect()
|
|
335
1002
|
self.assertTrue(connected)
|
|
336
|
-
|
|
1003
|
+
pending_key = store.pending_key("DUPLICATE")
|
|
1004
|
+
first_consumer = store.connections.get(pending_key)
|
|
337
1005
|
|
|
338
1006
|
communicator2 = WebsocketCommunicator(application, "/DUPLICATE/")
|
|
339
1007
|
connected2, _ = await communicator2.connect()
|
|
@@ -341,9 +1009,119 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
341
1009
|
|
|
342
1010
|
# The first communicator should be closed when the second connects.
|
|
343
1011
|
await communicator1.wait()
|
|
344
|
-
self.assertIsNot(store.connections.get(
|
|
1012
|
+
self.assertIsNot(store.connections.get(pending_key), first_consumer)
|
|
1013
|
+
|
|
1014
|
+
await communicator2.disconnect()
|
|
1015
|
+
|
|
1016
|
+
async def test_connectors_share_serial_without_disconnecting(self):
|
|
1017
|
+
communicator1 = WebsocketCommunicator(application, "/MULTI/")
|
|
1018
|
+
connected1, _ = await communicator1.connect()
|
|
1019
|
+
self.assertTrue(connected1)
|
|
1020
|
+
await communicator1.send_json_to(
|
|
1021
|
+
[
|
|
1022
|
+
2,
|
|
1023
|
+
"1",
|
|
1024
|
+
"StartTransaction",
|
|
1025
|
+
{"connectorId": 1, "meterStart": 10},
|
|
1026
|
+
]
|
|
1027
|
+
)
|
|
1028
|
+
await communicator1.receive_json_from()
|
|
1029
|
+
|
|
1030
|
+
communicator2 = WebsocketCommunicator(application, "/MULTI/")
|
|
1031
|
+
connected2, _ = await communicator2.connect()
|
|
1032
|
+
self.assertTrue(connected2)
|
|
1033
|
+
await communicator2.send_json_to(
|
|
1034
|
+
[
|
|
1035
|
+
2,
|
|
1036
|
+
"2",
|
|
1037
|
+
"StartTransaction",
|
|
1038
|
+
{"connectorId": 2, "meterStart": 10},
|
|
1039
|
+
]
|
|
1040
|
+
)
|
|
1041
|
+
await communicator2.receive_json_from()
|
|
345
1042
|
|
|
1043
|
+
key1 = store.identity_key("MULTI", 1)
|
|
1044
|
+
key2 = store.identity_key("MULTI", 2)
|
|
1045
|
+
self.assertIn(key1, store.connections)
|
|
1046
|
+
self.assertIn(key2, store.connections)
|
|
1047
|
+
self.assertIsNot(store.connections[key1], store.connections[key2])
|
|
1048
|
+
|
|
1049
|
+
await communicator1.disconnect()
|
|
346
1050
|
await communicator2.disconnect()
|
|
1051
|
+
store.transactions.pop(key1, None)
|
|
1052
|
+
store.transactions.pop(key2, None)
|
|
1053
|
+
|
|
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()
|
|
347
1125
|
|
|
348
1126
|
|
|
349
1127
|
class ChargerLandingTests(TestCase):
|
|
@@ -359,10 +1137,17 @@ class ChargerLandingTests(TestCase):
|
|
|
359
1137
|
|
|
360
1138
|
response = self.client.get(reverse("charger-page", args=["PAGE1"]))
|
|
361
1139
|
self.assertEqual(response.status_code, 200)
|
|
362
|
-
self.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
1140
|
+
self.assertEqual(response.context["LANGUAGE_CODE"], "es")
|
|
1141
|
+
with override("es"):
|
|
1142
|
+
self.assertContains(
|
|
1143
|
+
response,
|
|
1144
|
+
_(
|
|
1145
|
+
"Plug in your vehicle and slide your RFID card over the reader to begin charging."
|
|
1146
|
+
),
|
|
1147
|
+
)
|
|
1148
|
+
self.assertContains(response, _("Advanced View"))
|
|
1149
|
+
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1150
|
+
self.assertContains(response, status_url)
|
|
366
1151
|
|
|
367
1152
|
def test_status_page_renders(self):
|
|
368
1153
|
charger = Charger.objects.create(charger_id="PAGE2")
|
|
@@ -377,10 +1162,23 @@ class ChargerLandingTests(TestCase):
|
|
|
377
1162
|
meter_start=1000,
|
|
378
1163
|
start_time=timezone.now(),
|
|
379
1164
|
)
|
|
380
|
-
store.
|
|
1165
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1166
|
+
store.transactions[key] = tx
|
|
381
1167
|
resp = self.client.get(reverse("charger-page", args=["STATS"]))
|
|
382
|
-
self.assertContains(resp, "progress")
|
|
383
|
-
store.transactions.pop(
|
|
1168
|
+
self.assertContains(resp, "progress-bar")
|
|
1169
|
+
store.transactions.pop(key, None)
|
|
1170
|
+
|
|
1171
|
+
def test_display_name_used_on_public_pages(self):
|
|
1172
|
+
charger = Charger.objects.create(
|
|
1173
|
+
charger_id="NAMED",
|
|
1174
|
+
display_name="Entrada",
|
|
1175
|
+
)
|
|
1176
|
+
landing = self.client.get(reverse("charger-page", args=["NAMED"]))
|
|
1177
|
+
self.assertContains(landing, "Entrada")
|
|
1178
|
+
status = self.client.get(
|
|
1179
|
+
reverse("charger-status-connector", args=["NAMED", "all"])
|
|
1180
|
+
)
|
|
1181
|
+
self.assertContains(status, "Entrada")
|
|
384
1182
|
|
|
385
1183
|
def test_total_includes_ongoing_transaction(self):
|
|
386
1184
|
charger = Charger.objects.create(charger_id="ONGOING")
|
|
@@ -389,7 +1187,8 @@ class ChargerLandingTests(TestCase):
|
|
|
389
1187
|
meter_start=1000,
|
|
390
1188
|
start_time=timezone.now(),
|
|
391
1189
|
)
|
|
392
|
-
store.
|
|
1190
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1191
|
+
store.transactions[key] = tx
|
|
393
1192
|
MeterReading.objects.create(
|
|
394
1193
|
charger=charger,
|
|
395
1194
|
transaction=tx,
|
|
@@ -399,10 +1198,29 @@ class ChargerLandingTests(TestCase):
|
|
|
399
1198
|
unit="W",
|
|
400
1199
|
)
|
|
401
1200
|
resp = self.client.get(reverse("charger-status", args=["ONGOING"]))
|
|
402
|
-
self.assertContains(
|
|
403
|
-
|
|
1201
|
+
self.assertContains(resp, 'Total Energy: <span id="total-kw">1.50</span> kW')
|
|
1202
|
+
store.transactions.pop(key, None)
|
|
1203
|
+
|
|
1204
|
+
def test_connector_specific_routes_render(self):
|
|
1205
|
+
Charger.objects.create(charger_id="ROUTED")
|
|
1206
|
+
connector = Charger.objects.create(charger_id="ROUTED", connector_id=1)
|
|
1207
|
+
page = self.client.get(reverse("charger-page-connector", args=["ROUTED", "1"]))
|
|
1208
|
+
self.assertEqual(page.status_code, 200)
|
|
1209
|
+
status = self.client.get(
|
|
1210
|
+
reverse("charger-status-connector", args=["ROUTED", "1"])
|
|
1211
|
+
)
|
|
1212
|
+
self.assertEqual(status.status_code, 200)
|
|
1213
|
+
search = self.client.get(
|
|
1214
|
+
reverse("charger-session-search-connector", args=["ROUTED", "1"])
|
|
404
1215
|
)
|
|
405
|
-
|
|
1216
|
+
self.assertEqual(search.status_code, 200)
|
|
1217
|
+
log_id = store.identity_key("ROUTED", connector.connector_id)
|
|
1218
|
+
store.add_log(log_id, "entry", log_type="charger")
|
|
1219
|
+
log = self.client.get(
|
|
1220
|
+
reverse("charger-log-connector", args=["ROUTED", "1"]) + "?type=charger"
|
|
1221
|
+
)
|
|
1222
|
+
self.assertContains(log, "entry")
|
|
1223
|
+
store.clear_log(log_id, log_type="charger")
|
|
406
1224
|
|
|
407
1225
|
def test_temperature_displayed(self):
|
|
408
1226
|
charger = Charger.objects.create(
|
|
@@ -413,20 +1231,22 @@ class ChargerLandingTests(TestCase):
|
|
|
413
1231
|
self.assertContains(resp, "21.5")
|
|
414
1232
|
|
|
415
1233
|
def test_log_page_renders_without_charger(self):
|
|
416
|
-
store.
|
|
417
|
-
|
|
1234
|
+
log_id = store.identity_key("LOG1", None)
|
|
1235
|
+
store.add_log(log_id, "hello", log_type="charger")
|
|
1236
|
+
entry = store.get_logs(log_id, log_type="charger")[0]
|
|
418
1237
|
self.assertRegex(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
|
|
419
1238
|
resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
|
|
420
1239
|
self.assertEqual(resp.status_code, 200)
|
|
421
1240
|
self.assertContains(resp, "hello")
|
|
422
|
-
store.clear_log(
|
|
1241
|
+
store.clear_log(log_id, log_type="charger")
|
|
423
1242
|
|
|
424
1243
|
def test_log_page_is_case_insensitive(self):
|
|
425
|
-
store.
|
|
1244
|
+
log_id = store.identity_key("cp2", None)
|
|
1245
|
+
store.add_log(log_id, "entry", log_type="charger")
|
|
426
1246
|
resp = self.client.get(reverse("charger-log", args=["CP2"]) + "?type=charger")
|
|
427
1247
|
self.assertEqual(resp.status_code, 200)
|
|
428
1248
|
self.assertContains(resp, "entry")
|
|
429
|
-
store.clear_log(
|
|
1249
|
+
store.clear_log(log_id, log_type="charger")
|
|
430
1250
|
|
|
431
1251
|
|
|
432
1252
|
class SimulatorLandingTests(TestCase):
|
|
@@ -471,7 +1291,7 @@ class ChargerAdminTests(TestCase):
|
|
|
471
1291
|
url = reverse("admin:ocpp_charger_changelist")
|
|
472
1292
|
resp = self.client.get(url)
|
|
473
1293
|
self.assertContains(resp, charger.get_absolute_url())
|
|
474
|
-
status_url = reverse("charger-status", args=["ADMIN1"])
|
|
1294
|
+
status_url = reverse("charger-status-connector", args=["ADMIN1", "all"])
|
|
475
1295
|
self.assertContains(resp, status_url)
|
|
476
1296
|
|
|
477
1297
|
def test_admin_does_not_list_qr_link(self):
|
|
@@ -484,9 +1304,19 @@ class ChargerAdminTests(TestCase):
|
|
|
484
1304
|
charger = Charger.objects.create(charger_id="LOG1")
|
|
485
1305
|
url = reverse("admin:ocpp_charger_changelist")
|
|
486
1306
|
resp = self.client.get(url)
|
|
487
|
-
log_url = reverse("
|
|
1307
|
+
log_url = reverse("admin:ocpp_charger_log", args=[charger.pk])
|
|
488
1308
|
self.assertContains(resp, log_url)
|
|
489
1309
|
|
|
1310
|
+
def test_admin_log_view_displays_entries(self):
|
|
1311
|
+
charger = Charger.objects.create(charger_id="LOG2")
|
|
1312
|
+
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
|
1313
|
+
store.add_log(log_id, "entry", log_type="charger")
|
|
1314
|
+
url = reverse("admin:ocpp_charger_log", args=[charger.pk])
|
|
1315
|
+
resp = self.client.get(url)
|
|
1316
|
+
self.assertEqual(resp.status_code, 200)
|
|
1317
|
+
self.assertContains(resp, "entry")
|
|
1318
|
+
store.clear_log(log_id, log_type="charger")
|
|
1319
|
+
|
|
490
1320
|
def test_admin_change_links_landing_page(self):
|
|
491
1321
|
charger = Charger.objects.create(charger_id="CHANGE1")
|
|
492
1322
|
url = reverse("admin:ocpp_charger_change", args=[charger.pk])
|
|
@@ -525,12 +1355,14 @@ class ChargerAdminTests(TestCase):
|
|
|
525
1355
|
timestamp=timezone.now(),
|
|
526
1356
|
value=1,
|
|
527
1357
|
)
|
|
528
|
-
store.add_log("PURGE1", "entry", log_type="charger")
|
|
1358
|
+
store.add_log(store.identity_key("PURGE1", None), "entry", log_type="charger")
|
|
529
1359
|
url = reverse("admin:ocpp_charger_changelist")
|
|
530
|
-
self.client.post(
|
|
1360
|
+
self.client.post(
|
|
1361
|
+
url, {"action": "purge_data", "_selected_action": [charger.pk]}
|
|
1362
|
+
)
|
|
531
1363
|
self.assertFalse(Transaction.objects.filter(charger=charger).exists())
|
|
532
1364
|
self.assertFalse(MeterReading.objects.filter(charger=charger).exists())
|
|
533
|
-
self.assertNotIn("PURGE1", store.logs["charger"])
|
|
1365
|
+
self.assertNotIn(store.identity_key("PURGE1", None), store.logs["charger"])
|
|
534
1366
|
|
|
535
1367
|
def test_delete_requires_purge(self):
|
|
536
1368
|
charger = Charger.objects.create(charger_id="DEL1")
|
|
@@ -543,11 +1375,44 @@ class ChargerAdminTests(TestCase):
|
|
|
543
1375
|
self.client.post(delete_url, {"post": "yes"})
|
|
544
1376
|
self.assertTrue(Charger.objects.filter(pk=charger.pk).exists())
|
|
545
1377
|
url = reverse("admin:ocpp_charger_changelist")
|
|
546
|
-
self.client.post(
|
|
1378
|
+
self.client.post(
|
|
1379
|
+
url, {"action": "purge_data", "_selected_action": [charger.pk]}
|
|
1380
|
+
)
|
|
547
1381
|
self.client.post(delete_url, {"post": "yes"})
|
|
548
1382
|
self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
|
|
549
1383
|
|
|
550
1384
|
|
|
1385
|
+
class LocationAdminTests(TestCase):
|
|
1386
|
+
def setUp(self):
|
|
1387
|
+
self.client = Client()
|
|
1388
|
+
User = get_user_model()
|
|
1389
|
+
self.admin = User.objects.create_superuser(
|
|
1390
|
+
username="loc-admin", password="secret", email="loc@example.com"
|
|
1391
|
+
)
|
|
1392
|
+
self.client.force_login(self.admin)
|
|
1393
|
+
|
|
1394
|
+
def test_change_form_lists_related_chargers(self):
|
|
1395
|
+
location = Location.objects.create(name="LocAdmin")
|
|
1396
|
+
base = Charger.objects.create(charger_id="LOCBASE", location=location)
|
|
1397
|
+
connector = Charger.objects.create(
|
|
1398
|
+
charger_id="LOCALTWO",
|
|
1399
|
+
connector_id=1,
|
|
1400
|
+
location=location,
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
url = reverse("admin:ocpp_location_change", args=[location.pk])
|
|
1404
|
+
resp = self.client.get(url)
|
|
1405
|
+
self.assertEqual(resp.status_code, 200)
|
|
1406
|
+
|
|
1407
|
+
base_change_url = reverse("admin:ocpp_charger_change", args=[base.pk])
|
|
1408
|
+
connector_change_url = reverse("admin:ocpp_charger_change", args=[connector.pk])
|
|
1409
|
+
|
|
1410
|
+
self.assertContains(resp, base_change_url)
|
|
1411
|
+
self.assertContains(resp, connector_change_url)
|
|
1412
|
+
self.assertContains(resp, f"Charge Point: {base.charger_id}")
|
|
1413
|
+
self.assertContains(resp, f"Charge Point: {connector.charger_id} #1")
|
|
1414
|
+
|
|
1415
|
+
|
|
551
1416
|
class TransactionAdminTests(TestCase):
|
|
552
1417
|
def setUp(self):
|
|
553
1418
|
self.client = Client()
|
|
@@ -572,7 +1437,7 @@ class TransactionAdminTests(TestCase):
|
|
|
572
1437
|
self.assertContains(resp, str(reading.value))
|
|
573
1438
|
|
|
574
1439
|
|
|
575
|
-
class SimulatorAdminTests(
|
|
1440
|
+
class SimulatorAdminTests(TransactionTestCase):
|
|
576
1441
|
def setUp(self):
|
|
577
1442
|
self.client = Client()
|
|
578
1443
|
User = get_user_model()
|
|
@@ -580,21 +1445,88 @@ class SimulatorAdminTests(TestCase):
|
|
|
580
1445
|
username="admin2", password="secret", email="admin2@example.com"
|
|
581
1446
|
)
|
|
582
1447
|
self.client.force_login(self.admin)
|
|
1448
|
+
store.simulators.clear()
|
|
1449
|
+
store.logs["simulator"].clear()
|
|
1450
|
+
store.log_names["simulator"].clear()
|
|
583
1451
|
|
|
584
1452
|
def test_admin_lists_log_link(self):
|
|
585
1453
|
sim = Simulator.objects.create(name="SIM", cp_path="SIMX")
|
|
586
1454
|
url = reverse("admin:ocpp_simulator_changelist")
|
|
587
1455
|
resp = self.client.get(url)
|
|
588
|
-
log_url = reverse("
|
|
1456
|
+
log_url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
|
|
1457
|
+
self.assertContains(resp, log_url)
|
|
1458
|
+
|
|
1459
|
+
def test_admin_log_view_displays_entries(self):
|
|
1460
|
+
sim = Simulator.objects.create(name="SIMLOG", cp_path="SIMLOG")
|
|
1461
|
+
store.add_log("SIMLOG", "entry", log_type="simulator")
|
|
1462
|
+
url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
|
|
1463
|
+
resp = self.client.get(url)
|
|
1464
|
+
self.assertEqual(resp.status_code, 200)
|
|
1465
|
+
self.assertContains(resp, "entry")
|
|
1466
|
+
store.clear_log("SIMLOG", log_type="simulator")
|
|
1467
|
+
|
|
1468
|
+
@patch("ocpp.admin.ChargePointSimulator.start")
|
|
1469
|
+
def test_start_simulator_message_includes_log_link(self, mock_start):
|
|
1470
|
+
sim = Simulator.objects.create(name="SIMMSG", cp_path="SIMMSG")
|
|
1471
|
+
mock_start.return_value = (True, "Connection accepted", "/tmp/sim.log")
|
|
1472
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
1473
|
+
resp = self.client.post(
|
|
1474
|
+
url,
|
|
1475
|
+
{"action": "start_simulator", "_selected_action": [sim.pk]},
|
|
1476
|
+
follow=True,
|
|
1477
|
+
)
|
|
1478
|
+
self.assertEqual(resp.status_code, 200)
|
|
1479
|
+
log_url = reverse("admin:ocpp_simulator_log", args=[sim.pk])
|
|
1480
|
+
self.assertContains(resp, "View Log")
|
|
589
1481
|
self.assertContains(resp, log_url)
|
|
1482
|
+
self.assertContains(resp, "/tmp/sim.log")
|
|
1483
|
+
mock_start.assert_called_once()
|
|
1484
|
+
store.simulators.clear()
|
|
590
1485
|
|
|
591
1486
|
def test_admin_shows_ws_url(self):
|
|
592
|
-
sim = Simulator.objects.create(
|
|
593
|
-
|
|
1487
|
+
sim = Simulator.objects.create(
|
|
1488
|
+
name="SIM2", cp_path="SIMY", host="h", ws_port=1111
|
|
1489
|
+
)
|
|
594
1490
|
url = reverse("admin:ocpp_simulator_changelist")
|
|
595
1491
|
resp = self.client.get(url)
|
|
596
1492
|
self.assertContains(resp, "ws://h:1111/SIMY/")
|
|
597
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
|
+
|
|
598
1530
|
def test_as_config_includes_custom_fields(self):
|
|
599
1531
|
sim = Simulator.objects.create(
|
|
600
1532
|
name="SIM3",
|
|
@@ -612,12 +1544,53 @@ class SimulatorAdminTests(TestCase):
|
|
|
612
1544
|
self.assertEqual(cfg.pre_charge_delay, 5)
|
|
613
1545
|
self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
|
|
614
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
|
+
|
|
615
1586
|
async def test_unknown_charger_auto_registered(self):
|
|
616
1587
|
communicator = WebsocketCommunicator(application, "/NEWCHG/")
|
|
617
1588
|
connected, _ = await communicator.connect()
|
|
618
1589
|
self.assertTrue(connected)
|
|
619
1590
|
|
|
620
|
-
exists = await database_sync_to_async(
|
|
1591
|
+
exists = await database_sync_to_async(
|
|
1592
|
+
Charger.objects.filter(charger_id="NEWCHG").exists
|
|
1593
|
+
)()
|
|
621
1594
|
self.assertTrue(exists)
|
|
622
1595
|
|
|
623
1596
|
charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEWCHG")
|
|
@@ -636,21 +1609,27 @@ class SimulatorAdminTests(TestCase):
|
|
|
636
1609
|
self.assertEqual(charger.last_path, "/foo/NEST/")
|
|
637
1610
|
|
|
638
1611
|
async def test_rfid_required_rejects_invalid(self):
|
|
639
|
-
await database_sync_to_async(Charger.objects.create)(
|
|
1612
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
1613
|
+
charger_id="RFID", require_rfid=True
|
|
1614
|
+
)
|
|
640
1615
|
communicator = WebsocketCommunicator(application, "/RFID/")
|
|
641
1616
|
connected, _ = await communicator.connect()
|
|
642
1617
|
self.assertTrue(connected)
|
|
643
1618
|
|
|
644
|
-
await communicator.send_json_to(
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1619
|
+
await communicator.send_json_to(
|
|
1620
|
+
[
|
|
1621
|
+
2,
|
|
1622
|
+
"1",
|
|
1623
|
+
"StartTransaction",
|
|
1624
|
+
{"meterStart": 0},
|
|
1625
|
+
]
|
|
1626
|
+
)
|
|
650
1627
|
response = await communicator.receive_json_from()
|
|
651
1628
|
self.assertEqual(response[2]["idTagInfo"]["status"], "Invalid")
|
|
652
1629
|
|
|
653
|
-
exists = await database_sync_to_async(
|
|
1630
|
+
exists = await database_sync_to_async(
|
|
1631
|
+
Transaction.objects.filter(charger__charger_id="RFID").exists
|
|
1632
|
+
)()
|
|
654
1633
|
self.assertFalse(exists)
|
|
655
1634
|
|
|
656
1635
|
await communicator.disconnect()
|
|
@@ -668,22 +1647,28 @@ class SimulatorAdminTests(TestCase):
|
|
|
668
1647
|
)
|
|
669
1648
|
tag = await database_sync_to_async(RFID.objects.create)(rfid="CARDX")
|
|
670
1649
|
await database_sync_to_async(acc.rfids.add)(tag)
|
|
671
|
-
await database_sync_to_async(Charger.objects.create)(
|
|
1650
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
1651
|
+
charger_id="RFIDOK", require_rfid=True
|
|
1652
|
+
)
|
|
672
1653
|
communicator = WebsocketCommunicator(application, "/RFIDOK/")
|
|
673
1654
|
connected, _ = await communicator.connect()
|
|
674
1655
|
self.assertTrue(connected)
|
|
675
1656
|
|
|
676
|
-
await communicator.send_json_to(
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1657
|
+
await communicator.send_json_to(
|
|
1658
|
+
[
|
|
1659
|
+
2,
|
|
1660
|
+
"1",
|
|
1661
|
+
"StartTransaction",
|
|
1662
|
+
{"meterStart": 5, "idTag": "CARDX"},
|
|
1663
|
+
]
|
|
1664
|
+
)
|
|
682
1665
|
response = await communicator.receive_json_from()
|
|
683
1666
|
self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
|
|
684
1667
|
tx_id = response[2]["transactionId"]
|
|
685
1668
|
|
|
686
|
-
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1669
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1670
|
+
pk=tx_id, charger__charger_id="RFIDOK"
|
|
1671
|
+
)
|
|
687
1672
|
self.assertEqual(tx.account_id, user.energy_account.id)
|
|
688
1673
|
|
|
689
1674
|
async def test_status_fields_updated(self):
|
|
@@ -709,7 +1694,10 @@ class SimulatorAdminTests(TestCase):
|
|
|
709
1694
|
await communicator.receive_json_from()
|
|
710
1695
|
|
|
711
1696
|
await database_sync_to_async(charger.refresh_from_db)()
|
|
712
|
-
self.assertEqual(
|
|
1697
|
+
self.assertEqual(
|
|
1698
|
+
charger.last_meter_values.get("meterValue")[0]["sampledValue"][0]["value"],
|
|
1699
|
+
"42",
|
|
1700
|
+
)
|
|
713
1701
|
|
|
714
1702
|
await communicator.disconnect()
|
|
715
1703
|
|
|
@@ -724,6 +1712,18 @@ class ChargerLocationTests(TestCase):
|
|
|
724
1712
|
self.assertAlmostEqual(float(charger.longitude), -20.654321)
|
|
725
1713
|
self.assertEqual(charger.name, "Loc1")
|
|
726
1714
|
|
|
1715
|
+
def test_location_created_when_missing(self):
|
|
1716
|
+
charger = Charger.objects.create(charger_id="AUTOLOC")
|
|
1717
|
+
self.assertIsNotNone(charger.location)
|
|
1718
|
+
self.assertEqual(charger.location.name, "AUTOLOC")
|
|
1719
|
+
|
|
1720
|
+
def test_location_reused_for_matching_serial(self):
|
|
1721
|
+
first = Charger.objects.create(charger_id="SHARE", connector_id=1)
|
|
1722
|
+
first.location.name = "Custom"
|
|
1723
|
+
first.location.save()
|
|
1724
|
+
second = Charger.objects.create(charger_id="SHARE", connector_id=2)
|
|
1725
|
+
self.assertEqual(second.location, first.location)
|
|
1726
|
+
|
|
727
1727
|
|
|
728
1728
|
class MeterReadingTests(TransactionTestCase):
|
|
729
1729
|
async def test_meter_values_saved_as_readings(self):
|
|
@@ -750,10 +1750,14 @@ class MeterReadingTests(TransactionTestCase):
|
|
|
750
1750
|
await communicator.send_json_to([2, "1", "MeterValues", payload])
|
|
751
1751
|
await communicator.receive_json_from()
|
|
752
1752
|
|
|
753
|
-
reading = await database_sync_to_async(MeterReading.objects.get)(
|
|
1753
|
+
reading = await database_sync_to_async(MeterReading.objects.get)(
|
|
1754
|
+
charger__charger_id="MR1"
|
|
1755
|
+
)
|
|
754
1756
|
self.assertEqual(reading.transaction_id, 100)
|
|
755
1757
|
self.assertEqual(str(reading.value), "2.749")
|
|
756
|
-
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1758
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1759
|
+
pk=100, charger__charger_id="MR1"
|
|
1760
|
+
)
|
|
757
1761
|
self.assertEqual(tx.meter_start, 2749)
|
|
758
1762
|
|
|
759
1763
|
await communicator.disconnect()
|
|
@@ -819,7 +1823,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
819
1823
|
)
|
|
820
1824
|
break
|
|
821
1825
|
|
|
822
|
-
server = await websockets.serve(
|
|
1826
|
+
server = await websockets.serve(
|
|
1827
|
+
handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
|
|
1828
|
+
)
|
|
823
1829
|
port = server.sockets[0].getsockname()[1]
|
|
824
1830
|
|
|
825
1831
|
try:
|
|
@@ -833,6 +1839,8 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
833
1839
|
kw_min=0.1,
|
|
834
1840
|
kw_max=0.2,
|
|
835
1841
|
pre_charge_delay=0.0,
|
|
1842
|
+
serial_number="SN123",
|
|
1843
|
+
connector_id=7,
|
|
836
1844
|
)
|
|
837
1845
|
sim = ChargePointSimulator(cfg)
|
|
838
1846
|
await sim._run_session()
|
|
@@ -843,8 +1851,11 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
843
1851
|
actions = [msg[2] for msg in received]
|
|
844
1852
|
self.assertIn("BootNotification", actions)
|
|
845
1853
|
self.assertIn("StartTransaction", actions)
|
|
1854
|
+
boot_msg = next(msg for msg in received if msg[2] == "BootNotification")
|
|
1855
|
+
self.assertEqual(boot_msg[3].get("serialNumber"), "SN123")
|
|
846
1856
|
start_msg = next(msg for msg in received if msg[2] == "StartTransaction")
|
|
847
1857
|
self.assertEqual(start_msg[3].get("vin"), "WP0ZZZ12345678901")
|
|
1858
|
+
self.assertEqual(start_msg[3].get("connectorId"), 7)
|
|
848
1859
|
|
|
849
1860
|
async def test_start_returns_status_and_log(self):
|
|
850
1861
|
async def handler(ws):
|
|
@@ -867,9 +1878,7 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
867
1878
|
)
|
|
868
1879
|
elif action == "Authorize":
|
|
869
1880
|
await ws.send(
|
|
870
|
-
json.dumps(
|
|
871
|
-
[3, data[1], {"idTagInfo": {"status": "Accepted"}}]
|
|
872
|
-
)
|
|
1881
|
+
json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
|
|
873
1882
|
)
|
|
874
1883
|
elif action == "StartTransaction":
|
|
875
1884
|
await ws.send(
|
|
@@ -886,15 +1895,15 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
886
1895
|
)
|
|
887
1896
|
elif action == "StopTransaction":
|
|
888
1897
|
await ws.send(
|
|
889
|
-
json.dumps(
|
|
890
|
-
[3, data[1], {"idTagInfo": {"status": "Accepted"}}]
|
|
891
|
-
)
|
|
1898
|
+
json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
|
|
892
1899
|
)
|
|
893
1900
|
break
|
|
894
1901
|
else:
|
|
895
1902
|
await ws.send(json.dumps([3, data[1], {}]))
|
|
896
1903
|
|
|
897
|
-
server = await websockets.serve(
|
|
1904
|
+
server = await websockets.serve(
|
|
1905
|
+
handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
|
|
1906
|
+
)
|
|
898
1907
|
port = server.sockets[0].getsockname()[1]
|
|
899
1908
|
|
|
900
1909
|
cfg = SimulatorConfig(
|
|
@@ -927,9 +1936,7 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
927
1936
|
data = json.loads(msg)
|
|
928
1937
|
action = data[2]
|
|
929
1938
|
if action == "BootNotification":
|
|
930
|
-
await ws.send(
|
|
931
|
-
json.dumps([3, data[1], {"status": "Accepted"}])
|
|
932
|
-
)
|
|
1939
|
+
await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
|
|
933
1940
|
elif action == "Authorize":
|
|
934
1941
|
await ws.send(
|
|
935
1942
|
json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
|
|
@@ -937,7 +1944,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
937
1944
|
await ws.close()
|
|
938
1945
|
break
|
|
939
1946
|
|
|
940
|
-
server = await websockets.serve(
|
|
1947
|
+
server = await websockets.serve(
|
|
1948
|
+
handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
|
|
1949
|
+
)
|
|
941
1950
|
port = server.sockets[0].getsockname()[1]
|
|
942
1951
|
|
|
943
1952
|
cfg = SimulatorConfig(
|
|
@@ -973,7 +1982,12 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
973
1982
|
action = data[2]
|
|
974
1983
|
if action == "BootNotification":
|
|
975
1984
|
await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
|
|
976
|
-
elif action in {
|
|
1985
|
+
elif action in {
|
|
1986
|
+
"Authorize",
|
|
1987
|
+
"StatusNotification",
|
|
1988
|
+
"Heartbeat",
|
|
1989
|
+
"MeterValues",
|
|
1990
|
+
}:
|
|
977
1991
|
await ws.send(json.dumps([3, data[1], {}]))
|
|
978
1992
|
elif action == "StartTransaction":
|
|
979
1993
|
await ws.send(
|
|
@@ -981,15 +1995,22 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
981
1995
|
[
|
|
982
1996
|
3,
|
|
983
1997
|
data[1],
|
|
984
|
-
{
|
|
1998
|
+
{
|
|
1999
|
+
"transactionId": 1,
|
|
2000
|
+
"idTagInfo": {"status": "Accepted"},
|
|
2001
|
+
},
|
|
985
2002
|
]
|
|
986
2003
|
)
|
|
987
2004
|
)
|
|
988
2005
|
elif action == "StopTransaction":
|
|
989
|
-
await ws.send(
|
|
2006
|
+
await ws.send(
|
|
2007
|
+
json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
|
|
2008
|
+
)
|
|
990
2009
|
break
|
|
991
2010
|
|
|
992
|
-
server = await websockets.serve(
|
|
2011
|
+
server = await websockets.serve(
|
|
2012
|
+
handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
|
|
2013
|
+
)
|
|
993
2014
|
port = server.sockets[0].getsockname()[1]
|
|
994
2015
|
|
|
995
2016
|
try:
|
|
@@ -1020,13 +2041,16 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
1020
2041
|
async for _ in ws:
|
|
1021
2042
|
pass
|
|
1022
2043
|
|
|
1023
|
-
server = await websockets.serve(
|
|
2044
|
+
server = await websockets.serve(
|
|
2045
|
+
handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"]
|
|
2046
|
+
)
|
|
1024
2047
|
port = server.sockets[0].getsockname()[1]
|
|
1025
2048
|
|
|
1026
2049
|
cfg = SimulatorConfig(host="127.0.0.1", ws_port=port, cp_path="SIMTO/")
|
|
1027
2050
|
sim = ChargePointSimulator(cfg)
|
|
1028
2051
|
store.simulators[99] = sim
|
|
1029
2052
|
try:
|
|
2053
|
+
|
|
1030
2054
|
async def fake_wait_for(coro, timeout):
|
|
1031
2055
|
coro.close()
|
|
1032
2056
|
raise asyncio.TimeoutError
|
|
@@ -1042,6 +2066,80 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
1042
2066
|
server.close()
|
|
1043
2067
|
await server.wait_closed()
|
|
1044
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
|
+
|
|
1045
2143
|
|
|
1046
2144
|
class PurgeMeterReadingsTaskTests(TestCase):
|
|
1047
2145
|
def test_purge_old_meter_readings(self):
|
|
@@ -1066,7 +2164,9 @@ class PurgeMeterReadingsTaskTests(TestCase):
|
|
|
1066
2164
|
|
|
1067
2165
|
self.assertEqual(MeterReading.objects.count(), 1)
|
|
1068
2166
|
self.assertTrue(
|
|
1069
|
-
MeterReading.objects.filter(
|
|
2167
|
+
MeterReading.objects.filter(
|
|
2168
|
+
timestamp__gte=recent - timedelta(minutes=1)
|
|
2169
|
+
).exists()
|
|
1070
2170
|
)
|
|
1071
2171
|
self.assertTrue(Transaction.objects.filter(pk=tx.pk).exists())
|
|
1072
2172
|
|
|
@@ -1090,19 +2190,21 @@ class PurgeMeterReadingsTaskTests(TestCase):
|
|
|
1090
2190
|
class TransactionKwTests(TestCase):
|
|
1091
2191
|
def test_kw_sums_meter_readings(self):
|
|
1092
2192
|
charger = Charger.objects.create(charger_id="SUM1")
|
|
1093
|
-
tx = Transaction.objects.create(
|
|
2193
|
+
tx = Transaction.objects.create(
|
|
2194
|
+
charger=charger, start_time=timezone.now(), meter_start=0
|
|
2195
|
+
)
|
|
1094
2196
|
MeterReading.objects.create(
|
|
1095
2197
|
charger=charger,
|
|
1096
2198
|
transaction=tx,
|
|
1097
2199
|
timestamp=timezone.now(),
|
|
1098
|
-
value=Decimal("
|
|
1099
|
-
unit="
|
|
2200
|
+
value=Decimal("1000"),
|
|
2201
|
+
unit="W",
|
|
1100
2202
|
)
|
|
1101
2203
|
MeterReading.objects.create(
|
|
1102
2204
|
charger=charger,
|
|
1103
2205
|
transaction=tx,
|
|
1104
2206
|
timestamp=timezone.now(),
|
|
1105
|
-
value=Decimal("
|
|
2207
|
+
value=Decimal("1500"),
|
|
1106
2208
|
unit="W",
|
|
1107
2209
|
)
|
|
1108
2210
|
self.assertAlmostEqual(tx.kw, 1.5)
|
|
@@ -1113,15 +2215,95 @@ class TransactionKwTests(TestCase):
|
|
|
1113
2215
|
self.assertEqual(tx.kw, 0.0)
|
|
1114
2216
|
|
|
1115
2217
|
|
|
2218
|
+
class DispatchActionViewTests(TestCase):
|
|
2219
|
+
def setUp(self):
|
|
2220
|
+
self.client = Client()
|
|
2221
|
+
User = get_user_model()
|
|
2222
|
+
self.user = User.objects.create_user(username="dispatch", password="pw")
|
|
2223
|
+
self.client.force_login(self.user)
|
|
2224
|
+
try:
|
|
2225
|
+
self.previous_loop = asyncio.get_event_loop()
|
|
2226
|
+
except RuntimeError:
|
|
2227
|
+
self.previous_loop = None
|
|
2228
|
+
self.loop = asyncio.new_event_loop()
|
|
2229
|
+
asyncio.set_event_loop(self.loop)
|
|
2230
|
+
self.addCleanup(self._close_loop)
|
|
2231
|
+
self.charger = Charger.objects.create(
|
|
2232
|
+
charger_id="DISPATCH", connector_id=1
|
|
2233
|
+
)
|
|
2234
|
+
self.ws = DummyWebSocket()
|
|
2235
|
+
store.set_connection(
|
|
2236
|
+
self.charger.charger_id, self.charger.connector_id, self.ws
|
|
2237
|
+
)
|
|
2238
|
+
self.addCleanup(
|
|
2239
|
+
store.pop_connection,
|
|
2240
|
+
self.charger.charger_id,
|
|
2241
|
+
self.charger.connector_id,
|
|
2242
|
+
)
|
|
2243
|
+
self.log_key = store.identity_key(
|
|
2244
|
+
self.charger.charger_id, self.charger.connector_id
|
|
2245
|
+
)
|
|
2246
|
+
store.clear_log(self.log_key, log_type="charger")
|
|
2247
|
+
self.addCleanup(store.clear_log, self.log_key, "charger")
|
|
2248
|
+
self.url = reverse(
|
|
2249
|
+
"charger-action-connector",
|
|
2250
|
+
args=[self.charger.charger_id, self.charger.connector_slug],
|
|
2251
|
+
)
|
|
2252
|
+
|
|
2253
|
+
def _close_loop(self):
|
|
2254
|
+
try:
|
|
2255
|
+
if not self.loop.is_closed():
|
|
2256
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
2257
|
+
except RuntimeError:
|
|
2258
|
+
pass
|
|
2259
|
+
finally:
|
|
2260
|
+
if not self.loop.is_closed():
|
|
2261
|
+
self.loop.close()
|
|
2262
|
+
asyncio.set_event_loop(self.previous_loop)
|
|
2263
|
+
|
|
2264
|
+
def test_remote_start_requires_id_tag(self):
|
|
2265
|
+
response = self.client.post(
|
|
2266
|
+
self.url,
|
|
2267
|
+
data=json.dumps({"action": "remote_start"}),
|
|
2268
|
+
content_type="application/json",
|
|
2269
|
+
)
|
|
2270
|
+
self.assertEqual(response.status_code, 400)
|
|
2271
|
+
self.assertEqual(response.json().get("detail"), "idTag required")
|
|
2272
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
2273
|
+
self.assertEqual(self.ws.sent, [])
|
|
2274
|
+
|
|
2275
|
+
def test_remote_start_dispatches_frame(self):
|
|
2276
|
+
response = self.client.post(
|
|
2277
|
+
self.url,
|
|
2278
|
+
data=json.dumps({"action": "remote_start", "idTag": "RF1234"}),
|
|
2279
|
+
content_type="application/json",
|
|
2280
|
+
)
|
|
2281
|
+
self.assertEqual(response.status_code, 200)
|
|
2282
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
2283
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
2284
|
+
frame = json.loads(self.ws.sent[0])
|
|
2285
|
+
self.assertEqual(frame[0], 2)
|
|
2286
|
+
self.assertEqual(frame[2], "RemoteStartTransaction")
|
|
2287
|
+
self.assertEqual(frame[3]["idTag"], "RF1234")
|
|
2288
|
+
self.assertEqual(frame[3]["connectorId"], 1)
|
|
2289
|
+
log_entries = store.logs["charger"].get(self.log_key, [])
|
|
2290
|
+
self.assertTrue(
|
|
2291
|
+
any("RemoteStartTransaction" in entry for entry in log_entries)
|
|
2292
|
+
)
|
|
2293
|
+
|
|
2294
|
+
|
|
1116
2295
|
class ChargerStatusViewTests(TestCase):
|
|
1117
2296
|
def setUp(self):
|
|
1118
2297
|
self.client = Client()
|
|
1119
2298
|
User = get_user_model()
|
|
1120
2299
|
self.user = User.objects.create_user(username="status", password="pwd")
|
|
1121
2300
|
self.client.force_login(self.user)
|
|
2301
|
+
|
|
1122
2302
|
def test_chart_data_populated_from_existing_readings(self):
|
|
1123
|
-
charger = Charger.objects.create(charger_id="VIEW1")
|
|
1124
|
-
tx = Transaction.objects.create(
|
|
2303
|
+
charger = Charger.objects.create(charger_id="VIEW1", connector_id=1)
|
|
2304
|
+
tx = Transaction.objects.create(
|
|
2305
|
+
charger=charger, start_time=timezone.now(), meter_start=0
|
|
2306
|
+
)
|
|
1125
2307
|
t0 = timezone.now()
|
|
1126
2308
|
MeterReading.objects.create(
|
|
1127
2309
|
charger=charger,
|
|
@@ -1134,17 +2316,109 @@ class ChargerStatusViewTests(TestCase):
|
|
|
1134
2316
|
charger=charger,
|
|
1135
2317
|
transaction=tx,
|
|
1136
2318
|
timestamp=t0 + timedelta(seconds=10),
|
|
1137
|
-
value=Decimal("
|
|
2319
|
+
value=Decimal("1500"),
|
|
1138
2320
|
unit="W",
|
|
1139
2321
|
)
|
|
1140
|
-
store.
|
|
1141
|
-
|
|
2322
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2323
|
+
store.transactions[key] = tx
|
|
2324
|
+
resp = self.client.get(
|
|
2325
|
+
reverse(
|
|
2326
|
+
"charger-status-connector",
|
|
2327
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
2328
|
+
)
|
|
2329
|
+
)
|
|
1142
2330
|
self.assertEqual(resp.status_code, 200)
|
|
1143
|
-
chart =
|
|
2331
|
+
chart = resp.context["chart_data"]
|
|
2332
|
+
self.assertEqual(len(chart["labels"]), 2)
|
|
2333
|
+
self.assertEqual(len(chart["datasets"]), 1)
|
|
2334
|
+
values = chart["datasets"][0]["values"]
|
|
2335
|
+
self.assertEqual(chart["datasets"][0]["connector_id"], 1)
|
|
2336
|
+
self.assertAlmostEqual(values[0], 1.0)
|
|
2337
|
+
self.assertAlmostEqual(values[1], 1.5)
|
|
2338
|
+
store.transactions.pop(key, None)
|
|
2339
|
+
|
|
2340
|
+
def test_chart_data_uses_meter_start_for_register_values(self):
|
|
2341
|
+
charger = Charger.objects.create(charger_id="VIEWREG", connector_id=1)
|
|
2342
|
+
tx = Transaction.objects.create(
|
|
2343
|
+
charger=charger, start_time=timezone.now(), meter_start=746060
|
|
2344
|
+
)
|
|
2345
|
+
t0 = timezone.now()
|
|
2346
|
+
MeterReading.objects.create(
|
|
2347
|
+
charger=charger,
|
|
2348
|
+
transaction=tx,
|
|
2349
|
+
timestamp=t0,
|
|
2350
|
+
measurand="Energy.Active.Import.Register",
|
|
2351
|
+
value=Decimal("746.060"),
|
|
2352
|
+
unit="kWh",
|
|
2353
|
+
)
|
|
2354
|
+
MeterReading.objects.create(
|
|
2355
|
+
charger=charger,
|
|
2356
|
+
transaction=tx,
|
|
2357
|
+
timestamp=t0 + timedelta(seconds=10),
|
|
2358
|
+
measurand="Energy.Active.Import.Register",
|
|
2359
|
+
value=Decimal("746.080"),
|
|
2360
|
+
unit="kWh",
|
|
2361
|
+
)
|
|
2362
|
+
key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
2363
|
+
store.transactions[key] = tx
|
|
2364
|
+
resp = self.client.get(
|
|
2365
|
+
reverse(
|
|
2366
|
+
"charger-status-connector",
|
|
2367
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
2368
|
+
)
|
|
2369
|
+
)
|
|
2370
|
+
chart = resp.context["chart_data"]
|
|
1144
2371
|
self.assertEqual(len(chart["labels"]), 2)
|
|
1145
|
-
self.
|
|
1146
|
-
|
|
1147
|
-
|
|
2372
|
+
self.assertEqual(len(chart["datasets"]), 1)
|
|
2373
|
+
values = chart["datasets"][0]["values"]
|
|
2374
|
+
self.assertEqual(chart["datasets"][0]["connector_id"], 1)
|
|
2375
|
+
self.assertAlmostEqual(values[0], 0.0)
|
|
2376
|
+
self.assertAlmostEqual(values[1], 0.02)
|
|
2377
|
+
self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
|
|
2378
|
+
store.transactions.pop(key, None)
|
|
2379
|
+
|
|
2380
|
+
def test_diagnostics_status_displayed(self):
|
|
2381
|
+
reported_at = timezone.now().replace(microsecond=0)
|
|
2382
|
+
charger = Charger.objects.create(
|
|
2383
|
+
charger_id="DIAGPAGE",
|
|
2384
|
+
diagnostics_status="Uploaded",
|
|
2385
|
+
diagnostics_location="https://example.com/report.tar",
|
|
2386
|
+
diagnostics_timestamp=reported_at,
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
2390
|
+
self.assertEqual(resp.status_code, 200)
|
|
2391
|
+
self.assertContains(resp, "Diagnostics")
|
|
2392
|
+
self.assertContains(resp, "id=\"diagnostics-status\"")
|
|
2393
|
+
self.assertContains(resp, "Uploaded")
|
|
2394
|
+
self.assertContains(resp, "id=\"diagnostics-timestamp\"")
|
|
2395
|
+
self.assertContains(resp, "id=\"diagnostics-location\"")
|
|
2396
|
+
self.assertContains(resp, "https://example.com/report.tar")
|
|
2397
|
+
|
|
2398
|
+
def test_connector_status_prefers_connector_diagnostics(self):
|
|
2399
|
+
aggregate = Charger.objects.create(
|
|
2400
|
+
charger_id="DIAGCONN",
|
|
2401
|
+
diagnostics_status="Uploaded",
|
|
2402
|
+
)
|
|
2403
|
+
connector = Charger.objects.create(
|
|
2404
|
+
charger_id="DIAGCONN",
|
|
2405
|
+
connector_id=1,
|
|
2406
|
+
diagnostics_status="Uploading",
|
|
2407
|
+
)
|
|
2408
|
+
|
|
2409
|
+
aggregate_resp = self.client.get(
|
|
2410
|
+
reverse("charger-status", args=[aggregate.charger_id])
|
|
2411
|
+
)
|
|
2412
|
+
self.assertContains(aggregate_resp, "Uploaded")
|
|
2413
|
+
self.assertNotContains(aggregate_resp, "Uploading")
|
|
2414
|
+
|
|
2415
|
+
connector_resp = self.client.get(
|
|
2416
|
+
reverse(
|
|
2417
|
+
"charger-status-connector",
|
|
2418
|
+
args=[connector.charger_id, connector.connector_slug],
|
|
2419
|
+
)
|
|
2420
|
+
)
|
|
2421
|
+
self.assertContains(connector_resp, "Uploading")
|
|
1148
2422
|
|
|
1149
2423
|
def test_sessions_are_linked(self):
|
|
1150
2424
|
charger = Charger.objects.create(charger_id="LINK1")
|
|
@@ -1157,9 +2431,27 @@ class ChargerStatusViewTests(TestCase):
|
|
|
1157
2431
|
resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
1158
2432
|
self.assertContains(resp, reverse("charger-page", args=[charger.charger_id]))
|
|
1159
2433
|
|
|
2434
|
+
def test_configuration_link_hidden_for_non_staff(self):
|
|
2435
|
+
charger = Charger.objects.create(charger_id="CFG-HIDE")
|
|
2436
|
+
response = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
2437
|
+
admin_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
|
|
2438
|
+
self.assertNotContains(response, admin_url)
|
|
2439
|
+
self.assertNotContains(response, _("Configuration"))
|
|
2440
|
+
|
|
2441
|
+
def test_configuration_link_visible_for_staff(self):
|
|
2442
|
+
charger = Charger.objects.create(charger_id="CFG-SHOW")
|
|
2443
|
+
self.user.is_staff = True
|
|
2444
|
+
self.user.save(update_fields=["is_staff"])
|
|
2445
|
+
response = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
2446
|
+
admin_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
|
|
2447
|
+
self.assertContains(response, admin_url)
|
|
2448
|
+
self.assertContains(response, _("Configuration"))
|
|
2449
|
+
|
|
1160
2450
|
def test_past_session_chart(self):
|
|
1161
|
-
charger = Charger.objects.create(charger_id="PAST1")
|
|
1162
|
-
tx = Transaction.objects.create(
|
|
2451
|
+
charger = Charger.objects.create(charger_id="PAST1", connector_id=1)
|
|
2452
|
+
tx = Transaction.objects.create(
|
|
2453
|
+
charger=charger, start_time=timezone.now(), meter_start=0
|
|
2454
|
+
)
|
|
1163
2455
|
t0 = timezone.now()
|
|
1164
2456
|
MeterReading.objects.create(
|
|
1165
2457
|
charger=charger,
|
|
@@ -1172,17 +2464,147 @@ class ChargerStatusViewTests(TestCase):
|
|
|
1172
2464
|
charger=charger,
|
|
1173
2465
|
transaction=tx,
|
|
1174
2466
|
timestamp=t0 + timedelta(seconds=10),
|
|
1175
|
-
value=Decimal("
|
|
2467
|
+
value=Decimal("1500"),
|
|
1176
2468
|
unit="W",
|
|
1177
2469
|
)
|
|
1178
2470
|
resp = self.client.get(
|
|
1179
|
-
reverse(
|
|
2471
|
+
reverse(
|
|
2472
|
+
"charger-status-connector",
|
|
2473
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
2474
|
+
)
|
|
2475
|
+
+ f"?session={tx.id}"
|
|
1180
2476
|
)
|
|
1181
2477
|
self.assertContains(resp, "Back to live")
|
|
1182
|
-
chart =
|
|
2478
|
+
chart = resp.context["chart_data"]
|
|
1183
2479
|
self.assertEqual(len(chart["labels"]), 2)
|
|
2480
|
+
self.assertEqual(len(chart["datasets"]), 1)
|
|
2481
|
+
self.assertEqual(chart["datasets"][0]["connector_id"], 1)
|
|
1184
2482
|
self.assertTrue(resp.context["past_session"])
|
|
1185
2483
|
|
|
2484
|
+
def test_aggregate_chart_includes_multiple_connectors(self):
|
|
2485
|
+
aggregate = Charger.objects.create(charger_id="VIEWAGG")
|
|
2486
|
+
connector_one = Charger.objects.create(charger_id="VIEWAGG", connector_id=1)
|
|
2487
|
+
connector_two = Charger.objects.create(charger_id="VIEWAGG", connector_id=2)
|
|
2488
|
+
base_time = timezone.now()
|
|
2489
|
+
tx_one = Transaction.objects.create(
|
|
2490
|
+
charger=connector_one, start_time=base_time, meter_start=0
|
|
2491
|
+
)
|
|
2492
|
+
tx_two = Transaction.objects.create(
|
|
2493
|
+
charger=connector_two, start_time=base_time, meter_start=0
|
|
2494
|
+
)
|
|
2495
|
+
MeterReading.objects.create(
|
|
2496
|
+
charger=connector_one,
|
|
2497
|
+
transaction=tx_one,
|
|
2498
|
+
timestamp=base_time,
|
|
2499
|
+
value=Decimal("1000"),
|
|
2500
|
+
unit="W",
|
|
2501
|
+
)
|
|
2502
|
+
MeterReading.objects.create(
|
|
2503
|
+
charger=connector_one,
|
|
2504
|
+
transaction=tx_one,
|
|
2505
|
+
timestamp=base_time + timedelta(seconds=15),
|
|
2506
|
+
value=Decimal("1500"),
|
|
2507
|
+
unit="W",
|
|
2508
|
+
)
|
|
2509
|
+
MeterReading.objects.create(
|
|
2510
|
+
charger=connector_two,
|
|
2511
|
+
transaction=tx_two,
|
|
2512
|
+
timestamp=base_time + timedelta(seconds=5),
|
|
2513
|
+
value=Decimal("2000"),
|
|
2514
|
+
unit="W",
|
|
2515
|
+
)
|
|
2516
|
+
MeterReading.objects.create(
|
|
2517
|
+
charger=connector_two,
|
|
2518
|
+
transaction=tx_two,
|
|
2519
|
+
timestamp=base_time + timedelta(seconds=20),
|
|
2520
|
+
value=Decimal("2600"),
|
|
2521
|
+
unit="W",
|
|
2522
|
+
)
|
|
2523
|
+
key_one = store.identity_key(
|
|
2524
|
+
connector_one.charger_id, connector_one.connector_id
|
|
2525
|
+
)
|
|
2526
|
+
key_two = store.identity_key(
|
|
2527
|
+
connector_two.charger_id, connector_two.connector_id
|
|
2528
|
+
)
|
|
2529
|
+
store.transactions[key_one] = tx_one
|
|
2530
|
+
store.transactions[key_two] = tx_two
|
|
2531
|
+
try:
|
|
2532
|
+
resp = self.client.get(
|
|
2533
|
+
reverse("charger-status", args=[aggregate.charger_id])
|
|
2534
|
+
)
|
|
2535
|
+
chart = resp.context["chart_data"]
|
|
2536
|
+
self.assertTrue(resp.context["show_chart"])
|
|
2537
|
+
self.assertEqual(len(chart["datasets"]), 2)
|
|
2538
|
+
data_map = {
|
|
2539
|
+
dataset["label"]: dataset["values"] for dataset in chart["datasets"]
|
|
2540
|
+
}
|
|
2541
|
+
connector_id_map = {
|
|
2542
|
+
dataset["label"]: dataset.get("connector_id")
|
|
2543
|
+
for dataset in chart["datasets"]
|
|
2544
|
+
}
|
|
2545
|
+
label_one = str(connector_one.connector_label)
|
|
2546
|
+
label_two = str(connector_two.connector_label)
|
|
2547
|
+
self.assertEqual(set(data_map), {label_one, label_two})
|
|
2548
|
+
self.assertEqual(len(data_map[label_one]), len(chart["labels"]))
|
|
2549
|
+
self.assertEqual(len(data_map[label_two]), len(chart["labels"]))
|
|
2550
|
+
self.assertTrue(any(value is not None for value in data_map[label_one]))
|
|
2551
|
+
self.assertTrue(any(value is not None for value in data_map[label_two]))
|
|
2552
|
+
self.assertEqual(connector_id_map[label_one], connector_one.connector_id)
|
|
2553
|
+
self.assertEqual(connector_id_map[label_two], connector_two.connector_id)
|
|
2554
|
+
finally:
|
|
2555
|
+
store.transactions.pop(key_one, None)
|
|
2556
|
+
store.transactions.pop(key_two, None)
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
class ChargerApiDiagnosticsTests(TestCase):
|
|
2560
|
+
def setUp(self):
|
|
2561
|
+
self.client = Client()
|
|
2562
|
+
User = get_user_model()
|
|
2563
|
+
self.user = User.objects.create_user(username="diagapi", password="pwd")
|
|
2564
|
+
self.client.force_login(self.user)
|
|
2565
|
+
|
|
2566
|
+
def test_detail_includes_diagnostics_fields(self):
|
|
2567
|
+
reported_at = timezone.now().replace(microsecond=0)
|
|
2568
|
+
charger = Charger.objects.create(
|
|
2569
|
+
charger_id="APIDIAG",
|
|
2570
|
+
diagnostics_status="Uploaded",
|
|
2571
|
+
diagnostics_timestamp=reported_at,
|
|
2572
|
+
diagnostics_location="https://example.com/diag.tar",
|
|
2573
|
+
)
|
|
2574
|
+
|
|
2575
|
+
resp = self.client.get(reverse("charger-detail", args=[charger.charger_id]))
|
|
2576
|
+
self.assertEqual(resp.status_code, 200)
|
|
2577
|
+
payload = resp.json()
|
|
2578
|
+
self.assertEqual(payload["diagnosticsStatus"], "Uploaded")
|
|
2579
|
+
self.assertEqual(
|
|
2580
|
+
payload["diagnosticsTimestamp"], reported_at.isoformat()
|
|
2581
|
+
)
|
|
2582
|
+
self.assertEqual(
|
|
2583
|
+
payload["diagnosticsLocation"], "https://example.com/diag.tar"
|
|
2584
|
+
)
|
|
2585
|
+
|
|
2586
|
+
def test_list_includes_diagnostics_fields(self):
|
|
2587
|
+
reported_at = timezone.now().replace(microsecond=0)
|
|
2588
|
+
Charger.objects.create(
|
|
2589
|
+
charger_id="APILIST",
|
|
2590
|
+
diagnostics_status="Idle",
|
|
2591
|
+
diagnostics_timestamp=reported_at,
|
|
2592
|
+
diagnostics_location="s3://bucket/diag.zip",
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
resp = self.client.get(reverse("charger-list"))
|
|
2596
|
+
self.assertEqual(resp.status_code, 200)
|
|
2597
|
+
payload = resp.json()
|
|
2598
|
+
self.assertIn("chargers", payload)
|
|
2599
|
+
target = next(
|
|
2600
|
+
item
|
|
2601
|
+
for item in payload["chargers"]
|
|
2602
|
+
if item["charger_id"] == "APILIST" and item["connector_id"] is None
|
|
2603
|
+
)
|
|
2604
|
+
self.assertEqual(target["diagnosticsStatus"], "Idle")
|
|
2605
|
+
self.assertEqual(target["diagnosticsLocation"], "s3://bucket/diag.zip")
|
|
2606
|
+
self.assertEqual(target["diagnosticsTimestamp"], reported_at.isoformat())
|
|
2607
|
+
|
|
1186
2608
|
|
|
1187
2609
|
class ChargerSessionPaginationTests(TestCase):
|
|
1188
2610
|
def setUp(self):
|
|
@@ -1199,7 +2621,9 @@ class ChargerSessionPaginationTests(TestCase):
|
|
|
1199
2621
|
)
|
|
1200
2622
|
|
|
1201
2623
|
def test_only_ten_transactions_shown(self):
|
|
1202
|
-
resp = self.client.get(
|
|
2624
|
+
resp = self.client.get(
|
|
2625
|
+
reverse("charger-status", args=[self.charger.charger_id])
|
|
2626
|
+
)
|
|
1203
2627
|
self.assertEqual(resp.status_code, 200)
|
|
1204
2628
|
self.assertEqual(len(resp.context["transactions"]), 10)
|
|
1205
2629
|
self.assertTrue(resp.context["page_obj"].has_next())
|
|
@@ -1214,20 +2638,35 @@ class ChargerSessionPaginationTests(TestCase):
|
|
|
1214
2638
|
self.assertEqual(len(resp.context["transactions"]), 15)
|
|
1215
2639
|
|
|
1216
2640
|
|
|
1217
|
-
class
|
|
2641
|
+
class LiveUpdateViewTests(TestCase):
|
|
1218
2642
|
def setUp(self):
|
|
1219
2643
|
User = get_user_model()
|
|
1220
|
-
self.user = User.objects.create_user(
|
|
1221
|
-
username="eff", password="secret", email="eff@example.com"
|
|
1222
|
-
)
|
|
2644
|
+
self.user = User.objects.create_user(username="lu", password="pw")
|
|
1223
2645
|
self.client.force_login(self.user)
|
|
1224
2646
|
|
|
1225
|
-
def
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
self.assertContains(resp, "
|
|
2647
|
+
def test_dashboard_includes_interval(self):
|
|
2648
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
2649
|
+
self.assertEqual(resp.context["request"].live_update_interval, 5)
|
|
2650
|
+
self.assertContains(resp, "setInterval(() => location.reload()")
|
|
2651
|
+
|
|
2652
|
+
def test_cp_simulator_includes_interval(self):
|
|
2653
|
+
resp = self.client.get(reverse("cp-simulator"))
|
|
2654
|
+
self.assertEqual(resp.context["request"].live_update_interval, 5)
|
|
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)
|
|
1229
2667
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
self.
|
|
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)
|