arthexis 0.1.11__py3-none-any.whl → 0.1.12__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.11.dist-info → arthexis-0.1.12.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/RECORD +38 -35
- config/settings.py +7 -2
- core/admin.py +246 -68
- core/apps.py +21 -0
- core/models.py +41 -8
- core/reference_utils.py +1 -1
- core/release.py +4 -0
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +64 -0
- core/views.py +131 -17
- nodes/admin.py +316 -6
- nodes/feature_checks.py +133 -0
- nodes/models.py +83 -26
- nodes/reports.py +411 -0
- nodes/tests.py +365 -36
- nodes/utils.py +32 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +506 -8
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +234 -4
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/tests.py +789 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +225 -19
- pages/admin.py +135 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +38 -0
- pages/models.py +136 -1
- pages/tests.py +262 -4
- pages/urls.py +1 -0
- pages/views.py +52 -3
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
ocpp/evcs_discovery.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from ipaddress import IPv4Address
|
|
6
|
+
from typing import Iterable, Sequence
|
|
7
|
+
|
|
8
|
+
DEFAULT_TOP_PORTS = 200
|
|
9
|
+
DEFAULT_CONSOLE_PORT = 8900
|
|
10
|
+
PORT_PREFERENCES: Sequence[int] = (
|
|
11
|
+
DEFAULT_CONSOLE_PORT,
|
|
12
|
+
8443,
|
|
13
|
+
9443,
|
|
14
|
+
443,
|
|
15
|
+
8080,
|
|
16
|
+
8800,
|
|
17
|
+
8000,
|
|
18
|
+
8001,
|
|
19
|
+
8880,
|
|
20
|
+
80,
|
|
21
|
+
)
|
|
22
|
+
HTTPS_PORTS = {443, 8443, 9443}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ConsoleEndpoint:
|
|
27
|
+
host: str
|
|
28
|
+
port: int
|
|
29
|
+
secure: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def url(self) -> str:
|
|
33
|
+
return build_console_url(self.host, self.port, self.secure)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scan_open_ports(
|
|
37
|
+
host: str,
|
|
38
|
+
*,
|
|
39
|
+
nmap_path: str = "nmap",
|
|
40
|
+
full: bool = False,
|
|
41
|
+
top_ports: int = DEFAULT_TOP_PORTS,
|
|
42
|
+
) -> list[int]:
|
|
43
|
+
"""Return the list of open TCP ports discovered with nmap.
|
|
44
|
+
|
|
45
|
+
The function mirrors the behaviour of the ``evcs_discover`` shell script.
|
|
46
|
+
It uses nmap's grepable output so the caller can avoid touching the
|
|
47
|
+
filesystem and parse the results quickly.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
port_args: list[str]
|
|
51
|
+
if full:
|
|
52
|
+
port_args = ["-p-"]
|
|
53
|
+
else:
|
|
54
|
+
if top_ports <= 0:
|
|
55
|
+
raise ValueError("top_ports must be greater than zero")
|
|
56
|
+
port_args = ["--top-ports", str(top_ports)]
|
|
57
|
+
|
|
58
|
+
args = [
|
|
59
|
+
nmap_path,
|
|
60
|
+
"-sS",
|
|
61
|
+
"-Pn",
|
|
62
|
+
"-n",
|
|
63
|
+
"-T4",
|
|
64
|
+
"--open",
|
|
65
|
+
*port_args,
|
|
66
|
+
host,
|
|
67
|
+
"-oG",
|
|
68
|
+
"-",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run(
|
|
73
|
+
args,
|
|
74
|
+
check=False,
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return []
|
|
80
|
+
except subprocess.SubprocessError:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
if proc.returncode != 0:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
return _parse_nmap_open_ports(proc.stdout)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_nmap_open_ports(output: str) -> list[int]:
|
|
90
|
+
ports: list[int] = []
|
|
91
|
+
for line in output.splitlines():
|
|
92
|
+
if "Ports:" not in line:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
_, ports_section = line.split("Ports:", 1)
|
|
96
|
+
except ValueError:
|
|
97
|
+
continue
|
|
98
|
+
for entry in ports_section.split(","):
|
|
99
|
+
entry = entry.strip()
|
|
100
|
+
if not entry:
|
|
101
|
+
continue
|
|
102
|
+
parts = entry.split("/")
|
|
103
|
+
if len(parts) < 2:
|
|
104
|
+
continue
|
|
105
|
+
state = parts[1].strip().lower()
|
|
106
|
+
if state != "open":
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
port = int(parts[0])
|
|
110
|
+
except ValueError:
|
|
111
|
+
continue
|
|
112
|
+
if port not in ports:
|
|
113
|
+
ports.append(port)
|
|
114
|
+
return ports
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def prioritise_ports(ports: Iterable[int]) -> list[int]:
|
|
118
|
+
"""Order ports so the most likely console endpoints are tried first."""
|
|
119
|
+
|
|
120
|
+
unique = []
|
|
121
|
+
seen = set()
|
|
122
|
+
available = list(dict.fromkeys(ports))
|
|
123
|
+
for preferred in PORT_PREFERENCES:
|
|
124
|
+
if preferred in available and preferred not in seen:
|
|
125
|
+
unique.append(preferred)
|
|
126
|
+
seen.add(preferred)
|
|
127
|
+
for port in available:
|
|
128
|
+
if port not in seen:
|
|
129
|
+
unique.append(port)
|
|
130
|
+
seen.add(port)
|
|
131
|
+
return unique
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def select_console_port(ports: Sequence[int]) -> ConsoleEndpoint | None:
|
|
135
|
+
"""Pick the best console port from a list of open ports."""
|
|
136
|
+
|
|
137
|
+
if not ports:
|
|
138
|
+
return None
|
|
139
|
+
ordered = prioritise_ports(ports)
|
|
140
|
+
if not ordered:
|
|
141
|
+
return None
|
|
142
|
+
port = ordered[0]
|
|
143
|
+
secure = port in HTTPS_PORTS
|
|
144
|
+
return ConsoleEndpoint(host="", port=port, secure=secure)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_console_url(host: str, port: int, secure: bool) -> str:
|
|
148
|
+
scheme = "https" if secure else "http"
|
|
149
|
+
host_part = host
|
|
150
|
+
if ":" in host and not host.startswith("["):
|
|
151
|
+
host_part = f"[{host}]"
|
|
152
|
+
return f"{scheme}://{host_part}:{port}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def normalise_host(host: str | IPv4Address) -> str:
|
|
156
|
+
if isinstance(host, IPv4Address):
|
|
157
|
+
return str(host)
|
|
158
|
+
return str(host)
|
ocpp/models.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import re
|
|
1
2
|
import socket
|
|
2
3
|
from decimal import Decimal, InvalidOperation
|
|
3
4
|
|
|
4
5
|
from django.conf import settings
|
|
5
6
|
from django.contrib.sites.models import Site
|
|
6
7
|
from django.db import models
|
|
8
|
+
from django.db.models import Q
|
|
9
|
+
from django.core.exceptions import ValidationError
|
|
7
10
|
from django.urls import reverse
|
|
8
11
|
from django.utils.translation import gettext_lazy as _
|
|
9
12
|
|
|
@@ -17,6 +20,7 @@ from core.models import (
|
|
|
17
20
|
ElectricVehicle as CoreElectricVehicle,
|
|
18
21
|
Brand as CoreBrand,
|
|
19
22
|
EVModel as CoreEVModel,
|
|
23
|
+
SecurityGroup,
|
|
20
24
|
)
|
|
21
25
|
from .reference_utils import url_targets_local_loopback
|
|
22
26
|
|
|
@@ -43,6 +47,19 @@ class Location(Entity):
|
|
|
43
47
|
class Charger(Entity):
|
|
44
48
|
"""Known charge point."""
|
|
45
49
|
|
|
50
|
+
_PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
|
|
51
|
+
|
|
52
|
+
OPERATIVE_STATUSES = {
|
|
53
|
+
"Available",
|
|
54
|
+
"Preparing",
|
|
55
|
+
"Charging",
|
|
56
|
+
"SuspendedEV",
|
|
57
|
+
"SuspendedEVSE",
|
|
58
|
+
"Finishing",
|
|
59
|
+
"Reserved",
|
|
60
|
+
}
|
|
61
|
+
INOPERATIVE_STATUSES = {"Unavailable", "Faulted"}
|
|
62
|
+
|
|
46
63
|
charger_id = models.CharField(
|
|
47
64
|
_("Serial Number"),
|
|
48
65
|
max_length=100,
|
|
@@ -61,7 +78,7 @@ class Charger(Entity):
|
|
|
61
78
|
help_text="Optional connector identifier for multi-connector chargers.",
|
|
62
79
|
)
|
|
63
80
|
public_display = models.BooleanField(
|
|
64
|
-
_("
|
|
81
|
+
_("Public"),
|
|
65
82
|
default=True,
|
|
66
83
|
help_text="Display this charger on the public status dashboard.",
|
|
67
84
|
)
|
|
@@ -71,21 +88,21 @@ class Charger(Entity):
|
|
|
71
88
|
help_text="Require a valid RFID before starting a charging session.",
|
|
72
89
|
)
|
|
73
90
|
firmware_status = models.CharField(
|
|
74
|
-
_("
|
|
91
|
+
_("Status"),
|
|
75
92
|
max_length=32,
|
|
76
93
|
blank=True,
|
|
77
94
|
default="",
|
|
78
95
|
help_text="Latest firmware status reported by the charger.",
|
|
79
96
|
)
|
|
80
97
|
firmware_status_info = models.CharField(
|
|
81
|
-
_("
|
|
98
|
+
_("Status Details"),
|
|
82
99
|
max_length=255,
|
|
83
100
|
blank=True,
|
|
84
101
|
default="",
|
|
85
102
|
help_text="Additional information supplied with the firmware status.",
|
|
86
103
|
)
|
|
87
104
|
firmware_timestamp = models.DateTimeField(
|
|
88
|
-
_("
|
|
105
|
+
_("Status Timestamp"),
|
|
89
106
|
null=True,
|
|
90
107
|
blank=True,
|
|
91
108
|
help_text="When the charger reported the current firmware status.",
|
|
@@ -96,6 +113,58 @@ class Charger(Entity):
|
|
|
96
113
|
last_error_code = models.CharField(max_length=64, blank=True)
|
|
97
114
|
last_status_vendor_info = models.JSONField(null=True, blank=True)
|
|
98
115
|
last_status_timestamp = models.DateTimeField(null=True, blank=True)
|
|
116
|
+
availability_state = models.CharField(
|
|
117
|
+
_("State"),
|
|
118
|
+
max_length=16,
|
|
119
|
+
blank=True,
|
|
120
|
+
default="",
|
|
121
|
+
help_text=(
|
|
122
|
+
"Current availability reported by the charger "
|
|
123
|
+
"(Operative/Inoperative)."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
availability_state_updated_at = models.DateTimeField(
|
|
127
|
+
_("State Updated At"),
|
|
128
|
+
null=True,
|
|
129
|
+
blank=True,
|
|
130
|
+
help_text="When the current availability state became effective.",
|
|
131
|
+
)
|
|
132
|
+
availability_requested_state = models.CharField(
|
|
133
|
+
_("Requested State"),
|
|
134
|
+
max_length=16,
|
|
135
|
+
blank=True,
|
|
136
|
+
default="",
|
|
137
|
+
help_text="Last availability state requested via ChangeAvailability.",
|
|
138
|
+
)
|
|
139
|
+
availability_requested_at = models.DateTimeField(
|
|
140
|
+
_("Requested At"),
|
|
141
|
+
null=True,
|
|
142
|
+
blank=True,
|
|
143
|
+
help_text="When the last ChangeAvailability request was sent.",
|
|
144
|
+
)
|
|
145
|
+
availability_request_status = models.CharField(
|
|
146
|
+
_("Request Status"),
|
|
147
|
+
max_length=16,
|
|
148
|
+
blank=True,
|
|
149
|
+
default="",
|
|
150
|
+
help_text=(
|
|
151
|
+
"Latest response status for ChangeAvailability "
|
|
152
|
+
"(Accepted/Rejected/Scheduled)."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
availability_request_status_at = models.DateTimeField(
|
|
156
|
+
_("Request Status At"),
|
|
157
|
+
null=True,
|
|
158
|
+
blank=True,
|
|
159
|
+
help_text="When the last ChangeAvailability response was received.",
|
|
160
|
+
)
|
|
161
|
+
availability_request_details = models.CharField(
|
|
162
|
+
_("Request Details"),
|
|
163
|
+
max_length=255,
|
|
164
|
+
blank=True,
|
|
165
|
+
default="",
|
|
166
|
+
help_text="Additional details from the last ChangeAvailability response.",
|
|
167
|
+
)
|
|
99
168
|
temperature = models.DecimalField(
|
|
100
169
|
max_digits=5, decimal_places=1, null=True, blank=True
|
|
101
170
|
)
|
|
@@ -135,10 +204,60 @@ class Charger(Entity):
|
|
|
135
204
|
blank=True,
|
|
136
205
|
related_name="managed_chargers",
|
|
137
206
|
)
|
|
207
|
+
owner_users = models.ManyToManyField(
|
|
208
|
+
settings.AUTH_USER_MODEL,
|
|
209
|
+
blank=True,
|
|
210
|
+
related_name="owned_chargers",
|
|
211
|
+
help_text=_("Users who can view this charge point."),
|
|
212
|
+
)
|
|
213
|
+
owner_groups = models.ManyToManyField(
|
|
214
|
+
SecurityGroup,
|
|
215
|
+
blank=True,
|
|
216
|
+
related_name="owned_chargers",
|
|
217
|
+
help_text=_("Security groups that can view this charge point."),
|
|
218
|
+
)
|
|
138
219
|
|
|
139
220
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
140
221
|
return self.charger_id
|
|
141
222
|
|
|
223
|
+
@classmethod
|
|
224
|
+
def visible_for_user(cls, user):
|
|
225
|
+
"""Return chargers marked for display that the user may view."""
|
|
226
|
+
|
|
227
|
+
qs = cls.objects.filter(public_display=True)
|
|
228
|
+
if getattr(user, "is_superuser", False):
|
|
229
|
+
return qs
|
|
230
|
+
if not getattr(user, "is_authenticated", False):
|
|
231
|
+
return qs.filter(
|
|
232
|
+
owner_users__isnull=True, owner_groups__isnull=True
|
|
233
|
+
).distinct()
|
|
234
|
+
group_ids = list(user.groups.values_list("pk", flat=True))
|
|
235
|
+
visibility = Q(owner_users__isnull=True, owner_groups__isnull=True) | Q(
|
|
236
|
+
owner_users=user
|
|
237
|
+
)
|
|
238
|
+
if group_ids:
|
|
239
|
+
visibility |= Q(owner_groups__pk__in=group_ids)
|
|
240
|
+
return qs.filter(visibility).distinct()
|
|
241
|
+
|
|
242
|
+
def has_owner_scope(self) -> bool:
|
|
243
|
+
"""Return ``True`` when owner restrictions are defined."""
|
|
244
|
+
|
|
245
|
+
return self.owner_users.exists() or self.owner_groups.exists()
|
|
246
|
+
|
|
247
|
+
def is_visible_to(self, user) -> bool:
|
|
248
|
+
"""Return ``True`` when ``user`` may view this charger."""
|
|
249
|
+
|
|
250
|
+
if getattr(user, "is_superuser", False):
|
|
251
|
+
return True
|
|
252
|
+
if not self.has_owner_scope():
|
|
253
|
+
return True
|
|
254
|
+
if not getattr(user, "is_authenticated", False):
|
|
255
|
+
return False
|
|
256
|
+
if self.owner_users.filter(pk=user.pk).exists():
|
|
257
|
+
return True
|
|
258
|
+
user_group_ids = user.groups.values_list("pk", flat=True)
|
|
259
|
+
return self.owner_groups.filter(pk__in=user_group_ids).exists()
|
|
260
|
+
|
|
142
261
|
class Meta:
|
|
143
262
|
verbose_name = _("Charge Point")
|
|
144
263
|
verbose_name_plural = _("Charge Points")
|
|
@@ -155,6 +274,39 @@ class Charger(Entity):
|
|
|
155
274
|
),
|
|
156
275
|
]
|
|
157
276
|
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def normalize_serial(cls, value: str | None) -> str:
|
|
280
|
+
"""Return ``value`` trimmed for consistent comparisons."""
|
|
281
|
+
|
|
282
|
+
if value is None:
|
|
283
|
+
return ""
|
|
284
|
+
return str(value).strip()
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def is_placeholder_serial(cls, value: str | None) -> bool:
|
|
288
|
+
"""Return ``True`` when ``value`` matches the placeholder pattern."""
|
|
289
|
+
|
|
290
|
+
normalized = cls.normalize_serial(value)
|
|
291
|
+
return bool(normalized) and bool(cls._PLACEHOLDER_SERIAL_RE.match(normalized))
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def validate_serial(cls, value: str | None) -> str:
|
|
295
|
+
"""Return a normalized serial number or raise ``ValidationError``."""
|
|
296
|
+
|
|
297
|
+
normalized = cls.normalize_serial(value)
|
|
298
|
+
if not normalized:
|
|
299
|
+
raise ValidationError({"charger_id": _("Serial Number cannot be blank.")})
|
|
300
|
+
if cls.is_placeholder_serial(normalized):
|
|
301
|
+
raise ValidationError(
|
|
302
|
+
{
|
|
303
|
+
"charger_id": _(
|
|
304
|
+
"Serial Number placeholder values such as <charger_id> are not allowed."
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
return normalized
|
|
309
|
+
|
|
158
310
|
AGGREGATE_CONNECTOR_SLUG = "all"
|
|
159
311
|
|
|
160
312
|
def identity_tuple(self) -> tuple[str, int | None]:
|
|
@@ -184,6 +336,19 @@ class Charger(Entity):
|
|
|
184
336
|
except (TypeError, ValueError) as exc:
|
|
185
337
|
raise ValueError(f"Invalid connector slug: {slug}") from exc
|
|
186
338
|
|
|
339
|
+
@classmethod
|
|
340
|
+
def availability_state_from_status(cls, status: str) -> str | None:
|
|
341
|
+
"""Return the availability state implied by a status notification."""
|
|
342
|
+
|
|
343
|
+
normalized = (status or "").strip()
|
|
344
|
+
if not normalized:
|
|
345
|
+
return None
|
|
346
|
+
if normalized in cls.INOPERATIVE_STATUSES:
|
|
347
|
+
return "Inoperative"
|
|
348
|
+
if normalized in cls.OPERATIVE_STATUSES:
|
|
349
|
+
return "Operative"
|
|
350
|
+
return None
|
|
351
|
+
|
|
187
352
|
@property
|
|
188
353
|
def connector_slug(self) -> str:
|
|
189
354
|
"""Return the slug representing this charger's connector."""
|
|
@@ -252,7 +417,12 @@ class Charger(Entity):
|
|
|
252
417
|
scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
|
|
253
418
|
return f"{scheme}://{domain}{self.get_absolute_url()}"
|
|
254
419
|
|
|
420
|
+
def clean(self):
|
|
421
|
+
super().clean()
|
|
422
|
+
self.charger_id = type(self).validate_serial(self.charger_id)
|
|
423
|
+
|
|
255
424
|
def save(self, *args, **kwargs):
|
|
425
|
+
self.charger_id = type(self).validate_serial(self.charger_id)
|
|
256
426
|
update_fields = kwargs.get("update_fields")
|
|
257
427
|
update_list = list(update_fields) if update_fields is not None else None
|
|
258
428
|
if not self.manager_node_id:
|
|
@@ -615,6 +785,18 @@ class Simulator(Entity):
|
|
|
615
785
|
default=False,
|
|
616
786
|
help_text=_("Send a DoorOpen error StatusNotification when enabled."),
|
|
617
787
|
)
|
|
788
|
+
configuration_keys = models.JSONField(
|
|
789
|
+
default=list,
|
|
790
|
+
blank=True,
|
|
791
|
+
help_text=_(
|
|
792
|
+
"List of configurationKey entries to return for GetConfiguration calls."
|
|
793
|
+
),
|
|
794
|
+
)
|
|
795
|
+
configuration_unknown_keys = models.JSONField(
|
|
796
|
+
default=list,
|
|
797
|
+
blank=True,
|
|
798
|
+
help_text=_("Keys to include in the GetConfiguration unknownKey response."),
|
|
799
|
+
)
|
|
618
800
|
|
|
619
801
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
620
802
|
return self.name
|
|
@@ -641,6 +823,8 @@ class Simulator(Entity):
|
|
|
641
823
|
repeat=self.repeat,
|
|
642
824
|
username=self.username or None,
|
|
643
825
|
password=self.password or None,
|
|
826
|
+
configuration_keys=self.configuration_keys or [],
|
|
827
|
+
configuration_unknown_keys=self.configuration_unknown_keys or [],
|
|
644
828
|
)
|
|
645
829
|
|
|
646
830
|
@property
|
|
@@ -653,6 +837,52 @@ class Simulator(Entity):
|
|
|
653
837
|
return f"ws://{self.host}/{path}"
|
|
654
838
|
|
|
655
839
|
|
|
840
|
+
class DataTransferMessage(models.Model):
|
|
841
|
+
"""Persisted record of OCPP DataTransfer exchanges."""
|
|
842
|
+
|
|
843
|
+
DIRECTION_CP_TO_CSMS = "cp_to_csms"
|
|
844
|
+
DIRECTION_CSMS_TO_CP = "csms_to_cp"
|
|
845
|
+
DIRECTION_CHOICES = (
|
|
846
|
+
(DIRECTION_CP_TO_CSMS, _("Charge Point → CSMS")),
|
|
847
|
+
(DIRECTION_CSMS_TO_CP, _("CSMS → Charge Point")),
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
charger = models.ForeignKey(
|
|
851
|
+
"Charger",
|
|
852
|
+
on_delete=models.CASCADE,
|
|
853
|
+
related_name="data_transfer_messages",
|
|
854
|
+
)
|
|
855
|
+
connector_id = models.PositiveIntegerField(null=True, blank=True)
|
|
856
|
+
direction = models.CharField(max_length=16, choices=DIRECTION_CHOICES)
|
|
857
|
+
ocpp_message_id = models.CharField(max_length=64)
|
|
858
|
+
vendor_id = models.CharField(max_length=255, blank=True)
|
|
859
|
+
message_id = models.CharField(max_length=255, blank=True)
|
|
860
|
+
payload = models.JSONField(default=dict, blank=True)
|
|
861
|
+
status = models.CharField(max_length=64, blank=True)
|
|
862
|
+
response_data = models.JSONField(null=True, blank=True)
|
|
863
|
+
error_code = models.CharField(max_length=64, blank=True)
|
|
864
|
+
error_description = models.TextField(blank=True)
|
|
865
|
+
error_details = models.JSONField(null=True, blank=True)
|
|
866
|
+
responded_at = models.DateTimeField(null=True, blank=True)
|
|
867
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
868
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
869
|
+
|
|
870
|
+
class Meta:
|
|
871
|
+
ordering = ["-created_at"]
|
|
872
|
+
indexes = [
|
|
873
|
+
models.Index(
|
|
874
|
+
fields=["ocpp_message_id"],
|
|
875
|
+
name="ocpp_datatr_ocpp_me_70d17f_idx",
|
|
876
|
+
),
|
|
877
|
+
models.Index(
|
|
878
|
+
fields=["vendor_id"], name="ocpp_datatr_vendor__59e1c7_idx"
|
|
879
|
+
),
|
|
880
|
+
]
|
|
881
|
+
|
|
882
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
883
|
+
return f"{self.get_direction_display()} {self.vendor_id or 'DataTransfer'}"
|
|
884
|
+
|
|
885
|
+
|
|
656
886
|
class RFID(CoreRFID):
|
|
657
887
|
class Meta:
|
|
658
888
|
proxy = True
|