arthexis 0.1.9__py3-none-any.whl → 0.1.26__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.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/simulator.py
CHANGED
|
@@ -1,368 +1,745 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import base64
|
|
3
|
-
import json
|
|
4
|
-
import random
|
|
5
|
-
import time
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
self.
|
|
46
|
-
self.
|
|
47
|
-
self.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
cfg.
|
|
95
|
-
"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
import websockets
|
|
12
|
+
from config.offline import requires_network
|
|
13
|
+
|
|
14
|
+
from . import store
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SimulatorConfig:
|
|
19
|
+
"""Configuration for a simulated charge point."""
|
|
20
|
+
|
|
21
|
+
host: str = "127.0.0.1"
|
|
22
|
+
ws_port: Optional[int] = 8000
|
|
23
|
+
rfid: str = "FFFFFFFF"
|
|
24
|
+
vin: str = ""
|
|
25
|
+
# WebSocket path for the charge point. Defaults to just the charger ID at the root.
|
|
26
|
+
cp_path: str = "CPX/"
|
|
27
|
+
duration: int = 600
|
|
28
|
+
kw_min: float = 30.0
|
|
29
|
+
kw_max: float = 60.0
|
|
30
|
+
interval: float = 5.0
|
|
31
|
+
pre_charge_delay: float = 10.0
|
|
32
|
+
repeat: bool = False
|
|
33
|
+
username: Optional[str] = None
|
|
34
|
+
password: Optional[str] = None
|
|
35
|
+
serial_number: str = ""
|
|
36
|
+
connector_id: int = 1
|
|
37
|
+
configuration_keys: list[dict[str, object]] = field(default_factory=list)
|
|
38
|
+
configuration_unknown_keys: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChargePointSimulator:
|
|
42
|
+
"""Lightweight simulator for a single OCPP 1.6 charge point."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: SimulatorConfig) -> None:
|
|
45
|
+
self.config = config
|
|
46
|
+
self._thread: Optional[threading.Thread] = None
|
|
47
|
+
self._stop_event = threading.Event()
|
|
48
|
+
self._door_open_event = threading.Event()
|
|
49
|
+
self.status = "stopped"
|
|
50
|
+
self._connected = threading.Event()
|
|
51
|
+
self._connect_error = ""
|
|
52
|
+
self._availability_state = "Operative"
|
|
53
|
+
self._pending_availability: Optional[str] = None
|
|
54
|
+
self._in_transaction = False
|
|
55
|
+
|
|
56
|
+
def trigger_door_open(self) -> None:
|
|
57
|
+
"""Queue a DoorOpen status notification for the simulator."""
|
|
58
|
+
|
|
59
|
+
self._door_open_event.set()
|
|
60
|
+
|
|
61
|
+
async def _maybe_send_door_event(self, send, recv) -> None:
|
|
62
|
+
if not self._door_open_event.is_set():
|
|
63
|
+
return
|
|
64
|
+
self._door_open_event.clear()
|
|
65
|
+
cfg = self.config
|
|
66
|
+
store.add_log(
|
|
67
|
+
cfg.cp_path,
|
|
68
|
+
"Sending DoorOpen StatusNotification",
|
|
69
|
+
log_type="simulator",
|
|
70
|
+
)
|
|
71
|
+
event_id = uuid.uuid4().hex
|
|
72
|
+
await send(
|
|
73
|
+
json.dumps(
|
|
74
|
+
[
|
|
75
|
+
2,
|
|
76
|
+
f"door-open-{event_id}",
|
|
77
|
+
"StatusNotification",
|
|
78
|
+
{
|
|
79
|
+
"connectorId": cfg.connector_id,
|
|
80
|
+
"errorCode": "DoorOpen",
|
|
81
|
+
"status": "Faulted",
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
await recv()
|
|
87
|
+
await send(
|
|
88
|
+
json.dumps(
|
|
89
|
+
[
|
|
90
|
+
2,
|
|
91
|
+
f"door-closed-{event_id}",
|
|
92
|
+
"StatusNotification",
|
|
93
|
+
{
|
|
94
|
+
"connectorId": cfg.connector_id,
|
|
95
|
+
"errorCode": "NoError",
|
|
96
|
+
"status": "Available",
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
await recv()
|
|
102
|
+
|
|
103
|
+
async def _send_status_notification(self, send, recv, status: str) -> None:
|
|
104
|
+
cfg = self.config
|
|
105
|
+
await send(
|
|
106
|
+
json.dumps(
|
|
107
|
+
[
|
|
108
|
+
2,
|
|
109
|
+
f"status-{uuid.uuid4().hex}",
|
|
110
|
+
"StatusNotification",
|
|
111
|
+
{
|
|
112
|
+
"connectorId": cfg.connector_id,
|
|
113
|
+
"errorCode": "NoError",
|
|
114
|
+
"status": status,
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
await recv()
|
|
120
|
+
|
|
121
|
+
async def _wait_until_operative(self, send, recv) -> bool:
|
|
122
|
+
cfg = self.config
|
|
123
|
+
delay = cfg.interval if cfg.interval > 0 else 1.0
|
|
124
|
+
while self._availability_state != "Operative" and not self._stop_event.is_set():
|
|
125
|
+
await send(
|
|
126
|
+
json.dumps(
|
|
127
|
+
[
|
|
128
|
+
2,
|
|
129
|
+
f"hb-wait-{uuid.uuid4().hex}",
|
|
130
|
+
"Heartbeat",
|
|
131
|
+
{},
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
await recv()
|
|
137
|
+
except Exception:
|
|
138
|
+
return False
|
|
139
|
+
await self._maybe_send_door_event(send, recv)
|
|
140
|
+
await asyncio.sleep(delay)
|
|
141
|
+
return self._availability_state == "Operative" and not self._stop_event.is_set()
|
|
142
|
+
|
|
143
|
+
async def _handle_change_availability(self, message_id: str, payload, send, recv) -> None:
|
|
144
|
+
cfg = self.config
|
|
145
|
+
requested_type = str((payload or {}).get("type") or "").strip()
|
|
146
|
+
connector_raw = (payload or {}).get("connectorId")
|
|
147
|
+
try:
|
|
148
|
+
connector_value = int(connector_raw)
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
connector_value = None
|
|
151
|
+
if connector_value in (None, 0):
|
|
152
|
+
connector_value = 0
|
|
153
|
+
valid_connectors = {0, cfg.connector_id}
|
|
154
|
+
send_status: Optional[str] = None
|
|
155
|
+
status_result = "Rejected"
|
|
156
|
+
if requested_type in {"Operative", "Inoperative"} and connector_value in valid_connectors:
|
|
157
|
+
if requested_type == "Inoperative":
|
|
158
|
+
if self._in_transaction:
|
|
159
|
+
self._pending_availability = "Inoperative"
|
|
160
|
+
status_result = "Scheduled"
|
|
161
|
+
else:
|
|
162
|
+
self._pending_availability = None
|
|
163
|
+
status_result = "Accepted"
|
|
164
|
+
if self._availability_state != "Inoperative":
|
|
165
|
+
self._availability_state = "Inoperative"
|
|
166
|
+
send_status = "Unavailable"
|
|
167
|
+
else: # Operative
|
|
168
|
+
self._pending_availability = None
|
|
169
|
+
status_result = "Accepted"
|
|
170
|
+
if self._availability_state != "Operative":
|
|
171
|
+
self._availability_state = "Operative"
|
|
172
|
+
send_status = "Available"
|
|
173
|
+
response = [3, message_id, {"status": status_result}]
|
|
174
|
+
await send(json.dumps(response))
|
|
175
|
+
if send_status:
|
|
176
|
+
await self._send_status_notification(send, recv, send_status)
|
|
177
|
+
|
|
178
|
+
async def _handle_trigger_message(self, message_id: str, payload, send, recv) -> None:
|
|
179
|
+
cfg = self.config
|
|
180
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
181
|
+
requested = str(payload.get("requestedMessage") or "").strip()
|
|
182
|
+
connector_raw = payload.get("connectorId")
|
|
183
|
+
try:
|
|
184
|
+
connector_value = int(connector_raw) if connector_raw is not None else None
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
connector_value = None
|
|
187
|
+
|
|
188
|
+
async def _send_follow_up(action: str, payload_obj: dict) -> None:
|
|
189
|
+
await send(
|
|
190
|
+
json.dumps(
|
|
191
|
+
[
|
|
192
|
+
2,
|
|
193
|
+
f"trigger-{uuid.uuid4().hex}",
|
|
194
|
+
action,
|
|
195
|
+
payload_obj,
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
await recv()
|
|
200
|
+
|
|
201
|
+
status_result = "NotSupported"
|
|
202
|
+
follow_up = None
|
|
203
|
+
|
|
204
|
+
if requested == "BootNotification":
|
|
205
|
+
status_result = "Accepted"
|
|
206
|
+
|
|
207
|
+
async def _boot_notification() -> None:
|
|
208
|
+
await _send_follow_up(
|
|
209
|
+
"BootNotification",
|
|
210
|
+
{
|
|
211
|
+
"chargePointVendor": "SimVendor",
|
|
212
|
+
"chargePointModel": "Simulator",
|
|
213
|
+
"serialNumber": cfg.serial_number,
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
follow_up = _boot_notification
|
|
218
|
+
elif requested == "Heartbeat":
|
|
219
|
+
status_result = "Accepted"
|
|
220
|
+
|
|
221
|
+
async def _heartbeat() -> None:
|
|
222
|
+
await _send_follow_up("Heartbeat", {})
|
|
223
|
+
|
|
224
|
+
follow_up = _heartbeat
|
|
225
|
+
elif requested == "StatusNotification":
|
|
226
|
+
valid_connector = connector_value in (None, cfg.connector_id)
|
|
227
|
+
if valid_connector:
|
|
228
|
+
status_result = "Accepted"
|
|
229
|
+
|
|
230
|
+
async def _status_notification() -> None:
|
|
231
|
+
status_label = (
|
|
232
|
+
"Available"
|
|
233
|
+
if self._availability_state == "Operative"
|
|
234
|
+
else "Unavailable"
|
|
235
|
+
)
|
|
236
|
+
await _send_follow_up(
|
|
237
|
+
"StatusNotification",
|
|
238
|
+
{
|
|
239
|
+
"connectorId": connector_value or cfg.connector_id,
|
|
240
|
+
"errorCode": "NoError",
|
|
241
|
+
"status": status_label,
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
follow_up = _status_notification
|
|
246
|
+
else:
|
|
247
|
+
status_result = "Rejected"
|
|
248
|
+
elif requested == "MeterValues":
|
|
249
|
+
valid_connector = connector_value in (None, cfg.connector_id)
|
|
250
|
+
if valid_connector:
|
|
251
|
+
status_result = "Accepted"
|
|
252
|
+
|
|
253
|
+
async def _meter_values() -> None:
|
|
254
|
+
await _send_follow_up(
|
|
255
|
+
"MeterValues",
|
|
256
|
+
{
|
|
257
|
+
"connectorId": connector_value or cfg.connector_id,
|
|
258
|
+
"meterValue": [
|
|
259
|
+
{
|
|
260
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
261
|
+
"sampledValue": [
|
|
262
|
+
{
|
|
263
|
+
"value": "0",
|
|
264
|
+
"measurand": "Energy.Active.Import.Register",
|
|
265
|
+
"unit": "kW",
|
|
266
|
+
}
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
follow_up = _meter_values
|
|
274
|
+
else:
|
|
275
|
+
status_result = "Rejected"
|
|
276
|
+
elif requested == "DiagnosticsStatusNotification":
|
|
277
|
+
status_result = "Accepted"
|
|
278
|
+
|
|
279
|
+
async def _diagnostics() -> None:
|
|
280
|
+
await _send_follow_up(
|
|
281
|
+
"DiagnosticsStatusNotification",
|
|
282
|
+
{"status": "Idle"},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
follow_up = _diagnostics
|
|
286
|
+
elif requested == "FirmwareStatusNotification":
|
|
287
|
+
status_result = "Accepted"
|
|
288
|
+
|
|
289
|
+
async def _firmware() -> None:
|
|
290
|
+
await _send_follow_up(
|
|
291
|
+
"FirmwareStatusNotification",
|
|
292
|
+
{"status": "Idle"},
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
follow_up = _firmware
|
|
296
|
+
|
|
297
|
+
response = [3, message_id, {"status": status_result}]
|
|
298
|
+
await send(json.dumps(response))
|
|
299
|
+
if status_result == "Accepted" and follow_up:
|
|
300
|
+
await follow_up()
|
|
301
|
+
|
|
302
|
+
async def _handle_csms_call(self, msg, send, recv) -> bool:
|
|
303
|
+
if not isinstance(msg, list) or not msg or msg[0] != 2:
|
|
304
|
+
return False
|
|
305
|
+
message_id = msg[1] if len(msg) > 1 else ""
|
|
306
|
+
if not isinstance(message_id, str):
|
|
307
|
+
message_id = str(message_id)
|
|
308
|
+
action = msg[2]
|
|
309
|
+
payload = msg[3] if len(msg) > 3 else {}
|
|
310
|
+
if action == "ChangeAvailability":
|
|
311
|
+
await self._handle_change_availability(message_id, payload, send, recv)
|
|
312
|
+
return True
|
|
313
|
+
if action == "GetConfiguration":
|
|
314
|
+
await self._handle_get_configuration(message_id, payload, send)
|
|
315
|
+
return True
|
|
316
|
+
if action == "TriggerMessage":
|
|
317
|
+
await self._handle_trigger_message(message_id, payload, send, recv)
|
|
318
|
+
return True
|
|
319
|
+
cfg = self.config
|
|
320
|
+
action_name = str(action)
|
|
321
|
+
store.add_log(
|
|
322
|
+
cfg.cp_path,
|
|
323
|
+
f"Received unsupported action '{action_name}', replying with CallError",
|
|
324
|
+
log_type="simulator",
|
|
325
|
+
)
|
|
326
|
+
await send(
|
|
327
|
+
json.dumps(
|
|
328
|
+
[
|
|
329
|
+
4,
|
|
330
|
+
message_id,
|
|
331
|
+
"NotSupported",
|
|
332
|
+
f"Simulator does not implement {action_name}",
|
|
333
|
+
{},
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
async def _handle_get_configuration(self, message_id: str, payload, send) -> None:
|
|
340
|
+
cfg = self.config
|
|
341
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
342
|
+
requested_keys_raw = payload.get("key")
|
|
343
|
+
requested_keys: list[str] = []
|
|
344
|
+
if isinstance(requested_keys_raw, (list, tuple)):
|
|
345
|
+
for item in requested_keys_raw:
|
|
346
|
+
if isinstance(item, str):
|
|
347
|
+
key_text = item.strip()
|
|
348
|
+
else:
|
|
349
|
+
key_text = str(item).strip()
|
|
350
|
+
if key_text:
|
|
351
|
+
requested_keys.append(key_text)
|
|
352
|
+
|
|
353
|
+
configured_entries: list[dict[str, object]] = []
|
|
354
|
+
for entry in cfg.configuration_keys:
|
|
355
|
+
if not isinstance(entry, dict):
|
|
356
|
+
continue
|
|
357
|
+
key_raw = entry.get("key")
|
|
358
|
+
key_text = str(key_raw).strip() if key_raw is not None else ""
|
|
359
|
+
if not key_text:
|
|
360
|
+
continue
|
|
361
|
+
if requested_keys and key_text not in requested_keys:
|
|
362
|
+
continue
|
|
363
|
+
value = entry.get("value")
|
|
364
|
+
readonly = entry.get("readonly")
|
|
365
|
+
payload_entry: dict[str, object] = {"key": key_text}
|
|
366
|
+
if value is not None:
|
|
367
|
+
payload_entry["value"] = str(value)
|
|
368
|
+
if readonly is not None:
|
|
369
|
+
payload_entry["readonly"] = bool(readonly)
|
|
370
|
+
configured_entries.append(payload_entry)
|
|
371
|
+
|
|
372
|
+
unknown_keys: list[str] = []
|
|
373
|
+
for key in cfg.configuration_unknown_keys:
|
|
374
|
+
key_text = str(key).strip()
|
|
375
|
+
if not key_text:
|
|
376
|
+
continue
|
|
377
|
+
if requested_keys and key_text not in requested_keys:
|
|
378
|
+
continue
|
|
379
|
+
if key_text not in unknown_keys:
|
|
380
|
+
unknown_keys.append(key_text)
|
|
381
|
+
|
|
382
|
+
if requested_keys:
|
|
383
|
+
matched = {entry["key"] for entry in configured_entries}
|
|
384
|
+
for key in requested_keys:
|
|
385
|
+
if key not in matched and key not in unknown_keys:
|
|
386
|
+
unknown_keys.append(key)
|
|
387
|
+
|
|
388
|
+
response_payload: dict[str, object] = {}
|
|
389
|
+
if configured_entries:
|
|
390
|
+
response_payload["configurationKey"] = configured_entries
|
|
391
|
+
if unknown_keys:
|
|
392
|
+
response_payload["unknownKey"] = unknown_keys
|
|
393
|
+
await send(json.dumps([3, message_id, response_payload]))
|
|
394
|
+
|
|
395
|
+
@requires_network
|
|
396
|
+
async def _run_session(self) -> None:
|
|
397
|
+
cfg = self.config
|
|
398
|
+
if cfg.ws_port:
|
|
399
|
+
uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
|
|
400
|
+
else:
|
|
401
|
+
uri = f"ws://{cfg.host}/{cfg.cp_path}"
|
|
402
|
+
headers = {}
|
|
403
|
+
if cfg.username and cfg.password:
|
|
404
|
+
userpass = f"{cfg.username}:{cfg.password}"
|
|
405
|
+
b64 = base64.b64encode(userpass.encode()).decode()
|
|
406
|
+
headers["Authorization"] = f"Basic {b64}"
|
|
407
|
+
|
|
408
|
+
ws = None
|
|
409
|
+
try:
|
|
410
|
+
try:
|
|
411
|
+
ws = await websockets.connect(
|
|
412
|
+
uri, subprotocols=["ocpp1.6"], extra_headers=headers
|
|
413
|
+
)
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
store.add_log(
|
|
416
|
+
cfg.cp_path,
|
|
417
|
+
f"Connection with subprotocol failed: {exc}",
|
|
418
|
+
log_type="simulator",
|
|
419
|
+
)
|
|
420
|
+
ws = await websockets.connect(uri, extra_headers=headers)
|
|
421
|
+
|
|
422
|
+
store.add_log(
|
|
423
|
+
cfg.cp_path,
|
|
424
|
+
f"Connected (subprotocol={ws.subprotocol or 'none'})",
|
|
425
|
+
log_type="simulator",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
async def send(msg: str) -> None:
|
|
429
|
+
try:
|
|
430
|
+
await ws.send(msg)
|
|
431
|
+
except Exception:
|
|
432
|
+
self.status = "error"
|
|
433
|
+
raise
|
|
434
|
+
store.add_log(cfg.cp_path, f"> {msg}", log_type="simulator")
|
|
435
|
+
|
|
436
|
+
async def recv() -> str:
|
|
437
|
+
while True:
|
|
438
|
+
try:
|
|
439
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=60)
|
|
440
|
+
except asyncio.TimeoutError:
|
|
441
|
+
self.status = "stopped"
|
|
442
|
+
self._stop_event.set()
|
|
443
|
+
store.add_log(
|
|
444
|
+
cfg.cp_path,
|
|
445
|
+
"Timeout waiting for response from charger",
|
|
446
|
+
log_type="simulator",
|
|
447
|
+
)
|
|
448
|
+
raise
|
|
449
|
+
except websockets.exceptions.ConnectionClosed:
|
|
450
|
+
self.status = "stopped"
|
|
451
|
+
self._stop_event.set()
|
|
452
|
+
raise
|
|
453
|
+
except Exception:
|
|
454
|
+
self.status = "error"
|
|
455
|
+
raise
|
|
456
|
+
store.add_log(cfg.cp_path, f"< {raw}", log_type="simulator")
|
|
457
|
+
try:
|
|
458
|
+
parsed = json.loads(raw)
|
|
459
|
+
except Exception:
|
|
460
|
+
return raw
|
|
461
|
+
handled = await self._handle_csms_call(parsed, send, recv)
|
|
462
|
+
if handled:
|
|
463
|
+
continue
|
|
464
|
+
return raw
|
|
465
|
+
|
|
466
|
+
# handshake
|
|
467
|
+
boot = json.dumps(
|
|
468
|
+
[
|
|
469
|
+
2,
|
|
470
|
+
"boot",
|
|
471
|
+
"BootNotification",
|
|
472
|
+
{
|
|
473
|
+
"chargePointModel": "Simulator",
|
|
474
|
+
"chargePointVendor": "SimVendor",
|
|
475
|
+
"serialNumber": cfg.serial_number,
|
|
476
|
+
},
|
|
477
|
+
]
|
|
478
|
+
)
|
|
479
|
+
await send(boot)
|
|
480
|
+
try:
|
|
481
|
+
resp = json.loads(await recv())
|
|
482
|
+
except Exception:
|
|
483
|
+
self.status = "error"
|
|
484
|
+
raise
|
|
485
|
+
status = resp[2].get("status")
|
|
486
|
+
if status != "Accepted":
|
|
487
|
+
if not self._connected.is_set():
|
|
488
|
+
self._connect_error = f"Boot status {status}"
|
|
489
|
+
self._connected.set()
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
|
|
493
|
+
await recv()
|
|
494
|
+
await self._maybe_send_door_event(send, recv)
|
|
495
|
+
if not self._connected.is_set():
|
|
496
|
+
self.status = "running"
|
|
497
|
+
self._connect_error = "accepted"
|
|
498
|
+
self._connected.set()
|
|
499
|
+
if cfg.pre_charge_delay > 0:
|
|
500
|
+
idle_start = time.monotonic()
|
|
501
|
+
while time.monotonic() - idle_start < cfg.pre_charge_delay:
|
|
502
|
+
await send(
|
|
503
|
+
json.dumps(
|
|
504
|
+
[
|
|
505
|
+
2,
|
|
506
|
+
"status",
|
|
507
|
+
"StatusNotification",
|
|
508
|
+
{
|
|
509
|
+
"connectorId": cfg.connector_id,
|
|
510
|
+
"errorCode": "NoError",
|
|
511
|
+
"status": (
|
|
512
|
+
"Available"
|
|
513
|
+
if self._availability_state == "Operative"
|
|
514
|
+
else "Unavailable"
|
|
515
|
+
),
|
|
516
|
+
},
|
|
517
|
+
]
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
await recv()
|
|
521
|
+
await send(json.dumps([2, "hb", "Heartbeat", {}]))
|
|
522
|
+
await recv()
|
|
523
|
+
await send(
|
|
524
|
+
json.dumps(
|
|
525
|
+
[
|
|
526
|
+
2,
|
|
527
|
+
"meter",
|
|
528
|
+
"MeterValues",
|
|
529
|
+
{
|
|
530
|
+
"connectorId": cfg.connector_id,
|
|
531
|
+
"meterValue": [
|
|
532
|
+
{
|
|
533
|
+
"timestamp": time.strftime(
|
|
534
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
535
|
+
),
|
|
536
|
+
"sampledValue": [
|
|
537
|
+
{
|
|
538
|
+
"value": "0",
|
|
539
|
+
"measurand": "Energy.Active.Import.Register",
|
|
540
|
+
"unit": "kW",
|
|
541
|
+
}
|
|
542
|
+
],
|
|
543
|
+
}
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
]
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
await recv()
|
|
550
|
+
await self._maybe_send_door_event(send, recv)
|
|
551
|
+
await asyncio.sleep(cfg.interval)
|
|
552
|
+
|
|
553
|
+
if not await self._wait_until_operative(send, recv):
|
|
554
|
+
return
|
|
555
|
+
meter_start = random.randint(1000, 2000)
|
|
556
|
+
await send(
|
|
557
|
+
json.dumps(
|
|
558
|
+
[
|
|
559
|
+
2,
|
|
560
|
+
"start",
|
|
561
|
+
"StartTransaction",
|
|
562
|
+
{
|
|
563
|
+
"connectorId": cfg.connector_id,
|
|
564
|
+
"idTag": cfg.rfid,
|
|
565
|
+
"meterStart": meter_start,
|
|
566
|
+
"vin": cfg.vin,
|
|
567
|
+
},
|
|
568
|
+
]
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
try:
|
|
572
|
+
resp = json.loads(await recv())
|
|
573
|
+
except Exception:
|
|
574
|
+
self.status = "error"
|
|
575
|
+
raise
|
|
576
|
+
tx_id = resp[2].get("transactionId")
|
|
577
|
+
self._in_transaction = True
|
|
578
|
+
|
|
579
|
+
meter = meter_start
|
|
580
|
+
steps = max(1, int(cfg.duration / cfg.interval))
|
|
581
|
+
target_kwh = cfg.kw_max * random.uniform(0.9, 1.1)
|
|
582
|
+
step_avg = (target_kwh * 1000) / steps
|
|
583
|
+
|
|
584
|
+
start_time = time.monotonic()
|
|
585
|
+
while time.monotonic() - start_time < cfg.duration:
|
|
586
|
+
if self._stop_event.is_set():
|
|
587
|
+
break
|
|
588
|
+
inc = random.gauss(step_avg, step_avg * 0.05)
|
|
589
|
+
meter += max(1, int(inc))
|
|
590
|
+
meter_kw = meter / 1000.0
|
|
591
|
+
await send(
|
|
592
|
+
json.dumps(
|
|
593
|
+
[
|
|
594
|
+
2,
|
|
595
|
+
"meter",
|
|
596
|
+
"MeterValues",
|
|
597
|
+
{
|
|
598
|
+
"connectorId": cfg.connector_id,
|
|
599
|
+
"transactionId": tx_id,
|
|
600
|
+
"meterValue": [
|
|
601
|
+
{
|
|
602
|
+
"timestamp": time.strftime(
|
|
603
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
604
|
+
),
|
|
605
|
+
"sampledValue": [
|
|
606
|
+
{
|
|
607
|
+
"value": f"{meter_kw:.3f}",
|
|
608
|
+
"measurand": "Energy.Active.Import.Register",
|
|
609
|
+
"unit": "kW",
|
|
610
|
+
}
|
|
611
|
+
],
|
|
612
|
+
}
|
|
613
|
+
],
|
|
614
|
+
},
|
|
615
|
+
]
|
|
616
|
+
)
|
|
617
|
+
)
|
|
618
|
+
await recv()
|
|
619
|
+
await self._maybe_send_door_event(send, recv)
|
|
620
|
+
await asyncio.sleep(cfg.interval)
|
|
621
|
+
|
|
622
|
+
await send(
|
|
623
|
+
json.dumps(
|
|
624
|
+
[
|
|
625
|
+
2,
|
|
626
|
+
"stop",
|
|
627
|
+
"StopTransaction",
|
|
628
|
+
{
|
|
629
|
+
"transactionId": tx_id,
|
|
630
|
+
"idTag": cfg.rfid,
|
|
631
|
+
"meterStop": meter,
|
|
632
|
+
},
|
|
633
|
+
]
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
await recv()
|
|
637
|
+
await self._maybe_send_door_event(send, recv)
|
|
638
|
+
self._in_transaction = False
|
|
639
|
+
if self._pending_availability:
|
|
640
|
+
pending = self._pending_availability
|
|
641
|
+
self._pending_availability = None
|
|
642
|
+
self._availability_state = pending
|
|
643
|
+
status_label = "Available" if pending == "Operative" else "Unavailable"
|
|
644
|
+
await self._send_status_notification(send, recv, status_label)
|
|
645
|
+
except asyncio.TimeoutError:
|
|
646
|
+
if not self._connected.is_set():
|
|
647
|
+
self._connect_error = "Timeout waiting for response"
|
|
648
|
+
self._connected.set()
|
|
649
|
+
self.status = "stopped"
|
|
650
|
+
self._stop_event.set()
|
|
651
|
+
return
|
|
652
|
+
except websockets.exceptions.ConnectionClosed as exc:
|
|
653
|
+
if not self._connected.is_set():
|
|
654
|
+
self._connect_error = str(exc)
|
|
655
|
+
self._connected.set()
|
|
656
|
+
# The charger closed the connection; mark the simulator as
|
|
657
|
+
# terminated rather than erroring so the status reflects that it
|
|
658
|
+
# was stopped remotely.
|
|
659
|
+
self.status = "stopped"
|
|
660
|
+
self._stop_event.set()
|
|
661
|
+
store.add_log(
|
|
662
|
+
cfg.cp_path,
|
|
663
|
+
f"Disconnected by charger (code={getattr(exc, 'code', '')})",
|
|
664
|
+
log_type="simulator",
|
|
665
|
+
)
|
|
666
|
+
return
|
|
667
|
+
except Exception as exc:
|
|
668
|
+
if not self._connected.is_set():
|
|
669
|
+
self._connect_error = str(exc)
|
|
670
|
+
self._connected.set()
|
|
671
|
+
self.status = "error"
|
|
672
|
+
self._stop_event.set()
|
|
673
|
+
raise
|
|
674
|
+
finally:
|
|
675
|
+
self._in_transaction = False
|
|
676
|
+
if ws is not None:
|
|
677
|
+
await ws.close()
|
|
678
|
+
store.add_log(
|
|
679
|
+
cfg.cp_path,
|
|
680
|
+
f"Closed (code={ws.close_code}, reason={getattr(ws, 'close_reason', '')})",
|
|
681
|
+
log_type="simulator",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
async def _run(self) -> None:
|
|
685
|
+
try:
|
|
686
|
+
while not self._stop_event.is_set():
|
|
687
|
+
try:
|
|
688
|
+
await self._run_session()
|
|
689
|
+
except asyncio.CancelledError:
|
|
690
|
+
break
|
|
691
|
+
except Exception:
|
|
692
|
+
# wait briefly then retry
|
|
693
|
+
await asyncio.sleep(1)
|
|
694
|
+
continue
|
|
695
|
+
if not self.config.repeat:
|
|
696
|
+
break
|
|
697
|
+
finally:
|
|
698
|
+
for key, sim in list(store.simulators.items()):
|
|
699
|
+
if sim is self:
|
|
700
|
+
store.simulators.pop(key, None)
|
|
701
|
+
break
|
|
702
|
+
|
|
703
|
+
def start(self) -> tuple[bool, str, str]:
|
|
704
|
+
if self._thread and self._thread.is_alive():
|
|
705
|
+
return (
|
|
706
|
+
False,
|
|
707
|
+
"already running",
|
|
708
|
+
str(store._file_path(self.config.cp_path, log_type="simulator")),
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
self._stop_event.clear()
|
|
712
|
+
self.status = "starting"
|
|
713
|
+
self._connected.clear()
|
|
714
|
+
self._connect_error = ""
|
|
715
|
+
self._door_open_event.clear()
|
|
716
|
+
|
|
717
|
+
def _runner() -> None:
|
|
718
|
+
asyncio.run(self._run())
|
|
719
|
+
|
|
720
|
+
self._thread = threading.Thread(target=_runner, daemon=True)
|
|
721
|
+
self._thread.start()
|
|
722
|
+
|
|
723
|
+
log_file = str(store._file_path(self.config.cp_path, log_type="simulator"))
|
|
724
|
+
if not self._connected.wait(15):
|
|
725
|
+
self.status = "error"
|
|
726
|
+
return False, "Connection timeout", log_file
|
|
727
|
+
if self._connect_error == "accepted":
|
|
728
|
+
self.status = "running"
|
|
729
|
+
return True, "Connection accepted", log_file
|
|
730
|
+
if "Timeout" in self._connect_error:
|
|
731
|
+
self.status = "stopped"
|
|
732
|
+
else:
|
|
733
|
+
self.status = "error"
|
|
734
|
+
return False, f"Connection failed: {self._connect_error}", log_file
|
|
735
|
+
|
|
736
|
+
async def stop(self) -> None:
|
|
737
|
+
if self._thread and self._thread.is_alive():
|
|
738
|
+
self._stop_event.set()
|
|
739
|
+
await asyncio.to_thread(self._thread.join)
|
|
740
|
+
self._thread = None
|
|
741
|
+
self._stop_event = threading.Event()
|
|
742
|
+
self.status = "stopped"
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
__all__ = ["SimulatorConfig", "ChargePointSimulator"]
|