arthexis 0.1.18__py3-none-any.whl → 0.1.20__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
@@ -6,7 +6,7 @@ from datetime import timedelta
6
6
  import json
7
7
 
8
8
  from django.shortcuts import redirect
9
- from django.utils import timezone
9
+ from django.utils import formats, timezone, translation
10
10
  from django.urls import path
11
11
  from django.http import HttpResponse, HttpResponseRedirect
12
12
  from django.template.response import TemplateResponse
@@ -16,6 +16,7 @@ from asgiref.sync import async_to_sync
16
16
 
17
17
  from .models import (
18
18
  Charger,
19
+ ChargerConfiguration,
19
20
  Simulator,
20
21
  MeterValue,
21
22
  Transaction,
@@ -108,6 +109,82 @@ class LogViewAdminMixin:
108
109
  return TemplateResponse(request, self.log_template_name, context)
109
110
 
110
111
 
112
+ @admin.register(ChargerConfiguration)
113
+ class ChargerConfigurationAdmin(admin.ModelAdmin):
114
+ list_display = ("charger_identifier", "connector_display", "created_at")
115
+ list_filter = ("connector_id",)
116
+ search_fields = ("charger_identifier",)
117
+ readonly_fields = (
118
+ "charger_identifier",
119
+ "connector_id",
120
+ "created_at",
121
+ "updated_at",
122
+ "linked_chargers",
123
+ "configuration_keys_display",
124
+ "unknown_keys_display",
125
+ "raw_payload_display",
126
+ )
127
+ fieldsets = (
128
+ (
129
+ None,
130
+ {
131
+ "fields": (
132
+ "charger_identifier",
133
+ "connector_id",
134
+ "linked_chargers",
135
+ "created_at",
136
+ "updated_at",
137
+ )
138
+ },
139
+ ),
140
+ (
141
+ "Payload",
142
+ {
143
+ "fields": (
144
+ "configuration_keys_display",
145
+ "unknown_keys_display",
146
+ "raw_payload_display",
147
+ )
148
+ },
149
+ ),
150
+ )
151
+
152
+ @admin.display(description="Connector")
153
+ def connector_display(self, obj):
154
+ if obj.connector_id is None:
155
+ return "All"
156
+ return obj.connector_id
157
+
158
+ @admin.display(description="Linked charge points")
159
+ def linked_chargers(self, obj):
160
+ if obj.pk is None:
161
+ return ""
162
+ linked = [charger.identity_slug() for charger in obj.chargers.all()]
163
+ if not linked:
164
+ return "-"
165
+ return ", ".join(sorted(linked))
166
+
167
+ def _render_json(self, data):
168
+ from django.utils.html import format_html
169
+
170
+ if not data:
171
+ return "-"
172
+ formatted = json.dumps(data, indent=2, ensure_ascii=False)
173
+ return format_html("<pre>{}</pre>", formatted)
174
+
175
+ @admin.display(description="configurationKey")
176
+ def configuration_keys_display(self, obj):
177
+ return self._render_json(obj.configuration_keys)
178
+
179
+ @admin.display(description="unknownKey")
180
+ def unknown_keys_display(self, obj):
181
+ return self._render_json(obj.unknown_keys)
182
+
183
+ @admin.display(description="Raw payload")
184
+ def raw_payload_display(self, obj):
185
+ return self._render_json(obj.raw_payload)
186
+
187
+
111
188
  @admin.register(Location)
112
189
  class LocationAdmin(EntityModelAdmin):
113
190
  form = LocationAdminForm
@@ -163,6 +240,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
163
240
  "charger_id",
164
241
  "display_name",
165
242
  "connector_id",
243
+ "language",
166
244
  "location",
167
245
  "last_path",
168
246
  "last_heartbeat",
@@ -206,7 +284,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
206
284
  ),
207
285
  (
208
286
  "Configuration",
209
- {"fields": ("public_display", "require_rfid")},
287
+ {"fields": ("public_display", "require_rfid", "configuration")},
210
288
  ),
211
289
  (
212
290
  "References",
@@ -235,11 +313,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
235
313
  "availability_request_status",
236
314
  "availability_request_status_at",
237
315
  "availability_request_details",
316
+ "configuration",
238
317
  )
239
318
  list_display = (
240
- "charger_id",
319
+ "display_name_with_fallback",
241
320
  "connector_number",
242
- "location_name",
321
+ "serial_number_display",
243
322
  "require_rfid_display",
244
323
  "public_display",
245
324
  "last_heartbeat",
@@ -348,6 +427,18 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
348
427
  return True
349
428
  return False
350
429
 
430
+ @admin.display(description="Display Name", ordering="display_name")
431
+ def display_name_with_fallback(self, obj):
432
+ if obj.display_name:
433
+ return obj.display_name
434
+ if obj.location:
435
+ return obj.location.name
436
+ return obj.charger_id
437
+
438
+ @admin.display(description="Serial Number", ordering="charger_id")
439
+ def serial_number_display(self, obj):
440
+ return obj.charger_id
441
+
351
442
  def location_name(self, obj):
352
443
  return obj.location.name if obj.location else ""
353
444
 
@@ -719,7 +810,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
719
810
  "ws_port",
720
811
  "ws_url",
721
812
  "interval",
722
- "kw_max",
813
+ "kw_max_display",
723
814
  "running",
724
815
  "log_link",
725
816
  )
@@ -759,6 +850,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
759
850
 
760
851
  log_type = "simulator"
761
852
 
853
+ @admin.display(description="kW Max", ordering="kw_max")
854
+ def kw_max_display(self, obj):
855
+ """Display ``kw_max`` with a dot decimal separator for Spanish locales."""
856
+
857
+ language = translation.get_language() or ""
858
+ if language.startswith("es"):
859
+ return formats.number_format(
860
+ obj.kw_max,
861
+ decimal_pos=2,
862
+ use_l10n=False,
863
+ force_grouping=False,
864
+ )
865
+
866
+ return formats.number_format(
867
+ obj.kw_max,
868
+ decimal_pos=2,
869
+ use_l10n=True,
870
+ force_grouping=False,
871
+ )
872
+
762
873
  def save_model(self, request, obj, form, change):
763
874
  previous_door_open = False
764
875
  if change and obj.pk:
@@ -912,8 +1023,10 @@ class TransactionAdmin(EntityModelAdmin):
912
1023
  change_list_template = "admin/ocpp/transaction/change_list.html"
913
1024
  list_display = (
914
1025
  "charger",
1026
+ "connector_number",
915
1027
  "account",
916
1028
  "rfid",
1029
+ "vid",
917
1030
  "meter_start",
918
1031
  "meter_stop",
919
1032
  "start_time",
@@ -925,6 +1038,12 @@ class TransactionAdmin(EntityModelAdmin):
925
1038
  date_hierarchy = "start_time"
926
1039
  inlines = [MeterValueInline]
927
1040
 
1041
+ def connector_number(self, obj):
1042
+ return obj.connector_id or ""
1043
+
1044
+ connector_number.short_description = "#"
1045
+ connector_number.admin_order_field = "connector_id"
1046
+
928
1047
  def get_urls(self):
929
1048
  urls = super().get_urls()
930
1049
  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
@@ -82,6 +82,13 @@ class Charger(Entity):
82
82
  default=True,
83
83
  help_text="Display this charger on the public status dashboard.",
84
84
  )
85
+ language = models.CharField(
86
+ _("Language"),
87
+ max_length=12,
88
+ choices=settings.LANGUAGES,
89
+ default="es",
90
+ help_text=_("Preferred language for the public landing page."),
91
+ )
85
92
  require_rfid = models.BooleanField(
86
93
  _("Require RFID Authorization"),
87
94
  default=False,
@@ -197,6 +204,16 @@ class Charger(Entity):
197
204
  related_name="chargers",
198
205
  )
199
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
+ )
200
217
  manager_node = models.ForeignKey(
201
218
  "nodes.Node",
202
219
  on_delete=models.SET_NULL,
@@ -578,6 +595,51 @@ class Charger(Entity):
578
595
  super().delete(*args, **kwargs)
579
596
 
580
597
 
598
+ class ChargerConfiguration(models.Model):
599
+ """Persisted configuration package returned by a charge point."""
600
+
601
+ charger_identifier = models.CharField(_("Serial Number"), max_length=100)
602
+ connector_id = models.PositiveIntegerField(
603
+ _("Connector ID"),
604
+ null=True,
605
+ blank=True,
606
+ help_text=_("Connector that returned this configuration (if specified)."),
607
+ )
608
+ configuration_keys = models.JSONField(
609
+ default=list,
610
+ blank=True,
611
+ help_text=_("Entries from the configurationKey list."),
612
+ )
613
+ unknown_keys = models.JSONField(
614
+ default=list,
615
+ blank=True,
616
+ help_text=_("Keys returned in the unknownKey list."),
617
+ )
618
+ raw_payload = models.JSONField(
619
+ default=dict,
620
+ blank=True,
621
+ help_text=_("Raw payload returned by the GetConfiguration call."),
622
+ )
623
+ created_at = models.DateTimeField(auto_now_add=True)
624
+ updated_at = models.DateTimeField(auto_now=True)
625
+
626
+ class Meta:
627
+ ordering = ["-created_at"]
628
+ verbose_name = _("CP Configuration")
629
+ verbose_name_plural = _("CP Configurations")
630
+
631
+ def __str__(self) -> str: # pragma: no cover - simple representation
632
+ connector = (
633
+ _("connector %(number)s") % {"number": self.connector_id}
634
+ if self.connector_id is not None
635
+ else _("all connectors")
636
+ )
637
+ return _("%(serial)s configuration (%(connector)s)") % {
638
+ "serial": self.charger_identifier,
639
+ "connector": connector,
640
+ }
641
+
642
+
581
643
  class Transaction(Entity):
582
644
  """Charging session data stored for each charger."""
583
645
 
@@ -592,7 +654,18 @@ class Transaction(Entity):
592
654
  blank=True,
593
655
  verbose_name=_("RFID"),
594
656
  )
595
- vin = models.CharField(max_length=17, blank=True)
657
+ vid = models.CharField(
658
+ max_length=64,
659
+ blank=True,
660
+ default="",
661
+ verbose_name=_("VID"),
662
+ help_text=_("Vehicle identifier reported by the charger."),
663
+ )
664
+ vin = models.CharField(
665
+ max_length=17,
666
+ blank=True,
667
+ help_text=_("Deprecated. Use VID instead."),
668
+ )
596
669
  connector_id = models.PositiveIntegerField(null=True, blank=True)
597
670
  meter_start = models.IntegerField(null=True, blank=True)
598
671
  meter_stop = models.IntegerField(null=True, blank=True)
@@ -638,6 +711,22 @@ class Transaction(Entity):
638
711
  verbose_name = _("Transaction")
639
712
  verbose_name_plural = _("CP Transactions")
640
713
 
714
+ @property
715
+ def vehicle_identifier(self) -> str:
716
+ """Return the preferred vehicle identifier for this transaction."""
717
+
718
+ return (self.vid or self.vin or "").strip()
719
+
720
+ @property
721
+ def vehicle_identifier_source(self) -> str:
722
+ """Return which field supplies :pyattr:`vehicle_identifier`."""
723
+
724
+ if (self.vid or "").strip():
725
+ return "vid"
726
+ if (self.vin or "").strip():
727
+ return "vin"
728
+ return ""
729
+
641
730
  @property
642
731
  def kw(self) -> float:
643
732
  """Return consumed energy in kW for this session."""
ocpp/store.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from datetime import datetime
6
+ from datetime import datetime, timezone
7
7
  import json
8
8
  from pathlib import Path
9
9
  import re
@@ -427,7 +427,7 @@ def _file_path(cid: str, log_type: str = "charger") -> Path:
427
427
  def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
428
428
  """Append a timestamped log entry for the given id and log type."""
429
429
 
430
- timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
430
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
431
431
  entry = f"{timestamp} {entry}"
432
432
 
433
433
  store = logs[log_type]
@@ -454,7 +454,7 @@ def start_session_log(cid: str, tx_id: int) -> None:
454
454
 
455
455
  history[cid] = {
456
456
  "transaction": tx_id,
457
- "start": datetime.utcnow(),
457
+ "start": datetime.now(timezone.utc),
458
458
  "messages": [],
459
459
  }
460
460
 
@@ -467,7 +467,9 @@ def add_session_message(cid: str, message: str) -> None:
467
467
  return
468
468
  sess["messages"].append(
469
469
  {
470
- "timestamp": datetime.utcnow().isoformat() + "Z",
470
+ "timestamp": datetime.now(timezone.utc)
471
+ .isoformat()
472
+ .replace("+00:00", "Z"),
471
473
  "message": message,
472
474
  }
473
475
  )
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):