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