arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/tests.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import sys
|
|
3
3
|
import time
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections import deque
|
|
4
6
|
from importlib import util as importlib_util
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from types import ModuleType
|
|
@@ -55,19 +57,27 @@ from django.contrib.sites.models import Site
|
|
|
55
57
|
from django.core.exceptions import ValidationError
|
|
56
58
|
from pages.models import Application, Module
|
|
57
59
|
from nodes.models import Node, NodeRole
|
|
60
|
+
from django.contrib.admin.sites import AdminSite
|
|
58
61
|
|
|
59
62
|
from config.asgi import application
|
|
60
63
|
|
|
61
64
|
from .models import (
|
|
62
65
|
Transaction,
|
|
63
66
|
Charger,
|
|
67
|
+
ChargerConfiguration,
|
|
68
|
+
ConfigurationKey,
|
|
64
69
|
Simulator,
|
|
65
70
|
MeterReading,
|
|
71
|
+
MeterValue,
|
|
66
72
|
Location,
|
|
67
73
|
DataTransferMessage,
|
|
74
|
+
CPReservation,
|
|
75
|
+
CPFirmware,
|
|
76
|
+
CPFirmwareDeployment,
|
|
68
77
|
)
|
|
78
|
+
from .admin import ChargerConfigurationAdmin, ConfigurationKeyAdmin, ConfigurationKeyInline
|
|
69
79
|
from .consumers import CSMSConsumer
|
|
70
|
-
from .views import dispatch_action
|
|
80
|
+
from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
|
|
71
81
|
from .status_display import STATUS_BADGE_MAP
|
|
72
82
|
from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
|
|
73
83
|
from . import store
|
|
@@ -80,8 +90,14 @@ from .simulator import SimulatorConfig, ChargePointSimulator
|
|
|
80
90
|
from .evcs import simulate, SimulatorState, _simulators
|
|
81
91
|
import re
|
|
82
92
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
83
|
-
from .tasks import
|
|
84
|
-
|
|
93
|
+
from .tasks import (
|
|
94
|
+
purge_meter_readings,
|
|
95
|
+
send_daily_session_report,
|
|
96
|
+
check_charge_point_configuration,
|
|
97
|
+
schedule_daily_charge_point_configuration_checks,
|
|
98
|
+
)
|
|
99
|
+
from django.db import close_old_connections, connection
|
|
100
|
+
from django.test.utils import CaptureQueriesContext
|
|
85
101
|
from django.db.utils import OperationalError
|
|
86
102
|
from urllib.parse import unquote, urlparse
|
|
87
103
|
|
|
@@ -169,6 +185,36 @@ class DispatchActionTests(TestCase):
|
|
|
169
185
|
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
170
186
|
self.assertEqual(metadata.get("log_key"), log_key)
|
|
171
187
|
|
|
188
|
+
def test_reset_rejected_when_transaction_active(self):
|
|
189
|
+
charger = Charger.objects.create(charger_id="RESETBLOCK")
|
|
190
|
+
dummy = DummyWebSocket()
|
|
191
|
+
connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
|
|
192
|
+
self.addCleanup(lambda: store.connections.pop(connection_key, None))
|
|
193
|
+
tx_obj = Transaction.objects.create(
|
|
194
|
+
charger=charger,
|
|
195
|
+
connector_id=charger.connector_id,
|
|
196
|
+
start_time=timezone.now(),
|
|
197
|
+
)
|
|
198
|
+
tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
199
|
+
self.addCleanup(lambda: store.transactions.pop(tx_key, None))
|
|
200
|
+
|
|
201
|
+
request = self.factory.post(
|
|
202
|
+
"/chargers/RESETBLOCK/action/",
|
|
203
|
+
data=json.dumps({"action": "reset"}),
|
|
204
|
+
content_type="application/json",
|
|
205
|
+
)
|
|
206
|
+
request.user = SimpleNamespace(
|
|
207
|
+
is_authenticated=True,
|
|
208
|
+
is_superuser=True,
|
|
209
|
+
is_staff=True,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
response = dispatch_action(request, charger.charger_id)
|
|
213
|
+
self.assertEqual(response.status_code, 409)
|
|
214
|
+
payload = json.loads(response.content.decode("utf-8"))
|
|
215
|
+
self.assertIn("stop the session first", payload.get("detail", "").lower())
|
|
216
|
+
self.assertFalse(dummy.sent)
|
|
217
|
+
|
|
172
218
|
class ChargerFixtureTests(TestCase):
|
|
173
219
|
fixtures = [
|
|
174
220
|
p.name
|
|
@@ -210,6 +256,368 @@ class ChargerFixtureTests(TestCase):
|
|
|
210
256
|
self.assertEqual(cp2.name, "Simulator #2")
|
|
211
257
|
|
|
212
258
|
|
|
259
|
+
class ChargerRefreshManagerNodeTests(TestCase):
|
|
260
|
+
@classmethod
|
|
261
|
+
def setUpTestData(cls):
|
|
262
|
+
local = Node.objects.create(
|
|
263
|
+
hostname="local-node",
|
|
264
|
+
address="127.0.0.1",
|
|
265
|
+
port=8000,
|
|
266
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
267
|
+
current_relation=Node.Relation.SELF,
|
|
268
|
+
)
|
|
269
|
+
Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
|
|
270
|
+
cls.local_node = Node.objects.get(pk=local.pk)
|
|
271
|
+
|
|
272
|
+
def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
|
|
273
|
+
charger = Charger(charger_id="UNSAVED")
|
|
274
|
+
|
|
275
|
+
with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
|
|
276
|
+
result = charger.refresh_manager_node()
|
|
277
|
+
|
|
278
|
+
self.assertEqual(result, self.local_node)
|
|
279
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
280
|
+
|
|
281
|
+
def test_refresh_manager_node_updates_persisted_charger(self):
|
|
282
|
+
remote = Node.objects.create(
|
|
283
|
+
hostname="remote-node",
|
|
284
|
+
address="10.0.0.1",
|
|
285
|
+
port=9000,
|
|
286
|
+
mac_address="11:22:33:44:55:66",
|
|
287
|
+
)
|
|
288
|
+
charger = Charger.objects.create(
|
|
289
|
+
charger_id="PERSISTED",
|
|
290
|
+
manager_node=remote,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
charger.refresh_manager_node(node=self.local_node)
|
|
294
|
+
|
|
295
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
296
|
+
charger.refresh_from_db()
|
|
297
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
298
|
+
|
|
299
|
+
def test_refresh_manager_node_handles_missing_local_node(self):
|
|
300
|
+
remote = Node.objects.create(
|
|
301
|
+
hostname="existing-manager",
|
|
302
|
+
address="10.0.0.2",
|
|
303
|
+
port=9001,
|
|
304
|
+
mac_address="22:33:44:55:66:77",
|
|
305
|
+
)
|
|
306
|
+
charger = Charger(charger_id="NOLOCAL", manager_node=remote)
|
|
307
|
+
|
|
308
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
309
|
+
result = charger.refresh_manager_node()
|
|
310
|
+
|
|
311
|
+
self.assertIsNone(result)
|
|
312
|
+
self.assertEqual(charger.manager_node, remote)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class CPReservationTests(TransactionTestCase):
|
|
316
|
+
def setUp(self):
|
|
317
|
+
self.location = Location.objects.create(name="Reservation Site")
|
|
318
|
+
self.aggregate = Charger.objects.create(charger_id="RSV100", location=self.location)
|
|
319
|
+
self.connector_one = Charger.objects.create(
|
|
320
|
+
charger_id="RSV100", connector_id=1, location=self.location
|
|
321
|
+
)
|
|
322
|
+
self.connector_two = Charger.objects.create(
|
|
323
|
+
charger_id="RSV100", connector_id=2, location=self.location
|
|
324
|
+
)
|
|
325
|
+
self.addCleanup(store.clear_pending_calls, "RSV100")
|
|
326
|
+
|
|
327
|
+
def test_allocates_preferred_connector(self):
|
|
328
|
+
start = timezone.now() + timedelta(hours=1)
|
|
329
|
+
reservation = CPReservation(
|
|
330
|
+
location=self.location,
|
|
331
|
+
start_time=start,
|
|
332
|
+
duration_minutes=90,
|
|
333
|
+
id_tag="TAG001",
|
|
334
|
+
)
|
|
335
|
+
reservation.save()
|
|
336
|
+
self.assertEqual(reservation.connector, self.connector_two)
|
|
337
|
+
|
|
338
|
+
def test_allocation_falls_back_and_blocks_overlaps(self):
|
|
339
|
+
start = timezone.now() + timedelta(hours=1)
|
|
340
|
+
first = CPReservation.objects.create(
|
|
341
|
+
location=self.location,
|
|
342
|
+
start_time=start,
|
|
343
|
+
duration_minutes=60,
|
|
344
|
+
id_tag="TAG002",
|
|
345
|
+
)
|
|
346
|
+
self.assertEqual(first.connector, self.connector_two)
|
|
347
|
+
second = CPReservation(
|
|
348
|
+
location=self.location,
|
|
349
|
+
start_time=start + timedelta(minutes=15),
|
|
350
|
+
duration_minutes=60,
|
|
351
|
+
id_tag="TAG003",
|
|
352
|
+
)
|
|
353
|
+
second.save()
|
|
354
|
+
self.assertEqual(second.connector, self.connector_one)
|
|
355
|
+
third = CPReservation(
|
|
356
|
+
location=self.location,
|
|
357
|
+
start_time=start + timedelta(minutes=30),
|
|
358
|
+
duration_minutes=45,
|
|
359
|
+
id_tag="TAG004",
|
|
360
|
+
)
|
|
361
|
+
with self.assertRaises(ValidationError):
|
|
362
|
+
third.save()
|
|
363
|
+
|
|
364
|
+
def test_send_reservation_request_dispatches_frame(self):
|
|
365
|
+
start = timezone.now() + timedelta(hours=1)
|
|
366
|
+
reservation = CPReservation.objects.create(
|
|
367
|
+
location=self.location,
|
|
368
|
+
start_time=start,
|
|
369
|
+
duration_minutes=30,
|
|
370
|
+
id_tag="TAG005",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
class DummyConnection:
|
|
374
|
+
def __init__(self):
|
|
375
|
+
self.sent: list[str] = []
|
|
376
|
+
|
|
377
|
+
async def send(self, message):
|
|
378
|
+
self.sent.append(message)
|
|
379
|
+
|
|
380
|
+
ws = DummyConnection()
|
|
381
|
+
store.set_connection(
|
|
382
|
+
reservation.connector.charger_id,
|
|
383
|
+
reservation.connector.connector_id,
|
|
384
|
+
ws,
|
|
385
|
+
)
|
|
386
|
+
self.addCleanup(
|
|
387
|
+
store.pop_connection,
|
|
388
|
+
reservation.connector.charger_id,
|
|
389
|
+
reservation.connector.connector_id,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
message_id = reservation.send_reservation_request()
|
|
393
|
+
self.assertTrue(ws.sent)
|
|
394
|
+
frame = json.loads(ws.sent[0])
|
|
395
|
+
self.assertEqual(frame[0], 2)
|
|
396
|
+
self.assertEqual(frame[2], "ReserveNow")
|
|
397
|
+
self.assertEqual(frame[3]["reservationId"], reservation.pk)
|
|
398
|
+
self.assertEqual(frame[3]["connectorId"], reservation.connector.connector_id)
|
|
399
|
+
self.assertEqual(frame[3]["idTag"], "TAG005")
|
|
400
|
+
metadata = store.pending_calls.get(message_id)
|
|
401
|
+
self.assertIsNotNone(metadata)
|
|
402
|
+
self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
|
|
403
|
+
|
|
404
|
+
def test_send_cancel_request_dispatches_frame(self):
|
|
405
|
+
start = timezone.now() + timedelta(hours=1)
|
|
406
|
+
reservation = CPReservation.objects.create(
|
|
407
|
+
location=self.location,
|
|
408
|
+
start_time=start,
|
|
409
|
+
duration_minutes=30,
|
|
410
|
+
id_tag="TAG010",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
class DummyConnection:
|
|
414
|
+
def __init__(self):
|
|
415
|
+
self.sent: list[str] = []
|
|
416
|
+
|
|
417
|
+
async def send(self, message):
|
|
418
|
+
self.sent.append(message)
|
|
419
|
+
|
|
420
|
+
ws = DummyConnection()
|
|
421
|
+
store.set_connection(
|
|
422
|
+
reservation.connector.charger_id,
|
|
423
|
+
reservation.connector.connector_id,
|
|
424
|
+
ws,
|
|
425
|
+
)
|
|
426
|
+
self.addCleanup(
|
|
427
|
+
store.pop_connection,
|
|
428
|
+
reservation.connector.charger_id,
|
|
429
|
+
reservation.connector.connector_id,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
message_id = reservation.send_cancel_request()
|
|
433
|
+
self.assertTrue(ws.sent)
|
|
434
|
+
frame = json.loads(ws.sent[0])
|
|
435
|
+
self.assertEqual(frame[0], 2)
|
|
436
|
+
self.assertEqual(frame[2], "CancelReservation")
|
|
437
|
+
self.assertEqual(frame[3]["reservationId"], reservation.pk)
|
|
438
|
+
metadata = store.pending_calls.get(message_id)
|
|
439
|
+
self.assertIsNotNone(metadata)
|
|
440
|
+
self.assertEqual(metadata.get("reservation_pk"), reservation.pk)
|
|
441
|
+
self.assertEqual(metadata.get("action"), "CancelReservation")
|
|
442
|
+
|
|
443
|
+
def test_call_result_marks_reservation_confirmed(self):
|
|
444
|
+
start = timezone.now() + timedelta(hours=1)
|
|
445
|
+
reservation = CPReservation.objects.create(
|
|
446
|
+
location=self.location,
|
|
447
|
+
start_time=start,
|
|
448
|
+
duration_minutes=45,
|
|
449
|
+
id_tag="TAG006",
|
|
450
|
+
)
|
|
451
|
+
log_key = store.identity_key(
|
|
452
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
453
|
+
)
|
|
454
|
+
message_id = "reserve-success"
|
|
455
|
+
store.register_pending_call(
|
|
456
|
+
message_id,
|
|
457
|
+
{
|
|
458
|
+
"action": "ReserveNow",
|
|
459
|
+
"charger_id": reservation.connector.charger_id,
|
|
460
|
+
"connector_id": reservation.connector.connector_id,
|
|
461
|
+
"log_key": log_key,
|
|
462
|
+
"reservation_pk": reservation.pk,
|
|
463
|
+
},
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
consumer = CSMSConsumer()
|
|
467
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
468
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
469
|
+
consumer.store_key = log_key
|
|
470
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
471
|
+
consumer.charger = reservation.connector
|
|
472
|
+
consumer.aggregate_charger = self.aggregate
|
|
473
|
+
consumer._consumption_task = None
|
|
474
|
+
consumer._consumption_message_uuid = None
|
|
475
|
+
consumer.send = AsyncMock()
|
|
476
|
+
|
|
477
|
+
async_to_sync(consumer._handle_call_result)(
|
|
478
|
+
message_id, {"status": "Accepted"}
|
|
479
|
+
)
|
|
480
|
+
reservation.refresh_from_db()
|
|
481
|
+
self.assertTrue(reservation.evcs_confirmed)
|
|
482
|
+
self.assertEqual(reservation.evcs_status, "Accepted")
|
|
483
|
+
self.assertIsNotNone(reservation.evcs_confirmed_at)
|
|
484
|
+
|
|
485
|
+
def test_call_error_updates_reservation_status(self):
|
|
486
|
+
start = timezone.now() + timedelta(hours=1)
|
|
487
|
+
reservation = CPReservation.objects.create(
|
|
488
|
+
location=self.location,
|
|
489
|
+
start_time=start,
|
|
490
|
+
duration_minutes=45,
|
|
491
|
+
id_tag="TAG007",
|
|
492
|
+
)
|
|
493
|
+
log_key = store.identity_key(
|
|
494
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
495
|
+
)
|
|
496
|
+
message_id = "reserve-error"
|
|
497
|
+
store.register_pending_call(
|
|
498
|
+
message_id,
|
|
499
|
+
{
|
|
500
|
+
"action": "ReserveNow",
|
|
501
|
+
"charger_id": reservation.connector.charger_id,
|
|
502
|
+
"connector_id": reservation.connector.connector_id,
|
|
503
|
+
"log_key": log_key,
|
|
504
|
+
"reservation_pk": reservation.pk,
|
|
505
|
+
},
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
consumer = CSMSConsumer()
|
|
509
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
510
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
511
|
+
consumer.store_key = log_key
|
|
512
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
513
|
+
consumer.charger = reservation.connector
|
|
514
|
+
consumer.aggregate_charger = self.aggregate
|
|
515
|
+
consumer._consumption_task = None
|
|
516
|
+
consumer._consumption_message_uuid = None
|
|
517
|
+
consumer.send = AsyncMock()
|
|
518
|
+
|
|
519
|
+
async_to_sync(consumer._handle_call_error)(
|
|
520
|
+
message_id,
|
|
521
|
+
"Rejected",
|
|
522
|
+
"Charger unavailable",
|
|
523
|
+
{"reason": "maintenance"},
|
|
524
|
+
)
|
|
525
|
+
reservation.refresh_from_db()
|
|
526
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
527
|
+
self.assertEqual(reservation.evcs_status, "")
|
|
528
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
529
|
+
self.assertIn("Rejected", reservation.evcs_error or "")
|
|
530
|
+
|
|
531
|
+
def test_cancel_reservation_result_updates_status(self):
|
|
532
|
+
start = timezone.now() + timedelta(hours=1)
|
|
533
|
+
reservation = CPReservation.objects.create(
|
|
534
|
+
location=self.location,
|
|
535
|
+
start_time=start,
|
|
536
|
+
duration_minutes=45,
|
|
537
|
+
id_tag="TAG008",
|
|
538
|
+
)
|
|
539
|
+
log_key = store.identity_key(
|
|
540
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
541
|
+
)
|
|
542
|
+
message_id = "cancel-success"
|
|
543
|
+
store.register_pending_call(
|
|
544
|
+
message_id,
|
|
545
|
+
{
|
|
546
|
+
"action": "CancelReservation",
|
|
547
|
+
"charger_id": reservation.connector.charger_id,
|
|
548
|
+
"connector_id": reservation.connector.connector_id,
|
|
549
|
+
"log_key": log_key,
|
|
550
|
+
"reservation_pk": reservation.pk,
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
consumer = CSMSConsumer()
|
|
555
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
556
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
557
|
+
consumer.store_key = log_key
|
|
558
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
559
|
+
consumer.charger = reservation.connector
|
|
560
|
+
consumer.aggregate_charger = self.aggregate
|
|
561
|
+
consumer._consumption_task = None
|
|
562
|
+
consumer._consumption_message_uuid = None
|
|
563
|
+
consumer.send = AsyncMock()
|
|
564
|
+
|
|
565
|
+
async_to_sync(consumer._handle_call_result)(
|
|
566
|
+
message_id, {"status": "Accepted"}
|
|
567
|
+
)
|
|
568
|
+
reservation.refresh_from_db()
|
|
569
|
+
self.assertEqual(reservation.evcs_status, "Accepted")
|
|
570
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
571
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
572
|
+
self.assertEqual(reservation.evcs_error, "")
|
|
573
|
+
|
|
574
|
+
def test_cancel_reservation_error_updates_status(self):
|
|
575
|
+
start = timezone.now() + timedelta(hours=1)
|
|
576
|
+
reservation = CPReservation.objects.create(
|
|
577
|
+
location=self.location,
|
|
578
|
+
start_time=start,
|
|
579
|
+
duration_minutes=45,
|
|
580
|
+
id_tag="TAG009",
|
|
581
|
+
)
|
|
582
|
+
log_key = store.identity_key(
|
|
583
|
+
reservation.connector.charger_id, reservation.connector.connector_id
|
|
584
|
+
)
|
|
585
|
+
message_id = "cancel-error"
|
|
586
|
+
store.register_pending_call(
|
|
587
|
+
message_id,
|
|
588
|
+
{
|
|
589
|
+
"action": "CancelReservation",
|
|
590
|
+
"charger_id": reservation.connector.charger_id,
|
|
591
|
+
"connector_id": reservation.connector.connector_id,
|
|
592
|
+
"log_key": log_key,
|
|
593
|
+
"reservation_pk": reservation.pk,
|
|
594
|
+
},
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
consumer = CSMSConsumer()
|
|
598
|
+
consumer.scope = {"headers": [], "client": ("127.0.0.1", 1234)}
|
|
599
|
+
consumer.charger_id = reservation.connector.charger_id
|
|
600
|
+
consumer.store_key = log_key
|
|
601
|
+
consumer.connector_value = reservation.connector.connector_id
|
|
602
|
+
consumer.charger = reservation.connector
|
|
603
|
+
consumer.aggregate_charger = self.aggregate
|
|
604
|
+
consumer._consumption_task = None
|
|
605
|
+
consumer._consumption_message_uuid = None
|
|
606
|
+
consumer.send = AsyncMock()
|
|
607
|
+
|
|
608
|
+
async_to_sync(consumer._handle_call_error)(
|
|
609
|
+
message_id,
|
|
610
|
+
"Rejected",
|
|
611
|
+
"Connector busy",
|
|
612
|
+
{"reason": "occupied"},
|
|
613
|
+
)
|
|
614
|
+
reservation.refresh_from_db()
|
|
615
|
+
self.assertEqual(reservation.evcs_status, "")
|
|
616
|
+
self.assertFalse(reservation.evcs_confirmed)
|
|
617
|
+
self.assertIsNone(reservation.evcs_confirmed_at)
|
|
618
|
+
self.assertIn("Rejected", reservation.evcs_error or "")
|
|
619
|
+
|
|
620
|
+
|
|
213
621
|
class ChargerUrlFallbackTests(TestCase):
|
|
214
622
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
215
623
|
def test_reference_created_when_site_missing(self):
|
|
@@ -259,6 +667,33 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
259
667
|
await asyncio.sleep(delay)
|
|
260
668
|
raise
|
|
261
669
|
|
|
670
|
+
def _create_firmware_deployment(self, charger_id: str) -> int:
|
|
671
|
+
try:
|
|
672
|
+
charger = Charger.objects.get(charger_id=charger_id, connector_id=None)
|
|
673
|
+
except Charger.DoesNotExist:
|
|
674
|
+
charger = Charger.objects.create(charger_id=charger_id, connector_id=None)
|
|
675
|
+
firmware = CPFirmware.objects.create(
|
|
676
|
+
name=f"{charger_id} firmware",
|
|
677
|
+
filename=f"{charger_id}.bin",
|
|
678
|
+
payload_binary=b"firmware",
|
|
679
|
+
content_type="application/octet-stream",
|
|
680
|
+
source=CPFirmware.Source.DOWNLOAD,
|
|
681
|
+
is_user_data=True,
|
|
682
|
+
)
|
|
683
|
+
deployment = CPFirmwareDeployment.objects.create(
|
|
684
|
+
firmware=firmware,
|
|
685
|
+
charger=charger,
|
|
686
|
+
node=charger.node_origin,
|
|
687
|
+
ocpp_message_id=f"deploy-{charger_id}",
|
|
688
|
+
status="Pending",
|
|
689
|
+
status_info="",
|
|
690
|
+
status_timestamp=timezone.now(),
|
|
691
|
+
retrieve_date=timezone.now(),
|
|
692
|
+
request_payload={},
|
|
693
|
+
is_user_data=True,
|
|
694
|
+
)
|
|
695
|
+
return deployment.pk
|
|
696
|
+
|
|
262
697
|
async def _send_status_notification(self, serial: str, payload: dict):
|
|
263
698
|
communicator = WebsocketCommunicator(application, f"/{serial}/")
|
|
264
699
|
connected, _ = await communicator.connect()
|
|
@@ -439,7 +874,9 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
439
874
|
store.logs.setdefault("charger", {})
|
|
440
875
|
store.logs["charger"].clear()
|
|
441
876
|
for key, entries in original_logs.items():
|
|
442
|
-
store.logs["charger"][key] =
|
|
877
|
+
store.logs["charger"][key] = deque(
|
|
878
|
+
entries, maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
|
|
879
|
+
)
|
|
443
880
|
store.log_names.setdefault("charger", {})
|
|
444
881
|
store.log_names["charger"].clear()
|
|
445
882
|
store.log_names["charger"].update(original_log_names)
|
|
@@ -704,6 +1141,151 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
704
1141
|
self.assertEqual(charger.availability_requested_state, "Inoperative")
|
|
705
1142
|
await communicator.disconnect()
|
|
706
1143
|
|
|
1144
|
+
async def test_clear_cache_result_updates_local_auth_version(self):
|
|
1145
|
+
store.pending_calls.clear()
|
|
1146
|
+
log_key = store.identity_key("CLEARCACHE", None)
|
|
1147
|
+
store.clear_log(log_key, log_type="charger")
|
|
1148
|
+
communicator = WebsocketCommunicator(application, "/CLEARCACHE/")
|
|
1149
|
+
connected, _ = await communicator.connect()
|
|
1150
|
+
self.assertTrue(connected)
|
|
1151
|
+
|
|
1152
|
+
await communicator.send_json_to(
|
|
1153
|
+
[
|
|
1154
|
+
2,
|
|
1155
|
+
"boot",
|
|
1156
|
+
"BootNotification",
|
|
1157
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
1158
|
+
]
|
|
1159
|
+
)
|
|
1160
|
+
await communicator.receive_json_from()
|
|
1161
|
+
|
|
1162
|
+
message_id = "cc-accepted"
|
|
1163
|
+
requested_at = timezone.now()
|
|
1164
|
+
store.register_pending_call(
|
|
1165
|
+
message_id,
|
|
1166
|
+
{
|
|
1167
|
+
"action": "ClearCache",
|
|
1168
|
+
"charger_id": "CLEARCACHE",
|
|
1169
|
+
"connector_id": None,
|
|
1170
|
+
"log_key": log_key,
|
|
1171
|
+
"requested_at": requested_at,
|
|
1172
|
+
},
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
mock_update = AsyncMock()
|
|
1176
|
+
with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
|
|
1177
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
1178
|
+
await asyncio.sleep(0.05)
|
|
1179
|
+
|
|
1180
|
+
mock_update.assert_awaited_with(0)
|
|
1181
|
+
result = store.wait_for_pending_call(message_id, timeout=0.1)
|
|
1182
|
+
self.assertIsNotNone(result)
|
|
1183
|
+
payload = result.get("payload") or {}
|
|
1184
|
+
self.assertEqual(payload.get("status"), "Accepted")
|
|
1185
|
+
log_entries = store.logs["charger"].get(log_key, [])
|
|
1186
|
+
self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
|
|
1187
|
+
|
|
1188
|
+
store.clear_log(log_key, log_type="charger")
|
|
1189
|
+
await communicator.disconnect()
|
|
1190
|
+
|
|
1191
|
+
async def test_clear_cache_rejection_updates_timestamp(self):
|
|
1192
|
+
store.pending_calls.clear()
|
|
1193
|
+
log_key = store.identity_key("CLEARCACHE-REJ", None)
|
|
1194
|
+
store.clear_log(log_key, log_type="charger")
|
|
1195
|
+
communicator = WebsocketCommunicator(application, "/CLEARCACHE-REJ/")
|
|
1196
|
+
connected, _ = await communicator.connect()
|
|
1197
|
+
self.assertTrue(connected)
|
|
1198
|
+
|
|
1199
|
+
await communicator.send_json_to(
|
|
1200
|
+
[
|
|
1201
|
+
2,
|
|
1202
|
+
"boot",
|
|
1203
|
+
"BootNotification",
|
|
1204
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
1205
|
+
]
|
|
1206
|
+
)
|
|
1207
|
+
await communicator.receive_json_from()
|
|
1208
|
+
|
|
1209
|
+
message_id = "cc-rejected"
|
|
1210
|
+
store.register_pending_call(
|
|
1211
|
+
message_id,
|
|
1212
|
+
{
|
|
1213
|
+
"action": "ClearCache",
|
|
1214
|
+
"charger_id": "CLEARCACHE-REJ",
|
|
1215
|
+
"connector_id": None,
|
|
1216
|
+
"log_key": log_key,
|
|
1217
|
+
},
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
mock_update = AsyncMock()
|
|
1221
|
+
with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
|
|
1222
|
+
await communicator.send_json_to([3, message_id, {"status": "Rejected"}])
|
|
1223
|
+
await asyncio.sleep(0.05)
|
|
1224
|
+
|
|
1225
|
+
mock_update.assert_awaited_with(None)
|
|
1226
|
+
result = store.wait_for_pending_call(message_id, timeout=0.1)
|
|
1227
|
+
self.assertIsNotNone(result)
|
|
1228
|
+
payload = result.get("payload") or {}
|
|
1229
|
+
self.assertEqual(payload.get("status"), "Rejected")
|
|
1230
|
+
log_entries = store.logs["charger"].get(log_key, [])
|
|
1231
|
+
self.assertTrue(any("ClearCache result" in entry for entry in log_entries))
|
|
1232
|
+
|
|
1233
|
+
store.clear_log(log_key, log_type="charger")
|
|
1234
|
+
await communicator.disconnect()
|
|
1235
|
+
|
|
1236
|
+
async def test_clear_cache_error_records_failure(self):
|
|
1237
|
+
store.pending_calls.clear()
|
|
1238
|
+
log_key = store.identity_key("CLEARCACHE-ERR", None)
|
|
1239
|
+
store.clear_log(log_key, log_type="charger")
|
|
1240
|
+
communicator = WebsocketCommunicator(application, "/CLEARCACHE-ERR/")
|
|
1241
|
+
connected, _ = await communicator.connect()
|
|
1242
|
+
self.assertTrue(connected)
|
|
1243
|
+
|
|
1244
|
+
await communicator.send_json_to(
|
|
1245
|
+
[
|
|
1246
|
+
2,
|
|
1247
|
+
"boot",
|
|
1248
|
+
"BootNotification",
|
|
1249
|
+
{"chargePointVendor": "Test", "chargePointModel": "Model"},
|
|
1250
|
+
]
|
|
1251
|
+
)
|
|
1252
|
+
await communicator.receive_json_from()
|
|
1253
|
+
|
|
1254
|
+
message_id = "cc-error"
|
|
1255
|
+
store.register_pending_call(
|
|
1256
|
+
message_id,
|
|
1257
|
+
{
|
|
1258
|
+
"action": "ClearCache",
|
|
1259
|
+
"charger_id": "CLEARCACHE-ERR",
|
|
1260
|
+
"connector_id": None,
|
|
1261
|
+
"log_key": log_key,
|
|
1262
|
+
},
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
mock_update = AsyncMock()
|
|
1266
|
+
with patch.object(CSMSConsumer, "_update_local_authorization_state", new=mock_update):
|
|
1267
|
+
await communicator.send_json_to(
|
|
1268
|
+
[
|
|
1269
|
+
4,
|
|
1270
|
+
message_id,
|
|
1271
|
+
"InternalError",
|
|
1272
|
+
"Failed",
|
|
1273
|
+
{"detail": "boom"},
|
|
1274
|
+
]
|
|
1275
|
+
)
|
|
1276
|
+
await asyncio.sleep(0.05)
|
|
1277
|
+
|
|
1278
|
+
mock_update.assert_awaited_with(None)
|
|
1279
|
+
result = store.wait_for_pending_call(message_id, timeout=0.1)
|
|
1280
|
+
self.assertIsNotNone(result)
|
|
1281
|
+
self.assertFalse(result.get("success"))
|
|
1282
|
+
self.assertEqual(result.get("error_code"), "InternalError")
|
|
1283
|
+
log_entries = store.logs["charger"].get(log_key, [])
|
|
1284
|
+
self.assertTrue(any("ClearCache error" in entry for entry in log_entries))
|
|
1285
|
+
|
|
1286
|
+
store.clear_log(log_key, log_type="charger")
|
|
1287
|
+
await communicator.disconnect()
|
|
1288
|
+
|
|
707
1289
|
async def test_get_configuration_result_logged(self):
|
|
708
1290
|
store.pending_calls.clear()
|
|
709
1291
|
pending_key = store.pending_key("CFGRES")
|
|
@@ -714,6 +1296,13 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
714
1296
|
connected, _ = await communicator.connect()
|
|
715
1297
|
self.assertTrue(connected)
|
|
716
1298
|
|
|
1299
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
1300
|
+
charger_id="CFGRES", connector_id=1
|
|
1301
|
+
)
|
|
1302
|
+
await database_sync_to_async(Charger.objects.get_or_create)(
|
|
1303
|
+
charger_id="CFGRES", connector_id=2
|
|
1304
|
+
)
|
|
1305
|
+
|
|
717
1306
|
message_id = "cfg-result"
|
|
718
1307
|
payload = {
|
|
719
1308
|
"configurationKey": [
|
|
@@ -744,6 +1333,56 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
744
1333
|
)
|
|
745
1334
|
self.assertNotIn(message_id, store.pending_calls)
|
|
746
1335
|
|
|
1336
|
+
configuration = await database_sync_to_async(
|
|
1337
|
+
lambda: ChargerConfiguration.objects.order_by("-created_at").first()
|
|
1338
|
+
)()
|
|
1339
|
+
self.assertIsNotNone(configuration)
|
|
1340
|
+
self.assertEqual(configuration.charger_identifier, "CFGRES")
|
|
1341
|
+
self.assertIsNotNone(configuration.evcs_snapshot_at)
|
|
1342
|
+
self.assertEqual(
|
|
1343
|
+
configuration.configuration_keys,
|
|
1344
|
+
[
|
|
1345
|
+
{
|
|
1346
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
1347
|
+
"value": "false",
|
|
1348
|
+
"readonly": True,
|
|
1349
|
+
}
|
|
1350
|
+
],
|
|
1351
|
+
)
|
|
1352
|
+
key_rows = await database_sync_to_async(
|
|
1353
|
+
lambda: [
|
|
1354
|
+
{
|
|
1355
|
+
"key": item.key,
|
|
1356
|
+
"value": item.value,
|
|
1357
|
+
"readonly": item.readonly,
|
|
1358
|
+
"has_value": item.has_value,
|
|
1359
|
+
}
|
|
1360
|
+
for item in ConfigurationKey.objects.filter(
|
|
1361
|
+
configuration=configuration
|
|
1362
|
+
).order_by("position", "id")
|
|
1363
|
+
]
|
|
1364
|
+
)()
|
|
1365
|
+
self.assertEqual(
|
|
1366
|
+
key_rows,
|
|
1367
|
+
[
|
|
1368
|
+
{
|
|
1369
|
+
"key": "AllowOfflineTxForUnknownId",
|
|
1370
|
+
"value": "false",
|
|
1371
|
+
"readonly": True,
|
|
1372
|
+
"has_value": True,
|
|
1373
|
+
}
|
|
1374
|
+
],
|
|
1375
|
+
)
|
|
1376
|
+
self.assertEqual(configuration.unknown_keys, [])
|
|
1377
|
+
config_ids = await database_sync_to_async(
|
|
1378
|
+
lambda: set(
|
|
1379
|
+
Charger.objects.filter(charger_id="CFGRES").values_list(
|
|
1380
|
+
"configuration_id", flat=True
|
|
1381
|
+
)
|
|
1382
|
+
)
|
|
1383
|
+
)()
|
|
1384
|
+
self.assertEqual(config_ids, {configuration.pk})
|
|
1385
|
+
|
|
747
1386
|
await communicator.disconnect()
|
|
748
1387
|
store.clear_log(log_key, log_type="charger")
|
|
749
1388
|
store.clear_log(pending_key, log_type="charger")
|
|
@@ -956,91 +1595,179 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
956
1595
|
await communicator.disconnect()
|
|
957
1596
|
|
|
958
1597
|
async def test_firmware_status_notification_updates_database_and_views(self):
|
|
1598
|
+
store.ip_connections.clear()
|
|
1599
|
+
limit = store.MAX_CONNECTIONS_PER_IP
|
|
1600
|
+
store.MAX_CONNECTIONS_PER_IP = 10
|
|
959
1601
|
communicator = WebsocketCommunicator(application, "/FWSTAT/")
|
|
960
|
-
|
|
961
|
-
|
|
1602
|
+
try:
|
|
1603
|
+
connected, detail = await communicator.connect()
|
|
1604
|
+
self.assertTrue(connected, detail)
|
|
1605
|
+
|
|
1606
|
+
deployment_pk = await database_sync_to_async(
|
|
1607
|
+
self._create_firmware_deployment
|
|
1608
|
+
)("FWSTAT")
|
|
1609
|
+
ts = timezone.now().replace(microsecond=0)
|
|
1610
|
+
payload = {
|
|
1611
|
+
"status": "Installing",
|
|
1612
|
+
"statusInfo": "Applying patch",
|
|
1613
|
+
"timestamp": ts.isoformat(),
|
|
1614
|
+
}
|
|
962
1615
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
"
|
|
968
|
-
}
|
|
1616
|
+
await communicator.send_json_to(
|
|
1617
|
+
[2, "1", "FirmwareStatusNotification", payload]
|
|
1618
|
+
)
|
|
1619
|
+
response = await communicator.receive_json_from()
|
|
1620
|
+
self.assertEqual(response, [3, "1", {}])
|
|
969
1621
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1622
|
+
def _fetch_status():
|
|
1623
|
+
charger = Charger.objects.get(
|
|
1624
|
+
charger_id="FWSTAT", connector_id=None
|
|
1625
|
+
)
|
|
1626
|
+
return (
|
|
1627
|
+
charger.firmware_status,
|
|
1628
|
+
charger.firmware_status_info,
|
|
1629
|
+
charger.firmware_timestamp,
|
|
1630
|
+
)
|
|
975
1631
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1632
|
+
status, info, recorded_ts = await database_sync_to_async(
|
|
1633
|
+
_fetch_status
|
|
1634
|
+
)()
|
|
1635
|
+
self.assertEqual(status, "Installing")
|
|
1636
|
+
self.assertEqual(info, "Applying patch")
|
|
1637
|
+
self.assertIsNotNone(recorded_ts)
|
|
1638
|
+
self.assertEqual(recorded_ts.replace(microsecond=0), ts)
|
|
1639
|
+
|
|
1640
|
+
def _fetch_deployment():
|
|
1641
|
+
return CPFirmwareDeployment.objects.get(pk=deployment_pk)
|
|
1642
|
+
|
|
1643
|
+
deployment = await database_sync_to_async(_fetch_deployment)()
|
|
1644
|
+
self.assertEqual(deployment.status, "Installing")
|
|
1645
|
+
self.assertEqual(deployment.status_info, "Applying patch")
|
|
1646
|
+
self.assertIsNotNone(deployment.status_timestamp)
|
|
1647
|
+
self.assertEqual(
|
|
1648
|
+
deployment.status_timestamp.replace(microsecond=0), ts
|
|
982
1649
|
)
|
|
983
1650
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1651
|
+
log_entries = store.get_logs(
|
|
1652
|
+
store.identity_key("FWSTAT", None), log_type="charger"
|
|
1653
|
+
)
|
|
1654
|
+
self.assertTrue(
|
|
1655
|
+
any("FirmwareStatusNotification" in entry for entry in log_entries)
|
|
1656
|
+
)
|
|
989
1657
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1658
|
+
def _fetch_views():
|
|
1659
|
+
User = get_user_model()
|
|
1660
|
+
user = User.objects.create_user(username="fwstatus", password="pw")
|
|
1661
|
+
client = Client()
|
|
1662
|
+
client.force_login(user)
|
|
1663
|
+
detail = client.get(reverse("charger-detail", args=["FWSTAT"]))
|
|
1664
|
+
status_page = client.get(reverse("charger-status", args=["FWSTAT"]))
|
|
1665
|
+
list_response = client.get(reverse("charger-list"))
|
|
1666
|
+
return (
|
|
1667
|
+
detail.status_code,
|
|
1668
|
+
json.loads(detail.content.decode()),
|
|
1669
|
+
status_page.status_code,
|
|
1670
|
+
status_page.content.decode(),
|
|
1671
|
+
list_response.status_code,
|
|
1672
|
+
json.loads(list_response.content.decode()),
|
|
1673
|
+
)
|
|
994
1674
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1675
|
+
(
|
|
1676
|
+
detail_code,
|
|
1677
|
+
detail_payload,
|
|
1678
|
+
status_code,
|
|
1679
|
+
html,
|
|
1680
|
+
list_code,
|
|
1681
|
+
list_payload,
|
|
1682
|
+
) = await database_sync_to_async(_fetch_views)()
|
|
1683
|
+
self.assertEqual(detail_code, 200)
|
|
1684
|
+
self.assertEqual(status_code, 200)
|
|
1685
|
+
self.assertEqual(list_code, 200)
|
|
1686
|
+
self.assertEqual(detail_payload["firmwareStatus"], "Installing")
|
|
1687
|
+
self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
|
|
1688
|
+
self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
|
|
1689
|
+
self.assertNotIn('id="firmware-status"', html)
|
|
1690
|
+
self.assertNotIn('id="firmware-status-info"', html)
|
|
1691
|
+
self.assertNotIn('id="firmware-timestamp"', html)
|
|
1692
|
+
|
|
1693
|
+
matching = [
|
|
1694
|
+
item
|
|
1695
|
+
for item in list_payload.get("chargers", [])
|
|
1696
|
+
if item["charger_id"] == "FWSTAT"
|
|
1697
|
+
and item["connector_id"] is None
|
|
1698
|
+
]
|
|
1699
|
+
self.assertTrue(matching)
|
|
1700
|
+
self.assertEqual(matching[0]["firmwareStatus"], "Installing")
|
|
1701
|
+
self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
|
|
1702
|
+
list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
|
|
1703
|
+
self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
|
|
1704
|
+
|
|
1705
|
+
store.clear_log(
|
|
1706
|
+
store.identity_key("FWSTAT", None), log_type="charger"
|
|
1010
1707
|
)
|
|
1708
|
+
finally:
|
|
1709
|
+
with suppress(Exception):
|
|
1710
|
+
await communicator.disconnect()
|
|
1711
|
+
store.MAX_CONNECTIONS_PER_IP = limit
|
|
1712
|
+
|
|
1713
|
+
async def test_update_firmware_call_result_updates_deployment(self):
|
|
1714
|
+
store.ip_connections.clear()
|
|
1715
|
+
limit = store.MAX_CONNECTIONS_PER_IP
|
|
1716
|
+
store.MAX_CONNECTIONS_PER_IP = 10
|
|
1717
|
+
charger = await database_sync_to_async(Charger.objects.create)(
|
|
1718
|
+
charger_id="UPFW", connector_id=None
|
|
1719
|
+
)
|
|
1720
|
+
firmware = await database_sync_to_async(CPFirmware.objects.create)(
|
|
1721
|
+
name="Update firmware",
|
|
1722
|
+
filename="update.bin",
|
|
1723
|
+
payload_binary=b"bin",
|
|
1724
|
+
content_type="application/octet-stream",
|
|
1725
|
+
source=CPFirmware.Source.UPLOAD,
|
|
1726
|
+
is_user_data=True,
|
|
1727
|
+
)
|
|
1728
|
+
deployment = await database_sync_to_async(CPFirmwareDeployment.objects.create)(
|
|
1729
|
+
firmware=firmware,
|
|
1730
|
+
charger=charger,
|
|
1731
|
+
node=charger.node_origin,
|
|
1732
|
+
ocpp_message_id="upfw-msg",
|
|
1733
|
+
status="Pending",
|
|
1734
|
+
status_info="",
|
|
1735
|
+
status_timestamp=timezone.now(),
|
|
1736
|
+
retrieve_date=timezone.now(),
|
|
1737
|
+
request_payload={},
|
|
1738
|
+
is_user_data=True,
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
message_id = "firmware-update"
|
|
1742
|
+
store.register_pending_call(
|
|
1743
|
+
message_id,
|
|
1744
|
+
{
|
|
1745
|
+
"action": "UpdateFirmware",
|
|
1746
|
+
"charger_id": "UPFW",
|
|
1747
|
+
"connector_id": None,
|
|
1748
|
+
"deployment_pk": deployment.pk,
|
|
1749
|
+
},
|
|
1750
|
+
)
|
|
1751
|
+
|
|
1752
|
+
communicator = WebsocketCommunicator(application, "/UPFW/")
|
|
1753
|
+
try:
|
|
1754
|
+
connected, detail = await communicator.connect()
|
|
1755
|
+
self.assertTrue(connected, detail)
|
|
1011
1756
|
|
|
1012
|
-
|
|
1013
|
-
detail_code,
|
|
1014
|
-
detail_payload,
|
|
1015
|
-
status_code,
|
|
1016
|
-
html,
|
|
1017
|
-
list_code,
|
|
1018
|
-
list_payload,
|
|
1019
|
-
) = await database_sync_to_async(_fetch_views)()
|
|
1020
|
-
self.assertEqual(detail_code, 200)
|
|
1021
|
-
self.assertEqual(status_code, 200)
|
|
1022
|
-
self.assertEqual(list_code, 200)
|
|
1023
|
-
self.assertEqual(detail_payload["firmwareStatus"], "Installing")
|
|
1024
|
-
self.assertEqual(detail_payload["firmwareStatusInfo"], "Applying patch")
|
|
1025
|
-
self.assertEqual(detail_payload["firmwareTimestamp"], ts.isoformat())
|
|
1026
|
-
self.assertNotIn('id="firmware-status"', html)
|
|
1027
|
-
self.assertNotIn('id="firmware-status-info"', html)
|
|
1028
|
-
self.assertNotIn('id="firmware-timestamp"', html)
|
|
1029
|
-
|
|
1030
|
-
matching = [
|
|
1031
|
-
item
|
|
1032
|
-
for item in list_payload.get("chargers", [])
|
|
1033
|
-
if item["charger_id"] == "FWSTAT" and item["connector_id"] is None
|
|
1034
|
-
]
|
|
1035
|
-
self.assertTrue(matching)
|
|
1036
|
-
self.assertEqual(matching[0]["firmwareStatus"], "Installing")
|
|
1037
|
-
self.assertEqual(matching[0]["firmwareStatusInfo"], "Applying patch")
|
|
1038
|
-
list_ts = datetime.fromisoformat(matching[0]["firmwareTimestamp"])
|
|
1039
|
-
self.assertAlmostEqual(list_ts.timestamp(), ts.timestamp(), places=3)
|
|
1757
|
+
await communicator.send_json_to([3, message_id, {"status": "Accepted"}])
|
|
1040
1758
|
|
|
1041
|
-
|
|
1759
|
+
await asyncio.sleep(0.05)
|
|
1042
1760
|
|
|
1043
|
-
|
|
1761
|
+
updated = await database_sync_to_async(CPFirmwareDeployment.objects.get)(
|
|
1762
|
+
pk=deployment.pk
|
|
1763
|
+
)
|
|
1764
|
+
self.assertEqual(updated.status, "Accepted")
|
|
1765
|
+
self.assertIsNotNone(updated.status_timestamp)
|
|
1766
|
+
self.assertFalse(updated.completed_at)
|
|
1767
|
+
finally:
|
|
1768
|
+
with suppress(Exception):
|
|
1769
|
+
await communicator.disconnect()
|
|
1770
|
+
store.MAX_CONNECTIONS_PER_IP = limit
|
|
1044
1771
|
|
|
1045
1772
|
async def test_firmware_status_notification_updates_connector_and_aggregate(
|
|
1046
1773
|
self,
|
|
@@ -1109,7 +1836,7 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1109
1836
|
|
|
1110
1837
|
await communicator.disconnect()
|
|
1111
1838
|
|
|
1112
|
-
async def
|
|
1839
|
+
async def test_vid_populated_from_vin(self):
|
|
1113
1840
|
await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
|
|
1114
1841
|
communicator = WebsocketCommunicator(application, "/VINREC/")
|
|
1115
1842
|
connected, _ = await communicator.connect()
|
|
@@ -1124,7 +1851,29 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1124
1851
|
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1125
1852
|
pk=tx_id, charger__charger_id="VINREC"
|
|
1126
1853
|
)
|
|
1127
|
-
self.assertEqual(tx.
|
|
1854
|
+
self.assertEqual(tx.vid, "WP0ZZZ11111111111")
|
|
1855
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
|
|
1856
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
1857
|
+
|
|
1858
|
+
await communicator.disconnect()
|
|
1859
|
+
|
|
1860
|
+
async def test_vid_recorded(self):
|
|
1861
|
+
await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
|
|
1862
|
+
communicator = WebsocketCommunicator(application, "/VIDREC/")
|
|
1863
|
+
connected, _ = await communicator.connect()
|
|
1864
|
+
self.assertTrue(connected)
|
|
1865
|
+
|
|
1866
|
+
await communicator.send_json_to(
|
|
1867
|
+
[2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
|
|
1868
|
+
)
|
|
1869
|
+
response = await communicator.receive_json_from()
|
|
1870
|
+
tx_id = response[2]["transactionId"]
|
|
1871
|
+
|
|
1872
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
1873
|
+
pk=tx_id, charger__charger_id="VIDREC"
|
|
1874
|
+
)
|
|
1875
|
+
self.assertEqual(tx.vid, "VID123456")
|
|
1876
|
+
self.assertEqual(tx.rfid, "")
|
|
1128
1877
|
|
|
1129
1878
|
await communicator.disconnect()
|
|
1130
1879
|
|
|
@@ -1613,6 +2362,35 @@ class CSMSConsumerTests(TransactionTestCase):
|
|
|
1613
2362
|
data = json.loads(files[0].read_text())
|
|
1614
2363
|
self.assertTrue(any("StartTransaction" in m["message"] for m in data))
|
|
1615
2364
|
|
|
2365
|
+
def test_session_log_buffer_bounded(self):
|
|
2366
|
+
cid = "BUFFER-LIMIT"
|
|
2367
|
+
session_dir = Path("logs") / "sessions" / cid
|
|
2368
|
+
if session_dir.exists():
|
|
2369
|
+
for file_path in session_dir.glob("*.json"):
|
|
2370
|
+
file_path.unlink()
|
|
2371
|
+
|
|
2372
|
+
tx_id = 999
|
|
2373
|
+
store.start_session_log(cid, tx_id)
|
|
2374
|
+
self.addCleanup(lambda: store.history.pop(cid, None))
|
|
2375
|
+
|
|
2376
|
+
try:
|
|
2377
|
+
metadata = store.history[cid]
|
|
2378
|
+
path = metadata["path"]
|
|
2379
|
+
message_count = store.SESSION_LOG_BUFFER_LIMIT * 3 + 5
|
|
2380
|
+
for idx in range(message_count):
|
|
2381
|
+
store.add_session_message(cid, f"message {idx}")
|
|
2382
|
+
buffer = metadata["buffer"]
|
|
2383
|
+
self.assertLessEqual(len(buffer), store.SESSION_LOG_BUFFER_LIMIT)
|
|
2384
|
+
finally:
|
|
2385
|
+
store.end_session_log(cid)
|
|
2386
|
+
|
|
2387
|
+
self.assertTrue(path.exists())
|
|
2388
|
+
try:
|
|
2389
|
+
payload = json.loads(path.read_text())
|
|
2390
|
+
self.assertEqual(len(payload), message_count)
|
|
2391
|
+
finally:
|
|
2392
|
+
path.unlink(missing_ok=True)
|
|
2393
|
+
|
|
1616
2394
|
async def test_second_connection_closes_first(self):
|
|
1617
2395
|
communicator1 = WebsocketCommunicator(application, "/DUPLICATE/")
|
|
1618
2396
|
connected, _ = await communicator1.connect()
|
|
@@ -1852,6 +2630,16 @@ class ChargerLandingTests(TestCase):
|
|
|
1852
2630
|
status_url = reverse("charger-status-connector", args=["PAGE1", "all"])
|
|
1853
2631
|
self.assertContains(response, status_url)
|
|
1854
2632
|
|
|
2633
|
+
def test_charger_page_respects_language_configuration(self):
|
|
2634
|
+
charger = Charger.objects.create(charger_id="PAGE-DE", language="de")
|
|
2635
|
+
|
|
2636
|
+
response = self.client.get(reverse("charger-page", args=["PAGE-DE"]))
|
|
2637
|
+
|
|
2638
|
+
self.assertEqual(response.status_code, 200)
|
|
2639
|
+
self.assertEqual(response.context["LANGUAGE_CODE"], "de")
|
|
2640
|
+
self.assertContains(response, 'lang="de"')
|
|
2641
|
+
self.assertContains(response, 'data-preferred-language="de"')
|
|
2642
|
+
|
|
1855
2643
|
def test_status_page_renders(self):
|
|
1856
2644
|
charger = Charger.objects.create(charger_id="PAGE2")
|
|
1857
2645
|
resp = self.client.get(reverse("charger-status", args=["PAGE2"]))
|
|
@@ -1931,6 +2719,27 @@ class ChargerLandingTests(TestCase):
|
|
|
1931
2719
|
finally:
|
|
1932
2720
|
store.transactions.pop(key, None)
|
|
1933
2721
|
|
|
2722
|
+
def test_public_page_shows_available_when_status_stale(self):
|
|
2723
|
+
charger = Charger.objects.create(
|
|
2724
|
+
charger_id="STALEPUB",
|
|
2725
|
+
last_status="Charging",
|
|
2726
|
+
)
|
|
2727
|
+
response = self.client.get(reverse("charger-page", args=["STALEPUB"]))
|
|
2728
|
+
self.assertEqual(response.status_code, 200)
|
|
2729
|
+
self.assertContains(
|
|
2730
|
+
response,
|
|
2731
|
+
'style="background-color: #0d6efd; color: #fff;">Available</span>',
|
|
2732
|
+
)
|
|
2733
|
+
|
|
2734
|
+
def test_admin_status_shows_available_when_status_stale(self):
|
|
2735
|
+
charger = Charger.objects.create(
|
|
2736
|
+
charger_id="STALEADM",
|
|
2737
|
+
last_status="Charging",
|
|
2738
|
+
)
|
|
2739
|
+
response = self.client.get(reverse("charger-status", args=["STALEADM"]))
|
|
2740
|
+
self.assertEqual(response.status_code, 200)
|
|
2741
|
+
self.assertContains(response, 'id="charger-state">Available</strong>')
|
|
2742
|
+
|
|
1934
2743
|
def test_public_status_shows_rfid_link_for_known_tag(self):
|
|
1935
2744
|
aggregate = Charger.objects.create(charger_id="PUBRFID")
|
|
1936
2745
|
connector = Charger.objects.create(
|
|
@@ -2057,7 +2866,10 @@ class ChargerLandingTests(TestCase):
|
|
|
2057
2866
|
log_id = store.identity_key("LOG1", None)
|
|
2058
2867
|
store.add_log(log_id, "hello", log_type="charger")
|
|
2059
2868
|
entry = store.get_logs(log_id, log_type="charger")[0]
|
|
2060
|
-
self.assertRegex(
|
|
2869
|
+
self.assertRegex(
|
|
2870
|
+
entry,
|
|
2871
|
+
r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} hello$",
|
|
2872
|
+
)
|
|
2061
2873
|
resp = self.client.get(reverse("charger-log", args=["LOG1"]) + "?type=charger")
|
|
2062
2874
|
self.assertEqual(resp.status_code, 200)
|
|
2063
2875
|
self.assertContains(resp, "hello")
|
|
@@ -2092,12 +2904,12 @@ class SimulatorLandingTests(TestCase):
|
|
|
2092
2904
|
@skip("Navigation links unavailable in test environment")
|
|
2093
2905
|
def test_simulator_app_link_in_nav(self):
|
|
2094
2906
|
resp = self.client.get(reverse("pages:index"))
|
|
2095
|
-
self.assertContains(resp, "/ocpp/")
|
|
2096
|
-
self.assertNotContains(resp, "/ocpp/simulator/")
|
|
2907
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2908
|
+
self.assertNotContains(resp, "/ocpp/evcs/simulator/")
|
|
2097
2909
|
self.client.force_login(self.user)
|
|
2098
2910
|
resp = self.client.get(reverse("pages:index"))
|
|
2099
|
-
self.assertContains(resp, "/ocpp/")
|
|
2100
|
-
self.assertContains(resp, "/ocpp/simulator/")
|
|
2911
|
+
self.assertContains(resp, "/ocpp/cpms/dashboard/")
|
|
2912
|
+
self.assertContains(resp, "/ocpp/evcs/simulator/")
|
|
2101
2913
|
|
|
2102
2914
|
def test_cp_simulator_redirects_to_login(self):
|
|
2103
2915
|
response = self.client.get(reverse("cp-simulator"))
|
|
@@ -2129,6 +2941,32 @@ class ChargerAdminTests(TestCase):
|
|
|
2129
2941
|
resp = self.client.get(url)
|
|
2130
2942
|
self.assertNotContains(resp, charger.reference.image.url)
|
|
2131
2943
|
|
|
2944
|
+
def test_toggle_rfid_authentication_action_toggles_value(self):
|
|
2945
|
+
charger_requires = Charger.objects.create(
|
|
2946
|
+
charger_id="RFIDON", require_rfid=True
|
|
2947
|
+
)
|
|
2948
|
+
charger_optional = Charger.objects.create(
|
|
2949
|
+
charger_id="RFIDOFF", require_rfid=False
|
|
2950
|
+
)
|
|
2951
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2952
|
+
response = self.client.post(
|
|
2953
|
+
url,
|
|
2954
|
+
{
|
|
2955
|
+
"action": "toggle_rfid_authentication",
|
|
2956
|
+
"_selected_action": [
|
|
2957
|
+
charger_requires.pk,
|
|
2958
|
+
charger_optional.pk,
|
|
2959
|
+
],
|
|
2960
|
+
},
|
|
2961
|
+
follow=True,
|
|
2962
|
+
)
|
|
2963
|
+
self.assertEqual(response.status_code, 200)
|
|
2964
|
+
charger_requires.refresh_from_db()
|
|
2965
|
+
charger_optional.refresh_from_db()
|
|
2966
|
+
self.assertFalse(charger_requires.require_rfid)
|
|
2967
|
+
self.assertTrue(charger_optional.require_rfid)
|
|
2968
|
+
self.assertContains(response, "Updated RFID authentication")
|
|
2969
|
+
|
|
2132
2970
|
def test_admin_lists_log_link(self):
|
|
2133
2971
|
charger = Charger.objects.create(charger_id="LOG1")
|
|
2134
2972
|
url = reverse("admin:ocpp_charger_changelist")
|
|
@@ -2155,6 +2993,85 @@ class ChargerAdminTests(TestCase):
|
|
|
2155
2993
|
finally:
|
|
2156
2994
|
store.transactions.pop(key, None)
|
|
2157
2995
|
|
|
2996
|
+
def test_admin_status_shows_available_when_status_stale(self):
|
|
2997
|
+
charger = Charger.objects.create(
|
|
2998
|
+
charger_id="ADMINSTALE",
|
|
2999
|
+
last_status="Charging",
|
|
3000
|
+
)
|
|
3001
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3002
|
+
resp = self.client.get(url)
|
|
3003
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
3004
|
+
self.assertContains(resp, f">{available_label}<")
|
|
3005
|
+
|
|
3006
|
+
def test_recheck_charger_status_action_sends_trigger(self):
|
|
3007
|
+
charger = Charger.objects.create(charger_id="RECHECK1")
|
|
3008
|
+
|
|
3009
|
+
class DummyConnection:
|
|
3010
|
+
def __init__(self):
|
|
3011
|
+
self.sent: list[str] = []
|
|
3012
|
+
|
|
3013
|
+
async def send(self, message):
|
|
3014
|
+
self.sent.append(message)
|
|
3015
|
+
|
|
3016
|
+
ws = DummyConnection()
|
|
3017
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
3018
|
+
try:
|
|
3019
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3020
|
+
response = self.client.post(
|
|
3021
|
+
url,
|
|
3022
|
+
{
|
|
3023
|
+
"action": "recheck_charger_status",
|
|
3024
|
+
"index": 0,
|
|
3025
|
+
"select_across": 0,
|
|
3026
|
+
"_selected_action": [charger.pk],
|
|
3027
|
+
},
|
|
3028
|
+
follow=True,
|
|
3029
|
+
)
|
|
3030
|
+
self.assertEqual(response.status_code, 200)
|
|
3031
|
+
self.assertTrue(ws.sent)
|
|
3032
|
+
self.assertIn("TriggerMessage", ws.sent[0])
|
|
3033
|
+
self.assertContains(response, "Requested status update")
|
|
3034
|
+
finally:
|
|
3035
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
3036
|
+
store.clear_pending_calls(charger.charger_id)
|
|
3037
|
+
|
|
3038
|
+
def test_reset_charger_action_skips_when_transaction_active(self):
|
|
3039
|
+
charger = Charger.objects.create(charger_id="RESETADMIN")
|
|
3040
|
+
|
|
3041
|
+
class DummyConnection:
|
|
3042
|
+
def __init__(self):
|
|
3043
|
+
self.sent: list[str] = []
|
|
3044
|
+
|
|
3045
|
+
async def send(self, message):
|
|
3046
|
+
self.sent.append(message)
|
|
3047
|
+
|
|
3048
|
+
ws = DummyConnection()
|
|
3049
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
3050
|
+
tx_obj = Transaction.objects.create(
|
|
3051
|
+
charger=charger,
|
|
3052
|
+
connector_id=charger.connector_id,
|
|
3053
|
+
start_time=timezone.now(),
|
|
3054
|
+
)
|
|
3055
|
+
store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
3056
|
+
try:
|
|
3057
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3058
|
+
response = self.client.post(
|
|
3059
|
+
url,
|
|
3060
|
+
{
|
|
3061
|
+
"action": "reset_chargers",
|
|
3062
|
+
"index": 0,
|
|
3063
|
+
"select_across": 0,
|
|
3064
|
+
"_selected_action": [charger.pk],
|
|
3065
|
+
},
|
|
3066
|
+
follow=True,
|
|
3067
|
+
)
|
|
3068
|
+
self.assertEqual(response.status_code, 200)
|
|
3069
|
+
self.assertFalse(ws.sent)
|
|
3070
|
+
self.assertContains(response, "stop the session first")
|
|
3071
|
+
finally:
|
|
3072
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
3073
|
+
store.pop_transaction(charger.charger_id, charger.connector_id)
|
|
3074
|
+
|
|
2158
3075
|
def test_admin_log_view_displays_entries(self):
|
|
2159
3076
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
2160
3077
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
|
@@ -2178,6 +3095,36 @@ class ChargerAdminTests(TestCase):
|
|
|
2178
3095
|
resp = self.client.get(url)
|
|
2179
3096
|
self.assertContains(resp, "AdminLoc")
|
|
2180
3097
|
|
|
3098
|
+
def test_admin_changelist_displays_quick_stats(self):
|
|
3099
|
+
charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
|
|
3100
|
+
connector = Charger.objects.create(
|
|
3101
|
+
charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
|
|
3102
|
+
)
|
|
3103
|
+
start = timezone.now() - timedelta(minutes=30)
|
|
3104
|
+
Transaction.objects.create(
|
|
3105
|
+
charger=connector,
|
|
3106
|
+
start_time=start,
|
|
3107
|
+
stop_time=start + timedelta(minutes=10),
|
|
3108
|
+
meter_start=1000,
|
|
3109
|
+
meter_stop=6000,
|
|
3110
|
+
)
|
|
3111
|
+
|
|
3112
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3113
|
+
resp = self.client.get(url)
|
|
3114
|
+
|
|
3115
|
+
self.assertContains(resp, "Total kW")
|
|
3116
|
+
self.assertContains(resp, "Today kW")
|
|
3117
|
+
self.assertContains(resp, "5.00")
|
|
3118
|
+
|
|
3119
|
+
def test_admin_changelist_does_not_indent_connectors(self):
|
|
3120
|
+
Charger.objects.create(charger_id="INDENTMAIN")
|
|
3121
|
+
Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
|
|
3122
|
+
|
|
3123
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3124
|
+
resp = self.client.get(url)
|
|
3125
|
+
|
|
3126
|
+
self.assertNotContains(resp, 'class="charger-connector-entry"')
|
|
3127
|
+
|
|
2181
3128
|
def test_last_fields_are_read_only(self):
|
|
2182
3129
|
now = timezone.now()
|
|
2183
3130
|
charger = Charger.objects.create(
|
|
@@ -2426,6 +3373,208 @@ class ChargerAdminTests(TestCase):
|
|
|
2426
3373
|
store.clear_log(log_key, log_type="charger")
|
|
2427
3374
|
store.clear_log(pending_key, log_type="charger")
|
|
2428
3375
|
|
|
3376
|
+
def test_get_diagnostics_downloads_file_to_work_directory(self):
|
|
3377
|
+
charger = Charger.objects.create(
|
|
3378
|
+
charger_id="DIAGADMIN",
|
|
3379
|
+
diagnostics_location="https://example.com/diag.tar.gz",
|
|
3380
|
+
diagnostics_status="Uploaded",
|
|
3381
|
+
)
|
|
3382
|
+
fixed_now = datetime(2024, 1, 2, 3, 4, 5, tzinfo=dt_timezone.utc)
|
|
3383
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
3384
|
+
base_path = Path(tempdir)
|
|
3385
|
+
response_mock = Mock()
|
|
3386
|
+
response_mock.status_code = 200
|
|
3387
|
+
response_mock.iter_content.return_value = [b"diagnostics"]
|
|
3388
|
+
response_mock.headers = {
|
|
3389
|
+
"Content-Disposition": 'attachment; filename="diagnostics.tar.gz"'
|
|
3390
|
+
}
|
|
3391
|
+
response_mock.close = Mock()
|
|
3392
|
+
with override_settings(BASE_DIR=base_path):
|
|
3393
|
+
with patch("ocpp.admin.requests.get", return_value=response_mock) as mock_get:
|
|
3394
|
+
with patch("ocpp.admin.timezone.now", return_value=fixed_now):
|
|
3395
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3396
|
+
response = self.client.post(
|
|
3397
|
+
url,
|
|
3398
|
+
{
|
|
3399
|
+
"action": "get_diagnostics",
|
|
3400
|
+
"_selected_action": [charger.pk],
|
|
3401
|
+
},
|
|
3402
|
+
follow=True,
|
|
3403
|
+
)
|
|
3404
|
+
self.assertEqual(response.status_code, 200)
|
|
3405
|
+
work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
|
|
3406
|
+
self.assertTrue(work_dir.exists())
|
|
3407
|
+
files = list(work_dir.glob("*"))
|
|
3408
|
+
self.assertEqual(len(files), 1)
|
|
3409
|
+
saved_file = files[0]
|
|
3410
|
+
self.assertEqual(saved_file.read_bytes(), b"diagnostics")
|
|
3411
|
+
asset_path = saved_file.relative_to(base_path / "work" / "ocpp-admin").as_posix()
|
|
3412
|
+
asset_url = "http://testserver" + reverse(
|
|
3413
|
+
"pages:readme-asset", kwargs={"source": "work", "asset": asset_path}
|
|
3414
|
+
)
|
|
3415
|
+
self.assertContains(response, asset_url)
|
|
3416
|
+
self.assertContains(response, str(saved_file))
|
|
3417
|
+
mock_get.assert_called_once_with(
|
|
3418
|
+
"https://example.com/diag.tar.gz", stream=True, timeout=15
|
|
3419
|
+
)
|
|
3420
|
+
response_mock.close.assert_called_once()
|
|
3421
|
+
|
|
3422
|
+
def test_get_diagnostics_requires_location_reports_warning(self):
|
|
3423
|
+
charger = Charger.objects.create(charger_id="DIAGNOLOC")
|
|
3424
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
3425
|
+
base_path = Path(tempdir)
|
|
3426
|
+
with override_settings(BASE_DIR=base_path):
|
|
3427
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3428
|
+
response = self.client.post(
|
|
3429
|
+
url,
|
|
3430
|
+
{"action": "get_diagnostics", "_selected_action": [charger.pk]},
|
|
3431
|
+
follow=True,
|
|
3432
|
+
)
|
|
3433
|
+
self.assertEqual(response.status_code, 200)
|
|
3434
|
+
self.assertContains(response, "DIAGNOLOC: no diagnostics location reported.")
|
|
3435
|
+
work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
|
|
3436
|
+
self.assertTrue(work_dir.exists())
|
|
3437
|
+
self.assertFalse(list(work_dir.iterdir()))
|
|
3438
|
+
|
|
3439
|
+
def test_get_diagnostics_handles_download_error(self):
|
|
3440
|
+
charger = Charger.objects.create(
|
|
3441
|
+
charger_id="DIAGFAIL",
|
|
3442
|
+
diagnostics_location="https://example.com/diag.tar",
|
|
3443
|
+
)
|
|
3444
|
+
response_mock = Mock()
|
|
3445
|
+
response_mock.status_code = 500
|
|
3446
|
+
response_mock.iter_content.return_value = []
|
|
3447
|
+
response_mock.headers = {}
|
|
3448
|
+
response_mock.close = Mock()
|
|
3449
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
3450
|
+
base_path = Path(tempdir)
|
|
3451
|
+
with override_settings(BASE_DIR=base_path):
|
|
3452
|
+
with patch("ocpp.admin.requests.get", return_value=response_mock):
|
|
3453
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
3454
|
+
response = self.client.post(
|
|
3455
|
+
url,
|
|
3456
|
+
{
|
|
3457
|
+
"action": "get_diagnostics",
|
|
3458
|
+
"_selected_action": [charger.pk],
|
|
3459
|
+
},
|
|
3460
|
+
follow=True,
|
|
3461
|
+
)
|
|
3462
|
+
self.assertEqual(response.status_code, 200)
|
|
3463
|
+
self.assertContains(
|
|
3464
|
+
response, "DIAGFAIL: Diagnostics download returned status 500."
|
|
3465
|
+
)
|
|
3466
|
+
work_dir = base_path / "work" / "ocpp-admin" / "diagnostics"
|
|
3467
|
+
self.assertTrue(work_dir.exists())
|
|
3468
|
+
self.assertFalse(list(work_dir.iterdir()))
|
|
3469
|
+
response_mock.close.assert_called_once()
|
|
3470
|
+
|
|
3471
|
+
|
|
3472
|
+
class ChargerConfigurationAdminUnitTests(TestCase):
|
|
3473
|
+
def setUp(self):
|
|
3474
|
+
self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
|
|
3475
|
+
self.request_factory = RequestFactory()
|
|
3476
|
+
|
|
3477
|
+
def test_origin_display_returns_evcs_when_snapshot_present(self):
|
|
3478
|
+
configuration = ChargerConfiguration.objects.create(
|
|
3479
|
+
charger_identifier="CFG-EVCS",
|
|
3480
|
+
evcs_snapshot_at=timezone.now(),
|
|
3481
|
+
)
|
|
3482
|
+
self.assertEqual(self.admin.origin_display(configuration), "EVCS")
|
|
3483
|
+
|
|
3484
|
+
def test_origin_display_returns_local_without_snapshot(self):
|
|
3485
|
+
configuration = ChargerConfiguration.objects.create(
|
|
3486
|
+
charger_identifier="CFG-LOCAL",
|
|
3487
|
+
)
|
|
3488
|
+
self.assertEqual(self.admin.origin_display(configuration), "Local")
|
|
3489
|
+
|
|
3490
|
+
def test_save_model_resets_snapshot_timestamp(self):
|
|
3491
|
+
configuration = ChargerConfiguration.objects.create(
|
|
3492
|
+
charger_identifier="CFG-SAVE",
|
|
3493
|
+
evcs_snapshot_at=timezone.now(),
|
|
3494
|
+
)
|
|
3495
|
+
request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
|
|
3496
|
+
self.admin.save_model(request, configuration, form=None, change=True)
|
|
3497
|
+
configuration.refresh_from_db()
|
|
3498
|
+
self.assertIsNone(configuration.evcs_snapshot_at)
|
|
3499
|
+
|
|
3500
|
+
def test_configuration_key_inline_readonly_helpers(self):
|
|
3501
|
+
configuration = ChargerConfiguration.objects.create(
|
|
3502
|
+
charger_identifier="CFG-INLINE"
|
|
3503
|
+
)
|
|
3504
|
+
configuration.replace_configuration_keys(
|
|
3505
|
+
[
|
|
3506
|
+
{
|
|
3507
|
+
"key": "HeartbeatInterval",
|
|
3508
|
+
"value": {"interval": 300},
|
|
3509
|
+
"readonly": True,
|
|
3510
|
+
"note": "Check",
|
|
3511
|
+
},
|
|
3512
|
+
{"key": "AuthorizeRemoteTxRequests", "readonly": False},
|
|
3513
|
+
]
|
|
3514
|
+
)
|
|
3515
|
+
inline = ConfigurationKeyInline(ChargerConfiguration, self.admin.admin_site)
|
|
3516
|
+
entries = list(
|
|
3517
|
+
configuration.configuration_entries.order_by("position", "id")
|
|
3518
|
+
)
|
|
3519
|
+
self.assertEqual(len(entries), 2)
|
|
3520
|
+
self.assertIn("<pre>", inline.value_display(entries[0]))
|
|
3521
|
+
self.assertIn("\"note\"", inline.extra_display(entries[0]))
|
|
3522
|
+
self.assertEqual(inline.value_display(entries[1]), "-")
|
|
3523
|
+
self.assertEqual(inline.extra_display(entries[1]), "-")
|
|
3524
|
+
request = self.request_factory.get("/admin/ocpp/chargerconfiguration/")
|
|
3525
|
+
self.assertFalse(inline.has_add_permission(request, configuration))
|
|
3526
|
+
|
|
3527
|
+
def test_configuration_key_admin_hidden_from_index(self):
|
|
3528
|
+
key_admin = ConfigurationKeyAdmin(ConfigurationKey, AdminSite())
|
|
3529
|
+
perms = key_admin.get_model_perms(self.request_factory.get("/"))
|
|
3530
|
+
self.assertEqual(perms, {})
|
|
3531
|
+
|
|
3532
|
+
|
|
3533
|
+
class ConfigurationTaskTests(TestCase):
|
|
3534
|
+
def tearDown(self):
|
|
3535
|
+
store.pending_calls.clear()
|
|
3536
|
+
|
|
3537
|
+
def test_check_charge_point_configuration_dispatches_request(self):
|
|
3538
|
+
charger = Charger.objects.create(charger_id="TASKCFG")
|
|
3539
|
+
ws = DummyWebSocket()
|
|
3540
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
3541
|
+
pending_key = store.pending_key(charger.charger_id)
|
|
3542
|
+
store.clear_log(log_key, log_type="charger")
|
|
3543
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3544
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
3545
|
+
try:
|
|
3546
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
3547
|
+
self.assertTrue(result)
|
|
3548
|
+
self.assertEqual(len(ws.sent), 1)
|
|
3549
|
+
frame = json.loads(ws.sent[0])
|
|
3550
|
+
self.assertEqual(frame[0], 2)
|
|
3551
|
+
self.assertEqual(frame[2], "GetConfiguration")
|
|
3552
|
+
self.assertIn(frame[1], store.pending_calls)
|
|
3553
|
+
finally:
|
|
3554
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
3555
|
+
store.pending_calls.clear()
|
|
3556
|
+
store.clear_log(log_key, log_type="charger")
|
|
3557
|
+
store.clear_log(pending_key, log_type="charger")
|
|
3558
|
+
|
|
3559
|
+
def test_check_charge_point_configuration_without_connection(self):
|
|
3560
|
+
charger = Charger.objects.create(charger_id="TASKNOCONN")
|
|
3561
|
+
result = check_charge_point_configuration.run(charger.pk)
|
|
3562
|
+
self.assertFalse(result)
|
|
3563
|
+
|
|
3564
|
+
def test_schedule_daily_checks_only_includes_root_chargers(self):
|
|
3565
|
+
eligible = Charger.objects.create(charger_id="TASKROOT")
|
|
3566
|
+
Charger.objects.create(charger_id="TASKCONN", connector_id=1)
|
|
3567
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
3568
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
3569
|
+
self.assertEqual(scheduled, 1)
|
|
3570
|
+
mock_delay.assert_called_once_with(eligible.pk)
|
|
3571
|
+
|
|
3572
|
+
def test_schedule_daily_checks_returns_zero_without_chargers(self):
|
|
3573
|
+
with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
|
|
3574
|
+
scheduled = schedule_daily_charge_point_configuration_checks.run()
|
|
3575
|
+
self.assertEqual(scheduled, 0)
|
|
3576
|
+
mock_delay.assert_not_called()
|
|
3577
|
+
|
|
2429
3578
|
|
|
2430
3579
|
class LocationAdminTests(TestCase):
|
|
2431
3580
|
def setUp(self):
|
|
@@ -2700,6 +3849,28 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2700
3849
|
|
|
2701
3850
|
await communicator.disconnect()
|
|
2702
3851
|
|
|
3852
|
+
def test_auto_registered_charger_location_name_sanitized(self):
|
|
3853
|
+
async def exercise():
|
|
3854
|
+
communicator = WebsocketCommunicator(
|
|
3855
|
+
application, "/?cid=ACME%20Charger%20%231"
|
|
3856
|
+
)
|
|
3857
|
+
connected, _ = await communicator.connect()
|
|
3858
|
+
self.assertTrue(connected)
|
|
3859
|
+
|
|
3860
|
+
await communicator.disconnect()
|
|
3861
|
+
|
|
3862
|
+
def fetch_location_name() -> str:
|
|
3863
|
+
charger = (
|
|
3864
|
+
Charger.objects.select_related("location")
|
|
3865
|
+
.get(charger_id="ACME Charger #1")
|
|
3866
|
+
)
|
|
3867
|
+
return charger.location.name
|
|
3868
|
+
|
|
3869
|
+
location_name = await database_sync_to_async(fetch_location_name)()
|
|
3870
|
+
self.assertEqual(location_name, "ACME_Charger_1")
|
|
3871
|
+
|
|
3872
|
+
async_to_sync(exercise)()
|
|
3873
|
+
|
|
2703
3874
|
async def test_query_string_cid_supported(self):
|
|
2704
3875
|
communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
|
|
2705
3876
|
connected, _ = await communicator.connect()
|
|
@@ -2860,6 +4031,44 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2860
4031
|
|
|
2861
4032
|
await communicator.disconnect()
|
|
2862
4033
|
|
|
4034
|
+
async def test_authorize_requires_rfid_accepts_allowed_tag_without_account(self):
|
|
4035
|
+
charger_id = "AUTHWARN"
|
|
4036
|
+
tag_value = "WARN01"
|
|
4037
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
4038
|
+
charger_id=charger_id, require_rfid=True
|
|
4039
|
+
)
|
|
4040
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
4041
|
+
|
|
4042
|
+
pending_key = store.pending_key(charger_id)
|
|
4043
|
+
store.clear_log(pending_key, log_type="charger")
|
|
4044
|
+
|
|
4045
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
4046
|
+
connected, _ = await communicator.connect()
|
|
4047
|
+
self.assertTrue(connected)
|
|
4048
|
+
|
|
4049
|
+
message_id = "auth-unlinked"
|
|
4050
|
+
await communicator.send_json_to(
|
|
4051
|
+
[2, message_id, "Authorize", {"idTag": tag_value}]
|
|
4052
|
+
)
|
|
4053
|
+
response = await communicator.receive_json_from()
|
|
4054
|
+
self.assertEqual(response[0], 3)
|
|
4055
|
+
self.assertEqual(response[1], message_id)
|
|
4056
|
+
self.assertEqual(response[2], {"idTagInfo": {"status": "Accepted"}})
|
|
4057
|
+
|
|
4058
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
4059
|
+
self.assertTrue(
|
|
4060
|
+
any(
|
|
4061
|
+
"Authorized RFID" in entry
|
|
4062
|
+
and tag_value in entry
|
|
4063
|
+
and charger_id in entry
|
|
4064
|
+
for entry in log_entries
|
|
4065
|
+
),
|
|
4066
|
+
log_entries,
|
|
4067
|
+
)
|
|
4068
|
+
|
|
4069
|
+
await communicator.disconnect()
|
|
4070
|
+
store.clear_log(pending_key, log_type="charger")
|
|
4071
|
+
|
|
2863
4072
|
async def test_authorize_without_requirement_records_rfid(self):
|
|
2864
4073
|
await database_sync_to_async(Charger.objects.create)(
|
|
2865
4074
|
charger_id="AUTHOPT", require_rfid=False
|
|
@@ -2952,6 +4161,61 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
2952
4161
|
)
|
|
2953
4162
|
self.assertEqual(tx.account_id, user.energy_account.id)
|
|
2954
4163
|
|
|
4164
|
+
async def test_start_transaction_allows_allowed_tag_without_account(self):
|
|
4165
|
+
charger_id = "STARTWARN"
|
|
4166
|
+
tag_value = "WARN02"
|
|
4167
|
+
await database_sync_to_async(Charger.objects.create)(
|
|
4168
|
+
charger_id=charger_id, require_rfid=True
|
|
4169
|
+
)
|
|
4170
|
+
await database_sync_to_async(RFID.objects.create)(rfid=tag_value, allowed=True)
|
|
4171
|
+
|
|
4172
|
+
pending_key = store.pending_key(charger_id)
|
|
4173
|
+
store.clear_log(pending_key, log_type="charger")
|
|
4174
|
+
|
|
4175
|
+
communicator = WebsocketCommunicator(application, f"/{charger_id}/")
|
|
4176
|
+
connected, _ = await communicator.connect()
|
|
4177
|
+
self.assertTrue(connected)
|
|
4178
|
+
|
|
4179
|
+
start_payload = {
|
|
4180
|
+
"meterStart": 5,
|
|
4181
|
+
"idTag": tag_value,
|
|
4182
|
+
"connectorId": 1,
|
|
4183
|
+
}
|
|
4184
|
+
await communicator.send_json_to([2, "start-1", "StartTransaction", start_payload])
|
|
4185
|
+
response = await communicator.receive_json_from()
|
|
4186
|
+
self.assertEqual(response[0], 3)
|
|
4187
|
+
self.assertEqual(response[2]["idTagInfo"]["status"], "Accepted")
|
|
4188
|
+
tx_id = response[2]["transactionId"]
|
|
4189
|
+
|
|
4190
|
+
tx = await database_sync_to_async(Transaction.objects.get)(
|
|
4191
|
+
pk=tx_id, charger__charger_id=charger_id
|
|
4192
|
+
)
|
|
4193
|
+
self.assertIsNone(tx.account_id)
|
|
4194
|
+
|
|
4195
|
+
log_entries = store.get_logs(pending_key, log_type="charger")
|
|
4196
|
+
self.assertTrue(
|
|
4197
|
+
any(
|
|
4198
|
+
"Authorized RFID" in entry
|
|
4199
|
+
and tag_value in entry
|
|
4200
|
+
and charger_id in entry
|
|
4201
|
+
for entry in log_entries
|
|
4202
|
+
),
|
|
4203
|
+
log_entries,
|
|
4204
|
+
)
|
|
4205
|
+
|
|
4206
|
+
await communicator.send_json_to(
|
|
4207
|
+
[
|
|
4208
|
+
2,
|
|
4209
|
+
"stop-1",
|
|
4210
|
+
"StopTransaction",
|
|
4211
|
+
{"transactionId": tx_id, "meterStop": 6},
|
|
4212
|
+
]
|
|
4213
|
+
)
|
|
4214
|
+
await communicator.receive_json_from()
|
|
4215
|
+
|
|
4216
|
+
await communicator.disconnect()
|
|
4217
|
+
store.clear_log(pending_key, log_type="charger")
|
|
4218
|
+
|
|
2955
4219
|
async def test_status_fields_updated(self):
|
|
2956
4220
|
communicator = WebsocketCommunicator(application, "/STAT/")
|
|
2957
4221
|
connected, _ = await communicator.connect()
|
|
@@ -3017,8 +4281,7 @@ class SimulatorAdminTests(TransactionTestCase):
|
|
|
3017
4281
|
self.assertIsNotNone(aggregate.last_heartbeat)
|
|
3018
4282
|
if previous_heartbeat:
|
|
3019
4283
|
self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
|
|
3020
|
-
|
|
3021
|
-
self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
|
|
4284
|
+
self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
|
|
3022
4285
|
|
|
3023
4286
|
await communicator.disconnect()
|
|
3024
4287
|
|
|
@@ -3045,6 +4308,10 @@ class ChargerLocationTests(TestCase):
|
|
|
3045
4308
|
second = Charger.objects.create(charger_id="SHARE", connector_id=2)
|
|
3046
4309
|
self.assertEqual(second.location, first.location)
|
|
3047
4310
|
|
|
4311
|
+
def test_location_name_sanitized_when_auto_created(self):
|
|
4312
|
+
charger = Charger.objects.create(charger_id="Name With spaces!#1")
|
|
4313
|
+
self.assertEqual(charger.location.name, "Name_With_spaces_1")
|
|
4314
|
+
|
|
3048
4315
|
|
|
3049
4316
|
class MeterReadingTests(TransactionTestCase):
|
|
3050
4317
|
async def test_meter_values_saved_as_readings(self):
|
|
@@ -3391,7 +4658,9 @@ class ChargePointSimulatorTests(TransactionTestCase):
|
|
|
3391
4658
|
cfg = SimulatorConfig(cp_path="SIMLOG/")
|
|
3392
4659
|
sim = ChargePointSimulator(cfg)
|
|
3393
4660
|
store.clear_log(cfg.cp_path, log_type="simulator")
|
|
3394
|
-
store.logs["simulator"][cfg.cp_path] =
|
|
4661
|
+
store.logs["simulator"][cfg.cp_path] = deque(
|
|
4662
|
+
maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES
|
|
4663
|
+
)
|
|
3395
4664
|
sent_frames: list[str] = []
|
|
3396
4665
|
|
|
3397
4666
|
async def send(payload: str) -> None:
|
|
@@ -3732,6 +5001,28 @@ class DailySessionReportTaskTests(TestCase):
|
|
|
3732
5001
|
except FileNotFoundError:
|
|
3733
5002
|
pass
|
|
3734
5003
|
|
|
5004
|
+
def _create_transaction_with_reading(
|
|
5005
|
+
self,
|
|
5006
|
+
charger: Charger,
|
|
5007
|
+
start: datetime,
|
|
5008
|
+
energy: Decimal,
|
|
5009
|
+
) -> Transaction:
|
|
5010
|
+
transaction = Transaction.objects.create(
|
|
5011
|
+
charger=charger,
|
|
5012
|
+
start_time=start,
|
|
5013
|
+
stop_time=start + timedelta(minutes=30),
|
|
5014
|
+
meter_start=0,
|
|
5015
|
+
connector_id=1,
|
|
5016
|
+
)
|
|
5017
|
+
MeterValue.objects.create(
|
|
5018
|
+
charger=charger,
|
|
5019
|
+
connector_id=1,
|
|
5020
|
+
transaction=transaction,
|
|
5021
|
+
timestamp=start + timedelta(minutes=15),
|
|
5022
|
+
energy=energy,
|
|
5023
|
+
)
|
|
5024
|
+
return transaction
|
|
5025
|
+
|
|
3735
5026
|
def test_report_sends_email_when_sessions_exist(self):
|
|
3736
5027
|
User = get_user_model()
|
|
3737
5028
|
User.objects.create_superuser(
|
|
@@ -3785,6 +5076,41 @@ class DailySessionReportTaskTests(TestCase):
|
|
|
3785
5076
|
self.assertEqual(count, 0)
|
|
3786
5077
|
mock_send.assert_not_called()
|
|
3787
5078
|
|
|
5079
|
+
def test_report_query_count_constant(self):
|
|
5080
|
+
User = get_user_model()
|
|
5081
|
+
User.objects.create_superuser(
|
|
5082
|
+
username="report-admin", email="report-admin@example.com", password="pw"
|
|
5083
|
+
)
|
|
5084
|
+
charger = Charger.objects.create(charger_id="RPT-QUERY", display_name="Pod Q")
|
|
5085
|
+
base_start = timezone.now().replace(second=0, microsecond=0)
|
|
5086
|
+
|
|
5087
|
+
self._create_transaction_with_reading(
|
|
5088
|
+
charger, base_start, Decimal("1.2")
|
|
5089
|
+
)
|
|
5090
|
+
|
|
5091
|
+
with patch("core.mailer.can_send_email", return_value=True), patch(
|
|
5092
|
+
"core.mailer.send"
|
|
5093
|
+
) as mock_send, CaptureQueriesContext(connection) as ctx_single:
|
|
5094
|
+
count_single = send_daily_session_report()
|
|
5095
|
+
|
|
5096
|
+
self.assertEqual(count_single, 1)
|
|
5097
|
+
mock_send.assert_called_once()
|
|
5098
|
+
single_query_count = len(ctx_single)
|
|
5099
|
+
|
|
5100
|
+
later_start = base_start + timedelta(hours=1)
|
|
5101
|
+
self._create_transaction_with_reading(
|
|
5102
|
+
charger, later_start, Decimal("3.4")
|
|
5103
|
+
)
|
|
5104
|
+
|
|
5105
|
+
with patch("core.mailer.can_send_email", return_value=True), patch(
|
|
5106
|
+
"core.mailer.send"
|
|
5107
|
+
) as mock_send_multi, CaptureQueriesContext(connection) as ctx_multi:
|
|
5108
|
+
count_multi = send_daily_session_report()
|
|
5109
|
+
|
|
5110
|
+
self.assertEqual(count_multi, 2)
|
|
5111
|
+
mock_send_multi.assert_called_once()
|
|
5112
|
+
self.assertEqual(len(ctx_multi), single_query_count)
|
|
5113
|
+
|
|
3788
5114
|
|
|
3789
5115
|
class TransactionKwTests(TestCase):
|
|
3790
5116
|
def test_kw_sums_meter_readings(self):
|
|
@@ -3814,6 +5140,43 @@ class TransactionKwTests(TestCase):
|
|
|
3814
5140
|
self.assertEqual(tx.kw, 0.0)
|
|
3815
5141
|
|
|
3816
5142
|
|
|
5143
|
+
class TransactionIdentifierTests(TestCase):
|
|
5144
|
+
def test_vehicle_identifier_prefers_vid(self):
|
|
5145
|
+
charger = Charger.objects.create(charger_id="VIDPREF")
|
|
5146
|
+
tx = Transaction.objects.create(
|
|
5147
|
+
charger=charger,
|
|
5148
|
+
start_time=timezone.now(),
|
|
5149
|
+
vid="VID-123",
|
|
5150
|
+
vin="VIN-456",
|
|
5151
|
+
)
|
|
5152
|
+
self.assertEqual(tx.vehicle_identifier, "VID-123")
|
|
5153
|
+
self.assertEqual(tx.vehicle_identifier_source, "vid")
|
|
5154
|
+
|
|
5155
|
+
def test_vehicle_identifier_falls_back_to_vin(self):
|
|
5156
|
+
charger = Charger.objects.create(charger_id="VINONLY")
|
|
5157
|
+
tx = Transaction.objects.create(
|
|
5158
|
+
charger=charger,
|
|
5159
|
+
start_time=timezone.now(),
|
|
5160
|
+
vin="WP0ZZZ00000000001",
|
|
5161
|
+
)
|
|
5162
|
+
self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
|
|
5163
|
+
self.assertEqual(tx.vehicle_identifier_source, "vin")
|
|
5164
|
+
|
|
5165
|
+
def test_transaction_rfid_details_handles_vin(self):
|
|
5166
|
+
charger = Charger.objects.create(charger_id="VINDET")
|
|
5167
|
+
tx = Transaction.objects.create(
|
|
5168
|
+
charger=charger,
|
|
5169
|
+
start_time=timezone.now(),
|
|
5170
|
+
vin="WAUZZZ00000000002",
|
|
5171
|
+
)
|
|
5172
|
+
details = _transaction_rfid_details(tx, cache={})
|
|
5173
|
+
self.assertIsNotNone(details)
|
|
5174
|
+
assert details is not None # for type checkers
|
|
5175
|
+
self.assertEqual(details["value"], "WAUZZZ00000000002")
|
|
5176
|
+
self.assertEqual(details["display_label"], "VIN")
|
|
5177
|
+
self.assertEqual(details["type"], "vin")
|
|
5178
|
+
|
|
5179
|
+
|
|
3817
5180
|
class DispatchActionViewTests(TestCase):
|
|
3818
5181
|
def setUp(self):
|
|
3819
5182
|
self.client = Client()
|
|
@@ -3860,6 +5223,9 @@ class DispatchActionViewTests(TestCase):
|
|
|
3860
5223
|
)
|
|
3861
5224
|
self.mock_wait = self.wait_patch.start()
|
|
3862
5225
|
self.addCleanup(self.wait_patch.stop)
|
|
5226
|
+
self.schedule_patch = patch("ocpp.views.store.schedule_call_timeout")
|
|
5227
|
+
self.mock_schedule = self.schedule_patch.start()
|
|
5228
|
+
self.addCleanup(self.schedule_patch.stop)
|
|
3863
5229
|
|
|
3864
5230
|
def _close_loop(self):
|
|
3865
5231
|
try:
|
|
@@ -3932,6 +5298,75 @@ class DispatchActionViewTests(TestCase):
|
|
|
3932
5298
|
self.assertEqual(self.charger.availability_request_status, "")
|
|
3933
5299
|
self.assertNotIn(frame[1], store.pending_calls)
|
|
3934
5300
|
|
|
5301
|
+
def test_clear_cache_dispatches_frame_and_schedules_timeout(self):
|
|
5302
|
+
with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
|
|
5303
|
+
response = self.client.post(
|
|
5304
|
+
self.url,
|
|
5305
|
+
data=json.dumps({"action": "clear_cache"}),
|
|
5306
|
+
content_type="application/json",
|
|
5307
|
+
)
|
|
5308
|
+
self.assertEqual(response.status_code, 200)
|
|
5309
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5310
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5311
|
+
frame = json.loads(self.ws.sent[0])
|
|
5312
|
+
self.assertEqual(frame[0], 2)
|
|
5313
|
+
self.assertEqual(frame[2], "ClearCache")
|
|
5314
|
+
self.assertEqual(frame[3], {})
|
|
5315
|
+
mock_timeout.assert_called_once()
|
|
5316
|
+
timeout_call = mock_timeout.call_args
|
|
5317
|
+
self.assertIsNotNone(timeout_call)
|
|
5318
|
+
self.assertEqual(timeout_call.args[0], frame[1])
|
|
5319
|
+
self.assertEqual(timeout_call.kwargs.get("action"), "ClearCache")
|
|
5320
|
+
log_entries = store.logs["charger"].get(self.log_key, [])
|
|
5321
|
+
self.assertTrue(any("ClearCache" in entry for entry in log_entries))
|
|
5322
|
+
|
|
5323
|
+
def test_clear_cache_allows_rejected_status(self):
|
|
5324
|
+
def wait_rejected(message_id, timeout=5.0):
|
|
5325
|
+
metadata = store.pending_calls.pop(message_id, None)
|
|
5326
|
+
store._pending_call_events.pop(message_id, None)
|
|
5327
|
+
store._pending_call_results.pop(message_id, None)
|
|
5328
|
+
return {
|
|
5329
|
+
"success": True,
|
|
5330
|
+
"payload": {"status": "Rejected"},
|
|
5331
|
+
"metadata": dict(metadata or {}),
|
|
5332
|
+
}
|
|
5333
|
+
|
|
5334
|
+
self.mock_wait.side_effect = wait_rejected
|
|
5335
|
+
with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
|
|
5336
|
+
response = self.client.post(
|
|
5337
|
+
self.url,
|
|
5338
|
+
data=json.dumps({"action": "clear_cache"}),
|
|
5339
|
+
content_type="application/json",
|
|
5340
|
+
)
|
|
5341
|
+
self.assertEqual(response.status_code, 200)
|
|
5342
|
+
payload = response.json()
|
|
5343
|
+
self.assertIn("sent", payload)
|
|
5344
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5345
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5346
|
+
frame = json.loads(self.ws.sent[0])
|
|
5347
|
+
self.assertEqual(frame[2], "ClearCache")
|
|
5348
|
+
mock_timeout.assert_called_once()
|
|
5349
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5350
|
+
|
|
5351
|
+
def test_clear_cache_reports_timeout(self):
|
|
5352
|
+
def no_response(message_id, timeout=5.0):
|
|
5353
|
+
return None
|
|
5354
|
+
|
|
5355
|
+
self.mock_wait.side_effect = no_response
|
|
5356
|
+
with patch("ocpp.views.store.schedule_call_timeout") as mock_timeout:
|
|
5357
|
+
response = self.client.post(
|
|
5358
|
+
self.url,
|
|
5359
|
+
data=json.dumps({"action": "clear_cache"}),
|
|
5360
|
+
content_type="application/json",
|
|
5361
|
+
)
|
|
5362
|
+
self.assertEqual(response.status_code, 504)
|
|
5363
|
+
detail = response.json().get("detail", "")
|
|
5364
|
+
self.assertIn("did not receive", detail)
|
|
5365
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5366
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5367
|
+
mock_timeout.assert_called_once()
|
|
5368
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5369
|
+
|
|
3935
5370
|
def test_remote_start_reports_rejection(self):
|
|
3936
5371
|
def rejected(message_id, timeout=5.0):
|
|
3937
5372
|
metadata = store.pending_calls.pop(message_id, None)
|
|
@@ -3994,6 +5429,118 @@ class DispatchActionViewTests(TestCase):
|
|
|
3994
5429
|
self.assertIn("did not receive", detail)
|
|
3995
5430
|
self.mock_wait.side_effect = self._wait_success
|
|
3996
5431
|
|
|
5432
|
+
def test_change_configuration_requires_key(self):
|
|
5433
|
+
self.mock_schedule.reset_mock()
|
|
5434
|
+
response = self.client.post(
|
|
5435
|
+
self.url,
|
|
5436
|
+
data=json.dumps({"action": "change_configuration"}),
|
|
5437
|
+
content_type="application/json",
|
|
5438
|
+
)
|
|
5439
|
+
self.assertEqual(response.status_code, 400)
|
|
5440
|
+
self.assertEqual(response.json().get("detail"), "key required")
|
|
5441
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5442
|
+
self.assertEqual(self.ws.sent, [])
|
|
5443
|
+
self.mock_schedule.assert_not_called()
|
|
5444
|
+
|
|
5445
|
+
def test_change_configuration_rejects_invalid_value_type(self):
|
|
5446
|
+
self.mock_schedule.reset_mock()
|
|
5447
|
+
response = self.client.post(
|
|
5448
|
+
self.url,
|
|
5449
|
+
data=json.dumps(
|
|
5450
|
+
{
|
|
5451
|
+
"action": "change_configuration",
|
|
5452
|
+
"key": "HeartbeatInterval",
|
|
5453
|
+
"value": {"unexpected": "object"},
|
|
5454
|
+
}
|
|
5455
|
+
),
|
|
5456
|
+
content_type="application/json",
|
|
5457
|
+
)
|
|
5458
|
+
self.assertEqual(response.status_code, 400)
|
|
5459
|
+
self.assertIn("value must", response.json().get("detail", ""))
|
|
5460
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5461
|
+
self.assertEqual(self.ws.sent, [])
|
|
5462
|
+
self.mock_schedule.assert_not_called()
|
|
5463
|
+
|
|
5464
|
+
def test_change_configuration_dispatches_frame(self):
|
|
5465
|
+
self.mock_schedule.reset_mock()
|
|
5466
|
+
response = self.client.post(
|
|
5467
|
+
self.url,
|
|
5468
|
+
data=json.dumps(
|
|
5469
|
+
{
|
|
5470
|
+
"action": "change_configuration",
|
|
5471
|
+
"key": "HeartbeatInterval",
|
|
5472
|
+
"value": "120",
|
|
5473
|
+
}
|
|
5474
|
+
),
|
|
5475
|
+
content_type="application/json",
|
|
5476
|
+
)
|
|
5477
|
+
self.assertEqual(response.status_code, 200)
|
|
5478
|
+
self.loop.run_until_complete(asyncio.sleep(0))
|
|
5479
|
+
self.assertEqual(len(self.ws.sent), 1)
|
|
5480
|
+
frame = json.loads(self.ws.sent[0])
|
|
5481
|
+
self.assertEqual(frame[0], 2)
|
|
5482
|
+
self.assertEqual(frame[2], "ChangeConfiguration")
|
|
5483
|
+
self.assertEqual(frame[3]["key"], "HeartbeatInterval")
|
|
5484
|
+
self.assertEqual(frame[3]["value"], "120")
|
|
5485
|
+
self.mock_schedule.assert_called_once()
|
|
5486
|
+
log_entries = store.logs["charger"].get(self.log_key, [])
|
|
5487
|
+
self.assertTrue(
|
|
5488
|
+
any("Requested configuration change" in entry for entry in log_entries)
|
|
5489
|
+
)
|
|
5490
|
+
|
|
5491
|
+
def test_change_configuration_reports_rejection(self):
|
|
5492
|
+
self.mock_schedule.reset_mock()
|
|
5493
|
+
|
|
5494
|
+
def rejected(message_id, timeout=5.0):
|
|
5495
|
+
metadata = store.pending_calls.pop(message_id, None)
|
|
5496
|
+
store._pending_call_events.pop(message_id, None)
|
|
5497
|
+
store._pending_call_results.pop(message_id, None)
|
|
5498
|
+
return {
|
|
5499
|
+
"success": True,
|
|
5500
|
+
"payload": {"status": "Rejected"},
|
|
5501
|
+
"metadata": dict(metadata or {}),
|
|
5502
|
+
}
|
|
5503
|
+
|
|
5504
|
+
self.mock_wait.side_effect = rejected
|
|
5505
|
+
response = self.client.post(
|
|
5506
|
+
self.url,
|
|
5507
|
+
data=json.dumps(
|
|
5508
|
+
{
|
|
5509
|
+
"action": "change_configuration",
|
|
5510
|
+
"key": "HeartbeatInterval",
|
|
5511
|
+
"value": "120",
|
|
5512
|
+
}
|
|
5513
|
+
),
|
|
5514
|
+
content_type="application/json",
|
|
5515
|
+
)
|
|
5516
|
+
self.assertEqual(response.status_code, 400)
|
|
5517
|
+
detail = response.json().get("detail", "")
|
|
5518
|
+
self.assertIn("Rejected", detail)
|
|
5519
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5520
|
+
|
|
5521
|
+
def test_change_configuration_reports_timeout(self):
|
|
5522
|
+
self.mock_schedule.reset_mock()
|
|
5523
|
+
|
|
5524
|
+
def no_response(message_id, timeout=5.0):
|
|
5525
|
+
return None
|
|
5526
|
+
|
|
5527
|
+
self.mock_wait.side_effect = no_response
|
|
5528
|
+
response = self.client.post(
|
|
5529
|
+
self.url,
|
|
5530
|
+
data=json.dumps(
|
|
5531
|
+
{
|
|
5532
|
+
"action": "change_configuration",
|
|
5533
|
+
"key": "HeartbeatInterval",
|
|
5534
|
+
"value": "120",
|
|
5535
|
+
}
|
|
5536
|
+
),
|
|
5537
|
+
content_type="application/json",
|
|
5538
|
+
)
|
|
5539
|
+
self.assertEqual(response.status_code, 504)
|
|
5540
|
+
detail = response.json().get("detail", "")
|
|
5541
|
+
self.assertIn("did not receive", detail)
|
|
5542
|
+
self.mock_wait.side_effect = self._wait_success
|
|
5543
|
+
|
|
3997
5544
|
def test_change_availability_requires_valid_type(self):
|
|
3998
5545
|
response = self.client.post(
|
|
3999
5546
|
self.url,
|
|
@@ -4001,6 +5548,9 @@ class DispatchActionViewTests(TestCase):
|
|
|
4001
5548
|
content_type="application/json",
|
|
4002
5549
|
)
|
|
4003
5550
|
self.assertEqual(response.status_code, 400)
|
|
5551
|
+
self.assertEqual(
|
|
5552
|
+
response.json().get("detail"), "invalid availability type"
|
|
5553
|
+
)
|
|
4004
5554
|
self.loop.run_until_complete(asyncio.sleep(0))
|
|
4005
5555
|
self.assertEqual(self.ws.sent, [])
|
|
4006
5556
|
self.assertFalse(store.pending_calls)
|
|
@@ -4112,6 +5662,130 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4112
5662
|
self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
|
|
4113
5663
|
store.transactions.pop(key, None)
|
|
4114
5664
|
|
|
5665
|
+
def test_usage_timeline_rendered_when_chart_unavailable(self):
|
|
5666
|
+
original_logs = store.logs["charger"]
|
|
5667
|
+
store.logs["charger"] = {}
|
|
5668
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
5669
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
5670
|
+
charger = Charger.objects.create(charger_id="TL1", connector_id=1)
|
|
5671
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
5672
|
+
|
|
5673
|
+
def build_entry(delta, status):
|
|
5674
|
+
timestamp = fixed_now - delta
|
|
5675
|
+
payload = {
|
|
5676
|
+
"connectorId": 1,
|
|
5677
|
+
"status": status,
|
|
5678
|
+
"timestamp": timestamp.isoformat(),
|
|
5679
|
+
}
|
|
5680
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
5681
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
5682
|
+
|
|
5683
|
+
store.logs["charger"][log_key] = deque(
|
|
5684
|
+
[
|
|
5685
|
+
build_entry(timedelta(days=2), "Available"),
|
|
5686
|
+
build_entry(timedelta(days=1), "Charging"),
|
|
5687
|
+
build_entry(timedelta(hours=12), "Available"),
|
|
5688
|
+
],
|
|
5689
|
+
maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
|
|
5690
|
+
)
|
|
5691
|
+
|
|
5692
|
+
data, _window = _usage_timeline(charger, [], now=fixed_now)
|
|
5693
|
+
self.assertEqual(len(data), 1)
|
|
5694
|
+
statuses = {segment["status"] for segment in data[0]["segments"]}
|
|
5695
|
+
self.assertIn("charging", statuses)
|
|
5696
|
+
self.assertIn("available", statuses)
|
|
5697
|
+
|
|
5698
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
5699
|
+
resp = self.client.get(
|
|
5700
|
+
reverse(
|
|
5701
|
+
"charger-status-connector",
|
|
5702
|
+
args=[charger.charger_id, charger.connector_slug],
|
|
5703
|
+
)
|
|
5704
|
+
)
|
|
5705
|
+
|
|
5706
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
5707
|
+
self.assertContains(resp, "usage-timeline-segment usage-charging")
|
|
5708
|
+
|
|
5709
|
+
def test_usage_timeline_includes_multiple_connectors(self):
|
|
5710
|
+
original_logs = store.logs["charger"]
|
|
5711
|
+
store.logs["charger"] = {}
|
|
5712
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
5713
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
5714
|
+
aggregate = Charger.objects.create(charger_id="TLAGG")
|
|
5715
|
+
connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
|
|
5716
|
+
connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
|
|
5717
|
+
|
|
5718
|
+
def build_entry(connector_id, delta, status):
|
|
5719
|
+
timestamp = fixed_now - delta
|
|
5720
|
+
payload = {
|
|
5721
|
+
"connectorId": connector_id,
|
|
5722
|
+
"status": status,
|
|
5723
|
+
"timestamp": timestamp.isoformat(),
|
|
5724
|
+
}
|
|
5725
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
5726
|
+
key = store.identity_key(aggregate.charger_id, connector_id)
|
|
5727
|
+
store.logs["charger"].setdefault(
|
|
5728
|
+
key, deque(maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES)
|
|
5729
|
+
).append(
|
|
5730
|
+
f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
5731
|
+
)
|
|
5732
|
+
|
|
5733
|
+
build_entry(1, timedelta(days=3), "Available")
|
|
5734
|
+
build_entry(2, timedelta(days=2), "Charging")
|
|
5735
|
+
|
|
5736
|
+
overview = [{"charger": connector_one}, {"charger": connector_two}]
|
|
5737
|
+
data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
|
|
5738
|
+
self.assertEqual(len(data), 2)
|
|
5739
|
+
self.assertTrue(all(entry["segments"] for entry in data))
|
|
5740
|
+
|
|
5741
|
+
with patch("ocpp.views.timezone.now", return_value=fixed_now):
|
|
5742
|
+
resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
|
|
5743
|
+
|
|
5744
|
+
self.assertContains(resp, "Usage (last 7 days)")
|
|
5745
|
+
self.assertContains(resp, connector_one.connector_label)
|
|
5746
|
+
self.assertContains(resp, connector_two.connector_label)
|
|
5747
|
+
|
|
5748
|
+
def test_usage_timeline_merges_repeated_status_entries(self):
|
|
5749
|
+
original_logs = store.logs["charger"]
|
|
5750
|
+
store.logs["charger"] = {}
|
|
5751
|
+
self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
|
|
5752
|
+
fixed_now = timezone.now().replace(microsecond=0)
|
|
5753
|
+
charger = Charger.objects.create(
|
|
5754
|
+
charger_id="TLDEDUP",
|
|
5755
|
+
connector_id=1,
|
|
5756
|
+
last_status="Available",
|
|
5757
|
+
)
|
|
5758
|
+
|
|
5759
|
+
def build_entry(delta, status):
|
|
5760
|
+
timestamp = fixed_now - delta
|
|
5761
|
+
payload = {
|
|
5762
|
+
"connectorId": 1,
|
|
5763
|
+
"status": status,
|
|
5764
|
+
"timestamp": timestamp.isoformat(),
|
|
5765
|
+
}
|
|
5766
|
+
prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
|
|
5767
|
+
return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
|
|
5768
|
+
|
|
5769
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
5770
|
+
store.logs["charger"][log_key] = deque(
|
|
5771
|
+
[
|
|
5772
|
+
build_entry(timedelta(days=6, hours=12), "Available"),
|
|
5773
|
+
build_entry(timedelta(days=5), "Available"),
|
|
5774
|
+
build_entry(timedelta(days=3, hours=6), "Charging"),
|
|
5775
|
+
build_entry(timedelta(days=2), "Charging"),
|
|
5776
|
+
build_entry(timedelta(days=1), "Available"),
|
|
5777
|
+
],
|
|
5778
|
+
maxlen=store.MAX_IN_MEMORY_LOG_ENTRIES,
|
|
5779
|
+
)
|
|
5780
|
+
|
|
5781
|
+
data, window = _usage_timeline(charger, [], now=fixed_now)
|
|
5782
|
+
self.assertIsNotNone(window)
|
|
5783
|
+
self.assertEqual(len(data), 1)
|
|
5784
|
+
segments = data[0]["segments"]
|
|
5785
|
+
self.assertGreaterEqual(len(segments), 1)
|
|
5786
|
+
statuses = [segment["status"] for segment in segments]
|
|
5787
|
+
self.assertEqual(statuses, ["available", "charging", "available"])
|
|
5788
|
+
|
|
4115
5789
|
def test_diagnostics_status_displayed(self):
|
|
4116
5790
|
reported_at = timezone.now().replace(microsecond=0)
|
|
4117
5791
|
charger = Charger.objects.create(
|
|
@@ -4130,6 +5804,37 @@ class ChargerStatusViewTests(TestCase):
|
|
|
4130
5804
|
self.assertContains(resp, "id=\"diagnostics-location\"")
|
|
4131
5805
|
self.assertContains(resp, "https://example.com/report.tar")
|
|
4132
5806
|
|
|
5807
|
+
def test_firmware_download_serves_payload(self):
|
|
5808
|
+
charger = Charger.objects.create(charger_id="DLVIEW")
|
|
5809
|
+
firmware = CPFirmware.objects.create(
|
|
5810
|
+
name="Download",
|
|
5811
|
+
filename="download.bin",
|
|
5812
|
+
payload_binary=b"payload",
|
|
5813
|
+
content_type="application/octet-stream",
|
|
5814
|
+
source=CPFirmware.Source.DOWNLOAD,
|
|
5815
|
+
is_user_data=True,
|
|
5816
|
+
)
|
|
5817
|
+
deployment = CPFirmwareDeployment.objects.create(
|
|
5818
|
+
firmware=firmware,
|
|
5819
|
+
charger=charger,
|
|
5820
|
+
node=charger.node_origin,
|
|
5821
|
+
ocpp_message_id="dl-msg",
|
|
5822
|
+
status="Pending",
|
|
5823
|
+
status_info="",
|
|
5824
|
+
status_timestamp=timezone.now(),
|
|
5825
|
+
retrieve_date=timezone.now(),
|
|
5826
|
+
request_payload={},
|
|
5827
|
+
is_user_data=True,
|
|
5828
|
+
)
|
|
5829
|
+
token = deployment.issue_download_token(lifetime=timedelta(hours=1))
|
|
5830
|
+
response = self.client.get(
|
|
5831
|
+
reverse("cp-firmware-download", args=[deployment.pk, token])
|
|
5832
|
+
)
|
|
5833
|
+
self.assertEqual(response.status_code, 200)
|
|
5834
|
+
self.assertEqual(response.content, b"payload")
|
|
5835
|
+
deployment.refresh_from_db()
|
|
5836
|
+
self.assertIsNotNone(deployment.downloaded_at)
|
|
5837
|
+
|
|
4133
5838
|
def test_connector_status_prefers_connector_diagnostics(self):
|
|
4134
5839
|
aggregate = Charger.objects.create(
|
|
4135
5840
|
charger_id="DIAGCONN",
|
|
@@ -4442,6 +6147,49 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4442
6147
|
)
|
|
4443
6148
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4444
6149
|
|
|
6150
|
+
def test_dashboard_connector_treats_finishing_as_available_without_session(self):
|
|
6151
|
+
charger = Charger.objects.create(
|
|
6152
|
+
charger_id="FINISH-STATE",
|
|
6153
|
+
connector_id=1,
|
|
6154
|
+
last_status="Finishing",
|
|
6155
|
+
)
|
|
6156
|
+
|
|
6157
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
6158
|
+
self.assertEqual(resp.status_code, 200)
|
|
6159
|
+
self.assertIsNotNone(resp.context)
|
|
6160
|
+
context = resp.context
|
|
6161
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
6162
|
+
entry = next(
|
|
6163
|
+
item
|
|
6164
|
+
for item in context["chargers"]
|
|
6165
|
+
if item["charger"].pk == charger.pk
|
|
6166
|
+
)
|
|
6167
|
+
self.assertEqual(entry["state"], available_label)
|
|
6168
|
+
|
|
6169
|
+
def test_dashboard_aggregate_treats_finishing_as_available_without_session(self):
|
|
6170
|
+
aggregate = Charger.objects.create(
|
|
6171
|
+
charger_id="FINISH-AGG",
|
|
6172
|
+
connector_id=None,
|
|
6173
|
+
last_status="Finishing",
|
|
6174
|
+
)
|
|
6175
|
+
Charger.objects.create(
|
|
6176
|
+
charger_id=aggregate.charger_id,
|
|
6177
|
+
connector_id=1,
|
|
6178
|
+
last_status="Finishing",
|
|
6179
|
+
)
|
|
6180
|
+
|
|
6181
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
6182
|
+
self.assertEqual(resp.status_code, 200)
|
|
6183
|
+
self.assertIsNotNone(resp.context)
|
|
6184
|
+
context = resp.context
|
|
6185
|
+
available_label = force_str(STATUS_BADGE_MAP["available"][0])
|
|
6186
|
+
aggregate_entry = next(
|
|
6187
|
+
item
|
|
6188
|
+
for item in context["chargers"]
|
|
6189
|
+
if item["charger"].pk == aggregate.pk
|
|
6190
|
+
)
|
|
6191
|
+
self.assertEqual(aggregate_entry["state"], available_label)
|
|
6192
|
+
|
|
4445
6193
|
def test_dashboard_aggregate_uses_connection_when_status_missing(self):
|
|
4446
6194
|
aggregate = Charger.objects.create(
|
|
4447
6195
|
charger_id="DASHAGG-CONN", last_status="Charging"
|
|
@@ -4466,6 +6214,59 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4466
6214
|
)
|
|
4467
6215
|
self.assertEqual(aggregate_entry["state"], available_label)
|
|
4468
6216
|
|
|
6217
|
+
def test_dashboard_groups_connectors_under_parent(self):
|
|
6218
|
+
aggregate = Charger.objects.create(charger_id="GROUPED")
|
|
6219
|
+
first = Charger.objects.create(
|
|
6220
|
+
charger_id=aggregate.charger_id, connector_id=1
|
|
6221
|
+
)
|
|
6222
|
+
second = Charger.objects.create(
|
|
6223
|
+
charger_id=aggregate.charger_id, connector_id=2
|
|
6224
|
+
)
|
|
6225
|
+
|
|
6226
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
6227
|
+
self.assertEqual(resp.status_code, 200)
|
|
6228
|
+
groups = resp.context["charger_groups"]
|
|
6229
|
+
target = next(
|
|
6230
|
+
group
|
|
6231
|
+
for group in groups
|
|
6232
|
+
if group.get("parent")
|
|
6233
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
6234
|
+
)
|
|
6235
|
+
child_ids = [item["charger"].pk for item in target["children"]]
|
|
6236
|
+
self.assertEqual(child_ids, [first.pk, second.pk])
|
|
6237
|
+
|
|
6238
|
+
def test_dashboard_includes_energy_totals(self):
|
|
6239
|
+
aggregate = Charger.objects.create(charger_id="KWSTATS")
|
|
6240
|
+
now = timezone.now()
|
|
6241
|
+
Transaction.objects.create(
|
|
6242
|
+
charger=aggregate,
|
|
6243
|
+
start_time=now - timedelta(hours=1),
|
|
6244
|
+
stop_time=now,
|
|
6245
|
+
meter_start=0,
|
|
6246
|
+
meter_stop=3000,
|
|
6247
|
+
)
|
|
6248
|
+
past_start = now - timedelta(days=2)
|
|
6249
|
+
Transaction.objects.create(
|
|
6250
|
+
charger=aggregate,
|
|
6251
|
+
start_time=past_start,
|
|
6252
|
+
stop_time=past_start + timedelta(hours=1),
|
|
6253
|
+
meter_start=0,
|
|
6254
|
+
meter_stop=1000,
|
|
6255
|
+
)
|
|
6256
|
+
|
|
6257
|
+
resp = self.client.get(reverse("ocpp-dashboard"))
|
|
6258
|
+
self.assertEqual(resp.status_code, 200)
|
|
6259
|
+
groups = resp.context["charger_groups"]
|
|
6260
|
+
target = next(
|
|
6261
|
+
group
|
|
6262
|
+
for group in groups
|
|
6263
|
+
if group.get("parent")
|
|
6264
|
+
and group["parent"]["charger"].pk == aggregate.pk
|
|
6265
|
+
)
|
|
6266
|
+
stats = target["parent"]["stats"]
|
|
6267
|
+
self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
|
|
6268
|
+
self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
|
|
6269
|
+
|
|
4469
6270
|
def test_cp_simulator_includes_interval(self):
|
|
4470
6271
|
resp = self.client.get(reverse("cp-simulator"))
|
|
4471
6272
|
self.assertEqual(resp.context["request"].live_update_interval, 5)
|
|
@@ -4553,3 +6354,33 @@ class LiveUpdateViewTests(TestCase):
|
|
|
4553
6354
|
reverse("charger-status", args=[restricted.charger_id])
|
|
4554
6355
|
)
|
|
4555
6356
|
self.assertEqual(group_denied.status_code, 404)
|
|
6357
|
+
|
|
6358
|
+
|
|
6359
|
+
class StoreLogBufferTests(TestCase):
|
|
6360
|
+
def test_add_log_enforces_in_memory_cap(self):
|
|
6361
|
+
cid = "BUFFER-CAP-TEST"
|
|
6362
|
+
log_type = "charger"
|
|
6363
|
+
store.clear_log(cid, log_type=log_type)
|
|
6364
|
+
self.addCleanup(lambda: store.clear_log(cid, log_type=log_type))
|
|
6365
|
+
|
|
6366
|
+
with patch("ocpp.store.MAX_IN_MEMORY_LOG_ENTRIES", 3):
|
|
6367
|
+
for index in range(6):
|
|
6368
|
+
store.add_log(cid, f"message {index}", log_type=log_type)
|
|
6369
|
+
|
|
6370
|
+
buffer = None
|
|
6371
|
+
lower = cid.lower()
|
|
6372
|
+
for key, entries in store.logs[log_type].items():
|
|
6373
|
+
if key.lower() == lower:
|
|
6374
|
+
buffer = entries
|
|
6375
|
+
break
|
|
6376
|
+
|
|
6377
|
+
self.assertIsNotNone(buffer, "Expected in-memory log buffer to be created")
|
|
6378
|
+
self.assertIsInstance(buffer, deque)
|
|
6379
|
+
self.assertEqual(len(buffer), 3)
|
|
6380
|
+
self.assertTrue(buffer[0].endswith("message 3"))
|
|
6381
|
+
self.assertTrue(buffer[-1].endswith("message 5"))
|
|
6382
|
+
|
|
6383
|
+
merged = store.get_logs(cid, log_type=log_type)
|
|
6384
|
+
|
|
6385
|
+
self.assertTrue(any(entry.endswith("message 5") for entry in merged))
|
|
6386
|
+
self.assertTrue(any(entry.endswith("message 4") for entry in merged))
|