arthexis 0.1.3__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 (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. pages/views.py +191 -0
ocpp/models.py ADDED
@@ -0,0 +1,300 @@
1
+ from django.db import models
2
+ from core.entity import Entity
3
+ from django.urls import reverse
4
+ from django.contrib.sites.models import Site
5
+ from django.conf import settings
6
+ from django.utils.translation import gettext_lazy as _
7
+
8
+ from core.models import (
9
+ EnergyAccount,
10
+ Reference,
11
+ RFID as CoreRFID,
12
+ ElectricVehicle as CoreElectricVehicle,
13
+ )
14
+
15
+
16
+ class Location(Entity):
17
+ """Physical location shared by chargers."""
18
+
19
+ name = models.CharField(max_length=200)
20
+ latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
21
+ longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
22
+
23
+ def __str__(self) -> str: # pragma: no cover - simple representation
24
+ return self.name
25
+
26
+ class Meta:
27
+ verbose_name = _("Charge Location")
28
+ verbose_name_plural = _("Charge Locations")
29
+
30
+
31
+ class Charger(Entity):
32
+ """Known charge point."""
33
+
34
+ charger_id = models.CharField(
35
+ _("Serial Number"),
36
+ max_length=100,
37
+ unique=True,
38
+ help_text="Unique identifier reported by the charger.",
39
+ )
40
+ connector_id = models.CharField(
41
+ _("Connector ID"),
42
+ max_length=10,
43
+ blank=True,
44
+ null=True,
45
+ help_text="Optional connector identifier for multi-connector chargers.",
46
+ )
47
+ require_rfid = models.BooleanField(
48
+ _("Require RFID Authorization"),
49
+ default=False,
50
+ help_text="Require a valid RFID before starting a charging session.",
51
+ )
52
+ last_heartbeat = models.DateTimeField(null=True, blank=True)
53
+ last_meter_values = models.JSONField(default=dict, blank=True)
54
+ temperature = models.DecimalField(max_digits=5, decimal_places=1, null=True, blank=True)
55
+ temperature_unit = models.CharField(max_length=16, blank=True)
56
+ reference = models.OneToOneField(Reference, null=True, blank=True, on_delete=models.SET_NULL)
57
+ location = models.ForeignKey(
58
+ Location, null=True, blank=True, on_delete=models.SET_NULL, related_name="chargers"
59
+ )
60
+ last_path = models.CharField(max_length=255, blank=True)
61
+
62
+ def __str__(self) -> str: # pragma: no cover - simple representation
63
+ return self.charger_id
64
+
65
+ class Meta:
66
+ verbose_name = _("Charge Point")
67
+ verbose_name_plural = _("Charge Points")
68
+
69
+ def get_absolute_url(self):
70
+ return reverse("charger-page", args=[self.charger_id])
71
+
72
+ def _full_url(self) -> str:
73
+ """Return absolute URL for the charger landing page."""
74
+ domain = Site.objects.get_current().domain
75
+ scheme = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http")
76
+ return f"{scheme}://{domain}{self.get_absolute_url()}"
77
+
78
+ def save(self, *args, **kwargs):
79
+ super().save(*args, **kwargs)
80
+ ref_value = self._full_url()
81
+ if not self.reference or self.reference.value != ref_value:
82
+ ref, _ = Reference.objects.get_or_create(
83
+ value=ref_value, defaults={"alt_text": self.charger_id}
84
+ )
85
+ self.reference = ref
86
+ super().save(update_fields=["reference"])
87
+
88
+ @property
89
+ def name(self) -> str:
90
+ if self.location:
91
+ return (
92
+ f"{self.location.name} #{self.connector_id}"
93
+ if self.connector_id
94
+ else self.location.name
95
+ )
96
+ return ""
97
+
98
+ @property
99
+ def latitude(self):
100
+ return self.location.latitude if self.location else None
101
+
102
+ @property
103
+ def longitude(self):
104
+ return self.location.longitude if self.location else None
105
+
106
+ @property
107
+ def total_kw(self) -> float:
108
+ """Return total energy delivered by this charger in kW."""
109
+ from . import store
110
+
111
+ total = 0.0
112
+ tx_active = store.transactions.get(self.charger_id)
113
+ qs = self.transactions.all()
114
+ if tx_active and tx_active.pk is not None:
115
+ qs = qs.exclude(pk=tx_active.pk)
116
+ for tx in qs:
117
+ kw = tx.kw
118
+ if kw:
119
+ total += kw
120
+ if tx_active:
121
+ kw = tx_active.kw
122
+ if kw:
123
+ total += kw
124
+ return total
125
+
126
+ def purge(self):
127
+ from . import store
128
+
129
+ self.transactions.all().delete()
130
+ self.meter_readings.all().delete()
131
+ store.clear_log(self.charger_id, log_type="charger")
132
+ store.transactions.pop(self.charger_id, None)
133
+ store.history.pop(self.charger_id, None)
134
+
135
+ def delete(self, *args, **kwargs):
136
+ from django.db.models.deletion import ProtectedError
137
+ from . import store
138
+
139
+ if (
140
+ self.transactions.exists()
141
+ or self.meter_readings.exists()
142
+ or store.get_logs(self.charger_id, log_type="charger")
143
+ or store.transactions.get(self.charger_id)
144
+ or store.history.get(self.charger_id)
145
+ ):
146
+ raise ProtectedError("Purge data before deleting charger.", [])
147
+ super().delete(*args, **kwargs)
148
+
149
+
150
+ class Transaction(Entity):
151
+ """Charging session data stored for each charger."""
152
+
153
+ charger = models.ForeignKey(
154
+ Charger, on_delete=models.CASCADE, related_name="transactions", null=True
155
+ )
156
+ account = models.ForeignKey(
157
+ EnergyAccount, on_delete=models.PROTECT, related_name="transactions", null=True
158
+ )
159
+ rfid = models.CharField(
160
+ max_length=20,
161
+ blank=True,
162
+ verbose_name=_("RFID"),
163
+ )
164
+ vin = models.CharField(max_length=17, blank=True)
165
+ meter_start = models.IntegerField(null=True, blank=True)
166
+ meter_stop = models.IntegerField(null=True, blank=True)
167
+ start_time = models.DateTimeField()
168
+ stop_time = models.DateTimeField(null=True, blank=True)
169
+
170
+ def __str__(self) -> str: # pragma: no cover - simple representation
171
+ return f"{self.charger}:{self.pk}"
172
+
173
+ class Meta:
174
+ verbose_name = _("Transaction")
175
+ verbose_name_plural = _("Transactions")
176
+
177
+ @property
178
+ def kw(self) -> float:
179
+ """Return consumed energy in kW for this session."""
180
+ total = 0.0
181
+ qs = self.meter_readings.filter(
182
+ measurand__in=["", "Energy.Active.Import.Register"]
183
+ ).order_by("timestamp")
184
+ first = True
185
+ for reading in qs:
186
+ try:
187
+ val = float(reading.value)
188
+ except (TypeError, ValueError): # pragma: no cover - unexpected
189
+ continue
190
+ if reading.unit != "kW":
191
+ val = val / 1000.0
192
+ if first and self.meter_start is not None:
193
+ total += val - (self.meter_start / 1000.0)
194
+ first = False
195
+ else:
196
+ total += val
197
+ first = False
198
+ if total == 0 and self.meter_start is not None and self.meter_stop is not None:
199
+ total = (self.meter_stop - self.meter_start) / 1000.0
200
+ if total < 0:
201
+ return 0.0
202
+ return total
203
+
204
+
205
+ class MeterReading(Entity):
206
+ """Parsed meter values reported by chargers."""
207
+
208
+ charger = models.ForeignKey(
209
+ Charger, on_delete=models.CASCADE, related_name="meter_readings"
210
+ )
211
+ connector_id = models.IntegerField(null=True, blank=True)
212
+ transaction = models.ForeignKey(
213
+ Transaction,
214
+ on_delete=models.CASCADE,
215
+ related_name="meter_readings",
216
+ null=True,
217
+ blank=True,
218
+ )
219
+ timestamp = models.DateTimeField()
220
+ measurand = models.CharField(max_length=100, blank=True)
221
+ value = models.DecimalField(max_digits=12, decimal_places=3)
222
+ unit = models.CharField(max_length=16, blank=True)
223
+
224
+ def __str__(self) -> str: # pragma: no cover - simple representation
225
+ return f"{self.charger} {self.measurand} {self.value}{self.unit}".strip()
226
+
227
+ class Meta:
228
+ verbose_name = _("Meter Reading")
229
+ verbose_name_plural = _("Meter Readings")
230
+
231
+
232
+ class Simulator(Entity):
233
+ """Preconfigured simulator that can be started from the admin."""
234
+
235
+ name = models.CharField(max_length=100, unique=True)
236
+ cp_path = models.CharField(_("CP Path"), max_length=100)
237
+ host = models.CharField(max_length=100, default="127.0.0.1")
238
+ ws_port = models.IntegerField(_("WS Port"), default=8000)
239
+ rfid = models.CharField(
240
+ max_length=255,
241
+ default="FFFFFFFF",
242
+ verbose_name=_("RFID"),
243
+ )
244
+ vin = models.CharField(max_length=17, blank=True)
245
+ duration = models.IntegerField(default=600)
246
+ interval = models.FloatField(default=5.0)
247
+ pre_charge_delay = models.FloatField(_("Delay"), default=10.0)
248
+ kw_max = models.FloatField(default=60.0)
249
+ repeat = models.BooleanField(default=False)
250
+ username = models.CharField(max_length=100, blank=True)
251
+ password = models.CharField(max_length=100, blank=True)
252
+
253
+ def __str__(self) -> str: # pragma: no cover - simple representation
254
+ return self.name
255
+
256
+ class Meta:
257
+ verbose_name = _("CP Simulator")
258
+ verbose_name_plural = _("CP Simulators")
259
+
260
+ def as_config(self):
261
+ from .simulator import SimulatorConfig
262
+
263
+ return SimulatorConfig(
264
+ host=self.host,
265
+ ws_port=self.ws_port,
266
+ rfid=self.rfid,
267
+ vin=self.vin,
268
+ cp_path=self.cp_path,
269
+ duration=self.duration,
270
+ interval=self.interval,
271
+ pre_charge_delay=self.pre_charge_delay,
272
+ kw_max=self.kw_max,
273
+ repeat=self.repeat,
274
+ username=self.username or None,
275
+ password=self.password or None,
276
+ )
277
+
278
+ @property
279
+ def ws_url(self) -> str: # pragma: no cover - simple helper
280
+ path = self.cp_path
281
+ if not path.endswith("/"):
282
+ path += "/"
283
+ return f"ws://{self.host}:{self.ws_port}/{path}"
284
+
285
+
286
+ class RFID(CoreRFID):
287
+ class Meta:
288
+ proxy = True
289
+ app_label = "ocpp"
290
+ verbose_name = CoreRFID._meta.verbose_name
291
+ verbose_name_plural = CoreRFID._meta.verbose_name_plural
292
+
293
+
294
+ class ElectricVehicle(CoreElectricVehicle):
295
+ class Meta:
296
+ proxy = True
297
+ app_label = "ocpp"
298
+ verbose_name = _("Electric Vehicle")
299
+ verbose_name_plural = _("Electric Vehicles")
300
+
ocpp/routing.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.urls import re_path
2
+
3
+ from . import consumers
4
+
5
+ websocket_urlpatterns = [
6
+ re_path(r"^ws/sink/$", consumers.SinkConsumer.as_asgi()),
7
+ # Accept connections at any path; the last segment is the charger ID
8
+ re_path(r"^(?:.*/)?(?P<cid>[^/]+)/?$", consumers.CSMSConsumer.as_asgi()),
9
+ ]
ocpp/simulator.py ADDED
@@ -0,0 +1,357 @@
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
+
35
+
36
+ class ChargePointSimulator:
37
+ """Lightweight simulator for a single OCPP 1.6 charge point."""
38
+
39
+ def __init__(self, config: SimulatorConfig) -> None:
40
+ self.config = config
41
+ self._thread: Optional[threading.Thread] = None
42
+ self._stop_event = threading.Event()
43
+ self.status = "stopped"
44
+ self._connected = threading.Event()
45
+ self._connect_error = ""
46
+
47
+ @requires_network
48
+ async def _run_session(self) -> None:
49
+ cfg = self.config
50
+ uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
51
+ headers = {}
52
+ if cfg.username and cfg.password:
53
+ userpass = f"{cfg.username}:{cfg.password}"
54
+ b64 = base64.b64encode(userpass.encode()).decode()
55
+ headers["Authorization"] = f"Basic {b64}"
56
+
57
+ ws = None
58
+ try:
59
+ try:
60
+ ws = await websockets.connect(
61
+ uri, subprotocols=["ocpp1.6"], extra_headers=headers
62
+ )
63
+ except Exception as exc:
64
+ store.add_log(
65
+ cfg.cp_path,
66
+ f"Connection with subprotocol failed: {exc}",
67
+ log_type="simulator",
68
+ )
69
+ ws = await websockets.connect(uri, extra_headers=headers)
70
+
71
+ store.add_log(
72
+ cfg.cp_path,
73
+ f"Connected (subprotocol={ws.subprotocol or 'none'})",
74
+ log_type="simulator",
75
+ )
76
+
77
+ async def send(msg: str) -> None:
78
+ try:
79
+ await ws.send(msg)
80
+ except Exception:
81
+ self.status = "error"
82
+ raise
83
+ store.add_log(cfg.cp_path, f"> {msg}", log_type="simulator")
84
+
85
+ async def recv() -> str:
86
+ try:
87
+ raw = await asyncio.wait_for(ws.recv(), timeout=60)
88
+ except asyncio.TimeoutError:
89
+ self.status = "stopped"
90
+ self._stop_event.set()
91
+ store.add_log(
92
+ cfg.cp_path,
93
+ "Timeout waiting for response from charger",
94
+ log_type="simulator",
95
+ )
96
+ raise
97
+ except Exception:
98
+ self.status = "error"
99
+ raise
100
+ store.add_log(cfg.cp_path, f"< {raw}", log_type="simulator")
101
+ return raw
102
+
103
+ # handshake
104
+ boot = json.dumps(
105
+ [
106
+ 2,
107
+ "boot",
108
+ "BootNotification",
109
+ {
110
+ "chargePointModel": "Simulator",
111
+ "chargePointVendor": "SimVendor",
112
+ },
113
+ ]
114
+ )
115
+ await send(boot)
116
+ try:
117
+ resp = json.loads(await recv())
118
+ except Exception:
119
+ self.status = "error"
120
+ raise
121
+ status = resp[2].get("status")
122
+ if status != "Accepted":
123
+ if not self._connected.is_set():
124
+ self._connect_error = f"Boot status {status}"
125
+ self._connected.set()
126
+ return
127
+
128
+ await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
129
+ await recv()
130
+ if not self._connected.is_set():
131
+ self.status = "running"
132
+ self._connect_error = "accepted"
133
+ self._connected.set()
134
+ if cfg.pre_charge_delay > 0:
135
+ idle_start = time.monotonic()
136
+ while time.monotonic() - idle_start < cfg.pre_charge_delay:
137
+ await send(
138
+ json.dumps(
139
+ [
140
+ 2,
141
+ "status",
142
+ "StatusNotification",
143
+ {
144
+ "connectorId": 1,
145
+ "errorCode": "NoError",
146
+ "status": "Available",
147
+ },
148
+ ]
149
+ )
150
+ )
151
+ await recv()
152
+ await send(json.dumps([2, "hb", "Heartbeat", {}]))
153
+ await recv()
154
+ await send(
155
+ json.dumps(
156
+ [
157
+ 2,
158
+ "meter",
159
+ "MeterValues",
160
+ {
161
+ "connectorId": 1,
162
+ "meterValue": [
163
+ {
164
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
165
+ "sampledValue": [
166
+ {
167
+ "value": "0",
168
+ "measurand": "Energy.Active.Import.Register",
169
+ "unit": "kW",
170
+ }
171
+ ],
172
+ }
173
+ ],
174
+ },
175
+ ]
176
+ )
177
+ )
178
+ await recv()
179
+ await asyncio.sleep(cfg.interval)
180
+
181
+ meter_start = random.randint(1000, 2000)
182
+ await send(
183
+ json.dumps(
184
+ [
185
+ 2,
186
+ "start",
187
+ "StartTransaction",
188
+ {
189
+ "connectorId": 1,
190
+ "idTag": cfg.rfid,
191
+ "meterStart": meter_start,
192
+ "vin": cfg.vin,
193
+ },
194
+ ]
195
+ )
196
+ )
197
+ try:
198
+ resp = json.loads(await recv())
199
+ except Exception:
200
+ self.status = "error"
201
+ raise
202
+ tx_id = resp[2].get("transactionId")
203
+
204
+ meter = meter_start
205
+ steps = max(1, int(cfg.duration / cfg.interval))
206
+ target_kwh = cfg.kw_max * random.uniform(0.9, 1.1)
207
+ step_avg = (target_kwh * 1000) / steps
208
+
209
+ start_time = time.monotonic()
210
+ while time.monotonic() - start_time < cfg.duration:
211
+ if self._stop_event.is_set():
212
+ break
213
+ inc = random.gauss(step_avg, step_avg * 0.05)
214
+ meter += max(1, int(inc))
215
+ meter_kw = meter / 1000.0
216
+ await send(
217
+ json.dumps(
218
+ [
219
+ 2,
220
+ "meter",
221
+ "MeterValues",
222
+ {
223
+ "connectorId": 1,
224
+ "transactionId": tx_id,
225
+ "meterValue": [
226
+ {
227
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
228
+ "sampledValue": [
229
+ {
230
+ "value": f"{meter_kw:.3f}",
231
+ "measurand": "Energy.Active.Import.Register",
232
+ "unit": "kW",
233
+ }
234
+ ],
235
+ }
236
+ ],
237
+ },
238
+ ]
239
+ )
240
+ )
241
+ await recv()
242
+ await asyncio.sleep(cfg.interval)
243
+
244
+ await send(
245
+ json.dumps(
246
+ [
247
+ 2,
248
+ "stop",
249
+ "StopTransaction",
250
+ {
251
+ "transactionId": tx_id,
252
+ "idTag": cfg.rfid,
253
+ "meterStop": meter,
254
+ },
255
+ ]
256
+ )
257
+ )
258
+ await recv()
259
+ except asyncio.TimeoutError:
260
+ if not self._connected.is_set():
261
+ self._connect_error = "Timeout waiting for response"
262
+ self._connected.set()
263
+ self.status = "stopped"
264
+ self._stop_event.set()
265
+ return
266
+ except websockets.exceptions.ConnectionClosed as exc:
267
+ if not self._connected.is_set():
268
+ self._connect_error = str(exc)
269
+ self._connected.set()
270
+ # The charger closed the connection; mark the simulator as
271
+ # terminated rather than erroring so the status reflects that it
272
+ # was stopped remotely.
273
+ self.status = "stopped"
274
+ self._stop_event.set()
275
+ store.add_log(
276
+ cfg.cp_path,
277
+ f"Disconnected by charger (code={getattr(exc, 'code', '')})",
278
+ log_type="simulator",
279
+ )
280
+ return
281
+ except Exception as exc:
282
+ if not self._connected.is_set():
283
+ self._connect_error = str(exc)
284
+ self._connected.set()
285
+ self.status = "error"
286
+ self._stop_event.set()
287
+ raise
288
+ finally:
289
+ if ws is not None:
290
+ await ws.close()
291
+ store.add_log(
292
+ cfg.cp_path,
293
+ f"Closed (code={ws.close_code}, reason={getattr(ws, 'close_reason', '')})",
294
+ log_type="simulator",
295
+ )
296
+
297
+ async def _run(self) -> None:
298
+ try:
299
+ while not self._stop_event.is_set():
300
+ try:
301
+ await self._run_session()
302
+ except asyncio.CancelledError:
303
+ break
304
+ except Exception:
305
+ # wait briefly then retry
306
+ await asyncio.sleep(1)
307
+ continue
308
+ if not self.config.repeat:
309
+ break
310
+ finally:
311
+ for key, sim in list(store.simulators.items()):
312
+ if sim is self:
313
+ store.simulators.pop(key, None)
314
+ break
315
+
316
+ def start(self) -> tuple[bool, str, str]:
317
+ if self._thread and self._thread.is_alive():
318
+ return (
319
+ False,
320
+ "already running",
321
+ str(store._file_path(self.config.cp_path, log_type="simulator")),
322
+ )
323
+
324
+ self._stop_event.clear()
325
+ self.status = "starting"
326
+ self._connected.clear()
327
+ self._connect_error = ""
328
+
329
+ def _runner() -> None:
330
+ asyncio.run(self._run())
331
+
332
+ self._thread = threading.Thread(target=_runner, daemon=True)
333
+ self._thread.start()
334
+
335
+ log_file = str(store._file_path(self.config.cp_path, log_type="simulator"))
336
+ if not self._connected.wait(15):
337
+ self.status = "error"
338
+ return False, "Connection timeout", log_file
339
+ if self._connect_error == "accepted":
340
+ self.status = "running"
341
+ return True, "Connection accepted", log_file
342
+ if "Timeout" in self._connect_error:
343
+ self.status = "stopped"
344
+ else:
345
+ self.status = "error"
346
+ return False, f"Connection failed: {self._connect_error}", log_file
347
+
348
+ async def stop(self) -> None:
349
+ if self._thread and self._thread.is_alive():
350
+ self._stop_event.set()
351
+ await asyncio.to_thread(self._thread.join)
352
+ self._thread = None
353
+ self._stop_event = threading.Event()
354
+ self.status = "stopped"
355
+
356
+
357
+ __all__ = ["SimulatorConfig", "ChargePointSimulator"]