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.
- arthexis-0.1.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- 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"]
|