arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.
ocpp/tests.py CHANGED
@@ -61,13 +61,14 @@ from config.asgi import application
61
61
  from .models import (
62
62
  Transaction,
63
63
  Charger,
64
+ ChargerConfiguration,
64
65
  Simulator,
65
66
  MeterReading,
66
67
  Location,
67
68
  DataTransferMessage,
68
69
  )
69
70
  from .consumers import CSMSConsumer
70
- from .views import dispatch_action
71
+ from .views import dispatch_action, _transaction_rfid_details
71
72
  from .status_display import STATUS_BADGE_MAP
72
73
  from core.models import EnergyAccount, EnergyCredit, Reference, RFID, SecurityGroup
73
74
  from . import store
@@ -714,6 +715,13 @@ class CSMSConsumerTests(TransactionTestCase):
714
715
  connected, _ = await communicator.connect()
715
716
  self.assertTrue(connected)
716
717
 
718
+ await database_sync_to_async(Charger.objects.get_or_create)(
719
+ charger_id="CFGRES", connector_id=1
720
+ )
721
+ await database_sync_to_async(Charger.objects.get_or_create)(
722
+ charger_id="CFGRES", connector_id=2
723
+ )
724
+
717
725
  message_id = "cfg-result"
718
726
  payload = {
719
727
  "configurationKey": [
@@ -744,6 +752,31 @@ class CSMSConsumerTests(TransactionTestCase):
744
752
  )
745
753
  self.assertNotIn(message_id, store.pending_calls)
746
754
 
755
+ configuration = await database_sync_to_async(
756
+ lambda: ChargerConfiguration.objects.order_by("-created_at").first()
757
+ )()
758
+ self.assertIsNotNone(configuration)
759
+ self.assertEqual(configuration.charger_identifier, "CFGRES")
760
+ self.assertEqual(
761
+ configuration.configuration_keys,
762
+ [
763
+ {
764
+ "key": "AllowOfflineTxForUnknownId",
765
+ "value": "false",
766
+ "readonly": True,
767
+ }
768
+ ],
769
+ )
770
+ self.assertEqual(configuration.unknown_keys, [])
771
+ config_ids = await database_sync_to_async(
772
+ lambda: set(
773
+ Charger.objects.filter(charger_id="CFGRES").values_list(
774
+ "configuration_id", flat=True
775
+ )
776
+ )
777
+ )()
778
+ self.assertEqual(config_ids, {configuration.pk})
779
+
747
780
  await communicator.disconnect()
748
781
  store.clear_log(log_key, log_type="charger")
749
782
  store.clear_log(pending_key, log_type="charger")
@@ -1109,7 +1142,7 @@ class CSMSConsumerTests(TransactionTestCase):
1109
1142
 
1110
1143
  await communicator.disconnect()
1111
1144
 
1112
- async def test_vin_recorded(self):
1145
+ async def test_vid_populated_from_vin(self):
1113
1146
  await database_sync_to_async(Charger.objects.create)(charger_id="VINREC")
1114
1147
  communicator = WebsocketCommunicator(application, "/VINREC/")
1115
1148
  connected, _ = await communicator.connect()
@@ -1124,7 +1157,29 @@ class CSMSConsumerTests(TransactionTestCase):
1124
1157
  tx = await database_sync_to_async(Transaction.objects.get)(
1125
1158
  pk=tx_id, charger__charger_id="VINREC"
1126
1159
  )
1127
- self.assertEqual(tx.vin, "WP0ZZZ11111111111")
1160
+ self.assertEqual(tx.vid, "WP0ZZZ11111111111")
1161
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ11111111111")
1162
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
1163
+
1164
+ await communicator.disconnect()
1165
+
1166
+ async def test_vid_recorded(self):
1167
+ await database_sync_to_async(Charger.objects.create)(charger_id="VIDREC")
1168
+ communicator = WebsocketCommunicator(application, "/VIDREC/")
1169
+ connected, _ = await communicator.connect()
1170
+ self.assertTrue(connected)
1171
+
1172
+ await communicator.send_json_to(
1173
+ [2, "1", "StartTransaction", {"meterStart": 1, "vid": "VID123456"}]
1174
+ )
1175
+ response = await communicator.receive_json_from()
1176
+ tx_id = response[2]["transactionId"]
1177
+
1178
+ tx = await database_sync_to_async(Transaction.objects.get)(
1179
+ pk=tx_id, charger__charger_id="VIDREC"
1180
+ )
1181
+ self.assertEqual(tx.vid, "VID123456")
1182
+ self.assertEqual(tx.rfid, "")
1128
1183
 
1129
1184
  await communicator.disconnect()
1130
1185
 
@@ -2126,12 +2181,12 @@ class SimulatorLandingTests(TestCase):
2126
2181
  @skip("Navigation links unavailable in test environment")
2127
2182
  def test_simulator_app_link_in_nav(self):
2128
2183
  resp = self.client.get(reverse("pages:index"))
2129
- self.assertContains(resp, "/ocpp/")
2130
- self.assertNotContains(resp, "/ocpp/simulator/")
2184
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2185
+ self.assertNotContains(resp, "/ocpp/evcs/simulator/")
2131
2186
  self.client.force_login(self.user)
2132
2187
  resp = self.client.get(reverse("pages:index"))
2133
- self.assertContains(resp, "/ocpp/")
2134
- self.assertContains(resp, "/ocpp/simulator/")
2188
+ self.assertContains(resp, "/ocpp/cpms/dashboard/")
2189
+ self.assertContains(resp, "/ocpp/evcs/simulator/")
2135
2190
 
2136
2191
  def test_cp_simulator_redirects_to_login(self):
2137
2192
  response = self.client.get(reverse("cp-simulator"))
@@ -2280,6 +2335,36 @@ class ChargerAdminTests(TestCase):
2280
2335
  resp = self.client.get(url)
2281
2336
  self.assertContains(resp, "AdminLoc")
2282
2337
 
2338
+ def test_admin_changelist_displays_quick_stats(self):
2339
+ charger = Charger.objects.create(charger_id="STATMAIN", display_name="Main EVCS")
2340
+ connector = Charger.objects.create(
2341
+ charger_id="STATMAIN", connector_id=1, display_name="Connector 1"
2342
+ )
2343
+ start = timezone.now() - timedelta(minutes=30)
2344
+ Transaction.objects.create(
2345
+ charger=connector,
2346
+ start_time=start,
2347
+ stop_time=start + timedelta(minutes=10),
2348
+ meter_start=1000,
2349
+ meter_stop=6000,
2350
+ )
2351
+
2352
+ url = reverse("admin:ocpp_charger_changelist")
2353
+ resp = self.client.get(url)
2354
+
2355
+ self.assertContains(resp, "Total kW")
2356
+ self.assertContains(resp, "Today kW")
2357
+ self.assertContains(resp, "5.00")
2358
+
2359
+ def test_admin_changelist_does_not_indent_connectors(self):
2360
+ Charger.objects.create(charger_id="INDENTMAIN")
2361
+ Charger.objects.create(charger_id="INDENTMAIN", connector_id=1)
2362
+
2363
+ url = reverse("admin:ocpp_charger_changelist")
2364
+ resp = self.client.get(url)
2365
+
2366
+ self.assertNotContains(resp, 'class="charger-connector-entry"')
2367
+
2283
2368
  def test_last_fields_are_read_only(self):
2284
2369
  now = timezone.now()
2285
2370
  charger = Charger.objects.create(
@@ -3212,8 +3297,7 @@ class SimulatorAdminTests(TransactionTestCase):
3212
3297
  self.assertIsNotNone(aggregate.last_heartbeat)
3213
3298
  if previous_heartbeat:
3214
3299
  self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3215
- if connector.last_heartbeat:
3216
- self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3300
+ self.assertEqual(connector.last_heartbeat, aggregate.last_heartbeat)
3217
3301
 
3218
3302
  await communicator.disconnect()
3219
3303
 
@@ -4009,6 +4093,43 @@ class TransactionKwTests(TestCase):
4009
4093
  self.assertEqual(tx.kw, 0.0)
4010
4094
 
4011
4095
 
4096
+ class TransactionIdentifierTests(TestCase):
4097
+ def test_vehicle_identifier_prefers_vid(self):
4098
+ charger = Charger.objects.create(charger_id="VIDPREF")
4099
+ tx = Transaction.objects.create(
4100
+ charger=charger,
4101
+ start_time=timezone.now(),
4102
+ vid="VID-123",
4103
+ vin="VIN-456",
4104
+ )
4105
+ self.assertEqual(tx.vehicle_identifier, "VID-123")
4106
+ self.assertEqual(tx.vehicle_identifier_source, "vid")
4107
+
4108
+ def test_vehicle_identifier_falls_back_to_vin(self):
4109
+ charger = Charger.objects.create(charger_id="VINONLY")
4110
+ tx = Transaction.objects.create(
4111
+ charger=charger,
4112
+ start_time=timezone.now(),
4113
+ vin="WP0ZZZ00000000001",
4114
+ )
4115
+ self.assertEqual(tx.vehicle_identifier, "WP0ZZZ00000000001")
4116
+ self.assertEqual(tx.vehicle_identifier_source, "vin")
4117
+
4118
+ def test_transaction_rfid_details_handles_vin(self):
4119
+ charger = Charger.objects.create(charger_id="VINDET")
4120
+ tx = Transaction.objects.create(
4121
+ charger=charger,
4122
+ start_time=timezone.now(),
4123
+ vin="WAUZZZ00000000002",
4124
+ )
4125
+ details = _transaction_rfid_details(tx, cache={})
4126
+ self.assertIsNotNone(details)
4127
+ assert details is not None # for type checkers
4128
+ self.assertEqual(details["value"], "WAUZZZ00000000002")
4129
+ self.assertEqual(details["display_label"], "VIN")
4130
+ self.assertEqual(details["type"], "vin")
4131
+
4132
+
4012
4133
  class DispatchActionViewTests(TestCase):
4013
4134
  def setUp(self):
4014
4135
  self.client = Client()
@@ -4704,6 +4825,59 @@ class LiveUpdateViewTests(TestCase):
4704
4825
  )
4705
4826
  self.assertEqual(aggregate_entry["state"], available_label)
4706
4827
 
4828
+ def test_dashboard_groups_connectors_under_parent(self):
4829
+ aggregate = Charger.objects.create(charger_id="GROUPED")
4830
+ first = Charger.objects.create(
4831
+ charger_id=aggregate.charger_id, connector_id=1
4832
+ )
4833
+ second = Charger.objects.create(
4834
+ charger_id=aggregate.charger_id, connector_id=2
4835
+ )
4836
+
4837
+ resp = self.client.get(reverse("ocpp-dashboard"))
4838
+ self.assertEqual(resp.status_code, 200)
4839
+ groups = resp.context["charger_groups"]
4840
+ target = next(
4841
+ group
4842
+ for group in groups
4843
+ if group.get("parent")
4844
+ and group["parent"]["charger"].pk == aggregate.pk
4845
+ )
4846
+ child_ids = [item["charger"].pk for item in target["children"]]
4847
+ self.assertEqual(child_ids, [first.pk, second.pk])
4848
+
4849
+ def test_dashboard_includes_energy_totals(self):
4850
+ aggregate = Charger.objects.create(charger_id="KWSTATS")
4851
+ now = timezone.now()
4852
+ Transaction.objects.create(
4853
+ charger=aggregate,
4854
+ start_time=now - timedelta(hours=1),
4855
+ stop_time=now,
4856
+ meter_start=0,
4857
+ meter_stop=3000,
4858
+ )
4859
+ past_start = now - timedelta(days=2)
4860
+ Transaction.objects.create(
4861
+ charger=aggregate,
4862
+ start_time=past_start,
4863
+ stop_time=past_start + timedelta(hours=1),
4864
+ meter_start=0,
4865
+ meter_stop=1000,
4866
+ )
4867
+
4868
+ resp = self.client.get(reverse("ocpp-dashboard"))
4869
+ self.assertEqual(resp.status_code, 200)
4870
+ groups = resp.context["charger_groups"]
4871
+ target = next(
4872
+ group
4873
+ for group in groups
4874
+ if group.get("parent")
4875
+ and group["parent"]["charger"].pk == aggregate.pk
4876
+ )
4877
+ stats = target["parent"]["stats"]
4878
+ self.assertAlmostEqual(stats["total_kw"], 4.0, places=2)
4879
+ self.assertAlmostEqual(stats["today_kw"], 3.0, places=2)
4880
+
4707
4881
  def test_cp_simulator_includes_interval(self):
4708
4882
  resp = self.client.get(reverse("cp-simulator"))
4709
4883
  self.assertEqual(resp.context["request"].live_update_interval, 5)
ocpp/transactions_io.py CHANGED
@@ -46,6 +46,7 @@ def export_transactions(
46
46
  "charger": tx.charger.charger_id if tx.charger else None,
47
47
  "account": tx.account_id,
48
48
  "rfid": tx.rfid,
49
+ "vid": tx.vehicle_identifier,
49
50
  "vin": tx.vin,
50
51
  "meter_start": tx.meter_start,
51
52
  "meter_stop": tx.meter_stop,
@@ -144,11 +145,18 @@ def import_transactions(data: dict) -> int:
144
145
  except ValidationError:
145
146
  continue
146
147
  charger_map[serial] = charger
148
+ vid_value = tx.get("vid")
149
+ vin_value = tx.get("vin")
150
+ vid_text = str(vid_value).strip() if vid_value is not None else ""
151
+ vin_text = str(vin_value).strip() if vin_value is not None else ""
152
+ if not vid_text and vin_text:
153
+ vid_text = vin_text
147
154
  transaction = Transaction.objects.create(
148
155
  charger=charger,
149
156
  account_id=tx.get("account"),
150
157
  rfid=tx.get("rfid", ""),
151
- vin=tx.get("vin", ""),
158
+ vid=vid_text,
159
+ vin=vin_text,
152
160
  meter_start=tx.get("meter_start"),
153
161
  meter_stop=tx.get("meter_stop"),
154
162
  voltage_start=tx.get("voltage_start"),
ocpp/urls.py CHANGED
@@ -3,8 +3,8 @@ from django.urls import include, path
3
3
  from . import views
4
4
 
5
5
  urlpatterns = [
6
- path("", views.dashboard, name="ocpp-dashboard"),
7
- path("simulator/", views.cp_simulator, name="cp-simulator"),
6
+ path("cpms/dashboard/", views.dashboard, name="ocpp-dashboard"),
7
+ path("evcs/simulator/", views.cp_simulator, name="cp-simulator"),
8
8
  path("chargers/", views.charger_list, name="charger-list"),
9
9
  path("chargers/<str:cid>/", views.charger_detail, name="charger-detail"),
10
10
  path(
@@ -46,5 +46,5 @@ urlpatterns = [
46
46
  views.charger_status,
47
47
  name="charger-status-connector",
48
48
  ),
49
- path("rfid/", include("ocpp.rfid.urls")),
49
+ path("rfid/validator/", include("ocpp.rfid.urls")),
50
50
  ]
ocpp/views.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import uuid
3
3
  from datetime import datetime, timedelta, timezone as dt_timezone
4
+ from datetime import datetime, time, timedelta
4
5
  from types import SimpleNamespace
5
6
 
6
7
  from django.http import Http404, HttpResponse, JsonResponse
@@ -224,24 +225,75 @@ def _transaction_rfid_details(
224
225
  if not tx_obj:
225
226
  return None
226
227
  rfid_value = getattr(tx_obj, "rfid", None)
227
- if not rfid_value:
228
- return None
229
- normalized = str(rfid_value).strip()
230
- if not normalized:
228
+ normalized = str(rfid_value or "").strip().upper()
229
+ cache_key = normalized
230
+ if normalized:
231
+ if cache is not None and cache_key in cache:
232
+ return cache[cache_key]
233
+ tag = (
234
+ RFID.matching_queryset(normalized)
235
+ .only("pk", "label_id", "custom_label")
236
+ .first()
237
+ )
238
+ rfid_url = None
239
+ label_value = None
240
+ canonical_value = normalized
241
+ if tag:
242
+ try:
243
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
244
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
245
+ rfid_url = None
246
+ custom_label = (tag.custom_label or "").strip()
247
+ if custom_label:
248
+ label_value = custom_label
249
+ elif tag.label_id is not None:
250
+ label_value = str(tag.label_id)
251
+ canonical_value = tag.rfid or canonical_value
252
+ display_value = label_value or canonical_value
253
+ details = {
254
+ "value": display_value,
255
+ "url": rfid_url,
256
+ "uid": canonical_value,
257
+ "type": "rfid",
258
+ "display_label": gettext("RFID"),
259
+ }
260
+ if label_value:
261
+ details["label"] = label_value
262
+ if cache is not None:
263
+ cache[cache_key] = details
264
+ return details
265
+
266
+ identifier_value = getattr(tx_obj, "vehicle_identifier", None)
267
+ normalized_identifier = str(identifier_value or "").strip()
268
+ if not normalized_identifier:
269
+ vid_value = getattr(tx_obj, "vid", None)
270
+ vin_value = getattr(tx_obj, "vin", None)
271
+ normalized_identifier = str(vid_value or vin_value or "").strip()
272
+ if not normalized_identifier:
231
273
  return None
232
- normalized = normalized.upper()
233
- if cache is not None and normalized in cache:
234
- return cache[normalized]
235
- tag = RFID.objects.filter(rfid=normalized).only("pk").first()
236
- rfid_url = None
237
- if tag:
238
- try:
239
- rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
240
- except NoReverseMatch: # pragma: no cover - admin may be disabled
241
- rfid_url = None
242
- details = {"value": normalized, "url": rfid_url}
274
+ source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
275
+ if source not in {"vid", "vin"}:
276
+ vid_raw = getattr(tx_obj, "vid", None)
277
+ vin_raw = getattr(tx_obj, "vin", None)
278
+ if str(vid_raw or "").strip():
279
+ source = "vid"
280
+ elif str(vin_raw or "").strip():
281
+ source = "vin"
282
+ else:
283
+ source = "vid"
284
+ cache_key = f"{source}:{normalized_identifier}"
285
+ if cache is not None and cache_key in cache:
286
+ return cache[cache_key]
287
+ label = gettext("VID") if source == "vid" else gettext("VIN")
288
+ details = {
289
+ "value": normalized_identifier,
290
+ "url": None,
291
+ "uid": None,
292
+ "type": source,
293
+ "display_label": label,
294
+ }
243
295
  if cache is not None:
244
- cache[normalized] = details
296
+ cache[cache_key] = details
245
297
  return details
246
298
 
247
299
 
@@ -509,8 +561,12 @@ def charger_list(request):
509
561
  "meterStart": tx_obj.meter_start,
510
562
  "startTime": tx_obj.start_time.isoformat(),
511
563
  }
512
- if tx_obj.vin:
513
- tx_data["vin"] = tx_obj.vin
564
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
565
+ if identifier:
566
+ tx_data["vid"] = identifier
567
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
568
+ if legacy_vin:
569
+ tx_data["vin"] = legacy_vin
514
570
  if tx_obj.meter_stop is not None:
515
571
  tx_data["meterStop"] = tx_obj.meter_stop
516
572
  if tx_obj.stop_time is not None:
@@ -525,8 +581,12 @@ def charger_list(request):
525
581
  "meterStart": session_tx.meter_start,
526
582
  "startTime": session_tx.start_time.isoformat(),
527
583
  }
528
- if session_tx.vin:
529
- active_payload["vin"] = session_tx.vin
584
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
585
+ if identifier:
586
+ active_payload["vid"] = identifier
587
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
588
+ if legacy_vin:
589
+ active_payload["vin"] = legacy_vin
530
590
  if session_tx.meter_stop is not None:
531
591
  active_payload["meterStop"] = session_tx.meter_stop
532
592
  if session_tx.stop_time is not None:
@@ -606,8 +666,12 @@ def charger_detail(request, cid, connector=None):
606
666
  "meterStart": tx_obj.meter_start,
607
667
  "startTime": tx_obj.start_time.isoformat(),
608
668
  }
609
- if tx_obj.vin:
610
- tx_data["vin"] = tx_obj.vin
669
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
670
+ if identifier:
671
+ tx_data["vid"] = identifier
672
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
673
+ if legacy_vin:
674
+ tx_data["vin"] = legacy_vin
611
675
  if tx_obj.meter_stop is not None:
612
676
  tx_data["meterStop"] = tx_obj.meter_stop
613
677
  if tx_obj.stop_time is not None:
@@ -623,8 +687,12 @@ def charger_detail(request, cid, connector=None):
623
687
  "meterStart": session_tx.meter_start,
624
688
  "startTime": session_tx.start_time.isoformat(),
625
689
  }
626
- if session_tx.vin:
627
- payload["vin"] = session_tx.vin
690
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
691
+ if identifier:
692
+ payload["vid"] = identifier
693
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
694
+ if legacy_vin:
695
+ payload["vin"] = legacy_vin
628
696
  if session_tx.meter_stop is not None:
629
697
  payload["meterStop"] = session_tx.meter_stop
630
698
  if session_tx.stop_time is not None:
@@ -679,14 +747,54 @@ def dashboard(request):
679
747
  node = Node.get_local()
680
748
  role = node.role if node else None
681
749
  role_name = role.name if role else ""
682
- allow_anonymous_roles = {"Constellation", "Satellite"}
750
+ allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
683
751
  if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
684
752
  return redirect_to_login(
685
753
  request.get_full_path(), login_url=reverse("pages:login")
686
754
  )
687
- is_constellation = role_name == "Constellation"
688
- chargers = []
689
- for charger in _visible_chargers(request.user):
755
+ is_watchtower = role_name in {"Watchtower", "Constellation"}
756
+ visible_chargers = (
757
+ _visible_chargers(request.user)
758
+ .select_related("location")
759
+ .order_by("charger_id", "connector_id")
760
+ )
761
+ stats_cache: dict[int, dict[str, float]] = {}
762
+
763
+ def _charger_display_name(charger: Charger) -> str:
764
+ if charger.display_name:
765
+ return charger.display_name
766
+ if charger.location:
767
+ return charger.location.name
768
+ return charger.charger_id
769
+
770
+ today = timezone.localdate()
771
+ tz = timezone.get_current_timezone()
772
+ day_start = datetime.combine(today, time.min)
773
+ if timezone.is_naive(day_start):
774
+ day_start = timezone.make_aware(day_start, tz)
775
+ day_end = day_start + timedelta(days=1)
776
+
777
+ def _charger_stats(charger: Charger) -> dict[str, float]:
778
+ cache_key = charger.pk or id(charger)
779
+ if cache_key not in stats_cache:
780
+ stats_cache[cache_key] = {
781
+ "total_kw": charger.total_kw,
782
+ "today_kw": charger.total_kw_for_range(day_start, day_end),
783
+ }
784
+ return stats_cache[cache_key]
785
+
786
+ def _status_url(charger: Charger) -> str:
787
+ return _reverse_connector_url(
788
+ "charger-status",
789
+ charger.charger_id,
790
+ charger.connector_slug,
791
+ )
792
+
793
+ chargers: list[dict[str, object]] = []
794
+ charger_groups: list[dict[str, object]] = []
795
+ group_lookup: dict[str, dict[str, object]] = {}
796
+
797
+ for charger in visible_chargers:
690
798
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
691
799
  if not tx_obj:
692
800
  tx_obj = (
@@ -694,14 +802,55 @@ def dashboard(request):
694
802
  .order_by("-start_time")
695
803
  .first()
696
804
  )
805
+ has_session = _has_active_session(tx_obj)
697
806
  state, color = _charger_state(charger, tx_obj)
698
- chargers.append({"charger": charger, "state": state, "color": color})
807
+ if (
808
+ charger.connector_id is not None
809
+ and not has_session
810
+ and (charger.last_status or "").strip().casefold() == "charging"
811
+ ):
812
+ state, color = STATUS_BADGE_MAP["charging"]
813
+ entry = {
814
+ "charger": charger,
815
+ "state": state,
816
+ "color": color,
817
+ "display_name": _charger_display_name(charger),
818
+ "stats": _charger_stats(charger),
819
+ "status_url": _status_url(charger),
820
+ }
821
+ chargers.append(entry)
822
+ if charger.connector_id is None:
823
+ group = {"parent": entry, "children": []}
824
+ charger_groups.append(group)
825
+ group_lookup[charger.charger_id] = group
826
+ else:
827
+ group = group_lookup.get(charger.charger_id)
828
+ if group is None:
829
+ group = {"parent": None, "children": []}
830
+ charger_groups.append(group)
831
+ group_lookup[charger.charger_id] = group
832
+ group["children"].append(entry)
833
+
834
+ for group in charger_groups:
835
+ parent_entry = group.get("parent")
836
+ if not parent_entry or not group["children"]:
837
+ continue
838
+ connector_statuses = [
839
+ (child["charger"].last_status or "").strip().casefold()
840
+ for child in group["children"]
841
+ if child["charger"].connector_id is not None
842
+ ]
843
+ if connector_statuses and all(status == "charging" for status in connector_statuses):
844
+ label, badge_color = STATUS_BADGE_MAP["charging"]
845
+ parent_entry["state"] = label
846
+ parent_entry["color"] = badge_color
699
847
  scheme = "wss" if request.is_secure() else "ws"
700
848
  host = request.get_host()
701
849
  ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
702
850
  context = {
703
851
  "chargers": chargers,
704
- "show_demo_notice": is_constellation,
852
+ "charger_groups": charger_groups,
853
+ "show_demo_notice": is_watchtower,
705
854
  "demo_ws_url": ws_url,
706
855
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
707
856
  }
@@ -1049,7 +1198,10 @@ def charger_status(request, cid, connector=None):
1049
1198
  "connector_id": connector_id,
1050
1199
  }
1051
1200
  )
1052
- overview = _connector_overview(charger, request.user)
1201
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1202
+ overview = _connector_overview(
1203
+ charger, request.user, rfid_cache=rfid_cache
1204
+ )
1053
1205
  connector_links = [
1054
1206
  {
1055
1207
  "slug": item["slug"],
@@ -1090,12 +1242,15 @@ def charger_status(request, cid, connector=None):
1090
1242
  action_url = _reverse_connector_url("charger-action", cid, connector_slug)
1091
1243
  chart_should_animate = bool(has_active_session and not past_session)
1092
1244
 
1245
+ tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
1246
+
1093
1247
  return render(
1094
1248
  request,
1095
1249
  "ocpp/charger_status.html",
1096
1250
  {
1097
1251
  "charger": charger,
1098
1252
  "tx": tx_obj,
1253
+ "tx_rfid_details": tx_rfid_details,
1099
1254
  "state": state,
1100
1255
  "color": color,
1101
1256
  "transactions": transactions,