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.

Files changed (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {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
- try:
141
- raw = await asyncio.wait_for(ws.recv(), timeout=60)
142
- except asyncio.TimeoutError:
143
- self.status = "stopped"
144
- self._stop_event.set()
145
- store.add_log(
146
- cfg.cp_path,
147
- "Timeout waiting for response from charger",
148
- log_type="simulator",
149
- )
150
- raise
151
- except websockets.exceptions.ConnectionClosed:
152
- self.status = "stopped"
153
- self._stop_event.set()
154
- raise
155
- except Exception:
156
- self.status = "error"
157
- raise
158
- store.add_log(cfg.cp_path, f"< {raw}", log_type="simulator")
159
- return raw
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": "Available",
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
- from pathlib import Path
5
+ import asyncio
6
6
  from datetime import datetime
7
7
  import json
8
+ from pathlib import Path
8
9
  import re
9
- import asyncio
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