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.

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
- _("Show on Public Dashboard"),
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
- _("Firmware Status"),
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
- _("Firmware Status Details"),
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
- _("Firmware Status Timestamp"),
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