arthexis 0.1.11__py3-none-any.whl → 0.1.13__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.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
- config/asgi.py +15 -1
- config/celery.py +8 -1
- config/settings.py +49 -78
- config/settings_helpers.py +109 -0
- core/admin.py +293 -78
- core/apps.py +21 -0
- core/auto_upgrade.py +2 -2
- core/form_fields.py +75 -0
- core/models.py +203 -47
- core/reference_utils.py +1 -1
- core/release.py +42 -20
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +75 -1
- core/views.py +178 -29
- core/widgets.py +43 -0
- nodes/admin.py +583 -10
- nodes/apps.py +15 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +287 -49
- nodes/reports.py +411 -0
- nodes/tests.py +990 -42
- nodes/urls.py +1 -0
- nodes/utils.py +32 -0
- nodes/views.py +173 -5
- ocpp/admin.py +424 -17
- ocpp/consumers.py +630 -15
- ocpp/evcs.py +7 -94
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +236 -4
- ocpp/routing.py +4 -2
- ocpp/simulator.py +346 -26
- ocpp/status_display.py +26 -0
- ocpp/store.py +110 -2
- ocpp/tests.py +1425 -33
- ocpp/transactions_io.py +27 -3
- ocpp/views.py +344 -38
- pages/admin.py +138 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +67 -0
- pages/models.py +136 -1
- pages/tests.py +379 -4
- pages/urls.py +1 -0
- pages/views.py +64 -7
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/top_level.txt +0 -0
ocpp/simulator.py
CHANGED
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import random
|
|
5
5
|
import time
|
|
6
6
|
import uuid
|
|
7
|
-
from dataclasses import dataclass
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
8
|
from typing import Optional
|
|
9
9
|
import threading
|
|
10
10
|
|
|
@@ -34,6 +34,8 @@ class SimulatorConfig:
|
|
|
34
34
|
password: Optional[str] = None
|
|
35
35
|
serial_number: str = ""
|
|
36
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)
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
class ChargePointSimulator:
|
|
@@ -47,6 +49,9 @@ class ChargePointSimulator:
|
|
|
47
49
|
self.status = "stopped"
|
|
48
50
|
self._connected = threading.Event()
|
|
49
51
|
self._connect_error = ""
|
|
52
|
+
self._availability_state = "Operative"
|
|
53
|
+
self._pending_availability: Optional[str] = None
|
|
54
|
+
self._in_transaction = False
|
|
50
55
|
|
|
51
56
|
def trigger_door_open(self) -> None:
|
|
52
57
|
"""Queue a DoorOpen status notification for the simulator."""
|
|
@@ -95,6 +100,298 @@ class ChargePointSimulator:
|
|
|
95
100
|
)
|
|
96
101
|
await recv()
|
|
97
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
|
+
|
|
98
395
|
@requires_network
|
|
99
396
|
async def _run_session(self) -> None:
|
|
100
397
|
cfg = self.config
|
|
@@ -137,26 +434,34 @@ class ChargePointSimulator:
|
|
|
137
434
|
store.add_log(cfg.cp_path, f"> {msg}", log_type="simulator")
|
|
138
435
|
|
|
139
436
|
async def recv() -> str:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
160
465
|
|
|
161
466
|
# handshake
|
|
162
467
|
boot = json.dumps(
|
|
@@ -203,7 +508,11 @@ class ChargePointSimulator:
|
|
|
203
508
|
{
|
|
204
509
|
"connectorId": cfg.connector_id,
|
|
205
510
|
"errorCode": "NoError",
|
|
206
|
-
"status":
|
|
511
|
+
"status": (
|
|
512
|
+
"Available"
|
|
513
|
+
if self._availability_state == "Operative"
|
|
514
|
+
else "Unavailable"
|
|
515
|
+
),
|
|
207
516
|
},
|
|
208
517
|
]
|
|
209
518
|
)
|
|
@@ -235,12 +544,14 @@ class ChargePointSimulator:
|
|
|
235
544
|
],
|
|
236
545
|
},
|
|
237
546
|
]
|
|
547
|
+
)
|
|
238
548
|
)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
await asyncio.sleep(cfg.interval)
|
|
549
|
+
await recv()
|
|
550
|
+
await self._maybe_send_door_event(send, recv)
|
|
551
|
+
await asyncio.sleep(cfg.interval)
|
|
243
552
|
|
|
553
|
+
if not await self._wait_until_operative(send, recv):
|
|
554
|
+
return
|
|
244
555
|
meter_start = random.randint(1000, 2000)
|
|
245
556
|
await send(
|
|
246
557
|
json.dumps(
|
|
@@ -263,6 +574,7 @@ class ChargePointSimulator:
|
|
|
263
574
|
self.status = "error"
|
|
264
575
|
raise
|
|
265
576
|
tx_id = resp[2].get("transactionId")
|
|
577
|
+
self._in_transaction = True
|
|
266
578
|
|
|
267
579
|
meter = meter_start
|
|
268
580
|
steps = max(1, int(cfg.duration / cfg.interval))
|
|
@@ -323,6 +635,13 @@ class ChargePointSimulator:
|
|
|
323
635
|
)
|
|
324
636
|
await recv()
|
|
325
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)
|
|
326
645
|
except asyncio.TimeoutError:
|
|
327
646
|
if not self._connected.is_set():
|
|
328
647
|
self._connect_error = "Timeout waiting for response"
|
|
@@ -353,6 +672,7 @@ class ChargePointSimulator:
|
|
|
353
672
|
self._stop_event.set()
|
|
354
673
|
raise
|
|
355
674
|
finally:
|
|
675
|
+
self._in_transaction = False
|
|
356
676
|
if ws is not None:
|
|
357
677
|
await ws.close()
|
|
358
678
|
store.add_log(
|
ocpp/status_display.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared status display constants for charger views and admin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Map of normalized OCPP status values to human readable labels and colors.
|
|
9
|
+
STATUS_BADGE_MAP: dict[str, tuple[str, str]] = {
|
|
10
|
+
"available": (_("Available"), "#0d6efd"),
|
|
11
|
+
"preparing": (_("Preparing"), "#0d6efd"),
|
|
12
|
+
"charging": (_("Charging"), "#198754"),
|
|
13
|
+
"suspendedevse": (_("Suspended (EVSE)"), "#fd7e14"),
|
|
14
|
+
"suspendedev": (_("Suspended (EV)"), "#fd7e14"),
|
|
15
|
+
"finishing": (_("Finishing"), "#20c997"),
|
|
16
|
+
"faulted": (_("Faulted"), "#dc3545"),
|
|
17
|
+
"unavailable": (_("Unavailable"), "#6c757d"),
|
|
18
|
+
"reserved": (_("Reserved"), "#6f42c1"),
|
|
19
|
+
"occupied": (_("Occupied"), "#0dcaf0"),
|
|
20
|
+
"outofservice": (_("Out of Service"), "#6c757d"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Error codes that indicate "no error" according to the OCPP specification.
|
|
25
|
+
ERROR_OK_VALUES = {"", "noerror", "no_error"}
|
|
26
|
+
|
ocpp/store.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import asyncio
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
import json
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
import re
|
|
9
|
-
import
|
|
10
|
+
import threading
|
|
10
11
|
|
|
11
12
|
from core.log_paths import select_log_dir
|
|
12
13
|
|
|
@@ -23,6 +24,8 @@ logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
|
|
|
23
24
|
history: dict[str, dict[str, object]] = {}
|
|
24
25
|
simulators = {}
|
|
25
26
|
ip_connections: dict[str, set[object]] = {}
|
|
27
|
+
pending_calls: dict[str, dict[str, object]] = {}
|
|
28
|
+
triggered_followups: dict[str, list[dict[str, object]]] = {}
|
|
26
29
|
|
|
27
30
|
# mapping of charger id / cp_path to friendly names used for log files
|
|
28
31
|
log_names: dict[str, dict[str, str]] = {"charger": {}, "simulator": {}}
|
|
@@ -187,6 +190,111 @@ def pop_transaction(serial: str, connector: int | str | None = None):
|
|
|
187
190
|
return None
|
|
188
191
|
|
|
189
192
|
|
|
193
|
+
def register_pending_call(message_id: str, metadata: dict[str, object]) -> None:
|
|
194
|
+
"""Store metadata about an outstanding CSMS call."""
|
|
195
|
+
|
|
196
|
+
pending_calls[message_id] = dict(metadata)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def pop_pending_call(message_id: str) -> dict[str, object] | None:
|
|
200
|
+
"""Return and remove metadata for a previously registered call."""
|
|
201
|
+
|
|
202
|
+
return pending_calls.pop(message_id, None)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def schedule_call_timeout(
|
|
206
|
+
message_id: str,
|
|
207
|
+
*,
|
|
208
|
+
timeout: float = 5.0,
|
|
209
|
+
action: str | None = None,
|
|
210
|
+
log_key: str | None = None,
|
|
211
|
+
log_type: str = "charger",
|
|
212
|
+
message: str | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Schedule a timeout notice if a pending call is not answered."""
|
|
215
|
+
|
|
216
|
+
def _notify() -> None:
|
|
217
|
+
metadata = pending_calls.get(message_id)
|
|
218
|
+
if not metadata:
|
|
219
|
+
return
|
|
220
|
+
if action and metadata.get("action") != action:
|
|
221
|
+
return
|
|
222
|
+
if metadata.get("timeout_notice_sent"):
|
|
223
|
+
return
|
|
224
|
+
target_log = log_key or metadata.get("log_key")
|
|
225
|
+
if not target_log:
|
|
226
|
+
metadata["timeout_notice_sent"] = True
|
|
227
|
+
return
|
|
228
|
+
label = message
|
|
229
|
+
if not label:
|
|
230
|
+
action_label = action or str(metadata.get("action") or "Call")
|
|
231
|
+
label = f"{action_label} request timed out"
|
|
232
|
+
add_log(target_log, label, log_type=log_type)
|
|
233
|
+
metadata["timeout_notice_sent"] = True
|
|
234
|
+
|
|
235
|
+
timer = threading.Timer(timeout, _notify)
|
|
236
|
+
timer.daemon = True
|
|
237
|
+
timer.start()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def register_triggered_followup(
|
|
241
|
+
serial: str,
|
|
242
|
+
action: str,
|
|
243
|
+
*,
|
|
244
|
+
connector: int | str | None = None,
|
|
245
|
+
log_key: str | None = None,
|
|
246
|
+
target: str | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Record that ``serial`` should send ``action`` after a TriggerMessage."""
|
|
249
|
+
|
|
250
|
+
entry = {
|
|
251
|
+
"action": action,
|
|
252
|
+
"connector": connector_slug(connector),
|
|
253
|
+
"log_key": log_key,
|
|
254
|
+
"target": target,
|
|
255
|
+
}
|
|
256
|
+
triggered_followups.setdefault(serial, []).append(entry)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def consume_triggered_followup(
|
|
260
|
+
serial: str, action: str, connector: int | str | None = None
|
|
261
|
+
) -> dict[str, object] | None:
|
|
262
|
+
"""Return metadata for a previously registered follow-up message."""
|
|
263
|
+
|
|
264
|
+
entries = triggered_followups.get(serial)
|
|
265
|
+
if not entries:
|
|
266
|
+
return None
|
|
267
|
+
connector_slug_value = connector_slug(connector)
|
|
268
|
+
for index, entry in enumerate(entries):
|
|
269
|
+
if entry.get("action") != action:
|
|
270
|
+
continue
|
|
271
|
+
expected_slug = entry.get("connector")
|
|
272
|
+
if expected_slug == AGGREGATE_SLUG:
|
|
273
|
+
matched = True
|
|
274
|
+
else:
|
|
275
|
+
matched = connector_slug_value == expected_slug
|
|
276
|
+
if not matched:
|
|
277
|
+
continue
|
|
278
|
+
result = entries.pop(index)
|
|
279
|
+
if not entries:
|
|
280
|
+
triggered_followups.pop(serial, None)
|
|
281
|
+
return result
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def clear_pending_calls(serial: str) -> None:
|
|
286
|
+
"""Remove any pending calls associated with the provided charger id."""
|
|
287
|
+
|
|
288
|
+
to_remove = [
|
|
289
|
+
key
|
|
290
|
+
for key, value in pending_calls.items()
|
|
291
|
+
if value.get("charger_id") == serial
|
|
292
|
+
]
|
|
293
|
+
for key in to_remove:
|
|
294
|
+
pending_calls.pop(key, None)
|
|
295
|
+
triggered_followups.pop(serial, None)
|
|
296
|
+
|
|
297
|
+
|
|
190
298
|
def reassign_identity(old_key: str, new_key: str) -> str:
|
|
191
299
|
"""Move any stored data from ``old_key`` to ``new_key``."""
|
|
192
300
|
|