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/evcs.py
CHANGED
|
@@ -1,928 +1,844 @@
|
|
|
1
|
-
"""Advanced OCPP charge point simulator.
|
|
2
|
-
|
|
3
|
-
This module is based on a more feature rich simulator used in the
|
|
4
|
-
``projects/ocpp/evcs.py`` file of the upstream project. The original module
|
|
5
|
-
contains a large amount of functionality for driving one or more simulated
|
|
6
|
-
charge points, handling remote commands and persisting state. The version
|
|
7
|
-
included with this repository previously exposed only a very small subset of
|
|
8
|
-
those features. For the purposes of the tests in this kata we mirror the
|
|
9
|
-
behaviour of the upstream implementation in a lightweight, dependency free
|
|
10
|
-
fashion.
|
|
11
|
-
|
|
12
|
-
Only the portions that are useful for automated tests are implemented here.
|
|
13
|
-
The web based user interface present in the original file relies on additional
|
|
14
|
-
helpers and the Bottle framework. To keep the module self contained and
|
|
15
|
-
importable in the test environment those parts are intentionally omitted.
|
|
16
|
-
|
|
17
|
-
The simulator exposes two high level helpers:
|
|
18
|
-
|
|
19
|
-
``simulate``
|
|
20
|
-
Entry point used by administrative tasks to spawn one or more charge point
|
|
21
|
-
simulations. It can operate either synchronously or return a coroutine
|
|
22
|
-
that can be awaited by the caller.
|
|
23
|
-
|
|
24
|
-
``simulate_cp``
|
|
25
|
-
Coroutine that performs the actual OCPP exchange for a single charge point.
|
|
26
|
-
It implements features such as boot notification, authorisation,
|
|
27
|
-
meter‑value reporting, remote stop handling and optional pre‑charge delay.
|
|
28
|
-
|
|
29
|
-
In addition a small amount of state is persisted to ``simulator.json`` inside
|
|
30
|
-
the ``ocpp`` package. The state tracking is intentionally simple but mirrors
|
|
31
|
-
the behaviour of the original code which recorded the last command executed and
|
|
32
|
-
whether the simulator was currently running.
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
from __future__ import annotations
|
|
36
|
-
|
|
37
|
-
import asyncio
|
|
38
|
-
import base64
|
|
39
|
-
import json
|
|
40
|
-
import os
|
|
41
|
-
import random
|
|
42
|
-
import secrets
|
|
43
|
-
import threading
|
|
44
|
-
import time
|
|
45
|
-
from dataclasses import dataclass
|
|
46
|
-
from pathlib import Path
|
|
47
|
-
from typing import Dict, Optional
|
|
48
|
-
|
|
49
|
-
import websockets
|
|
50
|
-
from . import store
|
|
51
|
-
|
|
52
|
-
# ---------------------------------------------------------------------------
|
|
53
|
-
# Helper utilities
|
|
54
|
-
# ---------------------------------------------------------------------------
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def parse_repeat(repeat: object) -> float:
|
|
58
|
-
"""Return the number of times a session should be repeated.
|
|
59
|
-
|
|
60
|
-
The original implementation accepted a variety of inputs. ``True`` or one
|
|
61
|
-
of the strings ``"forever"``/``"infinite"`` result in an infinite loop. A
|
|
62
|
-
positive integer value indicates the exact number of sessions and any other
|
|
63
|
-
value defaults to ``1``.
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
if repeat is True or (
|
|
67
|
-
isinstance(repeat, str)
|
|
68
|
-
and repeat.lower() in {"true", "forever", "infinite", "loop"}
|
|
69
|
-
):
|
|
70
|
-
return float("inf")
|
|
71
|
-
|
|
72
|
-
try:
|
|
73
|
-
n = int(repeat) # type: ignore[arg-type]
|
|
74
|
-
except Exception:
|
|
75
|
-
return 1
|
|
76
|
-
return n if n > 0 else 1
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _thread_runner(target, *args, **kwargs) -> None:
|
|
80
|
-
"""Run ``target`` in a fresh asyncio loop inside a thread.
|
|
81
|
-
|
|
82
|
-
The websockets library requires a running event loop. When multiple charge
|
|
83
|
-
points are simulated concurrently we spawn one thread per charge point and
|
|
84
|
-
execute the async coroutine in its own event loop.
|
|
85
|
-
"""
|
|
86
|
-
|
|
87
|
-
try:
|
|
88
|
-
asyncio.run(target(*args, **kwargs))
|
|
89
|
-
except Exception as exc: # pragma: no cover - defensive programming
|
|
90
|
-
print(f"[Simulator:thread] Exception: {exc}")
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _unique_cp_path(cp_path: str, idx: int, total_threads: int) -> str:
|
|
94
|
-
"""Return a unique charger path when multiple threads are used."""
|
|
95
|
-
|
|
96
|
-
if total_threads == 1:
|
|
97
|
-
return cp_path
|
|
98
|
-
tag = secrets.token_hex(2).upper() # four hex digits
|
|
99
|
-
return f"{cp_path}-{tag}"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# ---------------------------------------------------------------------------
|
|
103
|
-
# Simulator state handling
|
|
104
|
-
# ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@dataclass
|
|
108
|
-
class SimulatorState:
|
|
109
|
-
running: bool = False
|
|
110
|
-
last_status: str = ""
|
|
111
|
-
last_command: Optional[str] = None
|
|
112
|
-
last_error: str = ""
|
|
113
|
-
last_message: str = ""
|
|
114
|
-
phase: str = ""
|
|
115
|
-
start_time: Optional[str] = None
|
|
116
|
-
stop_time: Optional[str] = None
|
|
117
|
-
params: Dict[str, object] | None = None
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
_simulators: Dict[int, SimulatorState] = {
|
|
121
|
-
1: SimulatorState(),
|
|
122
|
-
2: SimulatorState(),
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
# Persist state in the package directory so consecutive runs can load it.
|
|
126
|
-
STATE_FILE = Path(__file__).with_name("simulator.json")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _load_state_file() -> Dict[str, Dict[str, object]]:
|
|
130
|
-
if STATE_FILE.exists():
|
|
131
|
-
try:
|
|
132
|
-
return json.loads(STATE_FILE.read_text("utf-8"))
|
|
133
|
-
except Exception: # pragma: no cover - best effort load
|
|
134
|
-
return {}
|
|
135
|
-
return {}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _save_state_file(states: Dict[int, SimulatorState]) -> None:
|
|
139
|
-
try: # pragma: no cover - best effort persistence
|
|
140
|
-
data = {
|
|
141
|
-
str(k): {
|
|
142
|
-
"running": v.running,
|
|
143
|
-
"last_status": v.last_status,
|
|
144
|
-
"last_command": v.last_command,
|
|
145
|
-
"last_error": v.last_error,
|
|
146
|
-
"last_message": v.last_message,
|
|
147
|
-
"phase": v.phase,
|
|
148
|
-
"start_time": v.start_time,
|
|
149
|
-
"stop_time": v.stop_time,
|
|
150
|
-
"params": v.params or {},
|
|
151
|
-
}
|
|
152
|
-
for k, v in states.items()
|
|
153
|
-
}
|
|
154
|
-
STATE_FILE.write_text(json.dumps(data))
|
|
155
|
-
except Exception:
|
|
156
|
-
pass
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# Load persisted state at import time
|
|
160
|
-
for key, val in _load_state_file().items(): # pragma: no cover - simple load
|
|
161
|
-
try:
|
|
162
|
-
_simulators[int(key)].__dict__.update(val)
|
|
163
|
-
except Exception:
|
|
164
|
-
continue
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
# ---------------------------------------------------------------------------
|
|
168
|
-
# Simulation logic
|
|
169
|
-
# ---------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
async def simulate_cp(
|
|
173
|
-
cp_idx: int,
|
|
174
|
-
host: str,
|
|
175
|
-
ws_port: int,
|
|
176
|
-
rfid: str,
|
|
177
|
-
vin: str,
|
|
178
|
-
cp_path: str,
|
|
179
|
-
serial_number: str,
|
|
180
|
-
connector_id: int,
|
|
181
|
-
duration: int,
|
|
182
|
-
kw_min: float,
|
|
183
|
-
kw_max: float,
|
|
184
|
-
pre_charge_delay: float,
|
|
185
|
-
session_count: float,
|
|
186
|
-
interval: float = 5.0,
|
|
187
|
-
username: Optional[str] = None,
|
|
188
|
-
password: Optional[str] = None,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if
|
|
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
|
-
async def
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
"
|
|
625
|
-
|
|
626
|
-
"
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
state.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
if
|
|
716
|
-
return
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
"""
|
|
792
|
-
|
|
793
|
-
state
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
""
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
state.running = False
|
|
846
|
-
_save_state_file(_simulators)
|
|
847
|
-
return True
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
def _export_state(state: SimulatorState) -> Dict[str, object]:
|
|
851
|
-
return {
|
|
852
|
-
"running": state.running,
|
|
853
|
-
"last_status": state.last_status,
|
|
854
|
-
"last_command": state.last_command,
|
|
855
|
-
"last_error": state.last_error,
|
|
856
|
-
"last_message": state.last_message,
|
|
857
|
-
"phase": state.phase,
|
|
858
|
-
"start_time": state.start_time,
|
|
859
|
-
"stop_time": state.stop_time,
|
|
860
|
-
"params": state.params or {},
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
def _simulator_status_json(cp: Optional[int] = None) -> str:
|
|
865
|
-
"""Return a JSON representation of the simulator state."""
|
|
866
|
-
|
|
867
|
-
if cp is not None:
|
|
868
|
-
return json.dumps(_export_state(_simulators[cp]), indent=2)
|
|
869
|
-
return json.dumps(
|
|
870
|
-
{str(idx): _export_state(st) for idx, st in _simulators.items()}, indent=2
|
|
871
|
-
)
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
def get_simulator_state(cp: Optional[int] = None, refresh_file: bool = False):
|
|
875
|
-
"""Return the current simulator state.
|
|
876
|
-
|
|
877
|
-
When ``refresh_file`` is ``True`` the persisted state file is reloaded.
|
|
878
|
-
This mirrors the behaviour of the original implementation which allowed a
|
|
879
|
-
separate process to query the running simulator.
|
|
880
|
-
"""
|
|
881
|
-
|
|
882
|
-
if refresh_file:
|
|
883
|
-
file_state = _load_state_file()
|
|
884
|
-
for key, val in file_state.items():
|
|
885
|
-
try:
|
|
886
|
-
idx = int(key)
|
|
887
|
-
except ValueError: # pragma: no cover - defensive
|
|
888
|
-
continue
|
|
889
|
-
if idx in _simulators:
|
|
890
|
-
_simulators[idx].__dict__.update(val)
|
|
891
|
-
|
|
892
|
-
if cp is not None:
|
|
893
|
-
return _export_state(_simulators[cp])
|
|
894
|
-
return {idx: _export_state(st) for idx, st in _simulators.items()}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
# The original file exposed ``view_cp_simulator`` which rendered an HTML user
|
|
898
|
-
# interface. Implementing that functionality would require additional
|
|
899
|
-
# third‑party dependencies. For the scope of the exercises the function is
|
|
900
|
-
# retained as a simple placeholder so importing the module does not fail.
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
def view_cp_simulator(*args, **kwargs): # pragma: no cover - UI stub
|
|
904
|
-
"""Placeholder for the web based simulator view.
|
|
905
|
-
|
|
906
|
-
The real project renders a dynamic HTML page. Returning a short explanatory
|
|
907
|
-
string keeps the public API compatible for callers that expect a return
|
|
908
|
-
value while avoiding heavy dependencies.
|
|
909
|
-
"""
|
|
910
|
-
|
|
911
|
-
return "Simulator web UI is not available in this environment."
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
def view_simulator(*args, **kwargs): # pragma: no cover - simple alias
|
|
915
|
-
return view_cp_simulator(*args, **kwargs)
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
__all__ = [
|
|
919
|
-
"simulate",
|
|
920
|
-
"simulate_cp",
|
|
921
|
-
"parse_repeat",
|
|
922
|
-
"_start_simulator",
|
|
923
|
-
"_stop_simulator",
|
|
924
|
-
"_simulator_status_json",
|
|
925
|
-
"get_simulator_state",
|
|
926
|
-
"view_cp_simulator",
|
|
927
|
-
"view_simulator",
|
|
928
|
-
]
|
|
1
|
+
"""Advanced OCPP charge point simulator.
|
|
2
|
+
|
|
3
|
+
This module is based on a more feature rich simulator used in the
|
|
4
|
+
``projects/ocpp/evcs.py`` file of the upstream project. The original module
|
|
5
|
+
contains a large amount of functionality for driving one or more simulated
|
|
6
|
+
charge points, handling remote commands and persisting state. The version
|
|
7
|
+
included with this repository previously exposed only a very small subset of
|
|
8
|
+
those features. For the purposes of the tests in this kata we mirror the
|
|
9
|
+
behaviour of the upstream implementation in a lightweight, dependency free
|
|
10
|
+
fashion.
|
|
11
|
+
|
|
12
|
+
Only the portions that are useful for automated tests are implemented here.
|
|
13
|
+
The web based user interface present in the original file relies on additional
|
|
14
|
+
helpers and the Bottle framework. To keep the module self contained and
|
|
15
|
+
importable in the test environment those parts are intentionally omitted.
|
|
16
|
+
|
|
17
|
+
The simulator exposes two high level helpers:
|
|
18
|
+
|
|
19
|
+
``simulate``
|
|
20
|
+
Entry point used by administrative tasks to spawn one or more charge point
|
|
21
|
+
simulations. It can operate either synchronously or return a coroutine
|
|
22
|
+
that can be awaited by the caller.
|
|
23
|
+
|
|
24
|
+
``simulate_cp``
|
|
25
|
+
Coroutine that performs the actual OCPP exchange for a single charge point.
|
|
26
|
+
It implements features such as boot notification, authorisation,
|
|
27
|
+
meter‑value reporting, remote stop handling and optional pre‑charge delay.
|
|
28
|
+
|
|
29
|
+
In addition a small amount of state is persisted to ``simulator.json`` inside
|
|
30
|
+
the ``ocpp`` package. The state tracking is intentionally simple but mirrors
|
|
31
|
+
the behaviour of the original code which recorded the last command executed and
|
|
32
|
+
whether the simulator was currently running.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import base64
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import random
|
|
42
|
+
import secrets
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Dict, Optional
|
|
48
|
+
|
|
49
|
+
import websockets
|
|
50
|
+
from . import store
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Helper utilities
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_repeat(repeat: object) -> float:
|
|
58
|
+
"""Return the number of times a session should be repeated.
|
|
59
|
+
|
|
60
|
+
The original implementation accepted a variety of inputs. ``True`` or one
|
|
61
|
+
of the strings ``"forever"``/``"infinite"`` result in an infinite loop. A
|
|
62
|
+
positive integer value indicates the exact number of sessions and any other
|
|
63
|
+
value defaults to ``1``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if repeat is True or (
|
|
67
|
+
isinstance(repeat, str)
|
|
68
|
+
and repeat.lower() in {"true", "forever", "infinite", "loop"}
|
|
69
|
+
):
|
|
70
|
+
return float("inf")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
n = int(repeat) # type: ignore[arg-type]
|
|
74
|
+
except Exception:
|
|
75
|
+
return 1
|
|
76
|
+
return n if n > 0 else 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _thread_runner(target, *args, **kwargs) -> None:
|
|
80
|
+
"""Run ``target`` in a fresh asyncio loop inside a thread.
|
|
81
|
+
|
|
82
|
+
The websockets library requires a running event loop. When multiple charge
|
|
83
|
+
points are simulated concurrently we spawn one thread per charge point and
|
|
84
|
+
execute the async coroutine in its own event loop.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
asyncio.run(target(*args, **kwargs))
|
|
89
|
+
except Exception as exc: # pragma: no cover - defensive programming
|
|
90
|
+
print(f"[Simulator:thread] Exception: {exc}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _unique_cp_path(cp_path: str, idx: int, total_threads: int) -> str:
|
|
94
|
+
"""Return a unique charger path when multiple threads are used."""
|
|
95
|
+
|
|
96
|
+
if total_threads == 1:
|
|
97
|
+
return cp_path
|
|
98
|
+
tag = secrets.token_hex(2).upper() # four hex digits
|
|
99
|
+
return f"{cp_path}-{tag}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Simulator state handling
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class SimulatorState:
|
|
109
|
+
running: bool = False
|
|
110
|
+
last_status: str = ""
|
|
111
|
+
last_command: Optional[str] = None
|
|
112
|
+
last_error: str = ""
|
|
113
|
+
last_message: str = ""
|
|
114
|
+
phase: str = ""
|
|
115
|
+
start_time: Optional[str] = None
|
|
116
|
+
stop_time: Optional[str] = None
|
|
117
|
+
params: Dict[str, object] | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_simulators: Dict[int, SimulatorState] = {
|
|
121
|
+
1: SimulatorState(),
|
|
122
|
+
2: SimulatorState(),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Persist state in the package directory so consecutive runs can load it.
|
|
126
|
+
STATE_FILE = Path(__file__).with_name("simulator.json")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_state_file() -> Dict[str, Dict[str, object]]:
|
|
130
|
+
if STATE_FILE.exists():
|
|
131
|
+
try:
|
|
132
|
+
return json.loads(STATE_FILE.read_text("utf-8"))
|
|
133
|
+
except Exception: # pragma: no cover - best effort load
|
|
134
|
+
return {}
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _save_state_file(states: Dict[int, SimulatorState]) -> None:
|
|
139
|
+
try: # pragma: no cover - best effort persistence
|
|
140
|
+
data = {
|
|
141
|
+
str(k): {
|
|
142
|
+
"running": v.running,
|
|
143
|
+
"last_status": v.last_status,
|
|
144
|
+
"last_command": v.last_command,
|
|
145
|
+
"last_error": v.last_error,
|
|
146
|
+
"last_message": v.last_message,
|
|
147
|
+
"phase": v.phase,
|
|
148
|
+
"start_time": v.start_time,
|
|
149
|
+
"stop_time": v.stop_time,
|
|
150
|
+
"params": v.params or {},
|
|
151
|
+
}
|
|
152
|
+
for k, v in states.items()
|
|
153
|
+
}
|
|
154
|
+
STATE_FILE.write_text(json.dumps(data))
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Load persisted state at import time
|
|
160
|
+
for key, val in _load_state_file().items(): # pragma: no cover - simple load
|
|
161
|
+
try:
|
|
162
|
+
_simulators[int(key)].__dict__.update(val)
|
|
163
|
+
except Exception:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Simulation logic
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def simulate_cp(
|
|
173
|
+
cp_idx: int,
|
|
174
|
+
host: str,
|
|
175
|
+
ws_port: Optional[int],
|
|
176
|
+
rfid: str,
|
|
177
|
+
vin: str,
|
|
178
|
+
cp_path: str,
|
|
179
|
+
serial_number: str,
|
|
180
|
+
connector_id: int,
|
|
181
|
+
duration: int,
|
|
182
|
+
kw_min: float,
|
|
183
|
+
kw_max: float,
|
|
184
|
+
pre_charge_delay: float,
|
|
185
|
+
session_count: float,
|
|
186
|
+
interval: float = 5.0,
|
|
187
|
+
username: Optional[str] = None,
|
|
188
|
+
password: Optional[str] = None,
|
|
189
|
+
*,
|
|
190
|
+
sim_state: SimulatorState | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Simulate one charge point session.
|
|
193
|
+
|
|
194
|
+
This coroutine closely mirrors the behaviour of the upstream project. A
|
|
195
|
+
charge point connects to the central system, performs a boot notification,
|
|
196
|
+
authorisation and transaction loop while periodically reporting meter
|
|
197
|
+
values. The function is resilient to remote stop requests and reconnects
|
|
198
|
+
if the server closes the connection.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if ws_port:
|
|
202
|
+
uri = f"ws://{host}:{ws_port}/{cp_path}"
|
|
203
|
+
else:
|
|
204
|
+
uri = f"ws://{host}/{cp_path}"
|
|
205
|
+
headers = {}
|
|
206
|
+
if username and password:
|
|
207
|
+
userpass = f"{username}:{password}"
|
|
208
|
+
b64 = base64.b64encode(userpass.encode("utf-8")).decode("ascii")
|
|
209
|
+
headers["Authorization"] = f"Basic {b64}"
|
|
210
|
+
|
|
211
|
+
state = sim_state or _simulators.get(cp_idx + 1, _simulators[1])
|
|
212
|
+
|
|
213
|
+
loop_count = 0
|
|
214
|
+
while loop_count < session_count and state.running:
|
|
215
|
+
ws = None
|
|
216
|
+
reset_event: asyncio.Event | None = None
|
|
217
|
+
try:
|
|
218
|
+
try:
|
|
219
|
+
ws = await websockets.connect(
|
|
220
|
+
uri, subprotocols=["ocpp1.6"], extra_headers=headers
|
|
221
|
+
)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
store.add_log(
|
|
224
|
+
cp_path,
|
|
225
|
+
f"Connection with subprotocol failed: {exc}",
|
|
226
|
+
log_type="simulator",
|
|
227
|
+
)
|
|
228
|
+
ws = await websockets.connect(uri, extra_headers=headers)
|
|
229
|
+
|
|
230
|
+
state.phase = "Connected"
|
|
231
|
+
state.last_message = ""
|
|
232
|
+
store.add_log(
|
|
233
|
+
cp_path,
|
|
234
|
+
f"Connected (subprotocol={ws.subprotocol or 'none'})",
|
|
235
|
+
log_type="simulator",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def _send(payload):
|
|
239
|
+
text = json.dumps(payload)
|
|
240
|
+
await ws.send(text)
|
|
241
|
+
store.add_log(cp_path, f"> {text}", log_type="simulator")
|
|
242
|
+
|
|
243
|
+
async def _recv():
|
|
244
|
+
raw = await ws.recv()
|
|
245
|
+
store.add_log(cp_path, f"< {raw}", log_type="simulator")
|
|
246
|
+
return raw
|
|
247
|
+
|
|
248
|
+
# listen for remote commands
|
|
249
|
+
stop_event = asyncio.Event()
|
|
250
|
+
reset_event = asyncio.Event()
|
|
251
|
+
|
|
252
|
+
async def listen():
|
|
253
|
+
try:
|
|
254
|
+
while True:
|
|
255
|
+
raw = await _recv()
|
|
256
|
+
try:
|
|
257
|
+
msg = json.loads(raw)
|
|
258
|
+
except json.JSONDecodeError:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
if isinstance(msg, list) and msg and msg[0] == 2:
|
|
262
|
+
msg_id, action = msg[1], msg[2]
|
|
263
|
+
await _send([3, msg_id, {}])
|
|
264
|
+
if action == "RemoteStopTransaction":
|
|
265
|
+
state.last_message = "RemoteStopTransaction"
|
|
266
|
+
stop_event.set()
|
|
267
|
+
elif action == "Reset":
|
|
268
|
+
state.last_message = "Reset"
|
|
269
|
+
reset_event.set()
|
|
270
|
+
stop_event.set()
|
|
271
|
+
except websockets.ConnectionClosed:
|
|
272
|
+
stop_event.set()
|
|
273
|
+
|
|
274
|
+
await _send(
|
|
275
|
+
[
|
|
276
|
+
2,
|
|
277
|
+
"boot",
|
|
278
|
+
"BootNotification",
|
|
279
|
+
{
|
|
280
|
+
"chargePointModel": "Simulator",
|
|
281
|
+
"chargePointVendor": "SimVendor",
|
|
282
|
+
"serialNumber": serial_number,
|
|
283
|
+
},
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
state.last_message = "BootNotification"
|
|
287
|
+
await _recv()
|
|
288
|
+
await _send([2, "auth", "Authorize", {"idTag": rfid}])
|
|
289
|
+
state.last_message = "Authorize"
|
|
290
|
+
await _recv()
|
|
291
|
+
|
|
292
|
+
state.phase = "Available"
|
|
293
|
+
|
|
294
|
+
meter_start = random.randint(1000, 2000)
|
|
295
|
+
actual_duration = random.uniform(duration * 0.75, duration * 1.25)
|
|
296
|
+
steps = max(1, int(actual_duration / interval))
|
|
297
|
+
step_min = max(1, int((kw_min * 1000) / steps))
|
|
298
|
+
step_max = max(1, int((kw_max * 1000) / steps))
|
|
299
|
+
|
|
300
|
+
# optional pre‑charge delay while still sending heartbeats
|
|
301
|
+
if pre_charge_delay > 0:
|
|
302
|
+
start_delay = time.monotonic()
|
|
303
|
+
next_meter = meter_start
|
|
304
|
+
last_mv = time.monotonic()
|
|
305
|
+
while time.monotonic() - start_delay < pre_charge_delay:
|
|
306
|
+
await _send([2, "hb", "Heartbeat", {}])
|
|
307
|
+
state.last_message = "Heartbeat"
|
|
308
|
+
await _recv()
|
|
309
|
+
await asyncio.sleep(5)
|
|
310
|
+
if time.monotonic() - last_mv >= 30:
|
|
311
|
+
idle_step = max(2, int(step_max / 100))
|
|
312
|
+
next_meter += random.randint(0, idle_step)
|
|
313
|
+
next_kw = next_meter / 1000.0
|
|
314
|
+
await _send(
|
|
315
|
+
[
|
|
316
|
+
2,
|
|
317
|
+
"meter",
|
|
318
|
+
"MeterValues",
|
|
319
|
+
{
|
|
320
|
+
"connectorId": connector_id,
|
|
321
|
+
"meterValue": [
|
|
322
|
+
{
|
|
323
|
+
"timestamp": time.strftime(
|
|
324
|
+
"%Y-%m-%dT%H:%M:%S"
|
|
325
|
+
)
|
|
326
|
+
+ "Z",
|
|
327
|
+
"sampledValue": [
|
|
328
|
+
{
|
|
329
|
+
"value": f"{next_kw:.3f}",
|
|
330
|
+
"measurand": "Energy.Active.Import.Register",
|
|
331
|
+
"unit": "kW",
|
|
332
|
+
"context": "Sample.Clock",
|
|
333
|
+
}
|
|
334
|
+
],
|
|
335
|
+
}
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
state.last_message = "MeterValues"
|
|
341
|
+
await _recv()
|
|
342
|
+
last_mv = time.monotonic()
|
|
343
|
+
|
|
344
|
+
await _send(
|
|
345
|
+
[
|
|
346
|
+
2,
|
|
347
|
+
"start",
|
|
348
|
+
"StartTransaction",
|
|
349
|
+
{
|
|
350
|
+
"connectorId": connector_id,
|
|
351
|
+
"idTag": rfid,
|
|
352
|
+
"meterStart": meter_start,
|
|
353
|
+
"vin": vin,
|
|
354
|
+
},
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
state.last_message = "StartTransaction"
|
|
358
|
+
resp = await _recv()
|
|
359
|
+
tx_id = json.loads(resp)[2].get("transactionId")
|
|
360
|
+
|
|
361
|
+
state.last_status = "Running"
|
|
362
|
+
state.phase = "Charging"
|
|
363
|
+
|
|
364
|
+
listener = asyncio.create_task(listen())
|
|
365
|
+
|
|
366
|
+
meter = meter_start
|
|
367
|
+
for _ in range(steps):
|
|
368
|
+
if stop_event.is_set():
|
|
369
|
+
break
|
|
370
|
+
meter += random.randint(step_min, step_max)
|
|
371
|
+
meter_kw = meter / 1000.0
|
|
372
|
+
await _send(
|
|
373
|
+
[
|
|
374
|
+
2,
|
|
375
|
+
"meter",
|
|
376
|
+
"MeterValues",
|
|
377
|
+
{
|
|
378
|
+
"connectorId": connector_id,
|
|
379
|
+
"transactionId": tx_id,
|
|
380
|
+
"meterValue": [
|
|
381
|
+
{
|
|
382
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
383
|
+
+ "Z",
|
|
384
|
+
"sampledValue": [
|
|
385
|
+
{
|
|
386
|
+
"value": f"{meter_kw:.3f}",
|
|
387
|
+
"measurand": "Energy.Active.Import.Register",
|
|
388
|
+
"unit": "kW",
|
|
389
|
+
"context": "Sample.Periodic",
|
|
390
|
+
}
|
|
391
|
+
],
|
|
392
|
+
}
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
]
|
|
396
|
+
)
|
|
397
|
+
state.last_message = "MeterValues"
|
|
398
|
+
await asyncio.sleep(interval)
|
|
399
|
+
|
|
400
|
+
listener.cancel()
|
|
401
|
+
try:
|
|
402
|
+
await listener
|
|
403
|
+
except asyncio.CancelledError:
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
await _send(
|
|
407
|
+
[
|
|
408
|
+
2,
|
|
409
|
+
"stop",
|
|
410
|
+
"StopTransaction",
|
|
411
|
+
{
|
|
412
|
+
"transactionId": tx_id,
|
|
413
|
+
"idTag": rfid,
|
|
414
|
+
"meterStop": meter,
|
|
415
|
+
},
|
|
416
|
+
]
|
|
417
|
+
)
|
|
418
|
+
state.last_message = "StopTransaction"
|
|
419
|
+
state.phase = "Available"
|
|
420
|
+
await _recv()
|
|
421
|
+
|
|
422
|
+
# Idle phase: heartbeats and idle meter values
|
|
423
|
+
idle_time = 20 if session_count == 1 else 60
|
|
424
|
+
next_meter = meter
|
|
425
|
+
last_mv = time.monotonic()
|
|
426
|
+
start_idle = time.monotonic()
|
|
427
|
+
while time.monotonic() - start_idle < idle_time and not stop_event.is_set():
|
|
428
|
+
await _send([2, "hb", "Heartbeat", {}])
|
|
429
|
+
state.last_message = "Heartbeat"
|
|
430
|
+
await asyncio.sleep(5)
|
|
431
|
+
if time.monotonic() - last_mv >= 30:
|
|
432
|
+
idle_step = max(2, int(step_max / 100))
|
|
433
|
+
next_meter += random.randint(0, idle_step)
|
|
434
|
+
next_kw = next_meter / 1000.0
|
|
435
|
+
await _send(
|
|
436
|
+
[
|
|
437
|
+
2,
|
|
438
|
+
"meter",
|
|
439
|
+
"MeterValues",
|
|
440
|
+
{
|
|
441
|
+
"connectorId": connector_id,
|
|
442
|
+
"meterValue": [
|
|
443
|
+
{
|
|
444
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
445
|
+
+ "Z",
|
|
446
|
+
"sampledValue": [
|
|
447
|
+
{
|
|
448
|
+
"value": f"{next_kw:.3f}",
|
|
449
|
+
"measurand": "Energy.Active.Import.Register",
|
|
450
|
+
"unit": "kW",
|
|
451
|
+
"context": "Sample.Clock",
|
|
452
|
+
}
|
|
453
|
+
],
|
|
454
|
+
}
|
|
455
|
+
],
|
|
456
|
+
},
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
state.last_message = "MeterValues"
|
|
460
|
+
await _recv()
|
|
461
|
+
last_mv = time.monotonic()
|
|
462
|
+
|
|
463
|
+
except websockets.ConnectionClosedError:
|
|
464
|
+
state.last_status = "Reconnecting"
|
|
465
|
+
state.phase = "Reconnecting"
|
|
466
|
+
await asyncio.sleep(1)
|
|
467
|
+
continue
|
|
468
|
+
except Exception as exc: # pragma: no cover - defensive programming
|
|
469
|
+
state.last_error = str(exc)
|
|
470
|
+
break
|
|
471
|
+
finally:
|
|
472
|
+
if ws is not None:
|
|
473
|
+
await ws.close()
|
|
474
|
+
store.add_log(
|
|
475
|
+
cp_path,
|
|
476
|
+
f"Closed (code={ws.close_code}, reason={getattr(ws, 'close_reason', '')})",
|
|
477
|
+
log_type="simulator",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if reset_event and reset_event.is_set():
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
loop_count += 1
|
|
484
|
+
if session_count == float("inf"):
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
state.last_status = "Stopped"
|
|
488
|
+
state.running = False
|
|
489
|
+
state.phase = "Stopped"
|
|
490
|
+
state.stop_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
491
|
+
_save_state_file(_simulators)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def simulate(
|
|
495
|
+
*,
|
|
496
|
+
host: str = "127.0.0.1",
|
|
497
|
+
ws_port: Optional[int] = 8000,
|
|
498
|
+
rfid: str = "FFFFFFFF",
|
|
499
|
+
cp_path: str = "CPX",
|
|
500
|
+
vin: str = "",
|
|
501
|
+
serial_number: str = "",
|
|
502
|
+
connector_id: int = 1,
|
|
503
|
+
duration: int = 600,
|
|
504
|
+
kw_min: float = 30.0,
|
|
505
|
+
kw_max: float = 60.0,
|
|
506
|
+
pre_charge_delay: float = 0.0,
|
|
507
|
+
repeat: object = False,
|
|
508
|
+
threads: Optional[int] = None,
|
|
509
|
+
daemon: bool = True,
|
|
510
|
+
interval: float = 5.0,
|
|
511
|
+
username: Optional[str] = None,
|
|
512
|
+
password: Optional[str] = None,
|
|
513
|
+
cp: int = 1,
|
|
514
|
+
):
|
|
515
|
+
"""Entry point used by the admin interface.
|
|
516
|
+
|
|
517
|
+
When ``daemon`` is ``True`` a coroutine is returned which must be awaited
|
|
518
|
+
by the caller. When ``daemon`` is ``False`` the function blocks until all
|
|
519
|
+
sessions have completed.
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
session_count = parse_repeat(repeat)
|
|
523
|
+
n_threads = int(threads) if threads else 1
|
|
524
|
+
|
|
525
|
+
state = _simulators.get(cp, _simulators[1])
|
|
526
|
+
state.last_command = "start"
|
|
527
|
+
state.last_status = "Simulator launching..."
|
|
528
|
+
state.running = True
|
|
529
|
+
state.params = {
|
|
530
|
+
"host": host,
|
|
531
|
+
"ws_port": ws_port,
|
|
532
|
+
"rfid": rfid,
|
|
533
|
+
"cp_path": cp_path,
|
|
534
|
+
"vin": vin,
|
|
535
|
+
"serial_number": serial_number,
|
|
536
|
+
"connector_id": connector_id,
|
|
537
|
+
"duration": duration,
|
|
538
|
+
"kw_min": kw_min,
|
|
539
|
+
"kw_max": kw_max,
|
|
540
|
+
"pre_charge_delay": pre_charge_delay,
|
|
541
|
+
"repeat": repeat,
|
|
542
|
+
"threads": threads,
|
|
543
|
+
"daemon": daemon,
|
|
544
|
+
"interval": interval,
|
|
545
|
+
"username": username,
|
|
546
|
+
"password": password,
|
|
547
|
+
}
|
|
548
|
+
state.start_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
549
|
+
state.stop_time = None
|
|
550
|
+
_save_state_file(_simulators)
|
|
551
|
+
|
|
552
|
+
async def orchestrate_all():
|
|
553
|
+
tasks = []
|
|
554
|
+
threads_list = []
|
|
555
|
+
|
|
556
|
+
async def run_task(idx: int) -> None:
|
|
557
|
+
this_cp_path = _unique_cp_path(cp_path, idx, n_threads)
|
|
558
|
+
await simulate_cp(
|
|
559
|
+
idx,
|
|
560
|
+
host,
|
|
561
|
+
ws_port,
|
|
562
|
+
rfid,
|
|
563
|
+
vin,
|
|
564
|
+
this_cp_path,
|
|
565
|
+
serial_number,
|
|
566
|
+
connector_id,
|
|
567
|
+
duration,
|
|
568
|
+
kw_min,
|
|
569
|
+
kw_max,
|
|
570
|
+
pre_charge_delay,
|
|
571
|
+
session_count,
|
|
572
|
+
interval,
|
|
573
|
+
username,
|
|
574
|
+
password,
|
|
575
|
+
sim_state=state,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def run_thread(idx: int) -> None:
|
|
579
|
+
this_cp_path = _unique_cp_path(cp_path, idx, n_threads)
|
|
580
|
+
asyncio.run(
|
|
581
|
+
simulate_cp(
|
|
582
|
+
idx,
|
|
583
|
+
host,
|
|
584
|
+
ws_port,
|
|
585
|
+
rfid,
|
|
586
|
+
vin,
|
|
587
|
+
this_cp_path,
|
|
588
|
+
serial_number,
|
|
589
|
+
connector_id,
|
|
590
|
+
duration,
|
|
591
|
+
kw_min,
|
|
592
|
+
kw_max,
|
|
593
|
+
pre_charge_delay,
|
|
594
|
+
session_count,
|
|
595
|
+
interval,
|
|
596
|
+
username,
|
|
597
|
+
password,
|
|
598
|
+
sim_state=state,
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if n_threads == 1:
|
|
603
|
+
tasks.append(asyncio.create_task(run_task(0)))
|
|
604
|
+
try:
|
|
605
|
+
await asyncio.gather(*tasks)
|
|
606
|
+
except asyncio.CancelledError: # pragma: no cover - orchestration
|
|
607
|
+
for t in tasks:
|
|
608
|
+
t.cancel()
|
|
609
|
+
raise
|
|
610
|
+
else:
|
|
611
|
+
for idx in range(n_threads):
|
|
612
|
+
t = threading.Thread(target=run_thread, args=(idx,), daemon=True)
|
|
613
|
+
t.start()
|
|
614
|
+
threads_list.append(t)
|
|
615
|
+
try:
|
|
616
|
+
while any(t.is_alive() for t in threads_list):
|
|
617
|
+
await asyncio.sleep(0.5)
|
|
618
|
+
except asyncio.CancelledError: # pragma: no cover
|
|
619
|
+
pass
|
|
620
|
+
finally:
|
|
621
|
+
for t in threads_list:
|
|
622
|
+
t.join()
|
|
623
|
+
|
|
624
|
+
state.last_status = "Simulator finished."
|
|
625
|
+
state.running = False
|
|
626
|
+
state.stop_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
627
|
+
_save_state_file(_simulators)
|
|
628
|
+
|
|
629
|
+
if daemon:
|
|
630
|
+
return orchestrate_all()
|
|
631
|
+
|
|
632
|
+
if n_threads == 1:
|
|
633
|
+
asyncio.run(
|
|
634
|
+
simulate_cp(
|
|
635
|
+
0,
|
|
636
|
+
host,
|
|
637
|
+
ws_port,
|
|
638
|
+
rfid,
|
|
639
|
+
vin,
|
|
640
|
+
cp_path,
|
|
641
|
+
serial_number,
|
|
642
|
+
connector_id,
|
|
643
|
+
duration,
|
|
644
|
+
kw_min,
|
|
645
|
+
kw_max,
|
|
646
|
+
pre_charge_delay,
|
|
647
|
+
session_count,
|
|
648
|
+
interval,
|
|
649
|
+
username,
|
|
650
|
+
password,
|
|
651
|
+
sim_state=state,
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
else:
|
|
655
|
+
threads_list = []
|
|
656
|
+
for idx in range(n_threads):
|
|
657
|
+
this_cp_path = _unique_cp_path(cp_path, idx, n_threads)
|
|
658
|
+
t = threading.Thread(
|
|
659
|
+
target=_thread_runner,
|
|
660
|
+
args=(
|
|
661
|
+
simulate_cp,
|
|
662
|
+
idx,
|
|
663
|
+
host,
|
|
664
|
+
ws_port,
|
|
665
|
+
rfid,
|
|
666
|
+
vin,
|
|
667
|
+
this_cp_path,
|
|
668
|
+
serial_number,
|
|
669
|
+
connector_id,
|
|
670
|
+
duration,
|
|
671
|
+
kw_min,
|
|
672
|
+
kw_max,
|
|
673
|
+
pre_charge_delay,
|
|
674
|
+
session_count,
|
|
675
|
+
interval,
|
|
676
|
+
username,
|
|
677
|
+
password,
|
|
678
|
+
),
|
|
679
|
+
kwargs={"sim_state": state},
|
|
680
|
+
daemon=True,
|
|
681
|
+
)
|
|
682
|
+
t.start()
|
|
683
|
+
threads_list.append(t)
|
|
684
|
+
for t in threads_list:
|
|
685
|
+
t.join()
|
|
686
|
+
|
|
687
|
+
state.last_status = "Simulator finished."
|
|
688
|
+
state.running = False
|
|
689
|
+
state.stop_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
690
|
+
_save_state_file(_simulators)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ---------------------------------------------------------------------------
|
|
694
|
+
# Convenience helpers used by administrative tasks
|
|
695
|
+
# ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _start_simulator(
|
|
699
|
+
params: Optional[Dict[str, object]] = None, cp: int = 1
|
|
700
|
+
) -> tuple[bool, str, str]:
|
|
701
|
+
"""Start the simulator using the provided parameters.
|
|
702
|
+
|
|
703
|
+
Returns a tuple ``(started, status_message, log_file)`` where ``started``
|
|
704
|
+
indicates whether the simulator was launched successfully, the
|
|
705
|
+
``status_message`` reflects the result of attempting to connect and
|
|
706
|
+
``log_file`` is the path to the log capturing all simulator traffic.
|
|
707
|
+
"""
|
|
708
|
+
|
|
709
|
+
state = _simulators[cp]
|
|
710
|
+
cp_path = (params or {}).get(
|
|
711
|
+
"cp_path", (state.params or {}).get("cp_path", f"CP{cp}")
|
|
712
|
+
)
|
|
713
|
+
log_file = str(store._file_path(cp_path, log_type="simulator"))
|
|
714
|
+
|
|
715
|
+
if state.running:
|
|
716
|
+
return False, "already running", log_file
|
|
717
|
+
|
|
718
|
+
state.last_error = ""
|
|
719
|
+
state.last_command = "start"
|
|
720
|
+
state.last_status = "Simulator launching..."
|
|
721
|
+
state.last_message = ""
|
|
722
|
+
state.phase = "Starting"
|
|
723
|
+
state.params = params or {}
|
|
724
|
+
state.running = True
|
|
725
|
+
state.start_time = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
726
|
+
state.stop_time = None
|
|
727
|
+
_save_state_file(_simulators)
|
|
728
|
+
|
|
729
|
+
coro = simulate(cp=cp, **state.params)
|
|
730
|
+
threading.Thread(target=lambda: asyncio.run(coro), daemon=True).start()
|
|
731
|
+
|
|
732
|
+
# Wait for initial connection result
|
|
733
|
+
start_wait = time.time()
|
|
734
|
+
status_msg = "Connection timeout"
|
|
735
|
+
while time.time() - start_wait < 15:
|
|
736
|
+
if state.last_error:
|
|
737
|
+
state.running = False
|
|
738
|
+
status_msg = f"Connection failed: {state.last_error}"
|
|
739
|
+
break
|
|
740
|
+
if state.phase == "Available":
|
|
741
|
+
status_msg = "Connection accepted"
|
|
742
|
+
break
|
|
743
|
+
if not state.running:
|
|
744
|
+
status_msg = "Connection failed"
|
|
745
|
+
break
|
|
746
|
+
time.sleep(0.1)
|
|
747
|
+
|
|
748
|
+
state.last_status = status_msg
|
|
749
|
+
_save_state_file(_simulators)
|
|
750
|
+
|
|
751
|
+
return state.running and status_msg == "Connection accepted", status_msg, log_file
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _stop_simulator(cp: int = 1) -> bool:
|
|
755
|
+
"""Mark the simulator as requested to stop."""
|
|
756
|
+
|
|
757
|
+
state = _simulators[cp]
|
|
758
|
+
state.last_command = "stop"
|
|
759
|
+
state.last_status = "Requested stop (will finish current run)..."
|
|
760
|
+
state.phase = "Stopping"
|
|
761
|
+
state.running = False
|
|
762
|
+
_save_state_file(_simulators)
|
|
763
|
+
return True
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _export_state(state: SimulatorState) -> Dict[str, object]:
|
|
767
|
+
return {
|
|
768
|
+
"running": state.running,
|
|
769
|
+
"last_status": state.last_status,
|
|
770
|
+
"last_command": state.last_command,
|
|
771
|
+
"last_error": state.last_error,
|
|
772
|
+
"last_message": state.last_message,
|
|
773
|
+
"phase": state.phase,
|
|
774
|
+
"start_time": state.start_time,
|
|
775
|
+
"stop_time": state.stop_time,
|
|
776
|
+
"params": state.params or {},
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _simulator_status_json(cp: Optional[int] = None) -> str:
|
|
781
|
+
"""Return a JSON representation of the simulator state."""
|
|
782
|
+
|
|
783
|
+
if cp is not None:
|
|
784
|
+
return json.dumps(_export_state(_simulators[cp]), indent=2)
|
|
785
|
+
return json.dumps(
|
|
786
|
+
{str(idx): _export_state(st) for idx, st in _simulators.items()}, indent=2
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def get_simulator_state(cp: Optional[int] = None, refresh_file: bool = False):
|
|
791
|
+
"""Return the current simulator state.
|
|
792
|
+
|
|
793
|
+
When ``refresh_file`` is ``True`` the persisted state file is reloaded.
|
|
794
|
+
This mirrors the behaviour of the original implementation which allowed a
|
|
795
|
+
separate process to query the running simulator.
|
|
796
|
+
"""
|
|
797
|
+
|
|
798
|
+
if refresh_file:
|
|
799
|
+
file_state = _load_state_file()
|
|
800
|
+
for key, val in file_state.items():
|
|
801
|
+
try:
|
|
802
|
+
idx = int(key)
|
|
803
|
+
except ValueError: # pragma: no cover - defensive
|
|
804
|
+
continue
|
|
805
|
+
if idx in _simulators:
|
|
806
|
+
_simulators[idx].__dict__.update(val)
|
|
807
|
+
|
|
808
|
+
if cp is not None:
|
|
809
|
+
return _export_state(_simulators[cp])
|
|
810
|
+
return {idx: _export_state(st) for idx, st in _simulators.items()}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
# The original file exposed ``view_cp_simulator`` which rendered an HTML user
|
|
814
|
+
# interface. Implementing that functionality would require additional
|
|
815
|
+
# third‑party dependencies. For the scope of the exercises the function is
|
|
816
|
+
# retained as a simple placeholder so importing the module does not fail.
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def view_cp_simulator(*args, **kwargs): # pragma: no cover - UI stub
|
|
820
|
+
"""Placeholder for the web based simulator view.
|
|
821
|
+
|
|
822
|
+
The real project renders a dynamic HTML page. Returning a short explanatory
|
|
823
|
+
string keeps the public API compatible for callers that expect a return
|
|
824
|
+
value while avoiding heavy dependencies.
|
|
825
|
+
"""
|
|
826
|
+
|
|
827
|
+
return "Simulator web UI is not available in this environment."
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def view_simulator(*args, **kwargs): # pragma: no cover - simple alias
|
|
831
|
+
return view_cp_simulator(*args, **kwargs)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
__all__ = [
|
|
835
|
+
"simulate",
|
|
836
|
+
"simulate_cp",
|
|
837
|
+
"parse_repeat",
|
|
838
|
+
"_start_simulator",
|
|
839
|
+
"_stop_simulator",
|
|
840
|
+
"_simulator_status_json",
|
|
841
|
+
"get_simulator_state",
|
|
842
|
+
"view_cp_simulator",
|
|
843
|
+
"view_simulator",
|
|
844
|
+
]
|