arthexis 0.1.10__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.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.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,7 +20,9 @@ from core.models import (
|
|
|
17
20
|
ElectricVehicle as CoreElectricVehicle,
|
|
18
21
|
Brand as CoreBrand,
|
|
19
22
|
EVModel as CoreEVModel,
|
|
23
|
+
SecurityGroup,
|
|
20
24
|
)
|
|
25
|
+
from .reference_utils import url_targets_local_loopback
|
|
21
26
|
|
|
22
27
|
|
|
23
28
|
class Location(Entity):
|
|
@@ -42,6 +47,19 @@ class Location(Entity):
|
|
|
42
47
|
class Charger(Entity):
|
|
43
48
|
"""Known charge point."""
|
|
44
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
|
+
|
|
45
63
|
charger_id = models.CharField(
|
|
46
64
|
_("Serial Number"),
|
|
47
65
|
max_length=100,
|
|
@@ -60,7 +78,7 @@ class Charger(Entity):
|
|
|
60
78
|
help_text="Optional connector identifier for multi-connector chargers.",
|
|
61
79
|
)
|
|
62
80
|
public_display = models.BooleanField(
|
|
63
|
-
_("
|
|
81
|
+
_("Public"),
|
|
64
82
|
default=True,
|
|
65
83
|
help_text="Display this charger on the public status dashboard.",
|
|
66
84
|
)
|
|
@@ -70,21 +88,21 @@ class Charger(Entity):
|
|
|
70
88
|
help_text="Require a valid RFID before starting a charging session.",
|
|
71
89
|
)
|
|
72
90
|
firmware_status = models.CharField(
|
|
73
|
-
_("
|
|
91
|
+
_("Status"),
|
|
74
92
|
max_length=32,
|
|
75
93
|
blank=True,
|
|
76
94
|
default="",
|
|
77
95
|
help_text="Latest firmware status reported by the charger.",
|
|
78
96
|
)
|
|
79
97
|
firmware_status_info = models.CharField(
|
|
80
|
-
_("
|
|
98
|
+
_("Status Details"),
|
|
81
99
|
max_length=255,
|
|
82
100
|
blank=True,
|
|
83
101
|
default="",
|
|
84
102
|
help_text="Additional information supplied with the firmware status.",
|
|
85
103
|
)
|
|
86
104
|
firmware_timestamp = models.DateTimeField(
|
|
87
|
-
_("
|
|
105
|
+
_("Status Timestamp"),
|
|
88
106
|
null=True,
|
|
89
107
|
blank=True,
|
|
90
108
|
help_text="When the charger reported the current firmware status.",
|
|
@@ -95,6 +113,58 @@ class Charger(Entity):
|
|
|
95
113
|
last_error_code = models.CharField(max_length=64, blank=True)
|
|
96
114
|
last_status_vendor_info = models.JSONField(null=True, blank=True)
|
|
97
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
|
+
)
|
|
98
168
|
temperature = models.DecimalField(
|
|
99
169
|
max_digits=5, decimal_places=1, null=True, blank=True
|
|
100
170
|
)
|
|
@@ -134,10 +204,60 @@ class Charger(Entity):
|
|
|
134
204
|
blank=True,
|
|
135
205
|
related_name="managed_chargers",
|
|
136
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
|
+
)
|
|
137
219
|
|
|
138
220
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
139
221
|
return self.charger_id
|
|
140
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
|
+
|
|
141
261
|
class Meta:
|
|
142
262
|
verbose_name = _("Charge Point")
|
|
143
263
|
verbose_name_plural = _("Charge Points")
|
|
@@ -154,6 +274,39 @@ class Charger(Entity):
|
|
|
154
274
|
),
|
|
155
275
|
]
|
|
156
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
|
+
|
|
157
310
|
AGGREGATE_CONNECTOR_SLUG = "all"
|
|
158
311
|
|
|
159
312
|
def identity_tuple(self) -> tuple[str, int | None]:
|
|
@@ -183,6 +336,19 @@ class Charger(Entity):
|
|
|
183
336
|
except (TypeError, ValueError) as exc:
|
|
184
337
|
raise ValueError(f"Invalid connector slug: {slug}") from exc
|
|
185
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
|
+
|
|
186
352
|
@property
|
|
187
353
|
def connector_slug(self) -> str:
|
|
188
354
|
"""Return the slug representing this charger's connector."""
|
|
@@ -251,7 +417,12 @@ class Charger(Entity):
|
|
|
251
417
|
scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
|
|
252
418
|
return f"{scheme}://{domain}{self.get_absolute_url()}"
|
|
253
419
|
|
|
420
|
+
def clean(self):
|
|
421
|
+
super().clean()
|
|
422
|
+
self.charger_id = type(self).validate_serial(self.charger_id)
|
|
423
|
+
|
|
254
424
|
def save(self, *args, **kwargs):
|
|
425
|
+
self.charger_id = type(self).validate_serial(self.charger_id)
|
|
255
426
|
update_fields = kwargs.get("update_fields")
|
|
256
427
|
update_list = list(update_fields) if update_fields is not None else None
|
|
257
428
|
if not self.manager_node_id:
|
|
@@ -279,6 +450,8 @@ class Charger(Entity):
|
|
|
279
450
|
kwargs["update_fields"] = update_list
|
|
280
451
|
super().save(*args, **kwargs)
|
|
281
452
|
ref_value = self._full_url()
|
|
453
|
+
if url_targets_local_loopback(ref_value):
|
|
454
|
+
return
|
|
282
455
|
if not self.reference or self.reference.value != ref_value:
|
|
283
456
|
self.reference = Reference.objects.create(
|
|
284
457
|
value=ref_value, alt_text=self.charger_id
|
|
@@ -612,6 +785,18 @@ class Simulator(Entity):
|
|
|
612
785
|
default=False,
|
|
613
786
|
help_text=_("Send a DoorOpen error StatusNotification when enabled."),
|
|
614
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
|
+
)
|
|
615
800
|
|
|
616
801
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
617
802
|
return self.name
|
|
@@ -638,6 +823,8 @@ class Simulator(Entity):
|
|
|
638
823
|
repeat=self.repeat,
|
|
639
824
|
username=self.username or None,
|
|
640
825
|
password=self.password or None,
|
|
826
|
+
configuration_keys=self.configuration_keys or [],
|
|
827
|
+
configuration_unknown_keys=self.configuration_unknown_keys or [],
|
|
641
828
|
)
|
|
642
829
|
|
|
643
830
|
@property
|
|
@@ -650,6 +837,52 @@ class Simulator(Entity):
|
|
|
650
837
|
return f"ws://{self.host}/{path}"
|
|
651
838
|
|
|
652
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
|
+
|
|
653
886
|
class RFID(CoreRFID):
|
|
654
887
|
class Meta:
|
|
655
888
|
proxy = True
|
ocpp/reference_utils.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Helpers related to console Reference creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_host(host: str | None) -> str:
|
|
10
|
+
"""Return a trimmed host string without surrounding brackets."""
|
|
11
|
+
|
|
12
|
+
if not host:
|
|
13
|
+
return ""
|
|
14
|
+
host = host.strip()
|
|
15
|
+
if host.startswith("[") and host.endswith("]"):
|
|
16
|
+
return host[1:-1]
|
|
17
|
+
return host
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def host_is_local_loopback(host: str | None) -> bool:
|
|
21
|
+
"""Return ``True`` when the host string points to 127.0.0.1."""
|
|
22
|
+
|
|
23
|
+
normalized = _normalize_host(host)
|
|
24
|
+
if not normalized:
|
|
25
|
+
return False
|
|
26
|
+
try:
|
|
27
|
+
return ipaddress.ip_address(normalized) == ipaddress.ip_address("127.0.0.1")
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def url_targets_local_loopback(url: str | None) -> bool:
|
|
33
|
+
"""Return ``True`` when the parsed URL host equals 127.0.0.1."""
|
|
34
|
+
|
|
35
|
+
if not url:
|
|
36
|
+
return False
|
|
37
|
+
parsed = urlparse(url)
|
|
38
|
+
return host_is_local_loopback(parsed.hostname)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["host_is_local_loopback", "url_targets_local_loopback"]
|
|
42
|
+
|