arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
ocpp/models.py CHANGED
@@ -1,15 +1,22 @@
1
+ import socket
2
+ from decimal import Decimal, InvalidOperation
3
+
4
+ from django.conf import settings
5
+ from django.contrib.sites.models import Site
1
6
  from django.db import models
2
- from core.entity import Entity
3
7
  from django.urls import reverse
4
- from django.contrib.sites.models import Site
5
- from django.conf import settings
6
8
  from django.utils.translation import gettext_lazy as _
7
9
 
10
+ from core.entity import Entity, EntityManager
11
+ from nodes.models import Node
12
+
8
13
  from core.models import (
9
14
  EnergyAccount,
10
15
  Reference,
11
16
  RFID as CoreRFID,
12
17
  ElectricVehicle as CoreElectricVehicle,
18
+ Brand as CoreBrand,
19
+ EVModel as CoreEVModel,
13
20
  )
14
21
 
15
22
 
@@ -17,8 +24,12 @@ class Location(Entity):
17
24
  """Physical location shared by chargers."""
18
25
 
19
26
  name = models.CharField(max_length=200)
20
- latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
21
- longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
27
+ latitude = models.DecimalField(
28
+ max_digits=9, decimal_places=6, null=True, blank=True
29
+ )
30
+ longitude = models.DecimalField(
31
+ max_digits=9, decimal_places=6, null=True, blank=True
32
+ )
22
33
 
23
34
  def __str__(self) -> str: # pragma: no cover - simple representation
24
35
  return self.name
@@ -34,30 +45,95 @@ class Charger(Entity):
34
45
  charger_id = models.CharField(
35
46
  _("Serial Number"),
36
47
  max_length=100,
37
- unique=True,
38
48
  help_text="Unique identifier reported by the charger.",
39
49
  )
40
- connector_id = models.CharField(
50
+ display_name = models.CharField(
51
+ _("Display Name"),
52
+ max_length=200,
53
+ blank=True,
54
+ help_text="Optional friendly name shown on public pages.",
55
+ )
56
+ connector_id = models.PositiveIntegerField(
41
57
  _("Connector ID"),
42
- max_length=10,
43
58
  blank=True,
44
59
  null=True,
45
60
  help_text="Optional connector identifier for multi-connector chargers.",
46
61
  )
62
+ public_display = models.BooleanField(
63
+ _("Show on Public Dashboard"),
64
+ default=True,
65
+ help_text="Display this charger on the public status dashboard.",
66
+ )
47
67
  require_rfid = models.BooleanField(
48
68
  _("Require RFID Authorization"),
49
69
  default=False,
50
70
  help_text="Require a valid RFID before starting a charging session.",
51
71
  )
72
+ firmware_status = models.CharField(
73
+ _("Firmware Status"),
74
+ max_length=32,
75
+ blank=True,
76
+ default="",
77
+ help_text="Latest firmware status reported by the charger.",
78
+ )
79
+ firmware_status_info = models.CharField(
80
+ _("Firmware Status Details"),
81
+ max_length=255,
82
+ blank=True,
83
+ default="",
84
+ help_text="Additional information supplied with the firmware status.",
85
+ )
86
+ firmware_timestamp = models.DateTimeField(
87
+ _("Firmware Status Timestamp"),
88
+ null=True,
89
+ blank=True,
90
+ help_text="When the charger reported the current firmware status.",
91
+ )
52
92
  last_heartbeat = models.DateTimeField(null=True, blank=True)
53
93
  last_meter_values = models.JSONField(default=dict, blank=True)
54
- temperature = models.DecimalField(max_digits=5, decimal_places=1, null=True, blank=True)
94
+ last_status = models.CharField(max_length=64, blank=True)
95
+ last_error_code = models.CharField(max_length=64, blank=True)
96
+ last_status_vendor_info = models.JSONField(null=True, blank=True)
97
+ last_status_timestamp = models.DateTimeField(null=True, blank=True)
98
+ temperature = models.DecimalField(
99
+ max_digits=5, decimal_places=1, null=True, blank=True
100
+ )
55
101
  temperature_unit = models.CharField(max_length=16, blank=True)
56
- reference = models.OneToOneField(Reference, null=True, blank=True, on_delete=models.SET_NULL)
102
+ diagnostics_status = models.CharField(
103
+ max_length=32,
104
+ null=True,
105
+ blank=True,
106
+ help_text="Most recent diagnostics status reported by the charger.",
107
+ )
108
+ diagnostics_timestamp = models.DateTimeField(
109
+ null=True,
110
+ blank=True,
111
+ help_text="Timestamp associated with the latest diagnostics status.",
112
+ )
113
+ diagnostics_location = models.CharField(
114
+ max_length=255,
115
+ null=True,
116
+ blank=True,
117
+ help_text="Location or URI reported for the latest diagnostics upload.",
118
+ )
119
+ reference = models.OneToOneField(
120
+ Reference, null=True, blank=True, on_delete=models.SET_NULL
121
+ )
57
122
  location = models.ForeignKey(
58
- Location, null=True, blank=True, on_delete=models.SET_NULL, related_name="chargers"
123
+ Location,
124
+ null=True,
125
+ blank=True,
126
+ on_delete=models.SET_NULL,
127
+ related_name="chargers",
59
128
  )
60
129
  last_path = models.CharField(max_length=255, blank=True)
130
+ manager_node = models.ForeignKey(
131
+ "nodes.Node",
132
+ on_delete=models.SET_NULL,
133
+ null=True,
134
+ blank=True,
135
+ related_name="managed_chargers",
136
+ )
61
137
 
62
138
  def __str__(self) -> str: # pragma: no cover - simple representation
63
139
  return self.charger_id
@@ -65,34 +141,170 @@ class Charger(Entity):
65
141
  class Meta:
66
142
  verbose_name = _("Charge Point")
67
143
  verbose_name_plural = _("Charge Points")
144
+ constraints = [
145
+ models.UniqueConstraint(
146
+ fields=("charger_id", "connector_id"),
147
+ condition=models.Q(connector_id__isnull=False),
148
+ name="charger_connector_unique",
149
+ ),
150
+ models.UniqueConstraint(
151
+ fields=("charger_id",),
152
+ condition=models.Q(connector_id__isnull=True),
153
+ name="charger_unique_without_connector",
154
+ ),
155
+ ]
156
+
157
+ AGGREGATE_CONNECTOR_SLUG = "all"
158
+
159
+ def identity_tuple(self) -> tuple[str, int | None]:
160
+ """Return the canonical identity for this charger."""
161
+
162
+ return (
163
+ self.charger_id,
164
+ self.connector_id if self.connector_id is not None else None,
165
+ )
166
+
167
+ @classmethod
168
+ def connector_slug_from_value(cls, connector: int | None) -> str:
169
+ """Return the slug used in URLs for the given connector."""
170
+
171
+ return cls.AGGREGATE_CONNECTOR_SLUG if connector is None else str(connector)
172
+
173
+ @classmethod
174
+ def connector_value_from_slug(cls, slug: int | str | None) -> int | None:
175
+ """Return the connector integer represented by ``slug``."""
176
+
177
+ if slug in (None, "", cls.AGGREGATE_CONNECTOR_SLUG):
178
+ return None
179
+ if isinstance(slug, int):
180
+ return slug
181
+ try:
182
+ return int(str(slug))
183
+ except (TypeError, ValueError) as exc:
184
+ raise ValueError(f"Invalid connector slug: {slug}") from exc
185
+
186
+ @property
187
+ def connector_slug(self) -> str:
188
+ """Return the slug representing this charger's connector."""
189
+
190
+ return type(self).connector_slug_from_value(self.connector_id)
191
+
192
+ @property
193
+ def connector_label(self) -> str:
194
+ """Return a short human readable label for this connector."""
195
+
196
+ if self.connector_id is None:
197
+ return _("All Connectors")
198
+
199
+ special_labels = {
200
+ 1: _("Connector 1 (Left)"),
201
+ 2: _("Connector 2 (Right)"),
202
+ }
203
+ if self.connector_id in special_labels:
204
+ return special_labels[self.connector_id]
205
+
206
+ return _("Connector %(number)s") % {"number": self.connector_id}
207
+
208
+ def identity_slug(self) -> str:
209
+ """Return a unique slug for this charger identity."""
210
+
211
+ serial, connector = self.identity_tuple()
212
+ return f"{serial}#{type(self).connector_slug_from_value(connector)}"
68
213
 
69
214
  def get_absolute_url(self):
70
- return reverse("charger-page", args=[self.charger_id])
215
+ serial, connector = self.identity_tuple()
216
+ connector_slug = type(self).connector_slug_from_value(connector)
217
+ if connector_slug == self.AGGREGATE_CONNECTOR_SLUG:
218
+ return reverse("charger-page", args=[serial])
219
+ return reverse("charger-page-connector", args=[serial, connector_slug])
220
+
221
+ def _fallback_domain(self) -> str:
222
+ """Return a best-effort hostname when the Sites framework is unset."""
223
+
224
+ fallback = getattr(settings, "DEFAULT_SITE_DOMAIN", "") or getattr(
225
+ settings, "DEFAULT_DOMAIN", ""
226
+ )
227
+ if fallback:
228
+ return fallback.strip()
229
+
230
+ for host in getattr(settings, "ALLOWED_HOSTS", []):
231
+ if not isinstance(host, str):
232
+ continue
233
+ host = host.strip()
234
+ if not host or host.startswith("*") or "/" in host:
235
+ continue
236
+ return host
237
+
238
+ return socket.gethostname() or "localhost"
71
239
 
72
240
  def _full_url(self) -> str:
73
241
  """Return absolute URL for the charger landing page."""
74
- domain = Site.objects.get_current().domain
242
+
243
+ try:
244
+ domain = Site.objects.get_current().domain.strip()
245
+ except Site.DoesNotExist:
246
+ domain = ""
247
+
248
+ if not domain:
249
+ domain = self._fallback_domain()
250
+
75
251
  scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
76
252
  return f"{scheme}://{domain}{self.get_absolute_url()}"
77
253
 
78
254
  def save(self, *args, **kwargs):
255
+ update_fields = kwargs.get("update_fields")
256
+ update_list = list(update_fields) if update_fields is not None else None
257
+ if not self.manager_node_id:
258
+ local_node = Node.get_local()
259
+ if local_node:
260
+ self.manager_node = local_node
261
+ if update_list is not None and "manager_node" not in update_list:
262
+ update_list.append("manager_node")
263
+ if not self.location_id:
264
+ existing = (
265
+ type(self)
266
+ .objects.filter(charger_id=self.charger_id, location__isnull=False)
267
+ .exclude(pk=self.pk)
268
+ .select_related("location")
269
+ .first()
270
+ )
271
+ if existing:
272
+ self.location = existing.location
273
+ else:
274
+ location, _ = Location.objects.get_or_create(name=self.charger_id)
275
+ self.location = location
276
+ if update_list is not None and "location" not in update_list:
277
+ update_list.append("location")
278
+ if update_list is not None:
279
+ kwargs["update_fields"] = update_list
79
280
  super().save(*args, **kwargs)
80
281
  ref_value = self._full_url()
81
282
  if not self.reference or self.reference.value != ref_value:
82
- ref, _ = Reference.objects.get_or_create(
83
- value=ref_value, defaults={"alt_text": self.charger_id}
283
+ self.reference = Reference.objects.create(
284
+ value=ref_value, alt_text=self.charger_id
84
285
  )
85
- self.reference = ref
86
286
  super().save(update_fields=["reference"])
87
287
 
288
+ def refresh_manager_node(self, node: Node | None = None) -> Node | None:
289
+ """Ensure ``manager_node`` matches the provided or local node."""
290
+
291
+ node = node or Node.get_local()
292
+ if not node:
293
+ return None
294
+ if self.pk is None:
295
+ self.manager_node = node
296
+ return node
297
+ if self.manager_node_id != node.pk:
298
+ type(self).objects.filter(pk=self.pk).update(manager_node=node)
299
+ self.manager_node = node
300
+ return node
301
+
88
302
  @property
89
303
  def name(self) -> str:
90
304
  if self.location:
91
- return (
92
- f"{self.location.name} #{self.connector_id}"
93
- if self.connector_id
94
- else self.location.name
95
- )
305
+ if self.connector_id is not None:
306
+ return f"{self.location.name} #{self.connector_id}"
307
+ return self.location.name
96
308
  return ""
97
309
 
98
310
  @property
@@ -109,10 +321,49 @@ class Charger(Entity):
109
321
  from . import store
110
322
 
111
323
  total = 0.0
112
- tx_active = store.transactions.get(self.charger_id)
324
+ for charger in self._target_chargers():
325
+ total += charger._total_kw_single(store)
326
+ return total
327
+
328
+ def _store_keys(self) -> list[str]:
329
+ """Return keys used for store lookups with fallbacks."""
330
+
331
+ from . import store
332
+
333
+ base = self.charger_id
334
+ connector = self.connector_id
335
+ keys: list[str] = []
336
+ keys.append(store.identity_key(base, connector))
337
+ if connector is not None:
338
+ keys.append(store.identity_key(base, None))
339
+ keys.append(store.pending_key(base))
340
+ keys.append(base)
341
+ seen: set[str] = set()
342
+ deduped: list[str] = []
343
+ for key in keys:
344
+ if key not in seen:
345
+ seen.add(key)
346
+ deduped.append(key)
347
+ return deduped
348
+
349
+ def _target_chargers(self):
350
+ """Return chargers contributing to aggregate operations."""
351
+
352
+ qs = type(self).objects.filter(charger_id=self.charger_id)
353
+ if self.connector_id is None:
354
+ return qs
355
+ return qs.filter(pk=self.pk)
356
+
357
+ def _total_kw_single(self, store_module) -> float:
358
+ """Return total kW for this specific charger identity."""
359
+
360
+ tx_active = None
361
+ if self.connector_id is not None:
362
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
113
363
  qs = self.transactions.all()
114
364
  if tx_active and tx_active.pk is not None:
115
365
  qs = qs.exclude(pk=tx_active.pk)
366
+ total = 0.0
116
367
  for tx in qs:
117
368
  kw = tx.kw
118
369
  if kw:
@@ -126,24 +377,31 @@ class Charger(Entity):
126
377
  def purge(self):
127
378
  from . import store
128
379
 
129
- self.transactions.all().delete()
130
- self.meter_readings.all().delete()
131
- store.clear_log(self.charger_id, log_type="charger")
132
- store.transactions.pop(self.charger_id, None)
133
- store.history.pop(self.charger_id, None)
380
+ for charger in self._target_chargers():
381
+ charger.transactions.all().delete()
382
+ charger.meter_values.all().delete()
383
+ for key in charger._store_keys():
384
+ store.clear_log(key, log_type="charger")
385
+ store.transactions.pop(key, None)
386
+ store.history.pop(key, None)
134
387
 
135
388
  def delete(self, *args, **kwargs):
136
389
  from django.db.models.deletion import ProtectedError
137
390
  from . import store
138
391
 
139
- if (
140
- self.transactions.exists()
141
- or self.meter_readings.exists()
142
- or store.get_logs(self.charger_id, log_type="charger")
143
- or store.transactions.get(self.charger_id)
144
- or store.history.get(self.charger_id)
145
- ):
146
- raise ProtectedError("Purge data before deleting charger.", [])
392
+ for charger in self._target_chargers():
393
+ has_data = (
394
+ charger.transactions.exists()
395
+ or charger.meter_values.exists()
396
+ or any(
397
+ store.get_logs(key, log_type="charger")
398
+ for key in charger._store_keys()
399
+ )
400
+ or any(store.transactions.get(key) for key in charger._store_keys())
401
+ or any(store.history.get(key) for key in charger._store_keys())
402
+ )
403
+ if has_data:
404
+ raise ProtectedError("Purge data before deleting charger.", [])
147
405
  super().delete(*args, **kwargs)
148
406
 
149
407
 
@@ -162,8 +420,39 @@ class Transaction(Entity):
162
420
  verbose_name=_("RFID"),
163
421
  )
164
422
  vin = models.CharField(max_length=17, blank=True)
423
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
165
424
  meter_start = models.IntegerField(null=True, blank=True)
166
425
  meter_stop = models.IntegerField(null=True, blank=True)
426
+ voltage_start = models.DecimalField(
427
+ max_digits=12, decimal_places=3, null=True, blank=True
428
+ )
429
+ voltage_stop = models.DecimalField(
430
+ max_digits=12, decimal_places=3, null=True, blank=True
431
+ )
432
+ current_import_start = models.DecimalField(
433
+ max_digits=12, decimal_places=3, null=True, blank=True
434
+ )
435
+ current_import_stop = models.DecimalField(
436
+ max_digits=12, decimal_places=3, null=True, blank=True
437
+ )
438
+ current_offered_start = models.DecimalField(
439
+ max_digits=12, decimal_places=3, null=True, blank=True
440
+ )
441
+ current_offered_stop = models.DecimalField(
442
+ max_digits=12, decimal_places=3, null=True, blank=True
443
+ )
444
+ temperature_start = models.DecimalField(
445
+ max_digits=12, decimal_places=3, null=True, blank=True
446
+ )
447
+ temperature_stop = models.DecimalField(
448
+ max_digits=12, decimal_places=3, null=True, blank=True
449
+ )
450
+ soc_start = models.DecimalField(
451
+ max_digits=12, decimal_places=3, null=True, blank=True
452
+ )
453
+ soc_stop = models.DecimalField(
454
+ max_digits=12, decimal_places=3, null=True, blank=True
455
+ )
167
456
  start_time = models.DateTimeField()
168
457
  stop_time = models.DateTimeField(null=True, blank=True)
169
458
 
@@ -177,71 +466,140 @@ class Transaction(Entity):
177
466
  @property
178
467
  def kw(self) -> float:
179
468
  """Return consumed energy in kW for this session."""
180
- total = 0.0
181
- qs = self.meter_readings.filter(
182
- measurand__in=["", "Energy.Active.Import.Register"]
183
- ).order_by("timestamp")
184
- first = True
185
- for reading in qs:
186
- try:
187
- val = float(reading.value)
188
- except (TypeError, ValueError): # pragma: no cover - unexpected
189
- continue
190
- if reading.unit != "kW":
191
- val = val / 1000.0
192
- if first and self.meter_start is not None:
193
- total += val - (self.meter_start / 1000.0)
194
- first = False
195
- else:
196
- total += val
197
- first = False
198
- if total == 0 and self.meter_start is not None and self.meter_stop is not None:
199
- total = (self.meter_stop - self.meter_start) / 1000.0
200
- if total < 0:
469
+ start_val = None
470
+ if self.meter_start is not None:
471
+ start_val = float(self.meter_start) / 1000.0
472
+
473
+ end_val = None
474
+ if self.meter_stop is not None:
475
+ end_val = float(self.meter_stop) / 1000.0
476
+
477
+ readings = list(
478
+ self.meter_values.filter(energy__isnull=False).order_by("timestamp")
479
+ )
480
+ if readings:
481
+ if start_val is None:
482
+ start_val = float(readings[0].energy or 0)
483
+ # Always use the latest available reading for the end value when a
484
+ # stop meter has not been recorded yet. This allows active
485
+ # transactions to report totals using their most recent reading.
486
+ if end_val is None:
487
+ end_val = float(readings[-1].energy or 0)
488
+
489
+ if start_val is None or end_val is None:
201
490
  return 0.0
202
- return total
491
+
492
+ total = end_val - start_val
493
+ return max(total, 0.0)
203
494
 
204
495
 
205
- class MeterReading(Entity):
496
+ class MeterValue(Entity):
206
497
  """Parsed meter values reported by chargers."""
207
498
 
208
499
  charger = models.ForeignKey(
209
- Charger, on_delete=models.CASCADE, related_name="meter_readings"
500
+ Charger, on_delete=models.CASCADE, related_name="meter_values"
210
501
  )
211
- connector_id = models.IntegerField(null=True, blank=True)
502
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
212
503
  transaction = models.ForeignKey(
213
504
  Transaction,
214
505
  on_delete=models.CASCADE,
215
- related_name="meter_readings",
506
+ related_name="meter_values",
216
507
  null=True,
217
508
  blank=True,
218
509
  )
219
510
  timestamp = models.DateTimeField()
220
- measurand = models.CharField(max_length=100, blank=True)
221
- value = models.DecimalField(max_digits=12, decimal_places=3)
222
- unit = models.CharField(max_length=16, blank=True)
511
+ context = models.CharField(max_length=32, blank=True)
512
+ energy = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
513
+ voltage = models.DecimalField(
514
+ max_digits=12, decimal_places=3, null=True, blank=True
515
+ )
516
+ current_import = models.DecimalField(
517
+ max_digits=12, decimal_places=3, null=True, blank=True
518
+ )
519
+ current_offered = models.DecimalField(
520
+ max_digits=12, decimal_places=3, null=True, blank=True
521
+ )
522
+ temperature = models.DecimalField(
523
+ max_digits=12, decimal_places=3, null=True, blank=True
524
+ )
525
+ soc = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
223
526
 
224
527
  def __str__(self) -> str: # pragma: no cover - simple representation
225
- return f"{self.charger} {self.measurand} {self.value}{self.unit}".strip()
528
+ return f"{self.charger} {self.timestamp}"
529
+
530
+ @property
531
+ def value(self):
532
+ return self.energy
533
+
534
+ @value.setter
535
+ def value(self, new_value):
536
+ self.energy = new_value
537
+
538
+ class Meta:
539
+ verbose_name = _("Meter Value")
540
+ verbose_name_plural = _("Meter Values")
541
+
542
+
543
+ class MeterReadingManager(EntityManager):
544
+ def _normalize_kwargs(self, kwargs: dict) -> dict:
545
+ normalized = dict(kwargs)
546
+ value = normalized.pop("value", None)
547
+ unit = normalized.pop("unit", None)
548
+ if value is not None:
549
+ energy = value
550
+ try:
551
+ energy = Decimal(value)
552
+ except (InvalidOperation, TypeError, ValueError):
553
+ energy = None
554
+ if energy is not None:
555
+ unit_normalized = (unit or "").lower()
556
+ if unit_normalized in {"w", "wh"}:
557
+ energy = energy / Decimal("1000")
558
+ normalized.setdefault("energy", energy)
559
+ normalized.pop("measurand", None)
560
+ return normalized
561
+
562
+ def create(self, **kwargs):
563
+ return super().create(**self._normalize_kwargs(kwargs))
564
+
565
+ def get_or_create(self, defaults=None, **kwargs):
566
+ if defaults:
567
+ defaults = self._normalize_kwargs(defaults)
568
+ return super().get_or_create(
569
+ defaults=defaults, **self._normalize_kwargs(kwargs)
570
+ )
571
+
572
+
573
+ class MeterReading(MeterValue):
574
+ """Proxy model for backwards compatibility."""
575
+
576
+ objects = MeterReadingManager()
226
577
 
227
578
  class Meta:
228
- verbose_name = _("Meter Reading")
229
- verbose_name_plural = _("Meter Readings")
579
+ proxy = True
580
+ verbose_name = _("Meter Value")
581
+ verbose_name_plural = _("Meter Values")
230
582
 
231
583
 
232
584
  class Simulator(Entity):
233
585
  """Preconfigured simulator that can be started from the admin."""
234
586
 
235
587
  name = models.CharField(max_length=100, unique=True)
236
- cp_path = models.CharField(_("CP Path"), max_length=100)
588
+ cp_path = models.CharField(
589
+ _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
590
+ )
237
591
  host = models.CharField(max_length=100, default="127.0.0.1")
238
- ws_port = models.IntegerField(_("WS Port"), default=8000)
592
+ ws_port = models.IntegerField(
593
+ _("WS Port"), default=8000, null=True, blank=True
594
+ )
239
595
  rfid = models.CharField(
240
596
  max_length=255,
241
597
  default="FFFFFFFF",
242
598
  verbose_name=_("RFID"),
243
599
  )
244
600
  vin = models.CharField(max_length=17, blank=True)
601
+ serial_number = models.CharField(_("Serial Number"), max_length=100, blank=True)
602
+ connector_id = models.IntegerField(_("Connector ID"), default=1)
245
603
  duration = models.IntegerField(default=600)
246
604
  interval = models.FloatField(default=5.0)
247
605
  pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
@@ -249,6 +607,11 @@ class Simulator(Entity):
249
607
  repeat = models.BooleanField(default=False)
250
608
  username = models.CharField(max_length=100, blank=True)
251
609
  password = models.CharField(max_length=100, blank=True)
610
+ door_open = models.BooleanField(
611
+ _("Door Open"),
612
+ default=False,
613
+ help_text=_("Send a DoorOpen error StatusNotification when enabled."),
614
+ )
252
615
 
253
616
  def __str__(self) -> str: # pragma: no cover - simple representation
254
617
  return self.name
@@ -266,6 +629,8 @@ class Simulator(Entity):
266
629
  rfid=self.rfid,
267
630
  vin=self.vin,
268
631
  cp_path=self.cp_path,
632
+ serial_number=self.serial_number,
633
+ connector_id=self.connector_id,
269
634
  duration=self.duration,
270
635
  interval=self.interval,
271
636
  pre_charge_delay=self.pre_charge_delay,
@@ -280,7 +645,9 @@ class Simulator(Entity):
280
645
  path = self.cp_path
281
646
  if not path.endswith("/"):
282
647
  path += "/"
283
- return f"ws://{self.host}:{self.ws_port}/{path}"
648
+ if self.ws_port:
649
+ return f"ws://{self.host}:{self.ws_port}/{path}"
650
+ return f"ws://{self.host}/{path}"
284
651
 
285
652
 
286
653
  class RFID(CoreRFID):
@@ -298,3 +665,18 @@ class ElectricVehicle(CoreElectricVehicle):
298
665
  verbose_name = _("Electric Vehicle")
299
666
  verbose_name_plural = _("Electric Vehicles")
300
667
 
668
+
669
+ class Brand(CoreBrand):
670
+ class Meta:
671
+ proxy = True
672
+ app_label = "ocpp"
673
+ verbose_name = CoreBrand._meta.verbose_name
674
+ verbose_name_plural = CoreBrand._meta.verbose_name_plural
675
+
676
+
677
+ class EVModel(CoreEVModel):
678
+ class Meta:
679
+ proxy = True
680
+ app_label = "ocpp"
681
+ verbose_name = CoreEVModel._meta.verbose_name
682
+ verbose_name_plural = CoreEVModel._meta.verbose_name_plural