arthexis 0.1.20__py3-none-any.whl → 0.1.22__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/tasks.py CHANGED
@@ -1,7 +1,10 @@
1
+ import json
1
2
  import logging
3
+ import uuid
2
4
  from datetime import date, datetime, time, timedelta
3
5
  from pathlib import Path
4
6
 
7
+ from asgiref.sync import async_to_sync
5
8
  from celery import shared_task
6
9
  from django.conf import settings
7
10
  from django.contrib.auth import get_user_model
@@ -11,11 +14,106 @@ from django.db.models import Q
11
14
  from core import mailer
12
15
  from nodes.models import Node
13
16
 
14
- from .models import MeterValue, Transaction
17
+ from . import store
18
+ from .models import Charger, MeterValue, Transaction
15
19
 
16
20
  logger = logging.getLogger(__name__)
17
21
 
18
22
 
23
+ @shared_task
24
+ def check_charge_point_configuration(charger_pk: int) -> bool:
25
+ """Request the latest configuration from a connected charge point."""
26
+
27
+ try:
28
+ charger = Charger.objects.get(pk=charger_pk)
29
+ except Charger.DoesNotExist:
30
+ logger.warning(
31
+ "Unable to request configuration for missing charger %s",
32
+ charger_pk,
33
+ )
34
+ return False
35
+
36
+ connector_value = charger.connector_id
37
+ if connector_value is not None:
38
+ logger.debug(
39
+ "Skipping charger %s: connector %s is not eligible for automatic configuration checks",
40
+ charger.charger_id,
41
+ connector_value,
42
+ )
43
+ return False
44
+
45
+ ws = store.get_connection(charger.charger_id, connector_value)
46
+ if ws is None:
47
+ logger.info(
48
+ "Charge point %s is not connected; configuration request skipped",
49
+ charger.charger_id,
50
+ )
51
+ return False
52
+
53
+ message_id = uuid.uuid4().hex
54
+ payload: dict[str, object] = {}
55
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
56
+
57
+ try:
58
+ async_to_sync(ws.send)(msg)
59
+ except Exception as exc: # pragma: no cover - network error
60
+ logger.warning(
61
+ "Failed to send GetConfiguration to %s (%s)",
62
+ charger.charger_id,
63
+ exc,
64
+ )
65
+ return False
66
+
67
+ log_key = store.identity_key(charger.charger_id, connector_value)
68
+ store.add_log(log_key, f"< {msg}", log_type="charger")
69
+ store.register_pending_call(
70
+ message_id,
71
+ {
72
+ "action": "GetConfiguration",
73
+ "charger_id": charger.charger_id,
74
+ "connector_id": connector_value,
75
+ "log_key": log_key,
76
+ "requested_at": timezone.now(),
77
+ },
78
+ )
79
+ store.schedule_call_timeout(
80
+ message_id,
81
+ timeout=5.0,
82
+ action="GetConfiguration",
83
+ log_key=log_key,
84
+ message=(
85
+ "GetConfiguration timed out: charger did not respond"
86
+ " (operation may not be supported)"
87
+ ),
88
+ )
89
+ logger.info(
90
+ "Requested configuration from charge point %s",
91
+ charger.charger_id,
92
+ )
93
+ return True
94
+
95
+
96
+ @shared_task
97
+ def schedule_daily_charge_point_configuration_checks() -> int:
98
+ """Dispatch configuration requests for eligible charge points."""
99
+
100
+ charger_ids = list(
101
+ Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
102
+ )
103
+ if not charger_ids:
104
+ logger.debug("No eligible charge points available for configuration check")
105
+ return 0
106
+
107
+ scheduled = 0
108
+ for charger_pk in charger_ids:
109
+ check_charge_point_configuration.delay(charger_pk)
110
+ scheduled += 1
111
+ logger.info(
112
+ "Scheduled configuration checks for %s charge point(s)", scheduled
113
+ )
114
+ return scheduled
115
+
116
+
19
117
  @shared_task
20
118
  def purge_meter_values() -> int:
21
119
  """Delete meter values older than 7 days.
ocpp/tests.py CHANGED
@@ -55,6 +55,7 @@ from django.contrib.sites.models import Site
55
55
  from django.core.exceptions import ValidationError
56
56
  from pages.models import Application, Module
57
57
  from nodes.models import Node, NodeRole
58
+ from django.contrib.admin.sites import AdminSite
58
59
 
59
60
  from config.asgi import application
60
61
 
@@ -67,8 +68,9 @@ from .models import (
67
68
  Location,
68
69
  DataTransferMessage,
69
70
  )
71
+ from .admin import ChargerConfigurationAdmin
70
72
  from .consumers import CSMSConsumer
71
- from .views import dispatch_action, _transaction_rfid_details
73
+ from .views import dispatch_action, _transaction_rfid_details, _usage_timeline
72
74
  from .status_display import STATUS_BADGE_MAP
73
75
  from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
74
76
  from . import store
@@ -81,7 +83,12 @@ from .simulator import SimulatorConfig, ChargePointSimulator
81
83
  from .evcs import simulate, SimulatorState, _simulators
82
84
  import re
83
85
  from datetime import datetime, timedelta, timezone as dt_timezone
84
- from .tasks import purge_meter_readings, send_daily_session_report
86
+ from .tasks import (
87
+ purge_meter_readings,
88
+ send_daily_session_report,
89
+ check_charge_point_configuration,
90
+ schedule_daily_charge_point_configuration_checks,
91
+ )
85
92
  from django.db import close_old_connections
86
93
  from django.db.utils import OperationalError
87
94
  from urllib.parse import unquote, urlparse
@@ -757,6 +764,7 @@ class CSMSConsumerTests(TransactionTestCase):
757
764
  )()
758
765
  self.assertIsNotNone(configuration)
759
766
  self.assertEqual(configuration.charger_identifier, "CFGRES")
767
+ self.assertIsNotNone(configuration.evcs_snapshot_at)
760
768
  self.assertEqual(
761
769
  configuration.configuration_keys,
762
770
  [
@@ -2335,6 +2343,36 @@ class ChargerAdminTests(TestCase):
2335
2343
  resp = self.client.get(url)
2336
2344
  self.assertContains(resp, "AdminLoc")
2337
2345
 
2346
+ def test_admin_changelist_displays_quick_stats(self):
2347
+ charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
2348
+ connector = Charger.objects.create(
2349
+ charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
2350
+ )
2351
+ start = timezone.now() - timedelta(minutes=30)
2352
+ Transaction.objects.create(
2353
+ charger=connector,
2354
+ start_time=start,
2355
+ stop_time=start + timedelta(minutes=10),
2356
+ meter_start=1000,
2357
+ meter_stop=6000,
2358
+ )
2359
+
2360
+ url = reverse("admin:ocpp_charger_changelist")
2361
+ resp = self.client.get(url)
2362
+
2363
+ self.assertContains(resp, "Total kW")
2364
+ self.assertContains(resp, "Today kW")
2365
+ self.assertContains(resp, "5.00")
2366
+
2367
+ def test_admin_changelist_does_not_indent_connectors(self):
2368
+ Charger.objects.create(charger_id="INDENTMAIN")
2369
+ Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
2370
+
2371
+ url = reverse("admin:ocpp_charger_changelist")
2372
+ resp = self.client.get(url)
2373
+
2374
+ self.assertNotContains(resp, 'class="charger-connector-entry"')
2375
+
2338
2376
  def test_last_fields_are_read_only(self):
2339
2377
  now = timezone.now()
2340
2378
  charger = Charger.objects.create(
@@ -2584,6 +2622,81 @@ class ChargerAdminTests(TestCase):
2584
2622
  store.clear_log(pending_key, log_type="charger")
2585
2623
 
2586
2624
 
2625
+ class ChargerConfigurationAdminUnitTests(TestCase):
2626
+ def setUp(self):
2627
+ self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
2628
+ self.request_factory = RequestFactory()
2629
+
2630
+ def test_origin_display_returns_evcs_when_snapshot_present(self):
2631
+ configuration = ChargerConfiguration.objects.create(
2632
+ charger_identifier="CFG-EVCS",
2633
+ evcs_snapshot_at=timezone.now(),
2634
+ )
2635
+ self.assertEqual(self.admin.origin_display(configuration), "EVCS")
2636
+
2637
+ def test_origin_display_returns_local_without_snapshot(self):
2638
+ configuration = ChargerConfiguration.objects.create(
2639
+ charger_identifier="CFG-LOCAL",
2640
+ )
2641
+ self.assertEqual(self.admin.origin_display(configuration), "Local")
2642
+
2643
+ def test_save_model_resets_snapshot_timestamp(self):
2644
+ configuration = ChargerConfiguration.objects.create(
2645
+ charger_identifier="CFG-SAVE",
2646
+ evcs_snapshot_at=timezone.now(),
2647
+ )
2648
+ request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
2649
+ self.admin.save_model(request, configuration, form=None, change=True)
2650
+ configuration.refresh_from_db()
2651
+ self.assertIsNone(configuration.evcs_snapshot_at)
2652
+
2653
+
2654
+ class ConfigurationTaskTests(TestCase):
2655
+ def tearDown(self):
2656
+ store.pending_calls.clear()
2657
+
2658
+ def test_check_charge_point_configuration_dispatches_request(self):
2659
+ charger = Charger.objects.create(charger_id="TASKCFG")
2660
+ ws = DummyWebSocket()
2661
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
2662
+ pending_key = store.pending_key(charger.charger_id)
2663
+ store.clear_log(log_key, log_type="charger")
2664
+ store.clear_log(pending_key, log_type="charger")
2665
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2666
+ try:
2667
+ result = check_charge_point_configuration.run(charger.pk)
2668
+ self.assertTrue(result)
2669
+ self.assertEqual(len(ws.sent), 1)
2670
+ frame = json.loads(ws.sent[0])
2671
+ self.assertEqual(frame[0], 2)
2672
+ self.assertEqual(frame[2], "GetConfiguration")
2673
+ self.assertIn(frame[1], store.pending_calls)
2674
+ finally:
2675
+ store.pop_connection(charger.charger_id, charger.connector_id)
2676
+ store.pending_calls.clear()
2677
+ store.clear_log(log_key, log_type="charger")
2678
+ store.clear_log(pending_key, log_type="charger")
2679
+
2680
+ def test_check_charge_point_configuration_without_connection(self):
2681
+ charger = Charger.objects.create(charger_id="TASKNOCONN")
2682
+ result = check_charge_point_configuration.run(charger.pk)
2683
+ self.assertFalse(result)
2684
+
2685
+ def test_schedule_daily_checks_only_includes_root_chargers(self):
2686
+ eligible = Charger.objects.create(charger_id="TASKROOT")
2687
+ Charger.objects.create(charger_id="TASKCONN", connector_id=1)
2688
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
2689
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
2690
+ self.assertEqual(scheduled, 1)
2691
+ mock_delay.assert_called_once_with(eligible.pk)
2692
+
2693
+ def test_schedule_daily_checks_returns_zero_without_chargers(self):
2694
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
2695
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
2696
+ self.assertEqual(scheduled, 0)
2697
+ mock_delay.assert_not_called()
2698
+
2699
+
2587
2700
  class LocationAdminTests(TestCase):
2588
2701
  def setUp(self):
2589
2702
  self.client = Client()
@@ -2857,6 +2970,28 @@ class SimulatorAdminTests(TransactionTestCase):
2857
2970
 
2858
2971
  await communicator.disconnect()
2859
2972
 
2973
+ def test_auto_registered_charger_location_name_sanitized(self):
2974
+ async def exercise():
2975
+ communicator = WebsocketCommunicator(
2976
+ application, "/?cid=ACME%20Charger%20%231"
2977
+ )
2978
+ connected, _ = await communicator.connect()
2979
+ self.assertTrue(connected)
2980
+
2981
+ await communicator.disconnect()
2982
+
2983
+ def fetch_location_name() -> str:
2984
+ charger = (
2985
+ Charger.objects.select_related("location")
2986
+ .get(charger_id="ACME Charger #1")
2987
+ )
2988
+ return charger.location.name
2989
+
2990
+ location_name = await database_sync_to_async(fetch_location_name)()
2991
+ self.assertEqual(location_name, "ACME_Charger_1")
2992
+
2993
+ async_to_sync(exercise)()
2994
+
2860
2995
  async def test_query_string_cid_supported(self):
2861
2996
  communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
2862
2997
  connected, _ = await communicator.connect()
@@ -3294,6 +3429,10 @@ class ChargerLocationTests(TestCase):
3294
3429
  second = Charger.objects.create(charger_id="SHARE", connector_id=2)
3295
3430
  self.assertEqual(second.location, first.location)
3296
3431
 
3432
+ def test_location_name_sanitized_when_auto_created(self):
3433
+ charger = Charger.objects.create(charger_id="Name With spaces!#1")
3434
+ self.assertEqual(charger.location.name, "Name_With_spaces_1")
3435
+
3297
3436
 
3298
3437
  class MeterReadingTests(TransactionTestCase):
3299
3438
  async def test_meter_values_saved_as_readings(self):
@@ -4398,6 +4537,122 @@ class ChargerStatusViewTests(TestCase):
4398
4537
  self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
4399
4538
  store.transactions.pop(key, None)
4400
4539
 
4540
+ def test_usage_timeline_rendered_when_chart_unavailable(self):
4541
+ original_logs = store.logs["charger"]
4542
+ store.logs["charger"] = {}
4543
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4544
+ fixed_now = timezone.now().replace(microsecond=0)
4545
+ charger = Charger.objects.create(charger_id="TL1", connector_id=1)
4546
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
4547
+
4548
+ def build_entry(delta, status):
4549
+ timestamp = fixed_now - delta
4550
+ payload = {
4551
+ "connectorId": 1,
4552
+ "status": status,
4553
+ "timestamp": timestamp.isoformat(),
4554
+ }
4555
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4556
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4557
+
4558
+ store.logs["charger"][log_key] = [
4559
+ build_entry(timedelta(days=2), "Available"),
4560
+ build_entry(timedelta(days=1), "Charging"),
4561
+ build_entry(timedelta(hours=12), "Available"),
4562
+ ]
4563
+
4564
+ data, _window = _usage_timeline(charger, [], now=fixed_now)
4565
+ self.assertEqual(len(data), 1)
4566
+ statuses = {segment["status"] for segment in data[0]["segments"]}
4567
+ self.assertIn("charging", statuses)
4568
+ self.assertIn("available", statuses)
4569
+
4570
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
4571
+ resp = self.client.get(
4572
+ reverse(
4573
+ "charger-status-connector",
4574
+ args=[charger.charger_id, charger.connector_slug],
4575
+ )
4576
+ )
4577
+
4578
+ self.assertContains(resp, "Usage (last 7 days)")
4579
+ self.assertContains(resp, "usage-timeline-segment usage-charging")
4580
+
4581
+ def test_usage_timeline_includes_multiple_connectors(self):
4582
+ original_logs = store.logs["charger"]
4583
+ store.logs["charger"] = {}
4584
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4585
+ fixed_now = timezone.now().replace(microsecond=0)
4586
+ aggregate = Charger.objects.create(charger_id="TLAGG")
4587
+ connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
4588
+ connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
4589
+
4590
+ def build_entry(connector_id, delta, status):
4591
+ timestamp = fixed_now - delta
4592
+ payload = {
4593
+ "connectorId": connector_id,
4594
+ "status": status,
4595
+ "timestamp": timestamp.isoformat(),
4596
+ }
4597
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4598
+ key = store.identity_key(aggregate.charger_id, connector_id)
4599
+ store.logs["charger"].setdefault(key, []).append(
4600
+ f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4601
+ )
4602
+
4603
+ build_entry(1, timedelta(days=3), "Available")
4604
+ build_entry(2, timedelta(days=2), "Charging")
4605
+
4606
+ overview = [{"charger": connector_one}, {"charger": connector_two}]
4607
+ data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
4608
+ self.assertEqual(len(data), 2)
4609
+ self.assertTrue(all(entry["segments"] for entry in data))
4610
+
4611
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
4612
+ resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
4613
+
4614
+ self.assertContains(resp, "Usage (last 7 days)")
4615
+ self.assertContains(resp, connector_one.connector_label)
4616
+ self.assertContains(resp, connector_two.connector_label)
4617
+
4618
+ def test_usage_timeline_merges_repeated_status_entries(self):
4619
+ original_logs = store.logs["charger"]
4620
+ store.logs["charger"] = {}
4621
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4622
+ fixed_now = timezone.now().replace(microsecond=0)
4623
+ charger = Charger.objects.create(
4624
+ charger_id="TLDEDUP",
4625
+ connector_id=1,
4626
+ last_status="Available",
4627
+ )
4628
+
4629
+ def build_entry(delta, status):
4630
+ timestamp = fixed_now - delta
4631
+ payload = {
4632
+ "connectorId": 1,
4633
+ "status": status,
4634
+ "timestamp": timestamp.isoformat(),
4635
+ }
4636
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4637
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4638
+
4639
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
4640
+ store.logs["charger"][log_key] = [
4641
+ build_entry(timedelta(days=6, hours=12), "Available"),
4642
+ build_entry(timedelta(days=5), "Available"),
4643
+ build_entry(timedelta(days=3, hours=6), "Charging"),
4644
+ build_entry(timedelta(days=2), "Charging"),
4645
+ build_entry(timedelta(days=1), "Available"),
4646
+ ]
4647
+
4648
+ data, window = _usage_timeline(charger, [], now=fixed_now)
4649
+ self.assertIsNotNone(window)
4650
+ self.assertEqual(len(data), 1)
4651
+ segments = data[0]["segments"]
4652
+ self.assertGreaterEqual(len(segments), 1)
4653
+ statuses = [segment["status"] for segment in segments]
4654
+ self.assertEqual(statuses, ["available", "charging", "available"])
4655
+
4401
4656
  def test_diagnostics_status_displayed(self):
4402
4657
  reported_at = timezone.now().replace(microsecond=0)
4403
4658
  charger = Charger.objects.create(
@@ -4795,6 +5050,59 @@ class LiveUpdateViewTests(TestCase):
4795
5050
  )
4796
5051
  self.assertEqual(aggregate_entry["state"], available_label)
4797
5052
 
5053
+ def test_dashboard_groups_connectors_under_parent(self):
5054
+ aggregate = Charger.objects.create(charger_id="GROUPED")
5055
+ first = Charger.objects.create(
5056
+ charger_id=aggregate.charger_id, connector_id=1
5057
+ )
5058
+ second = Charger.objects.create(
5059
+ charger_id=aggregate.charger_id, connector_id=2
5060
+ )
5061
+
5062
+ resp = self.client.get(reverse("ocpp-dashboard"))
5063
+ self.assertEqual(resp.status_code, 200)
5064
+ groups = resp.context["charger_groups"]
5065
+ target = next(
5066
+ group
5067
+ for group in groups
5068
+ if group.get("parent")
5069
+ and group["parent"]["charger"].pk == aggregate.pk
5070
+ )
5071
+ child_ids = [item["charger"].pk for item in target["children"]]
5072
+ self.assertEqual(child_ids, [first.pk, second.pk])
5073
+
5074
+ def test_dashboard_includes_energy_totals(self):
5075
+ aggregate = Charger.objects.create(charger_id="KWSTATS")
5076
+ now = timezone.now()
5077
+ Transaction.objects.create(
5078
+ charger=aggregate,
5079
+ start_time=now - timedelta(hours=1),
5080
+ stop_time=now,
5081
+ meter_start=0,
5082
+ meter_stop=3000,
5083
+ )
5084
+ past_start = now - timedelta(days=2)
5085
+ Transaction.objects.create(
5086
+ charger=aggregate,
5087
+ start_time=past_start,
5088
+ stop_time=past_start + timedelta(hours=1),
5089
+ meter_start=0,
5090
+ meter_stop=1000,
5091
+ )
5092
+
5093
+ resp = self.client.get(reverse("ocpp-dashboard"))
5094
+ self.assertEqual(resp.status_code, 200)
5095
+ groups = resp.context["charger_groups"]
5096
+ target = next(
5097
+ group
5098
+ for group in groups
5099
+ if group.get("parent")
5100
+ and group["parent"]["charger"].pk == aggregate.pk
5101
+ )
5102
+ stats = target["parent"]["stats"]
5103
+ self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
5104
+ self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
5105
+
4798
5106
  def test_cp_simulator_includes_interval(self):
4799
5107
  resp = self.client.get(reverse("cp-simulator"))
4800
5108
  self.assertEqual(resp.context["request"].live_update_interval, 5)