arthexis 0.1.21__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/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
  [
@@ -2614,6 +2622,81 @@ class ChargerAdminTests(TestCase):
2614
2622
  store.clear_log(pending_key, log_type="charger")
2615
2623
 
2616
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
+
2617
2700
  class LocationAdminTests(TestCase):
2618
2701
  def setUp(self):
2619
2702
  self.client = Client()
@@ -2887,6 +2970,28 @@ class SimulatorAdminTests(TransactionTestCase):
2887
2970
 
2888
2971
  await communicator.disconnect()
2889
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
+
2890
2995
  async def test_query_string_cid_supported(self):
2891
2996
  communicator = WebsocketCommunicator(application, "/?cid=QSERIAL")
2892
2997
  connected, _ = await communicator.connect()
@@ -3324,6 +3429,10 @@ class ChargerLocationTests(TestCase):
3324
3429
  second = Charger.objects.create(charger_id="SHARE", connector_id=2)
3325
3430
  self.assertEqual(second.location, first.location)
3326
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
+
3327
3436
 
3328
3437
  class MeterReadingTests(TransactionTestCase):
3329
3438
  async def test_meter_values_saved_as_readings(self):
@@ -4428,6 +4537,122 @@ class ChargerStatusViewTests(TestCase):
4428
4537
  self.assertAlmostEqual(resp.context["tx"].kw, 0.02)
4429
4538
  store.transactions.pop(key, None)
4430
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
+
4431
4656
  def test_diagnostics_status_displayed(self):
4432
4657
  reported_at = timezone.now().replace(microsecond=0)
4433
4658
  charger = Charger.objects.create(
ocpp/views.py CHANGED
@@ -1,13 +1,13 @@
1
1
  import json
2
2
  import uuid
3
- from datetime import datetime, timedelta, timezone as dt_timezone
4
- from datetime import datetime, time, timedelta
3
+ from datetime import datetime, time, timedelta, timezone as dt_timezone
5
4
  from types import SimpleNamespace
6
5
 
7
6
  from django.http import Http404, HttpResponse, JsonResponse
8
7
  from django.http.request import split_domain_port
9
8
  from django.views.decorators.csrf import csrf_exempt
10
9
  from django.shortcuts import get_object_or_404, render, resolve_url
10
+ from django.template.loader import render_to_string
11
11
  from django.core.paginator import Paginator
12
12
  from django.contrib.auth.decorators import login_required
13
13
  from django.contrib.auth.views import redirect_to_login
@@ -15,7 +15,7 @@ from django.utils.translation import gettext_lazy as _, gettext, ngettext
15
15
  from django.utils.text import slugify
16
16
  from django.urls import NoReverseMatch, reverse
17
17
  from django.conf import settings
18
- from django.utils import translation, timezone
18
+ from django.utils import translation, timezone, formats
19
19
  from django.core.exceptions import ValidationError
20
20
 
21
21
  from asgiref.sync import async_to_sync
@@ -27,6 +27,8 @@ from nodes.models import Node
27
27
  from pages.utils import landing
28
28
  from core.liveupdate import live_update
29
29
 
30
+ from django.utils.dateparse import parse_datetime
31
+
30
32
  from . import store
31
33
  from .models import Transaction, Charger, DataTransferMessage, RFID
32
34
  from .evcs import (
@@ -337,6 +339,272 @@ def _connector_overview(
337
339
  return overview
338
340
 
339
341
 
342
+ def _normalize_timeline_status(value: str | None) -> str | None:
343
+ """Normalize raw charger status strings into timeline buckets."""
344
+
345
+ normalized = (value or "").strip().lower()
346
+ if not normalized:
347
+ return None
348
+ charging_states = {
349
+ "charging",
350
+ "finishing",
351
+ "suspendedev",
352
+ "suspendedevse",
353
+ "occupied",
354
+ }
355
+ available_states = {"available", "preparing", "reserved"}
356
+ offline_states = {"faulted", "unavailable", "outofservice"}
357
+ if normalized in charging_states:
358
+ return "charging"
359
+ if normalized in offline_states:
360
+ return "offline"
361
+ if normalized in available_states:
362
+ return "available"
363
+ # Treat other states as available for the initial implementation.
364
+ return "available"
365
+
366
+
367
+ def _timeline_labels() -> dict[str, str]:
368
+ """Return translated labels for timeline statuses."""
369
+
370
+ return {
371
+ "offline": gettext("Offline"),
372
+ "available": gettext("Available"),
373
+ "charging": gettext("Charging"),
374
+ }
375
+
376
+
377
+ def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
378
+ """Return localized display values for a timeline range."""
379
+
380
+ start_display = formats.date_format(
381
+ timezone.localtime(start), "SHORT_DATETIME_FORMAT"
382
+ )
383
+ end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
384
+ return start_display, end_display
385
+
386
+
387
+ def _collect_status_events(
388
+ charger: Charger,
389
+ connector: Charger,
390
+ window_start: datetime,
391
+ window_end: datetime,
392
+ ) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
393
+ """Parse log entries into ordered status events for the connector."""
394
+
395
+ connector_id = connector.connector_id
396
+ serial = connector.charger_id
397
+ keys = [store.identity_key(serial, connector_id)]
398
+ if connector_id is not None:
399
+ keys.append(store.identity_key(serial, None))
400
+ keys.append(store.pending_key(serial))
401
+
402
+ seen_entries: set[str] = set()
403
+ events: list[tuple[datetime, str]] = []
404
+ latest_before_window: tuple[datetime, str] | None = None
405
+
406
+ for key in keys:
407
+ for entry in store.get_logs(key, log_type="charger"):
408
+ if entry in seen_entries:
409
+ continue
410
+ seen_entries.add(entry)
411
+ if len(entry) < 24:
412
+ continue
413
+ timestamp_raw = entry[:23]
414
+ message = entry[24:].strip()
415
+ try:
416
+ log_timestamp = datetime.strptime(
417
+ timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
418
+ ).replace(tzinfo=dt_timezone.utc)
419
+ except ValueError:
420
+ continue
421
+
422
+ event_time = log_timestamp
423
+ status_bucket: str | None = None
424
+
425
+ if message.startswith("StatusNotification processed:"):
426
+ payload_text = message.split(":", 1)[1].strip()
427
+ try:
428
+ payload = json.loads(payload_text)
429
+ except json.JSONDecodeError:
430
+ continue
431
+ target_id = payload.get("connectorId")
432
+ if connector_id is not None:
433
+ try:
434
+ normalized_target = int(target_id)
435
+ except (TypeError, ValueError):
436
+ normalized_target = None
437
+ if normalized_target not in {connector_id, None}:
438
+ continue
439
+ raw_status = payload.get("status")
440
+ status_bucket = _normalize_timeline_status(
441
+ raw_status if isinstance(raw_status, str) else None
442
+ )
443
+ payload_timestamp = payload.get("timestamp")
444
+ if isinstance(payload_timestamp, str):
445
+ parsed = parse_datetime(payload_timestamp)
446
+ if parsed is not None:
447
+ if timezone.is_naive(parsed):
448
+ parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
449
+ event_time = parsed
450
+ elif message.startswith("Connected"):
451
+ status_bucket = "available"
452
+ elif message.startswith("Closed"):
453
+ status_bucket = "offline"
454
+
455
+ if not status_bucket:
456
+ continue
457
+
458
+ if event_time < window_start:
459
+ if (
460
+ latest_before_window is None
461
+ or event_time > latest_before_window[0]
462
+ ):
463
+ latest_before_window = (event_time, status_bucket)
464
+ continue
465
+ if event_time > window_end:
466
+ continue
467
+ events.append((event_time, status_bucket))
468
+
469
+ events.sort(key=lambda item: item[0])
470
+
471
+ deduped_events: list[tuple[datetime, str]] = []
472
+ for event_time, state in events:
473
+ if deduped_events and deduped_events[-1][1] == state:
474
+ continue
475
+ deduped_events.append((event_time, state))
476
+
477
+ return deduped_events, latest_before_window
478
+
479
+
480
+ def _usage_timeline(
481
+ charger: Charger,
482
+ connector_overview: list[dict],
483
+ *,
484
+ now: datetime | None = None,
485
+ ) -> tuple[list[dict], tuple[str, str] | None]:
486
+ """Build usage timeline data for inactive chargers."""
487
+
488
+ if now is None:
489
+ now = timezone.now()
490
+ window_end = now
491
+ window_start = now - timedelta(days=7)
492
+
493
+ if charger.connector_id is not None:
494
+ connectors = [charger]
495
+ else:
496
+ connectors = [
497
+ item["charger"]
498
+ for item in connector_overview
499
+ if item.get("charger") and item["charger"].connector_id is not None
500
+ ]
501
+ if not connectors:
502
+ connectors = [
503
+ sibling
504
+ for sibling in _connector_set(charger)
505
+ if sibling.connector_id is not None
506
+ ]
507
+
508
+ seen_ids: set[int] = set()
509
+ labels = _timeline_labels()
510
+ timeline_entries: list[dict] = []
511
+ window_display: tuple[str, str] | None = None
512
+
513
+ if window_start < window_end:
514
+ window_display = _format_segment_range(window_start, window_end)
515
+
516
+ for connector in connectors:
517
+ if connector.connector_id is None:
518
+ continue
519
+ if connector.connector_id in seen_ids:
520
+ continue
521
+ seen_ids.add(connector.connector_id)
522
+
523
+ events, prior_event = _collect_status_events(
524
+ charger, connector, window_start, window_end
525
+ )
526
+ fallback_state = _normalize_timeline_status(connector.last_status)
527
+ if fallback_state is None:
528
+ fallback_state = (
529
+ "available"
530
+ if store.is_connected(connector.charger_id, connector.connector_id)
531
+ else "offline"
532
+ )
533
+ current_state = fallback_state
534
+ if prior_event is not None:
535
+ current_state = prior_event[1]
536
+ segments: list[dict] = []
537
+ previous_time = window_start
538
+ total_seconds = (window_end - window_start).total_seconds()
539
+
540
+ for event_time, state in events:
541
+ if event_time <= window_start:
542
+ current_state = state
543
+ continue
544
+ if event_time > window_end:
545
+ break
546
+ if state == current_state:
547
+ continue
548
+ segment_start = max(previous_time, window_start)
549
+ segment_end = min(event_time, window_end)
550
+ if segment_end > segment_start:
551
+ duration = (segment_end - segment_start).total_seconds()
552
+ start_display, end_display = _format_segment_range(
553
+ segment_start, segment_end
554
+ )
555
+ segments.append(
556
+ {
557
+ "status": current_state,
558
+ "label": labels.get(current_state, current_state.title()),
559
+ "start_display": start_display,
560
+ "end_display": end_display,
561
+ "duration": max(duration, 1.0),
562
+ }
563
+ )
564
+ current_state = state
565
+ previous_time = max(event_time, window_start)
566
+
567
+ if previous_time < window_end:
568
+ segment_start = max(previous_time, window_start)
569
+ segment_end = window_end
570
+ if segment_end > segment_start:
571
+ duration = (segment_end - segment_start).total_seconds()
572
+ start_display, end_display = _format_segment_range(
573
+ segment_start, segment_end
574
+ )
575
+ segments.append(
576
+ {
577
+ "status": current_state,
578
+ "label": labels.get(current_state, current_state.title()),
579
+ "start_display": start_display,
580
+ "end_display": end_display,
581
+ "duration": max(duration, 1.0),
582
+ }
583
+ )
584
+
585
+ if not segments and total_seconds > 0:
586
+ start_display, end_display = _format_segment_range(window_start, window_end)
587
+ segments.append(
588
+ {
589
+ "status": current_state,
590
+ "label": labels.get(current_state, current_state.title()),
591
+ "start_display": start_display,
592
+ "end_display": end_display,
593
+ "duration": max(total_seconds, 1.0),
594
+ }
595
+ )
596
+
597
+ if segments:
598
+ timeline_entries.append(
599
+ {
600
+ "label": connector.connector_label,
601
+ "segments": segments,
602
+ }
603
+ )
604
+
605
+ return timeline_entries, window_display
606
+
607
+
340
608
  def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
341
609
  """Return active sessions grouped by connector for the charger."""
342
610
 
@@ -854,6 +1122,11 @@ def dashboard(request):
854
1122
  "demo_ws_url": ws_url,
855
1123
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
856
1124
  }
1125
+ if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
1126
+ html = render_to_string(
1127
+ "ocpp/includes/dashboard_table_rows.html", context, request=request
1128
+ )
1129
+ return JsonResponse({"html": html})
857
1130
  return render(request, "ocpp/dashboard.html", context)
858
1131
 
859
1132
 
@@ -1214,6 +1487,9 @@ def charger_status(request, cid, connector=None):
1214
1487
  connector_overview = [
1215
1488
  item for item in overview if item["charger"].connector_id is not None
1216
1489
  ]
1490
+ usage_timeline, usage_timeline_window = _usage_timeline(
1491
+ charger, connector_overview
1492
+ )
1217
1493
  search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1218
1494
  configuration_url = None
1219
1495
  if request.user.is_staff:
@@ -1280,6 +1556,8 @@ def charger_status(request, cid, connector=None):
1280
1556
  "pagination_query": pagination_query,
1281
1557
  "session_query": session_query,
1282
1558
  "chart_should_animate": chart_should_animate,
1559
+ "usage_timeline": usage_timeline,
1560
+ "usage_timeline_window": usage_timeline_window,
1283
1561
  },
1284
1562
  )
1285
1563