arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
ocpp/views.py CHANGED
@@ -1,17 +1,26 @@
1
1
  import asyncio
2
2
  import json
3
3
  from datetime import datetime, timedelta, timezone as dt_timezone
4
+ from types import SimpleNamespace
4
5
 
5
- from django.http import JsonResponse, HttpResponse
6
+ from django.http import JsonResponse, Http404
7
+ from django.http.request import split_domain_port
6
8
  from django.views.decorators.csrf import csrf_exempt
7
9
  from django.shortcuts import render, get_object_or_404
8
10
  from django.core.paginator import Paginator
9
11
  from django.contrib.auth.decorators import login_required
10
- from django.utils.translation import gettext_lazy as _
12
+ from django.contrib.auth.views import redirect_to_login
13
+ from django.utils.translation import gettext_lazy as _, gettext, ngettext
14
+ from django.urls import NoReverseMatch, reverse
15
+ from django.conf import settings
16
+ from django.utils import translation
11
17
 
12
18
  from utils.api import api_login_required
13
19
 
20
+ from nodes.models import Node
21
+
14
22
  from pages.utils import landing
23
+ from core.liveupdate import live_update
15
24
 
16
25
  from . import store
17
26
  from .models import Transaction, Charger
@@ -23,25 +32,216 @@ from .evcs import (
23
32
  )
24
33
 
25
34
 
26
- def _charger_state(charger: Charger, tx_obj: Transaction | None):
35
+ def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
36
+ """Return connector value and normalized slug or raise 404."""
37
+
38
+ try:
39
+ value = Charger.connector_value_from_slug(slug)
40
+ except ValueError as exc: # pragma: no cover - defensive guard
41
+ raise Http404("Invalid connector") from exc
42
+ return value, Charger.connector_slug_from_value(value)
43
+
44
+
45
+ def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
46
+ """Return URL name for connector-aware routes."""
47
+
48
+ target = f"{name}-connector"
49
+ if connector_slug == Charger.AGGREGATE_CONNECTOR_SLUG:
50
+ try:
51
+ return reverse(target, args=[serial, connector_slug])
52
+ except NoReverseMatch:
53
+ return reverse(name, args=[serial])
54
+ return reverse(target, args=[serial, connector_slug])
55
+
56
+
57
+ def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
58
+ """Return charger for the requested identity, creating if necessary."""
59
+
60
+ connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
61
+ if connector_value is None:
62
+ charger, _ = Charger.objects.get_or_create(
63
+ charger_id=serial,
64
+ connector_id=None,
65
+ )
66
+ else:
67
+ charger, _ = Charger.objects.get_or_create(
68
+ charger_id=serial,
69
+ connector_id=connector_value,
70
+ )
71
+ return charger, normalized_slug
72
+
73
+
74
+ def _connector_set(charger: Charger) -> list[Charger]:
75
+ """Return chargers sharing the same serial ordered for navigation."""
76
+
77
+ siblings = list(Charger.objects.filter(charger_id=charger.charger_id))
78
+ siblings.sort(key=lambda c: (c.connector_id is not None, c.connector_id or 0))
79
+ return siblings
80
+
81
+
82
+ def _connector_overview(charger: Charger) -> list[dict]:
83
+ """Return connector metadata used for navigation and summaries."""
84
+
85
+ overview: list[dict] = []
86
+ for sibling in _connector_set(charger):
87
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
88
+ state, color = _charger_state(sibling, tx_obj)
89
+ overview.append(
90
+ {
91
+ "charger": sibling,
92
+ "slug": sibling.connector_slug,
93
+ "label": sibling.connector_label,
94
+ "url": _reverse_connector_url(
95
+ "charger-page", sibling.charger_id, sibling.connector_slug
96
+ ),
97
+ "status": state,
98
+ "color": color,
99
+ "last_status": sibling.last_status,
100
+ "last_error_code": sibling.last_error_code,
101
+ "last_status_timestamp": sibling.last_status_timestamp,
102
+ "last_status_vendor_info": sibling.last_status_vendor_info,
103
+ "tx": tx_obj,
104
+ "connected": store.is_connected(
105
+ sibling.charger_id, sibling.connector_id
106
+ ),
107
+ }
108
+ )
109
+ return overview
110
+
111
+
112
+ def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
113
+ """Return active sessions grouped by connector for the charger."""
114
+
115
+ siblings = _connector_set(charger)
116
+ ordered = [c for c in siblings if c.connector_id is not None] + [
117
+ c for c in siblings if c.connector_id is None
118
+ ]
119
+ sessions: list[tuple[Charger, Transaction]] = []
120
+ seen: set[int] = set()
121
+ for sibling in ordered:
122
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
123
+ if not tx_obj:
124
+ continue
125
+ if tx_obj.pk and tx_obj.pk in seen:
126
+ continue
127
+ if tx_obj.pk:
128
+ seen.add(tx_obj.pk)
129
+ sessions.append((sibling, tx_obj))
130
+ return sessions
131
+
132
+
133
+ def _landing_page_translations() -> dict[str, dict[str, str]]:
134
+ """Return static translations used by the charger public landing page."""
135
+
136
+ catalog: dict[str, dict[str, str]] = {}
137
+ for code in ("en", "es"):
138
+ with translation.override(code):
139
+ catalog[code] = {
140
+ "serial_number_label": gettext("Serial Number"),
141
+ "connector_label": gettext("Connector"),
142
+ "advanced_view_label": gettext("Advanced View"),
143
+ "require_rfid_label": gettext("Require RFID Authorization"),
144
+ "charging_label": gettext("Charging"),
145
+ "energy_label": gettext("Energy"),
146
+ "started_label": gettext("Started"),
147
+ "instruction_text": gettext(
148
+ "Plug in your vehicle and slide your RFID card over the reader to begin charging."
149
+ ),
150
+ "connectors_heading": gettext("Connectors"),
151
+ "no_active_transaction": gettext("No active transaction"),
152
+ "connectors_active_singular": ngettext(
153
+ "%(count)s connector active",
154
+ "%(count)s connectors active",
155
+ 1,
156
+ ),
157
+ "connectors_active_plural": ngettext(
158
+ "%(count)s connector active",
159
+ "%(count)s connectors active",
160
+ 2,
161
+ ),
162
+ "status_reported_label": gettext("Reported status"),
163
+ "status_error_label": gettext("Error code"),
164
+ "status_updated_label": gettext("Last status update"),
165
+ "status_vendor_label": gettext("Vendor"),
166
+ "status_info_label": gettext("Info"),
167
+ }
168
+ return catalog
169
+
170
+
171
+ STATUS_BADGE_MAP: dict[str, tuple[str, str]] = {
172
+ "available": (_("Available"), "#0d6efd"),
173
+ "preparing": (_("Preparing"), "#0d6efd"),
174
+ "charging": (_("Charging"), "#198754"),
175
+ "suspendedevse": (_("Suspended (EVSE)"), "#fd7e14"),
176
+ "suspendedev": (_("Suspended (EV)"), "#fd7e14"),
177
+ "finishing": (_("Finishing"), "#20c997"),
178
+ "faulted": (_("Faulted"), "#dc3545"),
179
+ "unavailable": (_("Unavailable"), "#6c757d"),
180
+ "reserved": (_("Reserved"), "#6f42c1"),
181
+ "occupied": (_("Occupied"), "#0dcaf0"),
182
+ "outofservice": (_("Out of Service"), "#6c757d"),
183
+ }
184
+
185
+ _ERROR_OK_VALUES = {"", "noerror", "no_error"}
186
+
187
+
188
+ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
27
189
  """Return human readable state and color for a charger."""
190
+
191
+ status_value = (charger.last_status or "").strip()
192
+ if status_value:
193
+ key = status_value.lower()
194
+ label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
195
+ error_code = (charger.last_error_code or "").strip()
196
+ if error_code and error_code.lower() not in _ERROR_OK_VALUES:
197
+ label = _("%(status)s (%(error)s)") % {
198
+ "status": label,
199
+ "error": error_code,
200
+ }
201
+ color = "#dc3545"
202
+ return label, color
203
+
28
204
  cid = charger.charger_id
29
- connected = cid in store.connections
30
- if connected and tx_obj:
31
- return "Charging", "green"
205
+ connected = store.is_connected(cid, charger.connector_id)
206
+ has_session = bool(tx_obj)
207
+ if connected and has_session:
208
+ return _("Charging"), "green"
32
209
  if connected:
33
- return "Available", "blue"
34
- return "Offline", "grey"
210
+ return _("Available"), "blue"
211
+ return _("Offline"), "grey"
35
212
 
36
213
 
214
+ def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
215
+ """Return diagnostics metadata for API responses."""
216
+
217
+ timestamp = (
218
+ charger.diagnostics_timestamp.isoformat()
219
+ if charger.diagnostics_timestamp
220
+ else None
221
+ )
222
+ status = charger.diagnostics_status or None
223
+ location = charger.diagnostics_location or None
224
+ return {
225
+ "diagnosticsStatus": status,
226
+ "diagnosticsTimestamp": timestamp,
227
+ "diagnosticsLocation": location,
228
+ }
229
+
37
230
 
38
231
  @api_login_required
39
232
  def charger_list(request):
40
233
  """Return a JSON list of known chargers and state."""
41
234
  data = []
42
- for charger in Charger.objects.all():
235
+ for charger in Charger.objects.filter(public_display=True):
43
236
  cid = charger.charger_id
44
- tx_obj = store.transactions.get(cid)
237
+ sessions: list[tuple[Charger, Transaction]] = []
238
+ tx_obj = store.get_transaction(cid, charger.connector_id)
239
+ if charger.connector_id is None:
240
+ sessions = _live_sessions(charger)
241
+ if sessions:
242
+ tx_obj = sessions[0][1]
243
+ elif tx_obj:
244
+ sessions = [(charger, tx_obj)]
45
245
  if not tx_obj:
46
246
  tx_obj = (
47
247
  Transaction.objects.filter(charger__charger_id=cid)
@@ -61,27 +261,78 @@ def charger_list(request):
61
261
  tx_data["meterStop"] = tx_obj.meter_stop
62
262
  if tx_obj.stop_time is not None:
63
263
  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,
264
+ active_transactions = []
265
+ for session_charger, session_tx in sessions:
266
+ active_payload = {
267
+ "charger_id": session_charger.charger_id,
268
+ "connector_id": session_charger.connector_id,
269
+ "connector_slug": session_charger.connector_slug,
270
+ "transactionId": session_tx.pk,
271
+ "meterStart": session_tx.meter_start,
272
+ "startTime": session_tx.start_time.isoformat(),
73
273
  }
274
+ if session_tx.vin:
275
+ active_payload["vin"] = session_tx.vin
276
+ if session_tx.meter_stop is not None:
277
+ active_payload["meterStop"] = session_tx.meter_stop
278
+ if session_tx.stop_time is not None:
279
+ active_payload["stopTime"] = session_tx.stop_time.isoformat()
280
+ active_transactions.append(active_payload)
281
+ state, color = _charger_state(
282
+ charger,
283
+ tx_obj if charger.connector_id is not None else (sessions if sessions else None),
74
284
  )
285
+ entry = {
286
+ "charger_id": cid,
287
+ "name": charger.name,
288
+ "connector_id": charger.connector_id,
289
+ "connector_slug": charger.connector_slug,
290
+ "connector_label": charger.connector_label,
291
+ "require_rfid": charger.require_rfid,
292
+ "transaction": tx_data,
293
+ "activeTransactions": active_transactions,
294
+ "lastHeartbeat": (
295
+ charger.last_heartbeat.isoformat()
296
+ if charger.last_heartbeat
297
+ else None
298
+ ),
299
+ "lastMeterValues": charger.last_meter_values,
300
+ "firmwareStatus": charger.firmware_status,
301
+ "firmwareStatusInfo": charger.firmware_status_info,
302
+ "firmwareTimestamp": (
303
+ charger.firmware_timestamp.isoformat()
304
+ if charger.firmware_timestamp
305
+ else None
306
+ ),
307
+ "connected": store.is_connected(cid, charger.connector_id),
308
+ "lastStatus": charger.last_status or None,
309
+ "lastErrorCode": charger.last_error_code or None,
310
+ "lastStatusTimestamp": (
311
+ charger.last_status_timestamp.isoformat()
312
+ if charger.last_status_timestamp
313
+ else None
314
+ ),
315
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
316
+ "status": state,
317
+ "statusColor": color,
318
+ }
319
+ entry.update(_diagnostics_payload(charger))
320
+ data.append(entry)
75
321
  return JsonResponse({"chargers": data})
76
322
 
77
323
 
78
324
  @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)
325
+ def charger_detail(request, cid, connector=None):
326
+ charger, connector_slug = _get_charger(cid, connector)
327
+
328
+ sessions: list[tuple[Charger, Transaction]] = []
329
+ tx_obj = store.get_transaction(cid, charger.connector_id)
330
+ if charger.connector_id is None:
331
+ sessions = _live_sessions(charger)
332
+ if sessions:
333
+ tx_obj = sessions[0][1]
334
+ elif tx_obj:
335
+ sessions = [(charger, tx_obj)]
85
336
  if not tx_obj:
86
337
  tx_obj = (
87
338
  Transaction.objects.filter(charger__charger_id=cid)
@@ -103,27 +354,79 @@ def charger_detail(request, cid):
103
354
  if tx_obj.stop_time is not None:
104
355
  tx_data["stopTime"] = tx_obj.stop_time.isoformat()
105
356
 
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,
357
+ active_transactions = []
358
+ for session_charger, session_tx in sessions:
359
+ payload = {
360
+ "charger_id": session_charger.charger_id,
361
+ "connector_id": session_charger.connector_id,
362
+ "connector_slug": session_charger.connector_slug,
363
+ "transactionId": session_tx.pk,
364
+ "meterStart": session_tx.meter_start,
365
+ "startTime": session_tx.start_time.isoformat(),
116
366
  }
367
+ if session_tx.vin:
368
+ payload["vin"] = session_tx.vin
369
+ if session_tx.meter_stop is not None:
370
+ payload["meterStop"] = session_tx.meter_stop
371
+ if session_tx.stop_time is not None:
372
+ payload["stopTime"] = session_tx.stop_time.isoformat()
373
+ active_transactions.append(payload)
374
+
375
+ log_key = store.identity_key(cid, charger.connector_id)
376
+ log = store.get_logs(log_key, log_type="charger")
377
+ state, color = _charger_state(
378
+ charger,
379
+ tx_obj if charger.connector_id is not None else (sessions if sessions else None),
117
380
  )
381
+ payload = {
382
+ "charger_id": cid,
383
+ "connector_id": charger.connector_id,
384
+ "connector_slug": connector_slug,
385
+ "name": charger.name,
386
+ "require_rfid": charger.require_rfid,
387
+ "transaction": tx_data,
388
+ "activeTransactions": active_transactions,
389
+ "lastHeartbeat": (
390
+ charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
391
+ ),
392
+ "lastMeterValues": charger.last_meter_values,
393
+ "firmwareStatus": charger.firmware_status,
394
+ "firmwareStatusInfo": charger.firmware_status_info,
395
+ "firmwareTimestamp": (
396
+ charger.firmware_timestamp.isoformat()
397
+ if charger.firmware_timestamp
398
+ else None
399
+ ),
400
+ "log": log,
401
+ "lastStatus": charger.last_status or None,
402
+ "lastErrorCode": charger.last_error_code or None,
403
+ "lastStatusTimestamp": (
404
+ charger.last_status_timestamp.isoformat()
405
+ if charger.last_status_timestamp
406
+ else None
407
+ ),
408
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
409
+ "status": state,
410
+ "statusColor": color,
411
+ }
412
+ payload.update(_diagnostics_payload(charger))
413
+ return JsonResponse(payload)
118
414
 
119
415
 
120
- @login_required
121
- @landing("Dashboard")
416
+ @landing("OCPP CSMS Dashboard")
417
+ @live_update()
122
418
  def dashboard(request):
123
419
  """Landing page listing all known chargers and their status."""
420
+ node = Node.get_local()
421
+ role = node.role if node else None
422
+ is_constellation = bool(role and role.name == "Constellation")
423
+ if not request.user.is_authenticated and not is_constellation:
424
+ return redirect_to_login(
425
+ request.get_full_path(), login_url=reverse("pages:login")
426
+ )
124
427
  chargers = []
125
- for charger in Charger.objects.all():
126
- tx_obj = store.transactions.get(charger.charger_id)
428
+ for charger in Charger.objects.filter(public_display=True):
429
+ tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
127
430
  if not tx_obj:
128
431
  tx_obj = (
129
432
  Transaction.objects.filter(charger=charger)
@@ -132,16 +435,31 @@ def dashboard(request):
132
435
  )
133
436
  state, color = _charger_state(charger, tx_obj)
134
437
  chargers.append({"charger": charger, "state": state, "color": color})
135
- return render(request, "ocpp/dashboard.html", {"chargers": chargers})
438
+ scheme = "wss" if request.is_secure() else "ws"
439
+ host = request.get_host()
440
+ ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
441
+ context = {
442
+ "chargers": chargers,
443
+ "show_demo_notice": is_constellation,
444
+ "demo_ws_url": ws_url,
445
+ "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
446
+ }
447
+ return render(request, "ocpp/dashboard.html", context)
136
448
 
137
449
 
138
450
  @login_required
139
- @landing("CP Simulator")
451
+ @landing("Charge Point Simulator")
452
+ @live_update()
140
453
  def cp_simulator(request):
141
454
  """Public landing page to control the OCPP charge point simulator."""
142
- default_host = "127.0.0.1"
143
- default_ws_port = "9000"
455
+ host_header = request.get_host()
456
+ default_host, host_port = split_domain_port(host_header)
457
+ if not default_host:
458
+ default_host = "127.0.0.1"
459
+ default_ws_port = request.get_port() or host_port or "8000"
144
460
  default_cp_paths = ["CP1", "CP2"]
461
+ default_serial_numbers = default_cp_paths
462
+ default_connector_id = 1
145
463
  default_rfid = "FFFFFFFF"
146
464
  default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
147
465
 
@@ -150,14 +468,25 @@ def cp_simulator(request):
150
468
  cp_idx = int(request.POST.get("cp") or 1)
151
469
  action = request.POST.get("action")
152
470
  if action == "start":
471
+ ws_port_value = request.POST.get("ws_port")
472
+ if ws_port_value is None:
473
+ ws_port = int(default_ws_port) if default_ws_port else None
474
+ elif ws_port_value.strip():
475
+ ws_port = int(ws_port_value)
476
+ else:
477
+ ws_port = None
153
478
  sim_params = dict(
154
479
  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),
480
+ ws_port=ws_port,
481
+ cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
482
+ serial_number=request.POST.get("serial_number")
483
+ or default_serial_numbers[cp_idx - 1],
484
+ connector_id=int(
485
+ request.POST.get("connector_id") or default_connector_id
486
+ ),
487
+ rfid=request.POST.get("rfid") or default_rfid,
488
+ vin=request.POST.get("vin") or default_vins[cp_idx - 1],
489
+ duration=int(request.POST.get("duration") or 600),
161
490
  interval=float(request.POST.get("interval") or 5),
162
491
  kw_min=float(request.POST.get("kw_min") or 30),
163
492
  kw_max=float(request.POST.get("kw_max") or 60),
@@ -201,6 +530,8 @@ def cp_simulator(request):
201
530
  "default_host": default_host,
202
531
  "default_ws_port": default_ws_port,
203
532
  "default_cp_paths": default_cp_paths,
533
+ "default_serial_numbers": default_serial_numbers,
534
+ "default_connector_id": default_connector_id,
204
535
  "default_rfid": default_rfid,
205
536
  "default_vins": default_vins,
206
537
  "params_jsons": params_jsons,
@@ -209,46 +540,218 @@ def cp_simulator(request):
209
540
  return render(request, "ocpp/cp_simulator.html", context)
210
541
 
211
542
 
212
- def charger_page(request, cid):
543
+ def charger_page(request, cid, connector=None):
213
544
  """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})
545
+ charger, connector_slug = _get_charger(cid, connector)
546
+ overview = _connector_overview(charger)
547
+ sessions = _live_sessions(charger)
548
+ tx = None
549
+ active_connector_count = 0
550
+ if charger.connector_id is None:
551
+ if sessions:
552
+ total_kw = 0.0
553
+ start_times = [
554
+ tx_obj.start_time for _, tx_obj in sessions if tx_obj.start_time
555
+ ]
556
+ for _, tx_obj in sessions:
557
+ if tx_obj.kw:
558
+ total_kw += tx_obj.kw
559
+ tx = SimpleNamespace(
560
+ kw=total_kw, start_time=min(start_times) if start_times else None
561
+ )
562
+ active_connector_count = len(sessions)
563
+ else:
564
+ tx = (
565
+ sessions[0][1]
566
+ if sessions
567
+ else store.get_transaction(cid, charger.connector_id)
568
+ )
569
+ if tx:
570
+ active_connector_count = 1
571
+ state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
572
+ state, color = _charger_state(charger, state_source)
573
+ language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
574
+ preferred_language = "es"
575
+ supported_languages = {code for code, _ in settings.LANGUAGES}
576
+ if preferred_language in supported_languages and not language_cookie:
577
+ translation.activate(preferred_language)
578
+ request.LANGUAGE_CODE = translation.get_language()
579
+ connector_links = [
580
+ {
581
+ "slug": item["slug"],
582
+ "label": item["label"],
583
+ "url": item["url"],
584
+ "active": item["slug"] == connector_slug,
585
+ }
586
+ for item in overview
587
+ ]
588
+ connector_overview = [
589
+ item for item in overview if item["charger"].connector_id is not None
590
+ ]
591
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
592
+ return render(
593
+ request,
594
+ "ocpp/charger_page.html",
595
+ {
596
+ "charger": charger,
597
+ "tx": tx,
598
+ "connector_slug": connector_slug,
599
+ "connector_links": connector_links,
600
+ "connector_overview": connector_overview,
601
+ "active_connector_count": active_connector_count,
602
+ "status_url": status_url,
603
+ "landing_translations": _landing_page_translations(),
604
+ "state": state,
605
+ "color": color,
606
+ },
607
+ )
217
608
 
218
609
 
219
610
  @login_required
220
- def charger_status(request, cid):
221
- charger = get_object_or_404(Charger, charger_id=cid)
611
+ def charger_status(request, cid, connector=None):
612
+ charger, connector_slug = _get_charger(cid, connector)
222
613
  session_id = request.GET.get("session")
223
- live_tx = store.transactions.get(cid)
614
+ sessions = _live_sessions(charger)
615
+ live_tx = None
616
+ if charger.connector_id is not None and sessions:
617
+ live_tx = sessions[0][1]
224
618
  tx_obj = live_tx
225
619
  past_session = False
226
620
  if session_id:
227
- if not (live_tx and str(live_tx.pk) == session_id):
621
+ if charger.connector_id is None:
622
+ tx_obj = get_object_or_404(
623
+ Transaction, pk=session_id, charger__charger_id=cid
624
+ )
625
+ past_session = True
626
+ elif not (live_tx and str(live_tx.pk) == session_id):
228
627
  tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
229
628
  past_session = True
230
- state, color = _charger_state(charger, live_tx)
231
- transactions_qs = Transaction.objects.filter(charger=charger).order_by("-start_time")
629
+ state, color = _charger_state(
630
+ charger,
631
+ (
632
+ live_tx
633
+ if charger.connector_id is not None
634
+ else (sessions if sessions else None)
635
+ ),
636
+ )
637
+ if charger.connector_id is None:
638
+ transactions_qs = (
639
+ Transaction.objects.filter(charger__charger_id=cid)
640
+ .select_related("charger")
641
+ .order_by("-start_time")
642
+ )
643
+ else:
644
+ transactions_qs = Transaction.objects.filter(charger=charger).order_by(
645
+ "-start_time"
646
+ )
232
647
  paginator = Paginator(transactions_qs, 10)
233
648
  page_obj = paginator.get_page(request.GET.get("page"))
234
649
  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")
650
+ chart_data = {"labels": [], "datasets": []}
651
+
652
+ def _series_from_transaction(tx):
653
+ points: list[tuple[str, float]] = []
654
+ readings = list(
655
+ tx.meter_values.filter(energy__isnull=False).order_by("timestamp")
656
+ )
657
+ start_val = None
658
+ if tx.meter_start is not None:
659
+ start_val = float(tx.meter_start) / 1000.0
241
660
  for reading in readings:
242
661
  try:
243
- val = float(reading.value)
662
+ val = float(reading.energy)
244
663
  except (TypeError, ValueError):
245
664
  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)
665
+ if start_val is None:
666
+ start_val = val
667
+ total = val - start_val
668
+ points.append((reading.timestamp.isoformat(), max(total, 0.0)))
669
+ return points
670
+
671
+ if tx_obj and (charger.connector_id is not None or past_session):
672
+ series_points = _series_from_transaction(tx_obj)
673
+ if series_points:
674
+ chart_data["labels"] = [ts for ts, _ in series_points]
675
+ connector_id = None
676
+ if tx_obj.charger and tx_obj.charger.connector_id is not None:
677
+ connector_id = tx_obj.charger.connector_id
678
+ elif charger.connector_id is not None:
679
+ connector_id = charger.connector_id
680
+ chart_data["datasets"].append(
681
+ {
682
+ "label": str(
683
+ tx_obj.charger.connector_label
684
+ if tx_obj.charger and tx_obj.charger.connector_id is not None
685
+ else charger.connector_label
686
+ ),
687
+ "values": [value for _, value in series_points],
688
+ "connector_id": connector_id,
689
+ }
690
+ )
691
+ elif charger.connector_id is None:
692
+ dataset_points: list[tuple[str, list[tuple[str, float]], int]] = []
693
+ for sibling, sibling_tx in sessions:
694
+ if sibling.connector_id is None or not sibling_tx:
695
+ continue
696
+ points = _series_from_transaction(sibling_tx)
697
+ if not points:
698
+ continue
699
+ dataset_points.append(
700
+ (str(sibling.connector_label), points, sibling.connector_id)
701
+ )
702
+ if dataset_points:
703
+ all_labels: list[str] = sorted(
704
+ {ts for _, points, _ in dataset_points for ts, _ in points}
705
+ )
706
+ chart_data["labels"] = all_labels
707
+ for label, points, connector_id in dataset_points:
708
+ value_map = {ts: val for ts, val in points}
709
+ chart_data["datasets"].append(
710
+ {
711
+ "label": label,
712
+ "values": [value_map.get(ts) for ts in all_labels],
713
+ "connector_id": connector_id,
714
+ }
715
+ )
716
+ overview = _connector_overview(charger)
717
+ connector_links = [
718
+ {
719
+ "slug": item["slug"],
720
+ "label": item["label"],
721
+ "url": _reverse_connector_url("charger-status", cid, item["slug"]),
722
+ "active": item["slug"] == connector_slug,
723
+ }
724
+ for item in overview
725
+ ]
726
+ connector_overview = [
727
+ item for item in overview if item["charger"].connector_id is not None
728
+ ]
729
+ search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
730
+ configuration_url = None
731
+ if request.user.is_staff:
732
+ try:
733
+ configuration_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
734
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
735
+ configuration_url = None
736
+ is_connected = store.is_connected(cid, charger.connector_id)
737
+ has_active_session = bool(
738
+ live_tx if charger.connector_id is not None else sessions
739
+ )
740
+ can_remote_start = (
741
+ charger.connector_id is not None
742
+ and is_connected
743
+ and not has_active_session
744
+ and not past_session
745
+ )
746
+ remote_start_messages = None
747
+ if can_remote_start:
748
+ remote_start_messages = {
749
+ "required": str(_("RFID is required to start a session.")),
750
+ "sending": str(_("Sending remote start request...")),
751
+ "success": str(_("Remote start command queued.")),
752
+ "error": str(_("Unable to send remote start request.")),
753
+ }
754
+ action_url = _reverse_connector_url("charger-action", cid, connector_slug)
252
755
  return render(
253
756
  request,
254
757
  "ocpp/charger_status.html",
@@ -259,79 +762,122 @@ def charger_status(request, cid):
259
762
  "color": color,
260
763
  "transactions": transactions,
261
764
  "page_obj": page_obj,
262
- "chart_data": json.dumps(chart_data),
765
+ "chart_data": chart_data,
263
766
  "past_session": past_session,
767
+ "connector_slug": connector_slug,
768
+ "connector_links": connector_links,
769
+ "connector_overview": connector_overview,
770
+ "search_url": search_url,
771
+ "configuration_url": configuration_url,
772
+ "page_url": _reverse_connector_url("charger-page", cid, connector_slug),
773
+ "is_connected": is_connected,
774
+ "is_idle": is_connected and not has_active_session,
775
+ "can_remote_start": can_remote_start,
776
+ "remote_start_messages": remote_start_messages,
777
+ "action_url": action_url,
778
+ "show_chart": bool(
779
+ chart_data["datasets"]
780
+ and any(
781
+ any(value is not None for value in dataset["values"])
782
+ for dataset in chart_data["datasets"]
783
+ )
784
+ ),
264
785
  },
265
786
  )
266
787
 
267
788
 
268
789
  @login_required
269
- def charger_session_search(request, cid):
270
- charger = get_object_or_404(Charger, charger_id=cid)
790
+ def charger_session_search(request, cid, connector=None):
791
+ charger, connector_slug = _get_charger(cid, connector)
271
792
  date_str = request.GET.get("date")
272
793
  transactions = None
273
794
  if date_str:
274
795
  try:
275
796
  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")
797
+ start = datetime.combine(
798
+ date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
282
799
  )
800
+ end = start + timedelta(days=1)
801
+ qs = Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
802
+ if charger.connector_id is None:
803
+ qs = qs.filter(charger__charger_id=cid)
804
+ else:
805
+ qs = qs.filter(charger=charger)
806
+ transactions = qs.order_by("-start_time")
283
807
  except ValueError:
284
808
  transactions = []
809
+ overview = _connector_overview(charger)
810
+ connector_links = [
811
+ {
812
+ "slug": item["slug"],
813
+ "label": item["label"],
814
+ "url": _reverse_connector_url("charger-session-search", cid, item["slug"]),
815
+ "active": item["slug"] == connector_slug,
816
+ }
817
+ for item in overview
818
+ ]
819
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
285
820
  return render(
286
821
  request,
287
822
  "ocpp/charger_session_search.html",
288
- {"charger": charger, "transactions": transactions, "date": date_str},
823
+ {
824
+ "charger": charger,
825
+ "transactions": transactions,
826
+ "date": date_str,
827
+ "connector_slug": connector_slug,
828
+ "connector_links": connector_links,
829
+ "status_url": status_url,
830
+ },
289
831
  )
290
832
 
291
833
 
292
834
  @login_required
293
- def charger_log_page(request, cid):
835
+ def charger_log_page(request, cid, connector=None):
294
836
  """Render a simple page with the log for the charger or simulator."""
295
837
  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)
838
+ connector_links = []
839
+ connector_slug = None
840
+ status_url = None
841
+ if log_type == "charger":
842
+ charger, connector_slug = _get_charger(cid, connector)
843
+ log_key = store.identity_key(cid, charger.connector_id)
844
+ overview = _connector_overview(charger)
845
+ connector_links = [
846
+ {
847
+ "slug": item["slug"],
848
+ "label": item["label"],
849
+ "url": _reverse_connector_url("charger-log", cid, item["slug"]),
850
+ "active": item["slug"] == connector_slug,
851
+ }
852
+ for item in overview
853
+ ]
854
+ target_id = log_key
855
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
856
+ else:
857
+ charger = Charger.objects.filter(charger_id=cid).first() or Charger(
858
+ charger_id=cid
859
+ )
860
+ target_id = cid
861
+ log = store.get_logs(target_id, log_type=log_type)
301
862
  return render(
302
863
  request,
303
864
  "ocpp/charger_logs.html",
304
- {"charger": charger, "log": log},
865
+ {
866
+ "charger": charger,
867
+ "log": log,
868
+ "log_type": log_type,
869
+ "connector_slug": connector_slug,
870
+ "connector_links": connector_links,
871
+ "status_url": status_url,
872
+ },
305
873
  )
306
874
 
307
875
 
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
876
  @csrf_exempt
332
877
  @api_login_required
333
- def dispatch_action(request, cid):
334
- ws = store.connections.get(cid)
878
+ def dispatch_action(request, cid, connector=None):
879
+ connector_value, _ = _normalize_connector_slug(connector)
880
+ ws = store.get_connection(cid, connector_value)
335
881
  if ws is None:
336
882
  return JsonResponse({"detail": "no connection"}, status=404)
337
883
  try:
@@ -340,20 +886,52 @@ def dispatch_action(request, cid):
340
886
  data = {}
341
887
  action = data.get("action")
342
888
  if action == "remote_stop":
343
- tx_obj = store.transactions.get(cid)
889
+ tx_obj = store.get_transaction(cid, connector_value)
344
890
  if not tx_obj:
345
891
  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
- ])
892
+ msg = json.dumps(
893
+ [
894
+ 2,
895
+ str(datetime.utcnow().timestamp()),
896
+ "RemoteStopTransaction",
897
+ {"transactionId": tx_obj.pk},
898
+ ]
899
+ )
900
+ asyncio.get_event_loop().create_task(ws.send(msg))
901
+ elif action == "remote_start":
902
+ id_tag = data.get("idTag")
903
+ if not isinstance(id_tag, str) or not id_tag.strip():
904
+ return JsonResponse({"detail": "idTag required"}, status=400)
905
+ id_tag = id_tag.strip()
906
+ payload: dict[str, object] = {"idTag": id_tag}
907
+ connector_id = data.get("connectorId")
908
+ if connector_id in ("", None):
909
+ connector_id = None
910
+ if connector_id is None and connector_value is not None:
911
+ connector_id = connector_value
912
+ if connector_id is not None:
913
+ try:
914
+ payload["connectorId"] = int(connector_id)
915
+ except (TypeError, ValueError):
916
+ payload["connectorId"] = connector_id
917
+ if "chargingProfile" in data and data["chargingProfile"] is not None:
918
+ payload["chargingProfile"] = data["chargingProfile"]
919
+ msg = json.dumps(
920
+ [
921
+ 2,
922
+ str(datetime.utcnow().timestamp()),
923
+ "RemoteStartTransaction",
924
+ payload,
925
+ ]
926
+ )
352
927
  asyncio.get_event_loop().create_task(ws.send(msg))
353
928
  elif action == "reset":
354
- msg = json.dumps([2, str(datetime.utcnow().timestamp()), "Reset", {"type": "Soft"}])
929
+ msg = json.dumps(
930
+ [2, str(datetime.utcnow().timestamp()), "Reset", {"type": "Soft"}]
931
+ )
355
932
  asyncio.get_event_loop().create_task(ws.send(msg))
356
933
  else:
357
934
  return JsonResponse({"detail": "unknown action"}, status=400)
358
- store.add_log(cid, f"< {msg}", log_type="charger")
935
+ log_key = store.identity_key(cid, connector_value)
936
+ store.add_log(log_key, f"< {msg}", log_type="charger")
359
937
  return JsonResponse({"sent": msg})