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

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

Potentially problematic release.


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

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