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.

Files changed (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {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
- ) -> None:
190
- """Simulate one charge point session.
191
-
192
- This coroutine closely mirrors the behaviour of the upstream project. A
193
- charge point connects to the central system, performs a boot notification,
194
- authorisation and transaction loop while periodically reporting meter
195
- values. The function is resilient to remote stop requests and reconnects
196
- if the server closes the connection.
197
- """
198
-
199
- uri = f"ws://{host}:{ws_port}/{cp_path}"
200
- headers = {}
201
- if username and password:
202
- userpass = f"{username}:{password}"
203
- b64 = base64.b64encode(userpass.encode("utf-8")).decode("ascii")
204
- headers["Authorization"] = f"Basic {b64}"
205
-
206
- state = _simulators.get(cp_idx + 1, _simulators[1])
207
-
208
- loop_count = 0
209
- while loop_count < session_count and state.running:
210
- ws = None
211
- reset_event: asyncio.Event | None = None
212
- try:
213
- try:
214
- ws = await websockets.connect(
215
- uri, subprotocols=["ocpp1.6"], extra_headers=headers
216
- )
217
- except Exception as exc:
218
- store.add_log(
219
- cp_path,
220
- f"Connection with subprotocol failed: {exc}",
221
- log_type="simulator",
222
- )
223
- ws = await websockets.connect(uri, extra_headers=headers)
224
-
225
- state.phase = "Connected"
226
- state.last_message = ""
227
- store.add_log(
228
- cp_path,
229
- f"Connected (subprotocol={ws.subprotocol or 'none'})",
230
- log_type="simulator",
231
- )
232
-
233
- async def _send(payload):
234
- text = json.dumps(payload)
235
- await ws.send(text)
236
- store.add_log(cp_path, f"> {text}", log_type="simulator")
237
-
238
- async def _recv():
239
- raw = await ws.recv()
240
- store.add_log(cp_path, f"< {raw}", log_type="simulator")
241
- return raw
242
-
243
- # listen for remote commands
244
- stop_event = asyncio.Event()
245
- reset_event = asyncio.Event()
246
-
247
- async def listen():
248
- try:
249
- while True:
250
- raw = await _recv()
251
- try:
252
- msg = json.loads(raw)
253
- except json.JSONDecodeError:
254
- continue
255
-
256
- if isinstance(msg, list) and msg and msg[0] == 2:
257
- msg_id, action = msg[1], msg[2]
258
- await _send([3, msg_id, {}])
259
- if action == "RemoteStopTransaction":
260
- state.last_message = "RemoteStopTransaction"
261
- stop_event.set()
262
- elif action == "Reset":
263
- state.last_message = "Reset"
264
- reset_event.set()
265
- stop_event.set()
266
- except websockets.ConnectionClosed:
267
- stop_event.set()
268
-
269
- # boot notification / authorise
270
- await _send(
271
- [
272
- 2,
273
- "boot",
274
- "BootNotification",
275
- {
276
- "chargePointModel": "Simulator",
277
- "chargePointVendor": "SimVendor",
278
- "serialNumber": serial_number,
279
- },
280
- ]
281
- )
282
- state.last_message = "BootNotification"
283
- await _recv()
284
- await _send([2, "auth", "Authorize", {"idTag": rfid}])
285
- state.last_message = "Authorize"
286
- await _recv()
287
-
288
- state.phase = "Available"
289
-
290
- meter_start = random.randint(1000, 2000)
291
- actual_duration = random.uniform(duration * 0.75, duration * 1.25)
292
- steps = max(1, int(actual_duration / interval))
293
- step_min = max(1, int((kw_min * 1000) / steps))
294
- step_max = max(1, int((kw_max * 1000) / steps))
295
-
296
- # optional pre‑charge delay while still sending heartbeats
297
- if pre_charge_delay > 0:
298
- start_delay = time.monotonic()
299
- next_meter = meter_start
300
- last_mv = time.monotonic()
301
- while time.monotonic() - start_delay < pre_charge_delay:
302
- await _send([2, "hb", "Heartbeat", {}])
303
- state.last_message = "Heartbeat"
304
- await _recv()
305
- await asyncio.sleep(5)
306
- if time.monotonic() - last_mv >= 30:
307
- idle_step = max(2, int(step_max / 100))
308
- next_meter += random.randint(0, idle_step)
309
- next_kw = next_meter / 1000.0
310
- await _send(
311
- [
312
- 2,
313
- "meter",
314
- "MeterValues",
315
- {
316
- "connectorId": connector_id,
317
- "meterValue": [
318
- {
319
- "timestamp": time.strftime(
320
- "%Y-%m-%dT%H:%M:%S"
321
- )
322
- + "Z",
323
- "sampledValue": [
324
- {
325
- "value": f"{next_kw:.3f}",
326
- "measurand": "Energy.Active.Import.Register",
327
- "unit": "kW",
328
- "context": "Sample.Clock",
329
- }
330
- ],
331
- }
332
- ],
333
- },
334
- ]
335
- )
336
- state.last_message = "MeterValues"
337
- await _recv()
338
- last_mv = time.monotonic()
339
-
340
- async def listen():
341
- try:
342
- while True:
343
- raw = await _recv()
344
- try:
345
- msg = json.loads(raw)
346
- except json.JSONDecodeError:
347
- continue
348
-
349
- if isinstance(msg, list) and msg and msg[0] == 2:
350
- msg_id, action = msg[1], msg[2]
351
- await _send([3, msg_id, {}])
352
- if action == "RemoteStopTransaction":
353
- state.last_message = "RemoteStopTransaction"
354
- stop_event.set()
355
- elif action == "Reset":
356
- state.last_message = "Reset"
357
- reset_event.set()
358
- stop_event.set()
359
- except websockets.ConnectionClosed:
360
- stop_event.set()
361
-
362
- await _send(
363
- [
364
- 2,
365
- "boot",
366
- "BootNotification",
367
- {
368
- "chargePointModel": "Simulator",
369
- "chargePointVendor": "SimVendor",
370
- "serialNumber": serial_number,
371
- },
372
- ]
373
- )
374
- state.last_message = "BootNotification"
375
- await _recv()
376
- await _send([2, "auth", "Authorize", {"idTag": rfid}])
377
- state.last_message = "Authorize"
378
- await _recv()
379
-
380
- state.phase = "Available"
381
-
382
- meter_start = random.randint(1000, 2000)
383
- actual_duration = random.uniform(duration * 0.75, duration * 1.25)
384
- steps = max(1, int(actual_duration / interval))
385
- step_min = max(1, int((kw_min * 1000) / steps))
386
- step_max = max(1, int((kw_max * 1000) / steps))
387
-
388
- # optional pre‑charge delay while still sending heartbeats
389
- if pre_charge_delay > 0:
390
- start_delay = time.monotonic()
391
- next_meter = meter_start
392
- last_mv = time.monotonic()
393
- while time.monotonic() - start_delay < pre_charge_delay:
394
- await _send([2, "hb", "Heartbeat", {}])
395
- state.last_message = "Heartbeat"
396
- await _recv()
397
- await asyncio.sleep(5)
398
- if time.monotonic() - last_mv >= 30:
399
- idle_step = max(2, int(step_max / 100))
400
- next_meter += random.randint(0, idle_step)
401
- next_kw = next_meter / 1000.0
402
- await _send(
403
- [
404
- 2,
405
- "meter",
406
- "MeterValues",
407
- {
408
- "connectorId": connector_id,
409
- "meterValue": [
410
- {
411
- "timestamp": time.strftime(
412
- "%Y-%m-%dT%H:%M:%S"
413
- )
414
- + "Z",
415
- "sampledValue": [
416
- {
417
- "value": f"{next_kw:.3f}",
418
- "measurand": "Energy.Active.Import.Register",
419
- "unit": "kW",
420
- "context": "Sample.Clock",
421
- }
422
- ],
423
- }
424
- ],
425
- },
426
- ]
427
- )
428
- state.last_message = "MeterValues"
429
- await _recv()
430
- last_mv = time.monotonic()
431
-
432
- await _send(
433
- [
434
- 2,
435
- "start",
436
- "StartTransaction",
437
- {
438
- "connectorId": connector_id,
439
- "idTag": rfid,
440
- "meterStart": meter_start,
441
- "vin": vin,
442
- },
443
- ]
444
- )
445
- state.last_message = "StartTransaction"
446
- resp = await _recv()
447
- tx_id = json.loads(resp)[2].get("transactionId")
448
-
449
- state.last_status = "Running"
450
- state.phase = "Charging"
451
-
452
- listener = asyncio.create_task(listen())
453
-
454
- meter = meter_start
455
- for _ in range(steps):
456
- if stop_event.is_set():
457
- break
458
- meter += random.randint(step_min, step_max)
459
- meter_kw = meter / 1000.0
460
- await _send(
461
- [
462
- 2,
463
- "meter",
464
- "MeterValues",
465
- {
466
- "connectorId": connector_id,
467
- "transactionId": tx_id,
468
- "meterValue": [
469
- {
470
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S")
471
- + "Z",
472
- "sampledValue": [
473
- {
474
- "value": f"{meter_kw:.3f}",
475
- "measurand": "Energy.Active.Import.Register",
476
- "unit": "kW",
477
- "context": "Sample.Periodic",
478
- }
479
- ],
480
- }
481
- ],
482
- },
483
- ]
484
- )
485
- state.last_message = "MeterValues"
486
- await asyncio.sleep(interval)
487
-
488
- listener.cancel()
489
- try:
490
- await listener
491
- except asyncio.CancelledError:
492
- pass
493
-
494
- await _send(
495
- [
496
- 2,
497
- "stop",
498
- "StopTransaction",
499
- {
500
- "transactionId": tx_id,
501
- "idTag": rfid,
502
- "meterStop": meter,
503
- },
504
- ]
505
- )
506
- state.last_message = "StopTransaction"
507
- state.phase = "Available"
508
- await _recv()
509
-
510
- # Idle phase: heartbeats and idle meter values
511
- idle_time = 20 if session_count == 1 else 60
512
- next_meter = meter
513
- last_mv = time.monotonic()
514
- start_idle = time.monotonic()
515
- while time.monotonic() - start_idle < idle_time and not stop_event.is_set():
516
- await _send([2, "hb", "Heartbeat", {}])
517
- state.last_message = "Heartbeat"
518
- await asyncio.sleep(5)
519
- if time.monotonic() - last_mv >= 30:
520
- idle_step = max(2, int(step_max / 100))
521
- next_meter += random.randint(0, idle_step)
522
- next_kw = next_meter / 1000.0
523
- await _send(
524
- [
525
- 2,
526
- "meter",
527
- "MeterValues",
528
- {
529
- "connectorId": connector_id,
530
- "meterValue": [
531
- {
532
- "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S")
533
- + "Z",
534
- "sampledValue": [
535
- {
536
- "value": f"{next_kw:.3f}",
537
- "measurand": "Energy.Active.Import.Register",
538
- "unit": "kW",
539
- "context": "Sample.Clock",
540
- }
541
- ],
542
- }
543
- ],
544
- },
545
- ]
546
- )
547
- state.last_message = "MeterValues"
548
- await _recv()
549
- last_mv = time.monotonic()
550
-
551
- except websockets.ConnectionClosedError:
552
- state.last_status = "Reconnecting"
553
- state.phase = "Reconnecting"
554
- await asyncio.sleep(1)
555
- continue
556
- except Exception as exc: # pragma: no cover - defensive programming
557
- state.last_error = str(exc)
558
- break
559
- finally:
560
- if ws is not None:
561
- await ws.close()
562
- store.add_log(
563
- cp_path,
564
- f"Closed (code={ws.close_code}, reason={getattr(ws, 'close_reason', '')})",
565
- log_type="simulator",
566
- )
567
-
568
- if reset_event and reset_event.is_set():
569
- continue
570
-
571
- loop_count += 1
572
- if session_count == float("inf"):
573
- continue
574
-
575
- state.last_status = "Stopped"
576
- state.running = False
577
- state.phase = "Stopped"
578
- state.stop_time = time.strftime("%Y-%m-%d %H:%M:%S")
579
- _save_state_file(_simulators)
580
-
581
-
582
- def simulate(
583
- *,
584
- host: str = "127.0.0.1",
585
- ws_port: int = 8000,
586
- rfid: str = "FFFFFFFF",
587
- cp_path: str = "CPX",
588
- vin: str = "",
589
- serial_number: str = "",
590
- connector_id: int = 1,
591
- duration: int = 600,
592
- kw_min: float = 30.0,
593
- kw_max: float = 60.0,
594
- pre_charge_delay: float = 0.0,
595
- repeat: object = False,
596
- threads: Optional[int] = None,
597
- daemon: bool = True,
598
- interval: float = 5.0,
599
- username: Optional[str] = None,
600
- password: Optional[str] = None,
601
- cp: int = 1,
602
- ):
603
- """Entry point used by the admin interface.
604
-
605
- When ``daemon`` is ``True`` a coroutine is returned which must be awaited
606
- by the caller. When ``daemon`` is ``False`` the function blocks until all
607
- sessions have completed.
608
- """
609
-
610
- session_count = parse_repeat(repeat)
611
- n_threads = int(threads) if threads else 1
612
-
613
- state = _simulators.get(cp, _simulators[1])
614
- state.last_command = "start"
615
- state.last_status = "Simulator launching..."
616
- state.running = True
617
- state.params = {
618
- "host": host,
619
- "ws_port": ws_port,
620
- "rfid": rfid,
621
- "cp_path": cp_path,
622
- "vin": vin,
623
- "serial_number": serial_number,
624
- "connector_id": connector_id,
625
- "duration": duration,
626
- "kw_min": kw_min,
627
- "kw_max": kw_max,
628
- "pre_charge_delay": pre_charge_delay,
629
- "repeat": repeat,
630
- "threads": threads,
631
- "daemon": daemon,
632
- "interval": interval,
633
- "username": username,
634
- "password": password,
635
- }
636
- state.start_time = time.strftime("%Y-%m-%d %H:%M:%S")
637
- state.stop_time = None
638
- _save_state_file(_simulators)
639
-
640
- async def orchestrate_all():
641
- tasks = []
642
- threads_list = []
643
-
644
- async def run_task(idx: int) -> None:
645
- this_cp_path = _unique_cp_path(cp_path, idx, n_threads)
646
- await simulate_cp(
647
- idx,
648
- host,
649
- ws_port,
650
- rfid,
651
- vin,
652
- this_cp_path,
653
- serial_number,
654
- connector_id,
655
- duration,
656
- kw_min,
657
- kw_max,
658
- pre_charge_delay,
659
- session_count,
660
- interval,
661
- username,
662
- password,
663
- )
664
-
665
- def run_thread(idx: int) -> None:
666
- this_cp_path = _unique_cp_path(cp_path, idx, n_threads)
667
- asyncio.run(
668
- simulate_cp(
669
- idx,
670
- host,
671
- ws_port,
672
- rfid,
673
- vin,
674
- this_cp_path,
675
- serial_number,
676
- connector_id,
677
- duration,
678
- kw_min,
679
- kw_max,
680
- pre_charge_delay,
681
- session_count,
682
- interval,
683
- username,
684
- password,
685
- )
686
- )
687
-
688
- if n_threads == 1:
689
- tasks.append(asyncio.create_task(run_task(0)))
690
- try:
691
- await asyncio.gather(*tasks)
692
- except asyncio.CancelledError: # pragma: no cover - orchestration
693
- for t in tasks:
694
- t.cancel()
695
- raise
696
- else:
697
- for idx in range(n_threads):
698
- t = threading.Thread(target=run_thread, args=(idx,), daemon=True)
699
- t.start()
700
- threads_list.append(t)
701
- try:
702
- while any(t.is_alive() for t in threads_list):
703
- await asyncio.sleep(0.5)
704
- except asyncio.CancelledError: # pragma: no cover
705
- pass
706
- finally:
707
- for t in threads_list:
708
- t.join()
709
-
710
- state.last_status = "Simulator finished."
711
- state.running = False
712
- state.stop_time = time.strftime("%Y-%m-%d %H:%M:%S")
713
- _save_state_file(_simulators)
714
-
715
- if daemon:
716
- return orchestrate_all()
717
-
718
- if n_threads == 1:
719
- asyncio.run(
720
- simulate_cp(
721
- 0,
722
- host,
723
- ws_port,
724
- rfid,
725
- vin,
726
- cp_path,
727
- serial_number,
728
- connector_id,
729
- duration,
730
- kw_min,
731
- kw_max,
732
- pre_charge_delay,
733
- session_count,
734
- interval,
735
- username,
736
- password,
737
- )
738
- )
739
- else:
740
- threads_list = []
741
- for idx in range(n_threads):
742
- this_cp_path = _unique_cp_path(cp_path, idx, n_threads)
743
- t = threading.Thread(
744
- target=_thread_runner,
745
- args=(
746
- simulate_cp,
747
- idx,
748
- host,
749
- ws_port,
750
- rfid,
751
- vin,
752
- this_cp_path,
753
- serial_number,
754
- connector_id,
755
- duration,
756
- kw_min,
757
- kw_max,
758
- pre_charge_delay,
759
- session_count,
760
- interval,
761
- username,
762
- password,
763
- ),
764
- daemon=True,
765
- )
766
- t.start()
767
- threads_list.append(t)
768
- for t in threads_list:
769
- t.join()
770
-
771
- state.last_status = "Simulator finished."
772
- state.running = False
773
- state.stop_time = time.strftime("%Y-%m-%d %H:%M:%S")
774
- _save_state_file(_simulators)
775
-
776
-
777
- # ---------------------------------------------------------------------------
778
- # Convenience helpers used by administrative tasks
779
- # ---------------------------------------------------------------------------
780
-
781
-
782
- def _start_simulator(
783
- params: Optional[Dict[str, object]] = None, cp: int = 1
784
- ) -> tuple[bool, str, str]:
785
- """Start the simulator using the provided parameters.
786
-
787
- Returns a tuple ``(started, status_message, log_file)`` where ``started``
788
- indicates whether the simulator was launched successfully, the
789
- ``status_message`` reflects the result of attempting to connect and
790
- ``log_file`` is the path to the log capturing all simulator traffic.
791
- """
792
-
793
- state = _simulators[cp]
794
- cp_path = (params or {}).get(
795
- "cp_path", (state.params or {}).get("cp_path", f"CP{cp}")
796
- )
797
- log_file = str(store._file_path(cp_path, log_type="simulator"))
798
-
799
- if state.running:
800
- return False, "already running", log_file
801
-
802
- state.last_error = ""
803
- state.last_command = "start"
804
- state.last_status = "Simulator launching..."
805
- state.last_message = ""
806
- state.phase = "Starting"
807
- state.params = params or {}
808
- state.running = True
809
- state.start_time = time.strftime("%Y-%m-%d %H:%M:%S")
810
- state.stop_time = None
811
- _save_state_file(_simulators)
812
-
813
- coro = simulate(cp=cp, **state.params)
814
- threading.Thread(target=lambda: asyncio.run(coro), daemon=True).start()
815
-
816
- # Wait for initial connection result
817
- start_wait = time.time()
818
- status_msg = "Connection timeout"
819
- while time.time() - start_wait < 15:
820
- if state.last_error:
821
- state.running = False
822
- status_msg = f"Connection failed: {state.last_error}"
823
- break
824
- if state.phase == "Available":
825
- status_msg = "Connection accepted"
826
- break
827
- if not state.running:
828
- status_msg = "Connection failed"
829
- break
830
- time.sleep(0.1)
831
-
832
- state.last_status = status_msg
833
- _save_state_file(_simulators)
834
-
835
- return state.running and status_msg == "Connection accepted", status_msg, log_file
836
-
837
-
838
- def _stop_simulator(cp: int = 1) -> bool:
839
- """Mark the simulator as requested to stop."""
840
-
841
- state = _simulators[cp]
842
- state.last_command = "stop"
843
- state.last_status = "Requested stop (will finish current run)..."
844
- state.phase = "Stopping"
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
+ ]