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.

Files changed (50) hide show
  1. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/METADATA +2 -2
  2. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/RECORD +50 -44
  3. config/asgi.py +15 -1
  4. config/celery.py +8 -1
  5. config/settings.py +49 -78
  6. config/settings_helpers.py +109 -0
  7. core/admin.py +293 -78
  8. core/apps.py +21 -0
  9. core/auto_upgrade.py +2 -2
  10. core/form_fields.py +75 -0
  11. core/models.py +203 -47
  12. core/reference_utils.py +1 -1
  13. core/release.py +42 -20
  14. core/system.py +6 -3
  15. core/tasks.py +92 -40
  16. core/tests.py +75 -1
  17. core/views.py +178 -29
  18. core/widgets.py +43 -0
  19. nodes/admin.py +583 -10
  20. nodes/apps.py +15 -0
  21. nodes/feature_checks.py +133 -0
  22. nodes/models.py +287 -49
  23. nodes/reports.py +411 -0
  24. nodes/tests.py +990 -42
  25. nodes/urls.py +1 -0
  26. nodes/utils.py +32 -0
  27. nodes/views.py +173 -5
  28. ocpp/admin.py +424 -17
  29. ocpp/consumers.py +630 -15
  30. ocpp/evcs.py +7 -94
  31. ocpp/evcs_discovery.py +158 -0
  32. ocpp/models.py +236 -4
  33. ocpp/routing.py +4 -2
  34. ocpp/simulator.py +346 -26
  35. ocpp/status_display.py +26 -0
  36. ocpp/store.py +110 -2
  37. ocpp/tests.py +1425 -33
  38. ocpp/transactions_io.py +27 -3
  39. ocpp/views.py +344 -38
  40. pages/admin.py +138 -3
  41. pages/context_processors.py +15 -1
  42. pages/defaults.py +1 -2
  43. pages/forms.py +67 -0
  44. pages/models.py +136 -1
  45. pages/tests.py +379 -4
  46. pages/urls.py +1 -0
  47. pages/views.py +64 -7
  48. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/WHEEL +0 -0
  49. {arthexis-0.1.11.dist-info → arthexis-0.1.13.dist-info}/licenses/LICENSE +0 -0
  50. {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
- 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
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": "Available",
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
- await recv()
241
- await self._maybe_send_door_event(send, recv)
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
- 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