arthexis 0.1.9__py3-none-any.whl → 0.1.26__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/views.py CHANGED
@@ -1,912 +1,2024 @@
1
- import asyncio
2
- import json
3
- from datetime import datetime, timedelta, timezone as dt_timezone
4
- from types import SimpleNamespace
5
-
6
- from django.http import JsonResponse, Http404
7
- from django.http.request import split_domain_port
8
- from django.views.decorators.csrf import csrf_exempt
9
- from django.shortcuts import render, get_object_or_404
10
- from django.core.paginator import Paginator
11
- from django.contrib.auth.decorators import login_required
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
16
-
17
- from utils.api import api_login_required
18
-
19
- from pages.utils import landing
20
- from core.liveupdate import live_update
21
-
22
- from . import store
23
- from .models import Transaction, Charger
24
- from .evcs import (
25
- _start_simulator,
26
- _stop_simulator,
27
- get_simulator_state,
28
- _simulator_status_json,
29
- )
30
-
31
-
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):
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
-
201
- cid = charger.charger_id
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"
206
- if connected:
207
- return _("Available"), "blue"
208
- return _("Offline"), "grey"
209
-
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
-
227
-
228
- @api_login_required
229
- def charger_list(request):
230
- """Return a JSON list of known chargers and state."""
231
- data = []
232
- for charger in Charger.objects.all():
233
- cid = charger.charger_id
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)]
242
- if not tx_obj:
243
- tx_obj = (
244
- Transaction.objects.filter(charger__charger_id=cid)
245
- .order_by("-start_time")
246
- .first()
247
- )
248
- tx_data = None
249
- if tx_obj:
250
- tx_data = {
251
- "transactionId": tx_obj.pk,
252
- "meterStart": tx_obj.meter_start,
253
- "startTime": tx_obj.start_time.isoformat(),
254
- }
255
- if tx_obj.vin:
256
- tx_data["vin"] = tx_obj.vin
257
- if tx_obj.meter_stop is not None:
258
- tx_data["meterStop"] = tx_obj.meter_stop
259
- if tx_obj.stop_time is not None:
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
- )
282
- data.append(
283
- {
284
- "charger_id": cid,
285
- "name": charger.name,
286
- "connector_id": charger.connector_id,
287
- "connector_slug": charger.connector_slug,
288
- "connector_label": charger.connector_label,
289
- "require_rfid": charger.require_rfid,
290
- "transaction": tx_data,
291
- "activeTransactions": active_transactions,
292
- "lastHeartbeat": (
293
- charger.last_heartbeat.isoformat()
294
- if charger.last_heartbeat
295
- else None
296
- ),
297
- "lastMeterValues": charger.last_meter_values,
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,
316
- }
317
- )
318
- return JsonResponse({"chargers": data})
319
-
320
-
321
- @api_login_required
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)]
333
- if not tx_obj:
334
- tx_obj = (
335
- Transaction.objects.filter(charger__charger_id=cid)
336
- .order_by("-start_time")
337
- .first()
338
- )
339
-
340
- tx_data = None
341
- if tx_obj:
342
- tx_data = {
343
- "transactionId": tx_obj.pk,
344
- "meterStart": tx_obj.meter_start,
345
- "startTime": tx_obj.start_time.isoformat(),
346
- }
347
- if tx_obj.vin:
348
- tx_data["vin"] = tx_obj.vin
349
- if tx_obj.meter_stop is not None:
350
- tx_data["meterStop"] = tx_obj.meter_stop
351
- if tx_obj.stop_time is not None:
352
- tx_data["stopTime"] = tx_obj.stop_time.isoformat()
353
-
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
- )
378
- return JsonResponse(
379
- {
380
- "charger_id": cid,
381
- "connector_id": charger.connector_id,
382
- "connector_slug": connector_slug,
383
- "name": charger.name,
384
- "require_rfid": charger.require_rfid,
385
- "transaction": tx_data,
386
- "activeTransactions": active_transactions,
387
- "lastHeartbeat": (
388
- charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
389
- ),
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
- ),
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,
409
- }
410
- )
411
-
412
-
413
- @login_required
414
- @landing("Dashboard")
415
- @live_update()
416
- def dashboard(request):
417
- """Landing page listing all known chargers and their status."""
418
- chargers = []
419
- for charger in Charger.objects.all():
420
- tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
421
- if not tx_obj:
422
- tx_obj = (
423
- Transaction.objects.filter(charger=charger)
424
- .order_by("-start_time")
425
- .first()
426
- )
427
- state, color = _charger_state(charger, tx_obj)
428
- chargers.append({"charger": charger, "state": state, "color": color})
429
- return render(request, "ocpp/dashboard.html", {"chargers": chargers})
430
-
431
-
432
- @login_required
433
- @landing("CP Simulator")
434
- @live_update()
435
- def cp_simulator(request):
436
- """Public landing page to control the OCPP charge point simulator."""
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"
442
- default_cp_paths = ["CP1", "CP2"]
443
- default_serial_numbers = default_cp_paths
444
- default_connector_id = 1
445
- default_rfid = "FFFFFFFF"
446
- default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
447
-
448
- message = ""
449
- if request.method == "POST":
450
- cp_idx = int(request.POST.get("cp") or 1)
451
- action = request.POST.get("action")
452
- if action == "start":
453
- sim_params = dict(
454
- host=request.POST.get("host") or default_host,
455
- ws_port=int(request.POST.get("ws_port") or default_ws_port),
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),
465
- interval=float(request.POST.get("interval") or 5),
466
- kw_min=float(request.POST.get("kw_min") or 30),
467
- kw_max=float(request.POST.get("kw_max") or 60),
468
- pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
469
- repeat=request.POST.get("repeat") or False,
470
- daemon=True,
471
- username=request.POST.get("username") or None,
472
- password=request.POST.get("password") or None,
473
- )
474
- try:
475
- started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
476
- if started:
477
- message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
478
- else:
479
- message = f"CP{cp_idx} {status}. Logs: {log_file}"
480
- except Exception as exc: # pragma: no cover - unexpected
481
- message = f"Failed to start CP{cp_idx}: {exc}"
482
- elif action == "stop":
483
- try:
484
- _stop_simulator(cp=cp_idx)
485
- message = f"CP{cp_idx} stop requested."
486
- except Exception as exc: # pragma: no cover - unexpected
487
- message = f"Failed to stop CP{cp_idx}: {exc}"
488
- else:
489
- message = "Unknown action."
490
-
491
- states_dict = get_simulator_state()
492
- state_list = [states_dict[1], states_dict[2]]
493
- params_jsons = [
494
- json.dumps(state_list[0].get("params", {}), indent=2),
495
- json.dumps(state_list[1].get("params", {}), indent=2),
496
- ]
497
- state_jsons = [
498
- _simulator_status_json(1),
499
- _simulator_status_json(2),
500
- ]
501
-
502
- context = {
503
- "message": message,
504
- "states": state_list,
505
- "default_host": default_host,
506
- "default_ws_port": default_ws_port,
507
- "default_cp_paths": default_cp_paths,
508
- "default_serial_numbers": default_serial_numbers,
509
- "default_connector_id": default_connector_id,
510
- "default_rfid": default_rfid,
511
- "default_vins": default_vins,
512
- "params_jsons": params_jsons,
513
- "state_jsons": state_jsons,
514
- }
515
- return render(request, "ocpp/cp_simulator.html", context)
516
-
517
-
518
- def charger_page(request, cid, connector=None):
519
- """Public landing page for a charger displaying usage guidance or progress."""
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
- )
583
-
584
-
585
- @login_required
586
- def charger_status(request, cid, connector=None):
587
- charger, connector_slug = _get_charger(cid, connector)
588
- session_id = request.GET.get("session")
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]
593
- tx_obj = live_tx
594
- past_session = False
595
- if 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):
602
- tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
603
- past_session = True
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
- )
622
- paginator = Paginator(transactions_qs, 10)
623
- page_obj = paginator.get_page(request.GET.get("page"))
624
- transactions = page_obj.object_list
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
635
- for reading in readings:
636
- try:
637
- val = float(reading.energy)
638
- except (TypeError, ValueError):
639
- continue
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)
730
- return render(
731
- request,
732
- "ocpp/charger_status.html",
733
- {
734
- "charger": charger,
735
- "tx": tx_obj,
736
- "state": state,
737
- "color": color,
738
- "transactions": transactions,
739
- "page_obj": page_obj,
740
- "chart_data": chart_data,
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
- ),
760
- },
761
- )
762
-
763
-
764
- @login_required
765
- def charger_session_search(request, cid, connector=None):
766
- charger, connector_slug = _get_charger(cid, connector)
767
- date_str = request.GET.get("date")
768
- transactions = None
769
- if date_str:
770
- try:
771
- date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
772
- start = datetime.combine(
773
- date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
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")
782
- except ValueError:
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)
795
- return render(
796
- request,
797
- "ocpp/charger_session_search.html",
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
- },
806
- )
807
-
808
-
809
- @login_required
810
- def charger_log_page(request, cid, connector=None):
811
- """Render a simple page with the log for the charger or simulator."""
812
- log_type = request.GET.get("type", "charger")
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)
837
- return render(
838
- request,
839
- "ocpp/charger_logs.html",
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
- },
848
- )
849
-
850
-
851
- @csrf_exempt
852
- @api_login_required
853
- def dispatch_action(request, cid, connector=None):
854
- connector_value, _ = _normalize_connector_slug(connector)
855
- ws = store.get_connection(cid, connector_value)
856
- if ws is None:
857
- return JsonResponse({"detail": "no connection"}, status=404)
858
- try:
859
- data = json.loads(request.body.decode()) if request.body else {}
860
- except json.JSONDecodeError:
861
- data = {}
862
- action = data.get("action")
863
- if action == "remote_stop":
864
- tx_obj = store.get_transaction(cid, connector_value)
865
- if not tx_obj:
866
- return JsonResponse({"detail": "no transaction"}, status=404)
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
- )
902
- asyncio.get_event_loop().create_task(ws.send(msg))
903
- elif action == "reset":
904
- msg = json.dumps(
905
- [2, str(datetime.utcnow().timestamp()), "Reset", {"type": "Soft"}]
906
- )
907
- asyncio.get_event_loop().create_task(ws.send(msg))
908
- else:
909
- return JsonResponse({"detail": "unknown action"}, status=400)
910
- log_key = store.identity_key(cid, connector_value)
911
- store.add_log(log_key, f"< {msg}", log_type="charger")
912
- return JsonResponse({"sent": msg})
1
+ import json
2
+ import uuid
3
+ from datetime import datetime, time, timedelta, timezone as dt_timezone
4
+ from types import SimpleNamespace
5
+
6
+ from django.http import Http404, HttpResponse, JsonResponse
7
+ from django.http.request import split_domain_port
8
+ from django.views.decorators.csrf import csrf_exempt
9
+ from django.shortcuts import get_object_or_404, render, resolve_url
10
+ from django.template.loader import render_to_string
11
+ from django.core.paginator import Paginator
12
+ from django.contrib.auth.decorators import login_required
13
+ from django.contrib.auth.views import redirect_to_login
14
+ from django.utils.translation import gettext_lazy as _, gettext, ngettext
15
+ from django.utils.text import slugify
16
+ from django.urls import NoReverseMatch, reverse
17
+ from django.conf import settings
18
+ from django.utils import translation, timezone, formats
19
+ from django.core.exceptions import ValidationError
20
+
21
+ from asgiref.sync import async_to_sync
22
+
23
+ from utils.api import api_login_required
24
+
25
+ from nodes.models import Node
26
+
27
+ from pages.utils import landing
28
+ from core.liveupdate import live_update
29
+
30
+ from django.utils.dateparse import parse_datetime
31
+
32
+ from . import store
33
+ from .models import Transaction, Charger, DataTransferMessage, RFID
34
+ from .evcs import (
35
+ _start_simulator,
36
+ _stop_simulator,
37
+ get_simulator_state,
38
+ _simulator_status_json,
39
+ )
40
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
41
+
42
+
43
+ CALL_ACTION_LABELS = {
44
+ "RemoteStartTransaction": _("Remote start transaction"),
45
+ "RemoteStopTransaction": _("Remote stop transaction"),
46
+ "ChangeAvailability": _("Change availability"),
47
+ "DataTransfer": _("Data transfer"),
48
+ "Reset": _("Reset"),
49
+ "TriggerMessage": _("Trigger message"),
50
+ "ReserveNow": _("Reserve connector"),
51
+ }
52
+
53
+ CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
54
+ "RemoteStartTransaction": {"Accepted"},
55
+ "RemoteStopTransaction": {"Accepted"},
56
+ "ChangeAvailability": {"Accepted", "Scheduled"},
57
+ "DataTransfer": {"Accepted"},
58
+ "Reset": {"Accepted"},
59
+ "TriggerMessage": {"Accepted"},
60
+ "ReserveNow": {"Accepted"},
61
+ }
62
+
63
+
64
+ def _format_details(value: object) -> str:
65
+ """Return a JSON representation of ``value`` suitable for error messages."""
66
+
67
+ if value in (None, ""):
68
+ return ""
69
+ if isinstance(value, str):
70
+ text = value.strip()
71
+ if text:
72
+ return text
73
+ return ""
74
+ try:
75
+ return json.dumps(value, sort_keys=True, ensure_ascii=False)
76
+ except TypeError:
77
+ return str(value)
78
+
79
+
80
+ def _evaluate_pending_call_result(
81
+ message_id: str,
82
+ ocpp_action: str,
83
+ *,
84
+ expected_statuses: set[str] | None = None,
85
+ ) -> tuple[bool, str | None, int | None]:
86
+ """Wait for a pending call result and translate failures into messages."""
87
+
88
+ action_label = CALL_ACTION_LABELS.get(ocpp_action, ocpp_action)
89
+ result = store.wait_for_pending_call(message_id, timeout=5.0)
90
+ if result is None:
91
+ detail = _("%(action)s did not receive a response from the charger.") % {
92
+ "action": action_label,
93
+ }
94
+ return False, detail, 504
95
+ if not result.get("success", True):
96
+ parts: list[str] = []
97
+ error_code = str(result.get("error_code") or "").strip()
98
+ if error_code:
99
+ parts.append(_("code=%(code)s") % {"code": error_code})
100
+ error_description = str(result.get("error_description") or "").strip()
101
+ if error_description:
102
+ parts.append(
103
+ _("description=%(description)s") % {"description": error_description}
104
+ )
105
+ error_details = result.get("error_details")
106
+ details_text = _format_details(error_details)
107
+ if details_text:
108
+ parts.append(_("details=%(details)s") % {"details": details_text})
109
+ if parts:
110
+ detail = _("%(action)s failed: %(details)s") % {
111
+ "action": action_label,
112
+ "details": ", ".join(parts),
113
+ }
114
+ else:
115
+ detail = _("%(action)s failed.") % {"action": action_label}
116
+ return False, detail, 400
117
+ payload = result.get("payload")
118
+ payload_dict = payload if isinstance(payload, dict) else {}
119
+ if expected_statuses is not None:
120
+ status_value = str(payload_dict.get("status") or "").strip()
121
+ normalized_expected = {value.casefold() for value in expected_statuses if value}
122
+ if not status_value:
123
+ detail = _("%(action)s response did not include a status.") % {
124
+ "action": action_label,
125
+ }
126
+ return False, detail, 400
127
+ if normalized_expected and status_value.casefold() not in normalized_expected:
128
+ detail = _("%(action)s rejected with status %(status)s.") % {
129
+ "action": action_label,
130
+ "status": status_value,
131
+ }
132
+ remaining = {k: v for k, v in payload_dict.items() if k != "status"}
133
+ extra = _format_details(remaining)
134
+ if extra:
135
+ detail += " " + _("Details: %(details)s") % {"details": extra}
136
+ return False, detail, 400
137
+ return True, None, None
138
+
139
+
140
+ def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
141
+ """Return connector value and normalized slug or raise 404."""
142
+
143
+ try:
144
+ value = Charger.connector_value_from_slug(slug)
145
+ except ValueError as exc: # pragma: no cover - defensive guard
146
+ raise Http404("Invalid connector") from exc
147
+ return value, Charger.connector_slug_from_value(value)
148
+
149
+
150
+ def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
151
+ """Return URL name for connector-aware routes."""
152
+
153
+ target = f"{name}-connector"
154
+ if connector_slug == Charger.AGGREGATE_CONNECTOR_SLUG:
155
+ try:
156
+ return reverse(target, args=[serial, connector_slug])
157
+ except NoReverseMatch:
158
+ return reverse(name, args=[serial])
159
+ return reverse(target, args=[serial, connector_slug])
160
+
161
+
162
+ def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
163
+ """Return charger for the requested identity, creating if necessary."""
164
+
165
+ try:
166
+ serial = Charger.validate_serial(serial)
167
+ except ValidationError as exc:
168
+ raise Http404("Charger not found") from exc
169
+ connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
170
+ if connector_value is None:
171
+ charger, _ = Charger.objects.get_or_create(
172
+ charger_id=serial,
173
+ connector_id=None,
174
+ )
175
+ else:
176
+ charger, _ = Charger.objects.get_or_create(
177
+ charger_id=serial,
178
+ connector_id=connector_value,
179
+ )
180
+ return charger, normalized_slug
181
+
182
+
183
+ def _connector_set(charger: Charger) -> list[Charger]:
184
+ """Return chargers sharing the same serial ordered for navigation."""
185
+
186
+ siblings = list(Charger.objects.filter(charger_id=charger.charger_id))
187
+ siblings.sort(key=lambda c: (c.connector_id is not None, c.connector_id or 0))
188
+ return siblings
189
+
190
+
191
+ def _visible_chargers(user):
192
+ """Return chargers visible to ``user`` on public dashboards."""
193
+
194
+ return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
195
+
196
+
197
+ def _ensure_charger_access(
198
+ user,
199
+ charger: Charger,
200
+ *,
201
+ request=None,
202
+ ) -> HttpResponse | None:
203
+ """Ensure ``user`` may view ``charger``.
204
+
205
+ Returns a redirect to the login page when authentication is required,
206
+ otherwise raises :class:`~django.http.Http404` if the charger should not be
207
+ visible to the user.
208
+ """
209
+
210
+ if charger.is_visible_to(user):
211
+ return None
212
+ if (
213
+ request is not None
214
+ and not getattr(user, "is_authenticated", False)
215
+ and charger.has_owner_scope()
216
+ ):
217
+ return redirect_to_login(
218
+ request.get_full_path(),
219
+ login_url=resolve_url(settings.LOGIN_URL),
220
+ )
221
+ raise Http404("Charger not found")
222
+
223
+
224
+ def _transaction_rfid_details(
225
+ tx_obj, *, cache: dict[str, dict[str, str | None]] | None = None
226
+ ) -> dict[str, str | None] | None:
227
+ """Return normalized RFID metadata for a transaction-like object."""
228
+
229
+ if not tx_obj:
230
+ return None
231
+ rfid_value = getattr(tx_obj, "rfid", None)
232
+ normalized = str(rfid_value or "").strip().upper()
233
+ cache_key = normalized
234
+ if normalized:
235
+ if cache is not None and cache_key in cache:
236
+ return cache[cache_key]
237
+ tag = (
238
+ RFID.matching_queryset(normalized)
239
+ .only("pk", "label_id", "custom_label")
240
+ .first()
241
+ )
242
+ rfid_url = None
243
+ label_value = None
244
+ canonical_value = normalized
245
+ if tag:
246
+ try:
247
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
248
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
249
+ rfid_url = None
250
+ custom_label = (tag.custom_label or "").strip()
251
+ if custom_label:
252
+ label_value = custom_label
253
+ elif tag.label_id is not None:
254
+ label_value = str(tag.label_id)
255
+ canonical_value = tag.rfid or canonical_value
256
+ display_value = label_value or canonical_value
257
+ details = {
258
+ "value": display_value,
259
+ "url": rfid_url,
260
+ "uid": canonical_value,
261
+ "type": "rfid",
262
+ "display_label": gettext("RFID"),
263
+ }
264
+ if label_value:
265
+ details["label"] = label_value
266
+ if cache is not None:
267
+ cache[cache_key] = details
268
+ return details
269
+
270
+ identifier_value = getattr(tx_obj, "vehicle_identifier", None)
271
+ normalized_identifier = str(identifier_value or "").strip()
272
+ if not normalized_identifier:
273
+ vid_value = getattr(tx_obj, "vid", None)
274
+ vin_value = getattr(tx_obj, "vin", None)
275
+ normalized_identifier = str(vid_value or vin_value or "").strip()
276
+ if not normalized_identifier:
277
+ return None
278
+ source = getattr(tx_obj, "vehicle_identifier_source", "") or "vid"
279
+ if source not in {"vid", "vin"}:
280
+ vid_raw = getattr(tx_obj, "vid", None)
281
+ vin_raw = getattr(tx_obj, "vin", None)
282
+ if str(vid_raw or "").strip():
283
+ source = "vid"
284
+ elif str(vin_raw or "").strip():
285
+ source = "vin"
286
+ else:
287
+ source = "vid"
288
+ cache_key = f"{source}:{normalized_identifier}"
289
+ if cache is not None and cache_key in cache:
290
+ return cache[cache_key]
291
+ label = gettext("VID") if source == "vid" else gettext("VIN")
292
+ details = {
293
+ "value": normalized_identifier,
294
+ "url": None,
295
+ "uid": None,
296
+ "type": source,
297
+ "display_label": label,
298
+ }
299
+ if cache is not None:
300
+ cache[cache_key] = details
301
+ return details
302
+
303
+
304
+ def _connector_overview(
305
+ charger: Charger,
306
+ user=None,
307
+ *,
308
+ rfid_cache: dict[str, dict[str, str | None]] | None = None,
309
+ ) -> list[dict]:
310
+ """Return connector metadata used for navigation and summaries."""
311
+
312
+ overview: list[dict] = []
313
+ for sibling in _connector_set(charger):
314
+ if user is not None and not sibling.is_visible_to(user):
315
+ continue
316
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
317
+ state, color = _charger_state(sibling, tx_obj)
318
+ overview.append(
319
+ {
320
+ "charger": sibling,
321
+ "slug": sibling.connector_slug,
322
+ "label": sibling.connector_label,
323
+ "url": _reverse_connector_url(
324
+ "charger-page", sibling.charger_id, sibling.connector_slug
325
+ ),
326
+ "status": state,
327
+ "color": color,
328
+ "last_status": sibling.last_status,
329
+ "last_error_code": sibling.last_error_code,
330
+ "last_status_timestamp": sibling.last_status_timestamp,
331
+ "last_status_vendor_info": sibling.last_status_vendor_info,
332
+ "tx": tx_obj,
333
+ "rfid_details": _transaction_rfid_details(
334
+ tx_obj, cache=rfid_cache
335
+ ),
336
+ "connected": store.is_connected(
337
+ sibling.charger_id, sibling.connector_id
338
+ ),
339
+ }
340
+ )
341
+ return overview
342
+
343
+
344
+ def _normalize_timeline_status(value: str | None) -> str | None:
345
+ """Normalize raw charger status strings into timeline buckets."""
346
+
347
+ normalized = (value or "").strip().lower()
348
+ if not normalized:
349
+ return None
350
+ charging_states = {
351
+ "charging",
352
+ "finishing",
353
+ "suspendedev",
354
+ "suspendedevse",
355
+ "occupied",
356
+ }
357
+ available_states = {"available", "preparing", "reserved"}
358
+ offline_states = {"faulted", "unavailable", "outofservice"}
359
+ if normalized in charging_states:
360
+ return "charging"
361
+ if normalized in offline_states:
362
+ return "offline"
363
+ if normalized in available_states:
364
+ return "available"
365
+ # Treat other states as available for the initial implementation.
366
+ return "available"
367
+
368
+
369
+ def _timeline_labels() -> dict[str, str]:
370
+ """Return translated labels for timeline statuses."""
371
+
372
+ return {
373
+ "offline": gettext("Offline"),
374
+ "available": gettext("Available"),
375
+ "charging": gettext("Charging"),
376
+ }
377
+
378
+
379
+ def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
380
+ """Return localized display values for a timeline range."""
381
+
382
+ start_display = formats.date_format(
383
+ timezone.localtime(start), "SHORT_DATETIME_FORMAT"
384
+ )
385
+ end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
386
+ return start_display, end_display
387
+
388
+
389
+ def _collect_status_events(
390
+ charger: Charger,
391
+ connector: Charger,
392
+ window_start: datetime,
393
+ window_end: datetime,
394
+ ) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
395
+ """Parse log entries into ordered status events for the connector."""
396
+
397
+ connector_id = connector.connector_id
398
+ serial = connector.charger_id
399
+ keys = [store.identity_key(serial, connector_id)]
400
+ if connector_id is not None:
401
+ keys.append(store.identity_key(serial, None))
402
+ keys.append(store.pending_key(serial))
403
+
404
+ seen_entries: set[str] = set()
405
+ events: list[tuple[datetime, str]] = []
406
+ latest_before_window: tuple[datetime, str] | None = None
407
+
408
+ for key in keys:
409
+ for entry in store.get_logs(key, log_type="charger"):
410
+ if entry in seen_entries:
411
+ continue
412
+ seen_entries.add(entry)
413
+ if len(entry) < 24:
414
+ continue
415
+ timestamp_raw = entry[:23]
416
+ message = entry[24:].strip()
417
+ try:
418
+ log_timestamp = datetime.strptime(
419
+ timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
420
+ ).replace(tzinfo=dt_timezone.utc)
421
+ except ValueError:
422
+ continue
423
+
424
+ event_time = log_timestamp
425
+ status_bucket: str | None = None
426
+
427
+ if message.startswith("StatusNotification processed:"):
428
+ payload_text = message.split(":", 1)[1].strip()
429
+ try:
430
+ payload = json.loads(payload_text)
431
+ except json.JSONDecodeError:
432
+ continue
433
+ target_id = payload.get("connectorId")
434
+ if connector_id is not None:
435
+ try:
436
+ normalized_target = int(target_id)
437
+ except (TypeError, ValueError):
438
+ normalized_target = None
439
+ if normalized_target not in {connector_id, None}:
440
+ continue
441
+ raw_status = payload.get("status")
442
+ status_bucket = _normalize_timeline_status(
443
+ raw_status if isinstance(raw_status, str) else None
444
+ )
445
+ payload_timestamp = payload.get("timestamp")
446
+ if isinstance(payload_timestamp, str):
447
+ parsed = parse_datetime(payload_timestamp)
448
+ if parsed is not None:
449
+ if timezone.is_naive(parsed):
450
+ parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
451
+ event_time = parsed
452
+ elif message.startswith("Connected"):
453
+ status_bucket = "available"
454
+ elif message.startswith("Closed"):
455
+ status_bucket = "offline"
456
+
457
+ if not status_bucket:
458
+ continue
459
+
460
+ if event_time < window_start:
461
+ if (
462
+ latest_before_window is None
463
+ or event_time > latest_before_window[0]
464
+ ):
465
+ latest_before_window = (event_time, status_bucket)
466
+ continue
467
+ if event_time > window_end:
468
+ continue
469
+ events.append((event_time, status_bucket))
470
+
471
+ events.sort(key=lambda item: item[0])
472
+
473
+ deduped_events: list[tuple[datetime, str]] = []
474
+ for event_time, state in events:
475
+ if deduped_events and deduped_events[-1][1] == state:
476
+ continue
477
+ deduped_events.append((event_time, state))
478
+
479
+ return deduped_events, latest_before_window
480
+
481
+
482
+ def _usage_timeline(
483
+ charger: Charger,
484
+ connector_overview: list[dict],
485
+ *,
486
+ now: datetime | None = None,
487
+ ) -> tuple[list[dict], tuple[str, str] | None]:
488
+ """Build usage timeline data for inactive chargers."""
489
+
490
+ if now is None:
491
+ now = timezone.now()
492
+ window_end = now
493
+ window_start = now - timedelta(days=7)
494
+
495
+ if charger.connector_id is not None:
496
+ connectors = [charger]
497
+ else:
498
+ connectors = [
499
+ item["charger"]
500
+ for item in connector_overview
501
+ if item.get("charger") and item["charger"].connector_id is not None
502
+ ]
503
+ if not connectors:
504
+ connectors = [
505
+ sibling
506
+ for sibling in _connector_set(charger)
507
+ if sibling.connector_id is not None
508
+ ]
509
+
510
+ seen_ids: set[int] = set()
511
+ labels = _timeline_labels()
512
+ timeline_entries: list[dict] = []
513
+ window_display: tuple[str, str] | None = None
514
+
515
+ if window_start < window_end:
516
+ window_display = _format_segment_range(window_start, window_end)
517
+
518
+ for connector in connectors:
519
+ if connector.connector_id is None:
520
+ continue
521
+ if connector.connector_id in seen_ids:
522
+ continue
523
+ seen_ids.add(connector.connector_id)
524
+
525
+ events, prior_event = _collect_status_events(
526
+ charger, connector, window_start, window_end
527
+ )
528
+ fallback_state = _normalize_timeline_status(connector.last_status)
529
+ if fallback_state is None:
530
+ fallback_state = (
531
+ "available"
532
+ if store.is_connected(connector.charger_id, connector.connector_id)
533
+ else "offline"
534
+ )
535
+ current_state = fallback_state
536
+ if prior_event is not None:
537
+ current_state = prior_event[1]
538
+ segments: list[dict] = []
539
+ previous_time = window_start
540
+ total_seconds = (window_end - window_start).total_seconds()
541
+
542
+ for event_time, state in events:
543
+ if event_time <= window_start:
544
+ current_state = state
545
+ continue
546
+ if event_time > window_end:
547
+ break
548
+ if state == current_state:
549
+ continue
550
+ segment_start = max(previous_time, window_start)
551
+ segment_end = min(event_time, window_end)
552
+ if segment_end > segment_start:
553
+ duration = (segment_end - segment_start).total_seconds()
554
+ start_display, end_display = _format_segment_range(
555
+ segment_start, segment_end
556
+ )
557
+ segments.append(
558
+ {
559
+ "status": current_state,
560
+ "label": labels.get(current_state, current_state.title()),
561
+ "start_display": start_display,
562
+ "end_display": end_display,
563
+ "duration": max(duration, 1.0),
564
+ }
565
+ )
566
+ current_state = state
567
+ previous_time = max(event_time, window_start)
568
+
569
+ if previous_time < window_end:
570
+ segment_start = max(previous_time, window_start)
571
+ segment_end = window_end
572
+ if segment_end > segment_start:
573
+ duration = (segment_end - segment_start).total_seconds()
574
+ start_display, end_display = _format_segment_range(
575
+ segment_start, segment_end
576
+ )
577
+ segments.append(
578
+ {
579
+ "status": current_state,
580
+ "label": labels.get(current_state, current_state.title()),
581
+ "start_display": start_display,
582
+ "end_display": end_display,
583
+ "duration": max(duration, 1.0),
584
+ }
585
+ )
586
+
587
+ if not segments and total_seconds > 0:
588
+ start_display, end_display = _format_segment_range(window_start, window_end)
589
+ segments.append(
590
+ {
591
+ "status": current_state,
592
+ "label": labels.get(current_state, current_state.title()),
593
+ "start_display": start_display,
594
+ "end_display": end_display,
595
+ "duration": max(total_seconds, 1.0),
596
+ }
597
+ )
598
+
599
+ if segments:
600
+ timeline_entries.append(
601
+ {
602
+ "label": connector.connector_label,
603
+ "segments": segments,
604
+ }
605
+ )
606
+
607
+ return timeline_entries, window_display
608
+
609
+
610
+ def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
611
+ """Return active sessions grouped by connector for the charger."""
612
+
613
+ siblings = _connector_set(charger)
614
+ ordered = [c for c in siblings if c.connector_id is not None] + [
615
+ c for c in siblings if c.connector_id is None
616
+ ]
617
+ sessions: list[tuple[Charger, Transaction]] = []
618
+ seen: set[int] = set()
619
+ for sibling in ordered:
620
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
621
+ if not tx_obj:
622
+ continue
623
+ if tx_obj.pk and tx_obj.pk in seen:
624
+ continue
625
+ if tx_obj.pk:
626
+ seen.add(tx_obj.pk)
627
+ sessions.append((sibling, tx_obj))
628
+ return sessions
629
+
630
+
631
+ def _landing_page_translations() -> dict[str, dict[str, str]]:
632
+ """Return static translations used by the charger public landing page."""
633
+
634
+ catalog: dict[str, dict[str, str]] = {}
635
+ seen_codes: set[str] = set()
636
+ for code, _name in settings.LANGUAGES:
637
+ normalized = str(code).strip()
638
+ if not normalized or normalized in seen_codes:
639
+ continue
640
+ seen_codes.add(normalized)
641
+ with translation.override(normalized):
642
+ catalog[normalized] = {
643
+ "serial_number_label": gettext("Serial Number"),
644
+ "connector_label": gettext("Connector"),
645
+ "advanced_view_label": gettext("Advanced View"),
646
+ "require_rfid_label": gettext("Require RFID Authorization"),
647
+ "charging_label": gettext("Charging"),
648
+ "energy_label": gettext("Energy"),
649
+ "started_label": gettext("Started"),
650
+ "rfid_label": gettext("RFID"),
651
+ "instruction_text": gettext(
652
+ "Plug in your vehicle and slide your RFID card over the reader to begin charging."
653
+ ),
654
+ "connectors_heading": gettext("Connectors"),
655
+ "no_active_transaction": gettext("No active transaction"),
656
+ "connectors_active_singular": ngettext(
657
+ "%(count)s connector active",
658
+ "%(count)s connectors active",
659
+ 1,
660
+ ),
661
+ "connectors_active_plural": ngettext(
662
+ "%(count)s connector active",
663
+ "%(count)s connectors active",
664
+ 2,
665
+ ),
666
+ "status_reported_label": gettext("Reported status"),
667
+ "status_error_label": gettext("Error code"),
668
+ "status_updated_label": gettext("Last status update"),
669
+ "status_vendor_label": gettext("Vendor"),
670
+ "status_info_label": gettext("Info"),
671
+ }
672
+ return catalog
673
+
674
+
675
+ def _has_active_session(tx_obj) -> bool:
676
+ """Return whether the provided transaction-like object is active."""
677
+
678
+ if isinstance(tx_obj, (list, tuple, set)):
679
+ return any(_has_active_session(item) for item in tx_obj)
680
+ if not tx_obj:
681
+ return False
682
+ if isinstance(tx_obj, dict):
683
+ return tx_obj.get("stop_time") is None
684
+ stop_time = getattr(tx_obj, "stop_time", None)
685
+ return stop_time is None
686
+
687
+
688
+ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
689
+ """Return an aggregate badge for the charger when summarising connectors."""
690
+
691
+ if charger.connector_id is not None:
692
+ return None
693
+
694
+ siblings = (
695
+ Charger.objects.filter(charger_id=charger.charger_id)
696
+ .exclude(pk=charger.pk)
697
+ .exclude(connector_id__isnull=True)
698
+ )
699
+ statuses: list[str] = []
700
+ for sibling in siblings:
701
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
702
+ if not tx_obj:
703
+ tx_obj = (
704
+ Transaction.objects.filter(charger=sibling, stop_time__isnull=True)
705
+ .order_by("-start_time")
706
+ .first()
707
+ )
708
+ has_session = _has_active_session(tx_obj)
709
+ status_value = (sibling.last_status or "").strip()
710
+ normalized_status = status_value.casefold() if status_value else ""
711
+ error_code_lower = (sibling.last_error_code or "").strip().lower()
712
+ if has_session:
713
+ statuses.append("charging")
714
+ continue
715
+ if (
716
+ normalized_status in {"charging", "finishing"}
717
+ and error_code_lower in ERROR_OK_VALUES
718
+ ):
719
+ statuses.append("available")
720
+ continue
721
+ if normalized_status:
722
+ statuses.append(normalized_status)
723
+ continue
724
+ if store.is_connected(sibling.charger_id, sibling.connector_id):
725
+ statuses.append("available")
726
+
727
+ if not statuses:
728
+ return None
729
+
730
+ if any(status == "available" for status in statuses):
731
+ return STATUS_BADGE_MAP["available"]
732
+
733
+ if all(status == "charging" for status in statuses):
734
+ return STATUS_BADGE_MAP["charging"]
735
+
736
+ return None
737
+
738
+
739
+ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
740
+ """Return human readable state and color for a charger."""
741
+
742
+ status_value = (charger.last_status or "").strip()
743
+ normalized_status = status_value.casefold() if status_value else ""
744
+
745
+ aggregate_state = _aggregate_dashboard_state(charger)
746
+ if aggregate_state is not None and normalized_status in {"", "available", "charging"}:
747
+ return aggregate_state
748
+
749
+ has_session = _has_active_session(tx_obj)
750
+ if status_value:
751
+ key = normalized_status
752
+ label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
753
+ error_code = (charger.last_error_code or "").strip()
754
+ error_code_lower = error_code.lower()
755
+ if (
756
+ has_session
757
+ and error_code_lower in ERROR_OK_VALUES
758
+ and (key not in STATUS_BADGE_MAP or key == "available")
759
+ ):
760
+ # Some stations continue reporting "Available" (or an unknown status)
761
+ # while a session is active. Override the badge so the user can see
762
+ # the charger is actually busy.
763
+ label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
764
+ elif (
765
+ not has_session
766
+ and key in {"charging", "finishing"}
767
+ and error_code_lower in ERROR_OK_VALUES
768
+ ):
769
+ # Some chargers continue reporting "Charging" after a session ends.
770
+ # When no active transaction exists, surface the state as available
771
+ # so the UI reflects the actual behaviour at the site.
772
+ label, color = STATUS_BADGE_MAP.get("available", (_("Available"), "#0d6efd"))
773
+ elif error_code and error_code_lower not in ERROR_OK_VALUES:
774
+ label = _("%(status)s (%(error)s)") % {
775
+ "status": label,
776
+ "error": error_code,
777
+ }
778
+ color = "#dc3545"
779
+ return label, color
780
+
781
+ cid = charger.charger_id
782
+ connected = store.is_connected(cid, charger.connector_id)
783
+ if connected and has_session:
784
+ return _("Charging"), "green"
785
+ if connected:
786
+ return _("Available"), "blue"
787
+ return _("Offline"), "grey"
788
+
789
+
790
+ def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
791
+ """Return diagnostics metadata for API responses."""
792
+
793
+ timestamp = (
794
+ charger.diagnostics_timestamp.isoformat()
795
+ if charger.diagnostics_timestamp
796
+ else None
797
+ )
798
+ status = charger.diagnostics_status or None
799
+ location = charger.diagnostics_location or None
800
+ return {
801
+ "diagnosticsStatus": status,
802
+ "diagnosticsTimestamp": timestamp,
803
+ "diagnosticsLocation": location,
804
+ }
805
+
806
+
807
+ @api_login_required
808
+ def charger_list(request):
809
+ """Return a JSON list of known chargers and state."""
810
+ data = []
811
+ for charger in _visible_chargers(request.user):
812
+ cid = charger.charger_id
813
+ sessions: list[tuple[Charger, Transaction]] = []
814
+ tx_obj = store.get_transaction(cid, charger.connector_id)
815
+ if charger.connector_id is None:
816
+ sessions = _live_sessions(charger)
817
+ if sessions:
818
+ tx_obj = sessions[0][1]
819
+ elif tx_obj:
820
+ sessions = [(charger, tx_obj)]
821
+ if not tx_obj:
822
+ tx_obj = (
823
+ Transaction.objects.filter(charger__charger_id=cid)
824
+ .order_by("-start_time")
825
+ .first()
826
+ )
827
+ tx_data = None
828
+ if tx_obj:
829
+ tx_data = {
830
+ "transactionId": tx_obj.pk,
831
+ "meterStart": tx_obj.meter_start,
832
+ "startTime": tx_obj.start_time.isoformat(),
833
+ }
834
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
835
+ if identifier:
836
+ tx_data["vid"] = identifier
837
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
838
+ if legacy_vin:
839
+ tx_data["vin"] = legacy_vin
840
+ if tx_obj.meter_stop is not None:
841
+ tx_data["meterStop"] = tx_obj.meter_stop
842
+ if tx_obj.stop_time is not None:
843
+ tx_data["stopTime"] = tx_obj.stop_time.isoformat()
844
+ active_transactions = []
845
+ for session_charger, session_tx in sessions:
846
+ active_payload = {
847
+ "charger_id": session_charger.charger_id,
848
+ "connector_id": session_charger.connector_id,
849
+ "connector_slug": session_charger.connector_slug,
850
+ "transactionId": session_tx.pk,
851
+ "meterStart": session_tx.meter_start,
852
+ "startTime": session_tx.start_time.isoformat(),
853
+ }
854
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
855
+ if identifier:
856
+ active_payload["vid"] = identifier
857
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
858
+ if legacy_vin:
859
+ active_payload["vin"] = legacy_vin
860
+ if session_tx.meter_stop is not None:
861
+ active_payload["meterStop"] = session_tx.meter_stop
862
+ if session_tx.stop_time is not None:
863
+ active_payload["stopTime"] = session_tx.stop_time.isoformat()
864
+ active_transactions.append(active_payload)
865
+ state, color = _charger_state(
866
+ charger,
867
+ tx_obj if charger.connector_id is not None else (sessions if sessions else None),
868
+ )
869
+ entry = {
870
+ "charger_id": cid,
871
+ "name": charger.name,
872
+ "connector_id": charger.connector_id,
873
+ "connector_slug": charger.connector_slug,
874
+ "connector_label": charger.connector_label,
875
+ "require_rfid": charger.require_rfid,
876
+ "transaction": tx_data,
877
+ "activeTransactions": active_transactions,
878
+ "lastHeartbeat": (
879
+ charger.last_heartbeat.isoformat()
880
+ if charger.last_heartbeat
881
+ else None
882
+ ),
883
+ "lastMeterValues": charger.last_meter_values,
884
+ "firmwareStatus": charger.firmware_status,
885
+ "firmwareStatusInfo": charger.firmware_status_info,
886
+ "firmwareTimestamp": (
887
+ charger.firmware_timestamp.isoformat()
888
+ if charger.firmware_timestamp
889
+ else None
890
+ ),
891
+ "connected": store.is_connected(cid, charger.connector_id),
892
+ "lastStatus": charger.last_status or None,
893
+ "lastErrorCode": charger.last_error_code or None,
894
+ "lastStatusTimestamp": (
895
+ charger.last_status_timestamp.isoformat()
896
+ if charger.last_status_timestamp
897
+ else None
898
+ ),
899
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
900
+ "status": state,
901
+ "statusColor": color,
902
+ }
903
+ entry.update(_diagnostics_payload(charger))
904
+ data.append(entry)
905
+ return JsonResponse({"chargers": data})
906
+
907
+
908
+ @api_login_required
909
+ def charger_detail(request, cid, connector=None):
910
+ charger, connector_slug = _get_charger(cid, connector)
911
+ access_response = _ensure_charger_access(
912
+ request.user, charger, request=request
913
+ )
914
+ if access_response is not None:
915
+ return access_response
916
+
917
+ sessions: list[tuple[Charger, Transaction]] = []
918
+ tx_obj = store.get_transaction(cid, charger.connector_id)
919
+ if charger.connector_id is None:
920
+ sessions = _live_sessions(charger)
921
+ if sessions:
922
+ tx_obj = sessions[0][1]
923
+ elif tx_obj:
924
+ sessions = [(charger, tx_obj)]
925
+ if not tx_obj:
926
+ tx_obj = (
927
+ Transaction.objects.filter(charger__charger_id=cid)
928
+ .order_by("-start_time")
929
+ .first()
930
+ )
931
+
932
+ tx_data = None
933
+ if tx_obj:
934
+ tx_data = {
935
+ "transactionId": tx_obj.pk,
936
+ "meterStart": tx_obj.meter_start,
937
+ "startTime": tx_obj.start_time.isoformat(),
938
+ }
939
+ identifier = str(getattr(tx_obj, "vehicle_identifier", "") or "").strip()
940
+ if identifier:
941
+ tx_data["vid"] = identifier
942
+ legacy_vin = str(getattr(tx_obj, "vin", "") or "").strip()
943
+ if legacy_vin:
944
+ tx_data["vin"] = legacy_vin
945
+ if tx_obj.meter_stop is not None:
946
+ tx_data["meterStop"] = tx_obj.meter_stop
947
+ if tx_obj.stop_time is not None:
948
+ tx_data["stopTime"] = tx_obj.stop_time.isoformat()
949
+
950
+ active_transactions = []
951
+ for session_charger, session_tx in sessions:
952
+ payload = {
953
+ "charger_id": session_charger.charger_id,
954
+ "connector_id": session_charger.connector_id,
955
+ "connector_slug": session_charger.connector_slug,
956
+ "transactionId": session_tx.pk,
957
+ "meterStart": session_tx.meter_start,
958
+ "startTime": session_tx.start_time.isoformat(),
959
+ }
960
+ identifier = str(getattr(session_tx, "vehicle_identifier", "") or "").strip()
961
+ if identifier:
962
+ payload["vid"] = identifier
963
+ legacy_vin = str(getattr(session_tx, "vin", "") or "").strip()
964
+ if legacy_vin:
965
+ payload["vin"] = legacy_vin
966
+ if session_tx.meter_stop is not None:
967
+ payload["meterStop"] = session_tx.meter_stop
968
+ if session_tx.stop_time is not None:
969
+ payload["stopTime"] = session_tx.stop_time.isoformat()
970
+ active_transactions.append(payload)
971
+
972
+ log_key = store.identity_key(cid, charger.connector_id)
973
+ log = store.get_logs(log_key, log_type="charger")
974
+ state, color = _charger_state(
975
+ charger,
976
+ tx_obj if charger.connector_id is not None else (sessions if sessions else None),
977
+ )
978
+ payload = {
979
+ "charger_id": cid,
980
+ "connector_id": charger.connector_id,
981
+ "connector_slug": connector_slug,
982
+ "name": charger.name,
983
+ "require_rfid": charger.require_rfid,
984
+ "transaction": tx_data,
985
+ "activeTransactions": active_transactions,
986
+ "lastHeartbeat": (
987
+ charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
988
+ ),
989
+ "lastMeterValues": charger.last_meter_values,
990
+ "firmwareStatus": charger.firmware_status,
991
+ "firmwareStatusInfo": charger.firmware_status_info,
992
+ "firmwareTimestamp": (
993
+ charger.firmware_timestamp.isoformat()
994
+ if charger.firmware_timestamp
995
+ else None
996
+ ),
997
+ "log": log,
998
+ "lastStatus": charger.last_status or None,
999
+ "lastErrorCode": charger.last_error_code or None,
1000
+ "lastStatusTimestamp": (
1001
+ charger.last_status_timestamp.isoformat()
1002
+ if charger.last_status_timestamp
1003
+ else None
1004
+ ),
1005
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
1006
+ "status": state,
1007
+ "statusColor": color,
1008
+ }
1009
+ payload.update(_diagnostics_payload(charger))
1010
+ return JsonResponse(payload)
1011
+
1012
+
1013
+ @landing("CPMS Online Dashboard")
1014
+ @live_update()
1015
+ def dashboard(request):
1016
+ """Landing page listing all known chargers and their status."""
1017
+ node = Node.get_local()
1018
+ role = node.role if node else None
1019
+ role_name = role.name if role else ""
1020
+ allow_anonymous_roles = {"Watchtower", "Constellation", "Satellite"}
1021
+ if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
1022
+ return redirect_to_login(
1023
+ request.get_full_path(), login_url=reverse("pages:login")
1024
+ )
1025
+ is_watchtower = role_name in {"Watchtower", "Constellation"}
1026
+ visible_chargers = (
1027
+ _visible_chargers(request.user)
1028
+ .select_related("location")
1029
+ .order_by("charger_id", "connector_id")
1030
+ )
1031
+ stats_cache: dict[int, dict[str, float]] = {}
1032
+
1033
+ def _charger_display_name(charger: Charger) -> str:
1034
+ if charger.display_name:
1035
+ return charger.display_name
1036
+ if charger.location:
1037
+ return charger.location.name
1038
+ return charger.charger_id
1039
+
1040
+ today = timezone.localdate()
1041
+ tz = timezone.get_current_timezone()
1042
+ day_start = datetime.combine(today, time.min)
1043
+ if timezone.is_naive(day_start):
1044
+ day_start = timezone.make_aware(day_start, tz)
1045
+ day_end = day_start + timedelta(days=1)
1046
+
1047
+ def _charger_stats(charger: Charger) -> dict[str, float]:
1048
+ cache_key = charger.pk or id(charger)
1049
+ if cache_key not in stats_cache:
1050
+ stats_cache[cache_key] = {
1051
+ "total_kw": charger.total_kw,
1052
+ "today_kw": charger.total_kw_for_range(day_start, day_end),
1053
+ }
1054
+ return stats_cache[cache_key]
1055
+
1056
+ def _status_url(charger: Charger) -> str:
1057
+ return _reverse_connector_url(
1058
+ "charger-status",
1059
+ charger.charger_id,
1060
+ charger.connector_slug,
1061
+ )
1062
+
1063
+ chargers: list[dict[str, object]] = []
1064
+ charger_groups: list[dict[str, object]] = []
1065
+ group_lookup: dict[str, dict[str, object]] = {}
1066
+
1067
+ for charger in visible_chargers:
1068
+ tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
1069
+ if not tx_obj:
1070
+ tx_obj = (
1071
+ Transaction.objects.filter(charger=charger)
1072
+ .order_by("-start_time")
1073
+ .first()
1074
+ )
1075
+ has_session = _has_active_session(tx_obj)
1076
+ state, color = _charger_state(charger, tx_obj)
1077
+ if (
1078
+ charger.connector_id is not None
1079
+ and not has_session
1080
+ and (charger.last_status or "").strip().casefold() == "charging"
1081
+ ):
1082
+ state, color = STATUS_BADGE_MAP["charging"]
1083
+ entry = {
1084
+ "charger": charger,
1085
+ "state": state,
1086
+ "color": color,
1087
+ "display_name": _charger_display_name(charger),
1088
+ "stats": _charger_stats(charger),
1089
+ "status_url": _status_url(charger),
1090
+ }
1091
+ chargers.append(entry)
1092
+ if charger.connector_id is None:
1093
+ group = {"parent": entry, "children": []}
1094
+ charger_groups.append(group)
1095
+ group_lookup[charger.charger_id] = group
1096
+ else:
1097
+ group = group_lookup.get(charger.charger_id)
1098
+ if group is None:
1099
+ group = {"parent": None, "children": []}
1100
+ charger_groups.append(group)
1101
+ group_lookup[charger.charger_id] = group
1102
+ group["children"].append(entry)
1103
+
1104
+ for group in charger_groups:
1105
+ parent_entry = group.get("parent")
1106
+ if not parent_entry or not group["children"]:
1107
+ continue
1108
+ connector_statuses = [
1109
+ (child["charger"].last_status or "").strip().casefold()
1110
+ for child in group["children"]
1111
+ if child["charger"].connector_id is not None
1112
+ ]
1113
+ if connector_statuses and all(status == "charging" for status in connector_statuses):
1114
+ label, badge_color = STATUS_BADGE_MAP["charging"]
1115
+ parent_entry["state"] = label
1116
+ parent_entry["color"] = badge_color
1117
+ scheme = "wss" if request.is_secure() else "ws"
1118
+ host = request.get_host()
1119
+ ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
1120
+ context = {
1121
+ "chargers": chargers,
1122
+ "charger_groups": charger_groups,
1123
+ "show_demo_notice": is_watchtower,
1124
+ "demo_ws_url": ws_url,
1125
+ "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
1126
+ }
1127
+ if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
1128
+ html = render_to_string(
1129
+ "ocpp/includes/dashboard_table_rows.html", context, request=request
1130
+ )
1131
+ return JsonResponse({"html": html})
1132
+ return render(request, "ocpp/dashboard.html", context)
1133
+
1134
+
1135
+ @login_required(login_url="pages:login")
1136
+ @landing("Charge Point Simulator")
1137
+ @live_update()
1138
+ def cp_simulator(request):
1139
+ """Public landing page to control the OCPP charge point simulator."""
1140
+ host_header = request.get_host()
1141
+ default_host, host_port = split_domain_port(host_header)
1142
+ if not default_host:
1143
+ default_host = "127.0.0.1"
1144
+ default_ws_port = request.get_port() or host_port or "8000"
1145
+ default_cp_paths = ["CP1", "CP2"]
1146
+ default_serial_numbers = default_cp_paths
1147
+ default_connector_id = 1
1148
+ default_rfid = "FFFFFFFF"
1149
+ default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
1150
+
1151
+ message = ""
1152
+ dashboard_link: str | None = None
1153
+ if request.method == "POST":
1154
+ cp_idx = int(request.POST.get("cp") or 1)
1155
+ action = request.POST.get("action")
1156
+ if action == "start":
1157
+ ws_port_value = request.POST.get("ws_port")
1158
+ if ws_port_value is None:
1159
+ ws_port = int(default_ws_port) if default_ws_port else None
1160
+ elif ws_port_value.strip():
1161
+ ws_port = int(ws_port_value)
1162
+ else:
1163
+ ws_port = None
1164
+ sim_params = dict(
1165
+ host=request.POST.get("host") or default_host,
1166
+ ws_port=ws_port,
1167
+ cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
1168
+ serial_number=request.POST.get("serial_number")
1169
+ or default_serial_numbers[cp_idx - 1],
1170
+ connector_id=int(
1171
+ request.POST.get("connector_id") or default_connector_id
1172
+ ),
1173
+ rfid=request.POST.get("rfid") or default_rfid,
1174
+ vin=request.POST.get("vin") or default_vins[cp_idx - 1],
1175
+ duration=int(request.POST.get("duration") or 600),
1176
+ interval=float(request.POST.get("interval") or 5),
1177
+ kw_min=float(request.POST.get("kw_min") or 30),
1178
+ kw_max=float(request.POST.get("kw_max") or 60),
1179
+ pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
1180
+ repeat=request.POST.get("repeat") or False,
1181
+ daemon=True,
1182
+ username=request.POST.get("username") or None,
1183
+ password=request.POST.get("password") or None,
1184
+ )
1185
+ try:
1186
+ started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
1187
+ if started:
1188
+ message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
1189
+ try:
1190
+ dashboard_link = reverse(
1191
+ "charger-status", args=[sim_params["cp_path"]]
1192
+ )
1193
+ except NoReverseMatch: # pragma: no cover - defensive
1194
+ dashboard_link = None
1195
+ else:
1196
+ message = f"CP{cp_idx} {status}. Logs: {log_file}"
1197
+ except Exception as exc: # pragma: no cover - unexpected
1198
+ message = f"Failed to start CP{cp_idx}: {exc}"
1199
+ elif action == "stop":
1200
+ try:
1201
+ _stop_simulator(cp=cp_idx)
1202
+ message = f"CP{cp_idx} stop requested."
1203
+ except Exception as exc: # pragma: no cover - unexpected
1204
+ message = f"Failed to stop CP{cp_idx}: {exc}"
1205
+ else:
1206
+ message = "Unknown action."
1207
+
1208
+ states_dict = get_simulator_state()
1209
+ state_list = [states_dict[1], states_dict[2]]
1210
+ params_jsons = [
1211
+ json.dumps(state_list[0].get("params", {}), indent=2),
1212
+ json.dumps(state_list[1].get("params", {}), indent=2),
1213
+ ]
1214
+ state_jsons = [
1215
+ _simulator_status_json(1),
1216
+ _simulator_status_json(2),
1217
+ ]
1218
+
1219
+ context = {
1220
+ "message": message,
1221
+ "dashboard_link": dashboard_link,
1222
+ "states": state_list,
1223
+ "default_host": default_host,
1224
+ "default_ws_port": default_ws_port,
1225
+ "default_cp_paths": default_cp_paths,
1226
+ "default_serial_numbers": default_serial_numbers,
1227
+ "default_connector_id": default_connector_id,
1228
+ "default_rfid": default_rfid,
1229
+ "default_vins": default_vins,
1230
+ "params_jsons": params_jsons,
1231
+ "state_jsons": state_jsons,
1232
+ }
1233
+ return render(request, "ocpp/cp_simulator.html", context)
1234
+
1235
+
1236
+ def charger_page(request, cid, connector=None):
1237
+ """Public landing page for a charger displaying usage guidance or progress."""
1238
+ charger, connector_slug = _get_charger(cid, connector)
1239
+ access_response = _ensure_charger_access(
1240
+ request.user, charger, request=request
1241
+ )
1242
+ if access_response is not None:
1243
+ return access_response
1244
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1245
+ overview = _connector_overview(
1246
+ charger, request.user, rfid_cache=rfid_cache
1247
+ )
1248
+ sessions = _live_sessions(charger)
1249
+ tx = None
1250
+ active_connector_count = 0
1251
+ if charger.connector_id is None:
1252
+ if sessions:
1253
+ total_kw = 0.0
1254
+ start_times = [
1255
+ tx_obj.start_time for _, tx_obj in sessions if tx_obj.start_time
1256
+ ]
1257
+ for _, tx_obj in sessions:
1258
+ if tx_obj.kw:
1259
+ total_kw += tx_obj.kw
1260
+ tx = SimpleNamespace(
1261
+ kw=total_kw, start_time=min(start_times) if start_times else None
1262
+ )
1263
+ active_connector_count = len(sessions)
1264
+ else:
1265
+ tx = (
1266
+ sessions[0][1]
1267
+ if sessions
1268
+ else store.get_transaction(cid, charger.connector_id)
1269
+ )
1270
+ if tx:
1271
+ active_connector_count = 1
1272
+ state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
1273
+ state, color = _charger_state(charger, state_source)
1274
+ language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
1275
+ available_languages = [
1276
+ str(code).strip()
1277
+ for code, _ in settings.LANGUAGES
1278
+ if str(code).strip()
1279
+ ]
1280
+ supported_languages = set(available_languages)
1281
+ charger_language = (charger.language or "es").strip()
1282
+ if charger_language not in supported_languages:
1283
+ fallback = "es" if "es" in supported_languages else ""
1284
+ if not fallback and available_languages:
1285
+ fallback = available_languages[0]
1286
+ charger_language = fallback
1287
+ if (
1288
+ charger_language
1289
+ and (
1290
+ not language_cookie
1291
+ or language_cookie not in supported_languages
1292
+ or language_cookie != charger_language
1293
+ )
1294
+ ):
1295
+ translation.activate(charger_language)
1296
+ current_language = translation.get_language()
1297
+ request.LANGUAGE_CODE = current_language
1298
+ preferred_language = charger_language or current_language
1299
+ connector_links = [
1300
+ {
1301
+ "slug": item["slug"],
1302
+ "label": item["label"],
1303
+ "url": item["url"],
1304
+ "active": item["slug"] == connector_slug,
1305
+ }
1306
+ for item in overview
1307
+ ]
1308
+ connector_overview = [
1309
+ item for item in overview if item["charger"].connector_id is not None
1310
+ ]
1311
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
1312
+ tx_rfid_details = _transaction_rfid_details(tx, cache=rfid_cache)
1313
+ return render(
1314
+ request,
1315
+ "ocpp/charger_page.html",
1316
+ {
1317
+ "charger": charger,
1318
+ "tx": tx,
1319
+ "tx_rfid_details": tx_rfid_details,
1320
+ "connector_slug": connector_slug,
1321
+ "connector_links": connector_links,
1322
+ "connector_overview": connector_overview,
1323
+ "active_connector_count": active_connector_count,
1324
+ "status_url": status_url,
1325
+ "landing_translations": _landing_page_translations(),
1326
+ "preferred_language": preferred_language,
1327
+ "state": state,
1328
+ "color": color,
1329
+ },
1330
+ )
1331
+
1332
+
1333
+ @login_required
1334
+ def charger_status(request, cid, connector=None):
1335
+ charger, connector_slug = _get_charger(cid, connector)
1336
+ access_response = _ensure_charger_access(
1337
+ request.user, charger, request=request
1338
+ )
1339
+ if access_response is not None:
1340
+ return access_response
1341
+ session_id = request.GET.get("session")
1342
+ sessions = _live_sessions(charger)
1343
+ live_tx = None
1344
+ if charger.connector_id is not None and sessions:
1345
+ live_tx = sessions[0][1]
1346
+ tx_obj = live_tx
1347
+ past_session = False
1348
+ if session_id:
1349
+ if charger.connector_id is None:
1350
+ tx_obj = get_object_or_404(
1351
+ Transaction, pk=session_id, charger__charger_id=cid
1352
+ )
1353
+ past_session = True
1354
+ elif not (live_tx and str(live_tx.pk) == session_id):
1355
+ tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
1356
+ past_session = True
1357
+ state, color = _charger_state(
1358
+ charger,
1359
+ (
1360
+ live_tx
1361
+ if charger.connector_id is not None
1362
+ else (sessions if sessions else None)
1363
+ ),
1364
+ )
1365
+ if charger.connector_id is None:
1366
+ transactions_qs = (
1367
+ Transaction.objects.filter(charger__charger_id=cid)
1368
+ .select_related("charger")
1369
+ .order_by("-start_time")
1370
+ )
1371
+ else:
1372
+ transactions_qs = Transaction.objects.filter(charger=charger).order_by(
1373
+ "-start_time"
1374
+ )
1375
+ paginator = Paginator(transactions_qs, 10)
1376
+ page_obj = paginator.get_page(request.GET.get("page"))
1377
+ transactions = page_obj.object_list
1378
+ date_view = request.GET.get("dates", "charger").lower()
1379
+ if date_view not in {"charger", "received"}:
1380
+ date_view = "charger"
1381
+
1382
+ def _date_query(mode: str) -> str:
1383
+ params = request.GET.copy()
1384
+ params["dates"] = mode
1385
+ query = params.urlencode()
1386
+ return f"?{query}" if query else ""
1387
+
1388
+ date_view_options = {
1389
+ "charger": _("Charger timestamps"),
1390
+ "received": _("Received timestamps"),
1391
+ }
1392
+ date_toggle_links = [
1393
+ {
1394
+ "mode": mode,
1395
+ "label": label,
1396
+ "url": _date_query(mode),
1397
+ "active": mode == date_view,
1398
+ }
1399
+ for mode, label in date_view_options.items()
1400
+ ]
1401
+ chart_data = {"labels": [], "datasets": []}
1402
+ pagination_params = request.GET.copy()
1403
+ pagination_params["dates"] = date_view
1404
+ pagination_params.pop("page", None)
1405
+ pagination_query = pagination_params.urlencode()
1406
+ session_params = request.GET.copy()
1407
+ session_params["dates"] = date_view
1408
+ session_params.pop("session", None)
1409
+ session_params.pop("page", None)
1410
+ session_query = session_params.urlencode()
1411
+
1412
+ def _series_from_transaction(tx):
1413
+ points: list[tuple[str, float]] = []
1414
+ readings = list(
1415
+ tx.meter_values.filter(energy__isnull=False).order_by("timestamp")
1416
+ )
1417
+ start_val = None
1418
+ if tx.meter_start is not None:
1419
+ start_val = float(tx.meter_start) / 1000.0
1420
+ for reading in readings:
1421
+ try:
1422
+ val = float(reading.energy)
1423
+ except (TypeError, ValueError):
1424
+ continue
1425
+ if start_val is None:
1426
+ start_val = val
1427
+ total = val - start_val
1428
+ points.append((reading.timestamp.isoformat(), max(total, 0.0)))
1429
+ return points
1430
+
1431
+ if tx_obj and (charger.connector_id is not None or past_session):
1432
+ series_points = _series_from_transaction(tx_obj)
1433
+ if series_points:
1434
+ chart_data["labels"] = [ts for ts, _ in series_points]
1435
+ connector_id = None
1436
+ if tx_obj.charger and tx_obj.charger.connector_id is not None:
1437
+ connector_id = tx_obj.charger.connector_id
1438
+ elif charger.connector_id is not None:
1439
+ connector_id = charger.connector_id
1440
+ chart_data["datasets"].append(
1441
+ {
1442
+ "label": str(
1443
+ tx_obj.charger.connector_label
1444
+ if tx_obj.charger and tx_obj.charger.connector_id is not None
1445
+ else charger.connector_label
1446
+ ),
1447
+ "values": [value for _, value in series_points],
1448
+ "connector_id": connector_id,
1449
+ }
1450
+ )
1451
+ elif charger.connector_id is None:
1452
+ dataset_points: list[tuple[str, list[tuple[str, float]], int]] = []
1453
+ for sibling, sibling_tx in sessions:
1454
+ if sibling.connector_id is None or not sibling_tx:
1455
+ continue
1456
+ points = _series_from_transaction(sibling_tx)
1457
+ if not points:
1458
+ continue
1459
+ dataset_points.append(
1460
+ (str(sibling.connector_label), points, sibling.connector_id)
1461
+ )
1462
+ if dataset_points:
1463
+ all_labels: list[str] = sorted(
1464
+ {ts for _, points, _ in dataset_points for ts, _ in points}
1465
+ )
1466
+ chart_data["labels"] = all_labels
1467
+ for label, points, connector_id in dataset_points:
1468
+ value_map = {ts: val for ts, val in points}
1469
+ chart_data["datasets"].append(
1470
+ {
1471
+ "label": label,
1472
+ "values": [value_map.get(ts) for ts in all_labels],
1473
+ "connector_id": connector_id,
1474
+ }
1475
+ )
1476
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1477
+ overview = _connector_overview(
1478
+ charger, request.user, rfid_cache=rfid_cache
1479
+ )
1480
+ connector_links = [
1481
+ {
1482
+ "slug": item["slug"],
1483
+ "label": item["label"],
1484
+ "url": _reverse_connector_url("charger-status", cid, item["slug"]),
1485
+ "active": item["slug"] == connector_slug,
1486
+ }
1487
+ for item in overview
1488
+ ]
1489
+ connector_overview = [
1490
+ item for item in overview if item["charger"].connector_id is not None
1491
+ ]
1492
+ usage_timeline, usage_timeline_window = _usage_timeline(
1493
+ charger, connector_overview
1494
+ )
1495
+ search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1496
+ configuration_url = None
1497
+ if request.user.is_staff:
1498
+ try:
1499
+ configuration_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
1500
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
1501
+ configuration_url = None
1502
+ is_connected = store.is_connected(cid, charger.connector_id)
1503
+ has_active_session = bool(
1504
+ live_tx if charger.connector_id is not None else sessions
1505
+ )
1506
+ can_remote_start = (
1507
+ charger.connector_id is not None
1508
+ and is_connected
1509
+ and not has_active_session
1510
+ and not past_session
1511
+ )
1512
+ remote_start_messages = None
1513
+ if can_remote_start:
1514
+ remote_start_messages = {
1515
+ "required": str(_("RFID is required to start a session.")),
1516
+ "sending": str(_("Sending remote start request...")),
1517
+ "success": str(_("Remote start command queued.")),
1518
+ "error": str(_("Unable to send remote start request.")),
1519
+ }
1520
+ action_url = _reverse_connector_url("charger-action", cid, connector_slug)
1521
+ chart_should_animate = bool(has_active_session and not past_session)
1522
+
1523
+ tx_rfid_details = _transaction_rfid_details(tx_obj, cache=rfid_cache)
1524
+
1525
+ return render(
1526
+ request,
1527
+ "ocpp/charger_status.html",
1528
+ {
1529
+ "charger": charger,
1530
+ "tx": tx_obj,
1531
+ "tx_rfid_details": tx_rfid_details,
1532
+ "state": state,
1533
+ "color": color,
1534
+ "transactions": transactions,
1535
+ "page_obj": page_obj,
1536
+ "chart_data": chart_data,
1537
+ "past_session": past_session,
1538
+ "connector_slug": connector_slug,
1539
+ "connector_links": connector_links,
1540
+ "connector_overview": connector_overview,
1541
+ "search_url": search_url,
1542
+ "configuration_url": configuration_url,
1543
+ "page_url": _reverse_connector_url("charger-page", cid, connector_slug),
1544
+ "is_connected": is_connected,
1545
+ "is_idle": is_connected and not has_active_session,
1546
+ "can_remote_start": can_remote_start,
1547
+ "remote_start_messages": remote_start_messages,
1548
+ "action_url": action_url,
1549
+ "show_chart": bool(
1550
+ chart_data["datasets"]
1551
+ and any(
1552
+ any(value is not None for value in dataset["values"])
1553
+ for dataset in chart_data["datasets"]
1554
+ )
1555
+ ),
1556
+ "date_view": date_view,
1557
+ "date_toggle_links": date_toggle_links,
1558
+ "pagination_query": pagination_query,
1559
+ "session_query": session_query,
1560
+ "chart_should_animate": chart_should_animate,
1561
+ "usage_timeline": usage_timeline,
1562
+ "usage_timeline_window": usage_timeline_window,
1563
+ },
1564
+ )
1565
+
1566
+
1567
+ @login_required
1568
+ def charger_session_search(request, cid, connector=None):
1569
+ charger, connector_slug = _get_charger(cid, connector)
1570
+ access_response = _ensure_charger_access(
1571
+ request.user, charger, request=request
1572
+ )
1573
+ if access_response is not None:
1574
+ return access_response
1575
+ date_str = request.GET.get("date")
1576
+ date_view = request.GET.get("dates", "charger").lower()
1577
+ if date_view not in {"charger", "received"}:
1578
+ date_view = "charger"
1579
+
1580
+ def _date_query(mode: str) -> str:
1581
+ params = request.GET.copy()
1582
+ params["dates"] = mode
1583
+ query = params.urlencode()
1584
+ return f"?{query}" if query else ""
1585
+
1586
+ date_toggle_links = [
1587
+ {
1588
+ "mode": mode,
1589
+ "label": label,
1590
+ "url": _date_query(mode),
1591
+ "active": mode == date_view,
1592
+ }
1593
+ for mode, label in {
1594
+ "charger": _("Charger timestamps"),
1595
+ "received": _("Received timestamps"),
1596
+ }.items()
1597
+ ]
1598
+ transactions = None
1599
+ if date_str:
1600
+ try:
1601
+ date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
1602
+ start = datetime.combine(
1603
+ date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
1604
+ )
1605
+ end = start + timedelta(days=1)
1606
+ qs = Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
1607
+ if charger.connector_id is None:
1608
+ qs = qs.filter(charger__charger_id=cid)
1609
+ else:
1610
+ qs = qs.filter(charger=charger)
1611
+ transactions = qs.order_by("-start_time")
1612
+ except ValueError:
1613
+ transactions = []
1614
+ if transactions is not None:
1615
+ transactions = list(transactions)
1616
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1617
+ for tx in transactions:
1618
+ details = _transaction_rfid_details(tx, cache=rfid_cache)
1619
+ label_value = None
1620
+ if details:
1621
+ label_value = str(details.get("label") or "").strip() or None
1622
+ tx.rfid_label = label_value
1623
+ overview = _connector_overview(charger, request.user)
1624
+ connector_links = [
1625
+ {
1626
+ "slug": item["slug"],
1627
+ "label": item["label"],
1628
+ "url": _reverse_connector_url("charger-session-search", cid, item["slug"]),
1629
+ "active": item["slug"] == connector_slug,
1630
+ }
1631
+ for item in overview
1632
+ ]
1633
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
1634
+ return render(
1635
+ request,
1636
+ "ocpp/charger_session_search.html",
1637
+ {
1638
+ "charger": charger,
1639
+ "transactions": transactions,
1640
+ "date": date_str,
1641
+ "connector_slug": connector_slug,
1642
+ "connector_links": connector_links,
1643
+ "status_url": status_url,
1644
+ "date_view": date_view,
1645
+ "date_toggle_links": date_toggle_links,
1646
+ },
1647
+ )
1648
+
1649
+
1650
+ @login_required
1651
+ def charger_log_page(request, cid, connector=None):
1652
+ """Render a simple page with the log for the charger or simulator."""
1653
+ log_type = request.GET.get("type", "charger")
1654
+ connector_links = []
1655
+ connector_slug = None
1656
+ status_url = None
1657
+ if log_type == "charger":
1658
+ charger, connector_slug = _get_charger(cid, connector)
1659
+ access_response = _ensure_charger_access(
1660
+ request.user, charger, request=request
1661
+ )
1662
+ if access_response is not None:
1663
+ return access_response
1664
+ log_key = store.identity_key(cid, charger.connector_id)
1665
+ overview = _connector_overview(charger, request.user)
1666
+ connector_links = [
1667
+ {
1668
+ "slug": item["slug"],
1669
+ "label": item["label"],
1670
+ "url": _reverse_connector_url("charger-log", cid, item["slug"]),
1671
+ "active": item["slug"] == connector_slug,
1672
+ }
1673
+ for item in overview
1674
+ ]
1675
+ target_id = log_key
1676
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
1677
+ else:
1678
+ charger = Charger.objects.filter(charger_id=cid).first() or Charger(
1679
+ charger_id=cid
1680
+ )
1681
+ target_id = cid
1682
+
1683
+ slug_source = slugify(target_id) or slugify(cid) or "log"
1684
+ filename_parts = [log_type, slug_source]
1685
+ download_filename = f"{'-'.join(part for part in filename_parts if part)}.log"
1686
+ limit_options = [
1687
+ {"value": "20", "label": "20"},
1688
+ {"value": "40", "label": "40"},
1689
+ {"value": "100", "label": "100"},
1690
+ {"value": "all", "label": gettext("All")},
1691
+ ]
1692
+ allowed_values = [item["value"] for item in limit_options]
1693
+ limit_choice = request.GET.get("limit", "20")
1694
+ if limit_choice not in allowed_values:
1695
+ limit_choice = "20"
1696
+ limit_index = allowed_values.index(limit_choice)
1697
+
1698
+ log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
1699
+ download_requested = request.GET.get("download") == "1"
1700
+ if download_requested:
1701
+ download_content = "\n".join(log_entries_all)
1702
+ if download_content and not download_content.endswith("\n"):
1703
+ download_content = f"{download_content}\n"
1704
+ response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
1705
+ response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
1706
+ return response
1707
+
1708
+ log_entries = log_entries_all
1709
+ if limit_choice != "all":
1710
+ try:
1711
+ limit_value = int(limit_choice)
1712
+ except (TypeError, ValueError):
1713
+ limit_value = 20
1714
+ limit_choice = "20"
1715
+ limit_index = allowed_values.index(limit_choice)
1716
+ log_entries = log_entries[-limit_value:]
1717
+
1718
+ download_params = request.GET.copy()
1719
+ download_params["download"] = "1"
1720
+ download_params.pop("limit", None)
1721
+ download_query = download_params.urlencode()
1722
+ log_download_url = f"{request.path}?{download_query}" if download_query else request.path
1723
+
1724
+ limit_label = limit_options[limit_index]["label"]
1725
+ log_content = "\n".join(log_entries)
1726
+ return render(
1727
+ request,
1728
+ "ocpp/charger_logs.html",
1729
+ {
1730
+ "charger": charger,
1731
+ "log": log_entries,
1732
+ "log_content": log_content,
1733
+ "log_type": log_type,
1734
+ "connector_slug": connector_slug,
1735
+ "connector_links": connector_links,
1736
+ "status_url": status_url,
1737
+ "log_limit_options": limit_options,
1738
+ "log_limit_index": limit_index,
1739
+ "log_limit_choice": limit_choice,
1740
+ "log_limit_label": limit_label,
1741
+ "log_download_url": log_download_url,
1742
+ "log_filename": download_filename,
1743
+ },
1744
+ )
1745
+
1746
+
1747
+ @csrf_exempt
1748
+ @api_login_required
1749
+ def dispatch_action(request, cid, connector=None):
1750
+ connector_value, _normalized_slug = _normalize_connector_slug(connector)
1751
+ log_key = store.identity_key(cid, connector_value)
1752
+ if connector_value is None:
1753
+ charger_obj = (
1754
+ Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
1755
+ .order_by("pk")
1756
+ .first()
1757
+ )
1758
+ else:
1759
+ charger_obj = (
1760
+ Charger.objects.filter(charger_id=cid, connector_id=connector_value)
1761
+ .order_by("pk")
1762
+ .first()
1763
+ )
1764
+ if charger_obj is None:
1765
+ if connector_value is None:
1766
+ charger_obj, _created = Charger.objects.get_or_create(
1767
+ charger_id=cid, connector_id=None
1768
+ )
1769
+ else:
1770
+ charger_obj, _created = Charger.objects.get_or_create(
1771
+ charger_id=cid, connector_id=connector_value
1772
+ )
1773
+
1774
+ access_response = _ensure_charger_access(
1775
+ request.user, charger_obj, request=request
1776
+ )
1777
+ if access_response is not None:
1778
+ return access_response
1779
+ ws = store.get_connection(cid, connector_value)
1780
+ if ws is None:
1781
+ return JsonResponse({"detail": "no connection"}, status=404)
1782
+ try:
1783
+ data = json.loads(request.body.decode()) if request.body else {}
1784
+ except json.JSONDecodeError:
1785
+ data = {}
1786
+ action = data.get("action")
1787
+ message_id: str | None = None
1788
+ ocpp_action: str | None = None
1789
+ expected_statuses: set[str] | None = None
1790
+ msg: str | None = None
1791
+ if action == "remote_stop":
1792
+ tx_obj = store.get_transaction(cid, connector_value)
1793
+ if not tx_obj:
1794
+ return JsonResponse({"detail": "no transaction"}, status=404)
1795
+ message_id = uuid.uuid4().hex
1796
+ ocpp_action = "RemoteStopTransaction"
1797
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1798
+ msg = json.dumps(
1799
+ [
1800
+ 2,
1801
+ message_id,
1802
+ "RemoteStopTransaction",
1803
+ {"transactionId": tx_obj.pk},
1804
+ ]
1805
+ )
1806
+ async_to_sync(ws.send)(msg)
1807
+ store.register_pending_call(
1808
+ message_id,
1809
+ {
1810
+ "action": "RemoteStopTransaction",
1811
+ "charger_id": cid,
1812
+ "connector_id": connector_value,
1813
+ "log_key": log_key,
1814
+ "transaction_id": tx_obj.pk,
1815
+ "requested_at": timezone.now(),
1816
+ },
1817
+ )
1818
+ elif action == "remote_start":
1819
+ id_tag = data.get("idTag")
1820
+ if not isinstance(id_tag, str) or not id_tag.strip():
1821
+ return JsonResponse({"detail": "idTag required"}, status=400)
1822
+ id_tag = id_tag.strip()
1823
+ payload: dict[str, object] = {"idTag": id_tag}
1824
+ connector_id = data.get("connectorId")
1825
+ if connector_id in ("", None):
1826
+ connector_id = None
1827
+ if connector_id is None and connector_value is not None:
1828
+ connector_id = connector_value
1829
+ if connector_id is not None:
1830
+ try:
1831
+ payload["connectorId"] = int(connector_id)
1832
+ except (TypeError, ValueError):
1833
+ payload["connectorId"] = connector_id
1834
+ if "chargingProfile" in data and data["chargingProfile"] is not None:
1835
+ payload["chargingProfile"] = data["chargingProfile"]
1836
+ message_id = uuid.uuid4().hex
1837
+ ocpp_action = "RemoteStartTransaction"
1838
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1839
+ msg = json.dumps(
1840
+ [
1841
+ 2,
1842
+ message_id,
1843
+ "RemoteStartTransaction",
1844
+ payload,
1845
+ ]
1846
+ )
1847
+ async_to_sync(ws.send)(msg)
1848
+ store.register_pending_call(
1849
+ message_id,
1850
+ {
1851
+ "action": "RemoteStartTransaction",
1852
+ "charger_id": cid,
1853
+ "connector_id": connector_value,
1854
+ "log_key": log_key,
1855
+ "id_tag": id_tag,
1856
+ "requested_at": timezone.now(),
1857
+ },
1858
+ )
1859
+ elif action == "change_availability":
1860
+ availability_type = data.get("type")
1861
+ if availability_type not in {"Operative", "Inoperative"}:
1862
+ return JsonResponse({"detail": "invalid availability type"}, status=400)
1863
+ connector_payload = connector_value if connector_value is not None else 0
1864
+ if "connectorId" in data:
1865
+ candidate = data.get("connectorId")
1866
+ if candidate not in (None, ""):
1867
+ try:
1868
+ connector_payload = int(candidate)
1869
+ except (TypeError, ValueError):
1870
+ connector_payload = candidate
1871
+ message_id = uuid.uuid4().hex
1872
+ ocpp_action = "ChangeAvailability"
1873
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1874
+ payload = {"connectorId": connector_payload, "type": availability_type}
1875
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1876
+ async_to_sync(ws.send)(msg)
1877
+ requested_at = timezone.now()
1878
+ store.register_pending_call(
1879
+ message_id,
1880
+ {
1881
+ "action": "ChangeAvailability",
1882
+ "charger_id": cid,
1883
+ "connector_id": connector_value,
1884
+ "availability_type": availability_type,
1885
+ "requested_at": requested_at,
1886
+ },
1887
+ )
1888
+ if charger_obj:
1889
+ updates = {
1890
+ "availability_requested_state": availability_type,
1891
+ "availability_requested_at": requested_at,
1892
+ "availability_request_status": "",
1893
+ "availability_request_status_at": None,
1894
+ "availability_request_details": "",
1895
+ }
1896
+ Charger.objects.filter(pk=charger_obj.pk).update(**updates)
1897
+ for field, value in updates.items():
1898
+ setattr(charger_obj, field, value)
1899
+ elif action == "data_transfer":
1900
+ vendor_id = data.get("vendorId")
1901
+ if not isinstance(vendor_id, str) or not vendor_id.strip():
1902
+ return JsonResponse({"detail": "vendorId required"}, status=400)
1903
+ vendor_id = vendor_id.strip()
1904
+ payload: dict[str, object] = {"vendorId": vendor_id}
1905
+ message_identifier = ""
1906
+ if "messageId" in data and data["messageId"] is not None:
1907
+ message_candidate = data["messageId"]
1908
+ if not isinstance(message_candidate, str):
1909
+ return JsonResponse({"detail": "messageId must be a string"}, status=400)
1910
+ message_identifier = message_candidate.strip()
1911
+ if message_identifier:
1912
+ payload["messageId"] = message_identifier
1913
+ if "data" in data:
1914
+ payload["data"] = data["data"]
1915
+ message_id = uuid.uuid4().hex
1916
+ ocpp_action = "DataTransfer"
1917
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1918
+ msg = json.dumps([2, message_id, "DataTransfer", payload])
1919
+ record = DataTransferMessage.objects.create(
1920
+ charger=charger_obj,
1921
+ connector_id=connector_value,
1922
+ direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
1923
+ ocpp_message_id=message_id,
1924
+ vendor_id=vendor_id,
1925
+ message_id=message_identifier,
1926
+ payload=payload,
1927
+ status="Pending",
1928
+ )
1929
+ async_to_sync(ws.send)(msg)
1930
+ store.register_pending_call(
1931
+ message_id,
1932
+ {
1933
+ "action": "DataTransfer",
1934
+ "charger_id": cid,
1935
+ "connector_id": connector_value,
1936
+ "message_pk": record.pk,
1937
+ "log_key": log_key,
1938
+ },
1939
+ )
1940
+ elif action == "reset":
1941
+ tx_obj = store.get_transaction(cid, connector_value)
1942
+ if tx_obj is not None:
1943
+ detail = _(
1944
+ "Reset is blocked while a charging session is active. "
1945
+ "Stop the session first."
1946
+ )
1947
+ return JsonResponse({"detail": detail}, status=409)
1948
+ message_id = uuid.uuid4().hex
1949
+ ocpp_action = "Reset"
1950
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1951
+ msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
1952
+ async_to_sync(ws.send)(msg)
1953
+ store.register_pending_call(
1954
+ message_id,
1955
+ {
1956
+ "action": "Reset",
1957
+ "charger_id": cid,
1958
+ "connector_id": connector_value,
1959
+ "log_key": log_key,
1960
+ "requested_at": timezone.now(),
1961
+ },
1962
+ )
1963
+ elif action == "trigger_message":
1964
+ trigger_target = data.get("target") or data.get("triggerTarget")
1965
+ if not isinstance(trigger_target, str) or not trigger_target.strip():
1966
+ return JsonResponse({"detail": "target required"}, status=400)
1967
+ trigger_target = trigger_target.strip()
1968
+ allowed_targets = {
1969
+ "BootNotification",
1970
+ "DiagnosticsStatusNotification",
1971
+ "FirmwareStatusNotification",
1972
+ "Heartbeat",
1973
+ "MeterValues",
1974
+ "StatusNotification",
1975
+ }
1976
+ if trigger_target not in allowed_targets:
1977
+ return JsonResponse({"detail": "invalid target"}, status=400)
1978
+ payload: dict[str, object] = {"requestedMessage": trigger_target}
1979
+ trigger_connector = None
1980
+ connector_field = data.get("connectorId")
1981
+ if connector_field in (None, ""):
1982
+ connector_field = data.get("connector")
1983
+ if connector_field in (None, "") and connector_value is not None:
1984
+ connector_field = connector_value
1985
+ if connector_field not in (None, ""):
1986
+ try:
1987
+ trigger_connector = int(connector_field)
1988
+ except (TypeError, ValueError):
1989
+ return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
1990
+ if trigger_connector <= 0:
1991
+ return JsonResponse({"detail": "connectorId must be positive"}, status=400)
1992
+ payload["connectorId"] = trigger_connector
1993
+ message_id = uuid.uuid4().hex
1994
+ ocpp_action = "TriggerMessage"
1995
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1996
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
1997
+ async_to_sync(ws.send)(msg)
1998
+ store.register_pending_call(
1999
+ message_id,
2000
+ {
2001
+ "action": "TriggerMessage",
2002
+ "charger_id": cid,
2003
+ "connector_id": connector_value,
2004
+ "log_key": log_key,
2005
+ "trigger_target": trigger_target,
2006
+ "trigger_connector": trigger_connector,
2007
+ "requested_at": timezone.now(),
2008
+ },
2009
+ )
2010
+ else:
2011
+ return JsonResponse({"detail": "unknown action"}, status=400)
2012
+ log_key = store.identity_key(cid, connector_value)
2013
+ if msg is None or message_id is None or ocpp_action is None:
2014
+ return JsonResponse({"detail": "unknown action"}, status=400)
2015
+ store.add_log(log_key, f"< {msg}", log_type="charger")
2016
+ expected_statuses = expected_statuses or CALL_EXPECTED_STATUSES.get(ocpp_action)
2017
+ success, detail, status_code = _evaluate_pending_call_result(
2018
+ message_id,
2019
+ ocpp_action,
2020
+ expected_statuses=expected_statuses,
2021
+ )
2022
+ if not success:
2023
+ return JsonResponse({"detail": detail}, status=status_code or 400)
2024
+ return JsonResponse({"sent": msg})