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