arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
ocpp/tests.py
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
|
|
2
|
+
from channels.testing import WebsocketCommunicator
|
|
3
|
+
from channels.db import database_sync_to_async
|
|
4
|
+
from django.test import Client, TransactionTestCase, TestCase
|
|
5
|
+
from unittest import skip
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
from django.contrib.auth import get_user_model
|
|
8
|
+
from django.urls import reverse
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
from django.contrib.sites.models import Site
|
|
11
|
+
from pages.models import Application, Module
|
|
12
|
+
from nodes.models import Node, NodeRole
|
|
13
|
+
|
|
14
|
+
from config.asgi import application
|
|
15
|
+
|
|
16
|
+
from .models import Transaction, Charger, Simulator, MeterReading, Location
|
|
17
|
+
from core.models import EnergyAccount, EnergyCredit
|
|
18
|
+
from core.models import RFID
|
|
19
|
+
from . import store
|
|
20
|
+
from django.db.models.deletion import ProtectedError
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
import json
|
|
23
|
+
import websockets
|
|
24
|
+
import asyncio
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from .simulator import SimulatorConfig, ChargePointSimulator
|
|
27
|
+
import re
|
|
28
|
+
from datetime import timedelta
|
|
29
|
+
from .tasks import purge_meter_readings
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChargerFixtureTests(TestCase):
|
|
34
|
+
fixtures = ["initial_data.json"]
|
|
35
|
+
|
|
36
|
+
def test_cp2_requires_rfid(self):
|
|
37
|
+
cp2 = Charger.objects.get(charger_id="CP2")
|
|
38
|
+
self.assertTrue(cp2.require_rfid)
|
|
39
|
+
|
|
40
|
+
def test_cp1_does_not_require_rfid(self):
|
|
41
|
+
cp1 = Charger.objects.get(charger_id="CP1")
|
|
42
|
+
self.assertFalse(cp1.require_rfid)
|
|
43
|
+
|
|
44
|
+
def test_charger_connector_ids(self):
|
|
45
|
+
cp1 = Charger.objects.get(charger_id="CP1")
|
|
46
|
+
cp2 = Charger.objects.get(charger_id="CP2")
|
|
47
|
+
self.assertEqual(cp1.connector_id, "1")
|
|
48
|
+
self.assertEqual(cp2.connector_id, "2")
|
|
49
|
+
self.assertEqual(cp1.name, "Simulator #1")
|
|
50
|
+
self.assertEqual(cp2.name, "Simulator #2")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SinkConsumerTests(TransactionTestCase):
|
|
54
|
+
async def test_sink_replies(self):
|
|
55
|
+
communicator = WebsocketCommunicator(application, "/ws/sink/")
|
|
56
|
+
connected, _ = await communicator.connect()
|
|
57
|
+
self.assertTrue(connected)
|
|
58
|
+
|
|
59
|
+
await communicator.send_json_to([2, "1", "Foo", {}])
|
|
60
|
+
response = await communicator.receive_json_from()
|
|
61
|
+
self.assertEqual(response, [3, "1", {}])
|
|
62
|
+
|
|
63
|
+
await communicator.disconnect()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CSMSConsumerTests(TransactionTestCase):
|
|
67
|
+
async def test_transaction_saved(self):
|
|
68
|
+
communicator = WebsocketCommunicator(application, "/TEST/")
|
|
69
|
+
connected, _ = await communicator.connect()
|
|
70
|
+
self.assertTrue(connected)
|
|
71
|
+
|
|
72
|
+
await communicator.send_json_to([
|
|
73
|
+
2,
|
|
74
|
+
"1",
|
|
75
|
+
"StartTransaction",
|
|
76
|
+
{"meterStart": 10},
|
|
77
|
+
])
|
|
78
|
+
response = await communicator.receive_json_from()
|
|
79
|
+
tx_id = response[2]["transactionId"]
|
|
80
|
+
|
|
81
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
82
|
+
pk=tx_id, charger__charger_id="TEST"
|
|
83
|
+
)
|
|
84
|
+
self.assertEqual(tx.meter_start, 10)
|
|
85
|
+
self.assertIsNone(tx.stop_time)
|
|
86
|
+
|
|
87
|
+
await communicator.send_json_to([
|
|
88
|
+
2,
|
|
89
|
+
"2",
|
|
90
|
+
"StopTransaction",
|
|
91
|
+
{"transactionId": tx_id, "meterStop": 20},
|
|
92
|
+
])
|
|
93
|
+
await communicator.receive_json_from()
|
|
94
|
+
|
|
95
|
+
await database_sync_to_async(tx.refresh_from_db)()
|
|
96
|
+
self.assertEqual(tx.meter_stop, 20)
|
|
97
|
+
self.assertIsNotNone(tx.stop_time)
|
|
98
|
+
|
|
99
|
+
await communicator.disconnect()
|
|
100
|
+
|
|
101
|
+
async def test_rfid_recorded(self):
|
|
102
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="RFIDREC")
|
|
103
|
+
communicator = WebsocketCommunicator(application, "/RFIDREC/")
|
|
104
|
+
connected, _ = await communicator.connect()
|
|
105
|
+
self.assertTrue(connected)
|
|
106
|
+
|
|
107
|
+
await communicator.send_json_to(
|
|
108
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "idTag": "TAG123"}]
|
|
109
|
+
)
|
|
110
|
+
response = await communicator.receive_json_from()
|
|
111
|
+
tx_id = response[2]["transactionId"]
|
|
112
|
+
|
|
113
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
114
|
+
pk=tx_id, charger__charger_id="RFIDREC"
|
|
115
|
+
)
|
|
116
|
+
self.assertEqual(tx.rfid, "TAG123")
|
|
117
|
+
|
|
118
|
+
await communicator.disconnect()
|
|
119
|
+
|
|
120
|
+
async def test_vin_recorded(self):
|
|
121
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
|
|
122
|
+
communicator = WebsocketCommunicator(application, "/VINREC/")
|
|
123
|
+
connected, _ = await communicator.connect()
|
|
124
|
+
self.assertTrue(connected)
|
|
125
|
+
|
|
126
|
+
await communicator.send_json_to(
|
|
127
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "vin": "WP0ZZZ11111111111"}]
|
|
128
|
+
)
|
|
129
|
+
response = await communicator.receive_json_from()
|
|
130
|
+
tx_id = response[2]["transactionId"]
|
|
131
|
+
|
|
132
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
133
|
+
pk=tx_id, charger__charger_id="VINREC"
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual(tx.vin, "WP0ZZZ11111111111")
|
|
136
|
+
|
|
137
|
+
await communicator.disconnect()
|
|
138
|
+
|
|
139
|
+
async def test_connector_id_set_from_meter_values(self):
|
|
140
|
+
communicator = WebsocketCommunicator(application, "/NEWCID/")
|
|
141
|
+
connected, _ = await communicator.connect()
|
|
142
|
+
self.assertTrue(connected)
|
|
143
|
+
|
|
144
|
+
payload = {
|
|
145
|
+
"connectorId": 7,
|
|
146
|
+
"meterValue": [
|
|
147
|
+
{
|
|
148
|
+
"timestamp": timezone.now().isoformat(),
|
|
149
|
+
"sampledValue": [{"value": "1"}],
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
await communicator.send_json_to([2, "1", "MeterValues", payload])
|
|
154
|
+
await communicator.receive_json_from()
|
|
155
|
+
|
|
156
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEWCID")
|
|
157
|
+
self.assertEqual(charger.connector_id, "7")
|
|
158
|
+
|
|
159
|
+
await communicator.disconnect()
|
|
160
|
+
|
|
161
|
+
async def test_transaction_created_from_meter_values(self):
|
|
162
|
+
communicator = WebsocketCommunicator(application, "/NOSTART/")
|
|
163
|
+
connected, _ = await communicator.connect()
|
|
164
|
+
self.assertTrue(connected)
|
|
165
|
+
|
|
166
|
+
await communicator.send_json_to(
|
|
167
|
+
[
|
|
168
|
+
2,
|
|
169
|
+
"1",
|
|
170
|
+
"MeterValues",
|
|
171
|
+
{
|
|
172
|
+
"transactionId": 99,
|
|
173
|
+
"meterValue": [
|
|
174
|
+
{
|
|
175
|
+
"timestamp": "2025-01-01T00:00:00Z",
|
|
176
|
+
"sampledValue": [
|
|
177
|
+
{
|
|
178
|
+
"value": "1000",
|
|
179
|
+
"measurand": "Energy.Active.Import.Register",
|
|
180
|
+
"unit": "W",
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
]
|
|
187
|
+
)
|
|
188
|
+
await communicator.receive_json_from()
|
|
189
|
+
|
|
190
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
191
|
+
pk=99, charger__charger_id="NOSTART"
|
|
192
|
+
)
|
|
193
|
+
self.assertEqual(tx.meter_start, 1000)
|
|
194
|
+
self.assertIsNone(tx.meter_stop)
|
|
195
|
+
|
|
196
|
+
await communicator.send_json_to(
|
|
197
|
+
[
|
|
198
|
+
2,
|
|
199
|
+
"2",
|
|
200
|
+
"StopTransaction",
|
|
201
|
+
{"transactionId": 99, "meterStop": 1500},
|
|
202
|
+
]
|
|
203
|
+
)
|
|
204
|
+
await communicator.receive_json_from()
|
|
205
|
+
await database_sync_to_async(tx.refresh_from_db)()
|
|
206
|
+
self.assertEqual(tx.meter_stop, 1500)
|
|
207
|
+
self.assertIsNotNone(tx.stop_time)
|
|
208
|
+
|
|
209
|
+
await communicator.disconnect()
|
|
210
|
+
|
|
211
|
+
async def test_temperature_recorded(self):
|
|
212
|
+
charger = await database_sync_to_async(Charger.objects.create)(
|
|
213
|
+
charger_id="TEMP1"
|
|
214
|
+
)
|
|
215
|
+
communicator = WebsocketCommunicator(application, "/TEMP1/")
|
|
216
|
+
connected, _ = await communicator.connect()
|
|
217
|
+
self.assertTrue(connected)
|
|
218
|
+
|
|
219
|
+
await communicator.send_json_to(
|
|
220
|
+
[
|
|
221
|
+
2,
|
|
222
|
+
"1",
|
|
223
|
+
"MeterValues",
|
|
224
|
+
{
|
|
225
|
+
"meterValue": [
|
|
226
|
+
{
|
|
227
|
+
"timestamp": "2025-01-01T00:00:00Z",
|
|
228
|
+
"sampledValue": [
|
|
229
|
+
{
|
|
230
|
+
"value": "42",
|
|
231
|
+
"measurand": "Temperature",
|
|
232
|
+
"unit": "Celsius",
|
|
233
|
+
}
|
|
234
|
+
],
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
]
|
|
239
|
+
)
|
|
240
|
+
await communicator.receive_json_from()
|
|
241
|
+
|
|
242
|
+
await database_sync_to_async(charger.refresh_from_db)()
|
|
243
|
+
self.assertEqual(charger.temperature, Decimal("42"))
|
|
244
|
+
self.assertEqual(charger.temperature_unit, "Celsius")
|
|
245
|
+
|
|
246
|
+
await communicator.disconnect()
|
|
247
|
+
|
|
248
|
+
async def test_message_logged_and_session_file_created(self):
|
|
249
|
+
cid = "LOGTEST1"
|
|
250
|
+
log_path = Path("logs") / f"charger.{cid}.log"
|
|
251
|
+
if log_path.exists():
|
|
252
|
+
log_path.unlink()
|
|
253
|
+
session_dir = Path("logs") / "sessions" / cid
|
|
254
|
+
if session_dir.exists():
|
|
255
|
+
for f in session_dir.glob("*.json"):
|
|
256
|
+
f.unlink()
|
|
257
|
+
communicator = WebsocketCommunicator(application, f"/{cid}/")
|
|
258
|
+
connected, _ = await communicator.connect()
|
|
259
|
+
self.assertTrue(connected)
|
|
260
|
+
|
|
261
|
+
await communicator.send_json_to([
|
|
262
|
+
2,
|
|
263
|
+
"1",
|
|
264
|
+
"StartTransaction",
|
|
265
|
+
{"meterStart": 1},
|
|
266
|
+
])
|
|
267
|
+
response = await communicator.receive_json_from()
|
|
268
|
+
tx_id = response[2]["transactionId"]
|
|
269
|
+
|
|
270
|
+
await communicator.send_json_to([
|
|
271
|
+
2,
|
|
272
|
+
"2",
|
|
273
|
+
"StopTransaction",
|
|
274
|
+
{"transactionId": tx_id, "meterStop": 2},
|
|
275
|
+
])
|
|
276
|
+
await communicator.receive_json_from()
|
|
277
|
+
await communicator.disconnect()
|
|
278
|
+
|
|
279
|
+
content = log_path.read_text()
|
|
280
|
+
self.assertIn("StartTransaction", content)
|
|
281
|
+
self.assertNotIn(">", content)
|
|
282
|
+
|
|
283
|
+
files = list(session_dir.glob(f"*_{tx_id}.json"))
|
|
284
|
+
self.assertEqual(len(files), 1)
|
|
285
|
+
data = json.loads(files[0].read_text())
|
|
286
|
+
self.assertTrue(any("StartTransaction" in m["message"] for m in data))
|
|
287
|
+
|
|
288
|
+
async def test_binary_message_logged(self):
|
|
289
|
+
cid = "BINARY1"
|
|
290
|
+
log_path = Path("logs") / f"charger.{cid}.log"
|
|
291
|
+
if log_path.exists():
|
|
292
|
+
log_path.unlink()
|
|
293
|
+
communicator = WebsocketCommunicator(application, f"/{cid}/")
|
|
294
|
+
connected, _ = await communicator.connect()
|
|
295
|
+
self.assertTrue(connected)
|
|
296
|
+
|
|
297
|
+
await communicator.send_to(bytes_data=b"\x01\x02\x03")
|
|
298
|
+
await communicator.disconnect()
|
|
299
|
+
|
|
300
|
+
content = log_path.read_text()
|
|
301
|
+
self.assertIn("AQID", content)
|
|
302
|
+
|
|
303
|
+
async def test_session_file_written_on_disconnect(self):
|
|
304
|
+
cid = "LOGTEST2"
|
|
305
|
+
log_path = Path("logs") / f"charger.{cid}.log"
|
|
306
|
+
if log_path.exists():
|
|
307
|
+
log_path.unlink()
|
|
308
|
+
session_dir = Path("logs") / "sessions" / cid
|
|
309
|
+
if session_dir.exists():
|
|
310
|
+
for f in session_dir.glob("*.json"):
|
|
311
|
+
f.unlink()
|
|
312
|
+
communicator = WebsocketCommunicator(application, f"/{cid}/")
|
|
313
|
+
connected, _ = await communicator.connect()
|
|
314
|
+
self.assertTrue(connected)
|
|
315
|
+
|
|
316
|
+
await communicator.send_json_to([
|
|
317
|
+
2,
|
|
318
|
+
"1",
|
|
319
|
+
"StartTransaction",
|
|
320
|
+
{"meterStart": 5},
|
|
321
|
+
])
|
|
322
|
+
await communicator.receive_json_from()
|
|
323
|
+
|
|
324
|
+
await communicator.disconnect()
|
|
325
|
+
|
|
326
|
+
session_dir = Path("logs") / "sessions" / cid
|
|
327
|
+
files = list(session_dir.glob("*.json"))
|
|
328
|
+
self.assertEqual(len(files), 1)
|
|
329
|
+
data = json.loads(files[0].read_text())
|
|
330
|
+
self.assertTrue(any("StartTransaction" in m["message"] for m in data))
|
|
331
|
+
|
|
332
|
+
async def test_second_connection_closes_first(self):
|
|
333
|
+
communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
|
|
334
|
+
connected, _ = await communicator1.connect()
|
|
335
|
+
self.assertTrue(connected)
|
|
336
|
+
first_consumer = store.connections.get("DUPLICATE")
|
|
337
|
+
|
|
338
|
+
communicator2 = WebsocketCommunicator(application, "/DUPLICATE/")
|
|
339
|
+
connected2, _ = await communicator2.connect()
|
|
340
|
+
self.assertTrue(connected2)
|
|
341
|
+
|
|
342
|
+
# The first communicator should be closed when the second connects.
|
|
343
|
+
await communicator1.wait()
|
|
344
|
+
self.assertIsNot(store.connections.get("DUPLICATE"), first_consumer)
|
|
345
|
+
|
|
346
|
+
await communicator2.disconnect()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ChargerLandingTests(TestCase):
|
|
350
|
+
def setUp(self):
|
|
351
|
+
self.client = Client()
|
|
352
|
+
User = get_user_model()
|
|
353
|
+
self.user = User.objects.create_user(username="u", password="pwd")
|
|
354
|
+
self.client.force_login(self.user)
|
|
355
|
+
|
|
356
|
+
def test_reference_created_and_page_renders(self):
|
|
357
|
+
charger = Charger.objects.create(charger_id="PAGE1")
|
|
358
|
+
self.assertIsNotNone(charger.reference)
|
|
359
|
+
|
|
360
|
+
response = self.client.get(reverse("charger-page", args=["PAGE1"]))
|
|
361
|
+
self.assertEqual(response.status_code, 200)
|
|
362
|
+
self.assertContains(
|
|
363
|
+
response,
|
|
364
|
+
"Plug in your vehicle and slide your RFID card over the reader to begin charging.",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def test_status_page_renders(self):
|
|
368
|
+
charger = Charger.objects.create(charger_id="PAGE2")
|
|
369
|
+
resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
|
|
370
|
+
self.assertEqual(resp.status_code, 200)
|
|
371
|
+
self.assertContains(resp, "PAGE2")
|
|
372
|
+
|
|
373
|
+
def test_charger_page_shows_progress(self):
|
|
374
|
+
charger = Charger.objects.create(charger_id="STATS")
|
|
375
|
+
tx = Transaction.objects.create(
|
|
376
|
+
charger=charger,
|
|
377
|
+
meter_start=1000,
|
|
378
|
+
start_time=timezone.now(),
|
|
379
|
+
)
|
|
380
|
+
store.transactions[charger.charger_id] = tx
|
|
381
|
+
resp = self.client.get(reverse("charger-page", args=["STATS"]))
|
|
382
|
+
self.assertContains(resp, "progress")
|
|
383
|
+
store.transactions.pop(charger.charger_id, None)
|
|
384
|
+
|
|
385
|
+
def test_total_includes_ongoing_transaction(self):
|
|
386
|
+
charger = Charger.objects.create(charger_id="ONGOING")
|
|
387
|
+
tx = Transaction.objects.create(
|
|
388
|
+
charger=charger,
|
|
389
|
+
meter_start=1000,
|
|
390
|
+
start_time=timezone.now(),
|
|
391
|
+
)
|
|
392
|
+
store.transactions[charger.charger_id] = tx
|
|
393
|
+
MeterReading.objects.create(
|
|
394
|
+
charger=charger,
|
|
395
|
+
transaction=tx,
|
|
396
|
+
timestamp=timezone.now(),
|
|
397
|
+
measurand="Energy.Active.Import.Register",
|
|
398
|
+
value=Decimal("2500"),
|
|
399
|
+
unit="W",
|
|
400
|
+
)
|
|
401
|
+
resp = self.client.get(reverse("charger-status", args=["ONGOING"]))
|
|
402
|
+
self.assertContains(
|
|
403
|
+
resp, 'Total Energy: <span id="total-kw">1.50</span> kW'
|
|
404
|
+
)
|
|
405
|
+
store.transactions.pop(charger.charger_id, None)
|
|
406
|
+
|
|
407
|
+
def test_temperature_displayed(self):
|
|
408
|
+
charger = Charger.objects.create(
|
|
409
|
+
charger_id="TEMP2", temperature=Decimal("21.5"), temperature_unit="Celsius"
|
|
410
|
+
)
|
|
411
|
+
resp = self.client.get(reverse("charger-status", args=["TEMP2"]))
|
|
412
|
+
self.assertContains(resp, "Temperature")
|
|
413
|
+
self.assertContains(resp, "21.5")
|
|
414
|
+
|
|
415
|
+
def test_log_page_renders_without_charger(self):
|
|
416
|
+
store.add_log("LOG1", "hello", log_type="charger")
|
|
417
|
+
entry = store.get_logs("LOG1", log_type="charger")[0]
|
|
418
|
+
self.assertRegex(entry, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} hello$")
|
|
419
|
+
resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
|
|
420
|
+
self.assertEqual(resp.status_code, 200)
|
|
421
|
+
self.assertContains(resp, "hello")
|
|
422
|
+
store.clear_log("LOG1", log_type="charger")
|
|
423
|
+
|
|
424
|
+
def test_log_page_is_case_insensitive(self):
|
|
425
|
+
store.add_log("cp2", "entry", log_type="charger")
|
|
426
|
+
resp = self.client.get(reverse("charger-log", args=["CP2"]) + "?type=charger")
|
|
427
|
+
self.assertEqual(resp.status_code, 200)
|
|
428
|
+
self.assertContains(resp, "entry")
|
|
429
|
+
store.clear_log("cp2", log_type="charger")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class SimulatorLandingTests(TestCase):
|
|
433
|
+
def setUp(self):
|
|
434
|
+
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
435
|
+
Node.objects.update_or_create(
|
|
436
|
+
mac_address=Node.get_current_mac(),
|
|
437
|
+
defaults={"hostname": "localhost", "address": "127.0.0.1", "role": role},
|
|
438
|
+
)
|
|
439
|
+
Site.objects.update_or_create(
|
|
440
|
+
id=1, defaults={"domain": "testserver", "name": ""}
|
|
441
|
+
)
|
|
442
|
+
app = Application.objects.create(name="Ocpp")
|
|
443
|
+
module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
|
|
444
|
+
module.create_landings()
|
|
445
|
+
User = get_user_model()
|
|
446
|
+
self.user = User.objects.create_user(username="nav", password="pwd")
|
|
447
|
+
self.client = Client()
|
|
448
|
+
|
|
449
|
+
@skip("Navigation links unavailable in test environment")
|
|
450
|
+
def test_simulator_app_link_in_nav(self):
|
|
451
|
+
resp = self.client.get(reverse("pages:index"))
|
|
452
|
+
self.assertContains(resp, "/ocpp/")
|
|
453
|
+
self.assertNotContains(resp, "/ocpp/simulator/")
|
|
454
|
+
self.client.force_login(self.user)
|
|
455
|
+
resp = self.client.get(reverse("pages:index"))
|
|
456
|
+
self.assertContains(resp, "/ocpp/")
|
|
457
|
+
self.assertContains(resp, "/ocpp/simulator/")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class ChargerAdminTests(TestCase):
|
|
461
|
+
def setUp(self):
|
|
462
|
+
self.client = Client()
|
|
463
|
+
User = get_user_model()
|
|
464
|
+
self.admin = User.objects.create_superuser(
|
|
465
|
+
username="ocpp-admin", password="secret", email="admin@example.com"
|
|
466
|
+
)
|
|
467
|
+
self.client.force_login(self.admin)
|
|
468
|
+
|
|
469
|
+
def test_admin_lists_landing_link(self):
|
|
470
|
+
charger = Charger.objects.create(charger_id="ADMIN1")
|
|
471
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
472
|
+
resp = self.client.get(url)
|
|
473
|
+
self.assertContains(resp, charger.get_absolute_url())
|
|
474
|
+
status_url = reverse("charger-status", args=["ADMIN1"])
|
|
475
|
+
self.assertContains(resp, status_url)
|
|
476
|
+
|
|
477
|
+
def test_admin_does_not_list_qr_link(self):
|
|
478
|
+
charger = Charger.objects.create(charger_id="QR1")
|
|
479
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
480
|
+
resp = self.client.get(url)
|
|
481
|
+
self.assertNotContains(resp, charger.reference.image.url)
|
|
482
|
+
|
|
483
|
+
def test_admin_lists_log_link(self):
|
|
484
|
+
charger = Charger.objects.create(charger_id="LOG1")
|
|
485
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
486
|
+
resp = self.client.get(url)
|
|
487
|
+
log_url = reverse("charger-log", args=["LOG1"]) + "?type=charger"
|
|
488
|
+
self.assertContains(resp, log_url)
|
|
489
|
+
|
|
490
|
+
def test_admin_change_links_landing_page(self):
|
|
491
|
+
charger = Charger.objects.create(charger_id="CHANGE1")
|
|
492
|
+
url = reverse("admin:ocpp_charger_change", args=[charger.pk])
|
|
493
|
+
resp = self.client.get(url)
|
|
494
|
+
self.assertContains(resp, charger.get_absolute_url())
|
|
495
|
+
|
|
496
|
+
def test_admin_shows_location_name(self):
|
|
497
|
+
loc = Location.objects.create(name="AdminLoc")
|
|
498
|
+
Charger.objects.create(charger_id="ADMINLOC", location=loc)
|
|
499
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
500
|
+
resp = self.client.get(url)
|
|
501
|
+
self.assertContains(resp, "AdminLoc")
|
|
502
|
+
|
|
503
|
+
def test_last_fields_are_read_only(self):
|
|
504
|
+
now = timezone.now()
|
|
505
|
+
charger = Charger.objects.create(
|
|
506
|
+
charger_id="ADMINRO",
|
|
507
|
+
last_heartbeat=now,
|
|
508
|
+
last_meter_values={"a": 1},
|
|
509
|
+
)
|
|
510
|
+
url = reverse("admin:ocpp_charger_change", args=[charger.pk])
|
|
511
|
+
resp = self.client.get(url)
|
|
512
|
+
self.assertContains(resp, "Last heartbeat")
|
|
513
|
+
self.assertContains(resp, "Last meter values")
|
|
514
|
+
self.assertNotContains(resp, 'name="last_heartbeat"')
|
|
515
|
+
self.assertNotContains(resp, 'name="last_meter_values"')
|
|
516
|
+
|
|
517
|
+
def test_purge_action_removes_data(self):
|
|
518
|
+
charger = Charger.objects.create(charger_id="PURGE1")
|
|
519
|
+
Transaction.objects.create(
|
|
520
|
+
charger=charger,
|
|
521
|
+
start_time=timezone.now(),
|
|
522
|
+
)
|
|
523
|
+
MeterReading.objects.create(
|
|
524
|
+
charger=charger,
|
|
525
|
+
timestamp=timezone.now(),
|
|
526
|
+
value=1,
|
|
527
|
+
)
|
|
528
|
+
store.add_log("PURGE1", "entry", log_type="charger")
|
|
529
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
530
|
+
self.client.post(url, {"action": "purge_data", "_selected_action": [charger.pk]})
|
|
531
|
+
self.assertFalse(Transaction.objects.filter(charger=charger).exists())
|
|
532
|
+
self.assertFalse(MeterReading.objects.filter(charger=charger).exists())
|
|
533
|
+
self.assertNotIn("PURGE1", store.logs["charger"])
|
|
534
|
+
|
|
535
|
+
def test_delete_requires_purge(self):
|
|
536
|
+
charger = Charger.objects.create(charger_id="DEL1")
|
|
537
|
+
Transaction.objects.create(
|
|
538
|
+
charger=charger,
|
|
539
|
+
start_time=timezone.now(),
|
|
540
|
+
)
|
|
541
|
+
delete_url = reverse("admin:ocpp_charger_delete", args=[charger.pk])
|
|
542
|
+
with self.assertRaises(ProtectedError):
|
|
543
|
+
self.client.post(delete_url, {"post": "yes"})
|
|
544
|
+
self.assertTrue(Charger.objects.filter(pk=charger.pk).exists())
|
|
545
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
546
|
+
self.client.post(url, {"action": "purge_data", "_selected_action": [charger.pk]})
|
|
547
|
+
self.client.post(delete_url, {"post": "yes"})
|
|
548
|
+
self.assertFalse(Charger.objects.filter(pk=charger.pk).exists())
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class TransactionAdminTests(TestCase):
|
|
552
|
+
def setUp(self):
|
|
553
|
+
self.client = Client()
|
|
554
|
+
User = get_user_model()
|
|
555
|
+
self.admin = User.objects.create_superuser(
|
|
556
|
+
username="tx-admin", password="secret", email="tx@example.com"
|
|
557
|
+
)
|
|
558
|
+
self.client.force_login(self.admin)
|
|
559
|
+
|
|
560
|
+
def test_meter_readings_inline_displayed(self):
|
|
561
|
+
charger = Charger.objects.create(charger_id="T1")
|
|
562
|
+
tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
|
|
563
|
+
reading = MeterReading.objects.create(
|
|
564
|
+
charger=charger,
|
|
565
|
+
transaction=tx,
|
|
566
|
+
timestamp=timezone.now(),
|
|
567
|
+
value=Decimal("2.123"),
|
|
568
|
+
unit="kW",
|
|
569
|
+
)
|
|
570
|
+
url = reverse("admin:ocpp_transaction_change", args=[tx.pk])
|
|
571
|
+
resp = self.client.get(url)
|
|
572
|
+
self.assertContains(resp, str(reading.value))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
class SimulatorAdminTests(TestCase):
|
|
576
|
+
def setUp(self):
|
|
577
|
+
self.client = Client()
|
|
578
|
+
User = get_user_model()
|
|
579
|
+
self.admin = User.objects.create_superuser(
|
|
580
|
+
username="admin2", password="secret", email="admin2@example.com"
|
|
581
|
+
)
|
|
582
|
+
self.client.force_login(self.admin)
|
|
583
|
+
|
|
584
|
+
def test_admin_lists_log_link(self):
|
|
585
|
+
sim = Simulator.objects.create(name="SIM", cp_path="SIMX")
|
|
586
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
587
|
+
resp = self.client.get(url)
|
|
588
|
+
log_url = reverse("charger-log", args=["SIMX"]) + "?type=simulator"
|
|
589
|
+
self.assertContains(resp, log_url)
|
|
590
|
+
|
|
591
|
+
def test_admin_shows_ws_url(self):
|
|
592
|
+
sim = Simulator.objects.create(name="SIM2", cp_path="SIMY", host="h",
|
|
593
|
+
ws_port=1111)
|
|
594
|
+
url = reverse("admin:ocpp_simulator_changelist")
|
|
595
|
+
resp = self.client.get(url)
|
|
596
|
+
self.assertContains(resp, "ws://h:1111/SIMY/")
|
|
597
|
+
|
|
598
|
+
def test_as_config_includes_custom_fields(self):
|
|
599
|
+
sim = Simulator.objects.create(
|
|
600
|
+
name="SIM3",
|
|
601
|
+
cp_path="S3",
|
|
602
|
+
interval=3.5,
|
|
603
|
+
kw_max=70,
|
|
604
|
+
duration=500,
|
|
605
|
+
pre_charge_delay=5,
|
|
606
|
+
vin="WP0ZZZ99999999999",
|
|
607
|
+
)
|
|
608
|
+
cfg = sim.as_config()
|
|
609
|
+
self.assertEqual(cfg.interval, 3.5)
|
|
610
|
+
self.assertEqual(cfg.kw_max, 70)
|
|
611
|
+
self.assertEqual(cfg.duration, 500)
|
|
612
|
+
self.assertEqual(cfg.pre_charge_delay, 5)
|
|
613
|
+
self.assertEqual(cfg.vin, "WP0ZZZ99999999999")
|
|
614
|
+
|
|
615
|
+
async def test_unknown_charger_auto_registered(self):
|
|
616
|
+
communicator = WebsocketCommunicator(application, "/NEWCHG/")
|
|
617
|
+
connected, _ = await communicator.connect()
|
|
618
|
+
self.assertTrue(connected)
|
|
619
|
+
|
|
620
|
+
exists = await database_sync_to_async(Charger.objects.filter(charger_id="NEWCHG").exists)()
|
|
621
|
+
self.assertTrue(exists)
|
|
622
|
+
|
|
623
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEWCHG")
|
|
624
|
+
self.assertEqual(charger.last_path, "/NEWCHG/")
|
|
625
|
+
|
|
626
|
+
await communicator.disconnect()
|
|
627
|
+
|
|
628
|
+
async def test_nested_path_accepted_and_recorded(self):
|
|
629
|
+
communicator = WebsocketCommunicator(application, "/foo/NEST/")
|
|
630
|
+
connected, _ = await communicator.connect()
|
|
631
|
+
self.assertTrue(connected)
|
|
632
|
+
|
|
633
|
+
await communicator.disconnect()
|
|
634
|
+
|
|
635
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="NEST")
|
|
636
|
+
self.assertEqual(charger.last_path, "/foo/NEST/")
|
|
637
|
+
|
|
638
|
+
async def test_rfid_required_rejects_invalid(self):
|
|
639
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="RFID", require_rfid=True)
|
|
640
|
+
communicator = WebsocketCommunicator(application, "/RFID/")
|
|
641
|
+
connected, _ = await communicator.connect()
|
|
642
|
+
self.assertTrue(connected)
|
|
643
|
+
|
|
644
|
+
await communicator.send_json_to([
|
|
645
|
+
2,
|
|
646
|
+
"1",
|
|
647
|
+
"StartTransaction",
|
|
648
|
+
{"meterStart": 0},
|
|
649
|
+
])
|
|
650
|
+
response = await communicator.receive_json_from()
|
|
651
|
+
self.assertEqual(response[2]["idTagInfo"]["status"], "Invalid")
|
|
652
|
+
|
|
653
|
+
exists = await database_sync_to_async(Transaction.objects.filter(charger__charger_id="RFID").exists)()
|
|
654
|
+
self.assertFalse(exists)
|
|
655
|
+
|
|
656
|
+
await communicator.disconnect()
|
|
657
|
+
|
|
658
|
+
async def test_rfid_required_accepts_known_tag(self):
|
|
659
|
+
User = get_user_model()
|
|
660
|
+
user = await database_sync_to_async(User.objects.create_user)(
|
|
661
|
+
username="bob", password="pwd"
|
|
662
|
+
)
|
|
663
|
+
acc = await database_sync_to_async(EnergyAccount.objects.create)(
|
|
664
|
+
user=user, name="BOB"
|
|
665
|
+
)
|
|
666
|
+
await database_sync_to_async(EnergyCredit.objects.create)(
|
|
667
|
+
account=acc, amount_kw=10
|
|
668
|
+
)
|
|
669
|
+
tag = await database_sync_to_async(RFID.objects.create)(rfid="CARDX")
|
|
670
|
+
await database_sync_to_async(acc.rfids.add)(tag)
|
|
671
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="RFIDOK", require_rfid=True)
|
|
672
|
+
communicator = WebsocketCommunicator(application, "/RFIDOK/")
|
|
673
|
+
connected, _ = await communicator.connect()
|
|
674
|
+
self.assertTrue(connected)
|
|
675
|
+
|
|
676
|
+
await communicator.send_json_to([
|
|
677
|
+
2,
|
|
678
|
+
"1",
|
|
679
|
+
"StartTransaction",
|
|
680
|
+
{"meterStart": 5, "idTag": "CARDX"},
|
|
681
|
+
])
|
|
682
|
+
response = await communicator.receive_json_from()
|
|
683
|
+
self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
|
|
684
|
+
tx_id = response[2]["transactionId"]
|
|
685
|
+
|
|
686
|
+
tx = await database_sync_to_async(Transaction.objects.get)(pk=tx_id, charger__charger_id="RFIDOK")
|
|
687
|
+
self.assertEqual(tx.account_id, user.energy_account.id)
|
|
688
|
+
|
|
689
|
+
async def test_status_fields_updated(self):
|
|
690
|
+
communicator = WebsocketCommunicator(application, "/STAT/")
|
|
691
|
+
connected, _ = await communicator.connect()
|
|
692
|
+
self.assertTrue(connected)
|
|
693
|
+
|
|
694
|
+
await communicator.send_json_to([2, "1", "Heartbeat", {}])
|
|
695
|
+
await communicator.receive_json_from()
|
|
696
|
+
|
|
697
|
+
charger = await database_sync_to_async(Charger.objects.get)(charger_id="STAT")
|
|
698
|
+
self.assertIsNotNone(charger.last_heartbeat)
|
|
699
|
+
|
|
700
|
+
payload = {
|
|
701
|
+
"meterValue": [
|
|
702
|
+
{
|
|
703
|
+
"timestamp": "2025-01-01T00:00:00Z",
|
|
704
|
+
"sampledValue": [{"value": "42"}],
|
|
705
|
+
}
|
|
706
|
+
]
|
|
707
|
+
}
|
|
708
|
+
await communicator.send_json_to([2, "2", "MeterValues", payload])
|
|
709
|
+
await communicator.receive_json_from()
|
|
710
|
+
|
|
711
|
+
await database_sync_to_async(charger.refresh_from_db)()
|
|
712
|
+
self.assertEqual(charger.last_meter_values.get("meterValue")[0]["sampledValue"][0]["value"], "42")
|
|
713
|
+
|
|
714
|
+
await communicator.disconnect()
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class ChargerLocationTests(TestCase):
|
|
718
|
+
def test_lat_lon_fields_saved(self):
|
|
719
|
+
loc = Location.objects.create(
|
|
720
|
+
name="Loc1", latitude=10.123456, longitude=-20.654321
|
|
721
|
+
)
|
|
722
|
+
charger = Charger.objects.create(charger_id="LOC1", location=loc)
|
|
723
|
+
self.assertAlmostEqual(float(charger.latitude), 10.123456)
|
|
724
|
+
self.assertAlmostEqual(float(charger.longitude), -20.654321)
|
|
725
|
+
self.assertEqual(charger.name, "Loc1")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class MeterReadingTests(TransactionTestCase):
|
|
729
|
+
async def test_meter_values_saved_as_readings(self):
|
|
730
|
+
communicator = WebsocketCommunicator(application, "/MR1/")
|
|
731
|
+
connected, _ = await communicator.connect()
|
|
732
|
+
self.assertTrue(connected)
|
|
733
|
+
|
|
734
|
+
payload = {
|
|
735
|
+
"connectorId": 1,
|
|
736
|
+
"transactionId": 100,
|
|
737
|
+
"meterValue": [
|
|
738
|
+
{
|
|
739
|
+
"timestamp": "2025-07-29T10:01:51Z",
|
|
740
|
+
"sampledValue": [
|
|
741
|
+
{
|
|
742
|
+
"value": "2.749",
|
|
743
|
+
"measurand": "Energy.Active.Import.Register",
|
|
744
|
+
"unit": "kW",
|
|
745
|
+
}
|
|
746
|
+
],
|
|
747
|
+
}
|
|
748
|
+
],
|
|
749
|
+
}
|
|
750
|
+
await communicator.send_json_to([2, "1", "MeterValues", payload])
|
|
751
|
+
await communicator.receive_json_from()
|
|
752
|
+
|
|
753
|
+
reading = await database_sync_to_async(MeterReading.objects.get)(charger__charger_id="MR1")
|
|
754
|
+
self.assertEqual(reading.transaction_id, 100)
|
|
755
|
+
self.assertEqual(str(reading.value), "2.749")
|
|
756
|
+
tx = await database_sync_to_async(Transaction.objects.get)(pk=100, charger__charger_id="MR1")
|
|
757
|
+
self.assertEqual(tx.meter_start, 2749)
|
|
758
|
+
|
|
759
|
+
await communicator.disconnect()
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
class ChargePointSimulatorTests(TransactionTestCase):
|
|
763
|
+
async def test_simulator_sends_messages(self):
|
|
764
|
+
received = []
|
|
765
|
+
|
|
766
|
+
async def handler(ws):
|
|
767
|
+
async for msg in ws:
|
|
768
|
+
data = json.loads(msg)
|
|
769
|
+
received.append(data)
|
|
770
|
+
action = data[2]
|
|
771
|
+
if action == "BootNotification":
|
|
772
|
+
await ws.send(
|
|
773
|
+
json.dumps(
|
|
774
|
+
[
|
|
775
|
+
3,
|
|
776
|
+
data[1],
|
|
777
|
+
{
|
|
778
|
+
"status": "Accepted",
|
|
779
|
+
"currentTime": "2024-01-01T00:00:00Z",
|
|
780
|
+
"interval": 300,
|
|
781
|
+
},
|
|
782
|
+
]
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
elif action == "Authorize":
|
|
786
|
+
await ws.send(
|
|
787
|
+
json.dumps(
|
|
788
|
+
[
|
|
789
|
+
3,
|
|
790
|
+
data[1],
|
|
791
|
+
{"idTagInfo": {"status": "Accepted"}},
|
|
792
|
+
]
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
elif action == "StartTransaction":
|
|
796
|
+
await ws.send(
|
|
797
|
+
json.dumps(
|
|
798
|
+
[
|
|
799
|
+
3,
|
|
800
|
+
data[1],
|
|
801
|
+
{
|
|
802
|
+
"transactionId": 1,
|
|
803
|
+
"idTagInfo": {"status": "Accepted"},
|
|
804
|
+
},
|
|
805
|
+
]
|
|
806
|
+
)
|
|
807
|
+
)
|
|
808
|
+
elif action == "MeterValues":
|
|
809
|
+
await ws.send(json.dumps([3, data[1], {}]))
|
|
810
|
+
elif action == "StopTransaction":
|
|
811
|
+
await ws.send(
|
|
812
|
+
json.dumps(
|
|
813
|
+
[
|
|
814
|
+
3,
|
|
815
|
+
data[1],
|
|
816
|
+
{"idTagInfo": {"status": "Accepted"}},
|
|
817
|
+
]
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
break
|
|
821
|
+
|
|
822
|
+
server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
|
|
823
|
+
port = server.sockets[0].getsockname()[1]
|
|
824
|
+
|
|
825
|
+
try:
|
|
826
|
+
cfg = SimulatorConfig(
|
|
827
|
+
host="127.0.0.1",
|
|
828
|
+
ws_port=port,
|
|
829
|
+
cp_path="SIM1/",
|
|
830
|
+
vin="WP0ZZZ12345678901",
|
|
831
|
+
duration=0.2,
|
|
832
|
+
interval=0.05,
|
|
833
|
+
kw_min=0.1,
|
|
834
|
+
kw_max=0.2,
|
|
835
|
+
pre_charge_delay=0.0,
|
|
836
|
+
)
|
|
837
|
+
sim = ChargePointSimulator(cfg)
|
|
838
|
+
await sim._run_session()
|
|
839
|
+
finally:
|
|
840
|
+
server.close()
|
|
841
|
+
await server.wait_closed()
|
|
842
|
+
|
|
843
|
+
actions = [msg[2] for msg in received]
|
|
844
|
+
self.assertIn("BootNotification", actions)
|
|
845
|
+
self.assertIn("StartTransaction", actions)
|
|
846
|
+
start_msg = next(msg for msg in received if msg[2] == "StartTransaction")
|
|
847
|
+
self.assertEqual(start_msg[3].get("vin"), "WP0ZZZ12345678901")
|
|
848
|
+
|
|
849
|
+
async def test_start_returns_status_and_log(self):
|
|
850
|
+
async def handler(ws):
|
|
851
|
+
async for msg in ws:
|
|
852
|
+
data = json.loads(msg)
|
|
853
|
+
action = data[2]
|
|
854
|
+
if action == "BootNotification":
|
|
855
|
+
await ws.send(
|
|
856
|
+
json.dumps(
|
|
857
|
+
[
|
|
858
|
+
3,
|
|
859
|
+
data[1],
|
|
860
|
+
{
|
|
861
|
+
"status": "Accepted",
|
|
862
|
+
"currentTime": "2024-01-01T00:00:00Z",
|
|
863
|
+
"interval": 300,
|
|
864
|
+
},
|
|
865
|
+
]
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
elif action == "Authorize":
|
|
869
|
+
await ws.send(
|
|
870
|
+
json.dumps(
|
|
871
|
+
[3, data[1], {"idTagInfo": {"status": "Accepted"}}]
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
elif action == "StartTransaction":
|
|
875
|
+
await ws.send(
|
|
876
|
+
json.dumps(
|
|
877
|
+
[
|
|
878
|
+
3,
|
|
879
|
+
data[1],
|
|
880
|
+
{
|
|
881
|
+
"transactionId": 1,
|
|
882
|
+
"idTagInfo": {"status": "Accepted"},
|
|
883
|
+
},
|
|
884
|
+
]
|
|
885
|
+
)
|
|
886
|
+
)
|
|
887
|
+
elif action == "StopTransaction":
|
|
888
|
+
await ws.send(
|
|
889
|
+
json.dumps(
|
|
890
|
+
[3, data[1], {"idTagInfo": {"status": "Accepted"}}]
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
break
|
|
894
|
+
else:
|
|
895
|
+
await ws.send(json.dumps([3, data[1], {}]))
|
|
896
|
+
|
|
897
|
+
server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
|
|
898
|
+
port = server.sockets[0].getsockname()[1]
|
|
899
|
+
|
|
900
|
+
cfg = SimulatorConfig(
|
|
901
|
+
host="127.0.0.1",
|
|
902
|
+
ws_port=port,
|
|
903
|
+
cp_path="SIMSTART/",
|
|
904
|
+
duration=0.1,
|
|
905
|
+
interval=0.05,
|
|
906
|
+
kw_min=0.1,
|
|
907
|
+
kw_max=0.2,
|
|
908
|
+
pre_charge_delay=0.0,
|
|
909
|
+
)
|
|
910
|
+
store.register_log_name(cfg.cp_path, "SimStart", log_type="simulator")
|
|
911
|
+
try:
|
|
912
|
+
sim = ChargePointSimulator(cfg)
|
|
913
|
+
started, status, log_file = await asyncio.to_thread(sim.start)
|
|
914
|
+
self.assertTrue(started)
|
|
915
|
+
self.assertEqual(status, "Connection accepted")
|
|
916
|
+
self.assertEqual(sim.status, "running")
|
|
917
|
+
self.assertTrue(Path(log_file).exists())
|
|
918
|
+
finally:
|
|
919
|
+
await sim.stop()
|
|
920
|
+
store.clear_log(cfg.cp_path, log_type="simulator")
|
|
921
|
+
server.close()
|
|
922
|
+
await server.wait_closed()
|
|
923
|
+
|
|
924
|
+
async def test_simulator_stops_when_charger_closes(self):
|
|
925
|
+
async def handler(ws):
|
|
926
|
+
async for msg in ws:
|
|
927
|
+
data = json.loads(msg)
|
|
928
|
+
action = data[2]
|
|
929
|
+
if action == "BootNotification":
|
|
930
|
+
await ws.send(
|
|
931
|
+
json.dumps([3, data[1], {"status": "Accepted"}])
|
|
932
|
+
)
|
|
933
|
+
elif action == "Authorize":
|
|
934
|
+
await ws.send(
|
|
935
|
+
json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}])
|
|
936
|
+
)
|
|
937
|
+
await ws.close()
|
|
938
|
+
break
|
|
939
|
+
|
|
940
|
+
server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
|
|
941
|
+
port = server.sockets[0].getsockname()[1]
|
|
942
|
+
|
|
943
|
+
cfg = SimulatorConfig(
|
|
944
|
+
host="127.0.0.1",
|
|
945
|
+
ws_port=port,
|
|
946
|
+
cp_path="SIMTERM/",
|
|
947
|
+
duration=0.1,
|
|
948
|
+
interval=0.05,
|
|
949
|
+
kw_min=0.1,
|
|
950
|
+
kw_max=0.2,
|
|
951
|
+
pre_charge_delay=0.0,
|
|
952
|
+
)
|
|
953
|
+
sim = ChargePointSimulator(cfg)
|
|
954
|
+
try:
|
|
955
|
+
started, _, _ = await asyncio.to_thread(sim.start)
|
|
956
|
+
self.assertTrue(started)
|
|
957
|
+
# Allow time for the server to close the connection
|
|
958
|
+
await asyncio.sleep(0.1)
|
|
959
|
+
self.assertEqual(sim.status, "stopped")
|
|
960
|
+
self.assertFalse(sim._thread.is_alive())
|
|
961
|
+
finally:
|
|
962
|
+
await sim.stop()
|
|
963
|
+
server.close()
|
|
964
|
+
await server.wait_closed()
|
|
965
|
+
|
|
966
|
+
async def test_pre_charge_sends_heartbeat_and_meter(self):
|
|
967
|
+
received = []
|
|
968
|
+
|
|
969
|
+
async def handler(ws):
|
|
970
|
+
async for msg in ws:
|
|
971
|
+
data = json.loads(msg)
|
|
972
|
+
received.append(data)
|
|
973
|
+
action = data[2]
|
|
974
|
+
if action == "BootNotification":
|
|
975
|
+
await ws.send(json.dumps([3, data[1], {"status": "Accepted"}]))
|
|
976
|
+
elif action in {"Authorize", "StatusNotification", "Heartbeat", "MeterValues"}:
|
|
977
|
+
await ws.send(json.dumps([3, data[1], {}]))
|
|
978
|
+
elif action == "StartTransaction":
|
|
979
|
+
await ws.send(
|
|
980
|
+
json.dumps(
|
|
981
|
+
[
|
|
982
|
+
3,
|
|
983
|
+
data[1],
|
|
984
|
+
{"transactionId": 1, "idTagInfo": {"status": "Accepted"}},
|
|
985
|
+
]
|
|
986
|
+
)
|
|
987
|
+
)
|
|
988
|
+
elif action == "StopTransaction":
|
|
989
|
+
await ws.send(json.dumps([3, data[1], {"idTagInfo": {"status": "Accepted"}}]))
|
|
990
|
+
break
|
|
991
|
+
|
|
992
|
+
server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
|
|
993
|
+
port = server.sockets[0].getsockname()[1]
|
|
994
|
+
|
|
995
|
+
try:
|
|
996
|
+
cfg = SimulatorConfig(
|
|
997
|
+
host="127.0.0.1",
|
|
998
|
+
ws_port=port,
|
|
999
|
+
cp_path="SIMPRE/",
|
|
1000
|
+
duration=0.1,
|
|
1001
|
+
interval=0.05,
|
|
1002
|
+
kw_min=0.1,
|
|
1003
|
+
kw_max=0.2,
|
|
1004
|
+
pre_charge_delay=0.1,
|
|
1005
|
+
)
|
|
1006
|
+
sim = ChargePointSimulator(cfg)
|
|
1007
|
+
await sim._run_session()
|
|
1008
|
+
finally:
|
|
1009
|
+
server.close()
|
|
1010
|
+
await server.wait_closed()
|
|
1011
|
+
|
|
1012
|
+
actions = [msg[2] for msg in received]
|
|
1013
|
+
start_idx = actions.index("StartTransaction")
|
|
1014
|
+
pre_actions = actions[:start_idx]
|
|
1015
|
+
self.assertIn("Heartbeat", pre_actions)
|
|
1016
|
+
self.assertIn("MeterValues", pre_actions)
|
|
1017
|
+
|
|
1018
|
+
async def test_simulator_times_out_without_response(self):
|
|
1019
|
+
async def handler(ws):
|
|
1020
|
+
async for _ in ws:
|
|
1021
|
+
pass
|
|
1022
|
+
|
|
1023
|
+
server = await websockets.serve(handler, "127.0.0.1", 0, subprotocols=["ocpp1.6"])
|
|
1024
|
+
port = server.sockets[0].getsockname()[1]
|
|
1025
|
+
|
|
1026
|
+
cfg = SimulatorConfig(host="127.0.0.1", ws_port=port, cp_path="SIMTO/")
|
|
1027
|
+
sim = ChargePointSimulator(cfg)
|
|
1028
|
+
store.simulators[99] = sim
|
|
1029
|
+
try:
|
|
1030
|
+
with patch("ocpp.simulator.asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
|
1031
|
+
started, status, _ = await asyncio.to_thread(sim.start)
|
|
1032
|
+
await asyncio.to_thread(sim._thread.join)
|
|
1033
|
+
self.assertFalse(started)
|
|
1034
|
+
self.assertIn("Timeout", status)
|
|
1035
|
+
self.assertNotIn(99, store.simulators)
|
|
1036
|
+
finally:
|
|
1037
|
+
await sim.stop()
|
|
1038
|
+
server.close()
|
|
1039
|
+
await server.wait_closed()
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
class PurgeMeterReadingsTaskTests(TestCase):
|
|
1043
|
+
def test_purge_old_meter_readings(self):
|
|
1044
|
+
charger = Charger.objects.create(charger_id="PURGER")
|
|
1045
|
+
tx = Transaction.objects.create(
|
|
1046
|
+
charger=charger,
|
|
1047
|
+
meter_start=0,
|
|
1048
|
+
meter_stop=1000,
|
|
1049
|
+
start_time=timezone.now(),
|
|
1050
|
+
stop_time=timezone.now(),
|
|
1051
|
+
)
|
|
1052
|
+
old = timezone.now() - timedelta(days=8)
|
|
1053
|
+
recent = timezone.now() - timedelta(days=2)
|
|
1054
|
+
MeterReading.objects.create(
|
|
1055
|
+
charger=charger, transaction=tx, timestamp=old, value=1
|
|
1056
|
+
)
|
|
1057
|
+
MeterReading.objects.create(
|
|
1058
|
+
charger=charger, transaction=tx, timestamp=recent, value=2
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
purge_meter_readings()
|
|
1062
|
+
|
|
1063
|
+
self.assertEqual(MeterReading.objects.count(), 1)
|
|
1064
|
+
self.assertTrue(
|
|
1065
|
+
MeterReading.objects.filter(timestamp__gte=recent - timedelta(minutes=1)).exists()
|
|
1066
|
+
)
|
|
1067
|
+
self.assertTrue(Transaction.objects.filter(pk=tx.pk).exists())
|
|
1068
|
+
|
|
1069
|
+
def test_purge_skips_open_transactions(self):
|
|
1070
|
+
charger = Charger.objects.create(charger_id="PURGER2")
|
|
1071
|
+
tx = Transaction.objects.create(
|
|
1072
|
+
charger=charger,
|
|
1073
|
+
meter_start=0,
|
|
1074
|
+
start_time=timezone.now() - timedelta(days=9),
|
|
1075
|
+
)
|
|
1076
|
+
old = timezone.now() - timedelta(days=8)
|
|
1077
|
+
reading = MeterReading.objects.create(
|
|
1078
|
+
charger=charger, transaction=tx, timestamp=old, value=1
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
purge_meter_readings()
|
|
1082
|
+
|
|
1083
|
+
self.assertTrue(MeterReading.objects.filter(pk=reading.pk).exists())
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
class TransactionKwTests(TestCase):
|
|
1087
|
+
def test_kw_sums_meter_readings(self):
|
|
1088
|
+
charger = Charger.objects.create(charger_id="SUM1")
|
|
1089
|
+
tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
|
|
1090
|
+
MeterReading.objects.create(
|
|
1091
|
+
charger=charger,
|
|
1092
|
+
transaction=tx,
|
|
1093
|
+
timestamp=timezone.now(),
|
|
1094
|
+
value=Decimal("1.0"),
|
|
1095
|
+
unit="kW",
|
|
1096
|
+
)
|
|
1097
|
+
MeterReading.objects.create(
|
|
1098
|
+
charger=charger,
|
|
1099
|
+
transaction=tx,
|
|
1100
|
+
timestamp=timezone.now(),
|
|
1101
|
+
value=Decimal("500"),
|
|
1102
|
+
unit="W",
|
|
1103
|
+
)
|
|
1104
|
+
self.assertAlmostEqual(tx.kw, 1.5)
|
|
1105
|
+
|
|
1106
|
+
def test_kw_defaults_to_zero(self):
|
|
1107
|
+
charger = Charger.objects.create(charger_id="SUM2")
|
|
1108
|
+
tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
|
|
1109
|
+
self.assertEqual(tx.kw, 0.0)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
class ChargerStatusViewTests(TestCase):
|
|
1113
|
+
def setUp(self):
|
|
1114
|
+
self.client = Client()
|
|
1115
|
+
User = get_user_model()
|
|
1116
|
+
self.user = User.objects.create_user(username="status", password="pwd")
|
|
1117
|
+
self.client.force_login(self.user)
|
|
1118
|
+
def test_chart_data_populated_from_existing_readings(self):
|
|
1119
|
+
charger = Charger.objects.create(charger_id="VIEW1")
|
|
1120
|
+
tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
|
|
1121
|
+
t0 = timezone.now()
|
|
1122
|
+
MeterReading.objects.create(
|
|
1123
|
+
charger=charger,
|
|
1124
|
+
transaction=tx,
|
|
1125
|
+
timestamp=t0,
|
|
1126
|
+
value=Decimal("1000"),
|
|
1127
|
+
unit="W",
|
|
1128
|
+
)
|
|
1129
|
+
MeterReading.objects.create(
|
|
1130
|
+
charger=charger,
|
|
1131
|
+
transaction=tx,
|
|
1132
|
+
timestamp=t0 + timedelta(seconds=10),
|
|
1133
|
+
value=Decimal("500"),
|
|
1134
|
+
unit="W",
|
|
1135
|
+
)
|
|
1136
|
+
store.transactions[charger.charger_id] = tx
|
|
1137
|
+
resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
1138
|
+
self.assertEqual(resp.status_code, 200)
|
|
1139
|
+
chart = json.loads(resp.context["chart_data"])
|
|
1140
|
+
self.assertEqual(len(chart["labels"]), 2)
|
|
1141
|
+
self.assertAlmostEqual(chart["values"][0], 1.0)
|
|
1142
|
+
self.assertAlmostEqual(chart["values"][1], 1.5)
|
|
1143
|
+
store.transactions.pop(charger.charger_id, None)
|
|
1144
|
+
|
|
1145
|
+
def test_sessions_are_linked(self):
|
|
1146
|
+
charger = Charger.objects.create(charger_id="LINK1")
|
|
1147
|
+
tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
|
|
1148
|
+
resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
1149
|
+
self.assertContains(resp, f"?session={tx.id}")
|
|
1150
|
+
|
|
1151
|
+
def test_status_links_landing_page(self):
|
|
1152
|
+
charger = Charger.objects.create(charger_id="LAND1")
|
|
1153
|
+
resp = self.client.get(reverse("charger-status", args=[charger.charger_id]))
|
|
1154
|
+
self.assertContains(resp, reverse("charger-page", args=[charger.charger_id]))
|
|
1155
|
+
|
|
1156
|
+
def test_past_session_chart(self):
|
|
1157
|
+
charger = Charger.objects.create(charger_id="PAST1")
|
|
1158
|
+
tx = Transaction.objects.create(charger=charger, start_time=timezone.now())
|
|
1159
|
+
t0 = timezone.now()
|
|
1160
|
+
MeterReading.objects.create(
|
|
1161
|
+
charger=charger,
|
|
1162
|
+
transaction=tx,
|
|
1163
|
+
timestamp=t0,
|
|
1164
|
+
value=Decimal("1000"),
|
|
1165
|
+
unit="W",
|
|
1166
|
+
)
|
|
1167
|
+
MeterReading.objects.create(
|
|
1168
|
+
charger=charger,
|
|
1169
|
+
transaction=tx,
|
|
1170
|
+
timestamp=t0 + timedelta(seconds=10),
|
|
1171
|
+
value=Decimal("1000"),
|
|
1172
|
+
unit="W",
|
|
1173
|
+
)
|
|
1174
|
+
resp = self.client.get(
|
|
1175
|
+
reverse("charger-status", args=[charger.charger_id]) + f"?session={tx.id}"
|
|
1176
|
+
)
|
|
1177
|
+
self.assertContains(resp, "Back to live")
|
|
1178
|
+
chart = json.loads(resp.context["chart_data"])
|
|
1179
|
+
self.assertEqual(len(chart["labels"]), 2)
|
|
1180
|
+
self.assertTrue(resp.context["past_session"])
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
class ChargerSessionPaginationTests(TestCase):
|
|
1184
|
+
def setUp(self):
|
|
1185
|
+
self.client = Client()
|
|
1186
|
+
User = get_user_model()
|
|
1187
|
+
self.user = User.objects.create_user(username="page", password="pwd")
|
|
1188
|
+
self.client.force_login(self.user)
|
|
1189
|
+
self.charger = Charger.objects.create(charger_id="PAGETEST")
|
|
1190
|
+
for i in range(15):
|
|
1191
|
+
Transaction.objects.create(
|
|
1192
|
+
charger=self.charger,
|
|
1193
|
+
start_time=timezone.now() - timedelta(minutes=i),
|
|
1194
|
+
meter_start=0,
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
def test_only_ten_transactions_shown(self):
|
|
1198
|
+
resp = self.client.get(reverse("charger-status", args=[self.charger.charger_id]))
|
|
1199
|
+
self.assertEqual(resp.status_code, 200)
|
|
1200
|
+
self.assertEqual(len(resp.context["transactions"]), 10)
|
|
1201
|
+
self.assertTrue(resp.context["page_obj"].has_next())
|
|
1202
|
+
|
|
1203
|
+
def test_session_search_by_date(self):
|
|
1204
|
+
date_str = timezone.now().date().isoformat()
|
|
1205
|
+
resp = self.client.get(
|
|
1206
|
+
reverse("charger-session-search", args=[self.charger.charger_id]),
|
|
1207
|
+
{"date": date_str},
|
|
1208
|
+
)
|
|
1209
|
+
self.assertEqual(resp.status_code, 200)
|
|
1210
|
+
self.assertEqual(len(resp.context["transactions"]), 15)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
class EfficiencyCalculatorViewTests(TestCase):
|
|
1214
|
+
def setUp(self):
|
|
1215
|
+
User = get_user_model()
|
|
1216
|
+
self.user = User.objects.create_user(
|
|
1217
|
+
username="eff", password="secret", email="eff@example.com"
|
|
1218
|
+
)
|
|
1219
|
+
self.client.force_login(self.user)
|
|
1220
|
+
|
|
1221
|
+
def test_get_view(self):
|
|
1222
|
+
url = reverse("ev-efficiency")
|
|
1223
|
+
resp = self.client.get(url)
|
|
1224
|
+
self.assertContains(resp, "EV Efficiency Calculator")
|
|
1225
|
+
|
|
1226
|
+
def test_post_calculation(self):
|
|
1227
|
+
url = reverse("ev-efficiency")
|
|
1228
|
+
resp = self.client.post(url, {"distance": "100", "energy": "20"})
|
|
1229
|
+
self.assertContains(resp, "5.00")
|