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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {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(
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
value=ref_value,
|
|
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
|
-
|
|
92
|
-
f"{self.location.name} #{self.connector_id}"
|
|
93
|
-
|
|
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
|
-
|
|
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.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if
|
|
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
|
-
|
|
491
|
+
|
|
492
|
+
total = end_val - start_val
|
|
493
|
+
return max(total, 0.0)
|
|
203
494
|
|
|
204
495
|
|
|
205
|
-
class
|
|
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="
|
|
500
|
+
Charger, on_delete=models.CASCADE, related_name="meter_values"
|
|
210
501
|
)
|
|
211
|
-
connector_id = models.
|
|
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="
|
|
506
|
+
related_name="meter_values",
|
|
216
507
|
null=True,
|
|
217
508
|
blank=True,
|
|
218
509
|
)
|
|
219
510
|
timestamp = models.DateTimeField()
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
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
|
-
|
|
229
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|