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