arthexis 0.1.7__py3-none-any.whl → 0.1.9__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 (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
ocpp/models.py CHANGED
@@ -1,15 +1,21 @@
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
+
8
12
  from core.models import (
9
13
  EnergyAccount,
10
14
  Reference,
11
15
  RFID as CoreRFID,
12
16
  ElectricVehicle as CoreElectricVehicle,
17
+ Brand as CoreBrand,
18
+ EVModel as CoreEVModel,
13
19
  )
14
20
 
15
21
 
@@ -17,8 +23,12 @@ class Location(Entity):
17
23
  """Physical location shared by chargers."""
18
24
 
19
25
  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)
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
+ )
22
32
 
23
33
  def __str__(self) -> str: # pragma: no cover - simple representation
24
34
  return self.name
@@ -34,12 +44,16 @@ class Charger(Entity):
34
44
  charger_id = models.CharField(
35
45
  _("Serial Number"),
36
46
  max_length=100,
37
- unique=True,
38
47
  help_text="Unique identifier reported by the charger.",
39
48
  )
40
- connector_id = models.CharField(
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(
41
56
  _("Connector ID"),
42
- max_length=10,
43
57
  blank=True,
44
58
  null=True,
45
59
  help_text="Optional connector identifier for multi-connector chargers.",
@@ -49,13 +63,62 @@ class Charger(Entity):
49
63
  default=False,
50
64
  help_text="Require a valid RFID before starting a charging session.",
51
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
+ )
52
86
  last_heartbeat = models.DateTimeField(null=True, blank=True)
53
87
  last_meter_values = models.JSONField(default=dict, blank=True)
54
- temperature = models.DecimalField(max_digits=5, decimal_places=1, null=True, 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
+ )
55
95
  temperature_unit = models.CharField(max_length=16, blank=True)
56
- reference = models.OneToOneField(Reference, null=True, blank=True, on_delete=models.SET_NULL)
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
+ )
57
116
  location = models.ForeignKey(
58
- Location, null=True, blank=True, on_delete=models.SET_NULL, related_name="chargers"
117
+ Location,
118
+ null=True,
119
+ blank=True,
120
+ on_delete=models.SET_NULL,
121
+ related_name="chargers",
59
122
  )
60
123
  last_path = models.CharField(max_length=255, blank=True)
61
124
 
@@ -65,34 +128,150 @@ class Charger(Entity):
65
128
  class Meta:
66
129
  verbose_name = _("Charge Point")
67
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)}"
68
200
 
69
201
  def get_absolute_url(self):
70
- return reverse("charger-page", args=[self.charger_id])
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"
71
226
 
72
227
  def _full_url(self) -> str:
73
228
  """Return absolute URL for the charger landing page."""
74
- domain = Site.objects.get_current().domain
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
+
75
238
  scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
76
239
  return f"{scheme}://{domain}{self.get_absolute_url()}"
77
240
 
78
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
79
261
  super().save(*args, **kwargs)
80
262
  ref_value = self._full_url()
81
263
  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}
264
+ self.reference = Reference.objects.create(
265
+ value=ref_value, alt_text=self.charger_id
84
266
  )
85
- self.reference = ref
86
267
  super().save(update_fields=["reference"])
87
268
 
88
269
  @property
89
270
  def name(self) -> str:
90
271
  if self.location:
91
- return (
92
- f"{self.location.name} #{self.connector_id}"
93
- if self.connector_id
94
- else self.location.name
95
- )
272
+ if self.connector_id is not None:
273
+ return f"{self.location.name} #{self.connector_id}"
274
+ return self.location.name
96
275
  return ""
97
276
 
98
277
  @property
@@ -109,10 +288,49 @@ class Charger(Entity):
109
288
  from . import store
110
289
 
111
290
  total = 0.0
112
- tx_active = store.transactions.get(self.charger_id)
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)
113
330
  qs = self.transactions.all()
114
331
  if tx_active and tx_active.pk is not None:
115
332
  qs = qs.exclude(pk=tx_active.pk)
333
+ total = 0.0
116
334
  for tx in qs:
117
335
  kw = tx.kw
118
336
  if kw:
@@ -126,24 +344,31 @@ class Charger(Entity):
126
344
  def purge(self):
127
345
  from . import store
128
346
 
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)
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)
134
354
 
135
355
  def delete(self, *args, **kwargs):
136
356
  from django.db.models.deletion import ProtectedError
137
357
  from . import store
138
358
 
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.", [])
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.", [])
147
372
  super().delete(*args, **kwargs)
148
373
 
149
374
 
@@ -162,8 +387,39 @@ class Transaction(Entity):
162
387
  verbose_name=_("RFID"),
163
388
  )
164
389
  vin = models.CharField(max_length=17, blank=True)
390
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
165
391
  meter_start = models.IntegerField(null=True, blank=True)
166
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
+ )
167
423
  start_time = models.DateTimeField()
168
424
  stop_time = models.DateTimeField(null=True, blank=True)
169
425
 
@@ -177,63 +433,128 @@ class Transaction(Entity):
177
433
  @property
178
434
  def kw(self) -> float:
179
435
  """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:
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:
201
457
  return 0.0
202
- return total
203
458
 
459
+ total = end_val - start_val
460
+ return max(total, 0.0)
204
461
 
205
- class MeterReading(Entity):
462
+
463
+ class MeterValue(Entity):
206
464
  """Parsed meter values reported by chargers."""
207
465
 
208
466
  charger = models.ForeignKey(
209
- Charger, on_delete=models.CASCADE, related_name="meter_readings"
467
+ Charger, on_delete=models.CASCADE, related_name="meter_values"
210
468
  )
211
- connector_id = models.IntegerField(null=True, blank=True)
469
+ connector_id = models.PositiveIntegerField(null=True, blank=True)
212
470
  transaction = models.ForeignKey(
213
471
  Transaction,
214
472
  on_delete=models.CASCADE,
215
- related_name="meter_readings",
473
+ related_name="meter_values",
216
474
  null=True,
217
475
  blank=True,
218
476
  )
219
477
  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)
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)
223
493
 
224
494
  def __str__(self) -> str: # pragma: no cover - simple representation
225
- return f"{self.charger} {self.measurand} {self.value}{self.unit}".strip()
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()
226
544
 
227
545
  class Meta:
228
- verbose_name = _("Meter Reading")
229
- verbose_name_plural = _("Meter Readings")
546
+ proxy = True
547
+ verbose_name = _("Meter Value")
548
+ verbose_name_plural = _("Meter Values")
230
549
 
231
550
 
232
551
  class Simulator(Entity):
233
552
  """Preconfigured simulator that can be started from the admin."""
234
553
 
235
554
  name = models.CharField(max_length=100, unique=True)
236
- cp_path = models.CharField(_("CP Path"), max_length=100)
555
+ cp_path = models.CharField(
556
+ _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
557
+ )
237
558
  host = models.CharField(max_length=100, default="127.0.0.1")
238
559
  ws_port = models.IntegerField(_("WS Port"), default=8000)
239
560
  rfid = models.CharField(
@@ -242,6 +563,8 @@ class Simulator(Entity):
242
563
  verbose_name=_("RFID"),
243
564
  )
244
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)
245
568
  duration = models.IntegerField(default=600)
246
569
  interval = models.FloatField(default=5.0)
247
570
  pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
@@ -266,6 +589,8 @@ class Simulator(Entity):
266
589
  rfid=self.rfid,
267
590
  vin=self.vin,
268
591
  cp_path=self.cp_path,
592
+ serial_number=self.serial_number,
593
+ connector_id=self.connector_id,
269
594
  duration=self.duration,
270
595
  interval=self.interval,
271
596
  pre_charge_delay=self.pre_charge_delay,
@@ -298,3 +623,18 @@ class ElectricVehicle(CoreElectricVehicle):
298
623
  verbose_name = _("Electric Vehicle")
299
624
  verbose_name_plural = _("Electric Vehicles")
300
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
ocpp/simulator.py CHANGED
@@ -31,6 +31,8 @@ class SimulatorConfig:
31
31
  repeat: bool = False
32
32
  username: Optional[str] = None
33
33
  password: Optional[str] = None
34
+ serial_number: str = ""
35
+ connector_id: int = 1
34
36
 
35
37
 
36
38
  class ChargePointSimulator:
@@ -113,6 +115,7 @@ class ChargePointSimulator:
113
115
  {
114
116
  "chargePointModel": "Simulator",
115
117
  "chargePointVendor": "SimVendor",
118
+ "serialNumber": cfg.serial_number,
116
119
  },
117
120
  ]
118
121
  )
@@ -145,7 +148,7 @@ class ChargePointSimulator:
145
148
  "status",
146
149
  "StatusNotification",
147
150
  {
148
- "connectorId": 1,
151
+ "connectorId": cfg.connector_id,
149
152
  "errorCode": "NoError",
150
153
  "status": "Available",
151
154
  },
@@ -162,10 +165,12 @@ class ChargePointSimulator:
162
165
  "meter",
163
166
  "MeterValues",
164
167
  {
165
- "connectorId": 1,
168
+ "connectorId": cfg.connector_id,
166
169
  "meterValue": [
167
170
  {
168
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
171
+ "timestamp": time.strftime(
172
+ "%Y-%m-%dT%H:%M:%SZ"
173
+ ),
169
174
  "sampledValue": [
170
175
  {
171
176
  "value": "0",
@@ -190,7 +195,7 @@ class ChargePointSimulator:
190
195
  "start",
191
196
  "StartTransaction",
192
197
  {
193
- "connectorId": 1,
198
+ "connectorId": cfg.connector_id,
194
199
  "idTag": cfg.rfid,
195
200
  "meterStart": meter_start,
196
201
  "vin": cfg.vin,
@@ -224,11 +229,13 @@ class ChargePointSimulator:
224
229
  "meter",
225
230
  "MeterValues",
226
231
  {
227
- "connectorId": 1,
232
+ "connectorId": cfg.connector_id,
228
233
  "transactionId": tx_id,
229
234
  "meterValue": [
230
235
  {
231
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
236
+ "timestamp": time.strftime(
237
+ "%Y-%m-%dT%H:%M:%SZ"
238
+ ),
232
239
  "sampledValue": [
233
240
  {
234
241
  "value": f"{meter_kw:.3f}",