arthexis 0.1.10__py3-none-any.whl → 0.1.12__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.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
- arthexis-0.1.12.dist-info/RECORD +102 -0
- config/context_processors.py +1 -0
- config/settings.py +31 -5
- config/urls.py +5 -4
- core/admin.py +430 -90
- core/apps.py +48 -2
- core/backends.py +38 -0
- core/environment.py +23 -5
- core/mailer.py +3 -1
- core/models.py +303 -31
- core/reference_utils.py +20 -9
- core/release.py +4 -0
- core/sigil_builder.py +7 -2
- core/sigil_resolver.py +35 -4
- core/system.py +250 -1
- core/tasks.py +92 -40
- core/temp_passwords.py +181 -0
- core/test_system_info.py +62 -2
- core/tests.py +169 -3
- core/user_data.py +51 -8
- core/views.py +371 -20
- nodes/admin.py +453 -8
- nodes/backends.py +21 -6
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/models.py +374 -31
- nodes/reports.py +411 -0
- nodes/tests.py +677 -38
- nodes/utils.py +32 -0
- nodes/views.py +14 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +517 -16
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +237 -4
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +819 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +233 -19
- pages/admin.py +144 -4
- pages/context_processors.py +21 -7
- pages/defaults.py +13 -0
- pages/forms.py +38 -0
- pages/models.py +189 -15
- pages/tests.py +281 -8
- pages/urls.py +4 -0
- pages/views.py +137 -21
- arthexis-0.1.10.dist-info/RECORD +0 -95
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.10.dist-info → arthexis-0.1.12.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,277 @@ 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
|
+
action = msg[2]
|
|
306
|
+
payload = msg[3] if len(msg) > 3 else {}
|
|
307
|
+
if action == "ChangeAvailability":
|
|
308
|
+
await self._handle_change_availability(msg[1], payload, send, recv)
|
|
309
|
+
return True
|
|
310
|
+
if action == "GetConfiguration":
|
|
311
|
+
await self._handle_get_configuration(msg[1], payload, send)
|
|
312
|
+
return True
|
|
313
|
+
if action == "TriggerMessage":
|
|
314
|
+
await self._handle_trigger_message(msg[1], payload, send, recv)
|
|
315
|
+
return True
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
async def _handle_get_configuration(self, message_id: str, payload, send) -> None:
|
|
319
|
+
cfg = self.config
|
|
320
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
321
|
+
requested_keys_raw = payload.get("key")
|
|
322
|
+
requested_keys: list[str] = []
|
|
323
|
+
if isinstance(requested_keys_raw, (list, tuple)):
|
|
324
|
+
for item in requested_keys_raw:
|
|
325
|
+
if isinstance(item, str):
|
|
326
|
+
key_text = item.strip()
|
|
327
|
+
else:
|
|
328
|
+
key_text = str(item).strip()
|
|
329
|
+
if key_text:
|
|
330
|
+
requested_keys.append(key_text)
|
|
331
|
+
|
|
332
|
+
configured_entries: list[dict[str, object]] = []
|
|
333
|
+
for entry in cfg.configuration_keys:
|
|
334
|
+
if not isinstance(entry, dict):
|
|
335
|
+
continue
|
|
336
|
+
key_raw = entry.get("key")
|
|
337
|
+
key_text = str(key_raw).strip() if key_raw is not None else ""
|
|
338
|
+
if not key_text:
|
|
339
|
+
continue
|
|
340
|
+
if requested_keys and key_text not in requested_keys:
|
|
341
|
+
continue
|
|
342
|
+
value = entry.get("value")
|
|
343
|
+
readonly = entry.get("readonly")
|
|
344
|
+
payload_entry: dict[str, object] = {"key": key_text}
|
|
345
|
+
if value is not None:
|
|
346
|
+
payload_entry["value"] = str(value)
|
|
347
|
+
if readonly is not None:
|
|
348
|
+
payload_entry["readonly"] = bool(readonly)
|
|
349
|
+
configured_entries.append(payload_entry)
|
|
350
|
+
|
|
351
|
+
unknown_keys: list[str] = []
|
|
352
|
+
for key in cfg.configuration_unknown_keys:
|
|
353
|
+
key_text = str(key).strip()
|
|
354
|
+
if not key_text:
|
|
355
|
+
continue
|
|
356
|
+
if requested_keys and key_text not in requested_keys:
|
|
357
|
+
continue
|
|
358
|
+
if key_text not in unknown_keys:
|
|
359
|
+
unknown_keys.append(key_text)
|
|
360
|
+
|
|
361
|
+
if requested_keys:
|
|
362
|
+
matched = {entry["key"] for entry in configured_entries}
|
|
363
|
+
for key in requested_keys:
|
|
364
|
+
if key not in matched and key not in unknown_keys:
|
|
365
|
+
unknown_keys.append(key)
|
|
366
|
+
|
|
367
|
+
response_payload: dict[str, object] = {}
|
|
368
|
+
if configured_entries:
|
|
369
|
+
response_payload["configurationKey"] = configured_entries
|
|
370
|
+
if unknown_keys:
|
|
371
|
+
response_payload["unknownKey"] = unknown_keys
|
|
372
|
+
await send(json.dumps([3, message_id, response_payload]))
|
|
373
|
+
|
|
98
374
|
@requires_network
|
|
99
375
|
async def _run_session(self) -> None:
|
|
100
376
|
cfg = self.config
|
|
@@ -137,26 +413,34 @@ class ChargePointSimulator:
|
|
|
137
413
|
store.add_log(cfg.cp_path, f"> {msg}", log_type="simulator")
|
|
138
414
|
|
|
139
415
|
async def recv() -> str:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
416
|
+
while True:
|
|
417
|
+
try:
|
|
418
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=60)
|
|
419
|
+
except asyncio.TimeoutError:
|
|
420
|
+
self.status = "stopped"
|
|
421
|
+
self._stop_event.set()
|
|
422
|
+
store.add_log(
|
|
423
|
+
cfg.cp_path,
|
|
424
|
+
"Timeout waiting for response from charger",
|
|
425
|
+
log_type="simulator",
|
|
426
|
+
)
|
|
427
|
+
raise
|
|
428
|
+
except websockets.exceptions.ConnectionClosed:
|
|
429
|
+
self.status = "stopped"
|
|
430
|
+
self._stop_event.set()
|
|
431
|
+
raise
|
|
432
|
+
except Exception:
|
|
433
|
+
self.status = "error"
|
|
434
|
+
raise
|
|
435
|
+
store.add_log(cfg.cp_path, f"< {raw}", log_type="simulator")
|
|
436
|
+
try:
|
|
437
|
+
parsed = json.loads(raw)
|
|
438
|
+
except Exception:
|
|
439
|
+
return raw
|
|
440
|
+
handled = await self._handle_csms_call(parsed, send, recv)
|
|
441
|
+
if handled:
|
|
442
|
+
continue
|
|
443
|
+
return raw
|
|
160
444
|
|
|
161
445
|
# handshake
|
|
162
446
|
boot = json.dumps(
|
|
@@ -203,7 +487,11 @@ class ChargePointSimulator:
|
|
|
203
487
|
{
|
|
204
488
|
"connectorId": cfg.connector_id,
|
|
205
489
|
"errorCode": "NoError",
|
|
206
|
-
"status":
|
|
490
|
+
"status": (
|
|
491
|
+
"Available"
|
|
492
|
+
if self._availability_state == "Operative"
|
|
493
|
+
else "Unavailable"
|
|
494
|
+
),
|
|
207
495
|
},
|
|
208
496
|
]
|
|
209
497
|
)
|
|
@@ -241,6 +529,8 @@ class ChargePointSimulator:
|
|
|
241
529
|
await self._maybe_send_door_event(send, recv)
|
|
242
530
|
await asyncio.sleep(cfg.interval)
|
|
243
531
|
|
|
532
|
+
if not await self._wait_until_operative(send, recv):
|
|
533
|
+
return
|
|
244
534
|
meter_start = random.randint(1000, 2000)
|
|
245
535
|
await send(
|
|
246
536
|
json.dumps(
|
|
@@ -263,6 +553,7 @@ class ChargePointSimulator:
|
|
|
263
553
|
self.status = "error"
|
|
264
554
|
raise
|
|
265
555
|
tx_id = resp[2].get("transactionId")
|
|
556
|
+
self._in_transaction = True
|
|
266
557
|
|
|
267
558
|
meter = meter_start
|
|
268
559
|
steps = max(1, int(cfg.duration / cfg.interval))
|
|
@@ -323,6 +614,13 @@ class ChargePointSimulator:
|
|
|
323
614
|
)
|
|
324
615
|
await recv()
|
|
325
616
|
await self._maybe_send_door_event(send, recv)
|
|
617
|
+
self._in_transaction = False
|
|
618
|
+
if self._pending_availability:
|
|
619
|
+
pending = self._pending_availability
|
|
620
|
+
self._pending_availability = None
|
|
621
|
+
self._availability_state = pending
|
|
622
|
+
status_label = "Available" if pending == "Operative" else "Unavailable"
|
|
623
|
+
await self._send_status_notification(send, recv, status_label)
|
|
326
624
|
except asyncio.TimeoutError:
|
|
327
625
|
if not self._connected.is_set():
|
|
328
626
|
self._connect_error = "Timeout waiting for response"
|
|
@@ -353,6 +651,7 @@ class ChargePointSimulator:
|
|
|
353
651
|
self._stop_event.set()
|
|
354
652
|
raise
|
|
355
653
|
finally:
|
|
654
|
+
self._in_transaction = False
|
|
356
655
|
if ws is not None:
|
|
357
656
|
await ws.close()
|
|
358
657
|
store.add_log(
|
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
|
|