arthexis 0.1.9__py3-none-any.whl → 0.1.26__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -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 +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/models.py CHANGED
@@ -1,640 +1,1417 @@
1
- import socket
2
- from decimal import Decimal, InvalidOperation
3
-
4
- from django.conf import settings
5
- from django.contrib.sites.models import Site
6
- from django.db import models
7
- from django.urls import reverse
8
- from django.utils.translation import gettext_lazy as _
9
-
10
- from core.entity import Entity, EntityManager
11
-
12
- from core.models import (
13
- EnergyAccount,
14
- Reference,
15
- RFID as CoreRFID,
16
- ElectricVehicle as CoreElectricVehicle,
17
- Brand as CoreBrand,
18
- EVModel as CoreEVModel,
19
- )
20
-
21
-
22
- class Location(Entity):
23
- """Physical location shared by chargers."""
24
-
25
- name = models.CharField(max_length=200)
26
- latitude = models.DecimalField(
27
- max_digits=9, decimal_places=6, null=True, blank=True
28
- )
29
- longitude = models.DecimalField(
30
- max_digits=9, decimal_places=6, null=True, blank=True
31
- )
32
-
33
- def __str__(self) -> str: # pragma: no cover - simple representation
34
- return self.name
35
-
36
- class Meta:
37
- verbose_name = _("Charge Location")
38
- verbose_name_plural = _("Charge Locations")
39
-
40
-
41
- class Charger(Entity):
42
- """Known charge point."""
43
-
44
- charger_id = models.CharField(
45
- _("Serial Number"),
46
- max_length=100,
47
- help_text="Unique identifier reported by the charger.",
48
- )
49
- display_name = models.CharField(
50
- _("Display Name"),
51
- max_length=200,
52
- blank=True,
53
- help_text="Optional friendly name shown on public pages.",
54
- )
55
- connector_id = models.PositiveIntegerField(
56
- _("Connector ID"),
57
- blank=True,
58
- null=True,
59
- help_text="Optional connector identifier for multi-connector chargers.",
60
- )
61
- require_rfid = models.BooleanField(
62
- _("Require RFID Authorization"),
63
- default=False,
64
- help_text="Require a valid RFID before starting a charging session.",
65
- )
66
- firmware_status = models.CharField(
67
- _("Firmware Status"),
68
- max_length=32,
69
- blank=True,
70
- default="",
71
- help_text="Latest firmware status reported by the charger.",
72
- )
73
- firmware_status_info = models.CharField(
74
- _("Firmware Status Details"),
75
- max_length=255,
76
- blank=True,
77
- default="",
78
- help_text="Additional information supplied with the firmware status.",
79
- )
80
- firmware_timestamp = models.DateTimeField(
81
- _("Firmware Status Timestamp"),
82
- null=True,
83
- blank=True,
84
- help_text="When the charger reported the current firmware status.",
85
- )
86
- last_heartbeat = models.DateTimeField(null=True, blank=True)
87
- last_meter_values = models.JSONField(default=dict, blank=True)
88
- last_status = models.CharField(max_length=64, blank=True)
89
- last_error_code = models.CharField(max_length=64, blank=True)
90
- last_status_vendor_info = models.JSONField(null=True, blank=True)
91
- last_status_timestamp = models.DateTimeField(null=True, blank=True)
92
- temperature = models.DecimalField(
93
- max_digits=5, decimal_places=1, null=True, blank=True
94
- )
95
- temperature_unit = models.CharField(max_length=16, blank=True)
96
- diagnostics_status = models.CharField(
97
- max_length=32,
98
- null=True,
99
- blank=True,
100
- help_text="Most recent diagnostics status reported by the charger.",
101
- )
102
- diagnostics_timestamp = models.DateTimeField(
103
- null=True,
104
- blank=True,
105
- help_text="Timestamp associated with the latest diagnostics status.",
106
- )
107
- diagnostics_location = models.CharField(
108
- max_length=255,
109
- null=True,
110
- blank=True,
111
- help_text="Location or URI reported for the latest diagnostics upload.",
112
- )
113
- reference = models.OneToOneField(
114
- Reference, null=True, blank=True, on_delete=models.SET_NULL
115
- )
116
- location = models.ForeignKey(
117
- Location,
118
- null=True,
119
- blank=True,
120
- on_delete=models.SET_NULL,
121
- related_name="chargers",
122
- )
123
- last_path = models.CharField(max_length=255, blank=True)
124
-
125
- def __str__(self) -> str: # pragma: no cover - simple representation
126
- return self.charger_id
127
-
128
- class Meta:
129
- verbose_name = _("Charge Point")
130
- verbose_name_plural = _("Charge Points")
131
- constraints = [
132
- models.UniqueConstraint(
133
- fields=("charger_id", "connector_id"),
134
- condition=models.Q(connector_id__isnull=False),
135
- name="charger_connector_unique",
136
- ),
137
- models.UniqueConstraint(
138
- fields=("charger_id",),
139
- condition=models.Q(connector_id__isnull=True),
140
- name="charger_unique_without_connector",
141
- ),
142
- ]
143
-
144
- AGGREGATE_CONNECTOR_SLUG = "all"
145
-
146
- def identity_tuple(self) -> tuple[str, int | None]:
147
- """Return the canonical identity for this charger."""
148
-
149
- return (
150
- self.charger_id,
151
- self.connector_id if self.connector_id is not None else None,
152
- )
153
-
154
- @classmethod
155
- def connector_slug_from_value(cls, connector: int | None) -> str:
156
- """Return the slug used in URLs for the given connector."""
157
-
158
- return cls.AGGREGATE_CONNECTOR_SLUG if connector is None else str(connector)
159
-
160
- @classmethod
161
- def connector_value_from_slug(cls, slug: int | str | None) -> int | None:
162
- """Return the connector integer represented by ``slug``."""
163
-
164
- if slug in (None, "", cls.AGGREGATE_CONNECTOR_SLUG):
165
- return None
166
- if isinstance(slug, int):
167
- return slug
168
- try:
169
- return int(str(slug))
170
- except (TypeError, ValueError) as exc:
171
- raise ValueError(f"Invalid connector slug: {slug}") from exc
172
-
173
- @property
174
- def connector_slug(self) -> str:
175
- """Return the slug representing this charger's connector."""
176
-
177
- return type(self).connector_slug_from_value(self.connector_id)
178
-
179
- @property
180
- def connector_label(self) -> str:
181
- """Return a short human readable label for this connector."""
182
-
183
- if self.connector_id is None:
184
- return _("All Connectors")
185
-
186
- special_labels = {
187
- 1: _("Connector 1 (Left)"),
188
- 2: _("Connector 2 (Right)"),
189
- }
190
- if self.connector_id in special_labels:
191
- return special_labels[self.connector_id]
192
-
193
- return _("Connector %(number)s") % {"number": self.connector_id}
194
-
195
- def identity_slug(self) -> str:
196
- """Return a unique slug for this charger identity."""
197
-
198
- serial, connector = self.identity_tuple()
199
- return f"{serial}#{type(self).connector_slug_from_value(connector)}"
200
-
201
- def get_absolute_url(self):
202
- serial, connector = self.identity_tuple()
203
- connector_slug = type(self).connector_slug_from_value(connector)
204
- if connector_slug == self.AGGREGATE_CONNECTOR_SLUG:
205
- return reverse("charger-page", args=[serial])
206
- return reverse("charger-page-connector", args=[serial, connector_slug])
207
-
208
- def _fallback_domain(self) -> str:
209
- """Return a best-effort hostname when the Sites framework is unset."""
210
-
211
- fallback = getattr(settings, "DEFAULT_SITE_DOMAIN", "") or getattr(
212
- settings, "DEFAULT_DOMAIN", ""
213
- )
214
- if fallback:
215
- return fallback.strip()
216
-
217
- for host in getattr(settings, "ALLOWED_HOSTS", []):
218
- if not isinstance(host, str):
219
- continue
220
- host = host.strip()
221
- if not host or host.startswith("*") or "/" in host:
222
- continue
223
- return host
224
-
225
- return socket.gethostname() or "localhost"
226
-
227
- def _full_url(self) -> str:
228
- """Return absolute URL for the charger landing page."""
229
-
230
- try:
231
- domain = Site.objects.get_current().domain.strip()
232
- except Site.DoesNotExist:
233
- domain = ""
234
-
235
- if not domain:
236
- domain = self._fallback_domain()
237
-
238
- scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
239
- return f"{scheme}://{domain}{self.get_absolute_url()}"
240
-
241
- def save(self, *args, **kwargs):
242
- update_fields = kwargs.get("update_fields")
243
- if not self.location_id:
244
- existing = (
245
- type(self)
246
- .objects.filter(charger_id=self.charger_id, location__isnull=False)
247
- .exclude(pk=self.pk)
248
- .select_related("location")
249
- .first()
250
- )
251
- if existing:
252
- self.location = existing.location
253
- else:
254
- location, _ = Location.objects.get_or_create(name=self.charger_id)
255
- self.location = location
256
- if update_fields is not None:
257
- update_list = list(update_fields)
258
- if "location" not in update_list:
259
- update_list.append("location")
260
- kwargs["update_fields"] = update_list
261
- super().save(*args, **kwargs)
262
- ref_value = self._full_url()
263
- if not self.reference or self.reference.value != ref_value:
264
- self.reference = Reference.objects.create(
265
- value=ref_value, alt_text=self.charger_id
266
- )
267
- super().save(update_fields=["reference"])
268
-
269
- @property
270
- def name(self) -> str:
271
- if self.location:
272
- if self.connector_id is not None:
273
- return f"{self.location.name} #{self.connector_id}"
274
- return self.location.name
275
- return ""
276
-
277
- @property
278
- def latitude(self):
279
- return self.location.latitude if self.location else None
280
-
281
- @property
282
- def longitude(self):
283
- return self.location.longitude if self.location else None
284
-
285
- @property
286
- def total_kw(self) -> float:
287
- """Return total energy delivered by this charger in kW."""
288
- from . import store
289
-
290
- total = 0.0
291
- for charger in self._target_chargers():
292
- total += charger._total_kw_single(store)
293
- return total
294
-
295
- def _store_keys(self) -> list[str]:
296
- """Return keys used for store lookups with fallbacks."""
297
-
298
- from . import store
299
-
300
- base = self.charger_id
301
- connector = self.connector_id
302
- keys: list[str] = []
303
- keys.append(store.identity_key(base, connector))
304
- if connector is not None:
305
- keys.append(store.identity_key(base, None))
306
- keys.append(store.pending_key(base))
307
- keys.append(base)
308
- seen: set[str] = set()
309
- deduped: list[str] = []
310
- for key in keys:
311
- if key not in seen:
312
- seen.add(key)
313
- deduped.append(key)
314
- return deduped
315
-
316
- def _target_chargers(self):
317
- """Return chargers contributing to aggregate operations."""
318
-
319
- qs = type(self).objects.filter(charger_id=self.charger_id)
320
- if self.connector_id is None:
321
- return qs
322
- return qs.filter(pk=self.pk)
323
-
324
- def _total_kw_single(self, store_module) -> float:
325
- """Return total kW for this specific charger identity."""
326
-
327
- tx_active = None
328
- if self.connector_id is not None:
329
- tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
330
- qs = self.transactions.all()
331
- if tx_active and tx_active.pk is not None:
332
- qs = qs.exclude(pk=tx_active.pk)
333
- total = 0.0
334
- for tx in qs:
335
- kw = tx.kw
336
- if kw:
337
- total += kw
338
- if tx_active:
339
- kw = tx_active.kw
340
- if kw:
341
- total += kw
342
- return total
343
-
344
- def purge(self):
345
- from . import store
346
-
347
- for charger in self._target_chargers():
348
- charger.transactions.all().delete()
349
- charger.meter_values.all().delete()
350
- for key in charger._store_keys():
351
- store.clear_log(key, log_type="charger")
352
- store.transactions.pop(key, None)
353
- store.history.pop(key, None)
354
-
355
- def delete(self, *args, **kwargs):
356
- from django.db.models.deletion import ProtectedError
357
- from . import store
358
-
359
- for charger in self._target_chargers():
360
- has_data = (
361
- charger.transactions.exists()
362
- or charger.meter_values.exists()
363
- or any(
364
- store.get_logs(key, log_type="charger")
365
- for key in charger._store_keys()
366
- )
367
- or any(store.transactions.get(key) for key in charger._store_keys())
368
- or any(store.history.get(key) for key in charger._store_keys())
369
- )
370
- if has_data:
371
- raise ProtectedError("Purge data before deleting charger.", [])
372
- super().delete(*args, **kwargs)
373
-
374
-
375
- class Transaction(Entity):
376
- """Charging session data stored for each charger."""
377
-
378
- charger = models.ForeignKey(
379
- Charger, on_delete=models.CASCADE, related_name="transactions", null=True
380
- )
381
- account = models.ForeignKey(
382
- EnergyAccount, on_delete=models.PROTECT, related_name="transactions", null=True
383
- )
384
- rfid = models.CharField(
385
- max_length=20,
386
- blank=True,
387
- verbose_name=_("RFID"),
388
- )
389
- vin = models.CharField(max_length=17, blank=True)
390
- connector_id = models.PositiveIntegerField(null=True, blank=True)
391
- meter_start = models.IntegerField(null=True, blank=True)
392
- meter_stop = models.IntegerField(null=True, blank=True)
393
- voltage_start = models.DecimalField(
394
- max_digits=12, decimal_places=3, null=True, blank=True
395
- )
396
- voltage_stop = models.DecimalField(
397
- max_digits=12, decimal_places=3, null=True, blank=True
398
- )
399
- current_import_start = models.DecimalField(
400
- max_digits=12, decimal_places=3, null=True, blank=True
401
- )
402
- current_import_stop = models.DecimalField(
403
- max_digits=12, decimal_places=3, null=True, blank=True
404
- )
405
- current_offered_start = models.DecimalField(
406
- max_digits=12, decimal_places=3, null=True, blank=True
407
- )
408
- current_offered_stop = models.DecimalField(
409
- max_digits=12, decimal_places=3, null=True, blank=True
410
- )
411
- temperature_start = models.DecimalField(
412
- max_digits=12, decimal_places=3, null=True, blank=True
413
- )
414
- temperature_stop = models.DecimalField(
415
- max_digits=12, decimal_places=3, null=True, blank=True
416
- )
417
- soc_start = models.DecimalField(
418
- max_digits=12, decimal_places=3, null=True, blank=True
419
- )
420
- soc_stop = models.DecimalField(
421
- max_digits=12, decimal_places=3, null=True, blank=True
422
- )
423
- start_time = models.DateTimeField()
424
- stop_time = models.DateTimeField(null=True, blank=True)
425
-
426
- def __str__(self) -> str: # pragma: no cover - simple representation
427
- return f"{self.charger}:{self.pk}"
428
-
429
- class Meta:
430
- verbose_name = _("Transaction")
431
- verbose_name_plural = _("CP Transactions")
432
-
433
- @property
434
- def kw(self) -> float:
435
- """Return consumed energy in kW for this session."""
436
- start_val = None
437
- if self.meter_start is not None:
438
- start_val = float(self.meter_start) / 1000.0
439
-
440
- end_val = None
441
- if self.meter_stop is not None:
442
- end_val = float(self.meter_stop) / 1000.0
443
-
444
- readings = list(
445
- self.meter_values.filter(energy__isnull=False).order_by("timestamp")
446
- )
447
- if readings:
448
- if start_val is None:
449
- start_val = float(readings[0].energy or 0)
450
- # Always use the latest available reading for the end value when a
451
- # stop meter has not been recorded yet. This allows active
452
- # transactions to report totals using their most recent reading.
453
- if end_val is None:
454
- end_val = float(readings[-1].energy or 0)
455
-
456
- if start_val is None or end_val is None:
457
- return 0.0
458
-
459
- total = end_val - start_val
460
- return max(total, 0.0)
461
-
462
-
463
- class MeterValue(Entity):
464
- """Parsed meter values reported by chargers."""
465
-
466
- charger = models.ForeignKey(
467
- Charger, on_delete=models.CASCADE, related_name="meter_values"
468
- )
469
- connector_id = models.PositiveIntegerField(null=True, blank=True)
470
- transaction = models.ForeignKey(
471
- Transaction,
472
- on_delete=models.CASCADE,
473
- related_name="meter_values",
474
- null=True,
475
- blank=True,
476
- )
477
- timestamp = models.DateTimeField()
478
- context = models.CharField(max_length=32, blank=True)
479
- energy = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
480
- voltage = models.DecimalField(
481
- max_digits=12, decimal_places=3, null=True, blank=True
482
- )
483
- current_import = models.DecimalField(
484
- max_digits=12, decimal_places=3, null=True, blank=True
485
- )
486
- current_offered = models.DecimalField(
487
- max_digits=12, decimal_places=3, null=True, blank=True
488
- )
489
- temperature = models.DecimalField(
490
- max_digits=12, decimal_places=3, null=True, blank=True
491
- )
492
- soc = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
493
-
494
- def __str__(self) -> str: # pragma: no cover - simple representation
495
- return f"{self.charger} {self.timestamp}"
496
-
497
- @property
498
- def value(self):
499
- return self.energy
500
-
501
- @value.setter
502
- def value(self, new_value):
503
- self.energy = new_value
504
-
505
- class Meta:
506
- verbose_name = _("Meter Value")
507
- verbose_name_plural = _("Meter Values")
508
-
509
-
510
- class MeterReadingManager(EntityManager):
511
- def _normalize_kwargs(self, kwargs: dict) -> dict:
512
- normalized = dict(kwargs)
513
- value = normalized.pop("value", None)
514
- unit = normalized.pop("unit", None)
515
- if value is not None:
516
- energy = value
517
- try:
518
- energy = Decimal(value)
519
- except (InvalidOperation, TypeError, ValueError):
520
- energy = None
521
- if energy is not None:
522
- unit_normalized = (unit or "").lower()
523
- if unit_normalized in {"w", "wh"}:
524
- energy = energy / Decimal("1000")
525
- normalized.setdefault("energy", energy)
526
- normalized.pop("measurand", None)
527
- return normalized
528
-
529
- def create(self, **kwargs):
530
- return super().create(**self._normalize_kwargs(kwargs))
531
-
532
- def get_or_create(self, defaults=None, **kwargs):
533
- if defaults:
534
- defaults = self._normalize_kwargs(defaults)
535
- return super().get_or_create(
536
- defaults=defaults, **self._normalize_kwargs(kwargs)
537
- )
538
-
539
-
540
- class MeterReading(MeterValue):
541
- """Proxy model for backwards compatibility."""
542
-
543
- objects = MeterReadingManager()
544
-
545
- class Meta:
546
- proxy = True
547
- verbose_name = _("Meter Value")
548
- verbose_name_plural = _("Meter Values")
549
-
550
-
551
- class Simulator(Entity):
552
- """Preconfigured simulator that can be started from the admin."""
553
-
554
- name = models.CharField(max_length=100, unique=True)
555
- cp_path = models.CharField(
556
- _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
557
- )
558
- host = models.CharField(max_length=100, default="127.0.0.1")
559
- ws_port = models.IntegerField(_("WS Port"), default=8000)
560
- rfid = models.CharField(
561
- max_length=255,
562
- default="FFFFFFFF",
563
- verbose_name=_("RFID"),
564
- )
565
- vin = models.CharField(max_length=17, blank=True)
566
- serial_number = models.CharField(_("Serial Number"), max_length=100, blank=True)
567
- connector_id = models.IntegerField(_("Connector ID"), default=1)
568
- duration = models.IntegerField(default=600)
569
- interval = models.FloatField(default=5.0)
570
- pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
571
- kw_max = models.FloatField(default=60.0)
572
- repeat = models.BooleanField(default=False)
573
- username = models.CharField(max_length=100, blank=True)
574
- password = models.CharField(max_length=100, blank=True)
575
-
576
- def __str__(self) -> str: # pragma: no cover - simple representation
577
- return self.name
578
-
579
- class Meta:
580
- verbose_name = _("CP Simulator")
581
- verbose_name_plural = _("CP Simulators")
582
-
583
- def as_config(self):
584
- from .simulator import SimulatorConfig
585
-
586
- return SimulatorConfig(
587
- host=self.host,
588
- ws_port=self.ws_port,
589
- rfid=self.rfid,
590
- vin=self.vin,
591
- cp_path=self.cp_path,
592
- serial_number=self.serial_number,
593
- connector_id=self.connector_id,
594
- duration=self.duration,
595
- interval=self.interval,
596
- pre_charge_delay=self.pre_charge_delay,
597
- kw_max=self.kw_max,
598
- repeat=self.repeat,
599
- username=self.username or None,
600
- password=self.password or None,
601
- )
602
-
603
- @property
604
- def ws_url(self) -> str: # pragma: no cover - simple helper
605
- path = self.cp_path
606
- if not path.endswith("/"):
607
- path += "/"
608
- return f"ws://{self.host}:{self.ws_port}/{path}"
609
-
610
-
611
- class RFID(CoreRFID):
612
- class Meta:
613
- proxy = True
614
- app_label = "ocpp"
615
- verbose_name = CoreRFID._meta.verbose_name
616
- verbose_name_plural = CoreRFID._meta.verbose_name_plural
617
-
618
-
619
- class ElectricVehicle(CoreElectricVehicle):
620
- class Meta:
621
- proxy = True
622
- app_label = "ocpp"
623
- verbose_name = _("Electric Vehicle")
624
- verbose_name_plural = _("Electric Vehicles")
625
-
626
-
627
- class Brand(CoreBrand):
628
- class Meta:
629
- proxy = True
630
- app_label = "ocpp"
631
- verbose_name = CoreBrand._meta.verbose_name
632
- verbose_name_plural = CoreBrand._meta.verbose_name_plural
633
-
634
-
635
- class EVModel(CoreEVModel):
636
- class Meta:
637
- proxy = True
638
- app_label = "ocpp"
639
- verbose_name = CoreEVModel._meta.verbose_name
640
- verbose_name_plural = CoreEVModel._meta.verbose_name_plural
1
+ import json
2
+ import re
3
+ import socket
4
+ import uuid
5
+ from datetime import timedelta
6
+ from decimal import Decimal, InvalidOperation
7
+
8
+ from django.conf import settings
9
+ from django.contrib.sites.models import Site
10
+ from django.db import models
11
+ from django.db.models import Q
12
+ from django.core.exceptions import ValidationError
13
+ from django.urls import reverse
14
+ from django.utils.translation import gettext_lazy as _
15
+ from django.utils import timezone
16
+
17
+ from asgiref.sync import async_to_sync
18
+
19
+ from core.entity import Entity, EntityManager
20
+ from nodes.models import Node
21
+
22
+ from core.models import (
23
+ EnergyAccount,
24
+ EnergyTariff,
25
+ Reference,
26
+ RFID as CoreRFID,
27
+ ElectricVehicle as CoreElectricVehicle,
28
+ Brand as CoreBrand,
29
+ EVModel as CoreEVModel,
30
+ SecurityGroup,
31
+ )
32
+ from . import store
33
+ from .reference_utils import url_targets_local_loopback
34
+
35
+
36
+ class Location(Entity):
37
+ """Physical location shared by chargers."""
38
+
39
+ name = models.CharField(max_length=200)
40
+ latitude = models.DecimalField(
41
+ max_digits=9, decimal_places=6, null=True, blank=True
42
+ )
43
+ longitude = models.DecimalField(
44
+ max_digits=9, decimal_places=6, null=True, blank=True
45
+ )
46
+ zone = models.CharField(
47
+ max_length=3,
48
+ choices=EnergyTariff.Zone.choices,
49
+ blank=True,
50
+ null=True,
51
+ help_text=_("CFE climate zone used to select matching energy tariffs."),
52
+ )
53
+ contract_type = models.CharField(
54
+ max_length=16,
55
+ choices=EnergyTariff.ContractType.choices,
56
+ blank=True,
57
+ null=True,
58
+ help_text=_(
59
+ "CFE service contract type required to match energy tariff pricing."
60
+ ),
61
+ )
62
+
63
+ def __str__(self) -> str: # pragma: no cover - simple representation
64
+ return self.name
65
+
66
+ class Meta:
67
+ verbose_name = _("Charge Location")
68
+ verbose_name_plural = _("Charge Locations")
69
+
70
+
71
+ class Charger(Entity):
72
+ """Known charge point."""
73
+
74
+ _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
75
+ _AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
76
+
77
+ OPERATIVE_STATUSES = {
78
+ "Available",
79
+ "Preparing",
80
+ "Charging",
81
+ "SuspendedEV",
82
+ "SuspendedEVSE",
83
+ "Finishing",
84
+ "Reserved",
85
+ }
86
+ INOPERATIVE_STATUSES = {"Unavailable", "Faulted"}
87
+
88
+ charger_id = models.CharField(
89
+ _("Serial Number"),
90
+ max_length=100,
91
+ help_text="Unique identifier reported by the charger.",
92
+ )
93
+ display_name = models.CharField(
94
+ _("Display Name"),
95
+ max_length=200,
96
+ blank=True,
97
+ help_text="Optional friendly name shown on public pages.",
98
+ )
99
+ connector_id = models.PositiveIntegerField(
100
+ _("Connector ID"),
101
+ blank=True,
102
+ null=True,
103
+ help_text="Optional connector identifier for multi-connector chargers.",
104
+ )
105
+ public_display = models.BooleanField(
106
+ _("Public"),
107
+ default=True,
108
+ help_text="Display this charger on the public status dashboard.",
109
+ )
110
+ language = models.CharField(
111
+ _("Language"),
112
+ max_length=12,
113
+ choices=settings.LANGUAGES,
114
+ default="es",
115
+ help_text=_("Preferred language for the public landing page."),
116
+ )
117
+ require_rfid = models.BooleanField(
118
+ _("Require RFID Authorization"),
119
+ default=False,
120
+ help_text="Require a valid RFID before starting a charging session.",
121
+ )
122
+ firmware_status = models.CharField(
123
+ _("Status"),
124
+ max_length=32,
125
+ blank=True,
126
+ default="",
127
+ help_text="Latest firmware status reported by the charger.",
128
+ )
129
+ firmware_status_info = models.CharField(
130
+ _("Status Details"),
131
+ max_length=255,
132
+ blank=True,
133
+ default="",
134
+ help_text="Additional information supplied with the firmware status.",
135
+ )
136
+ firmware_timestamp = models.DateTimeField(
137
+ _("Status Timestamp"),
138
+ null=True,
139
+ blank=True,
140
+ help_text="When the charger reported the current firmware status.",
141
+ )
142
+ last_heartbeat = models.DateTimeField(null=True, blank=True)
143
+ last_meter_values = models.JSONField(default=dict, blank=True)
144
+ last_status = models.CharField(max_length=64, blank=True)
145
+ last_error_code = models.CharField(max_length=64, blank=True)
146
+ last_status_vendor_info = models.JSONField(null=True, blank=True)
147
+ last_status_timestamp = models.DateTimeField(null=True, blank=True)
148
+ availability_state = models.CharField(
149
+ _("State"),
150
+ max_length=16,
151
+ blank=True,
152
+ default="",
153
+ help_text=(
154
+ "Current availability reported by the charger "
155
+ "(Operative/Inoperative)."
156
+ ),
157
+ )
158
+ availability_state_updated_at = models.DateTimeField(
159
+ _("State Updated At"),
160
+ null=True,
161
+ blank=True,
162
+ help_text="When the current availability state became effective.",
163
+ )
164
+ availability_requested_state = models.CharField(
165
+ _("Requested State"),
166
+ max_length=16,
167
+ blank=True,
168
+ default="",
169
+ help_text="Last availability state requested via ChangeAvailability.",
170
+ )
171
+ availability_requested_at = models.DateTimeField(
172
+ _("Requested At"),
173
+ null=True,
174
+ blank=True,
175
+ help_text="When the last ChangeAvailability request was sent.",
176
+ )
177
+ availability_request_status = models.CharField(
178
+ _("Request Status"),
179
+ max_length=16,
180
+ blank=True,
181
+ default="",
182
+ help_text=(
183
+ "Latest response status for ChangeAvailability "
184
+ "(Accepted/Rejected/Scheduled)."
185
+ ),
186
+ )
187
+ availability_request_status_at = models.DateTimeField(
188
+ _("Request Status At"),
189
+ null=True,
190
+ blank=True,
191
+ help_text="When the last ChangeAvailability response was received.",
192
+ )
193
+ availability_request_details = models.CharField(
194
+ _("Request Details"),
195
+ max_length=255,
196
+ blank=True,
197
+ default="",
198
+ help_text="Additional details from the last ChangeAvailability response.",
199
+ )
200
+ temperature = models.DecimalField(
201
+ max_digits=5, decimal_places=1, null=True, blank=True
202
+ )
203
+ temperature_unit = models.CharField(max_length=16, blank=True)
204
+ diagnostics_status = models.CharField(
205
+ max_length=32,
206
+ null=True,
207
+ blank=True,
208
+ help_text="Most recent diagnostics status reported by the charger.",
209
+ )
210
+ diagnostics_timestamp = models.DateTimeField(
211
+ null=True,
212
+ blank=True,
213
+ help_text="Timestamp associated with the latest diagnostics status.",
214
+ )
215
+ diagnostics_location = models.CharField(
216
+ max_length=255,
217
+ null=True,
218
+ blank=True,
219
+ help_text="Location or URI reported for the latest diagnostics upload.",
220
+ )
221
+ reference = models.OneToOneField(
222
+ Reference, null=True, blank=True, on_delete=models.SET_NULL
223
+ )
224
+ location = models.ForeignKey(
225
+ Location,
226
+ null=True,
227
+ blank=True,
228
+ on_delete=models.SET_NULL,
229
+ related_name="chargers",
230
+ )
231
+ last_path = models.CharField(max_length=255, blank=True)
232
+ configuration = models.ForeignKey(
233
+ "ChargerConfiguration",
234
+ null=True,
235
+ blank=True,
236
+ on_delete=models.SET_NULL,
237
+ related_name="chargers",
238
+ help_text=_(
239
+ "Latest GetConfiguration response received from this charge point."
240
+ ),
241
+ )
242
+ node_origin = models.ForeignKey(
243
+ "nodes.Node",
244
+ on_delete=models.SET_NULL,
245
+ null=True,
246
+ blank=True,
247
+ related_name="origin_chargers",
248
+ )
249
+ manager_node = models.ForeignKey(
250
+ "nodes.Node",
251
+ on_delete=models.SET_NULL,
252
+ null=True,
253
+ blank=True,
254
+ related_name="managed_chargers",
255
+ )
256
+ forwarded_to = models.ForeignKey(
257
+ "nodes.Node",
258
+ on_delete=models.SET_NULL,
259
+ null=True,
260
+ blank=True,
261
+ related_name="forwarded_chargers",
262
+ help_text=_("Remote node receiving forwarded transactions."),
263
+ )
264
+ forwarding_watermark = models.DateTimeField(
265
+ null=True,
266
+ blank=True,
267
+ help_text=_("Timestamp of the last forwarded transaction."),
268
+ )
269
+ allow_remote = models.BooleanField(default=False)
270
+ export_transactions = models.BooleanField(default=False)
271
+ last_online_at = models.DateTimeField(null=True, blank=True)
272
+ owner_users = models.ManyToManyField(
273
+ settings.AUTH_USER_MODEL,
274
+ blank=True,
275
+ related_name="owned_chargers",
276
+ help_text=_("Users who can view this charge point."),
277
+ )
278
+ owner_groups = models.ManyToManyField(
279
+ SecurityGroup,
280
+ blank=True,
281
+ related_name="owned_chargers",
282
+ help_text=_("Security groups that can view this charge point."),
283
+ )
284
+
285
+ def __str__(self) -> str: # pragma: no cover - simple representation
286
+ return self.charger_id
287
+
288
+ @classmethod
289
+ def visible_for_user(cls, user):
290
+ """Return chargers marked for display that the user may view."""
291
+
292
+ qs = cls.objects.filter(public_display=True)
293
+ if getattr(user, "is_superuser", False):
294
+ return qs
295
+ if not getattr(user, "is_authenticated", False):
296
+ return qs.filter(
297
+ owner_users__isnull=True, owner_groups__isnull=True
298
+ ).distinct()
299
+ group_ids = list(user.groups.values_list("pk", flat=True))
300
+ visibility = Q(owner_users__isnull=True, owner_groups__isnull=True) | Q(
301
+ owner_users=user
302
+ )
303
+ if group_ids:
304
+ visibility |= Q(owner_groups__pk__in=group_ids)
305
+ return qs.filter(visibility).distinct()
306
+
307
+ def has_owner_scope(self) -> bool:
308
+ """Return ``True`` when owner restrictions are defined."""
309
+
310
+ return self.owner_users.exists() or self.owner_groups.exists()
311
+
312
+ def is_visible_to(self, user) -> bool:
313
+ """Return ``True`` when ``user`` may view this charger."""
314
+
315
+ if getattr(user, "is_superuser", False):
316
+ return True
317
+ if not self.has_owner_scope():
318
+ return True
319
+ if not getattr(user, "is_authenticated", False):
320
+ return False
321
+ if self.owner_users.filter(pk=user.pk).exists():
322
+ return True
323
+ user_group_ids = user.groups.values_list("pk", flat=True)
324
+ return self.owner_groups.filter(pk__in=user_group_ids).exists()
325
+
326
+ @property
327
+ def is_local(self) -> bool:
328
+ """Return ``True`` when this charger originates from the local node."""
329
+
330
+ local = Node.get_local()
331
+ if not local:
332
+ return False
333
+ if self.node_origin_id is None:
334
+ return True
335
+ return self.node_origin_id == local.pk
336
+
337
+ def save(self, *args, **kwargs):
338
+ if self.node_origin_id is None:
339
+ local = Node.get_local()
340
+ if local:
341
+ self.node_origin = local
342
+ super().save(*args, **kwargs)
343
+
344
+ class Meta:
345
+ verbose_name = _("Charge Point")
346
+ verbose_name_plural = _("Charge Points")
347
+ constraints = [
348
+ models.UniqueConstraint(
349
+ fields=("charger_id", "connector_id"),
350
+ condition=models.Q(connector_id__isnull=False),
351
+ name="charger_connector_unique",
352
+ ),
353
+ models.UniqueConstraint(
354
+ fields=("charger_id",),
355
+ condition=models.Q(connector_id__isnull=True),
356
+ name="charger_unique_without_connector",
357
+ ),
358
+ ]
359
+
360
+
361
+ @classmethod
362
+ def normalize_serial(cls, value: str | None) -> str:
363
+ """Return ``value`` trimmed for consistent comparisons."""
364
+
365
+ if value is None:
366
+ return ""
367
+ return str(value).strip()
368
+
369
+ @classmethod
370
+ def is_placeholder_serial(cls, value: str | None) -> bool:
371
+ """Return ``True`` when ``value`` matches the placeholder pattern."""
372
+
373
+ normalized = cls.normalize_serial(value)
374
+ return bool(normalized) and bool(cls._PLACEHOLDER_SERIAL_RE.match(normalized))
375
+
376
+ @classmethod
377
+ def validate_serial(cls, value: str | None) -> str:
378
+ """Return a normalized serial number or raise ``ValidationError``."""
379
+
380
+ normalized = cls.normalize_serial(value)
381
+ if not normalized:
382
+ raise ValidationError({"charger_id": _("Serial Number cannot be blank.")})
383
+ if cls.is_placeholder_serial(normalized):
384
+ raise ValidationError(
385
+ {
386
+ "charger_id": _(
387
+ "Serial Number placeholder values such as <charger_id> are not allowed."
388
+ )
389
+ }
390
+ )
391
+ return normalized
392
+
393
+ @classmethod
394
+ def sanitize_auto_location_name(cls, value: str) -> str:
395
+ """Return a location name containing only safe characters."""
396
+
397
+ sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
398
+ sanitized = re.sub(r"_+", "_", sanitized).strip("_")
399
+ if not sanitized:
400
+ return "Charger"
401
+ return sanitized
402
+
403
+ AGGREGATE_CONNECTOR_SLUG = "all"
404
+
405
+ def identity_tuple(self) -> tuple[str, int | None]:
406
+ """Return the canonical identity for this charger."""
407
+
408
+ return (
409
+ self.charger_id,
410
+ self.connector_id if self.connector_id is not None else None,
411
+ )
412
+
413
+ @classmethod
414
+ def connector_slug_from_value(cls, connector: int | None) -> str:
415
+ """Return the slug used in URLs for the given connector."""
416
+
417
+ return cls.AGGREGATE_CONNECTOR_SLUG if connector is None else str(connector)
418
+
419
+ @classmethod
420
+ def connector_value_from_slug(cls, slug: int | str | None) -> int | None:
421
+ """Return the connector integer represented by ``slug``."""
422
+
423
+ if slug in (None, "", cls.AGGREGATE_CONNECTOR_SLUG):
424
+ return None
425
+ if isinstance(slug, int):
426
+ return slug
427
+ try:
428
+ return int(str(slug))
429
+ except (TypeError, ValueError) as exc:
430
+ raise ValueError(f"Invalid connector slug: {slug}") from exc
431
+
432
+ @classmethod
433
+ def availability_state_from_status(cls, status: str) -> str | None:
434
+ """Return the availability state implied by a status notification."""
435
+
436
+ normalized = (status or "").strip()
437
+ if not normalized:
438
+ return None
439
+ if normalized in cls.INOPERATIVE_STATUSES:
440
+ return "Inoperative"
441
+ if normalized in cls.OPERATIVE_STATUSES:
442
+ return "Operative"
443
+ return None
444
+
445
+ @property
446
+ def connector_slug(self) -> str:
447
+ """Return the slug representing this charger's connector."""
448
+
449
+ return type(self).connector_slug_from_value(self.connector_id)
450
+
451
+ @property
452
+ def connector_label(self) -> str:
453
+ """Return a short human readable label for this connector."""
454
+
455
+ if self.connector_id is None:
456
+ return _("All Connectors")
457
+
458
+ special_labels = {
459
+ 1: _("Connector 1 (Left)"),
460
+ 2: _("Connector 2 (Right)"),
461
+ }
462
+ if self.connector_id in special_labels:
463
+ return special_labels[self.connector_id]
464
+
465
+ return _("Connector %(number)s") % {"number": self.connector_id}
466
+
467
+ def identity_slug(self) -> str:
468
+ """Return a unique slug for this charger identity."""
469
+
470
+ serial, connector = self.identity_tuple()
471
+ return f"{serial}#{type(self).connector_slug_from_value(connector)}"
472
+
473
+ def get_absolute_url(self):
474
+ serial, connector = self.identity_tuple()
475
+ connector_slug = type(self).connector_slug_from_value(connector)
476
+ if connector_slug == self.AGGREGATE_CONNECTOR_SLUG:
477
+ return reverse("charger-page", args=[serial])
478
+ return reverse("charger-page-connector", args=[serial, connector_slug])
479
+
480
+ def _fallback_domain(self) -> str:
481
+ """Return a best-effort hostname when the Sites framework is unset."""
482
+
483
+ fallback = getattr(settings, "DEFAULT_SITE_DOMAIN", "") or getattr(
484
+ settings, "DEFAULT_DOMAIN", ""
485
+ )
486
+ if fallback:
487
+ return fallback.strip()
488
+
489
+ for host in getattr(settings, "ALLOWED_HOSTS", []):
490
+ if not isinstance(host, str):
491
+ continue
492
+ host = host.strip()
493
+ if not host or host.startswith("*") or "/" in host:
494
+ continue
495
+ return host
496
+
497
+ return socket.gethostname() or "localhost"
498
+
499
+ def _full_url(self) -> str:
500
+ """Return absolute URL for the charger landing page."""
501
+
502
+ try:
503
+ domain = Site.objects.get_current().domain.strip()
504
+ except Site.DoesNotExist:
505
+ domain = ""
506
+
507
+ if not domain:
508
+ domain = self._fallback_domain()
509
+
510
+ scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
511
+ return f"{scheme}://{domain}{self.get_absolute_url()}"
512
+
513
+ def clean(self):
514
+ super().clean()
515
+ self.charger_id = type(self).validate_serial(self.charger_id)
516
+
517
+ def save(self, *args, **kwargs):
518
+ self.charger_id = type(self).validate_serial(self.charger_id)
519
+ update_fields = kwargs.get("update_fields")
520
+ update_list = list(update_fields) if update_fields is not None else None
521
+ if not self.manager_node_id:
522
+ local_node = Node.get_local()
523
+ if local_node:
524
+ self.manager_node = local_node
525
+ if update_list is not None and "manager_node" not in update_list:
526
+ update_list.append("manager_node")
527
+ if not self.location_id:
528
+ existing = (
529
+ type(self)
530
+ .objects.filter(charger_id=self.charger_id, location__isnull=False)
531
+ .exclude(pk=self.pk)
532
+ .select_related("location")
533
+ .first()
534
+ )
535
+ if existing:
536
+ self.location = existing.location
537
+ else:
538
+ auto_name = type(self).sanitize_auto_location_name(self.charger_id)
539
+ location, _ = Location.objects.get_or_create(name=auto_name)
540
+ self.location = location
541
+ if update_list is not None and "location" not in update_list:
542
+ update_list.append("location")
543
+ if update_list is not None:
544
+ kwargs["update_fields"] = update_list
545
+ super().save(*args, **kwargs)
546
+ ref_value = self._full_url()
547
+ if url_targets_local_loopback(ref_value):
548
+ return
549
+ if not self.reference:
550
+ self.reference = Reference.objects.create(
551
+ value=ref_value, alt_text=self.charger_id
552
+ )
553
+ super().save(update_fields=["reference"])
554
+ elif self.reference.value != ref_value:
555
+ Reference.objects.filter(pk=self.reference_id).update(
556
+ value=ref_value, alt_text=self.charger_id
557
+ )
558
+ self.reference.value = ref_value
559
+ self.reference.alt_text = self.charger_id
560
+
561
+ def refresh_manager_node(self, node: Node | None = None) -> Node | None:
562
+ """Ensure ``manager_node`` matches the provided or local node."""
563
+
564
+ node = node or Node.get_local()
565
+ if not node:
566
+ return None
567
+ if self.pk is None:
568
+ self.manager_node = node
569
+ return node
570
+ if self.manager_node_id != node.pk:
571
+ type(self).objects.filter(pk=self.pk).update(manager_node=node)
572
+ self.manager_node = node
573
+ return node
574
+
575
+ @property
576
+ def name(self) -> str:
577
+ if self.location:
578
+ if self.connector_id is not None:
579
+ return f"{self.location.name} #{self.connector_id}"
580
+ return self.location.name
581
+ return ""
582
+
583
+ @property
584
+ def latitude(self):
585
+ return self.location.latitude if self.location else None
586
+
587
+ @property
588
+ def longitude(self):
589
+ return self.location.longitude if self.location else None
590
+
591
+ @property
592
+ def total_kw(self) -> float:
593
+ """Return total energy delivered by this charger in kW."""
594
+ from . import store
595
+
596
+ total = 0.0
597
+ for charger in self._target_chargers():
598
+ total += charger._total_kw_single(store)
599
+ return total
600
+
601
+ def _store_keys(self) -> list[str]:
602
+ """Return keys used for store lookups with fallbacks."""
603
+
604
+ from . import store
605
+
606
+ base = self.charger_id
607
+ connector = self.connector_id
608
+ keys: list[str] = []
609
+ keys.append(store.identity_key(base, connector))
610
+ if connector is not None:
611
+ keys.append(store.identity_key(base, None))
612
+ keys.append(store.pending_key(base))
613
+ keys.append(base)
614
+ seen: set[str] = set()
615
+ deduped: list[str] = []
616
+ for key in keys:
617
+ if key not in seen:
618
+ seen.add(key)
619
+ deduped.append(key)
620
+ return deduped
621
+
622
+ def _target_chargers(self):
623
+ """Return chargers contributing to aggregate operations."""
624
+
625
+ qs = type(self).objects.filter(charger_id=self.charger_id)
626
+ if self.connector_id is None:
627
+ return qs
628
+ return qs.filter(pk=self.pk)
629
+
630
+ def total_kw_for_range(
631
+ self,
632
+ start=None,
633
+ end=None,
634
+ ) -> float:
635
+ """Return total energy delivered within ``start``/``end`` window."""
636
+
637
+ from . import store
638
+
639
+ total = 0.0
640
+ for charger in self._target_chargers():
641
+ total += charger._total_kw_range_single(store, start, end)
642
+ return total
643
+
644
+ def _total_kw_single(self, store_module) -> float:
645
+ """Return total kW for this specific charger identity."""
646
+
647
+ tx_active = None
648
+ if self.connector_id is not None:
649
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
650
+ qs = self.transactions.all()
651
+ if tx_active and tx_active.pk is not None:
652
+ qs = qs.exclude(pk=tx_active.pk)
653
+ total = 0.0
654
+ for tx in qs:
655
+ kw = tx.kw
656
+ if kw:
657
+ total += kw
658
+ if tx_active:
659
+ kw = tx_active.kw
660
+ if kw:
661
+ total += kw
662
+ return total
663
+
664
+ def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
665
+ """Return total kW for a date range for this charger."""
666
+
667
+ tx_active = None
668
+ if self.connector_id is not None:
669
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
670
+
671
+ qs = self.transactions.all()
672
+ if start is not None:
673
+ qs = qs.filter(start_time__gte=start)
674
+ if end is not None:
675
+ qs = qs.filter(start_time__lt=end)
676
+ if tx_active and tx_active.pk is not None:
677
+ qs = qs.exclude(pk=tx_active.pk)
678
+
679
+ total = 0.0
680
+ for tx in qs:
681
+ kw = tx.kw
682
+ if kw:
683
+ total += kw
684
+
685
+ if tx_active:
686
+ start_time = getattr(tx_active, "start_time", None)
687
+ include = True
688
+ if start is not None and start_time and start_time < start:
689
+ include = False
690
+ if end is not None and start_time and start_time >= end:
691
+ include = False
692
+ if include:
693
+ kw = tx_active.kw
694
+ if kw:
695
+ total += kw
696
+ return total
697
+
698
+ def purge(self):
699
+ from . import store
700
+
701
+ for charger in self._target_chargers():
702
+ charger.transactions.all().delete()
703
+ charger.meter_values.all().delete()
704
+ for key in charger._store_keys():
705
+ store.clear_log(key, log_type="charger")
706
+ store.transactions.pop(key, None)
707
+ store.history.pop(key, None)
708
+
709
+ def delete(self, *args, **kwargs):
710
+ from django.db.models.deletion import ProtectedError
711
+ from . import store
712
+
713
+ for charger in self._target_chargers():
714
+ has_data = (
715
+ charger.transactions.exists()
716
+ or charger.meter_values.exists()
717
+ or any(
718
+ store.get_logs(key, log_type="charger")
719
+ for key in charger._store_keys()
720
+ )
721
+ or any(store.transactions.get(key) for key in charger._store_keys())
722
+ or any(store.history.get(key) for key in charger._store_keys())
723
+ )
724
+ if has_data:
725
+ raise ProtectedError("Purge data before deleting charger.", [])
726
+ super().delete(*args, **kwargs)
727
+
728
+
729
+ class ChargerConfiguration(models.Model):
730
+ """Persisted configuration package returned by a charge point."""
731
+
732
+ charger_identifier = models.CharField(_("Serial Number"), max_length=100)
733
+ connector_id = models.PositiveIntegerField(
734
+ _("Connector ID"),
735
+ null=True,
736
+ blank=True,
737
+ help_text=_("Connector that returned this configuration (if specified)."),
738
+ )
739
+ configuration_keys = models.JSONField(
740
+ default=list,
741
+ blank=True,
742
+ help_text=_("Entries from the configurationKey list."),
743
+ )
744
+ unknown_keys = models.JSONField(
745
+ default=list,
746
+ blank=True,
747
+ help_text=_("Keys returned in the unknownKey list."),
748
+ )
749
+ evcs_snapshot_at = models.DateTimeField(
750
+ _("EVCS snapshot at"),
751
+ null=True,
752
+ blank=True,
753
+ help_text=_(
754
+ "Timestamp when this configuration was received from the charge point."
755
+ ),
756
+ )
757
+ raw_payload = models.JSONField(
758
+ default=dict,
759
+ blank=True,
760
+ help_text=_("Raw payload returned by the GetConfiguration call."),
761
+ )
762
+ created_at = models.DateTimeField(auto_now_add=True)
763
+ updated_at = models.DateTimeField(auto_now=True)
764
+
765
+ class Meta:
766
+ ordering = ["-created_at"]
767
+ verbose_name = _("CP Configuration")
768
+ verbose_name_plural = _("CP Configurations")
769
+
770
+ def __str__(self) -> str: # pragma: no cover - simple representation
771
+ connector = (
772
+ _("connector %(number)s") % {"number": self.connector_id}
773
+ if self.connector_id is not None
774
+ else _("all connectors")
775
+ )
776
+ return _("%(serial)s configuration (%(connector)s)") % {
777
+ "serial": self.charger_identifier,
778
+ "connector": connector,
779
+ }
780
+
781
+
782
+ class Transaction(Entity):
783
+ """Charging session data stored for each charger."""
784
+
785
+ charger = models.ForeignKey(
786
+ Charger, on_delete=models.CASCADE, related_name="transactions", null=True
787
+ )
788
+ account = models.ForeignKey(
789
+ EnergyAccount, on_delete=models.PROTECT, related_name="transactions", null=True
790
+ )
791
+ rfid = models.CharField(
792
+ max_length=20,
793
+ blank=True,
794
+ verbose_name=_("RFID"),
795
+ )
796
+ vid = models.CharField(
797
+ max_length=64,
798
+ blank=True,
799
+ default="",
800
+ verbose_name=_("VID"),
801
+ help_text=_("Vehicle identifier reported by the charger."),
802
+ )
803
+ vin = models.CharField(
804
+ max_length=17,
805
+ blank=True,
806
+ help_text=_("Deprecated. Use VID instead."),
807
+ )
808
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
809
+ meter_start = models.IntegerField(null=True, blank=True)
810
+ meter_stop = models.IntegerField(null=True, blank=True)
811
+ voltage_start = models.DecimalField(
812
+ max_digits=12, decimal_places=3, null=True, blank=True
813
+ )
814
+ voltage_stop = models.DecimalField(
815
+ max_digits=12, decimal_places=3, null=True, blank=True
816
+ )
817
+ current_import_start = models.DecimalField(
818
+ max_digits=12, decimal_places=3, null=True, blank=True
819
+ )
820
+ current_import_stop = models.DecimalField(
821
+ max_digits=12, decimal_places=3, null=True, blank=True
822
+ )
823
+ current_offered_start = models.DecimalField(
824
+ max_digits=12, decimal_places=3, null=True, blank=True
825
+ )
826
+ current_offered_stop = models.DecimalField(
827
+ max_digits=12, decimal_places=3, null=True, blank=True
828
+ )
829
+ temperature_start = models.DecimalField(
830
+ max_digits=12, decimal_places=3, null=True, blank=True
831
+ )
832
+ temperature_stop = models.DecimalField(
833
+ max_digits=12, decimal_places=3, null=True, blank=True
834
+ )
835
+ soc_start = models.DecimalField(
836
+ max_digits=12, decimal_places=3, null=True, blank=True
837
+ )
838
+ soc_stop = models.DecimalField(
839
+ max_digits=12, decimal_places=3, null=True, blank=True
840
+ )
841
+ start_time = models.DateTimeField()
842
+ stop_time = models.DateTimeField(null=True, blank=True)
843
+ received_start_time = models.DateTimeField(null=True, blank=True)
844
+ received_stop_time = models.DateTimeField(null=True, blank=True)
845
+
846
+ def __str__(self) -> str: # pragma: no cover - simple representation
847
+ return f"{self.charger}:{self.pk}"
848
+
849
+ class Meta:
850
+ verbose_name = _("Transaction")
851
+ verbose_name_plural = _("CP Transactions")
852
+
853
+ @property
854
+ def vehicle_identifier(self) -> str:
855
+ """Return the preferred vehicle identifier for this transaction."""
856
+
857
+ vid = (self.vid or "").strip()
858
+ if vid:
859
+ return vid
860
+
861
+ return (self.vin or "").strip()
862
+
863
+ @property
864
+ def vehicle_identifier_source(self) -> str:
865
+ """Return which field supplies :pyattr:`vehicle_identifier`."""
866
+
867
+ if (self.vid or "").strip():
868
+ return "vid"
869
+ if (self.vin or "").strip():
870
+ return "vin"
871
+ return ""
872
+
873
+ @property
874
+ def kw(self) -> float:
875
+ """Return consumed energy in kW for this session."""
876
+ start_val = None
877
+ if self.meter_start is not None:
878
+ start_val = float(self.meter_start) / 1000.0
879
+
880
+ end_val = None
881
+ if self.meter_stop is not None:
882
+ end_val = float(self.meter_stop) / 1000.0
883
+
884
+ readings = list(
885
+ self.meter_values.filter(energy__isnull=False).order_by("timestamp")
886
+ )
887
+ if readings:
888
+ if start_val is None:
889
+ start_val = float(readings[0].energy or 0)
890
+ # Always use the latest available reading for the end value when a
891
+ # stop meter has not been recorded yet. This allows active
892
+ # transactions to report totals using their most recent reading.
893
+ if end_val is None:
894
+ end_val = float(readings[-1].energy or 0)
895
+
896
+ if start_val is None or end_val is None:
897
+ return 0.0
898
+
899
+ total = end_val - start_val
900
+ return max(total, 0.0)
901
+
902
+
903
+ class MeterValue(Entity):
904
+ """Parsed meter values reported by chargers."""
905
+
906
+ charger = models.ForeignKey(
907
+ Charger, on_delete=models.CASCADE, related_name="meter_values"
908
+ )
909
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
910
+ transaction = models.ForeignKey(
911
+ Transaction,
912
+ on_delete=models.CASCADE,
913
+ related_name="meter_values",
914
+ null=True,
915
+ blank=True,
916
+ )
917
+ timestamp = models.DateTimeField()
918
+ context = models.CharField(max_length=32, blank=True)
919
+ energy = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
920
+ voltage = models.DecimalField(
921
+ max_digits=12, decimal_places=3, null=True, blank=True
922
+ )
923
+ current_import = models.DecimalField(
924
+ max_digits=12, decimal_places=3, null=True, blank=True
925
+ )
926
+ current_offered = models.DecimalField(
927
+ max_digits=12, decimal_places=3, null=True, blank=True
928
+ )
929
+ temperature = models.DecimalField(
930
+ max_digits=12, decimal_places=3, null=True, blank=True
931
+ )
932
+ soc = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
933
+
934
+ def __str__(self) -> str: # pragma: no cover - simple representation
935
+ return f"{self.charger} {self.timestamp}"
936
+
937
+ @property
938
+ def value(self):
939
+ return self.energy
940
+
941
+ @value.setter
942
+ def value(self, new_value):
943
+ self.energy = new_value
944
+
945
+ class Meta:
946
+ verbose_name = _("Meter Value")
947
+ verbose_name_plural = _("Meter Values")
948
+
949
+
950
+ class MeterReadingManager(EntityManager):
951
+ def _normalize_kwargs(self, kwargs: dict) -> dict:
952
+ normalized = dict(kwargs)
953
+ value = normalized.pop("value", None)
954
+ unit = normalized.pop("unit", None)
955
+ if value is not None:
956
+ energy = value
957
+ try:
958
+ energy = Decimal(value)
959
+ except (InvalidOperation, TypeError, ValueError):
960
+ energy = None
961
+ if energy is not None:
962
+ unit_normalized = (unit or "").lower()
963
+ if unit_normalized in {"w", "wh"}:
964
+ energy = energy / Decimal("1000")
965
+ normalized.setdefault("energy", energy)
966
+ normalized.pop("measurand", None)
967
+ return normalized
968
+
969
+ def create(self, **kwargs):
970
+ return super().create(**self._normalize_kwargs(kwargs))
971
+
972
+ def get_or_create(self, defaults=None, **kwargs):
973
+ if defaults:
974
+ defaults = self._normalize_kwargs(defaults)
975
+ return super().get_or_create(
976
+ defaults=defaults, **self._normalize_kwargs(kwargs)
977
+ )
978
+
979
+
980
+ class MeterReading(MeterValue):
981
+ """Proxy model for backwards compatibility."""
982
+
983
+ objects = MeterReadingManager()
984
+
985
+ class Meta:
986
+ proxy = True
987
+ verbose_name = _("Meter Value")
988
+ verbose_name_plural = _("Meter Values")
989
+
990
+
991
+ class Simulator(Entity):
992
+ """Preconfigured simulator that can be started from the admin."""
993
+
994
+ name = models.CharField(max_length=100, unique=True)
995
+ cp_path = models.CharField(
996
+ _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
997
+ )
998
+ host = models.CharField(max_length=100, default="127.0.0.1")
999
+ ws_port = models.IntegerField(
1000
+ _("WS Port"), default=8000, null=True, blank=True
1001
+ )
1002
+ rfid = models.CharField(
1003
+ max_length=255,
1004
+ default="FFFFFFFF",
1005
+ verbose_name=_("RFID"),
1006
+ )
1007
+ vin = models.CharField(max_length=17, blank=True)
1008
+ serial_number = models.CharField(_("Serial Number"), max_length=100, blank=True)
1009
+ connector_id = models.IntegerField(_("Connector ID"), default=1)
1010
+ duration = models.IntegerField(default=600)
1011
+ interval = models.FloatField(default=5.0)
1012
+ pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
1013
+ kw_max = models.FloatField(default=60.0)
1014
+ repeat = models.BooleanField(default=False)
1015
+ username = models.CharField(max_length=100, blank=True)
1016
+ password = models.CharField(max_length=100, blank=True)
1017
+ door_open = models.BooleanField(
1018
+ _("Door Open"),
1019
+ default=False,
1020
+ help_text=_("Send a DoorOpen error StatusNotification when enabled."),
1021
+ )
1022
+ configuration_keys = models.JSONField(
1023
+ default=list,
1024
+ blank=True,
1025
+ help_text=_(
1026
+ "List of configurationKey entries to return for GetConfiguration calls."
1027
+ ),
1028
+ )
1029
+ configuration_unknown_keys = models.JSONField(
1030
+ default=list,
1031
+ blank=True,
1032
+ help_text=_("Keys to include in the GetConfiguration unknownKey response."),
1033
+ )
1034
+
1035
+ def __str__(self) -> str: # pragma: no cover - simple representation
1036
+ return self.name
1037
+
1038
+ class Meta:
1039
+ verbose_name = _("CP Simulator")
1040
+ verbose_name_plural = _("CP Simulators")
1041
+
1042
+ def as_config(self):
1043
+ from .simulator import SimulatorConfig
1044
+
1045
+ return SimulatorConfig(
1046
+ host=self.host,
1047
+ ws_port=self.ws_port,
1048
+ rfid=self.rfid,
1049
+ vin=self.vin,
1050
+ cp_path=self.cp_path,
1051
+ serial_number=self.serial_number,
1052
+ connector_id=self.connector_id,
1053
+ duration=self.duration,
1054
+ interval=self.interval,
1055
+ pre_charge_delay=self.pre_charge_delay,
1056
+ kw_max=self.kw_max,
1057
+ repeat=self.repeat,
1058
+ username=self.username or None,
1059
+ password=self.password or None,
1060
+ configuration_keys=self.configuration_keys or [],
1061
+ configuration_unknown_keys=self.configuration_unknown_keys or [],
1062
+ )
1063
+
1064
+ @property
1065
+ def ws_url(self) -> str: # pragma: no cover - simple helper
1066
+ path = self.cp_path
1067
+ if not path.endswith("/"):
1068
+ path += "/"
1069
+ if self.ws_port:
1070
+ return f"ws://{self.host}:{self.ws_port}/{path}"
1071
+ return f"ws://{self.host}/{path}"
1072
+
1073
+
1074
+ class DataTransferMessage(models.Model):
1075
+ """Persisted record of OCPP DataTransfer exchanges."""
1076
+
1077
+ DIRECTION_CP_TO_CSMS = "cp_to_csms"
1078
+ DIRECTION_CSMS_TO_CP = "csms_to_cp"
1079
+ DIRECTION_CHOICES = (
1080
+ (DIRECTION_CP_TO_CSMS, _("Charge Point → CSMS")),
1081
+ (DIRECTION_CSMS_TO_CP, _("CSMS → Charge Point")),
1082
+ )
1083
+
1084
+ charger = models.ForeignKey(
1085
+ "Charger",
1086
+ on_delete=models.CASCADE,
1087
+ related_name="data_transfer_messages",
1088
+ )
1089
+ connector_id = models.PositiveIntegerField(
1090
+ null=True,
1091
+ blank=True,
1092
+ verbose_name="Connector ID",
1093
+ )
1094
+ direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
1095
+ ocpp_message_id = models.CharField(
1096
+ max_length=64,
1097
+ verbose_name="OCPP message ID",
1098
+ )
1099
+ vendor_id = models.CharField(
1100
+ max_length=255,
1101
+ blank=True,
1102
+ verbose_name="Vendor ID",
1103
+ )
1104
+ message_id = models.CharField(
1105
+ max_length=255,
1106
+ blank=True,
1107
+ verbose_name="Message ID",
1108
+ )
1109
+ payload = models.JSONField(default=dict, blank=True)
1110
+ status = models.CharField(max_length=64, blank=True)
1111
+ response_data = models.JSONField(null=True, blank=True)
1112
+ error_code = models.CharField(max_length=64, blank=True)
1113
+ error_description = models.TextField(blank=True)
1114
+ error_details = models.JSONField(null=True, blank=True)
1115
+ responded_at = models.DateTimeField(null=True, blank=True)
1116
+ created_at = models.DateTimeField(auto_now_add=True)
1117
+ updated_at = models.DateTimeField(auto_now=True)
1118
+
1119
+ class Meta:
1120
+ ordering = ["-created_at"]
1121
+ verbose_name = _("Data Message")
1122
+ verbose_name_plural = _("Data Messages")
1123
+ indexes = [
1124
+ models.Index(
1125
+ fields=["ocpp_message_id"],
1126
+ name="ocpp_datatr_ocpp_me_70d17f_idx",
1127
+ ),
1128
+ models.Index(
1129
+ fields=["vendor_id"], name="ocpp_datatr_vendor__59e1c7_idx"
1130
+ ),
1131
+ ]
1132
+
1133
+ def __str__(self) -> str: # pragma: no cover - simple representation
1134
+ return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
1135
+
1136
+
1137
+ class CPReservation(Entity):
1138
+ """Track connector reservations dispatched to an EVCS."""
1139
+
1140
+ location = models.ForeignKey(
1141
+ Location,
1142
+ on_delete=models.PROTECT,
1143
+ related_name="reservations",
1144
+ verbose_name=_("Location"),
1145
+ )
1146
+ connector = models.ForeignKey(
1147
+ Charger,
1148
+ on_delete=models.PROTECT,
1149
+ related_name="reservations",
1150
+ verbose_name=_("Connector"),
1151
+ )
1152
+ account = models.ForeignKey(
1153
+ EnergyAccount,
1154
+ on_delete=models.SET_NULL,
1155
+ null=True,
1156
+ blank=True,
1157
+ related_name="cp_reservations",
1158
+ verbose_name=_("Energy account"),
1159
+ )
1160
+ rfid = models.ForeignKey(
1161
+ CoreRFID,
1162
+ on_delete=models.SET_NULL,
1163
+ null=True,
1164
+ blank=True,
1165
+ related_name="cp_reservations",
1166
+ verbose_name=_("RFID"),
1167
+ )
1168
+ id_tag = models.CharField(
1169
+ _("Id Tag"),
1170
+ max_length=255,
1171
+ blank=True,
1172
+ default="",
1173
+ help_text=_("Identifier sent to the EVCS when reserving the connector."),
1174
+ )
1175
+ start_time = models.DateTimeField(verbose_name=_("Start time"))
1176
+ duration_minutes = models.PositiveIntegerField(
1177
+ verbose_name=_("Duration (minutes)"),
1178
+ default=120,
1179
+ help_text=_("Reservation window length in minutes."),
1180
+ )
1181
+ evcs_status = models.CharField(
1182
+ max_length=32,
1183
+ blank=True,
1184
+ default="",
1185
+ verbose_name=_("EVCS status"),
1186
+ )
1187
+ evcs_error = models.CharField(
1188
+ max_length=255,
1189
+ blank=True,
1190
+ default="",
1191
+ verbose_name=_("EVCS error"),
1192
+ )
1193
+ evcs_confirmed = models.BooleanField(
1194
+ default=False,
1195
+ verbose_name=_("Reservation confirmed"),
1196
+ )
1197
+ evcs_confirmed_at = models.DateTimeField(
1198
+ null=True,
1199
+ blank=True,
1200
+ verbose_name=_("Confirmed at"),
1201
+ )
1202
+ ocpp_message_id = models.CharField(
1203
+ max_length=36,
1204
+ blank=True,
1205
+ default="",
1206
+ editable=False,
1207
+ verbose_name=_("OCPP message id"),
1208
+ )
1209
+ created_on = models.DateTimeField(auto_now_add=True, verbose_name=_("Created on"))
1210
+ updated_on = models.DateTimeField(auto_now=True, verbose_name=_("Updated on"))
1211
+
1212
+ class Meta:
1213
+ ordering = ["-start_time"]
1214
+ verbose_name = _("CP Reservation")
1215
+ verbose_name_plural = _("CP Reservations")
1216
+
1217
+ def __str__(self) -> str: # pragma: no cover - simple representation
1218
+ start = timezone.localtime(self.start_time) if self.start_time else ""
1219
+ return f"{self.location} @ {start}" if self.location else str(start)
1220
+
1221
+ @property
1222
+ def end_time(self):
1223
+ duration = max(int(self.duration_minutes or 0), 0)
1224
+ return self.start_time + timedelta(minutes=duration)
1225
+
1226
+ @property
1227
+ def connector_label(self) -> str:
1228
+ if self.connector_id:
1229
+ return self.connector.connector_label
1230
+ return ""
1231
+
1232
+ @property
1233
+ def id_tag_value(self) -> str:
1234
+ if self.id_tag:
1235
+ return self.id_tag.strip()
1236
+ if self.rfid_id:
1237
+ return (self.rfid.rfid or "").strip()
1238
+ return ""
1239
+
1240
+ def allocate_connector(self, *, force: bool = False) -> Charger:
1241
+ """Select an available connector for this reservation."""
1242
+
1243
+ if not self.location_id:
1244
+ raise ValidationError({"location": _("Select a location for the reservation.")})
1245
+ if not self.start_time:
1246
+ raise ValidationError({"start_time": _("Provide a start time for the reservation.")})
1247
+ if self.duration_minutes <= 0:
1248
+ raise ValidationError(
1249
+ {"duration_minutes": _("Reservation window must be at least one minute.")}
1250
+ )
1251
+
1252
+ candidates = list(
1253
+ Charger.objects.filter(
1254
+ location=self.location, connector_id__isnull=False
1255
+ ).order_by("connector_id")
1256
+ )
1257
+ if not candidates:
1258
+ raise ValidationError(
1259
+ {"location": _("No connectors are configured for the selected location.")}
1260
+ )
1261
+
1262
+ def _priority(charger: Charger) -> tuple[int, int]:
1263
+ connector_id = charger.connector_id or 0
1264
+ if connector_id == 2:
1265
+ return (0, connector_id)
1266
+ if connector_id == 1:
1267
+ return (1, connector_id)
1268
+ return (2, connector_id)
1269
+
1270
+ def _is_available(charger: Charger) -> bool:
1271
+ existing = type(self).objects.filter(connector=charger).exclude(pk=self.pk)
1272
+ start = self.start_time
1273
+ end = self.end_time
1274
+ for entry in existing:
1275
+ if entry.start_time < end and entry.end_time > start:
1276
+ return False
1277
+ return True
1278
+
1279
+ if self.connector_id:
1280
+ current = next((c for c in candidates if c.pk == self.connector_id), None)
1281
+ if current and _is_available(current) and not force:
1282
+ return current
1283
+
1284
+ for charger in sorted(candidates, key=_priority):
1285
+ if _is_available(charger):
1286
+ self.connector = charger
1287
+ return charger
1288
+
1289
+ raise ValidationError(
1290
+ _("All connectors at this location are reserved for the selected time window.")
1291
+ )
1292
+
1293
+ def clean(self):
1294
+ super().clean()
1295
+ if self.start_time and timezone.is_naive(self.start_time):
1296
+ self.start_time = timezone.make_aware(
1297
+ self.start_time, timezone.get_current_timezone()
1298
+ )
1299
+ if self.duration_minutes <= 0:
1300
+ raise ValidationError(
1301
+ {"duration_minutes": _("Reservation window must be at least one minute.")}
1302
+ )
1303
+ try:
1304
+ self.allocate_connector(force=bool(self.pk))
1305
+ except ValidationError as exc:
1306
+ raise ValidationError(exc) from exc
1307
+
1308
+ def save(self, *args, **kwargs):
1309
+ if self.start_time and timezone.is_naive(self.start_time):
1310
+ self.start_time = timezone.make_aware(
1311
+ self.start_time, timezone.get_current_timezone()
1312
+ )
1313
+ update_fields = kwargs.get("update_fields")
1314
+ relevant_fields = {"location", "start_time", "duration_minutes", "connector"}
1315
+ should_allocate = True
1316
+ if update_fields is not None and not relevant_fields.intersection(update_fields):
1317
+ should_allocate = False
1318
+ if should_allocate:
1319
+ self.allocate_connector(force=bool(self.pk))
1320
+ super().save(*args, **kwargs)
1321
+
1322
+ def send_reservation_request(self) -> str:
1323
+ """Dispatch a ReserveNow request to the associated connector."""
1324
+
1325
+ if not self.pk:
1326
+ raise ValidationError(_("Save the reservation before sending it to the EVCS."))
1327
+ connector = self.connector
1328
+ if connector is None or connector.connector_id is None:
1329
+ raise ValidationError(_("Unable to determine which connector to reserve."))
1330
+ id_tag = self.id_tag_value
1331
+ if not id_tag:
1332
+ raise ValidationError(
1333
+ _("Provide an RFID or idTag before creating the reservation.")
1334
+ )
1335
+ connection = store.get_connection(connector.charger_id, connector.connector_id)
1336
+ if connection is None:
1337
+ raise ValidationError(
1338
+ _("The selected charge point is not currently connected to the system.")
1339
+ )
1340
+
1341
+ message_id = uuid.uuid4().hex
1342
+ expiry = timezone.localtime(self.end_time)
1343
+ payload = {
1344
+ "connectorId": connector.connector_id,
1345
+ "expiryDate": expiry.isoformat(),
1346
+ "idTag": id_tag,
1347
+ "reservationId": self.pk,
1348
+ }
1349
+ frame = json.dumps([2, message_id, "ReserveNow", payload])
1350
+
1351
+ log_key = store.identity_key(connector.charger_id, connector.connector_id)
1352
+ store.add_log(
1353
+ log_key,
1354
+ f"ReserveNow request: reservation={self.pk}, expiry={expiry.isoformat()}",
1355
+ log_type="charger",
1356
+ )
1357
+ async_to_sync(connection.send)(frame)
1358
+
1359
+ metadata = {
1360
+ "action": "ReserveNow",
1361
+ "charger_id": connector.charger_id,
1362
+ "connector_id": connector.connector_id,
1363
+ "log_key": log_key,
1364
+ "reservation_pk": self.pk,
1365
+ "requested_at": timezone.now(),
1366
+ }
1367
+ store.register_pending_call(message_id, metadata)
1368
+ store.schedule_call_timeout(message_id, action="ReserveNow", log_key=log_key)
1369
+
1370
+ self.ocpp_message_id = message_id
1371
+ self.evcs_status = ""
1372
+ self.evcs_error = ""
1373
+ self.evcs_confirmed = False
1374
+ self.evcs_confirmed_at = None
1375
+ super().save(
1376
+ update_fields=[
1377
+ "ocpp_message_id",
1378
+ "evcs_status",
1379
+ "evcs_error",
1380
+ "evcs_confirmed",
1381
+ "evcs_confirmed_at",
1382
+ "updated_on",
1383
+ ]
1384
+ )
1385
+ return message_id
1386
+
1387
+
1388
+ class RFID(CoreRFID):
1389
+ class Meta:
1390
+ proxy = True
1391
+ app_label = "ocpp"
1392
+ verbose_name = CoreRFID._meta.verbose_name
1393
+ verbose_name_plural = CoreRFID._meta.verbose_name_plural
1394
+
1395
+
1396
+ class ElectricVehicle(CoreElectricVehicle):
1397
+ class Meta:
1398
+ proxy = True
1399
+ app_label = "ocpp"
1400
+ verbose_name = _("Electric Vehicle")
1401
+ verbose_name_plural = _("Electric Vehicles")
1402
+
1403
+
1404
+ class Brand(CoreBrand):
1405
+ class Meta:
1406
+ proxy = True
1407
+ app_label = "ocpp"
1408
+ verbose_name = CoreBrand._meta.verbose_name
1409
+ verbose_name_plural = CoreBrand._meta.verbose_name_plural
1410
+
1411
+
1412
+ class EVModel(CoreEVModel):
1413
+ class Meta:
1414
+ proxy = True
1415
+ app_label = "ocpp"
1416
+ verbose_name = CoreEVModel._meta.verbose_name
1417
+ verbose_name_plural = CoreEVModel._meta.verbose_name_plural