arthexis 0.1.21__py3-none-any.whl → 0.1.23__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/models.py CHANGED
@@ -15,6 +15,7 @@ from nodes.models import Node
15
15
 
16
16
  from core.models import (
17
17
  EnergyAccount,
18
+ EnergyTariff,
18
19
  Reference,
19
20
  RFID as CoreRFID,
20
21
  ElectricVehicle as CoreElectricVehicle,
@@ -35,6 +36,22 @@ class Location(Entity):
35
36
  longitude = models.DecimalField(
36
37
  max_digits=9, decimal_places=6, null=True, blank=True
37
38
  )
39
+ zone = models.CharField(
40
+ max_length=3,
41
+ choices=EnergyTariff.Zone.choices,
42
+ blank=True,
43
+ null=True,
44
+ help_text=_("CFE climate zone used to select matching energy tariffs."),
45
+ )
46
+ contract_type = models.CharField(
47
+ max_length=16,
48
+ choices=EnergyTariff.ContractType.choices,
49
+ blank=True,
50
+ null=True,
51
+ help_text=_(
52
+ "CFE service contract type required to match energy tariff pricing."
53
+ ),
54
+ )
38
55
 
39
56
  def __str__(self) -> str: # pragma: no cover - simple representation
40
57
  return self.name
@@ -48,6 +65,7 @@ class Charger(Entity):
48
65
  """Known charge point."""
49
66
 
50
67
  _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
68
+ _AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
51
69
 
52
70
  OPERATIVE_STATUSES = {
53
71
  "Available",
@@ -324,6 +342,16 @@ class Charger(Entity):
324
342
  )
325
343
  return normalized
326
344
 
345
+ @classmethod
346
+ def sanitize_auto_location_name(cls, value: str) -> str:
347
+ """Return a location name containing only safe characters."""
348
+
349
+ sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
350
+ sanitized = re.sub(r"_+", "_", sanitized).strip("_")
351
+ if not sanitized:
352
+ return "Charger"
353
+ return sanitized
354
+
327
355
  AGGREGATE_CONNECTOR_SLUG = "all"
328
356
 
329
357
  def identity_tuple(self) -> tuple[str, int | None]:
@@ -459,7 +487,8 @@ class Charger(Entity):
459
487
  if existing:
460
488
  self.location = existing.location
461
489
  else:
462
- location, _ = Location.objects.get_or_create(name=self.charger_id)
490
+ auto_name = type(self).sanitize_auto_location_name(self.charger_id)
491
+ location, _ = Location.objects.get_or_create(name=auto_name)
463
492
  self.location = location
464
493
  if update_list is not None and "location" not in update_list:
465
494
  update_list.append("location")
@@ -469,11 +498,17 @@ class Charger(Entity):
469
498
  ref_value = self._full_url()
470
499
  if url_targets_local_loopback(ref_value):
471
500
  return
472
- if not self.reference or self.reference.value != ref_value:
501
+ if not self.reference:
473
502
  self.reference = Reference.objects.create(
474
503
  value=ref_value, alt_text=self.charger_id
475
504
  )
476
505
  super().save(update_fields=["reference"])
506
+ elif self.reference.value != ref_value:
507
+ Reference.objects.filter(pk=self.reference_id).update(
508
+ value=ref_value, alt_text=self.charger_id
509
+ )
510
+ self.reference.value = ref_value
511
+ self.reference.alt_text = self.charger_id
477
512
 
478
513
  def refresh_manager_node(self, node: Node | None = None) -> Node | None:
479
514
  """Ensure ``manager_node`` matches the provided or local node."""
@@ -663,6 +698,14 @@ class ChargerConfiguration(models.Model):
663
698
  blank=True,
664
699
  help_text=_("Keys returned in the unknownKey list."),
665
700
  )
701
+ evcs_snapshot_at = models.DateTimeField(
702
+ _("EVCS snapshot at"),
703
+ null=True,
704
+ blank=True,
705
+ help_text=_(
706
+ "Timestamp when this configuration was received from the charge point."
707
+ ),
708
+ )
666
709
  raw_payload = models.JSONField(
667
710
  default=dict,
668
711
  blank=True,
@@ -763,7 +806,11 @@ class Transaction(Entity):
763
806
  def vehicle_identifier(self) -> str:
764
807
  """Return the preferred vehicle identifier for this transaction."""
765
808
 
766
- return (self.vid or self.vin or "").strip()
809
+ vid = (self.vid or "").strip()
810
+ if vid:
811
+ return vid
812
+
813
+ return (self.vin or "").strip()
767
814
 
768
815
  @property
769
816
  def vehicle_identifier_source(self) -> str:
@@ -1023,6 +1070,8 @@ class DataTransferMessage(models.Model):
1023
1070
 
1024
1071
  class Meta:
1025
1072
  ordering = ["-created_at"]
1073
+ verbose_name = _("Data Message")
1074
+ verbose_name_plural = _("Data Messages")
1026
1075
  indexes = [
1027
1076
  models.Index(
1028
1077
  fields=["ocpp_message_id"],
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
@@ -170,6 +177,36 @@ class DispatchActionTests(TestCase):
170
177
  self.assertEqual(metadata.get("trigger_target"), "BootNotification")
171
178
  self.assertEqual(metadata.get("log_key"), log_key)
172
179
 
180
+ def test_reset_rejected_when_transaction_active(self):
181
+ charger = Charger.objects.create(charger_id="RESETBLOCK")
182
+ dummy = DummyWebSocket()
183
+ connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
184
+ self.addCleanup(lambda: store.connections.pop(connection_key, None))
185
+ tx_obj = Transaction.objects.create(
186
+ charger=charger,
187
+ connector_id=charger.connector_id,
188
+ start_time=timezone.now(),
189
+ )
190
+ tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
191
+ self.addCleanup(lambda: store.transactions.pop(tx_key, None))
192
+
193
+ request = self.factory.post(
194
+ "/chargers/RESETBLOCK/action/",
195
+ data=json.dumps({"action": "reset"}),
196
+ content_type="application/json",
197
+ )
198
+ request.user = SimpleNamespace(
199
+ is_authenticated=True,
200
+ is_superuser=True,
201
+ is_staff=True,
202
+ )
203
+
204
+ response = dispatch_action(request, charger.charger_id)
205
+ self.assertEqual(response.status_code, 409)
206
+ payload = json.loads(response.content.decode("utf-8"))
207
+ self.assertIn("stop the session first", payload.get("detail", "").lower())
208
+ self.assertFalse(dummy.sent)
209
+
173
210
  class ChargerFixtureTests(TestCase):
174
211
  fixtures = [
175
212
  p.name
@@ -211,6 +248,62 @@ class ChargerFixtureTests(TestCase):
211
248
  self.assertEqual(cp2.name, "Simulator #2")
212
249
 
213
250
 
251
+ class ChargerRefreshManagerNodeTests(TestCase):
252
+ @classmethod
253
+ def setUpTestData(cls):
254
+ local = Node.objects.create(
255
+ hostname="local-node",
256
+ address="127.0.0.1",
257
+ port=8000,
258
+ mac_address="aa:bb:cc:dd:ee:ff",
259
+ current_relation=Node.Relation.SELF,
260
+ )
261
+ Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
262
+ cls.local_node = Node.objects.get(pk=local.pk)
263
+
264
+ def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
265
+ charger = Charger(charger_id="UNSAVED")
266
+
267
+ with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
268
+ result = charger.refresh_manager_node()
269
+
270
+ self.assertEqual(result, self.local_node)
271
+ self.assertEqual(charger.manager_node, self.local_node)
272
+
273
+ def test_refresh_manager_node_updates_persisted_charger(self):
274
+ remote = Node.objects.create(
275
+ hostname="remote-node",
276
+ address="10.0.0.1",
277
+ port=9000,
278
+ mac_address="11:22:33:44:55:66",
279
+ )
280
+ charger = Charger.objects.create(
281
+ charger_id="PERSISTED",
282
+ manager_node=remote,
283
+ )
284
+
285
+ charger.refresh_manager_node(node=self.local_node)
286
+
287
+ self.assertEqual(charger.manager_node, self.local_node)
288
+ charger.refresh_from_db()
289
+ self.assertEqual(charger.manager_node, self.local_node)
290
+
291
+ def test_refresh_manager_node_handles_missing_local_node(self):
292
+ remote = Node.objects.create(
293
+ hostname="existing-manager",
294
+ address="10.0.0.2",
295
+ port=9001,
296
+ mac_address="22:33:44:55:66:77",
297
+ )
298
+ charger = Charger(charger_id="NOLOCAL", manager_node=remote)
299
+
300
+ with patch.object(Node, "get_local", return_value=None):
301
+ result = charger.refresh_manager_node()
302
+
303
+ self.assertIsNone(result)
304
+ self.assertEqual(charger.manager_node, remote)
305
+
306
+
214
307
  class ChargerUrlFallbackTests(TestCase):
215
308
  @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
216
309
  def test_reference_created_when_site_missing(self):
@@ -757,6 +850,7 @@ class CSMSConsumerTests(TransactionTestCase):
757
850
  )()
758
851
  self.assertIsNotNone(configuration)
759
852
  self.assertEqual(configuration.charger_identifier, "CFGRES")
853
+ self.assertIsNotNone(configuration.evcs_snapshot_at)
760
854
  self.assertEqual(
761
855
  configuration.configuration_keys,
762
856
  [
@@ -2312,6 +2406,43 @@ class ChargerAdminTests(TestCase):
2312
2406
  store.pop_connection(charger.charger_id, charger.connector_id)
2313
2407
  store.clear_pending_calls(charger.charger_id)
2314
2408
 
2409
+ def test_reset_charger_action_skips_when_transaction_active(self):
2410
+ charger = Charger.objects.create(charger_id="RESETADMIN")
2411
+
2412
+ class DummyConnection:
2413
+ def __init__(self):
2414
+ self.sent: list[str] = []
2415
+
2416
+ async def send(self, message):
2417
+ self.sent.append(message)
2418
+
2419
+ ws = DummyConnection()
2420
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2421
+ tx_obj = Transaction.objects.create(
2422
+ charger=charger,
2423
+ connector_id=charger.connector_id,
2424
+ start_time=timezone.now(),
2425
+ )
2426
+ store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
2427
+ try:
2428
+ url = reverse("admin:ocpp_charger_changelist")
2429
+ response = self.client.post(
2430
+ url,
2431
+ {
2432
+ "action": "reset_chargers",
2433
+ "index": 0,
2434
+ "select_across": 0,
2435
+ "_selected_action": [charger.pk],
2436
+ },
2437
+ follow=True,
2438
+ )
2439
+ self.assertEqual(response.status_code, 200)
2440
+ self.assertFalse(ws.sent)
2441
+ self.assertContains(response, "stop the session first")
2442
+ finally:
2443
+ store.pop_connection(charger.charger_id, charger.connector_id)
2444
+ store.pop_transaction(charger.charger_id, charger.connector_id)
2445
+
2315
2446
  def test_admin_log_view_displays_entries(self):
2316
2447
  charger = Charger.objects.create(charger_id="LOG2")
2317
2448
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
@@ -2614,6 +2745,81 @@ class ChargerAdminTests(TestCase):
2614
2745
  store.clear_log(pending_key, log_type="charger")
2615
2746
 
2616
2747
 
2748
+ class ChargerConfigurationAdminUnitTests(TestCase):
2749
+ def setUp(self):
2750
+ self.admin = ChargerConfigurationAdmin(ChargerConfiguration, AdminSite())
2751
+ self.request_factory = RequestFactory()
2752
+
2753
+ def test_origin_display_returns_evcs_when_snapshot_present(self):
2754
+ configuration = ChargerConfiguration.objects.create(
2755
+ charger_identifier="CFG-EVCS",
2756
+ evcs_snapshot_at=timezone.now(),
2757
+ )
2758
+ self.assertEqual(self.admin.origin_display(configuration), "EVCS")
2759
+
2760
+ def test_origin_display_returns_local_without_snapshot(self):
2761
+ configuration = ChargerConfiguration.objects.create(
2762
+ charger_identifier="CFG-LOCAL",
2763
+ )
2764
+ self.assertEqual(self.admin.origin_display(configuration), "Local")
2765
+
2766
+ def test_save_model_resets_snapshot_timestamp(self):
2767
+ configuration = ChargerConfiguration.objects.create(
2768
+ charger_identifier="CFG-SAVE",
2769
+ evcs_snapshot_at=timezone.now(),
2770
+ )
2771
+ request = self.request_factory.post("/admin/ocpp/chargerconfiguration/")
2772
+ self.admin.save_model(request, configuration, form=None, change=True)
2773
+ configuration.refresh_from_db()
2774
+ self.assertIsNone(configuration.evcs_snapshot_at)
2775
+
2776
+
2777
+ class ConfigurationTaskTests(TestCase):
2778
+ def tearDown(self):
2779
+ store.pending_calls.clear()
2780
+
2781
+ def test_check_charge_point_configuration_dispatches_request(self):
2782
+ charger = Charger.objects.create(charger_id="TASKCFG")
2783
+ ws = DummyWebSocket()
2784
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
2785
+ pending_key = store.pending_key(charger.charger_id)
2786
+ store.clear_log(log_key, log_type="charger")
2787
+ store.clear_log(pending_key, log_type="charger")
2788
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2789
+ try:
2790
+ result = check_charge_point_configuration.run(charger.pk)
2791
+ self.assertTrue(result)
2792
+ self.assertEqual(len(ws.sent), 1)
2793
+ frame = json.loads(ws.sent[0])
2794
+ self.assertEqual(frame[0], 2)
2795
+ self.assertEqual(frame[2], "GetConfiguration")
2796
+ self.assertIn(frame[1], store.pending_calls)
2797
+ finally:
2798
+ store.pop_connection(charger.charger_id, charger.connector_id)
2799
+ store.pending_calls.clear()
2800
+ store.clear_log(log_key, log_type="charger")
2801
+ store.clear_log(pending_key, log_type="charger")
2802
+
2803
+ def test_check_charge_point_configuration_without_connection(self):
2804
+ charger = Charger.objects.create(charger_id="TASKNOCONN")
2805
+ result = check_charge_point_configuration.run(charger.pk)
2806
+ self.assertFalse(result)
2807
+
2808
+ def test_schedule_daily_checks_only_includes_root_chargers(self):
2809
+ eligible = Charger.objects.create(charger_id="TASKROOT")
2810
+ Charger.objects.create(charger_id="TASKCONN", connector_id=1)
2811
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
2812
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
2813
+ self.assertEqual(scheduled, 1)
2814
+ mock_delay.assert_called_once_with(eligible.pk)
2815
+
2816
+ def test_schedule_daily_checks_returns_zero_without_chargers(self):
2817
+ with patch("ocpp.tasks.check_charge_point_configuration.delay") as mock_delay:
2818
+ scheduled = schedule_daily_charge_point_configuration_checks.run()
2819
+ self.assertEqual(scheduled, 0)
2820
+ mock_delay.assert_not_called()
2821
+
2822
+
2617
2823
  class LocationAdminTests(TestCase):
2618
2824
  def setUp(self):
2619
2825
  self.client = Client()
@@ -2887,6 +3093,28 @@ class SimulatorAdminTests(TransactionTestCase):
2887
3093
 
2888
3094
  await communicator.disconnect()
2889
3095
 
3096
+ def test_auto_registered_charger_location_name_sanitized(self):
3097
+ async def exercise():
3098
+ communicator = WebsocketCommunicator(
3099
+ application, "/?cid=ACME%20Charger%20%231"
3100
+ )
3101
+ connected, _ = await communicator.connect()
3102
+ self.assertTrue(connected)
3103
+
3104
+ await communicator.disconnect()
3105
+
3106
+ def fetch_location_name() -> str:
3107
+ charger = (
3108
+ Charger.objects.select_related("location")
3109
+ .get(charger_id="ACME Charger #1")
3110
+ )
3111
+ return charger.location.name
3112
+
3113
+ location_name = await database_sync_to_async(fetch_location_name)()
3114
+ self.assertEqual(location_name, "ACME_Charger_1")
3115
+
3116
+ async_to_sync(exercise)()
3117
+
2890
3118
  async def test_query_string_cid_supported(self):
2891
3119
  communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
2892
3120
  connected, _ = await communicator.connect()
@@ -3324,6 +3552,10 @@ class ChargerLocationTests(TestCase):
3324
3552
  second = Charger.objects.create(charger_id="SHARE", connector_id=2)
3325
3553
  self.assertEqual(second.location, first.location)
3326
3554
 
3555
+ def test_location_name_sanitized_when_auto_created(self):
3556
+ charger = Charger.objects.create(charger_id="Name With spaces!#1")
3557
+ self.assertEqual(charger.location.name, "Name_With_spaces_1")
3558
+
3327
3559
 
3328
3560
  class MeterReadingTests(TransactionTestCase):
3329
3561
  async def test_meter_values_saved_as_readings(self):
@@ -4428,6 +4660,122 @@ class ChargerStatusViewTests(TestCase):
4428
4660
  self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
4429
4661
  store.transactions.pop(key, None)
4430
4662
 
4663
+ def test_usage_timeline_rendered_when_chart_unavailable(self):
4664
+ original_logs = store.logs["charger"]
4665
+ store.logs["charger"] = {}
4666
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4667
+ fixed_now = timezone.now().replace(microsecond=0)
4668
+ charger = Charger.objects.create(charger_id="TL1", connector_id=1)
4669
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
4670
+
4671
+ def build_entry(delta, status):
4672
+ timestamp = fixed_now - delta
4673
+ payload = {
4674
+ "connectorId": 1,
4675
+ "status": status,
4676
+ "timestamp": timestamp.isoformat(),
4677
+ }
4678
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4679
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4680
+
4681
+ store.logs["charger"][log_key] = [
4682
+ build_entry(timedelta(days=2), "Available"),
4683
+ build_entry(timedelta(days=1), "Charging"),
4684
+ build_entry(timedelta(hours=12), "Available"),
4685
+ ]
4686
+
4687
+ data, _window = _usage_timeline(charger, [], now=fixed_now)
4688
+ self.assertEqual(len(data), 1)
4689
+ statuses = {segment["status"] for segment in data[0]["segments"]}
4690
+ self.assertIn("charging", statuses)
4691
+ self.assertIn("available", statuses)
4692
+
4693
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
4694
+ resp = self.client.get(
4695
+ reverse(
4696
+ "charger-status-connector",
4697
+ args=[charger.charger_id, charger.connector_slug],
4698
+ )
4699
+ )
4700
+
4701
+ self.assertContains(resp, "Usage (last 7 days)")
4702
+ self.assertContains(resp, "usage-timeline-segment usage-charging")
4703
+
4704
+ def test_usage_timeline_includes_multiple_connectors(self):
4705
+ original_logs = store.logs["charger"]
4706
+ store.logs["charger"] = {}
4707
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4708
+ fixed_now = timezone.now().replace(microsecond=0)
4709
+ aggregate = Charger.objects.create(charger_id="TLAGG")
4710
+ connector_one = Charger.objects.create(charger_id="TLAGG", connector_id=1)
4711
+ connector_two = Charger.objects.create(charger_id="TLAGG", connector_id=2)
4712
+
4713
+ def build_entry(connector_id, delta, status):
4714
+ timestamp = fixed_now - delta
4715
+ payload = {
4716
+ "connectorId": connector_id,
4717
+ "status": status,
4718
+ "timestamp": timestamp.isoformat(),
4719
+ }
4720
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4721
+ key = store.identity_key(aggregate.charger_id, connector_id)
4722
+ store.logs["charger"].setdefault(key, []).append(
4723
+ f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4724
+ )
4725
+
4726
+ build_entry(1, timedelta(days=3), "Available")
4727
+ build_entry(2, timedelta(days=2), "Charging")
4728
+
4729
+ overview = [{"charger": connector_one}, {"charger": connector_two}]
4730
+ data, _window = _usage_timeline(aggregate, overview, now=fixed_now)
4731
+ self.assertEqual(len(data), 2)
4732
+ self.assertTrue(all(entry["segments"] for entry in data))
4733
+
4734
+ with patch("ocpp.views.timezone.now", return_value=fixed_now):
4735
+ resp = self.client.get(reverse("charger-status", args=[aggregate.charger_id]))
4736
+
4737
+ self.assertContains(resp, "Usage (last 7 days)")
4738
+ self.assertContains(resp, connector_one.connector_label)
4739
+ self.assertContains(resp, connector_two.connector_label)
4740
+
4741
+ def test_usage_timeline_merges_repeated_status_entries(self):
4742
+ original_logs = store.logs["charger"]
4743
+ store.logs["charger"] = {}
4744
+ self.addCleanup(lambda: store.logs.__setitem__("charger", original_logs))
4745
+ fixed_now = timezone.now().replace(microsecond=0)
4746
+ charger = Charger.objects.create(
4747
+ charger_id="TLDEDUP",
4748
+ connector_id=1,
4749
+ last_status="Available",
4750
+ )
4751
+
4752
+ def build_entry(delta, status):
4753
+ timestamp = fixed_now - delta
4754
+ payload = {
4755
+ "connectorId": 1,
4756
+ "status": status,
4757
+ "timestamp": timestamp.isoformat(),
4758
+ }
4759
+ prefix = (timestamp.strftime("%Y-%m-%d %H:%M:%S.%f"))[:-3]
4760
+ return f"{prefix} StatusNotification processed: {json.dumps(payload, sort_keys=True)}"
4761
+
4762
+ log_key = store.identity_key(charger.charger_id, charger.connector_id)
4763
+ store.logs["charger"][log_key] = [
4764
+ build_entry(timedelta(days=6, hours=12), "Available"),
4765
+ build_entry(timedelta(days=5), "Available"),
4766
+ build_entry(timedelta(days=3, hours=6), "Charging"),
4767
+ build_entry(timedelta(days=2), "Charging"),
4768
+ build_entry(timedelta(days=1), "Available"),
4769
+ ]
4770
+
4771
+ data, window = _usage_timeline(charger, [], now=fixed_now)
4772
+ self.assertIsNotNone(window)
4773
+ self.assertEqual(len(data), 1)
4774
+ segments = data[0]["segments"]
4775
+ self.assertGreaterEqual(len(segments), 1)
4776
+ statuses = [segment["status"] for segment in segments]
4777
+ self.assertEqual(statuses, ["available", "charging", "available"])
4778
+
4431
4779
  def test_diagnostics_status_displayed(self):
4432
4780
  reported_at = timezone.now().replace(microsecond=0)
4433
4781
  charger = Charger.objects.create(