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/simulator.py CHANGED
@@ -1,368 +1,745 @@
1
- import asyncio
2
- import base64
3
- import json
4
- import random
5
- import time
6
- from dataclasses import dataclass
7
- from typing import Optional
8
- import threading
9
-
10
- import websockets
11
- from config.offline import requires_network
12
-
13
- from . import store
14
-
15
-
16
- @dataclass
17
- class SimulatorConfig:
18
- """Configuration for a simulated charge point."""
19
-
20
- host: str = "127.0.0.1"
21
- ws_port: int = 8000
22
- rfid: str = "FFFFFFFF"
23
- vin: str = ""
24
- # WebSocket path for the charge point. Defaults to just the charger ID at the root.
25
- cp_path: str = "CPX/"
26
- duration: int = 600
27
- kw_min: float = 30.0
28
- kw_max: float = 60.0
29
- interval: float = 5.0
30
- pre_charge_delay: float = 10.0
31
- repeat: bool = False
32
- username: Optional[str] = None
33
- password: Optional[str] = None
34
- serial_number: str = ""
35
- connector_id: int = 1
36
-
37
-
38
- class ChargePointSimulator:
39
- """Lightweight simulator for a single OCPP 1.6 charge point."""
40
-
41
- def __init__(self, config: SimulatorConfig) -> None:
42
- self.config = config
43
- self._thread: Optional[threading.Thread] = None
44
- self._stop_event = threading.Event()
45
- self.status = "stopped"
46
- self._connected = threading.Event()
47
- self._connect_error = ""
48
-
49
- @requires_network
50
- async def _run_session(self) -> None:
51
- cfg = self.config
52
- uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
53
- headers = {}
54
- if cfg.username and cfg.password:
55
- userpass = f"{cfg.username}:{cfg.password}"
56
- b64 = base64.b64encode(userpass.encode()).decode()
57
- headers["Authorization"] = f"Basic {b64}"
58
-
59
- ws = None
60
- try:
61
- try:
62
- ws = await websockets.connect(
63
- uri, subprotocols=["ocpp1.6"], extra_headers=headers
64
- )
65
- except Exception as exc:
66
- store.add_log(
67
- cfg.cp_path,
68
- f"Connection with subprotocol failed: {exc}",
69
- log_type="simulator",
70
- )
71
- ws = await websockets.connect(uri, extra_headers=headers)
72
-
73
- store.add_log(
74
- cfg.cp_path,
75
- f"Connected (subprotocol={ws.subprotocol or 'none'})",
76
- log_type="simulator",
77
- )
78
-
79
- async def send(msg: str) -> None:
80
- try:
81
- await ws.send(msg)
82
- except Exception:
83
- self.status = "error"
84
- raise
85
- store.add_log(cfg.cp_path, f"> {msg}", log_type="simulator")
86
-
87
- async def recv() -> str:
88
- try:
89
- raw = await asyncio.wait_for(ws.recv(), timeout=60)
90
- except asyncio.TimeoutError:
91
- self.status = "stopped"
92
- self._stop_event.set()
93
- store.add_log(
94
- cfg.cp_path,
95
- "Timeout waiting for response from charger",
96
- log_type="simulator",
97
- )
98
- raise
99
- except websockets.exceptions.ConnectionClosed:
100
- self.status = "stopped"
101
- self._stop_event.set()
102
- raise
103
- except Exception:
104
- self.status = "error"
105
- raise
106
- store.add_log(cfg.cp_path, f"< {raw}", log_type="simulator")
107
- return raw
108
-
109
- # handshake
110
- boot = json.dumps(
111
- [
112
- 2,
113
- "boot",
114
- "BootNotification",
115
- {
116
- "chargePointModel": "Simulator",
117
- "chargePointVendor": "SimVendor",
118
- "serialNumber": cfg.serial_number,
119
- },
120
- ]
121
- )
122
- await send(boot)
123
- try:
124
- resp = json.loads(await recv())
125
- except Exception:
126
- self.status = "error"
127
- raise
128
- status = resp[2].get("status")
129
- if status != "Accepted":
130
- if not self._connected.is_set():
131
- self._connect_error = f"Boot status {status}"
132
- self._connected.set()
133
- return
134
-
135
- await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
136
- await recv()
137
- if not self._connected.is_set():
138
- self.status = "running"
139
- self._connect_error = "accepted"
140
- self._connected.set()
141
- if cfg.pre_charge_delay > 0:
142
- idle_start = time.monotonic()
143
- while time.monotonic() - idle_start < cfg.pre_charge_delay:
144
- await send(
145
- json.dumps(
146
- [
147
- 2,
148
- "status",
149
- "StatusNotification",
150
- {
151
- "connectorId": cfg.connector_id,
152
- "errorCode": "NoError",
153
- "status": "Available",
154
- },
155
- ]
156
- )
157
- )
158
- await recv()
159
- await send(json.dumps([2, "hb", "Heartbeat", {}]))
160
- await recv()
161
- await send(
162
- json.dumps(
163
- [
164
- 2,
165
- "meter",
166
- "MeterValues",
167
- {
168
- "connectorId": cfg.connector_id,
169
- "meterValue": [
170
- {
171
- "timestamp": time.strftime(
172
- "%Y-%m-%dT%H:%M:%SZ"
173
- ),
174
- "sampledValue": [
175
- {
176
- "value": "0",
177
- "measurand": "Energy.Active.Import.Register",
178
- "unit": "kW",
179
- }
180
- ],
181
- }
182
- ],
183
- },
184
- ]
185
- )
186
- )
187
- await recv()
188
- await asyncio.sleep(cfg.interval)
189
-
190
- meter_start = random.randint(1000, 2000)
191
- await send(
192
- json.dumps(
193
- [
194
- 2,
195
- "start",
196
- "StartTransaction",
197
- {
198
- "connectorId": cfg.connector_id,
199
- "idTag": cfg.rfid,
200
- "meterStart": meter_start,
201
- "vin": cfg.vin,
202
- },
203
- ]
204
- )
205
- )
206
- try:
207
- resp = json.loads(await recv())
208
- except Exception:
209
- self.status = "error"
210
- raise
211
- tx_id = resp[2].get("transactionId")
212
-
213
- meter = meter_start
214
- steps = max(1, int(cfg.duration / cfg.interval))
215
- target_kwh = cfg.kw_max * random.uniform(0.9, 1.1)
216
- step_avg = (target_kwh * 1000) / steps
217
-
218
- start_time = time.monotonic()
219
- while time.monotonic() - start_time < cfg.duration:
220
- if self._stop_event.is_set():
221
- break
222
- inc = random.gauss(step_avg, step_avg * 0.05)
223
- meter += max(1, int(inc))
224
- meter_kw = meter / 1000.0
225
- await send(
226
- json.dumps(
227
- [
228
- 2,
229
- "meter",
230
- "MeterValues",
231
- {
232
- "connectorId": cfg.connector_id,
233
- "transactionId": tx_id,
234
- "meterValue": [
235
- {
236
- "timestamp": time.strftime(
237
- "%Y-%m-%dT%H:%M:%SZ"
238
- ),
239
- "sampledValue": [
240
- {
241
- "value": f"{meter_kw:.3f}",
242
- "measurand": "Energy.Active.Import.Register",
243
- "unit": "kW",
244
- }
245
- ],
246
- }
247
- ],
248
- },
249
- ]
250
- )
251
- )
252
- await recv()
253
- await asyncio.sleep(cfg.interval)
254
-
255
- await send(
256
- json.dumps(
257
- [
258
- 2,
259
- "stop",
260
- "StopTransaction",
261
- {
262
- "transactionId": tx_id,
263
- "idTag": cfg.rfid,
264
- "meterStop": meter,
265
- },
266
- ]
267
- )
268
- )
269
- await recv()
270
- except asyncio.TimeoutError:
271
- if not self._connected.is_set():
272
- self._connect_error = "Timeout waiting for response"
273
- self._connected.set()
274
- self.status = "stopped"
275
- self._stop_event.set()
276
- return
277
- except websockets.exceptions.ConnectionClosed as exc:
278
- if not self._connected.is_set():
279
- self._connect_error = str(exc)
280
- self._connected.set()
281
- # The charger closed the connection; mark the simulator as
282
- # terminated rather than erroring so the status reflects that it
283
- # was stopped remotely.
284
- self.status = "stopped"
285
- self._stop_event.set()
286
- store.add_log(
287
- cfg.cp_path,
288
- f"Disconnected by charger (code={getattr(exc, 'code', '')})",
289
- log_type="simulator",
290
- )
291
- return
292
- except Exception as exc:
293
- if not self._connected.is_set():
294
- self._connect_error = str(exc)
295
- self._connected.set()
296
- self.status = "error"
297
- self._stop_event.set()
298
- raise
299
- finally:
300
- if ws is not None:
301
- await ws.close()
302
- store.add_log(
303
- cfg.cp_path,
304
- f"Closed (code={ws.close_code}, reason={getattr(ws, 'close_reason', '')})",
305
- log_type="simulator",
306
- )
307
-
308
- async def _run(self) -> None:
309
- try:
310
- while not self._stop_event.is_set():
311
- try:
312
- await self._run_session()
313
- except asyncio.CancelledError:
314
- break
315
- except Exception:
316
- # wait briefly then retry
317
- await asyncio.sleep(1)
318
- continue
319
- if not self.config.repeat:
320
- break
321
- finally:
322
- for key, sim in list(store.simulators.items()):
323
- if sim is self:
324
- store.simulators.pop(key, None)
325
- break
326
-
327
- def start(self) -> tuple[bool, str, str]:
328
- if self._thread and self._thread.is_alive():
329
- return (
330
- False,
331
- "already running",
332
- str(store._file_path(self.config.cp_path, log_type="simulator")),
333
- )
334
-
335
- self._stop_event.clear()
336
- self.status = "starting"
337
- self._connected.clear()
338
- self._connect_error = ""
339
-
340
- def _runner() -> None:
341
- asyncio.run(self._run())
342
-
343
- self._thread = threading.Thread(target=_runner, daemon=True)
344
- self._thread.start()
345
-
346
- log_file = str(store._file_path(self.config.cp_path, log_type="simulator"))
347
- if not self._connected.wait(15):
348
- self.status = "error"
349
- return False, "Connection timeout", log_file
350
- if self._connect_error == "accepted":
351
- self.status = "running"
352
- return True, "Connection accepted", log_file
353
- if "Timeout" in self._connect_error:
354
- self.status = "stopped"
355
- else:
356
- self.status = "error"
357
- return False, f"Connection failed: {self._connect_error}", log_file
358
-
359
- async def stop(self) -> None:
360
- if self._thread and self._thread.is_alive():
361
- self._stop_event.set()
362
- await asyncio.to_thread(self._thread.join)
363
- self._thread = None
364
- self._stop_event = threading.Event()
365
- self.status = "stopped"
366
-
367
-
368
- __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"]