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.
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/METADATA +8 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/RECORD +31 -31
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +139 -19
- core/environment.py +2 -239
- core/models.py +419 -2
- core/system.py +76 -0
- core/tests.py +152 -8
- core/views.py +35 -1
- nodes/admin.py +148 -38
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +214 -1
- nodes/views.py +1 -0
- ocpp/admin.py +20 -1
- ocpp/consumers.py +1 -0
- ocpp/models.py +23 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +227 -2
- ocpp/views.py +281 -3
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -22
- pages/urls.py +5 -0
- pages/views.py +264 -11
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
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
|
|
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
|
|