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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
"""
|
|
460
|
+
async def _cancel_consumption_message(self) -> None:
|
|
461
|
+
"""Stop any scheduled consumption message updates."""
|
|
314
462
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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(
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
516
|
+
return None
|
|
517
|
+
if result is None:
|
|
345
518
|
store.add_log(
|
|
346
519
|
self.store_key,
|
|
347
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
257
|
-
update_list
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
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
|
-
|
|
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):
|
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
|
+
|