arthexis 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

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