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.

Files changed (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. pages/views.py +191 -0
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Iterable
5
+
6
+ from django.utils import timezone
7
+ from django.utils.dateparse import parse_datetime
8
+
9
+ from .models import Charger, Transaction, MeterReading
10
+
11
+
12
+ def export_transactions(
13
+ start: datetime | None = None,
14
+ end: datetime | None = None,
15
+ chargers: Iterable[str] | None = None,
16
+ ) -> dict:
17
+ """Return transaction export data."""
18
+ qs = (
19
+ Transaction.objects.all()
20
+ .select_related("charger")
21
+ .prefetch_related("meter_readings")
22
+ )
23
+ if start:
24
+ qs = qs.filter(start_time__gte=start)
25
+ if end:
26
+ qs = qs.filter(start_time__lte=end)
27
+ if chargers:
28
+ qs = qs.filter(charger__charger_id__in=chargers)
29
+
30
+ export_chargers = set(qs.values_list("charger__charger_id", flat=True))
31
+ data = {"chargers": [], "transactions": []}
32
+
33
+ for charger in Charger.objects.filter(charger_id__in=export_chargers):
34
+ data["chargers"].append(
35
+ {
36
+ "charger_id": charger.charger_id,
37
+ "connector_id": charger.connector_id,
38
+ "require_rfid": charger.require_rfid,
39
+ }
40
+ )
41
+
42
+ for tx in qs:
43
+ data["transactions"].append(
44
+ {
45
+ "charger": tx.charger.charger_id if tx.charger else None,
46
+ "account": tx.account_id,
47
+ "rfid": tx.rfid,
48
+ "vin": tx.vin,
49
+ "meter_start": tx.meter_start,
50
+ "meter_stop": tx.meter_stop,
51
+ "start_time": tx.start_time.isoformat(),
52
+ "stop_time": tx.stop_time.isoformat() if tx.stop_time else None,
53
+ "meter_readings": [
54
+ {
55
+ "connector_id": mr.connector_id,
56
+ "timestamp": mr.timestamp.isoformat(),
57
+ "measurand": mr.measurand,
58
+ "value": str(mr.value),
59
+ "unit": mr.unit,
60
+ }
61
+ for mr in tx.meter_readings.all()
62
+ ],
63
+ }
64
+ )
65
+ return data
66
+
67
+
68
+ def _parse_dt(value: str | None) -> datetime | None:
69
+ if value is None:
70
+ return None
71
+ dt = parse_datetime(value)
72
+ if dt is None:
73
+ raise ValueError(f"Invalid datetime: {value}")
74
+ if timezone.is_naive(dt):
75
+ dt = timezone.make_aware(dt)
76
+ return dt
77
+
78
+
79
+ def import_transactions(data: dict) -> int:
80
+ """Import transactions from export data.
81
+
82
+ Returns number of imported transactions.
83
+ """
84
+ charger_map: dict[str, Charger] = {}
85
+ for item in data.get("chargers", []):
86
+ charger, _ = Charger.objects.get_or_create(
87
+ charger_id=item["charger_id"],
88
+ defaults={
89
+ "connector_id": item.get("connector_id", None),
90
+ "require_rfid": item.get("require_rfid", False),
91
+ },
92
+ )
93
+ charger_map[item["charger_id"]] = charger
94
+
95
+ imported = 0
96
+ for tx in data.get("transactions", []):
97
+ charger = charger_map.get(tx.get("charger"))
98
+ transaction = Transaction.objects.create(
99
+ charger=charger,
100
+ account_id=tx.get("account"),
101
+ rfid=tx.get("rfid", ""),
102
+ vin=tx.get("vin", ""),
103
+ meter_start=tx.get("meter_start"),
104
+ meter_stop=tx.get("meter_stop"),
105
+ start_time=_parse_dt(tx.get("start_time")),
106
+ stop_time=_parse_dt(tx.get("stop_time")),
107
+ )
108
+ for mr in tx.get("meter_readings", []):
109
+ MeterReading.objects.create(
110
+ charger=charger,
111
+ transaction=transaction,
112
+ connector_id=mr.get("connector_id"),
113
+ timestamp=_parse_dt(mr.get("timestamp")),
114
+ measurand=mr.get("measurand", ""),
115
+ value=mr.get("value"),
116
+ unit=mr.get("unit", ""),
117
+ )
118
+ imported += 1
119
+ return imported
ocpp/urls.py ADDED
@@ -0,0 +1,17 @@
1
+ from django.urls import include, path
2
+
3
+ from . import views
4
+
5
+ urlpatterns = [
6
+ path("", views.dashboard, name="ocpp-dashboard"),
7
+ path("simulator/", views.cp_simulator, name="cp-simulator"),
8
+ path("chargers/", views.charger_list, name="charger-list"),
9
+ path("chargers/<str:cid>/", views.charger_detail, name="charger-detail"),
10
+ path("chargers/<str:cid>/action/", views.dispatch_action, name="charger-action"),
11
+ path("c/<str:cid>/", views.charger_page, name="charger-page"),
12
+ path("c/<str:cid>/sessions/", views.charger_session_search, name="charger-session-search"),
13
+ path("log/<str:cid>/", views.charger_log_page, name="charger-log"),
14
+ path("c/<str:cid>/status/", views.charger_status, name="charger-status"),
15
+ path("rfid/", include("ocpp.rfid.urls")),
16
+ path("efficiency/", views.efficiency_calculator, name="ev-efficiency"),
17
+ ]
ocpp/views.py ADDED
@@ -0,0 +1,359 @@
1
+ import asyncio
2
+ import json
3
+ from datetime import datetime, timedelta, timezone as dt_timezone
4
+
5
+ from django.http import JsonResponse, HttpResponse
6
+ from django.views.decorators.csrf import csrf_exempt
7
+ from django.shortcuts import render, get_object_or_404
8
+ from django.core.paginator import Paginator
9
+ from django.contrib.auth.decorators import login_required
10
+ from django.utils.translation import gettext_lazy as _
11
+
12
+ from utils.api import api_login_required
13
+
14
+ from pages.utils import landing
15
+
16
+ from . import store
17
+ from .models import Transaction, Charger
18
+ from .evcs import (
19
+ _start_simulator,
20
+ _stop_simulator,
21
+ get_simulator_state,
22
+ _simulator_status_json,
23
+ )
24
+
25
+
26
+ def _charger_state(charger: Charger, tx_obj: Transaction | None):
27
+ """Return human readable state and color for a charger."""
28
+ cid = charger.charger_id
29
+ connected = cid in store.connections
30
+ if connected and tx_obj:
31
+ return "Charging", "green"
32
+ if connected:
33
+ return "Available", "blue"
34
+ return "Offline", "grey"
35
+
36
+
37
+
38
+ @api_login_required
39
+ def charger_list(request):
40
+ """Return a JSON list of known chargers and state."""
41
+ data = []
42
+ for charger in Charger.objects.all():
43
+ cid = charger.charger_id
44
+ tx_obj = store.transactions.get(cid)
45
+ if not tx_obj:
46
+ tx_obj = (
47
+ Transaction.objects.filter(charger__charger_id=cid)
48
+ .order_by("-start_time")
49
+ .first()
50
+ )
51
+ tx_data = None
52
+ if tx_obj:
53
+ tx_data = {
54
+ "transactionId": tx_obj.pk,
55
+ "meterStart": tx_obj.meter_start,
56
+ "startTime": tx_obj.start_time.isoformat(),
57
+ }
58
+ if tx_obj.vin:
59
+ tx_data["vin"] = tx_obj.vin
60
+ if tx_obj.meter_stop is not None:
61
+ tx_data["meterStop"] = tx_obj.meter_stop
62
+ if tx_obj.stop_time is not None:
63
+ tx_data["stopTime"] = tx_obj.stop_time.isoformat()
64
+ data.append(
65
+ {
66
+ "charger_id": cid,
67
+ "name": charger.name,
68
+ "require_rfid": charger.require_rfid,
69
+ "transaction": tx_data,
70
+ "lastHeartbeat": charger.last_heartbeat.isoformat() if charger.last_heartbeat else None,
71
+ "lastMeterValues": charger.last_meter_values,
72
+ "connected": cid in store.connections,
73
+ }
74
+ )
75
+ return JsonResponse({"chargers": data})
76
+
77
+
78
+ @api_login_required
79
+ def charger_detail(request, cid):
80
+ charger = Charger.objects.filter(charger_id=cid).first()
81
+ if charger is None:
82
+ return JsonResponse({"detail": "not found"}, status=404)
83
+
84
+ tx_obj = store.transactions.get(cid)
85
+ if not tx_obj:
86
+ tx_obj = (
87
+ Transaction.objects.filter(charger__charger_id=cid)
88
+ .order_by("-start_time")
89
+ .first()
90
+ )
91
+
92
+ tx_data = None
93
+ if tx_obj:
94
+ tx_data = {
95
+ "transactionId": tx_obj.pk,
96
+ "meterStart": tx_obj.meter_start,
97
+ "startTime": tx_obj.start_time.isoformat(),
98
+ }
99
+ if tx_obj.vin:
100
+ tx_data["vin"] = tx_obj.vin
101
+ if tx_obj.meter_stop is not None:
102
+ tx_data["meterStop"] = tx_obj.meter_stop
103
+ if tx_obj.stop_time is not None:
104
+ tx_data["stopTime"] = tx_obj.stop_time.isoformat()
105
+
106
+ log = store.get_logs(cid, log_type="charger")
107
+ return JsonResponse(
108
+ {
109
+ "charger_id": cid,
110
+ "name": charger.name,
111
+ "require_rfid": charger.require_rfid,
112
+ "transaction": tx_data,
113
+ "lastHeartbeat": charger.last_heartbeat.isoformat() if charger.last_heartbeat else None,
114
+ "lastMeterValues": charger.last_meter_values,
115
+ "log": log,
116
+ }
117
+ )
118
+
119
+
120
+ @login_required
121
+ @landing("Dashboard")
122
+ def dashboard(request):
123
+ """Landing page listing all known chargers and their status."""
124
+ chargers = []
125
+ for charger in Charger.objects.all():
126
+ tx_obj = store.transactions.get(charger.charger_id)
127
+ if not tx_obj:
128
+ tx_obj = (
129
+ Transaction.objects.filter(charger=charger)
130
+ .order_by("-start_time")
131
+ .first()
132
+ )
133
+ state, color = _charger_state(charger, tx_obj)
134
+ chargers.append({"charger": charger, "state": state, "color": color})
135
+ return render(request, "ocpp/dashboard.html", {"chargers": chargers})
136
+
137
+
138
+ @login_required
139
+ @landing("CP Simulator")
140
+ def cp_simulator(request):
141
+ """Public landing page to control the OCPP charge point simulator."""
142
+ default_host = "127.0.0.1"
143
+ default_ws_port = "9000"
144
+ default_cp_paths = ["CP1", "CP2"]
145
+ default_rfid = "FFFFFFFF"
146
+ default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
147
+
148
+ message = ""
149
+ if request.method == "POST":
150
+ cp_idx = int(request.POST.get("cp") or 1)
151
+ action = request.POST.get("action")
152
+ if action == "start":
153
+ sim_params = dict(
154
+ host=request.POST.get("host") or default_host,
155
+ ws_port=int(request.POST.get("ws_port") or default_ws_port),
156
+ cp_path=request.POST.get("cp_path")
157
+ or default_cp_paths[cp_idx - 1],
158
+ rfid=request.POST.get("rfid") or default_rfid,
159
+ vin=request.POST.get("vin") or default_vins[cp_idx - 1],
160
+ duration=int(request.POST.get("duration") or 600),
161
+ interval=float(request.POST.get("interval") or 5),
162
+ kw_min=float(request.POST.get("kw_min") or 30),
163
+ kw_max=float(request.POST.get("kw_max") or 60),
164
+ pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
165
+ repeat=request.POST.get("repeat") or False,
166
+ daemon=True,
167
+ username=request.POST.get("username") or None,
168
+ password=request.POST.get("password") or None,
169
+ )
170
+ try:
171
+ started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
172
+ if started:
173
+ message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
174
+ else:
175
+ message = f"CP{cp_idx} {status}. Logs: {log_file}"
176
+ except Exception as exc: # pragma: no cover - unexpected
177
+ message = f"Failed to start CP{cp_idx}: {exc}"
178
+ elif action == "stop":
179
+ try:
180
+ _stop_simulator(cp=cp_idx)
181
+ message = f"CP{cp_idx} stop requested."
182
+ except Exception as exc: # pragma: no cover - unexpected
183
+ message = f"Failed to stop CP{cp_idx}: {exc}"
184
+ else:
185
+ message = "Unknown action."
186
+
187
+ states_dict = get_simulator_state()
188
+ state_list = [states_dict[1], states_dict[2]]
189
+ params_jsons = [
190
+ json.dumps(state_list[0].get("params", {}), indent=2),
191
+ json.dumps(state_list[1].get("params", {}), indent=2),
192
+ ]
193
+ state_jsons = [
194
+ _simulator_status_json(1),
195
+ _simulator_status_json(2),
196
+ ]
197
+
198
+ context = {
199
+ "message": message,
200
+ "states": state_list,
201
+ "default_host": default_host,
202
+ "default_ws_port": default_ws_port,
203
+ "default_cp_paths": default_cp_paths,
204
+ "default_rfid": default_rfid,
205
+ "default_vins": default_vins,
206
+ "params_jsons": params_jsons,
207
+ "state_jsons": state_jsons,
208
+ }
209
+ return render(request, "ocpp/cp_simulator.html", context)
210
+
211
+
212
+ def charger_page(request, cid):
213
+ """Public landing page for a charger displaying usage guidance or progress."""
214
+ charger = get_object_or_404(Charger, charger_id=cid)
215
+ tx = store.transactions.get(cid)
216
+ return render(request, "ocpp/charger_page.html", {"charger": charger, "tx": tx})
217
+
218
+
219
+ @login_required
220
+ def charger_status(request, cid):
221
+ charger = get_object_or_404(Charger, charger_id=cid)
222
+ session_id = request.GET.get("session")
223
+ live_tx = store.transactions.get(cid)
224
+ tx_obj = live_tx
225
+ past_session = False
226
+ if session_id:
227
+ if not (live_tx and str(live_tx.pk) == session_id):
228
+ tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
229
+ past_session = True
230
+ state, color = _charger_state(charger, live_tx)
231
+ transactions_qs = Transaction.objects.filter(charger=charger).order_by("-start_time")
232
+ paginator = Paginator(transactions_qs, 10)
233
+ page_obj = paginator.get_page(request.GET.get("page"))
234
+ transactions = page_obj.object_list
235
+ chart_data = {"labels": [], "values": []}
236
+ if tx_obj:
237
+ total = 0.0
238
+ readings = tx_obj.meter_readings.filter(
239
+ measurand__in=["", "Energy.Active.Import.Register"]
240
+ ).order_by("timestamp")
241
+ for reading in readings:
242
+ try:
243
+ val = float(reading.value)
244
+ except (TypeError, ValueError):
245
+ continue
246
+ if reading.unit == "kW":
247
+ total += val
248
+ else:
249
+ total += val / 1000.0
250
+ chart_data["labels"].append(reading.timestamp.isoformat())
251
+ chart_data["values"].append(total)
252
+ return render(
253
+ request,
254
+ "ocpp/charger_status.html",
255
+ {
256
+ "charger": charger,
257
+ "tx": tx_obj,
258
+ "state": state,
259
+ "color": color,
260
+ "transactions": transactions,
261
+ "page_obj": page_obj,
262
+ "chart_data": json.dumps(chart_data),
263
+ "past_session": past_session,
264
+ },
265
+ )
266
+
267
+
268
+ @login_required
269
+ def charger_session_search(request, cid):
270
+ charger = get_object_or_404(Charger, charger_id=cid)
271
+ date_str = request.GET.get("date")
272
+ transactions = None
273
+ if date_str:
274
+ try:
275
+ date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
276
+ start = datetime.combine(date_obj, datetime.min.time(), tzinfo=dt_timezone.utc)
277
+ end = start + timedelta(days=1)
278
+ transactions = (
279
+ Transaction.objects.filter(
280
+ charger=charger, start_time__gte=start, start_time__lt=end
281
+ ).order_by("-start_time")
282
+ )
283
+ except ValueError:
284
+ transactions = []
285
+ return render(
286
+ request,
287
+ "ocpp/charger_session_search.html",
288
+ {"charger": charger, "transactions": transactions, "date": date_str},
289
+ )
290
+
291
+
292
+ @login_required
293
+ def charger_log_page(request, cid):
294
+ """Render a simple page with the log for the charger or simulator."""
295
+ log_type = request.GET.get("type", "charger")
296
+ try:
297
+ charger = Charger.objects.get(charger_id=cid)
298
+ except Charger.DoesNotExist:
299
+ charger = Charger(charger_id=cid)
300
+ log = store.get_logs(cid, log_type=log_type)
301
+ return render(
302
+ request,
303
+ "ocpp/charger_logs.html",
304
+ {"charger": charger, "log": log},
305
+ )
306
+
307
+
308
+ @login_required
309
+ @landing("EV Efficiency")
310
+ def efficiency_calculator(request):
311
+ """Simple EV efficiency calculator."""
312
+ form = {k: v for k, v in (request.POST or request.GET).items() if v not in (None, "")}
313
+ context: dict[str, object] = {"form": form}
314
+ if request.method == "POST":
315
+ try:
316
+ distance = float(request.POST.get("distance"))
317
+ energy = float(request.POST.get("energy"))
318
+ if distance <= 0 or energy <= 0:
319
+ raise ValueError
320
+ except (TypeError, ValueError):
321
+ context["error"] = _("Invalid input values")
322
+ else:
323
+ km_per_kwh = distance / energy
324
+ wh_per_km = (energy * 1000) / distance
325
+ context["result"] = {
326
+ "km_per_kwh": km_per_kwh,
327
+ "wh_per_km": wh_per_km,
328
+ }
329
+ return render(request, "ocpp/efficiency_calculator.html", context)
330
+
331
+ @csrf_exempt
332
+ @api_login_required
333
+ def dispatch_action(request, cid):
334
+ ws = store.connections.get(cid)
335
+ if ws is None:
336
+ return JsonResponse({"detail": "no connection"}, status=404)
337
+ try:
338
+ data = json.loads(request.body.decode()) if request.body else {}
339
+ except json.JSONDecodeError:
340
+ data = {}
341
+ action = data.get("action")
342
+ if action == "remote_stop":
343
+ tx_obj = store.transactions.get(cid)
344
+ if not tx_obj:
345
+ return JsonResponse({"detail": "no transaction"}, status=404)
346
+ msg = json.dumps([
347
+ 2,
348
+ str(datetime.utcnow().timestamp()),
349
+ "RemoteStopTransaction",
350
+ {"transactionId": tx_obj.pk},
351
+ ])
352
+ asyncio.get_event_loop().create_task(ws.send(msg))
353
+ elif action == "reset":
354
+ msg = json.dumps([2, str(datetime.utcnow().timestamp()), "Reset", {"type": "Soft"}])
355
+ asyncio.get_event_loop().create_task(ws.send(msg))
356
+ else:
357
+ return JsonResponse({"detail": "unknown action"}, status=400)
358
+ store.add_log(cid, f"< {msg}", log_type="charger")
359
+ return JsonResponse({"sent": msg})
pages/__init__.py ADDED
File without changes