arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
ocpp/consumers.py CHANGED
@@ -1,8 +1,11 @@
1
+ import asyncio
1
2
  import json
2
3
  import base64
4
+ import ipaddress
5
+ import re
3
6
  from datetime import datetime
4
7
  from django.utils import timezone
5
- from core.models import EnergyAccount, RFID as CoreRFID
8
+ from core.models import EnergyAccount, Reference, RFID as CoreRFID
6
9
  from nodes.models import NetMessage
7
10
 
8
11
  from channels.generic.websocket import AsyncWebsocketConsumer
@@ -14,6 +17,83 @@ from . import store
14
17
  from decimal import Decimal
15
18
  from django.utils.dateparse import parse_datetime
16
19
  from .models import Transaction, Charger, MeterValue
20
+ from .reference_utils import host_is_local_loopback
21
+
22
+ FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
23
+
24
+
25
+ def _parse_ip(value: str | None):
26
+ """Return an :mod:`ipaddress` object for the provided value, if valid."""
27
+
28
+ candidate = (value or "").strip()
29
+ if not candidate or candidate.lower() == "unknown":
30
+ return None
31
+ if candidate.lower().startswith("for="):
32
+ candidate = candidate[4:].strip()
33
+ candidate = candidate.strip("'\"")
34
+ if candidate.startswith("["):
35
+ closing = candidate.find("]")
36
+ if closing != -1:
37
+ candidate = candidate[1:closing]
38
+ else:
39
+ candidate = candidate[1:]
40
+ # Remove any comma separated values that may remain.
41
+ if "," in candidate:
42
+ candidate = candidate.split(",", 1)[0].strip()
43
+ try:
44
+ parsed = ipaddress.ip_address(candidate)
45
+ except ValueError:
46
+ host, sep, maybe_port = candidate.rpartition(":")
47
+ if not sep or not maybe_port.isdigit():
48
+ return None
49
+ try:
50
+ parsed = ipaddress.ip_address(host)
51
+ except ValueError:
52
+ return None
53
+ return parsed
54
+
55
+
56
+ def _resolve_client_ip(scope: dict) -> str | None:
57
+ """Return the most useful client IP for the provided ASGI scope."""
58
+
59
+ headers = scope.get("headers") or []
60
+ header_map: dict[str, list[str]] = {}
61
+ for key_bytes, value_bytes in headers:
62
+ try:
63
+ key = key_bytes.decode("latin1").lower()
64
+ except Exception:
65
+ continue
66
+ try:
67
+ value = value_bytes.decode("latin1")
68
+ except Exception:
69
+ value = ""
70
+ header_map.setdefault(key, []).append(value)
71
+
72
+ candidates: list[str] = []
73
+ for raw in header_map.get("x-forwarded-for", []):
74
+ candidates.extend(part.strip() for part in raw.split(","))
75
+ for raw in header_map.get("forwarded", []):
76
+ for segment in raw.split(","):
77
+ match = FORWARDED_PAIR_RE.search(segment)
78
+ if match:
79
+ candidates.append(match.group("value"))
80
+ candidates.extend(header_map.get("x-real-ip", []))
81
+ client = scope.get("client")
82
+ if client:
83
+ candidates.append((client[0] or "").strip())
84
+
85
+ fallback: str | None = None
86
+ for raw in candidates:
87
+ parsed = _parse_ip(raw)
88
+ if not parsed:
89
+ continue
90
+ ip_text = str(parsed)
91
+ if parsed.is_loopback:
92
+ if fallback is None:
93
+ fallback = ip_text
94
+ continue
95
+ return ip_text
96
+ return fallback
17
97
 
18
98
 
19
99
  class SinkConsumer(AsyncWebsocketConsumer):
@@ -21,8 +101,15 @@ class SinkConsumer(AsyncWebsocketConsumer):
21
101
 
22
102
  @requires_network
23
103
  async def connect(self) -> None:
104
+ self.client_ip = _resolve_client_ip(self.scope)
105
+ if not store.register_ip_connection(self.client_ip, self):
106
+ await self.close(code=4003)
107
+ return
24
108
  await self.accept()
25
109
 
110
+ async def disconnect(self, close_code):
111
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
112
+
26
113
  async def receive(
27
114
  self, text_data: str | None = None, bytes_data: bytes | None = None
28
115
  ) -> None:
@@ -39,22 +126,37 @@ class SinkConsumer(AsyncWebsocketConsumer):
39
126
  class CSMSConsumer(AsyncWebsocketConsumer):
40
127
  """Very small subset of OCPP 1.6 CSMS behaviour."""
41
128
 
129
+ consumption_update_interval = 300
130
+
42
131
  @requires_network
43
132
  async def connect(self):
44
133
  self.charger_id = self.scope["url_route"]["kwargs"].get("cid", "")
45
134
  self.connector_value: int | None = None
46
135
  self.store_key = store.pending_key(self.charger_id)
47
136
  self.aggregate_charger: Charger | None = None
137
+ self._consumption_task: asyncio.Task | None = None
138
+ self._consumption_message_uuid: str | None = None
48
139
  subprotocol = None
49
140
  offered = self.scope.get("subprotocols", [])
50
141
  if "ocpp1.6" in offered:
51
142
  subprotocol = "ocpp1.6"
143
+ self.client_ip = _resolve_client_ip(self.scope)
144
+ self._header_reference_created = False
52
145
  # Close any pending connection for this charger so reconnections do
53
146
  # not leak stale consumers when the connector id has not been
54
147
  # negotiated yet.
55
148
  existing = store.connections.get(self.store_key)
56
149
  if existing is not None:
150
+ store.release_ip_connection(getattr(existing, "client_ip", None), existing)
57
151
  await existing.close()
152
+ if not store.register_ip_connection(self.client_ip, self):
153
+ store.add_log(
154
+ self.store_key,
155
+ f"Rejected connection from {self.client_ip or 'unknown'}: rate limit exceeded",
156
+ log_type="charger",
157
+ )
158
+ await self.close(code=4003)
159
+ return
58
160
  await self.accept(subprotocol=subprotocol)
59
161
  store.add_log(
60
162
  self.store_key,
@@ -70,6 +172,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
70
172
  connector_id=None,
71
173
  defaults={"last_path": self.scope.get("path", "")},
72
174
  )
175
+ await database_sync_to_async(self.charger.refresh_manager_node)()
73
176
  self.aggregate_charger = self.charger
74
177
  location_name = await sync_to_async(
75
178
  lambda: self.charger.location.name if self.charger.location else ""
@@ -95,11 +198,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
95
198
 
96
199
  async def _assign_connector(self, connector: int | str | None) -> None:
97
200
  """Ensure ``self.charger`` matches the provided connector id."""
98
- if connector is None:
99
- return
100
- try:
101
- connector_value = int(connector)
102
- except (TypeError, ValueError):
201
+ if connector in (None, "", "-"):
202
+ connector_value = None
203
+ else:
204
+ try:
205
+ connector_value = int(connector)
206
+ if connector_value == 0:
207
+ connector_value = None
208
+ except (TypeError, ValueError):
209
+ return
210
+ if connector_value is None:
211
+ if not self._header_reference_created and self.client_ip:
212
+ await database_sync_to_async(self._ensure_console_reference)()
213
+ self._header_reference_created = True
103
214
  return
104
215
  if (
105
216
  self.connector_value == connector_value
@@ -110,15 +221,15 @@ class CSMSConsumer(AsyncWebsocketConsumer):
110
221
  not self.aggregate_charger
111
222
  or self.aggregate_charger.connector_id is not None
112
223
  ):
113
- self.aggregate_charger = await database_sync_to_async(
224
+ aggregate, _ = await database_sync_to_async(
114
225
  Charger.objects.get_or_create
115
226
  )(
116
227
  charger_id=self.charger_id,
117
228
  connector_id=None,
118
229
  defaults={"last_path": self.scope.get("path", "")},
119
- )[
120
- 0
121
- ]
230
+ )
231
+ await database_sync_to_async(aggregate.refresh_manager_node)()
232
+ self.aggregate_charger = aggregate
122
233
  existing = await database_sync_to_async(
123
234
  Charger.objects.filter(
124
235
  charger_id=self.charger_id, connector_id=connector_value
@@ -126,6 +237,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
126
237
  )()
127
238
  if existing:
128
239
  self.charger = existing
240
+ await database_sync_to_async(self.charger.refresh_manager_node)()
129
241
  else:
130
242
 
131
243
  def _create_connector():
@@ -139,6 +251,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
139
251
  ):
140
252
  charger.last_path = self.scope.get("path")
141
253
  charger.save(update_fields=["last_path"])
254
+ charger.refresh_manager_node()
142
255
  return charger
143
256
 
144
257
  self.charger = await database_sync_to_async(_create_connector)()
@@ -168,6 +281,41 @@ class CSMSConsumer(AsyncWebsocketConsumer):
168
281
  self.store_key = new_key
169
282
  self.connector_value = connector_value
170
283
 
284
+ def _ensure_console_reference(self) -> None:
285
+ """Create or update a header reference for the connected charger."""
286
+
287
+ ip = (self.client_ip or "").strip()
288
+ serial = (self.charger_id or "").strip()
289
+ if not ip or not serial:
290
+ return
291
+ if host_is_local_loopback(ip):
292
+ return
293
+ host = ip
294
+ if ":" in host and not host.startswith("["):
295
+ host = f"[{host}]"
296
+ url = f"http://{host}:8900"
297
+ alt_text = f"{serial} Console"
298
+ reference = Reference.objects.filter(alt_text=alt_text).order_by("id").first()
299
+ if reference is None:
300
+ reference = Reference.objects.create(
301
+ alt_text=alt_text,
302
+ value=url,
303
+ show_in_header=True,
304
+ method="link",
305
+ )
306
+ updated_fields: list[str] = []
307
+ if reference.value != url:
308
+ reference.value = url
309
+ updated_fields.append("value")
310
+ if reference.method != "link":
311
+ reference.method = "link"
312
+ updated_fields.append("method")
313
+ if not reference.show_in_header:
314
+ reference.show_in_header = True
315
+ updated_fields.append("show_in_header")
316
+ if updated_fields:
317
+ reference.save(update_fields=updated_fields)
318
+
171
319
  async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
172
320
  """Parse a MeterValues payload into MeterValue rows."""
173
321
  connector_raw = payload.get("connectorId")
@@ -309,46 +457,110 @@ class CSMSConsumer(AsyncWebsocketConsumer):
309
457
  target.firmware_status_info = status_info
310
458
  target.firmware_timestamp = timestamp
311
459
 
312
- async def _broadcast_charging_started(self) -> None:
313
- """Send a network message announcing a charging session."""
460
+ async def _cancel_consumption_message(self) -> None:
461
+ """Stop any scheduled consumption message updates."""
314
462
 
315
- def _message_payload() -> dict[str, str] | None:
316
- charger = self.charger
317
- aggregate = self.aggregate_charger
318
- if not charger:
319
- return None
320
- location_name = ""
321
- if charger.location_id:
322
- location_name = charger.location.name
323
- elif aggregate and aggregate.location_id:
324
- location_name = aggregate.location.name
325
- cid_value = (
326
- charger.connector_slug
327
- if charger.connector_id is not None
328
- else Charger.AGGREGATE_CONNECTOR_SLUG
463
+ task = self._consumption_task
464
+ self._consumption_task = None
465
+ if task:
466
+ task.cancel()
467
+ try:
468
+ await task
469
+ except asyncio.CancelledError:
470
+ pass
471
+ self._consumption_message_uuid = None
472
+
473
+ async def _update_consumption_message(self, tx_id: int) -> str | None:
474
+ """Create or update the Net Message for an active transaction."""
475
+
476
+ existing_uuid = self._consumption_message_uuid
477
+
478
+ def _persist() -> str | None:
479
+ tx = (
480
+ Transaction.objects.select_related("charger")
481
+ .filter(pk=tx_id)
482
+ .first()
329
483
  )
330
- return {
331
- "location": location_name,
332
- "sn": charger.charger_id,
333
- "cid": str(cid_value),
334
- }
335
-
336
- payload = await database_sync_to_async(_message_payload)()
337
- if not payload:
338
- return
484
+ if not tx:
485
+ return None
486
+ charger = tx.charger or self.charger
487
+ serial = ""
488
+ if charger and charger.charger_id:
489
+ serial = charger.charger_id
490
+ elif self.charger_id:
491
+ serial = self.charger_id
492
+ serial = serial[:64]
493
+ if not serial:
494
+ return None
495
+ now_local = timezone.localtime(timezone.now())
496
+ body_value = f"{tx.kw:.1f} kWh {now_local.strftime('%H:%M')}"[:256]
497
+ if existing_uuid:
498
+ msg = NetMessage.objects.filter(uuid=existing_uuid).first()
499
+ if msg:
500
+ msg.subject = serial
501
+ msg.body = body_value
502
+ msg.save(update_fields=["subject", "body"])
503
+ msg.propagate()
504
+ return str(msg.uuid)
505
+ msg = NetMessage.broadcast(subject=serial, body=body_value)
506
+ return str(msg.uuid)
507
+
339
508
  try:
340
- await database_sync_to_async(NetMessage.broadcast)(
341
- subject="charging-started",
342
- body=json.dumps(payload, separators=(",", ":")),
509
+ result = await database_sync_to_async(_persist)()
510
+ except Exception as exc: # pragma: no cover - unexpected errors
511
+ store.add_log(
512
+ self.store_key,
513
+ f"Failed to broadcast consumption message: {exc}",
514
+ log_type="charger",
343
515
  )
344
- except Exception as exc: # pragma: no cover - logging of unexpected errors
516
+ return None
517
+ if result is None:
345
518
  store.add_log(
346
519
  self.store_key,
347
- f"Failed to broadcast charging start: {exc}",
520
+ "Unable to broadcast consumption message: missing data",
348
521
  log_type="charger",
349
522
  )
523
+ return None
524
+ self._consumption_message_uuid = result
525
+ return result
526
+
527
+ async def _consumption_message_loop(self, tx_id: int) -> None:
528
+ """Periodically refresh the consumption Net Message."""
529
+
530
+ try:
531
+ while True:
532
+ await asyncio.sleep(self.consumption_update_interval)
533
+ updated = await self._update_consumption_message(tx_id)
534
+ if not updated:
535
+ break
536
+ except asyncio.CancelledError:
537
+ pass
538
+ except Exception as exc: # pragma: no cover - unexpected errors
539
+ store.add_log(
540
+ self.store_key,
541
+ f"Failed to refresh consumption message: {exc}",
542
+ log_type="charger",
543
+ )
544
+
545
+ async def _start_consumption_updates(self, tx_obj: Transaction) -> None:
546
+ """Send the initial consumption message and schedule updates."""
547
+
548
+ await self._cancel_consumption_message()
549
+ initial = await self._update_consumption_message(tx_obj.pk)
550
+ if not initial:
551
+ return
552
+ task = asyncio.create_task(self._consumption_message_loop(tx_obj.pk))
553
+ task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
554
+ self._consumption_task = task
350
555
 
351
556
  async def disconnect(self, close_code):
557
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
558
+ tx_obj = None
559
+ if self.charger_id:
560
+ tx_obj = store.get_transaction(self.charger_id, self.connector_value)
561
+ if tx_obj:
562
+ await self._update_consumption_message(tx_obj.pk)
563
+ await self._cancel_consumption_message()
352
564
  store.connections.pop(self.store_key, None)
353
565
  pending_key = store.pending_key(self.charger_id)
354
566
  if self.store_key != pending_key:
@@ -552,7 +764,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
552
764
  store.start_session_log(self.store_key, tx_obj.pk)
553
765
  store.start_session_lock()
554
766
  store.add_session_message(self.store_key, text_data)
555
- await self._broadcast_charging_started()
767
+ await self._start_consumption_updates(tx_obj)
556
768
  reply_payload = {
557
769
  "transactionId": tx_obj.pk,
558
770
  "idTagInfo": {"status": "Accepted"},
@@ -579,6 +791,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
579
791
  tx_obj.meter_stop = payload.get("meterStop")
580
792
  tx_obj.stop_time = timezone.now()
581
793
  await database_sync_to_async(tx_obj.save)()
794
+ await self._update_consumption_message(tx_obj.pk)
795
+ await self._cancel_consumption_message()
582
796
  reply_payload = {"idTagInfo": {"status": "Accepted"}}
583
797
  store.end_session_log(self.store_key)
584
798
  store.stop_session_lock()
ocpp/evcs.py CHANGED
@@ -172,7 +172,7 @@ for key, val in _load_state_file().items(): # pragma: no cover - simple load
172
172
  async def simulate_cp(
173
173
  cp_idx: int,
174
174
  host: str,
175
- ws_port: int,
175
+ ws_port: Optional[int],
176
176
  rfid: str,
177
177
  vin: str,
178
178
  cp_path: str,
@@ -196,7 +196,10 @@ async def simulate_cp(
196
196
  if the server closes the connection.
197
197
  """
198
198
 
199
- uri = f"ws://{host}:{ws_port}/{cp_path}"
199
+ if ws_port:
200
+ uri = f"ws://{host}:{ws_port}/{cp_path}"
201
+ else:
202
+ uri = f"ws://{host}/{cp_path}"
200
203
  headers = {}
201
204
  if username and password:
202
205
  userpass = f"{username}:{password}"
@@ -582,7 +585,7 @@ async def simulate_cp(
582
585
  def simulate(
583
586
  *,
584
587
  host: str = "127.0.0.1",
585
- ws_port: int = 8000,
588
+ ws_port: Optional[int] = 8000,
586
589
  rfid: str = "FFFFFFFF",
587
590
  cp_path: str = "CPX",
588
591
  vin: str = "",
ocpp/models.py CHANGED
@@ -8,6 +8,7 @@ from django.urls import reverse
8
8
  from django.utils.translation import gettext_lazy as _
9
9
 
10
10
  from core.entity import Entity, EntityManager
11
+ from nodes.models import Node
11
12
 
12
13
  from core.models import (
13
14
  EnergyAccount,
@@ -17,6 +18,7 @@ from core.models import (
17
18
  Brand as CoreBrand,
18
19
  EVModel as CoreEVModel,
19
20
  )
21
+ from .reference_utils import url_targets_local_loopback
20
22
 
21
23
 
22
24
  class Location(Entity):
@@ -58,6 +60,11 @@ class Charger(Entity):
58
60
  null=True,
59
61
  help_text="Optional connector identifier for multi-connector chargers.",
60
62
  )
63
+ public_display = models.BooleanField(
64
+ _("Show on Public Dashboard"),
65
+ default=True,
66
+ help_text="Display this charger on the public status dashboard.",
67
+ )
61
68
  require_rfid = models.BooleanField(
62
69
  _("Require RFID Authorization"),
63
70
  default=False,
@@ -121,6 +128,13 @@ class Charger(Entity):
121
128
  related_name="chargers",
122
129
  )
123
130
  last_path = models.CharField(max_length=255, blank=True)
131
+ manager_node = models.ForeignKey(
132
+ "nodes.Node",
133
+ on_delete=models.SET_NULL,
134
+ null=True,
135
+ blank=True,
136
+ related_name="managed_chargers",
137
+ )
124
138
 
125
139
  def __str__(self) -> str: # pragma: no cover - simple representation
126
140
  return self.charger_id
@@ -240,6 +254,13 @@ class Charger(Entity):
240
254
 
241
255
  def save(self, *args, **kwargs):
242
256
  update_fields = kwargs.get("update_fields")
257
+ update_list = list(update_fields) if update_fields is not None else None
258
+ if not self.manager_node_id:
259
+ local_node = Node.get_local()
260
+ if local_node:
261
+ self.manager_node = local_node
262
+ if update_list is not None and "manager_node" not in update_list:
263
+ update_list.append("manager_node")
243
264
  if not self.location_id:
244
265
  existing = (
245
266
  type(self)
@@ -253,19 +274,34 @@ class Charger(Entity):
253
274
  else:
254
275
  location, _ = Location.objects.get_or_create(name=self.charger_id)
255
276
  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
277
+ if update_list is not None and "location" not in update_list:
278
+ update_list.append("location")
279
+ if update_list is not None:
280
+ kwargs["update_fields"] = update_list
261
281
  super().save(*args, **kwargs)
262
282
  ref_value = self._full_url()
283
+ if url_targets_local_loopback(ref_value):
284
+ return
263
285
  if not self.reference or self.reference.value != ref_value:
264
286
  self.reference = Reference.objects.create(
265
287
  value=ref_value, alt_text=self.charger_id
266
288
  )
267
289
  super().save(update_fields=["reference"])
268
290
 
291
+ def refresh_manager_node(self, node: Node | None = None) -> Node | None:
292
+ """Ensure ``manager_node`` matches the provided or local node."""
293
+
294
+ node = node or Node.get_local()
295
+ if not node:
296
+ return None
297
+ if self.pk is None:
298
+ self.manager_node = node
299
+ return node
300
+ if self.manager_node_id != node.pk:
301
+ type(self).objects.filter(pk=self.pk).update(manager_node=node)
302
+ self.manager_node = node
303
+ return node
304
+
269
305
  @property
270
306
  def name(self) -> str:
271
307
  if self.location:
@@ -556,7 +592,9 @@ class Simulator(Entity):
556
592
  _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
557
593
  )
558
594
  host = models.CharField(max_length=100, default="127.0.0.1")
559
- ws_port = models.IntegerField(_("WS Port"), default=8000)
595
+ ws_port = models.IntegerField(
596
+ _("WS Port"), default=8000, null=True, blank=True
597
+ )
560
598
  rfid = models.CharField(
561
599
  max_length=255,
562
600
  default="FFFFFFFF",
@@ -572,6 +610,11 @@ class Simulator(Entity):
572
610
  repeat = models.BooleanField(default=False)
573
611
  username = models.CharField(max_length=100, blank=True)
574
612
  password = models.CharField(max_length=100, blank=True)
613
+ door_open = models.BooleanField(
614
+ _("Door Open"),
615
+ default=False,
616
+ help_text=_("Send a DoorOpen error StatusNotification when enabled."),
617
+ )
575
618
 
576
619
  def __str__(self) -> str: # pragma: no cover - simple representation
577
620
  return self.name
@@ -605,7 +648,9 @@ class Simulator(Entity):
605
648
  path = self.cp_path
606
649
  if not path.endswith("/"):
607
650
  path += "/"
608
- return f"ws://{self.host}:{self.ws_port}/{path}"
651
+ if self.ws_port:
652
+ return f"ws://{self.host}:{self.ws_port}/{path}"
653
+ return f"ws://{self.host}/{path}"
609
654
 
610
655
 
611
656
  class RFID(CoreRFID):
@@ -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
+