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/admin.py CHANGED
@@ -2,11 +2,12 @@ from django.contrib import admin, messages
2
2
  from django import forms
3
3
 
4
4
  import asyncio
5
- from datetime import timedelta
5
+ from datetime import datetime, time, timedelta
6
6
  import json
7
7
 
8
8
  from django.shortcuts import redirect
9
9
  from django.utils import formats, timezone, translation
10
+ from django.utils.html import format_html
10
11
  from django.urls import path
11
12
  from django.http import HttpResponse, HttpResponseRedirect
12
13
  from django.template.response import TemplateResponse
@@ -16,6 +17,7 @@ from asgiref.sync import async_to_sync
16
17
 
17
18
  from .models import (
18
19
  Charger,
20
+ ChargerConfiguration,
19
21
  Simulator,
20
22
  MeterValue,
21
23
  Transaction,
@@ -108,6 +110,82 @@ class LogViewAdminMixin:
108
110
  return TemplateResponse(request, self.log_template_name, context)
109
111
 
110
112
 
113
+ @admin.register(ChargerConfiguration)
114
+ class ChargerConfigurationAdmin(admin.ModelAdmin):
115
+ list_display = ("charger_identifier", "connector_display", "created_at")
116
+ list_filter = ("connector_id",)
117
+ search_fields = ("charger_identifier",)
118
+ readonly_fields = (
119
+ "charger_identifier",
120
+ "connector_id",
121
+ "created_at",
122
+ "updated_at",
123
+ "linked_chargers",
124
+ "configuration_keys_display",
125
+ "unknown_keys_display",
126
+ "raw_payload_display",
127
+ )
128
+ fieldsets = (
129
+ (
130
+ None,
131
+ {
132
+ "fields": (
133
+ "charger_identifier",
134
+ "connector_id",
135
+ "linked_chargers",
136
+ "created_at",
137
+ "updated_at",
138
+ )
139
+ },
140
+ ),
141
+ (
142
+ "Payload",
143
+ {
144
+ "fields": (
145
+ "configuration_keys_display",
146
+ "unknown_keys_display",
147
+ "raw_payload_display",
148
+ )
149
+ },
150
+ ),
151
+ )
152
+
153
+ @admin.display(description="Connector")
154
+ def connector_display(self, obj):
155
+ if obj.connector_id is None:
156
+ return "All"
157
+ return obj.connector_id
158
+
159
+ @admin.display(description="Linked charge points")
160
+ def linked_chargers(self, obj):
161
+ if obj.pk is None:
162
+ return ""
163
+ linked = [charger.identity_slug() for charger in obj.chargers.all()]
164
+ if not linked:
165
+ return "-"
166
+ return ", ".join(sorted(linked))
167
+
168
+ def _render_json(self, data):
169
+ from django.utils.html import format_html
170
+
171
+ if not data:
172
+ return "-"
173
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
174
+ return format_html("<pre>{}</pre>", formatted)
175
+
176
+ @admin.display(description="configurationKey")
177
+ def configuration_keys_display(self, obj):
178
+ return self._render_json(obj.configuration_keys)
179
+
180
+ @admin.display(description="unknownKey")
181
+ def unknown_keys_display(self, obj):
182
+ return self._render_json(obj.unknown_keys)
183
+
184
+ @admin.display(description="Raw payload")
185
+ def raw_payload_display(self, obj):
186
+ return self._render_json(obj.raw_payload)
187
+
188
+
111
189
  @admin.register(Location)
112
190
  class LocationAdmin(EntityModelAdmin):
113
191
  form = LocationAdminForm
@@ -207,7 +285,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
207
285
  ),
208
286
  (
209
287
  "Configuration",
210
- {"fields": ("public_display", "require_rfid")},
288
+ {"fields": ("public_display", "require_rfid", "configuration")},
211
289
  ),
212
290
  (
213
291
  "References",
@@ -236,11 +314,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
236
314
  "availability_request_status",
237
315
  "availability_request_status_at",
238
316
  "availability_request_details",
317
+ "configuration",
239
318
  )
240
319
  list_display = (
241
- "charger_id",
320
+ "display_name_with_fallback",
242
321
  "connector_number",
243
- "location_name",
322
+ "charger_name_display",
244
323
  "require_rfid_display",
245
324
  "public_display",
246
325
  "last_heartbeat",
@@ -349,6 +428,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
349
428
  return True
350
429
  return False
351
430
 
431
+ @admin.display(description="Display Name", ordering="display_name")
432
+ def display_name_with_fallback(self, obj):
433
+ return self._charger_display_name(obj)
434
+
435
+ @admin.display(description="Charger", ordering="display_name")
436
+ def charger_name_display(self, obj):
437
+ return self._charger_display_name(obj)
438
+
439
+ def _charger_display_name(self, obj):
440
+ if obj.display_name:
441
+ return obj.display_name
442
+ if obj.location:
443
+ return obj.location.name
444
+ return obj.charger_id
445
+
352
446
  def location_name(self, obj):
353
447
  return obj.location.name if obj.location else ""
354
448
 
@@ -710,6 +804,44 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
710
804
 
711
805
  session_kw.short_description = "Session kW"
712
806
 
807
+ def changelist_view(self, request, extra_context=None):
808
+ response = super().changelist_view(request, extra_context=extra_context)
809
+ if hasattr(response, "context_data"):
810
+ cl = response.context_data.get("cl")
811
+ if cl is not None:
812
+ response.context_data.update(
813
+ self._charger_quick_stats_context(cl.queryset)
814
+ )
815
+ return response
816
+
817
+ def _charger_quick_stats_context(self, queryset):
818
+ chargers = list(queryset)
819
+ stats = {"total_kw": 0.0, "today_kw": 0.0}
820
+ if not chargers:
821
+ return {"charger_quick_stats": stats}
822
+
823
+ parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
824
+ start, end = self._today_range()
825
+
826
+ for charger in chargers:
827
+ include_totals = True
828
+ if charger.connector_id is not None and charger.charger_id in parent_ids:
829
+ include_totals = False
830
+ if include_totals:
831
+ stats["total_kw"] += charger.total_kw
832
+ stats["today_kw"] += charger.total_kw_for_range(start, end)
833
+
834
+ stats = {key: round(value, 2) for key, value in stats.items()}
835
+ return {"charger_quick_stats": stats}
836
+
837
+ def _today_range(self):
838
+ today = timezone.localdate()
839
+ start = datetime.combine(today, time.min)
840
+ if timezone.is_naive(start):
841
+ start = timezone.make_aware(start, timezone.get_current_timezone())
842
+ end = start + timedelta(days=1)
843
+ return start, end
844
+
713
845
 
714
846
  @admin.register(Simulator)
715
847
  class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
@@ -933,8 +1065,10 @@ class TransactionAdmin(EntityModelAdmin):
933
1065
  change_list_template = "admin/ocpp/transaction/change_list.html"
934
1066
  list_display = (
935
1067
  "charger",
1068
+ "connector_number",
936
1069
  "account",
937
1070
  "rfid",
1071
+ "vid",
938
1072
  "meter_start",
939
1073
  "meter_stop",
940
1074
  "start_time",
@@ -946,6 +1080,12 @@ class TransactionAdmin(EntityModelAdmin):
946
1080
  date_hierarchy = "start_time"
947
1081
  inlines = [MeterValueInline]
948
1082
 
1083
+ def connector_number(self, obj):
1084
+ return obj.connector_id or ""
1085
+
1086
+ connector_number.short_description = "#"
1087
+ connector_number.admin_order_field = "connector_id"
1088
+
949
1089
  def get_urls(self):
950
1090
  urls = super().get_urls()
951
1091
  custom = [
ocpp/consumers.py CHANGED
@@ -20,7 +20,13 @@ from config.offline import requires_network
20
20
  from . import store
21
21
  from decimal import Decimal
22
22
  from django.utils.dateparse import parse_datetime
23
- from .models import Transaction, Charger, MeterValue, DataTransferMessage
23
+ from .models import (
24
+ Transaction,
25
+ Charger,
26
+ ChargerConfiguration,
27
+ MeterValue,
28
+ DataTransferMessage,
29
+ )
24
30
  from .reference_utils import host_is_local_loopback
25
31
  from .evcs_discovery import (
26
32
  DEFAULT_CONSOLE_PORT,
@@ -138,6 +144,18 @@ def _parse_ocpp_timestamp(value) -> datetime | None:
138
144
  return timestamp
139
145
 
140
146
 
147
+ def _extract_vehicle_identifier(payload: dict) -> tuple[str, str]:
148
+ """Return normalized VID and VIN values from an OCPP message payload."""
149
+
150
+ raw_vid = payload.get("vid")
151
+ vid_value = str(raw_vid).strip() if raw_vid is not None else ""
152
+ raw_vin = payload.get("vin")
153
+ vin_value = str(raw_vin).strip() if raw_vin is not None else ""
154
+ if not vid_value and vin_value:
155
+ vid_value = vin_value
156
+ return vid_value, vin_value
157
+
158
+
141
159
  class SinkConsumer(AsyncWebsocketConsumer):
142
160
  """Accept any message without validation."""
143
161
 
@@ -283,11 +301,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
283
301
  """Return the energy account for the provided RFID if valid."""
284
302
  if not id_tag:
285
303
  return None
286
- return await database_sync_to_async(
287
- EnergyAccount.objects.filter(
288
- rfids__rfid=id_tag.upper(), rfids__allowed=True
289
- ).first
290
- )()
304
+
305
+ def _resolve() -> EnergyAccount | None:
306
+ matches = CoreRFID.matching_queryset(id_tag).filter(allowed=True)
307
+ if not matches.exists():
308
+ return None
309
+ return (
310
+ EnergyAccount.objects.filter(rfids__in=matches)
311
+ .distinct()
312
+ .first()
313
+ )
314
+
315
+ return await database_sync_to_async(_resolve)()
291
316
 
292
317
  async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
293
318
  """Ensure an RFID record exists and update its last seen timestamp."""
@@ -726,6 +751,53 @@ class CSMSConsumer(AsyncWebsocketConsumer):
726
751
  task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
727
752
  self._consumption_task = task
728
753
 
754
+ def _persist_configuration_result(
755
+ self, payload: dict, connector_hint: int | str | None
756
+ ) -> ChargerConfiguration | None:
757
+ if not isinstance(payload, dict):
758
+ return None
759
+
760
+ connector_value: int | None = None
761
+ if connector_hint not in (None, ""):
762
+ try:
763
+ connector_value = int(connector_hint)
764
+ except (TypeError, ValueError):
765
+ connector_value = None
766
+
767
+ normalized_entries: list[dict[str, object]] = []
768
+ for entry in payload.get("configurationKey") or []:
769
+ if not isinstance(entry, dict):
770
+ continue
771
+ key = str(entry.get("key") or "")
772
+ normalized: dict[str, object] = {"key": key}
773
+ if "value" in entry:
774
+ normalized["value"] = entry.get("value")
775
+ normalized["readonly"] = bool(entry.get("readonly"))
776
+ normalized_entries.append(normalized)
777
+
778
+ unknown_values: list[str] = []
779
+ for value in payload.get("unknownKey") or []:
780
+ if value is None:
781
+ continue
782
+ unknown_values.append(str(value))
783
+
784
+ try:
785
+ raw_payload = json.loads(json.dumps(payload, ensure_ascii=False))
786
+ except (TypeError, ValueError):
787
+ raw_payload = payload
788
+
789
+ configuration = ChargerConfiguration.objects.create(
790
+ charger_identifier=self.charger_id,
791
+ connector_id=connector_value,
792
+ configuration_keys=normalized_entries,
793
+ unknown_keys=unknown_values,
794
+ raw_payload=raw_payload,
795
+ )
796
+ Charger.objects.filter(charger_id=self.charger_id).update(
797
+ configuration=configuration
798
+ )
799
+ return configuration
800
+
729
801
  async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
730
802
  metadata = store.pop_pending_call(message_id)
731
803
  if not metadata:
@@ -787,6 +859,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
787
859
  f"GetConfiguration result: {payload_text}",
788
860
  log_type="charger",
789
861
  )
862
+ configuration = await database_sync_to_async(
863
+ self._persist_configuration_result
864
+ )(payload_data, metadata.get("connector_id"))
865
+ if configuration:
866
+ if self.charger and self.charger.charger_id == self.charger_id:
867
+ self.charger.configuration = configuration
868
+ if (
869
+ self.aggregate_charger
870
+ and self.aggregate_charger.charger_id == self.charger_id
871
+ ):
872
+ self.aggregate_charger.configuration = configuration
790
873
  store.record_pending_call_result(
791
874
  message_id,
792
875
  metadata=metadata,
@@ -1338,8 +1421,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1338
1421
  reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
1339
1422
  now = timezone.now()
1340
1423
  self.charger.last_heartbeat = now
1424
+ if (
1425
+ self.aggregate_charger
1426
+ and self.aggregate_charger is not self.charger
1427
+ ):
1428
+ self.aggregate_charger.last_heartbeat = now
1341
1429
  await database_sync_to_async(
1342
- Charger.objects.filter(pk=self.charger.pk).update
1430
+ Charger.objects.filter(charger_id=self.charger_id).update
1343
1431
  )(last_heartbeat=now)
1344
1432
  elif action == "StatusNotification":
1345
1433
  await self._assign_connector(payload.get("connectorId"))
@@ -1538,11 +1626,13 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1538
1626
  self._log_unlinked_rfid(tag.rfid)
1539
1627
  start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1540
1628
  received_start = timezone.now()
1629
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1541
1630
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1542
1631
  charger=self.charger,
1543
1632
  account=account,
1544
1633
  rfid=(id_tag or ""),
1545
- vin=(payload.get("vin") or ""),
1634
+ vid=vid_value,
1635
+ vin=vin_value,
1546
1636
  connector_id=payload.get("connectorId"),
1547
1637
  meter_start=payload.get("meterStart"),
1548
1638
  start_time=start_timestamp or received_start,
@@ -1568,6 +1658,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1568
1658
  )()
1569
1659
  if not tx_obj and tx_id is not None:
1570
1660
  received_start = timezone.now()
1661
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1571
1662
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
1572
1663
  pk=tx_id,
1573
1664
  charger=self.charger,
@@ -1575,12 +1666,18 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1575
1666
  received_start_time=received_start,
1576
1667
  meter_start=payload.get("meterStart")
1577
1668
  or payload.get("meterStop"),
1578
- vin=(payload.get("vin") or ""),
1669
+ vid=vid_value,
1670
+ vin=vin_value,
1579
1671
  )
1580
1672
  if tx_obj:
1581
1673
  stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1582
1674
  received_stop = timezone.now()
1583
1675
  tx_obj.meter_stop = payload.get("meterStop")
1676
+ vid_value, vin_value = _extract_vehicle_identifier(payload)
1677
+ if vid_value:
1678
+ tx_obj.vid = vid_value
1679
+ if vin_value:
1680
+ tx_obj.vin = vin_value
1584
1681
  tx_obj.stop_time = stop_timestamp or received_stop
1585
1682
  tx_obj.received_stop_time = received_stop
1586
1683
  await database_sync_to_async(tx_obj.save)()
ocpp/models.py CHANGED
@@ -204,6 +204,16 @@ class Charger(Entity):
204
204
  related_name="chargers",
205
205
  )
206
206
  last_path = models.CharField(max_length=255, blank=True)
207
+ configuration = models.ForeignKey(
208
+ "ChargerConfiguration",
209
+ null=True,
210
+ blank=True,
211
+ on_delete=models.SET_NULL,
212
+ related_name="chargers",
213
+ help_text=_(
214
+ "Latest GetConfiguration response received from this charge point."
215
+ ),
216
+ )
207
217
  manager_node = models.ForeignKey(
208
218
  "nodes.Node",
209
219
  on_delete=models.SET_NULL,
@@ -534,6 +544,20 @@ class Charger(Entity):
534
544
  return qs
535
545
  return qs.filter(pk=self.pk)
536
546
 
547
+ def total_kw_for_range(
548
+ self,
549
+ start=None,
550
+ end=None,
551
+ ) -> float:
552
+ """Return total energy delivered within ``start``/``end`` window."""
553
+
554
+ from . import store
555
+
556
+ total = 0.0
557
+ for charger in self._target_chargers():
558
+ total += charger._total_kw_range_single(store, start, end)
559
+ return total
560
+
537
561
  def _total_kw_single(self, store_module) -> float:
538
562
  """Return total kW for this specific charger identity."""
539
563
 
@@ -554,6 +578,40 @@ class Charger(Entity):
554
578
  total += kw
555
579
  return total
556
580
 
581
+ def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
582
+ """Return total kW for a date range for this charger."""
583
+
584
+ tx_active = None
585
+ if self.connector_id is not None:
586
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
587
+
588
+ qs = self.transactions.all()
589
+ if start is not None:
590
+ qs = qs.filter(start_time__gte=start)
591
+ if end is not None:
592
+ qs = qs.filter(start_time__lt=end)
593
+ if tx_active and tx_active.pk is not None:
594
+ qs = qs.exclude(pk=tx_active.pk)
595
+
596
+ total = 0.0
597
+ for tx in qs:
598
+ kw = tx.kw
599
+ if kw:
600
+ total += kw
601
+
602
+ if tx_active:
603
+ start_time = getattr(tx_active, "start_time", None)
604
+ include = True
605
+ if start is not None and start_time and start_time < start:
606
+ include = False
607
+ if end is not None and start_time and start_time >= end:
608
+ include = False
609
+ if include:
610
+ kw = tx_active.kw
611
+ if kw:
612
+ total += kw
613
+ return total
614
+
557
615
  def purge(self):
558
616
  from . import store
559
617
 
@@ -585,6 +643,51 @@ class Charger(Entity):
585
643
  super().delete(*args, **kwargs)
586
644
 
587
645
 
646
+ class ChargerConfiguration(models.Model):
647
+ """Persisted configuration package returned by a charge point."""
648
+
649
+ charger_identifier = models.CharField(_("Serial Number"), max_length=100)
650
+ connector_id = models.PositiveIntegerField(
651
+ _("Connector ID"),
652
+ null=True,
653
+ blank=True,
654
+ help_text=_("Connector that returned this configuration (if specified)."),
655
+ )
656
+ configuration_keys = models.JSONField(
657
+ default=list,
658
+ blank=True,
659
+ help_text=_("Entries from the configurationKey list."),
660
+ )
661
+ unknown_keys = models.JSONField(
662
+ default=list,
663
+ blank=True,
664
+ help_text=_("Keys returned in the unknownKey list."),
665
+ )
666
+ raw_payload = models.JSONField(
667
+ default=dict,
668
+ blank=True,
669
+ help_text=_("Raw payload returned by the GetConfiguration call."),
670
+ )
671
+ created_at = models.DateTimeField(auto_now_add=True)
672
+ updated_at = models.DateTimeField(auto_now=True)
673
+
674
+ class Meta:
675
+ ordering = ["-created_at"]
676
+ verbose_name = _("CP Configuration")
677
+ verbose_name_plural = _("CP Configurations")
678
+
679
+ def __str__(self) -> str: # pragma: no cover - simple representation
680
+ connector = (
681
+ _("connector %(number)s") % {"number": self.connector_id}
682
+ if self.connector_id is not None
683
+ else _("all connectors")
684
+ )
685
+ return _("%(serial)s configuration (%(connector)s)") % {
686
+ "serial": self.charger_identifier,
687
+ "connector": connector,
688
+ }
689
+
690
+
588
691
  class Transaction(Entity):
589
692
  """Charging session data stored for each charger."""
590
693
 
@@ -599,7 +702,18 @@ class Transaction(Entity):
599
702
  blank=True,
600
703
  verbose_name=_("RFID"),
601
704
  )
602
- vin = models.CharField(max_length=17, blank=True)
705
+ vid = models.CharField(
706
+ max_length=64,
707
+ blank=True,
708
+ default="",
709
+ verbose_name=_("VID"),
710
+ help_text=_("Vehicle identifier reported by the charger."),
711
+ )
712
+ vin = models.CharField(
713
+ max_length=17,
714
+ blank=True,
715
+ help_text=_("Deprecated. Use VID instead."),
716
+ )
603
717
  connector_id = models.PositiveIntegerField(null=True, blank=True)
604
718
  meter_start = models.IntegerField(null=True, blank=True)
605
719
  meter_stop = models.IntegerField(null=True, blank=True)
@@ -645,6 +759,22 @@ class Transaction(Entity):
645
759
  verbose_name = _("Transaction")
646
760
  verbose_name_plural = _("CP Transactions")
647
761
 
762
+ @property
763
+ def vehicle_identifier(self) -> str:
764
+ """Return the preferred vehicle identifier for this transaction."""
765
+
766
+ return (self.vid or self.vin or "").strip()
767
+
768
+ @property
769
+ def vehicle_identifier_source(self) -> str:
770
+ """Return which field supplies :pyattr:`vehicle_identifier`."""
771
+
772
+ if (self.vid or "").strip():
773
+ return "vid"
774
+ if (self.vin or "").strip():
775
+ return "vin"
776
+ return ""
777
+
648
778
  @property
649
779
  def kw(self) -> float:
650
780
  """Return consumed energy in kW for this session."""
ocpp/tasks.py CHANGED
@@ -149,6 +149,10 @@ def send_daily_session_report() -> int:
149
149
  lines.append(f" Account: {account}")
150
150
  if transaction.rfid:
151
151
  lines.append(f" RFID: {transaction.rfid}")
152
+ identifier = transaction.vehicle_identifier
153
+ if identifier:
154
+ label = "VID" if transaction.vehicle_identifier_source == "vid" else "VIN"
155
+ lines.append(f" {label}: {identifier}")
152
156
  if connector:
153
157
  lines.append(f" {connector}")
154
158
  lines.append(
@@ -111,6 +111,7 @@ class TransactionAdminExportImportTests(TestCase):
111
111
  "charger": "C9",
112
112
  "account": None,
113
113
  "rfid": "",
114
+ "vid": "",
114
115
  "vin": "",
115
116
  "meter_start": 0,
116
117
  "meter_stop": 0,
ocpp/test_rfid.py CHANGED
@@ -771,7 +771,9 @@ class RFIDLandingTests(TestCase):
771
771
  app = Application.objects.create(name="Ocpp")
772
772
  module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
773
773
  module.create_landings()
774
- self.assertTrue(module.landings.filter(path="/ocpp/rfid/").exists())
774
+ self.assertTrue(
775
+ module.landings.filter(path="/ocpp/rfid/validator/").exists()
776
+ )
775
777
 
776
778
 
777
779
  class ScannerTemplateTests(TestCase):