arthexis 0.1.12__py3-none-any.whl → 0.1.14__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.

Files changed (107) hide show
  1. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -716
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2772
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2672
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -350
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1511
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1382
  56. core/widgets.py +213 -51
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -898
  60. nodes/apps.py +87 -70
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1416
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -2497
  71. nodes/urls.py +15 -13
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -451
  74. ocpp/admin.py +948 -804
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1342
  77. ocpp/evcs.py +844 -931
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -915
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -9
  82. ocpp/simulator.py +745 -724
  83. ocpp/status_display.py +26 -0
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -3485
  89. ocpp/transactions_io.py +189 -179
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1151
  92. pages/admin.py +708 -536
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -169
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2083
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1120
  104. arthexis-0.1.12.dist-info/RECORD +0 -102
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
ocpp/models.py CHANGED
@@ -1,915 +1,917 @@
1
- import re
2
- import socket
3
- from decimal import Decimal, InvalidOperation
4
-
5
- from django.conf import settings
6
- from django.contrib.sites.models import Site
7
- from django.db import models
8
- from django.db.models import Q
9
- from django.core.exceptions import ValidationError
10
- from django.urls import reverse
11
- from django.utils.translation import gettext_lazy as _
12
-
13
- from core.entity import Entity, EntityManager
14
- from nodes.models import Node
15
-
16
- from core.models import (
17
- EnergyAccount,
18
- Reference,
19
- RFID as CoreRFID,
20
- ElectricVehicle as CoreElectricVehicle,
21
- Brand as CoreBrand,
22
- EVModel as CoreEVModel,
23
- SecurityGroup,
24
- )
25
- from .reference_utils import url_targets_local_loopback
26
-
27
-
28
- class Location(Entity):
29
- """Physical location shared by chargers."""
30
-
31
- name = models.CharField(max_length=200)
32
- latitude = models.DecimalField(
33
- max_digits=9, decimal_places=6, null=True, blank=True
34
- )
35
- longitude = models.DecimalField(
36
- max_digits=9, decimal_places=6, null=True, blank=True
37
- )
38
-
39
- def __str__(self) -> str: # pragma: no cover - simple representation
40
- return self.name
41
-
42
- class Meta:
43
- verbose_name = _("Charge Location")
44
- verbose_name_plural = _("Charge Locations")
45
-
46
-
47
- class Charger(Entity):
48
- """Known charge point."""
49
-
50
- _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
51
-
52
- OPERATIVE_STATUSES = {
53
- "Available",
54
- "Preparing",
55
- "Charging",
56
- "SuspendedEV",
57
- "SuspendedEVSE",
58
- "Finishing",
59
- "Reserved",
60
- }
61
- INOPERATIVE_STATUSES = {"Unavailable", "Faulted"}
62
-
63
- charger_id = models.CharField(
64
- _("Serial Number"),
65
- max_length=100,
66
- help_text="Unique identifier reported by the charger.",
67
- )
68
- display_name = models.CharField(
69
- _("Display Name"),
70
- max_length=200,
71
- blank=True,
72
- help_text="Optional friendly name shown on public pages.",
73
- )
74
- connector_id = models.PositiveIntegerField(
75
- _("Connector ID"),
76
- blank=True,
77
- null=True,
78
- help_text="Optional connector identifier for multi-connector chargers.",
79
- )
80
- public_display = models.BooleanField(
81
- _("Public"),
82
- default=True,
83
- help_text="Display this charger on the public status dashboard.",
84
- )
85
- require_rfid = models.BooleanField(
86
- _("Require RFID Authorization"),
87
- default=False,
88
- help_text="Require a valid RFID before starting a charging session.",
89
- )
90
- firmware_status = models.CharField(
91
- _("Status"),
92
- max_length=32,
93
- blank=True,
94
- default="",
95
- help_text="Latest firmware status reported by the charger.",
96
- )
97
- firmware_status_info = models.CharField(
98
- _("Status Details"),
99
- max_length=255,
100
- blank=True,
101
- default="",
102
- help_text="Additional information supplied with the firmware status.",
103
- )
104
- firmware_timestamp = models.DateTimeField(
105
- _("Status Timestamp"),
106
- null=True,
107
- blank=True,
108
- help_text="When the charger reported the current firmware status.",
109
- )
110
- last_heartbeat = models.DateTimeField(null=True, blank=True)
111
- last_meter_values = models.JSONField(default=dict, blank=True)
112
- last_status = models.CharField(max_length=64, blank=True)
113
- last_error_code = models.CharField(max_length=64, blank=True)
114
- last_status_vendor_info = models.JSONField(null=True, blank=True)
115
- last_status_timestamp = models.DateTimeField(null=True, blank=True)
116
- availability_state = models.CharField(
117
- _("State"),
118
- max_length=16,
119
- blank=True,
120
- default="",
121
- help_text=(
122
- "Current availability reported by the charger "
123
- "(Operative/Inoperative)."
124
- ),
125
- )
126
- availability_state_updated_at = models.DateTimeField(
127
- _("State Updated At"),
128
- null=True,
129
- blank=True,
130
- help_text="When the current availability state became effective.",
131
- )
132
- availability_requested_state = models.CharField(
133
- _("Requested State"),
134
- max_length=16,
135
- blank=True,
136
- default="",
137
- help_text="Last availability state requested via ChangeAvailability.",
138
- )
139
- availability_requested_at = models.DateTimeField(
140
- _("Requested At"),
141
- null=True,
142
- blank=True,
143
- help_text="When the last ChangeAvailability request was sent.",
144
- )
145
- availability_request_status = models.CharField(
146
- _("Request Status"),
147
- max_length=16,
148
- blank=True,
149
- default="",
150
- help_text=(
151
- "Latest response status for ChangeAvailability "
152
- "(Accepted/Rejected/Scheduled)."
153
- ),
154
- )
155
- availability_request_status_at = models.DateTimeField(
156
- _("Request Status At"),
157
- null=True,
158
- blank=True,
159
- help_text="When the last ChangeAvailability response was received.",
160
- )
161
- availability_request_details = models.CharField(
162
- _("Request Details"),
163
- max_length=255,
164
- blank=True,
165
- default="",
166
- help_text="Additional details from the last ChangeAvailability response.",
167
- )
168
- temperature = models.DecimalField(
169
- max_digits=5, decimal_places=1, null=True, blank=True
170
- )
171
- temperature_unit = models.CharField(max_length=16, blank=True)
172
- diagnostics_status = models.CharField(
173
- max_length=32,
174
- null=True,
175
- blank=True,
176
- help_text="Most recent diagnostics status reported by the charger.",
177
- )
178
- diagnostics_timestamp = models.DateTimeField(
179
- null=True,
180
- blank=True,
181
- help_text="Timestamp associated with the latest diagnostics status.",
182
- )
183
- diagnostics_location = models.CharField(
184
- max_length=255,
185
- null=True,
186
- blank=True,
187
- help_text="Location or URI reported for the latest diagnostics upload.",
188
- )
189
- reference = models.OneToOneField(
190
- Reference, null=True, blank=True, on_delete=models.SET_NULL
191
- )
192
- location = models.ForeignKey(
193
- Location,
194
- null=True,
195
- blank=True,
196
- on_delete=models.SET_NULL,
197
- related_name="chargers",
198
- )
199
- last_path = models.CharField(max_length=255, blank=True)
200
- manager_node = models.ForeignKey(
201
- "nodes.Node",
202
- on_delete=models.SET_NULL,
203
- null=True,
204
- blank=True,
205
- related_name="managed_chargers",
206
- )
207
- owner_users = models.ManyToManyField(
208
- settings.AUTH_USER_MODEL,
209
- blank=True,
210
- related_name="owned_chargers",
211
- help_text=_("Users who can view this charge point."),
212
- )
213
- owner_groups = models.ManyToManyField(
214
- SecurityGroup,
215
- blank=True,
216
- related_name="owned_chargers",
217
- help_text=_("Security groups that can view this charge point."),
218
- )
219
-
220
- def __str__(self) -> str: # pragma: no cover - simple representation
221
- return self.charger_id
222
-
223
- @classmethod
224
- def visible_for_user(cls, user):
225
- """Return chargers marked for display that the user may view."""
226
-
227
- qs = cls.objects.filter(public_display=True)
228
- if getattr(user, "is_superuser", False):
229
- return qs
230
- if not getattr(user, "is_authenticated", False):
231
- return qs.filter(
232
- owner_users__isnull=True, owner_groups__isnull=True
233
- ).distinct()
234
- group_ids = list(user.groups.values_list("pk", flat=True))
235
- visibility = Q(owner_users__isnull=True, owner_groups__isnull=True) | Q(
236
- owner_users=user
237
- )
238
- if group_ids:
239
- visibility |= Q(owner_groups__pk__in=group_ids)
240
- return qs.filter(visibility).distinct()
241
-
242
- def has_owner_scope(self) -> bool:
243
- """Return ``True`` when owner restrictions are defined."""
244
-
245
- return self.owner_users.exists() or self.owner_groups.exists()
246
-
247
- def is_visible_to(self, user) -> bool:
248
- """Return ``True`` when ``user`` may view this charger."""
249
-
250
- if getattr(user, "is_superuser", False):
251
- return True
252
- if not self.has_owner_scope():
253
- return True
254
- if not getattr(user, "is_authenticated", False):
255
- return False
256
- if self.owner_users.filter(pk=user.pk).exists():
257
- return True
258
- user_group_ids = user.groups.values_list("pk", flat=True)
259
- return self.owner_groups.filter(pk__in=user_group_ids).exists()
260
-
261
- class Meta:
262
- verbose_name = _("Charge Point")
263
- verbose_name_plural = _("Charge Points")
264
- constraints = [
265
- models.UniqueConstraint(
266
- fields=("charger_id", "connector_id"),
267
- condition=models.Q(connector_id__isnull=False),
268
- name="charger_connector_unique",
269
- ),
270
- models.UniqueConstraint(
271
- fields=("charger_id",),
272
- condition=models.Q(connector_id__isnull=True),
273
- name="charger_unique_without_connector",
274
- ),
275
- ]
276
-
277
-
278
- @classmethod
279
- def normalize_serial(cls, value: str | None) -> str:
280
- """Return ``value`` trimmed for consistent comparisons."""
281
-
282
- if value is None:
283
- return ""
284
- return str(value).strip()
285
-
286
- @classmethod
287
- def is_placeholder_serial(cls, value: str | None) -> bool:
288
- """Return ``True`` when ``value`` matches the placeholder pattern."""
289
-
290
- normalized = cls.normalize_serial(value)
291
- return bool(normalized) and bool(cls._PLACEHOLDER_SERIAL_RE.match(normalized))
292
-
293
- @classmethod
294
- def validate_serial(cls, value: str | None) -> str:
295
- """Return a normalized serial number or raise ``ValidationError``."""
296
-
297
- normalized = cls.normalize_serial(value)
298
- if not normalized:
299
- raise ValidationError({"charger_id": _("Serial Number cannot be blank.")})
300
- if cls.is_placeholder_serial(normalized):
301
- raise ValidationError(
302
- {
303
- "charger_id": _(
304
- "Serial Number placeholder values such as <charger_id> are not allowed."
305
- )
306
- }
307
- )
308
- return normalized
309
-
310
- AGGREGATE_CONNECTOR_SLUG = "all"
311
-
312
- def identity_tuple(self) -> tuple[str, int | None]:
313
- """Return the canonical identity for this charger."""
314
-
315
- return (
316
- self.charger_id,
317
- self.connector_id if self.connector_id is not None else None,
318
- )
319
-
320
- @classmethod
321
- def connector_slug_from_value(cls, connector: int | None) -> str:
322
- """Return the slug used in URLs for the given connector."""
323
-
324
- return cls.AGGREGATE_CONNECTOR_SLUG if connector is None else str(connector)
325
-
326
- @classmethod
327
- def connector_value_from_slug(cls, slug: int | str | None) -> int | None:
328
- """Return the connector integer represented by ``slug``."""
329
-
330
- if slug in (None, "", cls.AGGREGATE_CONNECTOR_SLUG):
331
- return None
332
- if isinstance(slug, int):
333
- return slug
334
- try:
335
- return int(str(slug))
336
- except (TypeError, ValueError) as exc:
337
- raise ValueError(f"Invalid connector slug: {slug}") from exc
338
-
339
- @classmethod
340
- def availability_state_from_status(cls, status: str) -> str | None:
341
- """Return the availability state implied by a status notification."""
342
-
343
- normalized = (status or "").strip()
344
- if not normalized:
345
- return None
346
- if normalized in cls.INOPERATIVE_STATUSES:
347
- return "Inoperative"
348
- if normalized in cls.OPERATIVE_STATUSES:
349
- return "Operative"
350
- return None
351
-
352
- @property
353
- def connector_slug(self) -> str:
354
- """Return the slug representing this charger's connector."""
355
-
356
- return type(self).connector_slug_from_value(self.connector_id)
357
-
358
- @property
359
- def connector_label(self) -> str:
360
- """Return a short human readable label for this connector."""
361
-
362
- if self.connector_id is None:
363
- return _("All Connectors")
364
-
365
- special_labels = {
366
- 1: _("Connector 1 (Left)"),
367
- 2: _("Connector 2 (Right)"),
368
- }
369
- if self.connector_id in special_labels:
370
- return special_labels[self.connector_id]
371
-
372
- return _("Connector %(number)s") % {"number": self.connector_id}
373
-
374
- def identity_slug(self) -> str:
375
- """Return a unique slug for this charger identity."""
376
-
377
- serial, connector = self.identity_tuple()
378
- return f"{serial}#{type(self).connector_slug_from_value(connector)}"
379
-
380
- def get_absolute_url(self):
381
- serial, connector = self.identity_tuple()
382
- connector_slug = type(self).connector_slug_from_value(connector)
383
- if connector_slug == self.AGGREGATE_CONNECTOR_SLUG:
384
- return reverse("charger-page", args=[serial])
385
- return reverse("charger-page-connector", args=[serial, connector_slug])
386
-
387
- def _fallback_domain(self) -> str:
388
- """Return a best-effort hostname when the Sites framework is unset."""
389
-
390
- fallback = getattr(settings, "DEFAULT_SITE_DOMAIN", "") or getattr(
391
- settings, "DEFAULT_DOMAIN", ""
392
- )
393
- if fallback:
394
- return fallback.strip()
395
-
396
- for host in getattr(settings, "ALLOWED_HOSTS", []):
397
- if not isinstance(host, str):
398
- continue
399
- host = host.strip()
400
- if not host or host.startswith("*") or "/" in host:
401
- continue
402
- return host
403
-
404
- return socket.gethostname() or "localhost"
405
-
406
- def _full_url(self) -> str:
407
- """Return absolute URL for the charger landing page."""
408
-
409
- try:
410
- domain = Site.objects.get_current().domain.strip()
411
- except Site.DoesNotExist:
412
- domain = ""
413
-
414
- if not domain:
415
- domain = self._fallback_domain()
416
-
417
- scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
418
- return f"{scheme}://{domain}{self.get_absolute_url()}"
419
-
420
- def clean(self):
421
- super().clean()
422
- self.charger_id = type(self).validate_serial(self.charger_id)
423
-
424
- def save(self, *args, **kwargs):
425
- self.charger_id = type(self).validate_serial(self.charger_id)
426
- update_fields = kwargs.get("update_fields")
427
- update_list = list(update_fields) if update_fields is not None else None
428
- if not self.manager_node_id:
429
- local_node = Node.get_local()
430
- if local_node:
431
- self.manager_node = local_node
432
- if update_list is not None and "manager_node" not in update_list:
433
- update_list.append("manager_node")
434
- if not self.location_id:
435
- existing = (
436
- type(self)
437
- .objects.filter(charger_id=self.charger_id, location__isnull=False)
438
- .exclude(pk=self.pk)
439
- .select_related("location")
440
- .first()
441
- )
442
- if existing:
443
- self.location = existing.location
444
- else:
445
- location, _ = Location.objects.get_or_create(name=self.charger_id)
446
- self.location = location
447
- if update_list is not None and "location" not in update_list:
448
- update_list.append("location")
449
- if update_list is not None:
450
- kwargs["update_fields"] = update_list
451
- super().save(*args, **kwargs)
452
- ref_value = self._full_url()
453
- if url_targets_local_loopback(ref_value):
454
- return
455
- if not self.reference or self.reference.value != ref_value:
456
- self.reference = Reference.objects.create(
457
- value=ref_value, alt_text=self.charger_id
458
- )
459
- super().save(update_fields=["reference"])
460
-
461
- def refresh_manager_node(self, node: Node | None = None) -> Node | None:
462
- """Ensure ``manager_node`` matches the provided or local node."""
463
-
464
- node = node or Node.get_local()
465
- if not node:
466
- return None
467
- if self.pk is None:
468
- self.manager_node = node
469
- return node
470
- if self.manager_node_id != node.pk:
471
- type(self).objects.filter(pk=self.pk).update(manager_node=node)
472
- self.manager_node = node
473
- return node
474
-
475
- @property
476
- def name(self) -> str:
477
- if self.location:
478
- if self.connector_id is not None:
479
- return f"{self.location.name} #{self.connector_id}"
480
- return self.location.name
481
- return ""
482
-
483
- @property
484
- def latitude(self):
485
- return self.location.latitude if self.location else None
486
-
487
- @property
488
- def longitude(self):
489
- return self.location.longitude if self.location else None
490
-
491
- @property
492
- def total_kw(self) -> float:
493
- """Return total energy delivered by this charger in kW."""
494
- from . import store
495
-
496
- total = 0.0
497
- for charger in self._target_chargers():
498
- total += charger._total_kw_single(store)
499
- return total
500
-
501
- def _store_keys(self) -> list[str]:
502
- """Return keys used for store lookups with fallbacks."""
503
-
504
- from . import store
505
-
506
- base = self.charger_id
507
- connector = self.connector_id
508
- keys: list[str] = []
509
- keys.append(store.identity_key(base, connector))
510
- if connector is not None:
511
- keys.append(store.identity_key(base, None))
512
- keys.append(store.pending_key(base))
513
- keys.append(base)
514
- seen: set[str] = set()
515
- deduped: list[str] = []
516
- for key in keys:
517
- if key not in seen:
518
- seen.add(key)
519
- deduped.append(key)
520
- return deduped
521
-
522
- def _target_chargers(self):
523
- """Return chargers contributing to aggregate operations."""
524
-
525
- qs = type(self).objects.filter(charger_id=self.charger_id)
526
- if self.connector_id is None:
527
- return qs
528
- return qs.filter(pk=self.pk)
529
-
530
- def _total_kw_single(self, store_module) -> float:
531
- """Return total kW for this specific charger identity."""
532
-
533
- tx_active = None
534
- if self.connector_id is not None:
535
- tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
536
- qs = self.transactions.all()
537
- if tx_active and tx_active.pk is not None:
538
- qs = qs.exclude(pk=tx_active.pk)
539
- total = 0.0
540
- for tx in qs:
541
- kw = tx.kw
542
- if kw:
543
- total += kw
544
- if tx_active:
545
- kw = tx_active.kw
546
- if kw:
547
- total += kw
548
- return total
549
-
550
- def purge(self):
551
- from . import store
552
-
553
- for charger in self._target_chargers():
554
- charger.transactions.all().delete()
555
- charger.meter_values.all().delete()
556
- for key in charger._store_keys():
557
- store.clear_log(key, log_type="charger")
558
- store.transactions.pop(key, None)
559
- store.history.pop(key, None)
560
-
561
- def delete(self, *args, **kwargs):
562
- from django.db.models.deletion import ProtectedError
563
- from . import store
564
-
565
- for charger in self._target_chargers():
566
- has_data = (
567
- charger.transactions.exists()
568
- or charger.meter_values.exists()
569
- or any(
570
- store.get_logs(key, log_type="charger")
571
- for key in charger._store_keys()
572
- )
573
- or any(store.transactions.get(key) for key in charger._store_keys())
574
- or any(store.history.get(key) for key in charger._store_keys())
575
- )
576
- if has_data:
577
- raise ProtectedError("Purge data before deleting charger.", [])
578
- super().delete(*args, **kwargs)
579
-
580
-
581
- class Transaction(Entity):
582
- """Charging session data stored for each charger."""
583
-
584
- charger = models.ForeignKey(
585
- Charger, on_delete=models.CASCADE, related_name="transactions", null=True
586
- )
587
- account = models.ForeignKey(
588
- EnergyAccount, on_delete=models.PROTECT, related_name="transactions", null=True
589
- )
590
- rfid = models.CharField(
591
- max_length=20,
592
- blank=True,
593
- verbose_name=_("RFID"),
594
- )
595
- vin = models.CharField(max_length=17, blank=True)
596
- connector_id = models.PositiveIntegerField(null=True, blank=True)
597
- meter_start = models.IntegerField(null=True, blank=True)
598
- meter_stop = models.IntegerField(null=True, blank=True)
599
- voltage_start = models.DecimalField(
600
- max_digits=12, decimal_places=3, null=True, blank=True
601
- )
602
- voltage_stop = models.DecimalField(
603
- max_digits=12, decimal_places=3, null=True, blank=True
604
- )
605
- current_import_start = models.DecimalField(
606
- max_digits=12, decimal_places=3, null=True, blank=True
607
- )
608
- current_import_stop = models.DecimalField(
609
- max_digits=12, decimal_places=3, null=True, blank=True
610
- )
611
- current_offered_start = models.DecimalField(
612
- max_digits=12, decimal_places=3, null=True, blank=True
613
- )
614
- current_offered_stop = models.DecimalField(
615
- max_digits=12, decimal_places=3, null=True, blank=True
616
- )
617
- temperature_start = models.DecimalField(
618
- max_digits=12, decimal_places=3, null=True, blank=True
619
- )
620
- temperature_stop = models.DecimalField(
621
- max_digits=12, decimal_places=3, null=True, blank=True
622
- )
623
- soc_start = models.DecimalField(
624
- max_digits=12, decimal_places=3, null=True, blank=True
625
- )
626
- soc_stop = models.DecimalField(
627
- max_digits=12, decimal_places=3, null=True, blank=True
628
- )
629
- start_time = models.DateTimeField()
630
- stop_time = models.DateTimeField(null=True, blank=True)
631
-
632
- def __str__(self) -> str: # pragma: no cover - simple representation
633
- return f"{self.charger}:{self.pk}"
634
-
635
- class Meta:
636
- verbose_name = _("Transaction")
637
- verbose_name_plural = _("CP Transactions")
638
-
639
- @property
640
- def kw(self) -> float:
641
- """Return consumed energy in kW for this session."""
642
- start_val = None
643
- if self.meter_start is not None:
644
- start_val = float(self.meter_start) / 1000.0
645
-
646
- end_val = None
647
- if self.meter_stop is not None:
648
- end_val = float(self.meter_stop) / 1000.0
649
-
650
- readings = list(
651
- self.meter_values.filter(energy__isnull=False).order_by("timestamp")
652
- )
653
- if readings:
654
- if start_val is None:
655
- start_val = float(readings[0].energy or 0)
656
- # Always use the latest available reading for the end value when a
657
- # stop meter has not been recorded yet. This allows active
658
- # transactions to report totals using their most recent reading.
659
- if end_val is None:
660
- end_val = float(readings[-1].energy or 0)
661
-
662
- if start_val is None or end_val is None:
663
- return 0.0
664
-
665
- total = end_val - start_val
666
- return max(total, 0.0)
667
-
668
-
669
- class MeterValue(Entity):
670
- """Parsed meter values reported by chargers."""
671
-
672
- charger = models.ForeignKey(
673
- Charger, on_delete=models.CASCADE, related_name="meter_values"
674
- )
675
- connector_id = models.PositiveIntegerField(null=True, blank=True)
676
- transaction = models.ForeignKey(
677
- Transaction,
678
- on_delete=models.CASCADE,
679
- related_name="meter_values",
680
- null=True,
681
- blank=True,
682
- )
683
- timestamp = models.DateTimeField()
684
- context = models.CharField(max_length=32, blank=True)
685
- energy = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
686
- voltage = models.DecimalField(
687
- max_digits=12, decimal_places=3, null=True, blank=True
688
- )
689
- current_import = models.DecimalField(
690
- max_digits=12, decimal_places=3, null=True, blank=True
691
- )
692
- current_offered = models.DecimalField(
693
- max_digits=12, decimal_places=3, null=True, blank=True
694
- )
695
- temperature = models.DecimalField(
696
- max_digits=12, decimal_places=3, null=True, blank=True
697
- )
698
- soc = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
699
-
700
- def __str__(self) -> str: # pragma: no cover - simple representation
701
- return f"{self.charger} {self.timestamp}"
702
-
703
- @property
704
- def value(self):
705
- return self.energy
706
-
707
- @value.setter
708
- def value(self, new_value):
709
- self.energy = new_value
710
-
711
- class Meta:
712
- verbose_name = _("Meter Value")
713
- verbose_name_plural = _("Meter Values")
714
-
715
-
716
- class MeterReadingManager(EntityManager):
717
- def _normalize_kwargs(self, kwargs: dict) -> dict:
718
- normalized = dict(kwargs)
719
- value = normalized.pop("value", None)
720
- unit = normalized.pop("unit", None)
721
- if value is not None:
722
- energy = value
723
- try:
724
- energy = Decimal(value)
725
- except (InvalidOperation, TypeError, ValueError):
726
- energy = None
727
- if energy is not None:
728
- unit_normalized = (unit or "").lower()
729
- if unit_normalized in {"w", "wh"}:
730
- energy = energy / Decimal("1000")
731
- normalized.setdefault("energy", energy)
732
- normalized.pop("measurand", None)
733
- return normalized
734
-
735
- def create(self, **kwargs):
736
- return super().create(**self._normalize_kwargs(kwargs))
737
-
738
- def get_or_create(self, defaults=None, **kwargs):
739
- if defaults:
740
- defaults = self._normalize_kwargs(defaults)
741
- return super().get_or_create(
742
- defaults=defaults, **self._normalize_kwargs(kwargs)
743
- )
744
-
745
-
746
- class MeterReading(MeterValue):
747
- """Proxy model for backwards compatibility."""
748
-
749
- objects = MeterReadingManager()
750
-
751
- class Meta:
752
- proxy = True
753
- verbose_name = _("Meter Value")
754
- verbose_name_plural = _("Meter Values")
755
-
756
-
757
- class Simulator(Entity):
758
- """Preconfigured simulator that can be started from the admin."""
759
-
760
- name = models.CharField(max_length=100, unique=True)
761
- cp_path = models.CharField(
762
- _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
763
- )
764
- host = models.CharField(max_length=100, default="127.0.0.1")
765
- ws_port = models.IntegerField(
766
- _("WS Port"), default=8000, null=True, blank=True
767
- )
768
- rfid = models.CharField(
769
- max_length=255,
770
- default="FFFFFFFF",
771
- verbose_name=_("RFID"),
772
- )
773
- vin = models.CharField(max_length=17, blank=True)
774
- serial_number = models.CharField(_("Serial Number"), max_length=100, blank=True)
775
- connector_id = models.IntegerField(_("Connector ID"), default=1)
776
- duration = models.IntegerField(default=600)
777
- interval = models.FloatField(default=5.0)
778
- pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
779
- kw_max = models.FloatField(default=60.0)
780
- repeat = models.BooleanField(default=False)
781
- username = models.CharField(max_length=100, blank=True)
782
- password = models.CharField(max_length=100, blank=True)
783
- door_open = models.BooleanField(
784
- _("Door Open"),
785
- default=False,
786
- help_text=_("Send a DoorOpen error StatusNotification when enabled."),
787
- )
788
- configuration_keys = models.JSONField(
789
- default=list,
790
- blank=True,
791
- help_text=_(
792
- "List of configurationKey entries to return for GetConfiguration calls."
793
- ),
794
- )
795
- configuration_unknown_keys = models.JSONField(
796
- default=list,
797
- blank=True,
798
- help_text=_("Keys to include in the GetConfiguration unknownKey response."),
799
- )
800
-
801
- def __str__(self) -> str: # pragma: no cover - simple representation
802
- return self.name
803
-
804
- class Meta:
805
- verbose_name = _("CP Simulator")
806
- verbose_name_plural = _("CP Simulators")
807
-
808
- def as_config(self):
809
- from .simulator import SimulatorConfig
810
-
811
- return SimulatorConfig(
812
- host=self.host,
813
- ws_port=self.ws_port,
814
- rfid=self.rfid,
815
- vin=self.vin,
816
- cp_path=self.cp_path,
817
- serial_number=self.serial_number,
818
- connector_id=self.connector_id,
819
- duration=self.duration,
820
- interval=self.interval,
821
- pre_charge_delay=self.pre_charge_delay,
822
- kw_max=self.kw_max,
823
- repeat=self.repeat,
824
- username=self.username or None,
825
- password=self.password or None,
826
- configuration_keys=self.configuration_keys or [],
827
- configuration_unknown_keys=self.configuration_unknown_keys or [],
828
- )
829
-
830
- @property
831
- def ws_url(self) -> str: # pragma: no cover - simple helper
832
- path = self.cp_path
833
- if not path.endswith("/"):
834
- path += "/"
835
- if self.ws_port:
836
- return f"ws://{self.host}:{self.ws_port}/{path}"
837
- return f"ws://{self.host}/{path}"
838
-
839
-
840
- class DataTransferMessage(models.Model):
841
- """Persisted record of OCPP DataTransfer exchanges."""
842
-
843
- DIRECTION_CP_TO_CSMS = "cp_to_csms"
844
- DIRECTION_CSMS_TO_CP = "csms_to_cp"
845
- DIRECTION_CHOICES = (
846
- (DIRECTION_CP_TO_CSMS, _("Charge Point → CSMS")),
847
- (DIRECTION_CSMS_TO_CP, _("CSMS → Charge Point")),
848
- )
849
-
850
- charger = models.ForeignKey(
851
- "Charger",
852
- on_delete=models.CASCADE,
853
- related_name="data_transfer_messages",
854
- )
855
- connector_id = models.PositiveIntegerField(null=True, blank=True)
856
- direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
857
- ocpp_message_id = models.CharField(max_length=64)
858
- vendor_id = models.CharField(max_length=255, blank=True)
859
- message_id = models.CharField(max_length=255, blank=True)
860
- payload = models.JSONField(default=dict, blank=True)
861
- status = models.CharField(max_length=64, blank=True)
862
- response_data = models.JSONField(null=True, blank=True)
863
- error_code = models.CharField(max_length=64, blank=True)
864
- error_description = models.TextField(blank=True)
865
- error_details = models.JSONField(null=True, blank=True)
866
- responded_at = models.DateTimeField(null=True, blank=True)
867
- created_at = models.DateTimeField(auto_now_add=True)
868
- updated_at = models.DateTimeField(auto_now=True)
869
-
870
- class Meta:
871
- ordering = ["-created_at"]
872
- indexes = [
873
- models.Index(
874
- fields=["ocpp_message_id"],
875
- name="ocpp_datatr_ocpp_me_70d17f_idx",
876
- ),
877
- models.Index(
878
- fields=["vendor_id"], name="ocpp_datatr_vendor__59e1c7_idx"
879
- ),
880
- ]
881
-
882
- def __str__(self) -> str: # pragma: no cover - simple representation
883
- return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
884
-
885
-
886
- class RFID(CoreRFID):
887
- class Meta:
888
- proxy = True
889
- app_label = "ocpp"
890
- verbose_name = CoreRFID._meta.verbose_name
891
- verbose_name_plural = CoreRFID._meta.verbose_name_plural
892
-
893
-
894
- class ElectricVehicle(CoreElectricVehicle):
895
- class Meta:
896
- proxy = True
897
- app_label = "ocpp"
898
- verbose_name = _("Electric Vehicle")
899
- verbose_name_plural = _("Electric Vehicles")
900
-
901
-
902
- class Brand(CoreBrand):
903
- class Meta:
904
- proxy = True
905
- app_label = "ocpp"
906
- verbose_name = CoreBrand._meta.verbose_name
907
- verbose_name_plural = CoreBrand._meta.verbose_name_plural
908
-
909
-
910
- class EVModel(CoreEVModel):
911
- class Meta:
912
- proxy = True
913
- app_label = "ocpp"
914
- verbose_name = CoreEVModel._meta.verbose_name
915
- verbose_name_plural = CoreEVModel._meta.verbose_name_plural
1
+ import re
2
+ import socket
3
+ from decimal import Decimal, InvalidOperation
4
+
5
+ from django.conf import settings
6
+ from django.contrib.sites.models import Site
7
+ from django.db import models
8
+ from django.db.models import Q
9
+ from django.core.exceptions import ValidationError
10
+ from django.urls import reverse
11
+ from django.utils.translation import gettext_lazy as _
12
+
13
+ from core.entity import Entity, EntityManager
14
+ from nodes.models import Node
15
+
16
+ from core.models import (
17
+ EnergyAccount,
18
+ Reference,
19
+ RFID as CoreRFID,
20
+ ElectricVehicle as CoreElectricVehicle,
21
+ Brand as CoreBrand,
22
+ EVModel as CoreEVModel,
23
+ SecurityGroup,
24
+ )
25
+ from .reference_utils import url_targets_local_loopback
26
+
27
+
28
+ class Location(Entity):
29
+ """Physical location shared by chargers."""
30
+
31
+ name = models.CharField(max_length=200)
32
+ latitude = models.DecimalField(
33
+ max_digits=9, decimal_places=6, null=True, blank=True
34
+ )
35
+ longitude = models.DecimalField(
36
+ max_digits=9, decimal_places=6, null=True, blank=True
37
+ )
38
+
39
+ def __str__(self) -> str: # pragma: no cover - simple representation
40
+ return self.name
41
+
42
+ class Meta:
43
+ verbose_name = _("Charge Location")
44
+ verbose_name_plural = _("Charge Locations")
45
+
46
+
47
+ class Charger(Entity):
48
+ """Known charge point."""
49
+
50
+ _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
51
+
52
+ OPERATIVE_STATUSES = {
53
+ "Available",
54
+ "Preparing",
55
+ "Charging",
56
+ "SuspendedEV",
57
+ "SuspendedEVSE",
58
+ "Finishing",
59
+ "Reserved",
60
+ }
61
+ INOPERATIVE_STATUSES = {"Unavailable", "Faulted"}
62
+
63
+ charger_id = models.CharField(
64
+ _("Serial Number"),
65
+ max_length=100,
66
+ help_text="Unique identifier reported by the charger.",
67
+ )
68
+ display_name = models.CharField(
69
+ _("Display Name"),
70
+ max_length=200,
71
+ blank=True,
72
+ help_text="Optional friendly name shown on public pages.",
73
+ )
74
+ connector_id = models.PositiveIntegerField(
75
+ _("Connector ID"),
76
+ blank=True,
77
+ null=True,
78
+ help_text="Optional connector identifier for multi-connector chargers.",
79
+ )
80
+ public_display = models.BooleanField(
81
+ _("Public"),
82
+ default=True,
83
+ help_text="Display this charger on the public status dashboard.",
84
+ )
85
+ require_rfid = models.BooleanField(
86
+ _("Require RFID Authorization"),
87
+ default=False,
88
+ help_text="Require a valid RFID before starting a charging session.",
89
+ )
90
+ firmware_status = models.CharField(
91
+ _("Status"),
92
+ max_length=32,
93
+ blank=True,
94
+ default="",
95
+ help_text="Latest firmware status reported by the charger.",
96
+ )
97
+ firmware_status_info = models.CharField(
98
+ _("Status Details"),
99
+ max_length=255,
100
+ blank=True,
101
+ default="",
102
+ help_text="Additional information supplied with the firmware status.",
103
+ )
104
+ firmware_timestamp = models.DateTimeField(
105
+ _("Status Timestamp"),
106
+ null=True,
107
+ blank=True,
108
+ help_text="When the charger reported the current firmware status.",
109
+ )
110
+ last_heartbeat = models.DateTimeField(null=True, blank=True)
111
+ last_meter_values = models.JSONField(default=dict, blank=True)
112
+ last_status = models.CharField(max_length=64, blank=True)
113
+ last_error_code = models.CharField(max_length=64, blank=True)
114
+ last_status_vendor_info = models.JSONField(null=True, blank=True)
115
+ last_status_timestamp = models.DateTimeField(null=True, blank=True)
116
+ availability_state = models.CharField(
117
+ _("State"),
118
+ max_length=16,
119
+ blank=True,
120
+ default="",
121
+ help_text=(
122
+ "Current availability reported by the charger "
123
+ "(Operative/Inoperative)."
124
+ ),
125
+ )
126
+ availability_state_updated_at = models.DateTimeField(
127
+ _("State Updated At"),
128
+ null=True,
129
+ blank=True,
130
+ help_text="When the current availability state became effective.",
131
+ )
132
+ availability_requested_state = models.CharField(
133
+ _("Requested State"),
134
+ max_length=16,
135
+ blank=True,
136
+ default="",
137
+ help_text="Last availability state requested via ChangeAvailability.",
138
+ )
139
+ availability_requested_at = models.DateTimeField(
140
+ _("Requested At"),
141
+ null=True,
142
+ blank=True,
143
+ help_text="When the last ChangeAvailability request was sent.",
144
+ )
145
+ availability_request_status = models.CharField(
146
+ _("Request Status"),
147
+ max_length=16,
148
+ blank=True,
149
+ default="",
150
+ help_text=(
151
+ "Latest response status for ChangeAvailability "
152
+ "(Accepted/Rejected/Scheduled)."
153
+ ),
154
+ )
155
+ availability_request_status_at = models.DateTimeField(
156
+ _("Request Status At"),
157
+ null=True,
158
+ blank=True,
159
+ help_text="When the last ChangeAvailability response was received.",
160
+ )
161
+ availability_request_details = models.CharField(
162
+ _("Request Details"),
163
+ max_length=255,
164
+ blank=True,
165
+ default="",
166
+ help_text="Additional details from the last ChangeAvailability response.",
167
+ )
168
+ temperature = models.DecimalField(
169
+ max_digits=5, decimal_places=1, null=True, blank=True
170
+ )
171
+ temperature_unit = models.CharField(max_length=16, blank=True)
172
+ diagnostics_status = models.CharField(
173
+ max_length=32,
174
+ null=True,
175
+ blank=True,
176
+ help_text="Most recent diagnostics status reported by the charger.",
177
+ )
178
+ diagnostics_timestamp = models.DateTimeField(
179
+ null=True,
180
+ blank=True,
181
+ help_text="Timestamp associated with the latest diagnostics status.",
182
+ )
183
+ diagnostics_location = models.CharField(
184
+ max_length=255,
185
+ null=True,
186
+ blank=True,
187
+ help_text="Location or URI reported for the latest diagnostics upload.",
188
+ )
189
+ reference = models.OneToOneField(
190
+ Reference, null=True, blank=True, on_delete=models.SET_NULL
191
+ )
192
+ location = models.ForeignKey(
193
+ Location,
194
+ null=True,
195
+ blank=True,
196
+ on_delete=models.SET_NULL,
197
+ related_name="chargers",
198
+ )
199
+ last_path = models.CharField(max_length=255, blank=True)
200
+ manager_node = models.ForeignKey(
201
+ "nodes.Node",
202
+ on_delete=models.SET_NULL,
203
+ null=True,
204
+ blank=True,
205
+ related_name="managed_chargers",
206
+ )
207
+ owner_users = models.ManyToManyField(
208
+ settings.AUTH_USER_MODEL,
209
+ blank=True,
210
+ related_name="owned_chargers",
211
+ help_text=_("Users who can view this charge point."),
212
+ )
213
+ owner_groups = models.ManyToManyField(
214
+ SecurityGroup,
215
+ blank=True,
216
+ related_name="owned_chargers",
217
+ help_text=_("Security groups that can view this charge point."),
218
+ )
219
+
220
+ def __str__(self) -> str: # pragma: no cover - simple representation
221
+ return self.charger_id
222
+
223
+ @classmethod
224
+ def visible_for_user(cls, user):
225
+ """Return chargers marked for display that the user may view."""
226
+
227
+ qs = cls.objects.filter(public_display=True)
228
+ if getattr(user, "is_superuser", False):
229
+ return qs
230
+ if not getattr(user, "is_authenticated", False):
231
+ return qs.filter(
232
+ owner_users__isnull=True, owner_groups__isnull=True
233
+ ).distinct()
234
+ group_ids = list(user.groups.values_list("pk", flat=True))
235
+ visibility = Q(owner_users__isnull=True, owner_groups__isnull=True) | Q(
236
+ owner_users=user
237
+ )
238
+ if group_ids:
239
+ visibility |= Q(owner_groups__pk__in=group_ids)
240
+ return qs.filter(visibility).distinct()
241
+
242
+ def has_owner_scope(self) -> bool:
243
+ """Return ``True`` when owner restrictions are defined."""
244
+
245
+ return self.owner_users.exists() or self.owner_groups.exists()
246
+
247
+ def is_visible_to(self, user) -> bool:
248
+ """Return ``True`` when ``user`` may view this charger."""
249
+
250
+ if getattr(user, "is_superuser", False):
251
+ return True
252
+ if not self.has_owner_scope():
253
+ return True
254
+ if not getattr(user, "is_authenticated", False):
255
+ return False
256
+ if self.owner_users.filter(pk=user.pk).exists():
257
+ return True
258
+ user_group_ids = user.groups.values_list("pk", flat=True)
259
+ return self.owner_groups.filter(pk__in=user_group_ids).exists()
260
+
261
+ class Meta:
262
+ verbose_name = _("Charge Point")
263
+ verbose_name_plural = _("Charge Points")
264
+ constraints = [
265
+ models.UniqueConstraint(
266
+ fields=("charger_id", "connector_id"),
267
+ condition=models.Q(connector_id__isnull=False),
268
+ name="charger_connector_unique",
269
+ ),
270
+ models.UniqueConstraint(
271
+ fields=("charger_id",),
272
+ condition=models.Q(connector_id__isnull=True),
273
+ name="charger_unique_without_connector",
274
+ ),
275
+ ]
276
+
277
+
278
+ @classmethod
279
+ def normalize_serial(cls, value: str | None) -> str:
280
+ """Return ``value`` trimmed for consistent comparisons."""
281
+
282
+ if value is None:
283
+ return ""
284
+ return str(value).strip()
285
+
286
+ @classmethod
287
+ def is_placeholder_serial(cls, value: str | None) -> bool:
288
+ """Return ``True`` when ``value`` matches the placeholder pattern."""
289
+
290
+ normalized = cls.normalize_serial(value)
291
+ return bool(normalized) and bool(cls._PLACEHOLDER_SERIAL_RE.match(normalized))
292
+
293
+ @classmethod
294
+ def validate_serial(cls, value: str | None) -> str:
295
+ """Return a normalized serial number or raise ``ValidationError``."""
296
+
297
+ normalized = cls.normalize_serial(value)
298
+ if not normalized:
299
+ raise ValidationError({"charger_id": _("Serial Number cannot be blank.")})
300
+ if cls.is_placeholder_serial(normalized):
301
+ raise ValidationError(
302
+ {
303
+ "charger_id": _(
304
+ "Serial Number placeholder values such as <charger_id> are not allowed."
305
+ )
306
+ }
307
+ )
308
+ return normalized
309
+
310
+ AGGREGATE_CONNECTOR_SLUG = "all"
311
+
312
+ def identity_tuple(self) -> tuple[str, int | None]:
313
+ """Return the canonical identity for this charger."""
314
+
315
+ return (
316
+ self.charger_id,
317
+ self.connector_id if self.connector_id is not None else None,
318
+ )
319
+
320
+ @classmethod
321
+ def connector_slug_from_value(cls, connector: int | None) -> str:
322
+ """Return the slug used in URLs for the given connector."""
323
+
324
+ return cls.AGGREGATE_CONNECTOR_SLUG if connector is None else str(connector)
325
+
326
+ @classmethod
327
+ def connector_value_from_slug(cls, slug: int | str | None) -> int | None:
328
+ """Return the connector integer represented by ``slug``."""
329
+
330
+ if slug in (None, "", cls.AGGREGATE_CONNECTOR_SLUG):
331
+ return None
332
+ if isinstance(slug, int):
333
+ return slug
334
+ try:
335
+ return int(str(slug))
336
+ except (TypeError, ValueError) as exc:
337
+ raise ValueError(f"Invalid connector slug: {slug}") from exc
338
+
339
+ @classmethod
340
+ def availability_state_from_status(cls, status: str) -> str | None:
341
+ """Return the availability state implied by a status notification."""
342
+
343
+ normalized = (status or "").strip()
344
+ if not normalized:
345
+ return None
346
+ if normalized in cls.INOPERATIVE_STATUSES:
347
+ return "Inoperative"
348
+ if normalized in cls.OPERATIVE_STATUSES:
349
+ return "Operative"
350
+ return None
351
+
352
+ @property
353
+ def connector_slug(self) -> str:
354
+ """Return the slug representing this charger's connector."""
355
+
356
+ return type(self).connector_slug_from_value(self.connector_id)
357
+
358
+ @property
359
+ def connector_label(self) -> str:
360
+ """Return a short human readable label for this connector."""
361
+
362
+ if self.connector_id is None:
363
+ return _("All Connectors")
364
+
365
+ special_labels = {
366
+ 1: _("Connector 1 (Left)"),
367
+ 2: _("Connector 2 (Right)"),
368
+ }
369
+ if self.connector_id in special_labels:
370
+ return special_labels[self.connector_id]
371
+
372
+ return _("Connector %(number)s") % {"number": self.connector_id}
373
+
374
+ def identity_slug(self) -> str:
375
+ """Return a unique slug for this charger identity."""
376
+
377
+ serial, connector = self.identity_tuple()
378
+ return f"{serial}#{type(self).connector_slug_from_value(connector)}"
379
+
380
+ def get_absolute_url(self):
381
+ serial, connector = self.identity_tuple()
382
+ connector_slug = type(self).connector_slug_from_value(connector)
383
+ if connector_slug == self.AGGREGATE_CONNECTOR_SLUG:
384
+ return reverse("charger-page", args=[serial])
385
+ return reverse("charger-page-connector", args=[serial, connector_slug])
386
+
387
+ def _fallback_domain(self) -> str:
388
+ """Return a best-effort hostname when the Sites framework is unset."""
389
+
390
+ fallback = getattr(settings, "DEFAULT_SITE_DOMAIN", "") or getattr(
391
+ settings, "DEFAULT_DOMAIN", ""
392
+ )
393
+ if fallback:
394
+ return fallback.strip()
395
+
396
+ for host in getattr(settings, "ALLOWED_HOSTS", []):
397
+ if not isinstance(host, str):
398
+ continue
399
+ host = host.strip()
400
+ if not host or host.startswith("*") or "/" in host:
401
+ continue
402
+ return host
403
+
404
+ return socket.gethostname() or "localhost"
405
+
406
+ def _full_url(self) -> str:
407
+ """Return absolute URL for the charger landing page."""
408
+
409
+ try:
410
+ domain = Site.objects.get_current().domain.strip()
411
+ except Site.DoesNotExist:
412
+ domain = ""
413
+
414
+ if not domain:
415
+ domain = self._fallback_domain()
416
+
417
+ scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
418
+ return f"{scheme}://{domain}{self.get_absolute_url()}"
419
+
420
+ def clean(self):
421
+ super().clean()
422
+ self.charger_id = type(self).validate_serial(self.charger_id)
423
+
424
+ def save(self, *args, **kwargs):
425
+ self.charger_id = type(self).validate_serial(self.charger_id)
426
+ update_fields = kwargs.get("update_fields")
427
+ update_list = list(update_fields) if update_fields is not None else None
428
+ if not self.manager_node_id:
429
+ local_node = Node.get_local()
430
+ if local_node:
431
+ self.manager_node = local_node
432
+ if update_list is not None and "manager_node" not in update_list:
433
+ update_list.append("manager_node")
434
+ if not self.location_id:
435
+ existing = (
436
+ type(self)
437
+ .objects.filter(charger_id=self.charger_id, location__isnull=False)
438
+ .exclude(pk=self.pk)
439
+ .select_related("location")
440
+ .first()
441
+ )
442
+ if existing:
443
+ self.location = existing.location
444
+ else:
445
+ location, _ = Location.objects.get_or_create(name=self.charger_id)
446
+ self.location = location
447
+ if update_list is not None and "location" not in update_list:
448
+ update_list.append("location")
449
+ if update_list is not None:
450
+ kwargs["update_fields"] = update_list
451
+ super().save(*args, **kwargs)
452
+ ref_value = self._full_url()
453
+ if url_targets_local_loopback(ref_value):
454
+ return
455
+ if not self.reference or self.reference.value != ref_value:
456
+ self.reference = Reference.objects.create(
457
+ value=ref_value, alt_text=self.charger_id
458
+ )
459
+ super().save(update_fields=["reference"])
460
+
461
+ def refresh_manager_node(self, node: Node | None = None) -> Node | None:
462
+ """Ensure ``manager_node`` matches the provided or local node."""
463
+
464
+ node = node or Node.get_local()
465
+ if not node:
466
+ return None
467
+ if self.pk is None:
468
+ self.manager_node = node
469
+ return node
470
+ if self.manager_node_id != node.pk:
471
+ type(self).objects.filter(pk=self.pk).update(manager_node=node)
472
+ self.manager_node = node
473
+ return node
474
+
475
+ @property
476
+ def name(self) -> str:
477
+ if self.location:
478
+ if self.connector_id is not None:
479
+ return f"{self.location.name} #{self.connector_id}"
480
+ return self.location.name
481
+ return ""
482
+
483
+ @property
484
+ def latitude(self):
485
+ return self.location.latitude if self.location else None
486
+
487
+ @property
488
+ def longitude(self):
489
+ return self.location.longitude if self.location else None
490
+
491
+ @property
492
+ def total_kw(self) -> float:
493
+ """Return total energy delivered by this charger in kW."""
494
+ from . import store
495
+
496
+ total = 0.0
497
+ for charger in self._target_chargers():
498
+ total += charger._total_kw_single(store)
499
+ return total
500
+
501
+ def _store_keys(self) -> list[str]:
502
+ """Return keys used for store lookups with fallbacks."""
503
+
504
+ from . import store
505
+
506
+ base = self.charger_id
507
+ connector = self.connector_id
508
+ keys: list[str] = []
509
+ keys.append(store.identity_key(base, connector))
510
+ if connector is not None:
511
+ keys.append(store.identity_key(base, None))
512
+ keys.append(store.pending_key(base))
513
+ keys.append(base)
514
+ seen: set[str] = set()
515
+ deduped: list[str] = []
516
+ for key in keys:
517
+ if key not in seen:
518
+ seen.add(key)
519
+ deduped.append(key)
520
+ return deduped
521
+
522
+ def _target_chargers(self):
523
+ """Return chargers contributing to aggregate operations."""
524
+
525
+ qs = type(self).objects.filter(charger_id=self.charger_id)
526
+ if self.connector_id is None:
527
+ return qs
528
+ return qs.filter(pk=self.pk)
529
+
530
+ def _total_kw_single(self, store_module) -> float:
531
+ """Return total kW for this specific charger identity."""
532
+
533
+ tx_active = None
534
+ if self.connector_id is not None:
535
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
536
+ qs = self.transactions.all()
537
+ if tx_active and tx_active.pk is not None:
538
+ qs = qs.exclude(pk=tx_active.pk)
539
+ total = 0.0
540
+ for tx in qs:
541
+ kw = tx.kw
542
+ if kw:
543
+ total += kw
544
+ if tx_active:
545
+ kw = tx_active.kw
546
+ if kw:
547
+ total += kw
548
+ return total
549
+
550
+ def purge(self):
551
+ from . import store
552
+
553
+ for charger in self._target_chargers():
554
+ charger.transactions.all().delete()
555
+ charger.meter_values.all().delete()
556
+ for key in charger._store_keys():
557
+ store.clear_log(key, log_type="charger")
558
+ store.transactions.pop(key, None)
559
+ store.history.pop(key, None)
560
+
561
+ def delete(self, *args, **kwargs):
562
+ from django.db.models.deletion import ProtectedError
563
+ from . import store
564
+
565
+ for charger in self._target_chargers():
566
+ has_data = (
567
+ charger.transactions.exists()
568
+ or charger.meter_values.exists()
569
+ or any(
570
+ store.get_logs(key, log_type="charger")
571
+ for key in charger._store_keys()
572
+ )
573
+ or any(store.transactions.get(key) for key in charger._store_keys())
574
+ or any(store.history.get(key) for key in charger._store_keys())
575
+ )
576
+ if has_data:
577
+ raise ProtectedError("Purge data before deleting charger.", [])
578
+ super().delete(*args, **kwargs)
579
+
580
+
581
+ class Transaction(Entity):
582
+ """Charging session data stored for each charger."""
583
+
584
+ charger = models.ForeignKey(
585
+ Charger, on_delete=models.CASCADE, related_name="transactions", null=True
586
+ )
587
+ account = models.ForeignKey(
588
+ EnergyAccount, on_delete=models.PROTECT, related_name="transactions", null=True
589
+ )
590
+ rfid = models.CharField(
591
+ max_length=20,
592
+ blank=True,
593
+ verbose_name=_("RFID"),
594
+ )
595
+ vin = models.CharField(max_length=17, blank=True)
596
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
597
+ meter_start = models.IntegerField(null=True, blank=True)
598
+ meter_stop = models.IntegerField(null=True, blank=True)
599
+ voltage_start = models.DecimalField(
600
+ max_digits=12, decimal_places=3, null=True, blank=True
601
+ )
602
+ voltage_stop = models.DecimalField(
603
+ max_digits=12, decimal_places=3, null=True, blank=True
604
+ )
605
+ current_import_start = models.DecimalField(
606
+ max_digits=12, decimal_places=3, null=True, blank=True
607
+ )
608
+ current_import_stop = models.DecimalField(
609
+ max_digits=12, decimal_places=3, null=True, blank=True
610
+ )
611
+ current_offered_start = models.DecimalField(
612
+ max_digits=12, decimal_places=3, null=True, blank=True
613
+ )
614
+ current_offered_stop = models.DecimalField(
615
+ max_digits=12, decimal_places=3, null=True, blank=True
616
+ )
617
+ temperature_start = models.DecimalField(
618
+ max_digits=12, decimal_places=3, null=True, blank=True
619
+ )
620
+ temperature_stop = models.DecimalField(
621
+ max_digits=12, decimal_places=3, null=True, blank=True
622
+ )
623
+ soc_start = models.DecimalField(
624
+ max_digits=12, decimal_places=3, null=True, blank=True
625
+ )
626
+ soc_stop = models.DecimalField(
627
+ max_digits=12, decimal_places=3, null=True, blank=True
628
+ )
629
+ start_time = models.DateTimeField()
630
+ stop_time = models.DateTimeField(null=True, blank=True)
631
+ received_start_time = models.DateTimeField(null=True, blank=True)
632
+ received_stop_time = models.DateTimeField(null=True, blank=True)
633
+
634
+ def __str__(self) -> str: # pragma: no cover - simple representation
635
+ return f"{self.charger}:{self.pk}"
636
+
637
+ class Meta:
638
+ verbose_name = _("Transaction")
639
+ verbose_name_plural = _("CP Transactions")
640
+
641
+ @property
642
+ def kw(self) -> float:
643
+ """Return consumed energy in kW for this session."""
644
+ start_val = None
645
+ if self.meter_start is not None:
646
+ start_val = float(self.meter_start) / 1000.0
647
+
648
+ end_val = None
649
+ if self.meter_stop is not None:
650
+ end_val = float(self.meter_stop) / 1000.0
651
+
652
+ readings = list(
653
+ self.meter_values.filter(energy__isnull=False).order_by("timestamp")
654
+ )
655
+ if readings:
656
+ if start_val is None:
657
+ start_val = float(readings[0].energy or 0)
658
+ # Always use the latest available reading for the end value when a
659
+ # stop meter has not been recorded yet. This allows active
660
+ # transactions to report totals using their most recent reading.
661
+ if end_val is None:
662
+ end_val = float(readings[-1].energy or 0)
663
+
664
+ if start_val is None or end_val is None:
665
+ return 0.0
666
+
667
+ total = end_val - start_val
668
+ return max(total, 0.0)
669
+
670
+
671
+ class MeterValue(Entity):
672
+ """Parsed meter values reported by chargers."""
673
+
674
+ charger = models.ForeignKey(
675
+ Charger, on_delete=models.CASCADE, related_name="meter_values"
676
+ )
677
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
678
+ transaction = models.ForeignKey(
679
+ Transaction,
680
+ on_delete=models.CASCADE,
681
+ related_name="meter_values",
682
+ null=True,
683
+ blank=True,
684
+ )
685
+ timestamp = models.DateTimeField()
686
+ context = models.CharField(max_length=32, blank=True)
687
+ energy = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
688
+ voltage = models.DecimalField(
689
+ max_digits=12, decimal_places=3, null=True, blank=True
690
+ )
691
+ current_import = models.DecimalField(
692
+ max_digits=12, decimal_places=3, null=True, blank=True
693
+ )
694
+ current_offered = models.DecimalField(
695
+ max_digits=12, decimal_places=3, null=True, blank=True
696
+ )
697
+ temperature = models.DecimalField(
698
+ max_digits=12, decimal_places=3, null=True, blank=True
699
+ )
700
+ soc = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
701
+
702
+ def __str__(self) -> str: # pragma: no cover - simple representation
703
+ return f"{self.charger} {self.timestamp}"
704
+
705
+ @property
706
+ def value(self):
707
+ return self.energy
708
+
709
+ @value.setter
710
+ def value(self, new_value):
711
+ self.energy = new_value
712
+
713
+ class Meta:
714
+ verbose_name = _("Meter Value")
715
+ verbose_name_plural = _("Meter Values")
716
+
717
+
718
+ class MeterReadingManager(EntityManager):
719
+ def _normalize_kwargs(self, kwargs: dict) -> dict:
720
+ normalized = dict(kwargs)
721
+ value = normalized.pop("value", None)
722
+ unit = normalized.pop("unit", None)
723
+ if value is not None:
724
+ energy = value
725
+ try:
726
+ energy = Decimal(value)
727
+ except (InvalidOperation, TypeError, ValueError):
728
+ energy = None
729
+ if energy is not None:
730
+ unit_normalized = (unit or "").lower()
731
+ if unit_normalized in {"w", "wh"}:
732
+ energy = energy / Decimal("1000")
733
+ normalized.setdefault("energy", energy)
734
+ normalized.pop("measurand", None)
735
+ return normalized
736
+
737
+ def create(self, **kwargs):
738
+ return super().create(**self._normalize_kwargs(kwargs))
739
+
740
+ def get_or_create(self, defaults=None, **kwargs):
741
+ if defaults:
742
+ defaults = self._normalize_kwargs(defaults)
743
+ return super().get_or_create(
744
+ defaults=defaults, **self._normalize_kwargs(kwargs)
745
+ )
746
+
747
+
748
+ class MeterReading(MeterValue):
749
+ """Proxy model for backwards compatibility."""
750
+
751
+ objects = MeterReadingManager()
752
+
753
+ class Meta:
754
+ proxy = True
755
+ verbose_name = _("Meter Value")
756
+ verbose_name_plural = _("Meter Values")
757
+
758
+
759
+ class Simulator(Entity):
760
+ """Preconfigured simulator that can be started from the admin."""
761
+
762
+ name = models.CharField(max_length=100, unique=True)
763
+ cp_path = models.CharField(
764
+ _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
765
+ )
766
+ host = models.CharField(max_length=100, default="127.0.0.1")
767
+ ws_port = models.IntegerField(
768
+ _("WS Port"), default=8000, null=True, blank=True
769
+ )
770
+ rfid = models.CharField(
771
+ max_length=255,
772
+ default="FFFFFFFF",
773
+ verbose_name=_("RFID"),
774
+ )
775
+ vin = models.CharField(max_length=17, blank=True)
776
+ serial_number = models.CharField(_("Serial Number"), max_length=100, blank=True)
777
+ connector_id = models.IntegerField(_("Connector ID"), default=1)
778
+ duration = models.IntegerField(default=600)
779
+ interval = models.FloatField(default=5.0)
780
+ pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
781
+ kw_max = models.FloatField(default=60.0)
782
+ repeat = models.BooleanField(default=False)
783
+ username = models.CharField(max_length=100, blank=True)
784
+ password = models.CharField(max_length=100, blank=True)
785
+ door_open = models.BooleanField(
786
+ _("Door Open"),
787
+ default=False,
788
+ help_text=_("Send a DoorOpen error StatusNotification when enabled."),
789
+ )
790
+ configuration_keys = models.JSONField(
791
+ default=list,
792
+ blank=True,
793
+ help_text=_(
794
+ "List of configurationKey entries to return for GetConfiguration calls."
795
+ ),
796
+ )
797
+ configuration_unknown_keys = models.JSONField(
798
+ default=list,
799
+ blank=True,
800
+ help_text=_("Keys to include in the GetConfiguration unknownKey response."),
801
+ )
802
+
803
+ def __str__(self) -> str: # pragma: no cover - simple representation
804
+ return self.name
805
+
806
+ class Meta:
807
+ verbose_name = _("CP Simulator")
808
+ verbose_name_plural = _("CP Simulators")
809
+
810
+ def as_config(self):
811
+ from .simulator import SimulatorConfig
812
+
813
+ return SimulatorConfig(
814
+ host=self.host,
815
+ ws_port=self.ws_port,
816
+ rfid=self.rfid,
817
+ vin=self.vin,
818
+ cp_path=self.cp_path,
819
+ serial_number=self.serial_number,
820
+ connector_id=self.connector_id,
821
+ duration=self.duration,
822
+ interval=self.interval,
823
+ pre_charge_delay=self.pre_charge_delay,
824
+ kw_max=self.kw_max,
825
+ repeat=self.repeat,
826
+ username=self.username or None,
827
+ password=self.password or None,
828
+ configuration_keys=self.configuration_keys or [],
829
+ configuration_unknown_keys=self.configuration_unknown_keys or [],
830
+ )
831
+
832
+ @property
833
+ def ws_url(self) -> str: # pragma: no cover - simple helper
834
+ path = self.cp_path
835
+ if not path.endswith("/"):
836
+ path += "/"
837
+ if self.ws_port:
838
+ return f"ws://{self.host}:{self.ws_port}/{path}"
839
+ return f"ws://{self.host}/{path}"
840
+
841
+
842
+ class DataTransferMessage(models.Model):
843
+ """Persisted record of OCPP DataTransfer exchanges."""
844
+
845
+ DIRECTION_CP_TO_CSMS = "cp_to_csms"
846
+ DIRECTION_CSMS_TO_CP = "csms_to_cp"
847
+ DIRECTION_CHOICES = (
848
+ (DIRECTION_CP_TO_CSMS, _("Charge Point → CSMS")),
849
+ (DIRECTION_CSMS_TO_CP, _("CSMS → Charge Point")),
850
+ )
851
+
852
+ charger = models.ForeignKey(
853
+ "Charger",
854
+ on_delete=models.CASCADE,
855
+ related_name="data_transfer_messages",
856
+ )
857
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
858
+ direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
859
+ ocpp_message_id = models.CharField(max_length=64)
860
+ vendor_id = models.CharField(max_length=255, blank=True)
861
+ message_id = models.CharField(max_length=255, blank=True)
862
+ payload = models.JSONField(default=dict, blank=True)
863
+ status = models.CharField(max_length=64, blank=True)
864
+ response_data = models.JSONField(null=True, blank=True)
865
+ error_code = models.CharField(max_length=64, blank=True)
866
+ error_description = models.TextField(blank=True)
867
+ error_details = models.JSONField(null=True, blank=True)
868
+ responded_at = models.DateTimeField(null=True, blank=True)
869
+ created_at = models.DateTimeField(auto_now_add=True)
870
+ updated_at = models.DateTimeField(auto_now=True)
871
+
872
+ class Meta:
873
+ ordering = ["-created_at"]
874
+ indexes = [
875
+ models.Index(
876
+ fields=["ocpp_message_id"],
877
+ name="ocpp_datatr_ocpp_me_70d17f_idx",
878
+ ),
879
+ models.Index(
880
+ fields=["vendor_id"], name="ocpp_datatr_vendor__59e1c7_idx"
881
+ ),
882
+ ]
883
+
884
+ def __str__(self) -> str: # pragma: no cover - simple representation
885
+ return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
886
+
887
+
888
+ class RFID(CoreRFID):
889
+ class Meta:
890
+ proxy = True
891
+ app_label = "ocpp"
892
+ verbose_name = CoreRFID._meta.verbose_name
893
+ verbose_name_plural = CoreRFID._meta.verbose_name_plural
894
+
895
+
896
+ class ElectricVehicle(CoreElectricVehicle):
897
+ class Meta:
898
+ proxy = True
899
+ app_label = "ocpp"
900
+ verbose_name = _("Electric Vehicle")
901
+ verbose_name_plural = _("Electric Vehicles")
902
+
903
+
904
+ class Brand(CoreBrand):
905
+ class Meta:
906
+ proxy = True
907
+ app_label = "ocpp"
908
+ verbose_name = CoreBrand._meta.verbose_name
909
+ verbose_name_plural = CoreBrand._meta.verbose_name_plural
910
+
911
+
912
+ class EVModel(CoreEVModel):
913
+ class Meta:
914
+ proxy = True
915
+ app_label = "ocpp"
916
+ verbose_name = CoreEVModel._meta.verbose_name
917
+ verbose_name_plural = CoreEVModel._meta.verbose_name_plural