arthexis 0.1.13__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -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 +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
ocpp/views.py CHANGED
@@ -1,1251 +1,1479 @@
1
- import json
2
- import uuid
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.contrib.auth.views import redirect_to_login
13
- from django.utils.translation import gettext_lazy as _, gettext, ngettext
14
- from django.urls import NoReverseMatch, reverse
15
- from django.conf import settings
16
- from django.utils import translation, timezone
17
- from django.core.exceptions import ValidationError
18
-
19
- from asgiref.sync import async_to_sync
20
-
21
- from utils.api import api_login_required
22
-
23
- from nodes.models import Node
24
-
25
- from pages.utils import landing
26
- from core.liveupdate import live_update
27
-
28
- from . import store
29
- from .models import Transaction, Charger, DataTransferMessage, RFID
30
- from .evcs import (
31
- _start_simulator,
32
- _stop_simulator,
33
- get_simulator_state,
34
- _simulator_status_json,
35
- )
36
- from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
37
-
38
-
39
- def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
40
- """Return connector value and normalized slug or raise 404."""
41
-
42
- try:
43
- value = Charger.connector_value_from_slug(slug)
44
- except ValueError as exc: # pragma: no cover - defensive guard
45
- raise Http404("Invalid connector") from exc
46
- return value, Charger.connector_slug_from_value(value)
47
-
48
-
49
- def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
50
- """Return URL name for connector-aware routes."""
51
-
52
- target = f"{name}-connector"
53
- if connector_slug == Charger.AGGREGATE_CONNECTOR_SLUG:
54
- try:
55
- return reverse(target, args=[serial, connector_slug])
56
- except NoReverseMatch:
57
- return reverse(name, args=[serial])
58
- return reverse(target, args=[serial, connector_slug])
59
-
60
-
61
- def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
62
- """Return charger for the requested identity, creating if necessary."""
63
-
64
- try:
65
- serial = Charger.validate_serial(serial)
66
- except ValidationError as exc:
67
- raise Http404("Charger not found") from exc
68
- connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
69
- if connector_value is None:
70
- charger, _ = Charger.objects.get_or_create(
71
- charger_id=serial,
72
- connector_id=None,
73
- )
74
- else:
75
- charger, _ = Charger.objects.get_or_create(
76
- charger_id=serial,
77
- connector_id=connector_value,
78
- )
79
- return charger, normalized_slug
80
-
81
-
82
- def _connector_set(charger: Charger) -> list[Charger]:
83
- """Return chargers sharing the same serial ordered for navigation."""
84
-
85
- siblings = list(Charger.objects.filter(charger_id=charger.charger_id))
86
- siblings.sort(key=lambda c: (c.connector_id is not None, c.connector_id or 0))
87
- return siblings
88
-
89
-
90
- def _visible_chargers(user):
91
- """Return chargers visible to ``user`` on public dashboards."""
92
-
93
- return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
94
-
95
-
96
- def _ensure_charger_access(user, charger: Charger):
97
- """Raise 404 when the user cannot view the charger."""
98
-
99
- if not charger.is_visible_to(user):
100
- raise Http404("Charger not found")
101
-
102
-
103
- def _transaction_rfid_details(
104
- tx_obj, *, cache: dict[str, dict[str, str | None]] | None = None
105
- ) -> dict[str, str | None] | None:
106
- """Return normalized RFID metadata for a transaction-like object."""
107
-
108
- if not tx_obj:
109
- return None
110
- rfid_value = getattr(tx_obj, "rfid", None)
111
- if not rfid_value:
112
- return None
113
- normalized = str(rfid_value).strip()
114
- if not normalized:
115
- return None
116
- normalized = normalized.upper()
117
- if cache is not None and normalized in cache:
118
- return cache[normalized]
119
- tag = RFID.objects.filter(rfid=normalized).only("pk").first()
120
- rfid_url = None
121
- if tag:
122
- try:
123
- rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
124
- except NoReverseMatch: # pragma: no cover - admin may be disabled
125
- rfid_url = None
126
- details = {"value": normalized, "url": rfid_url}
127
- if cache is not None:
128
- cache[normalized] = details
129
- return details
130
-
131
-
132
- def _connector_overview(
133
- charger: Charger,
134
- user=None,
135
- *,
136
- rfid_cache: dict[str, dict[str, str | None]] | None = None,
137
- ) -> list[dict]:
138
- """Return connector metadata used for navigation and summaries."""
139
-
140
- overview: list[dict] = []
141
- for sibling in _connector_set(charger):
142
- if user is not None and not sibling.is_visible_to(user):
143
- continue
144
- tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
145
- state, color = _charger_state(sibling, tx_obj)
146
- overview.append(
147
- {
148
- "charger": sibling,
149
- "slug": sibling.connector_slug,
150
- "label": sibling.connector_label,
151
- "url": _reverse_connector_url(
152
- "charger-page", sibling.charger_id, sibling.connector_slug
153
- ),
154
- "status": state,
155
- "color": color,
156
- "last_status": sibling.last_status,
157
- "last_error_code": sibling.last_error_code,
158
- "last_status_timestamp": sibling.last_status_timestamp,
159
- "last_status_vendor_info": sibling.last_status_vendor_info,
160
- "tx": tx_obj,
161
- "rfid_details": _transaction_rfid_details(
162
- tx_obj, cache=rfid_cache
163
- ),
164
- "connected": store.is_connected(
165
- sibling.charger_id, sibling.connector_id
166
- ),
167
- }
168
- )
169
- return overview
170
-
171
-
172
- def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
173
- """Return active sessions grouped by connector for the charger."""
174
-
175
- siblings = _connector_set(charger)
176
- ordered = [c for c in siblings if c.connector_id is not None] + [
177
- c for c in siblings if c.connector_id is None
178
- ]
179
- sessions: list[tuple[Charger, Transaction]] = []
180
- seen: set[int] = set()
181
- for sibling in ordered:
182
- tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
183
- if not tx_obj:
184
- continue
185
- if tx_obj.pk and tx_obj.pk in seen:
186
- continue
187
- if tx_obj.pk:
188
- seen.add(tx_obj.pk)
189
- sessions.append((sibling, tx_obj))
190
- return sessions
191
-
192
-
193
- def _landing_page_translations() -> dict[str, dict[str, str]]:
194
- """Return static translations used by the charger public landing page."""
195
-
196
- catalog: dict[str, dict[str, str]] = {}
197
- for code in ("en", "es"):
198
- with translation.override(code):
199
- catalog[code] = {
200
- "serial_number_label": gettext("Serial Number"),
201
- "connector_label": gettext("Connector"),
202
- "advanced_view_label": gettext("Advanced View"),
203
- "require_rfid_label": gettext("Require RFID Authorization"),
204
- "charging_label": gettext("Charging"),
205
- "energy_label": gettext("Energy"),
206
- "started_label": gettext("Started"),
207
- "rfid_label": gettext("RFID"),
208
- "instruction_text": gettext(
209
- "Plug in your vehicle and slide your RFID card over the reader to begin charging."
210
- ),
211
- "connectors_heading": gettext("Connectors"),
212
- "no_active_transaction": gettext("No active transaction"),
213
- "connectors_active_singular": ngettext(
214
- "%(count)s connector active",
215
- "%(count)s connectors active",
216
- 1,
217
- ),
218
- "connectors_active_plural": ngettext(
219
- "%(count)s connector active",
220
- "%(count)s connectors active",
221
- 2,
222
- ),
223
- "status_reported_label": gettext("Reported status"),
224
- "status_error_label": gettext("Error code"),
225
- "status_updated_label": gettext("Last status update"),
226
- "status_vendor_label": gettext("Vendor"),
227
- "status_info_label": gettext("Info"),
228
- }
229
- return catalog
230
-
231
-
232
- def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
233
- """Return human readable state and color for a charger."""
234
-
235
- status_value = (charger.last_status or "").strip()
236
- has_session = bool(tx_obj)
237
- if status_value:
238
- key = status_value.lower()
239
- label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
240
- error_code = (charger.last_error_code or "").strip()
241
- error_code_lower = error_code.lower()
242
- if (
243
- has_session
244
- and error_code_lower in ERROR_OK_VALUES
245
- and (key not in STATUS_BADGE_MAP or key == "available")
246
- ):
247
- # Some stations continue reporting "Available" (or an unknown status)
248
- # while a session is active. Override the badge so the user can see
249
- # the charger is actually busy.
250
- label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
251
- elif error_code and error_code_lower not in ERROR_OK_VALUES:
252
- label = _("%(status)s (%(error)s)") % {
253
- "status": label,
254
- "error": error_code,
255
- }
256
- color = "#dc3545"
257
- return label, color
258
-
259
- cid = charger.charger_id
260
- connected = store.is_connected(cid, charger.connector_id)
261
- if connected and has_session:
262
- return _("Charging"), "green"
263
- if connected:
264
- return _("Available"), "blue"
265
- return _("Offline"), "grey"
266
-
267
-
268
- def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
269
- """Return diagnostics metadata for API responses."""
270
-
271
- timestamp = (
272
- charger.diagnostics_timestamp.isoformat()
273
- if charger.diagnostics_timestamp
274
- else None
275
- )
276
- status = charger.diagnostics_status or None
277
- location = charger.diagnostics_location or None
278
- return {
279
- "diagnosticsStatus": status,
280
- "diagnosticsTimestamp": timestamp,
281
- "diagnosticsLocation": location,
282
- }
283
-
284
-
285
- @api_login_required
286
- def charger_list(request):
287
- """Return a JSON list of known chargers and state."""
288
- data = []
289
- for charger in _visible_chargers(request.user):
290
- cid = charger.charger_id
291
- sessions: list[tuple[Charger, Transaction]] = []
292
- tx_obj = store.get_transaction(cid, charger.connector_id)
293
- if charger.connector_id is None:
294
- sessions = _live_sessions(charger)
295
- if sessions:
296
- tx_obj = sessions[0][1]
297
- elif tx_obj:
298
- sessions = [(charger, tx_obj)]
299
- if not tx_obj:
300
- tx_obj = (
301
- Transaction.objects.filter(charger__charger_id=cid)
302
- .order_by("-start_time")
303
- .first()
304
- )
305
- tx_data = None
306
- if tx_obj:
307
- tx_data = {
308
- "transactionId": tx_obj.pk,
309
- "meterStart": tx_obj.meter_start,
310
- "startTime": tx_obj.start_time.isoformat(),
311
- }
312
- if tx_obj.vin:
313
- tx_data["vin"] = tx_obj.vin
314
- if tx_obj.meter_stop is not None:
315
- tx_data["meterStop"] = tx_obj.meter_stop
316
- if tx_obj.stop_time is not None:
317
- tx_data["stopTime"] = tx_obj.stop_time.isoformat()
318
- active_transactions = []
319
- for session_charger, session_tx in sessions:
320
- active_payload = {
321
- "charger_id": session_charger.charger_id,
322
- "connector_id": session_charger.connector_id,
323
- "connector_slug": session_charger.connector_slug,
324
- "transactionId": session_tx.pk,
325
- "meterStart": session_tx.meter_start,
326
- "startTime": session_tx.start_time.isoformat(),
327
- }
328
- if session_tx.vin:
329
- active_payload["vin"] = session_tx.vin
330
- if session_tx.meter_stop is not None:
331
- active_payload["meterStop"] = session_tx.meter_stop
332
- if session_tx.stop_time is not None:
333
- active_payload["stopTime"] = session_tx.stop_time.isoformat()
334
- active_transactions.append(active_payload)
335
- state, color = _charger_state(
336
- charger,
337
- tx_obj if charger.connector_id is not None else (sessions if sessions else None),
338
- )
339
- entry = {
340
- "charger_id": cid,
341
- "name": charger.name,
342
- "connector_id": charger.connector_id,
343
- "connector_slug": charger.connector_slug,
344
- "connector_label": charger.connector_label,
345
- "require_rfid": charger.require_rfid,
346
- "transaction": tx_data,
347
- "activeTransactions": active_transactions,
348
- "lastHeartbeat": (
349
- charger.last_heartbeat.isoformat()
350
- if charger.last_heartbeat
351
- else None
352
- ),
353
- "lastMeterValues": charger.last_meter_values,
354
- "firmwareStatus": charger.firmware_status,
355
- "firmwareStatusInfo": charger.firmware_status_info,
356
- "firmwareTimestamp": (
357
- charger.firmware_timestamp.isoformat()
358
- if charger.firmware_timestamp
359
- else None
360
- ),
361
- "connected": store.is_connected(cid, charger.connector_id),
362
- "lastStatus": charger.last_status or None,
363
- "lastErrorCode": charger.last_error_code or None,
364
- "lastStatusTimestamp": (
365
- charger.last_status_timestamp.isoformat()
366
- if charger.last_status_timestamp
367
- else None
368
- ),
369
- "lastStatusVendorInfo": charger.last_status_vendor_info,
370
- "status": state,
371
- "statusColor": color,
372
- }
373
- entry.update(_diagnostics_payload(charger))
374
- data.append(entry)
375
- return JsonResponse({"chargers": data})
376
-
377
-
378
- @api_login_required
379
- def charger_detail(request, cid, connector=None):
380
- charger, connector_slug = _get_charger(cid, connector)
381
- _ensure_charger_access(request.user, charger)
382
-
383
- sessions: list[tuple[Charger, Transaction]] = []
384
- tx_obj = store.get_transaction(cid, charger.connector_id)
385
- if charger.connector_id is None:
386
- sessions = _live_sessions(charger)
387
- if sessions:
388
- tx_obj = sessions[0][1]
389
- elif tx_obj:
390
- sessions = [(charger, tx_obj)]
391
- if not tx_obj:
392
- tx_obj = (
393
- Transaction.objects.filter(charger__charger_id=cid)
394
- .order_by("-start_time")
395
- .first()
396
- )
397
-
398
- tx_data = None
399
- if tx_obj:
400
- tx_data = {
401
- "transactionId": tx_obj.pk,
402
- "meterStart": tx_obj.meter_start,
403
- "startTime": tx_obj.start_time.isoformat(),
404
- }
405
- if tx_obj.vin:
406
- tx_data["vin"] = tx_obj.vin
407
- if tx_obj.meter_stop is not None:
408
- tx_data["meterStop"] = tx_obj.meter_stop
409
- if tx_obj.stop_time is not None:
410
- tx_data["stopTime"] = tx_obj.stop_time.isoformat()
411
-
412
- active_transactions = []
413
- for session_charger, session_tx in sessions:
414
- payload = {
415
- "charger_id": session_charger.charger_id,
416
- "connector_id": session_charger.connector_id,
417
- "connector_slug": session_charger.connector_slug,
418
- "transactionId": session_tx.pk,
419
- "meterStart": session_tx.meter_start,
420
- "startTime": session_tx.start_time.isoformat(),
421
- }
422
- if session_tx.vin:
423
- payload["vin"] = session_tx.vin
424
- if session_tx.meter_stop is not None:
425
- payload["meterStop"] = session_tx.meter_stop
426
- if session_tx.stop_time is not None:
427
- payload["stopTime"] = session_tx.stop_time.isoformat()
428
- active_transactions.append(payload)
429
-
430
- log_key = store.identity_key(cid, charger.connector_id)
431
- log = store.get_logs(log_key, log_type="charger")
432
- state, color = _charger_state(
433
- charger,
434
- tx_obj if charger.connector_id is not None else (sessions if sessions else None),
435
- )
436
- payload = {
437
- "charger_id": cid,
438
- "connector_id": charger.connector_id,
439
- "connector_slug": connector_slug,
440
- "name": charger.name,
441
- "require_rfid": charger.require_rfid,
442
- "transaction": tx_data,
443
- "activeTransactions": active_transactions,
444
- "lastHeartbeat": (
445
- charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
446
- ),
447
- "lastMeterValues": charger.last_meter_values,
448
- "firmwareStatus": charger.firmware_status,
449
- "firmwareStatusInfo": charger.firmware_status_info,
450
- "firmwareTimestamp": (
451
- charger.firmware_timestamp.isoformat()
452
- if charger.firmware_timestamp
453
- else None
454
- ),
455
- "log": log,
456
- "lastStatus": charger.last_status or None,
457
- "lastErrorCode": charger.last_error_code or None,
458
- "lastStatusTimestamp": (
459
- charger.last_status_timestamp.isoformat()
460
- if charger.last_status_timestamp
461
- else None
462
- ),
463
- "lastStatusVendorInfo": charger.last_status_vendor_info,
464
- "status": state,
465
- "statusColor": color,
466
- }
467
- payload.update(_diagnostics_payload(charger))
468
- return JsonResponse(payload)
469
-
470
-
471
- @landing("CPMS Online Dashboard")
472
- @live_update()
473
- def dashboard(request):
474
- """Landing page listing all known chargers and their status."""
475
- node = Node.get_local()
476
- role = node.role if node else None
477
- is_constellation = bool(role and role.name == "Constellation")
478
- if not request.user.is_authenticated and not is_constellation:
479
- return redirect_to_login(
480
- request.get_full_path(), login_url=reverse("pages:login")
481
- )
482
- chargers = []
483
- for charger in _visible_chargers(request.user):
484
- tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
485
- if not tx_obj:
486
- tx_obj = (
487
- Transaction.objects.filter(charger=charger)
488
- .order_by("-start_time")
489
- .first()
490
- )
491
- state, color = _charger_state(charger, tx_obj)
492
- chargers.append({"charger": charger, "state": state, "color": color})
493
- scheme = "wss" if request.is_secure() else "ws"
494
- host = request.get_host()
495
- ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
496
- context = {
497
- "chargers": chargers,
498
- "show_demo_notice": is_constellation,
499
- "demo_ws_url": ws_url,
500
- "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
501
- }
502
- return render(request, "ocpp/dashboard.html", context)
503
-
504
-
505
- @login_required(login_url="pages:login")
506
- @landing("Charge Point Simulator")
507
- @live_update()
508
- def cp_simulator(request):
509
- """Public landing page to control the OCPP charge point simulator."""
510
- host_header = request.get_host()
511
- default_host, host_port = split_domain_port(host_header)
512
- if not default_host:
513
- default_host = "127.0.0.1"
514
- default_ws_port = request.get_port() or host_port or "8000"
515
- default_cp_paths = ["CP1", "CP2"]
516
- default_serial_numbers = default_cp_paths
517
- default_connector_id = 1
518
- default_rfid = "FFFFFFFF"
519
- default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
520
-
521
- message = ""
522
- dashboard_link: str | None = None
523
- if request.method == "POST":
524
- cp_idx = int(request.POST.get("cp") or 1)
525
- action = request.POST.get("action")
526
- if action == "start":
527
- ws_port_value = request.POST.get("ws_port")
528
- if ws_port_value is None:
529
- ws_port = int(default_ws_port) if default_ws_port else None
530
- elif ws_port_value.strip():
531
- ws_port = int(ws_port_value)
532
- else:
533
- ws_port = None
534
- sim_params = dict(
535
- host=request.POST.get("host") or default_host,
536
- ws_port=ws_port,
537
- cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
538
- serial_number=request.POST.get("serial_number")
539
- or default_serial_numbers[cp_idx - 1],
540
- connector_id=int(
541
- request.POST.get("connector_id") or default_connector_id
542
- ),
543
- rfid=request.POST.get("rfid") or default_rfid,
544
- vin=request.POST.get("vin") or default_vins[cp_idx - 1],
545
- duration=int(request.POST.get("duration") or 600),
546
- interval=float(request.POST.get("interval") or 5),
547
- kw_min=float(request.POST.get("kw_min") or 30),
548
- kw_max=float(request.POST.get("kw_max") or 60),
549
- pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
550
- repeat=request.POST.get("repeat") or False,
551
- daemon=True,
552
- username=request.POST.get("username") or None,
553
- password=request.POST.get("password") or None,
554
- )
555
- try:
556
- started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
557
- if started:
558
- message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
559
- try:
560
- dashboard_link = reverse(
561
- "charger-status", args=[sim_params["cp_path"]]
562
- )
563
- except NoReverseMatch: # pragma: no cover - defensive
564
- dashboard_link = None
565
- else:
566
- message = f"CP{cp_idx} {status}. Logs: {log_file}"
567
- except Exception as exc: # pragma: no cover - unexpected
568
- message = f"Failed to start CP{cp_idx}: {exc}"
569
- elif action == "stop":
570
- try:
571
- _stop_simulator(cp=cp_idx)
572
- message = f"CP{cp_idx} stop requested."
573
- except Exception as exc: # pragma: no cover - unexpected
574
- message = f"Failed to stop CP{cp_idx}: {exc}"
575
- else:
576
- message = "Unknown action."
577
-
578
- states_dict = get_simulator_state()
579
- state_list = [states_dict[1], states_dict[2]]
580
- params_jsons = [
581
- json.dumps(state_list[0].get("params", {}), indent=2),
582
- json.dumps(state_list[1].get("params", {}), indent=2),
583
- ]
584
- state_jsons = [
585
- _simulator_status_json(1),
586
- _simulator_status_json(2),
587
- ]
588
-
589
- context = {
590
- "message": message,
591
- "dashboard_link": dashboard_link,
592
- "states": state_list,
593
- "default_host": default_host,
594
- "default_ws_port": default_ws_port,
595
- "default_cp_paths": default_cp_paths,
596
- "default_serial_numbers": default_serial_numbers,
597
- "default_connector_id": default_connector_id,
598
- "default_rfid": default_rfid,
599
- "default_vins": default_vins,
600
- "params_jsons": params_jsons,
601
- "state_jsons": state_jsons,
602
- }
603
- return render(request, "ocpp/cp_simulator.html", context)
604
-
605
-
606
- def charger_page(request, cid, connector=None):
607
- """Public landing page for a charger displaying usage guidance or progress."""
608
- charger, connector_slug = _get_charger(cid, connector)
609
- _ensure_charger_access(request.user, charger)
610
- rfid_cache: dict[str, dict[str, str | None]] = {}
611
- overview = _connector_overview(
612
- charger, request.user, rfid_cache=rfid_cache
613
- )
614
- sessions = _live_sessions(charger)
615
- tx = None
616
- active_connector_count = 0
617
- if charger.connector_id is None:
618
- if sessions:
619
- total_kw = 0.0
620
- start_times = [
621
- tx_obj.start_time for _, tx_obj in sessions if tx_obj.start_time
622
- ]
623
- for _, tx_obj in sessions:
624
- if tx_obj.kw:
625
- total_kw += tx_obj.kw
626
- tx = SimpleNamespace(
627
- kw=total_kw, start_time=min(start_times) if start_times else None
628
- )
629
- active_connector_count = len(sessions)
630
- else:
631
- tx = (
632
- sessions[0][1]
633
- if sessions
634
- else store.get_transaction(cid, charger.connector_id)
635
- )
636
- if tx:
637
- active_connector_count = 1
638
- state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
639
- state, color = _charger_state(charger, state_source)
640
- language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
641
- preferred_language = "es"
642
- supported_languages = {code for code, _ in settings.LANGUAGES}
643
- if preferred_language in supported_languages and not language_cookie:
644
- translation.activate(preferred_language)
645
- request.LANGUAGE_CODE = translation.get_language()
646
- connector_links = [
647
- {
648
- "slug": item["slug"],
649
- "label": item["label"],
650
- "url": item["url"],
651
- "active": item["slug"] == connector_slug,
652
- }
653
- for item in overview
654
- ]
655
- connector_overview = [
656
- item for item in overview if item["charger"].connector_id is not None
657
- ]
658
- status_url = _reverse_connector_url("charger-status", cid, connector_slug)
659
- tx_rfid_details = _transaction_rfid_details(tx, cache=rfid_cache)
660
- return render(
661
- request,
662
- "ocpp/charger_page.html",
663
- {
664
- "charger": charger,
665
- "tx": tx,
666
- "tx_rfid_details": tx_rfid_details,
667
- "connector_slug": connector_slug,
668
- "connector_links": connector_links,
669
- "connector_overview": connector_overview,
670
- "active_connector_count": active_connector_count,
671
- "status_url": status_url,
672
- "landing_translations": _landing_page_translations(),
673
- "state": state,
674
- "color": color,
675
- },
676
- )
677
-
678
-
679
- @login_required
680
- def charger_status(request, cid, connector=None):
681
- charger, connector_slug = _get_charger(cid, connector)
682
- _ensure_charger_access(request.user, charger)
683
- session_id = request.GET.get("session")
684
- sessions = _live_sessions(charger)
685
- live_tx = None
686
- if charger.connector_id is not None and sessions:
687
- live_tx = sessions[0][1]
688
- tx_obj = live_tx
689
- past_session = False
690
- if session_id:
691
- if charger.connector_id is None:
692
- tx_obj = get_object_or_404(
693
- Transaction, pk=session_id, charger__charger_id=cid
694
- )
695
- past_session = True
696
- elif not (live_tx and str(live_tx.pk) == session_id):
697
- tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
698
- past_session = True
699
- state, color = _charger_state(
700
- charger,
701
- (
702
- live_tx
703
- if charger.connector_id is not None
704
- else (sessions if sessions else None)
705
- ),
706
- )
707
- if charger.connector_id is None:
708
- transactions_qs = (
709
- Transaction.objects.filter(charger__charger_id=cid)
710
- .select_related("charger")
711
- .order_by("-start_time")
712
- )
713
- else:
714
- transactions_qs = Transaction.objects.filter(charger=charger).order_by(
715
- "-start_time"
716
- )
717
- paginator = Paginator(transactions_qs, 10)
718
- page_obj = paginator.get_page(request.GET.get("page"))
719
- transactions = page_obj.object_list
720
- date_view = request.GET.get("dates", "charger").lower()
721
- if date_view not in {"charger", "received"}:
722
- date_view = "charger"
723
-
724
- def _date_query(mode: str) -> str:
725
- params = request.GET.copy()
726
- params["dates"] = mode
727
- query = params.urlencode()
728
- return f"?{query}" if query else ""
729
-
730
- date_view_options = {
731
- "charger": _("Charger timestamps"),
732
- "received": _("Received timestamps"),
733
- }
734
- date_toggle_links = [
735
- {
736
- "mode": mode,
737
- "label": label,
738
- "url": _date_query(mode),
739
- "active": mode == date_view,
740
- }
741
- for mode, label in date_view_options.items()
742
- ]
743
- chart_data = {"labels": [], "datasets": []}
744
- pagination_params = request.GET.copy()
745
- pagination_params["dates"] = date_view
746
- pagination_params.pop("page", None)
747
- pagination_query = pagination_params.urlencode()
748
- session_params = request.GET.copy()
749
- session_params["dates"] = date_view
750
- session_params.pop("session", None)
751
- session_params.pop("page", None)
752
- session_query = session_params.urlencode()
753
-
754
- def _series_from_transaction(tx):
755
- points: list[tuple[str, float]] = []
756
- readings = list(
757
- tx.meter_values.filter(energy__isnull=False).order_by("timestamp")
758
- )
759
- start_val = None
760
- if tx.meter_start is not None:
761
- start_val = float(tx.meter_start) / 1000.0
762
- for reading in readings:
763
- try:
764
- val = float(reading.energy)
765
- except (TypeError, ValueError):
766
- continue
767
- if start_val is None:
768
- start_val = val
769
- total = val - start_val
770
- points.append((reading.timestamp.isoformat(), max(total, 0.0)))
771
- return points
772
-
773
- if tx_obj and (charger.connector_id is not None or past_session):
774
- series_points = _series_from_transaction(tx_obj)
775
- if series_points:
776
- chart_data["labels"] = [ts for ts, _ in series_points]
777
- connector_id = None
778
- if tx_obj.charger and tx_obj.charger.connector_id is not None:
779
- connector_id = tx_obj.charger.connector_id
780
- elif charger.connector_id is not None:
781
- connector_id = charger.connector_id
782
- chart_data["datasets"].append(
783
- {
784
- "label": str(
785
- tx_obj.charger.connector_label
786
- if tx_obj.charger and tx_obj.charger.connector_id is not None
787
- else charger.connector_label
788
- ),
789
- "values": [value for _, value in series_points],
790
- "connector_id": connector_id,
791
- }
792
- )
793
- elif charger.connector_id is None:
794
- dataset_points: list[tuple[str, list[tuple[str, float]], int]] = []
795
- for sibling, sibling_tx in sessions:
796
- if sibling.connector_id is None or not sibling_tx:
797
- continue
798
- points = _series_from_transaction(sibling_tx)
799
- if not points:
800
- continue
801
- dataset_points.append(
802
- (str(sibling.connector_label), points, sibling.connector_id)
803
- )
804
- if dataset_points:
805
- all_labels: list[str] = sorted(
806
- {ts for _, points, _ in dataset_points for ts, _ in points}
807
- )
808
- chart_data["labels"] = all_labels
809
- for label, points, connector_id in dataset_points:
810
- value_map = {ts: val for ts, val in points}
811
- chart_data["datasets"].append(
812
- {
813
- "label": label,
814
- "values": [value_map.get(ts) for ts in all_labels],
815
- "connector_id": connector_id,
816
- }
817
- )
818
- overview = _connector_overview(charger, request.user)
819
- connector_links = [
820
- {
821
- "slug": item["slug"],
822
- "label": item["label"],
823
- "url": _reverse_connector_url("charger-status", cid, item["slug"]),
824
- "active": item["slug"] == connector_slug,
825
- }
826
- for item in overview
827
- ]
828
- connector_overview = [
829
- item for item in overview if item["charger"].connector_id is not None
830
- ]
831
- search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
832
- configuration_url = None
833
- if request.user.is_staff:
834
- try:
835
- configuration_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
836
- except NoReverseMatch: # pragma: no cover - admin may be disabled
837
- configuration_url = None
838
- is_connected = store.is_connected(cid, charger.connector_id)
839
- has_active_session = bool(
840
- live_tx if charger.connector_id is not None else sessions
841
- )
842
- can_remote_start = (
843
- charger.connector_id is not None
844
- and is_connected
845
- and not has_active_session
846
- and not past_session
847
- )
848
- remote_start_messages = None
849
- if can_remote_start:
850
- remote_start_messages = {
851
- "required": str(_("RFID is required to start a session.")),
852
- "sending": str(_("Sending remote start request...")),
853
- "success": str(_("Remote start command queued.")),
854
- "error": str(_("Unable to send remote start request.")),
855
- }
856
- action_url = _reverse_connector_url("charger-action", cid, connector_slug)
857
- chart_should_animate = bool(has_active_session and not past_session)
858
-
859
- return render(
860
- request,
861
- "ocpp/charger_status.html",
862
- {
863
- "charger": charger,
864
- "tx": tx_obj,
865
- "state": state,
866
- "color": color,
867
- "transactions": transactions,
868
- "page_obj": page_obj,
869
- "chart_data": chart_data,
870
- "past_session": past_session,
871
- "connector_slug": connector_slug,
872
- "connector_links": connector_links,
873
- "connector_overview": connector_overview,
874
- "search_url": search_url,
875
- "configuration_url": configuration_url,
876
- "page_url": _reverse_connector_url("charger-page", cid, connector_slug),
877
- "is_connected": is_connected,
878
- "is_idle": is_connected and not has_active_session,
879
- "can_remote_start": can_remote_start,
880
- "remote_start_messages": remote_start_messages,
881
- "action_url": action_url,
882
- "show_chart": bool(
883
- chart_data["datasets"]
884
- and any(
885
- any(value is not None for value in dataset["values"])
886
- for dataset in chart_data["datasets"]
887
- )
888
- ),
889
- "date_view": date_view,
890
- "date_toggle_links": date_toggle_links,
891
- "pagination_query": pagination_query,
892
- "session_query": session_query,
893
- "chart_should_animate": chart_should_animate,
894
- },
895
- )
896
-
897
-
898
- @login_required
899
- def charger_session_search(request, cid, connector=None):
900
- charger, connector_slug = _get_charger(cid, connector)
901
- _ensure_charger_access(request.user, charger)
902
- date_str = request.GET.get("date")
903
- date_view = request.GET.get("dates", "charger").lower()
904
- if date_view not in {"charger", "received"}:
905
- date_view = "charger"
906
-
907
- def _date_query(mode: str) -> str:
908
- params = request.GET.copy()
909
- params["dates"] = mode
910
- query = params.urlencode()
911
- return f"?{query}" if query else ""
912
-
913
- date_toggle_links = [
914
- {
915
- "mode": mode,
916
- "label": label,
917
- "url": _date_query(mode),
918
- "active": mode == date_view,
919
- }
920
- for mode, label in {
921
- "charger": _("Charger timestamps"),
922
- "received": _("Received timestamps"),
923
- }.items()
924
- ]
925
- transactions = None
926
- if date_str:
927
- try:
928
- date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
929
- start = datetime.combine(
930
- date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
931
- )
932
- end = start + timedelta(days=1)
933
- qs = Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
934
- if charger.connector_id is None:
935
- qs = qs.filter(charger__charger_id=cid)
936
- else:
937
- qs = qs.filter(charger=charger)
938
- transactions = qs.order_by("-start_time")
939
- except ValueError:
940
- transactions = []
941
- overview = _connector_overview(charger, request.user)
942
- connector_links = [
943
- {
944
- "slug": item["slug"],
945
- "label": item["label"],
946
- "url": _reverse_connector_url("charger-session-search", cid, item["slug"]),
947
- "active": item["slug"] == connector_slug,
948
- }
949
- for item in overview
950
- ]
951
- status_url = _reverse_connector_url("charger-status", cid, connector_slug)
952
- return render(
953
- request,
954
- "ocpp/charger_session_search.html",
955
- {
956
- "charger": charger,
957
- "transactions": transactions,
958
- "date": date_str,
959
- "connector_slug": connector_slug,
960
- "connector_links": connector_links,
961
- "status_url": status_url,
962
- "date_view": date_view,
963
- "date_toggle_links": date_toggle_links,
964
- },
965
- )
966
-
967
-
968
- @login_required
969
- def charger_log_page(request, cid, connector=None):
970
- """Render a simple page with the log for the charger or simulator."""
971
- log_type = request.GET.get("type", "charger")
972
- connector_links = []
973
- connector_slug = None
974
- status_url = None
975
- if log_type == "charger":
976
- charger, connector_slug = _get_charger(cid, connector)
977
- _ensure_charger_access(request.user, charger)
978
- log_key = store.identity_key(cid, charger.connector_id)
979
- overview = _connector_overview(charger, request.user)
980
- connector_links = [
981
- {
982
- "slug": item["slug"],
983
- "label": item["label"],
984
- "url": _reverse_connector_url("charger-log", cid, item["slug"]),
985
- "active": item["slug"] == connector_slug,
986
- }
987
- for item in overview
988
- ]
989
- target_id = log_key
990
- status_url = _reverse_connector_url("charger-status", cid, connector_slug)
991
- else:
992
- charger = Charger.objects.filter(charger_id=cid).first() or Charger(
993
- charger_id=cid
994
- )
995
- target_id = cid
996
- log = store.get_logs(target_id, log_type=log_type)
997
- return render(
998
- request,
999
- "ocpp/charger_logs.html",
1000
- {
1001
- "charger": charger,
1002
- "log": log,
1003
- "log_type": log_type,
1004
- "connector_slug": connector_slug,
1005
- "connector_links": connector_links,
1006
- "status_url": status_url,
1007
- },
1008
- )
1009
-
1010
-
1011
- @csrf_exempt
1012
- @api_login_required
1013
- def dispatch_action(request, cid, connector=None):
1014
- connector_value, _ = _normalize_connector_slug(connector)
1015
- log_key = store.identity_key(cid, connector_value)
1016
- if connector_value is None:
1017
- charger_obj = (
1018
- Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
1019
- .order_by("pk")
1020
- .first()
1021
- )
1022
- else:
1023
- charger_obj = (
1024
- Charger.objects.filter(charger_id=cid, connector_id=connector_value)
1025
- .order_by("pk")
1026
- .first()
1027
- )
1028
- if charger_obj is None:
1029
- if connector_value is None:
1030
- charger_obj, _ = Charger.objects.get_or_create(
1031
- charger_id=cid, connector_id=None
1032
- )
1033
- else:
1034
- charger_obj, _ = Charger.objects.get_or_create(
1035
- charger_id=cid, connector_id=connector_value
1036
- )
1037
-
1038
- _ensure_charger_access(request.user, charger_obj)
1039
- ws = store.get_connection(cid, connector_value)
1040
- if ws is None:
1041
- return JsonResponse({"detail": "no connection"}, status=404)
1042
- try:
1043
- data = json.loads(request.body.decode()) if request.body else {}
1044
- except json.JSONDecodeError:
1045
- data = {}
1046
- action = data.get("action")
1047
- if action == "remote_stop":
1048
- tx_obj = store.get_transaction(cid, connector_value)
1049
- if not tx_obj:
1050
- return JsonResponse({"detail": "no transaction"}, status=404)
1051
- message_id = uuid.uuid4().hex
1052
- msg = json.dumps(
1053
- [
1054
- 2,
1055
- message_id,
1056
- "RemoteStopTransaction",
1057
- {"transactionId": tx_obj.pk},
1058
- ]
1059
- )
1060
- async_to_sync(ws.send)(msg)
1061
- store.register_pending_call(
1062
- message_id,
1063
- {
1064
- "action": "RemoteStopTransaction",
1065
- "charger_id": cid,
1066
- "connector_id": connector_value,
1067
- "log_key": log_key,
1068
- "transaction_id": tx_obj.pk,
1069
- "requested_at": timezone.now(),
1070
- },
1071
- )
1072
- elif action == "remote_start":
1073
- id_tag = data.get("idTag")
1074
- if not isinstance(id_tag, str) or not id_tag.strip():
1075
- return JsonResponse({"detail": "idTag required"}, status=400)
1076
- id_tag = id_tag.strip()
1077
- payload: dict[str, object] = {"idTag": id_tag}
1078
- connector_id = data.get("connectorId")
1079
- if connector_id in ("", None):
1080
- connector_id = None
1081
- if connector_id is None and connector_value is not None:
1082
- connector_id = connector_value
1083
- if connector_id is not None:
1084
- try:
1085
- payload["connectorId"] = int(connector_id)
1086
- except (TypeError, ValueError):
1087
- payload["connectorId"] = connector_id
1088
- if "chargingProfile" in data and data["chargingProfile"] is not None:
1089
- payload["chargingProfile"] = data["chargingProfile"]
1090
- message_id = uuid.uuid4().hex
1091
- msg = json.dumps(
1092
- [
1093
- 2,
1094
- message_id,
1095
- "RemoteStartTransaction",
1096
- payload,
1097
- ]
1098
- )
1099
- async_to_sync(ws.send)(msg)
1100
- store.register_pending_call(
1101
- message_id,
1102
- {
1103
- "action": "RemoteStartTransaction",
1104
- "charger_id": cid,
1105
- "connector_id": connector_value,
1106
- "log_key": log_key,
1107
- "id_tag": id_tag,
1108
- "requested_at": timezone.now(),
1109
- },
1110
- )
1111
- elif action == "change_availability":
1112
- availability_type = data.get("type")
1113
- if availability_type not in {"Operative", "Inoperative"}:
1114
- return JsonResponse({"detail": "invalid availability type"}, status=400)
1115
- connector_payload = connector_value if connector_value is not None else 0
1116
- if "connectorId" in data:
1117
- candidate = data.get("connectorId")
1118
- if candidate not in (None, ""):
1119
- try:
1120
- connector_payload = int(candidate)
1121
- except (TypeError, ValueError):
1122
- connector_payload = candidate
1123
- message_id = uuid.uuid4().hex
1124
- payload = {"connectorId": connector_payload, "type": availability_type}
1125
- msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1126
- async_to_sync(ws.send)(msg)
1127
- requested_at = timezone.now()
1128
- store.register_pending_call(
1129
- message_id,
1130
- {
1131
- "action": "ChangeAvailability",
1132
- "charger_id": cid,
1133
- "connector_id": connector_value,
1134
- "availability_type": availability_type,
1135
- "requested_at": requested_at,
1136
- },
1137
- )
1138
- if charger_obj:
1139
- updates = {
1140
- "availability_requested_state": availability_type,
1141
- "availability_requested_at": requested_at,
1142
- "availability_request_status": "",
1143
- "availability_request_status_at": None,
1144
- "availability_request_details": "",
1145
- }
1146
- Charger.objects.filter(pk=charger_obj.pk).update(**updates)
1147
- for field, value in updates.items():
1148
- setattr(charger_obj, field, value)
1149
- elif action == "data_transfer":
1150
- vendor_id = data.get("vendorId")
1151
- if not isinstance(vendor_id, str) or not vendor_id.strip():
1152
- return JsonResponse({"detail": "vendorId required"}, status=400)
1153
- vendor_id = vendor_id.strip()
1154
- payload: dict[str, object] = {"vendorId": vendor_id}
1155
- message_identifier = ""
1156
- if "messageId" in data and data["messageId"] is not None:
1157
- message_candidate = data["messageId"]
1158
- if not isinstance(message_candidate, str):
1159
- return JsonResponse({"detail": "messageId must be a string"}, status=400)
1160
- message_identifier = message_candidate.strip()
1161
- if message_identifier:
1162
- payload["messageId"] = message_identifier
1163
- if "data" in data:
1164
- payload["data"] = data["data"]
1165
- message_id = uuid.uuid4().hex
1166
- msg = json.dumps([2, message_id, "DataTransfer", payload])
1167
- record = DataTransferMessage.objects.create(
1168
- charger=charger_obj,
1169
- connector_id=connector_value,
1170
- direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
1171
- ocpp_message_id=message_id,
1172
- vendor_id=vendor_id,
1173
- message_id=message_identifier,
1174
- payload=payload,
1175
- status="Pending",
1176
- )
1177
- async_to_sync(ws.send)(msg)
1178
- store.register_pending_call(
1179
- message_id,
1180
- {
1181
- "action": "DataTransfer",
1182
- "charger_id": cid,
1183
- "connector_id": connector_value,
1184
- "message_pk": record.pk,
1185
- "log_key": log_key,
1186
- },
1187
- )
1188
- elif action == "reset":
1189
- message_id = uuid.uuid4().hex
1190
- msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
1191
- async_to_sync(ws.send)(msg)
1192
- store.register_pending_call(
1193
- message_id,
1194
- {
1195
- "action": "Reset",
1196
- "charger_id": cid,
1197
- "connector_id": connector_value,
1198
- "log_key": log_key,
1199
- "requested_at": timezone.now(),
1200
- },
1201
- )
1202
- elif action == "trigger_message":
1203
- trigger_target = data.get("target") or data.get("triggerTarget")
1204
- if not isinstance(trigger_target, str) or not trigger_target.strip():
1205
- return JsonResponse({"detail": "target required"}, status=400)
1206
- trigger_target = trigger_target.strip()
1207
- allowed_targets = {
1208
- "BootNotification",
1209
- "DiagnosticsStatusNotification",
1210
- "FirmwareStatusNotification",
1211
- "Heartbeat",
1212
- "MeterValues",
1213
- "StatusNotification",
1214
- }
1215
- if trigger_target not in allowed_targets:
1216
- return JsonResponse({"detail": "invalid target"}, status=400)
1217
- payload: dict[str, object] = {"requestedMessage": trigger_target}
1218
- trigger_connector = None
1219
- connector_field = data.get("connectorId")
1220
- if connector_field in (None, ""):
1221
- connector_field = data.get("connector")
1222
- if connector_field in (None, "") and connector_value is not None:
1223
- connector_field = connector_value
1224
- if connector_field not in (None, ""):
1225
- try:
1226
- trigger_connector = int(connector_field)
1227
- except (TypeError, ValueError):
1228
- return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
1229
- if trigger_connector <= 0:
1230
- return JsonResponse({"detail": "connectorId must be positive"}, status=400)
1231
- payload["connectorId"] = trigger_connector
1232
- message_id = uuid.uuid4().hex
1233
- msg = json.dumps([2, message_id, "TriggerMessage", payload])
1234
- async_to_sync(ws.send)(msg)
1235
- store.register_pending_call(
1236
- message_id,
1237
- {
1238
- "action": "TriggerMessage",
1239
- "charger_id": cid,
1240
- "connector_id": connector_value,
1241
- "log_key": log_key,
1242
- "trigger_target": trigger_target,
1243
- "trigger_connector": trigger_connector,
1244
- "requested_at": timezone.now(),
1245
- },
1246
- )
1247
- else:
1248
- return JsonResponse({"detail": "unknown action"}, status=400)
1249
- log_key = store.identity_key(cid, connector_value)
1250
- store.add_log(log_key, f"< {msg}", log_type="charger")
1251
- return JsonResponse({"sent": msg})
1
+ import json
2
+ import uuid
3
+ from datetime import datetime, 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.core.paginator import Paginator
11
+ from django.contrib.auth.decorators import login_required
12
+ from django.contrib.auth.views import redirect_to_login
13
+ from django.utils.translation import gettext_lazy as _, gettext, ngettext
14
+ from django.urls import NoReverseMatch, reverse
15
+ from django.conf import settings
16
+ from django.utils import translation, timezone
17
+ from django.core.exceptions import ValidationError
18
+
19
+ from asgiref.sync import async_to_sync
20
+
21
+ from utils.api import api_login_required
22
+
23
+ from nodes.models import Node
24
+
25
+ from pages.utils import landing
26
+ from core.liveupdate import live_update
27
+
28
+ from . import store
29
+ from .models import Transaction, Charger, DataTransferMessage, RFID
30
+ from .evcs import (
31
+ _start_simulator,
32
+ _stop_simulator,
33
+ get_simulator_state,
34
+ _simulator_status_json,
35
+ )
36
+ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
37
+
38
+
39
+ CALL_ACTION_LABELS = {
40
+ "RemoteStartTransaction": _("Remote start transaction"),
41
+ "RemoteStopTransaction": _("Remote stop transaction"),
42
+ "ChangeAvailability": _("Change availability"),
43
+ "DataTransfer": _("Data transfer"),
44
+ "Reset": _("Reset"),
45
+ "TriggerMessage": _("Trigger message"),
46
+ }
47
+
48
+ CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
49
+ "RemoteStartTransaction": {"Accepted"},
50
+ "RemoteStopTransaction": {"Accepted"},
51
+ "ChangeAvailability": {"Accepted", "Scheduled"},
52
+ "DataTransfer": {"Accepted"},
53
+ "Reset": {"Accepted"},
54
+ "TriggerMessage": {"Accepted"},
55
+ }
56
+
57
+
58
+ def _format_details(value: object) -> str:
59
+ """Return a JSON representation of ``value`` suitable for error messages."""
60
+
61
+ if value in (None, ""):
62
+ return ""
63
+ if isinstance(value, str):
64
+ text = value.strip()
65
+ if text:
66
+ return text
67
+ return ""
68
+ try:
69
+ return json.dumps(value, sort_keys=True, ensure_ascii=False)
70
+ except TypeError:
71
+ return str(value)
72
+
73
+
74
+ def _evaluate_pending_call_result(
75
+ message_id: str,
76
+ ocpp_action: str,
77
+ *,
78
+ expected_statuses: set[str] | None = None,
79
+ ) -> tuple[bool, str | None, int | None]:
80
+ """Wait for a pending call result and translate failures into messages."""
81
+
82
+ action_label = CALL_ACTION_LABELS.get(ocpp_action, ocpp_action)
83
+ result = store.wait_for_pending_call(message_id, timeout=5.0)
84
+ if result is None:
85
+ detail = _("%(action)s did not receive a response from the charger.") % {
86
+ "action": action_label,
87
+ }
88
+ return False, detail, 504
89
+ if not result.get("success", True):
90
+ parts: list[str] = []
91
+ error_code = str(result.get("error_code") or "").strip()
92
+ if error_code:
93
+ parts.append(_("code=%(code)s") % {"code": error_code})
94
+ error_description = str(result.get("error_description") or "").strip()
95
+ if error_description:
96
+ parts.append(
97
+ _("description=%(description)s") % {"description": error_description}
98
+ )
99
+ error_details = result.get("error_details")
100
+ details_text = _format_details(error_details)
101
+ if details_text:
102
+ parts.append(_("details=%(details)s") % {"details": details_text})
103
+ if parts:
104
+ detail = _("%(action)s failed: %(details)s") % {
105
+ "action": action_label,
106
+ "details": ", ".join(parts),
107
+ }
108
+ else:
109
+ detail = _("%(action)s failed.") % {"action": action_label}
110
+ return False, detail, 400
111
+ payload = result.get("payload")
112
+ payload_dict = payload if isinstance(payload, dict) else {}
113
+ if expected_statuses is not None:
114
+ status_value = str(payload_dict.get("status") or "").strip()
115
+ normalized_expected = {value.casefold() for value in expected_statuses if value}
116
+ if not status_value:
117
+ detail = _("%(action)s response did not include a status.") % {
118
+ "action": action_label,
119
+ }
120
+ return False, detail, 400
121
+ if normalized_expected and status_value.casefold() not in normalized_expected:
122
+ detail = _("%(action)s rejected with status %(status)s.") % {
123
+ "action": action_label,
124
+ "status": status_value,
125
+ }
126
+ remaining = {k: v for k, v in payload_dict.items() if k != "status"}
127
+ extra = _format_details(remaining)
128
+ if extra:
129
+ detail += " " + _("Details: %(details)s") % {"details": extra}
130
+ return False, detail, 400
131
+ return True, None, None
132
+
133
+
134
+ def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
135
+ """Return connector value and normalized slug or raise 404."""
136
+
137
+ try:
138
+ value = Charger.connector_value_from_slug(slug)
139
+ except ValueError as exc: # pragma: no cover - defensive guard
140
+ raise Http404("Invalid connector") from exc
141
+ return value, Charger.connector_slug_from_value(value)
142
+
143
+
144
+ def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
145
+ """Return URL name for connector-aware routes."""
146
+
147
+ target = f"{name}-connector"
148
+ if connector_slug == Charger.AGGREGATE_CONNECTOR_SLUG:
149
+ try:
150
+ return reverse(target, args=[serial, connector_slug])
151
+ except NoReverseMatch:
152
+ return reverse(name, args=[serial])
153
+ return reverse(target, args=[serial, connector_slug])
154
+
155
+
156
+ def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
157
+ """Return charger for the requested identity, creating if necessary."""
158
+
159
+ try:
160
+ serial = Charger.validate_serial(serial)
161
+ except ValidationError as exc:
162
+ raise Http404("Charger not found") from exc
163
+ connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
164
+ if connector_value is None:
165
+ charger, _ = Charger.objects.get_or_create(
166
+ charger_id=serial,
167
+ connector_id=None,
168
+ )
169
+ else:
170
+ charger, _ = Charger.objects.get_or_create(
171
+ charger_id=serial,
172
+ connector_id=connector_value,
173
+ )
174
+ return charger, normalized_slug
175
+
176
+
177
+ def _connector_set(charger: Charger) -> list[Charger]:
178
+ """Return chargers sharing the same serial ordered for navigation."""
179
+
180
+ siblings = list(Charger.objects.filter(charger_id=charger.charger_id))
181
+ siblings.sort(key=lambda c: (c.connector_id is not None, c.connector_id or 0))
182
+ return siblings
183
+
184
+
185
+ def _visible_chargers(user):
186
+ """Return chargers visible to ``user`` on public dashboards."""
187
+
188
+ return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
189
+
190
+
191
+ def _ensure_charger_access(
192
+ user,
193
+ charger: Charger,
194
+ *,
195
+ request=None,
196
+ ) -> HttpResponse | None:
197
+ """Ensure ``user`` may view ``charger``.
198
+
199
+ Returns a redirect to the login page when authentication is required,
200
+ otherwise raises :class:`~django.http.Http404` if the charger should not be
201
+ visible to the user.
202
+ """
203
+
204
+ if charger.is_visible_to(user):
205
+ return None
206
+ if (
207
+ request is not None
208
+ and not getattr(user, "is_authenticated", False)
209
+ and charger.has_owner_scope()
210
+ ):
211
+ return redirect_to_login(
212
+ request.get_full_path(),
213
+ login_url=resolve_url(settings.LOGIN_URL),
214
+ )
215
+ raise Http404("Charger not found")
216
+
217
+
218
+ def _transaction_rfid_details(
219
+ tx_obj, *, cache: dict[str, dict[str, str | None]] | None = None
220
+ ) -> dict[str, str | None] | None:
221
+ """Return normalized RFID metadata for a transaction-like object."""
222
+
223
+ if not tx_obj:
224
+ return None
225
+ rfid_value = getattr(tx_obj, "rfid", None)
226
+ if not rfid_value:
227
+ return None
228
+ normalized = str(rfid_value).strip()
229
+ if not normalized:
230
+ return None
231
+ normalized = normalized.upper()
232
+ if cache is not None and normalized in cache:
233
+ return cache[normalized]
234
+ tag = RFID.objects.filter(rfid=normalized).only("pk").first()
235
+ rfid_url = None
236
+ if tag:
237
+ try:
238
+ rfid_url = reverse("admin:core_rfid_change", args=[tag.pk])
239
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
240
+ rfid_url = None
241
+ details = {"value": normalized, "url": rfid_url}
242
+ if cache is not None:
243
+ cache[normalized] = details
244
+ return details
245
+
246
+
247
+ def _connector_overview(
248
+ charger: Charger,
249
+ user=None,
250
+ *,
251
+ rfid_cache: dict[str, dict[str, str | None]] | None = None,
252
+ ) -> list[dict]:
253
+ """Return connector metadata used for navigation and summaries."""
254
+
255
+ overview: list[dict] = []
256
+ for sibling in _connector_set(charger):
257
+ if user is not None and not sibling.is_visible_to(user):
258
+ continue
259
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
260
+ state, color = _charger_state(sibling, tx_obj)
261
+ overview.append(
262
+ {
263
+ "charger": sibling,
264
+ "slug": sibling.connector_slug,
265
+ "label": sibling.connector_label,
266
+ "url": _reverse_connector_url(
267
+ "charger-page", sibling.charger_id, sibling.connector_slug
268
+ ),
269
+ "status": state,
270
+ "color": color,
271
+ "last_status": sibling.last_status,
272
+ "last_error_code": sibling.last_error_code,
273
+ "last_status_timestamp": sibling.last_status_timestamp,
274
+ "last_status_vendor_info": sibling.last_status_vendor_info,
275
+ "tx": tx_obj,
276
+ "rfid_details": _transaction_rfid_details(
277
+ tx_obj, cache=rfid_cache
278
+ ),
279
+ "connected": store.is_connected(
280
+ sibling.charger_id, sibling.connector_id
281
+ ),
282
+ }
283
+ )
284
+ return overview
285
+
286
+
287
+ def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
288
+ """Return active sessions grouped by connector for the charger."""
289
+
290
+ siblings = _connector_set(charger)
291
+ ordered = [c for c in siblings if c.connector_id is not None] + [
292
+ c for c in siblings if c.connector_id is None
293
+ ]
294
+ sessions: list[tuple[Charger, Transaction]] = []
295
+ seen: set[int] = set()
296
+ for sibling in ordered:
297
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
298
+ if not tx_obj:
299
+ continue
300
+ if tx_obj.pk and tx_obj.pk in seen:
301
+ continue
302
+ if tx_obj.pk:
303
+ seen.add(tx_obj.pk)
304
+ sessions.append((sibling, tx_obj))
305
+ return sessions
306
+
307
+
308
+ def _landing_page_translations() -> dict[str, dict[str, str]]:
309
+ """Return static translations used by the charger public landing page."""
310
+
311
+ catalog: dict[str, dict[str, str]] = {}
312
+ for code in ("en", "es"):
313
+ with translation.override(code):
314
+ catalog[code] = {
315
+ "serial_number_label": gettext("Serial Number"),
316
+ "connector_label": gettext("Connector"),
317
+ "advanced_view_label": gettext("Advanced View"),
318
+ "require_rfid_label": gettext("Require RFID Authorization"),
319
+ "charging_label": gettext("Charging"),
320
+ "energy_label": gettext("Energy"),
321
+ "started_label": gettext("Started"),
322
+ "rfid_label": gettext("RFID"),
323
+ "instruction_text": gettext(
324
+ "Plug in your vehicle and slide your RFID card over the reader to begin charging."
325
+ ),
326
+ "connectors_heading": gettext("Connectors"),
327
+ "no_active_transaction": gettext("No active transaction"),
328
+ "connectors_active_singular": ngettext(
329
+ "%(count)s connector active",
330
+ "%(count)s connectors active",
331
+ 1,
332
+ ),
333
+ "connectors_active_plural": ngettext(
334
+ "%(count)s connector active",
335
+ "%(count)s connectors active",
336
+ 2,
337
+ ),
338
+ "status_reported_label": gettext("Reported status"),
339
+ "status_error_label": gettext("Error code"),
340
+ "status_updated_label": gettext("Last status update"),
341
+ "status_vendor_label": gettext("Vendor"),
342
+ "status_info_label": gettext("Info"),
343
+ }
344
+ return catalog
345
+
346
+
347
+ def _has_active_session(tx_obj) -> bool:
348
+ """Return whether the provided transaction-like object is active."""
349
+
350
+ if isinstance(tx_obj, (list, tuple, set)):
351
+ return any(_has_active_session(item) for item in tx_obj)
352
+ if not tx_obj:
353
+ return False
354
+ if isinstance(tx_obj, dict):
355
+ return tx_obj.get("stop_time") is None
356
+ stop_time = getattr(tx_obj, "stop_time", None)
357
+ return stop_time is None
358
+
359
+
360
+ def _aggregate_dashboard_state(charger: Charger) -> tuple[str, str] | None:
361
+ """Return an aggregate badge for the charger when summarising connectors."""
362
+
363
+ if charger.connector_id is not None:
364
+ return None
365
+
366
+ siblings = (
367
+ Charger.objects.filter(charger_id=charger.charger_id)
368
+ .exclude(pk=charger.pk)
369
+ .exclude(connector_id__isnull=True)
370
+ )
371
+ statuses: list[str] = []
372
+ for sibling in siblings:
373
+ status_value = (sibling.last_status or "").strip()
374
+ if status_value:
375
+ statuses.append(status_value.casefold())
376
+ continue
377
+ tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
378
+ if not tx_obj:
379
+ tx_obj = (
380
+ Transaction.objects.filter(charger=sibling, stop_time__isnull=True)
381
+ .order_by("-start_time")
382
+ .first()
383
+ )
384
+ if _has_active_session(tx_obj):
385
+ statuses.append("charging")
386
+ continue
387
+ if store.is_connected(sibling.charger_id, sibling.connector_id):
388
+ statuses.append("available")
389
+
390
+ if not statuses:
391
+ return None
392
+
393
+ if any(status == "available" for status in statuses):
394
+ return STATUS_BADGE_MAP["available"]
395
+
396
+ if all(status == "charging" for status in statuses):
397
+ return STATUS_BADGE_MAP["charging"]
398
+
399
+ return None
400
+
401
+
402
+ def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
403
+ """Return human readable state and color for a charger."""
404
+
405
+ status_value = (charger.last_status or "").strip()
406
+ normalized_status = status_value.casefold() if status_value else ""
407
+
408
+ aggregate_state = _aggregate_dashboard_state(charger)
409
+ if aggregate_state is not None and normalized_status in {"", "available", "charging"}:
410
+ return aggregate_state
411
+
412
+ has_session = _has_active_session(tx_obj)
413
+ if status_value:
414
+ key = normalized_status
415
+ label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
416
+ error_code = (charger.last_error_code or "").strip()
417
+ error_code_lower = error_code.lower()
418
+ if (
419
+ has_session
420
+ and error_code_lower in ERROR_OK_VALUES
421
+ and (key not in STATUS_BADGE_MAP or key == "available")
422
+ ):
423
+ # Some stations continue reporting "Available" (or an unknown status)
424
+ # while a session is active. Override the badge so the user can see
425
+ # the charger is actually busy.
426
+ label, color = STATUS_BADGE_MAP.get("charging", (_("Charging"), "#198754"))
427
+ elif error_code and error_code_lower not in ERROR_OK_VALUES:
428
+ label = _("%(status)s (%(error)s)") % {
429
+ "status": label,
430
+ "error": error_code,
431
+ }
432
+ color = "#dc3545"
433
+ return label, color
434
+
435
+ cid = charger.charger_id
436
+ connected = store.is_connected(cid, charger.connector_id)
437
+ if connected and has_session:
438
+ return _("Charging"), "green"
439
+ if connected:
440
+ return _("Available"), "blue"
441
+ return _("Offline"), "grey"
442
+
443
+
444
+ def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
445
+ """Return diagnostics metadata for API responses."""
446
+
447
+ timestamp = (
448
+ charger.diagnostics_timestamp.isoformat()
449
+ if charger.diagnostics_timestamp
450
+ else None
451
+ )
452
+ status = charger.diagnostics_status or None
453
+ location = charger.diagnostics_location or None
454
+ return {
455
+ "diagnosticsStatus": status,
456
+ "diagnosticsTimestamp": timestamp,
457
+ "diagnosticsLocation": location,
458
+ }
459
+
460
+
461
+ @api_login_required
462
+ def charger_list(request):
463
+ """Return a JSON list of known chargers and state."""
464
+ data = []
465
+ for charger in _visible_chargers(request.user):
466
+ cid = charger.charger_id
467
+ sessions: list[tuple[Charger, Transaction]] = []
468
+ tx_obj = store.get_transaction(cid, charger.connector_id)
469
+ if charger.connector_id is None:
470
+ sessions = _live_sessions(charger)
471
+ if sessions:
472
+ tx_obj = sessions[0][1]
473
+ elif tx_obj:
474
+ sessions = [(charger, tx_obj)]
475
+ if not tx_obj:
476
+ tx_obj = (
477
+ Transaction.objects.filter(charger__charger_id=cid)
478
+ .order_by("-start_time")
479
+ .first()
480
+ )
481
+ tx_data = None
482
+ if tx_obj:
483
+ tx_data = {
484
+ "transactionId": tx_obj.pk,
485
+ "meterStart": tx_obj.meter_start,
486
+ "startTime": tx_obj.start_time.isoformat(),
487
+ }
488
+ if tx_obj.vin:
489
+ tx_data["vin"] = tx_obj.vin
490
+ if tx_obj.meter_stop is not None:
491
+ tx_data["meterStop"] = tx_obj.meter_stop
492
+ if tx_obj.stop_time is not None:
493
+ tx_data["stopTime"] = tx_obj.stop_time.isoformat()
494
+ active_transactions = []
495
+ for session_charger, session_tx in sessions:
496
+ active_payload = {
497
+ "charger_id": session_charger.charger_id,
498
+ "connector_id": session_charger.connector_id,
499
+ "connector_slug": session_charger.connector_slug,
500
+ "transactionId": session_tx.pk,
501
+ "meterStart": session_tx.meter_start,
502
+ "startTime": session_tx.start_time.isoformat(),
503
+ }
504
+ if session_tx.vin:
505
+ active_payload["vin"] = session_tx.vin
506
+ if session_tx.meter_stop is not None:
507
+ active_payload["meterStop"] = session_tx.meter_stop
508
+ if session_tx.stop_time is not None:
509
+ active_payload["stopTime"] = session_tx.stop_time.isoformat()
510
+ active_transactions.append(active_payload)
511
+ state, color = _charger_state(
512
+ charger,
513
+ tx_obj if charger.connector_id is not None else (sessions if sessions else None),
514
+ )
515
+ entry = {
516
+ "charger_id": cid,
517
+ "name": charger.name,
518
+ "connector_id": charger.connector_id,
519
+ "connector_slug": charger.connector_slug,
520
+ "connector_label": charger.connector_label,
521
+ "require_rfid": charger.require_rfid,
522
+ "transaction": tx_data,
523
+ "activeTransactions": active_transactions,
524
+ "lastHeartbeat": (
525
+ charger.last_heartbeat.isoformat()
526
+ if charger.last_heartbeat
527
+ else None
528
+ ),
529
+ "lastMeterValues": charger.last_meter_values,
530
+ "firmwareStatus": charger.firmware_status,
531
+ "firmwareStatusInfo": charger.firmware_status_info,
532
+ "firmwareTimestamp": (
533
+ charger.firmware_timestamp.isoformat()
534
+ if charger.firmware_timestamp
535
+ else None
536
+ ),
537
+ "connected": store.is_connected(cid, charger.connector_id),
538
+ "lastStatus": charger.last_status or None,
539
+ "lastErrorCode": charger.last_error_code or None,
540
+ "lastStatusTimestamp": (
541
+ charger.last_status_timestamp.isoformat()
542
+ if charger.last_status_timestamp
543
+ else None
544
+ ),
545
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
546
+ "status": state,
547
+ "statusColor": color,
548
+ }
549
+ entry.update(_diagnostics_payload(charger))
550
+ data.append(entry)
551
+ return JsonResponse({"chargers": data})
552
+
553
+
554
+ @api_login_required
555
+ def charger_detail(request, cid, connector=None):
556
+ charger, connector_slug = _get_charger(cid, connector)
557
+ access_response = _ensure_charger_access(
558
+ request.user, charger, request=request
559
+ )
560
+ if access_response is not None:
561
+ return access_response
562
+
563
+ sessions: list[tuple[Charger, Transaction]] = []
564
+ tx_obj = store.get_transaction(cid, charger.connector_id)
565
+ if charger.connector_id is None:
566
+ sessions = _live_sessions(charger)
567
+ if sessions:
568
+ tx_obj = sessions[0][1]
569
+ elif tx_obj:
570
+ sessions = [(charger, tx_obj)]
571
+ if not tx_obj:
572
+ tx_obj = (
573
+ Transaction.objects.filter(charger__charger_id=cid)
574
+ .order_by("-start_time")
575
+ .first()
576
+ )
577
+
578
+ tx_data = None
579
+ if tx_obj:
580
+ tx_data = {
581
+ "transactionId": tx_obj.pk,
582
+ "meterStart": tx_obj.meter_start,
583
+ "startTime": tx_obj.start_time.isoformat(),
584
+ }
585
+ if tx_obj.vin:
586
+ tx_data["vin"] = tx_obj.vin
587
+ if tx_obj.meter_stop is not None:
588
+ tx_data["meterStop"] = tx_obj.meter_stop
589
+ if tx_obj.stop_time is not None:
590
+ tx_data["stopTime"] = tx_obj.stop_time.isoformat()
591
+
592
+ active_transactions = []
593
+ for session_charger, session_tx in sessions:
594
+ payload = {
595
+ "charger_id": session_charger.charger_id,
596
+ "connector_id": session_charger.connector_id,
597
+ "connector_slug": session_charger.connector_slug,
598
+ "transactionId": session_tx.pk,
599
+ "meterStart": session_tx.meter_start,
600
+ "startTime": session_tx.start_time.isoformat(),
601
+ }
602
+ if session_tx.vin:
603
+ payload["vin"] = session_tx.vin
604
+ if session_tx.meter_stop is not None:
605
+ payload["meterStop"] = session_tx.meter_stop
606
+ if session_tx.stop_time is not None:
607
+ payload["stopTime"] = session_tx.stop_time.isoformat()
608
+ active_transactions.append(payload)
609
+
610
+ log_key = store.identity_key(cid, charger.connector_id)
611
+ log = store.get_logs(log_key, log_type="charger")
612
+ state, color = _charger_state(
613
+ charger,
614
+ tx_obj if charger.connector_id is not None else (sessions if sessions else None),
615
+ )
616
+ payload = {
617
+ "charger_id": cid,
618
+ "connector_id": charger.connector_id,
619
+ "connector_slug": connector_slug,
620
+ "name": charger.name,
621
+ "require_rfid": charger.require_rfid,
622
+ "transaction": tx_data,
623
+ "activeTransactions": active_transactions,
624
+ "lastHeartbeat": (
625
+ charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
626
+ ),
627
+ "lastMeterValues": charger.last_meter_values,
628
+ "firmwareStatus": charger.firmware_status,
629
+ "firmwareStatusInfo": charger.firmware_status_info,
630
+ "firmwareTimestamp": (
631
+ charger.firmware_timestamp.isoformat()
632
+ if charger.firmware_timestamp
633
+ else None
634
+ ),
635
+ "log": log,
636
+ "lastStatus": charger.last_status or None,
637
+ "lastErrorCode": charger.last_error_code or None,
638
+ "lastStatusTimestamp": (
639
+ charger.last_status_timestamp.isoformat()
640
+ if charger.last_status_timestamp
641
+ else None
642
+ ),
643
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
644
+ "status": state,
645
+ "statusColor": color,
646
+ }
647
+ payload.update(_diagnostics_payload(charger))
648
+ return JsonResponse(payload)
649
+
650
+
651
+ @landing("CPMS Online Dashboard")
652
+ @live_update()
653
+ def dashboard(request):
654
+ """Landing page listing all known chargers and their status."""
655
+ node = Node.get_local()
656
+ role = node.role if node else None
657
+ role_name = role.name if role else ""
658
+ allow_anonymous_roles = {"Constellation", "Satellite"}
659
+ if not request.user.is_authenticated and role_name not in allow_anonymous_roles:
660
+ return redirect_to_login(
661
+ request.get_full_path(), login_url=reverse("pages:login")
662
+ )
663
+ is_constellation = role_name == "Constellation"
664
+ chargers = []
665
+ for charger in _visible_chargers(request.user):
666
+ tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
667
+ if not tx_obj:
668
+ tx_obj = (
669
+ Transaction.objects.filter(charger=charger)
670
+ .order_by("-start_time")
671
+ .first()
672
+ )
673
+ state, color = _charger_state(charger, tx_obj)
674
+ chargers.append({"charger": charger, "state": state, "color": color})
675
+ scheme = "wss" if request.is_secure() else "ws"
676
+ host = request.get_host()
677
+ ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
678
+ context = {
679
+ "chargers": chargers,
680
+ "show_demo_notice": is_constellation,
681
+ "demo_ws_url": ws_url,
682
+ "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
683
+ }
684
+ return render(request, "ocpp/dashboard.html", context)
685
+
686
+
687
+ @login_required(login_url="pages:login")
688
+ @landing("Charge Point Simulator")
689
+ @live_update()
690
+ def cp_simulator(request):
691
+ """Public landing page to control the OCPP charge point simulator."""
692
+ host_header = request.get_host()
693
+ default_host, host_port = split_domain_port(host_header)
694
+ if not default_host:
695
+ default_host = "127.0.0.1"
696
+ default_ws_port = request.get_port() or host_port or "8000"
697
+ default_cp_paths = ["CP1", "CP2"]
698
+ default_serial_numbers = default_cp_paths
699
+ default_connector_id = 1
700
+ default_rfid = "FFFFFFFF"
701
+ default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
702
+
703
+ message = ""
704
+ dashboard_link: str | None = None
705
+ if request.method == "POST":
706
+ cp_idx = int(request.POST.get("cp") or 1)
707
+ action = request.POST.get("action")
708
+ if action == "start":
709
+ ws_port_value = request.POST.get("ws_port")
710
+ if ws_port_value is None:
711
+ ws_port = int(default_ws_port) if default_ws_port else None
712
+ elif ws_port_value.strip():
713
+ ws_port = int(ws_port_value)
714
+ else:
715
+ ws_port = None
716
+ sim_params = dict(
717
+ host=request.POST.get("host") or default_host,
718
+ ws_port=ws_port,
719
+ cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
720
+ serial_number=request.POST.get("serial_number")
721
+ or default_serial_numbers[cp_idx - 1],
722
+ connector_id=int(
723
+ request.POST.get("connector_id") or default_connector_id
724
+ ),
725
+ rfid=request.POST.get("rfid") or default_rfid,
726
+ vin=request.POST.get("vin") or default_vins[cp_idx - 1],
727
+ duration=int(request.POST.get("duration") or 600),
728
+ interval=float(request.POST.get("interval") or 5),
729
+ kw_min=float(request.POST.get("kw_min") or 30),
730
+ kw_max=float(request.POST.get("kw_max") or 60),
731
+ pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
732
+ repeat=request.POST.get("repeat") or False,
733
+ daemon=True,
734
+ username=request.POST.get("username") or None,
735
+ password=request.POST.get("password") or None,
736
+ )
737
+ try:
738
+ started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
739
+ if started:
740
+ message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
741
+ try:
742
+ dashboard_link = reverse(
743
+ "charger-status", args=[sim_params["cp_path"]]
744
+ )
745
+ except NoReverseMatch: # pragma: no cover - defensive
746
+ dashboard_link = None
747
+ else:
748
+ message = f"CP{cp_idx} {status}. Logs: {log_file}"
749
+ except Exception as exc: # pragma: no cover - unexpected
750
+ message = f"Failed to start CP{cp_idx}: {exc}"
751
+ elif action == "stop":
752
+ try:
753
+ _stop_simulator(cp=cp_idx)
754
+ message = f"CP{cp_idx} stop requested."
755
+ except Exception as exc: # pragma: no cover - unexpected
756
+ message = f"Failed to stop CP{cp_idx}: {exc}"
757
+ else:
758
+ message = "Unknown action."
759
+
760
+ states_dict = get_simulator_state()
761
+ state_list = [states_dict[1], states_dict[2]]
762
+ params_jsons = [
763
+ json.dumps(state_list[0].get("params", {}), indent=2),
764
+ json.dumps(state_list[1].get("params", {}), indent=2),
765
+ ]
766
+ state_jsons = [
767
+ _simulator_status_json(1),
768
+ _simulator_status_json(2),
769
+ ]
770
+
771
+ context = {
772
+ "message": message,
773
+ "dashboard_link": dashboard_link,
774
+ "states": state_list,
775
+ "default_host": default_host,
776
+ "default_ws_port": default_ws_port,
777
+ "default_cp_paths": default_cp_paths,
778
+ "default_serial_numbers": default_serial_numbers,
779
+ "default_connector_id": default_connector_id,
780
+ "default_rfid": default_rfid,
781
+ "default_vins": default_vins,
782
+ "params_jsons": params_jsons,
783
+ "state_jsons": state_jsons,
784
+ }
785
+ return render(request, "ocpp/cp_simulator.html", context)
786
+
787
+
788
+ def charger_page(request, cid, connector=None):
789
+ """Public landing page for a charger displaying usage guidance or progress."""
790
+ charger, connector_slug = _get_charger(cid, connector)
791
+ access_response = _ensure_charger_access(
792
+ request.user, charger, request=request
793
+ )
794
+ if access_response is not None:
795
+ return access_response
796
+ rfid_cache: dict[str, dict[str, str | None]] = {}
797
+ overview = _connector_overview(
798
+ charger, request.user, rfid_cache=rfid_cache
799
+ )
800
+ sessions = _live_sessions(charger)
801
+ tx = None
802
+ active_connector_count = 0
803
+ if charger.connector_id is None:
804
+ if sessions:
805
+ total_kw = 0.0
806
+ start_times = [
807
+ tx_obj.start_time for _, tx_obj in sessions if tx_obj.start_time
808
+ ]
809
+ for _, tx_obj in sessions:
810
+ if tx_obj.kw:
811
+ total_kw += tx_obj.kw
812
+ tx = SimpleNamespace(
813
+ kw=total_kw, start_time=min(start_times) if start_times else None
814
+ )
815
+ active_connector_count = len(sessions)
816
+ else:
817
+ tx = (
818
+ sessions[0][1]
819
+ if sessions
820
+ else store.get_transaction(cid, charger.connector_id)
821
+ )
822
+ if tx:
823
+ active_connector_count = 1
824
+ state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
825
+ state, color = _charger_state(charger, state_source)
826
+ language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
827
+ preferred_language = "es"
828
+ supported_languages = {code for code, _ in settings.LANGUAGES}
829
+ if preferred_language in supported_languages and not language_cookie:
830
+ translation.activate(preferred_language)
831
+ request.LANGUAGE_CODE = translation.get_language()
832
+ connector_links = [
833
+ {
834
+ "slug": item["slug"],
835
+ "label": item["label"],
836
+ "url": item["url"],
837
+ "active": item["slug"] == connector_slug,
838
+ }
839
+ for item in overview
840
+ ]
841
+ connector_overview = [
842
+ item for item in overview if item["charger"].connector_id is not None
843
+ ]
844
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
845
+ tx_rfid_details = _transaction_rfid_details(tx, cache=rfid_cache)
846
+ return render(
847
+ request,
848
+ "ocpp/charger_page.html",
849
+ {
850
+ "charger": charger,
851
+ "tx": tx,
852
+ "tx_rfid_details": tx_rfid_details,
853
+ "connector_slug": connector_slug,
854
+ "connector_links": connector_links,
855
+ "connector_overview": connector_overview,
856
+ "active_connector_count": active_connector_count,
857
+ "status_url": status_url,
858
+ "landing_translations": _landing_page_translations(),
859
+ "state": state,
860
+ "color": color,
861
+ },
862
+ )
863
+
864
+
865
+ @login_required
866
+ def charger_status(request, cid, connector=None):
867
+ charger, connector_slug = _get_charger(cid, connector)
868
+ access_response = _ensure_charger_access(
869
+ request.user, charger, request=request
870
+ )
871
+ if access_response is not None:
872
+ return access_response
873
+ session_id = request.GET.get("session")
874
+ sessions = _live_sessions(charger)
875
+ live_tx = None
876
+ if charger.connector_id is not None and sessions:
877
+ live_tx = sessions[0][1]
878
+ tx_obj = live_tx
879
+ past_session = False
880
+ if session_id:
881
+ if charger.connector_id is None:
882
+ tx_obj = get_object_or_404(
883
+ Transaction, pk=session_id, charger__charger_id=cid
884
+ )
885
+ past_session = True
886
+ elif not (live_tx and str(live_tx.pk) == session_id):
887
+ tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
888
+ past_session = True
889
+ state, color = _charger_state(
890
+ charger,
891
+ (
892
+ live_tx
893
+ if charger.connector_id is not None
894
+ else (sessions if sessions else None)
895
+ ),
896
+ )
897
+ if charger.connector_id is None:
898
+ transactions_qs = (
899
+ Transaction.objects.filter(charger__charger_id=cid)
900
+ .select_related("charger")
901
+ .order_by("-start_time")
902
+ )
903
+ else:
904
+ transactions_qs = Transaction.objects.filter(charger=charger).order_by(
905
+ "-start_time"
906
+ )
907
+ paginator = Paginator(transactions_qs, 10)
908
+ page_obj = paginator.get_page(request.GET.get("page"))
909
+ transactions = page_obj.object_list
910
+ date_view = request.GET.get("dates", "charger").lower()
911
+ if date_view not in {"charger", "received"}:
912
+ date_view = "charger"
913
+
914
+ def _date_query(mode: str) -> str:
915
+ params = request.GET.copy()
916
+ params["dates"] = mode
917
+ query = params.urlencode()
918
+ return f"?{query}" if query else ""
919
+
920
+ date_view_options = {
921
+ "charger": _("Charger timestamps"),
922
+ "received": _("Received timestamps"),
923
+ }
924
+ date_toggle_links = [
925
+ {
926
+ "mode": mode,
927
+ "label": label,
928
+ "url": _date_query(mode),
929
+ "active": mode == date_view,
930
+ }
931
+ for mode, label in date_view_options.items()
932
+ ]
933
+ chart_data = {"labels": [], "datasets": []}
934
+ pagination_params = request.GET.copy()
935
+ pagination_params["dates"] = date_view
936
+ pagination_params.pop("page", None)
937
+ pagination_query = pagination_params.urlencode()
938
+ session_params = request.GET.copy()
939
+ session_params["dates"] = date_view
940
+ session_params.pop("session", None)
941
+ session_params.pop("page", None)
942
+ session_query = session_params.urlencode()
943
+
944
+ def _series_from_transaction(tx):
945
+ points: list[tuple[str, float]] = []
946
+ readings = list(
947
+ tx.meter_values.filter(energy__isnull=False).order_by("timestamp")
948
+ )
949
+ start_val = None
950
+ if tx.meter_start is not None:
951
+ start_val = float(tx.meter_start) / 1000.0
952
+ for reading in readings:
953
+ try:
954
+ val = float(reading.energy)
955
+ except (TypeError, ValueError):
956
+ continue
957
+ if start_val is None:
958
+ start_val = val
959
+ total = val - start_val
960
+ points.append((reading.timestamp.isoformat(), max(total, 0.0)))
961
+ return points
962
+
963
+ if tx_obj and (charger.connector_id is not None or past_session):
964
+ series_points = _series_from_transaction(tx_obj)
965
+ if series_points:
966
+ chart_data["labels"] = [ts for ts, _ in series_points]
967
+ connector_id = None
968
+ if tx_obj.charger and tx_obj.charger.connector_id is not None:
969
+ connector_id = tx_obj.charger.connector_id
970
+ elif charger.connector_id is not None:
971
+ connector_id = charger.connector_id
972
+ chart_data["datasets"].append(
973
+ {
974
+ "label": str(
975
+ tx_obj.charger.connector_label
976
+ if tx_obj.charger and tx_obj.charger.connector_id is not None
977
+ else charger.connector_label
978
+ ),
979
+ "values": [value for _, value in series_points],
980
+ "connector_id": connector_id,
981
+ }
982
+ )
983
+ elif charger.connector_id is None:
984
+ dataset_points: list[tuple[str, list[tuple[str, float]], int]] = []
985
+ for sibling, sibling_tx in sessions:
986
+ if sibling.connector_id is None or not sibling_tx:
987
+ continue
988
+ points = _series_from_transaction(sibling_tx)
989
+ if not points:
990
+ continue
991
+ dataset_points.append(
992
+ (str(sibling.connector_label), points, sibling.connector_id)
993
+ )
994
+ if dataset_points:
995
+ all_labels: list[str] = sorted(
996
+ {ts for _, points, _ in dataset_points for ts, _ in points}
997
+ )
998
+ chart_data["labels"] = all_labels
999
+ for label, points, connector_id in dataset_points:
1000
+ value_map = {ts: val for ts, val in points}
1001
+ chart_data["datasets"].append(
1002
+ {
1003
+ "label": label,
1004
+ "values": [value_map.get(ts) for ts in all_labels],
1005
+ "connector_id": connector_id,
1006
+ }
1007
+ )
1008
+ overview = _connector_overview(charger, request.user)
1009
+ connector_links = [
1010
+ {
1011
+ "slug": item["slug"],
1012
+ "label": item["label"],
1013
+ "url": _reverse_connector_url("charger-status", cid, item["slug"]),
1014
+ "active": item["slug"] == connector_slug,
1015
+ }
1016
+ for item in overview
1017
+ ]
1018
+ connector_overview = [
1019
+ item for item in overview if item["charger"].connector_id is not None
1020
+ ]
1021
+ search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1022
+ configuration_url = None
1023
+ if request.user.is_staff:
1024
+ try:
1025
+ configuration_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
1026
+ except NoReverseMatch: # pragma: no cover - admin may be disabled
1027
+ configuration_url = None
1028
+ is_connected = store.is_connected(cid, charger.connector_id)
1029
+ has_active_session = bool(
1030
+ live_tx if charger.connector_id is not None else sessions
1031
+ )
1032
+ can_remote_start = (
1033
+ charger.connector_id is not None
1034
+ and is_connected
1035
+ and not has_active_session
1036
+ and not past_session
1037
+ )
1038
+ remote_start_messages = None
1039
+ if can_remote_start:
1040
+ remote_start_messages = {
1041
+ "required": str(_("RFID is required to start a session.")),
1042
+ "sending": str(_("Sending remote start request...")),
1043
+ "success": str(_("Remote start command queued.")),
1044
+ "error": str(_("Unable to send remote start request.")),
1045
+ }
1046
+ action_url = _reverse_connector_url("charger-action", cid, connector_slug)
1047
+ chart_should_animate = bool(has_active_session and not past_session)
1048
+
1049
+ return render(
1050
+ request,
1051
+ "ocpp/charger_status.html",
1052
+ {
1053
+ "charger": charger,
1054
+ "tx": tx_obj,
1055
+ "state": state,
1056
+ "color": color,
1057
+ "transactions": transactions,
1058
+ "page_obj": page_obj,
1059
+ "chart_data": chart_data,
1060
+ "past_session": past_session,
1061
+ "connector_slug": connector_slug,
1062
+ "connector_links": connector_links,
1063
+ "connector_overview": connector_overview,
1064
+ "search_url": search_url,
1065
+ "configuration_url": configuration_url,
1066
+ "page_url": _reverse_connector_url("charger-page", cid, connector_slug),
1067
+ "is_connected": is_connected,
1068
+ "is_idle": is_connected and not has_active_session,
1069
+ "can_remote_start": can_remote_start,
1070
+ "remote_start_messages": remote_start_messages,
1071
+ "action_url": action_url,
1072
+ "show_chart": bool(
1073
+ chart_data["datasets"]
1074
+ and any(
1075
+ any(value is not None for value in dataset["values"])
1076
+ for dataset in chart_data["datasets"]
1077
+ )
1078
+ ),
1079
+ "date_view": date_view,
1080
+ "date_toggle_links": date_toggle_links,
1081
+ "pagination_query": pagination_query,
1082
+ "session_query": session_query,
1083
+ "chart_should_animate": chart_should_animate,
1084
+ },
1085
+ )
1086
+
1087
+
1088
+ @login_required
1089
+ def charger_session_search(request, cid, connector=None):
1090
+ charger, connector_slug = _get_charger(cid, connector)
1091
+ access_response = _ensure_charger_access(
1092
+ request.user, charger, request=request
1093
+ )
1094
+ if access_response is not None:
1095
+ return access_response
1096
+ date_str = request.GET.get("date")
1097
+ date_view = request.GET.get("dates", "charger").lower()
1098
+ if date_view not in {"charger", "received"}:
1099
+ date_view = "charger"
1100
+
1101
+ def _date_query(mode: str) -> str:
1102
+ params = request.GET.copy()
1103
+ params["dates"] = mode
1104
+ query = params.urlencode()
1105
+ return f"?{query}" if query else ""
1106
+
1107
+ date_toggle_links = [
1108
+ {
1109
+ "mode": mode,
1110
+ "label": label,
1111
+ "url": _date_query(mode),
1112
+ "active": mode == date_view,
1113
+ }
1114
+ for mode, label in {
1115
+ "charger": _("Charger timestamps"),
1116
+ "received": _("Received timestamps"),
1117
+ }.items()
1118
+ ]
1119
+ transactions = None
1120
+ if date_str:
1121
+ try:
1122
+ date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
1123
+ start = datetime.combine(
1124
+ date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
1125
+ )
1126
+ end = start + timedelta(days=1)
1127
+ qs = Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
1128
+ if charger.connector_id is None:
1129
+ qs = qs.filter(charger__charger_id=cid)
1130
+ else:
1131
+ qs = qs.filter(charger=charger)
1132
+ transactions = qs.order_by("-start_time")
1133
+ except ValueError:
1134
+ transactions = []
1135
+ overview = _connector_overview(charger, request.user)
1136
+ connector_links = [
1137
+ {
1138
+ "slug": item["slug"],
1139
+ "label": item["label"],
1140
+ "url": _reverse_connector_url("charger-session-search", cid, item["slug"]),
1141
+ "active": item["slug"] == connector_slug,
1142
+ }
1143
+ for item in overview
1144
+ ]
1145
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
1146
+ return render(
1147
+ request,
1148
+ "ocpp/charger_session_search.html",
1149
+ {
1150
+ "charger": charger,
1151
+ "transactions": transactions,
1152
+ "date": date_str,
1153
+ "connector_slug": connector_slug,
1154
+ "connector_links": connector_links,
1155
+ "status_url": status_url,
1156
+ "date_view": date_view,
1157
+ "date_toggle_links": date_toggle_links,
1158
+ },
1159
+ )
1160
+
1161
+
1162
+ @login_required
1163
+ def charger_log_page(request, cid, connector=None):
1164
+ """Render a simple page with the log for the charger or simulator."""
1165
+ log_type = request.GET.get("type", "charger")
1166
+ connector_links = []
1167
+ connector_slug = None
1168
+ status_url = None
1169
+ if log_type == "charger":
1170
+ charger, connector_slug = _get_charger(cid, connector)
1171
+ access_response = _ensure_charger_access(
1172
+ request.user, charger, request=request
1173
+ )
1174
+ if access_response is not None:
1175
+ return access_response
1176
+ log_key = store.identity_key(cid, charger.connector_id)
1177
+ overview = _connector_overview(charger, request.user)
1178
+ connector_links = [
1179
+ {
1180
+ "slug": item["slug"],
1181
+ "label": item["label"],
1182
+ "url": _reverse_connector_url("charger-log", cid, item["slug"]),
1183
+ "active": item["slug"] == connector_slug,
1184
+ }
1185
+ for item in overview
1186
+ ]
1187
+ target_id = log_key
1188
+ status_url = _reverse_connector_url("charger-status", cid, connector_slug)
1189
+ else:
1190
+ charger = Charger.objects.filter(charger_id=cid).first() or Charger(
1191
+ charger_id=cid
1192
+ )
1193
+ target_id = cid
1194
+ log = store.get_logs(target_id, log_type=log_type)
1195
+ return render(
1196
+ request,
1197
+ "ocpp/charger_logs.html",
1198
+ {
1199
+ "charger": charger,
1200
+ "log": log,
1201
+ "log_type": log_type,
1202
+ "connector_slug": connector_slug,
1203
+ "connector_links": connector_links,
1204
+ "status_url": status_url,
1205
+ },
1206
+ )
1207
+
1208
+
1209
+ @csrf_exempt
1210
+ @api_login_required
1211
+ def dispatch_action(request, cid, connector=None):
1212
+ connector_value, _ = _normalize_connector_slug(connector)
1213
+ log_key = store.identity_key(cid, connector_value)
1214
+ if connector_value is None:
1215
+ charger_obj = (
1216
+ Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
1217
+ .order_by("pk")
1218
+ .first()
1219
+ )
1220
+ else:
1221
+ charger_obj = (
1222
+ Charger.objects.filter(charger_id=cid, connector_id=connector_value)
1223
+ .order_by("pk")
1224
+ .first()
1225
+ )
1226
+ if charger_obj is None:
1227
+ if connector_value is None:
1228
+ charger_obj, _ = Charger.objects.get_or_create(
1229
+ charger_id=cid, connector_id=None
1230
+ )
1231
+ else:
1232
+ charger_obj, _ = Charger.objects.get_or_create(
1233
+ charger_id=cid, connector_id=connector_value
1234
+ )
1235
+
1236
+ access_response = _ensure_charger_access(
1237
+ request.user, charger_obj, request=request
1238
+ )
1239
+ if access_response is not None:
1240
+ return access_response
1241
+ ws = store.get_connection(cid, connector_value)
1242
+ if ws is None:
1243
+ return JsonResponse({"detail": "no connection"}, status=404)
1244
+ try:
1245
+ data = json.loads(request.body.decode()) if request.body else {}
1246
+ except json.JSONDecodeError:
1247
+ data = {}
1248
+ action = data.get("action")
1249
+ message_id: str | None = None
1250
+ ocpp_action: str | None = None
1251
+ expected_statuses: set[str] | None = None
1252
+ msg: str | None = None
1253
+ if action == "remote_stop":
1254
+ tx_obj = store.get_transaction(cid, connector_value)
1255
+ if not tx_obj:
1256
+ return JsonResponse({"detail": "no transaction"}, status=404)
1257
+ message_id = uuid.uuid4().hex
1258
+ ocpp_action = "RemoteStopTransaction"
1259
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1260
+ msg = json.dumps(
1261
+ [
1262
+ 2,
1263
+ message_id,
1264
+ "RemoteStopTransaction",
1265
+ {"transactionId": tx_obj.pk},
1266
+ ]
1267
+ )
1268
+ async_to_sync(ws.send)(msg)
1269
+ store.register_pending_call(
1270
+ message_id,
1271
+ {
1272
+ "action": "RemoteStopTransaction",
1273
+ "charger_id": cid,
1274
+ "connector_id": connector_value,
1275
+ "log_key": log_key,
1276
+ "transaction_id": tx_obj.pk,
1277
+ "requested_at": timezone.now(),
1278
+ },
1279
+ )
1280
+ elif action == "remote_start":
1281
+ id_tag = data.get("idTag")
1282
+ if not isinstance(id_tag, str) or not id_tag.strip():
1283
+ return JsonResponse({"detail": "idTag required"}, status=400)
1284
+ id_tag = id_tag.strip()
1285
+ payload: dict[str, object] = {"idTag": id_tag}
1286
+ connector_id = data.get("connectorId")
1287
+ if connector_id in ("", None):
1288
+ connector_id = None
1289
+ if connector_id is None and connector_value is not None:
1290
+ connector_id = connector_value
1291
+ if connector_id is not None:
1292
+ try:
1293
+ payload["connectorId"] = int(connector_id)
1294
+ except (TypeError, ValueError):
1295
+ payload["connectorId"] = connector_id
1296
+ if "chargingProfile" in data and data["chargingProfile"] is not None:
1297
+ payload["chargingProfile"] = data["chargingProfile"]
1298
+ message_id = uuid.uuid4().hex
1299
+ ocpp_action = "RemoteStartTransaction"
1300
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1301
+ msg = json.dumps(
1302
+ [
1303
+ 2,
1304
+ message_id,
1305
+ "RemoteStartTransaction",
1306
+ payload,
1307
+ ]
1308
+ )
1309
+ async_to_sync(ws.send)(msg)
1310
+ store.register_pending_call(
1311
+ message_id,
1312
+ {
1313
+ "action": "RemoteStartTransaction",
1314
+ "charger_id": cid,
1315
+ "connector_id": connector_value,
1316
+ "log_key": log_key,
1317
+ "id_tag": id_tag,
1318
+ "requested_at": timezone.now(),
1319
+ },
1320
+ )
1321
+ elif action == "change_availability":
1322
+ availability_type = data.get("type")
1323
+ if availability_type not in {"Operative", "Inoperative"}:
1324
+ return JsonResponse({"detail": "invalid availability type"}, status=400)
1325
+ connector_payload = connector_value if connector_value is not None else 0
1326
+ if "connectorId" in data:
1327
+ candidate = data.get("connectorId")
1328
+ if candidate not in (None, ""):
1329
+ try:
1330
+ connector_payload = int(candidate)
1331
+ except (TypeError, ValueError):
1332
+ connector_payload = candidate
1333
+ message_id = uuid.uuid4().hex
1334
+ ocpp_action = "ChangeAvailability"
1335
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1336
+ payload = {"connectorId": connector_payload, "type": availability_type}
1337
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1338
+ async_to_sync(ws.send)(msg)
1339
+ requested_at = timezone.now()
1340
+ store.register_pending_call(
1341
+ message_id,
1342
+ {
1343
+ "action": "ChangeAvailability",
1344
+ "charger_id": cid,
1345
+ "connector_id": connector_value,
1346
+ "availability_type": availability_type,
1347
+ "requested_at": requested_at,
1348
+ },
1349
+ )
1350
+ if charger_obj:
1351
+ updates = {
1352
+ "availability_requested_state": availability_type,
1353
+ "availability_requested_at": requested_at,
1354
+ "availability_request_status": "",
1355
+ "availability_request_status_at": None,
1356
+ "availability_request_details": "",
1357
+ }
1358
+ Charger.objects.filter(pk=charger_obj.pk).update(**updates)
1359
+ for field, value in updates.items():
1360
+ setattr(charger_obj, field, value)
1361
+ elif action == "data_transfer":
1362
+ vendor_id = data.get("vendorId")
1363
+ if not isinstance(vendor_id, str) or not vendor_id.strip():
1364
+ return JsonResponse({"detail": "vendorId required"}, status=400)
1365
+ vendor_id = vendor_id.strip()
1366
+ payload: dict[str, object] = {"vendorId": vendor_id}
1367
+ message_identifier = ""
1368
+ if "messageId" in data and data["messageId"] is not None:
1369
+ message_candidate = data["messageId"]
1370
+ if not isinstance(message_candidate, str):
1371
+ return JsonResponse({"detail": "messageId must be a string"}, status=400)
1372
+ message_identifier = message_candidate.strip()
1373
+ if message_identifier:
1374
+ payload["messageId"] = message_identifier
1375
+ if "data" in data:
1376
+ payload["data"] = data["data"]
1377
+ message_id = uuid.uuid4().hex
1378
+ ocpp_action = "DataTransfer"
1379
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1380
+ msg = json.dumps([2, message_id, "DataTransfer", payload])
1381
+ record = DataTransferMessage.objects.create(
1382
+ charger=charger_obj,
1383
+ connector_id=connector_value,
1384
+ direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
1385
+ ocpp_message_id=message_id,
1386
+ vendor_id=vendor_id,
1387
+ message_id=message_identifier,
1388
+ payload=payload,
1389
+ status="Pending",
1390
+ )
1391
+ async_to_sync(ws.send)(msg)
1392
+ store.register_pending_call(
1393
+ message_id,
1394
+ {
1395
+ "action": "DataTransfer",
1396
+ "charger_id": cid,
1397
+ "connector_id": connector_value,
1398
+ "message_pk": record.pk,
1399
+ "log_key": log_key,
1400
+ },
1401
+ )
1402
+ elif action == "reset":
1403
+ message_id = uuid.uuid4().hex
1404
+ ocpp_action = "Reset"
1405
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1406
+ msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
1407
+ async_to_sync(ws.send)(msg)
1408
+ store.register_pending_call(
1409
+ message_id,
1410
+ {
1411
+ "action": "Reset",
1412
+ "charger_id": cid,
1413
+ "connector_id": connector_value,
1414
+ "log_key": log_key,
1415
+ "requested_at": timezone.now(),
1416
+ },
1417
+ )
1418
+ elif action == "trigger_message":
1419
+ trigger_target = data.get("target") or data.get("triggerTarget")
1420
+ if not isinstance(trigger_target, str) or not trigger_target.strip():
1421
+ return JsonResponse({"detail": "target required"}, status=400)
1422
+ trigger_target = trigger_target.strip()
1423
+ allowed_targets = {
1424
+ "BootNotification",
1425
+ "DiagnosticsStatusNotification",
1426
+ "FirmwareStatusNotification",
1427
+ "Heartbeat",
1428
+ "MeterValues",
1429
+ "StatusNotification",
1430
+ }
1431
+ if trigger_target not in allowed_targets:
1432
+ return JsonResponse({"detail": "invalid target"}, status=400)
1433
+ payload: dict[str, object] = {"requestedMessage": trigger_target}
1434
+ trigger_connector = None
1435
+ connector_field = data.get("connectorId")
1436
+ if connector_field in (None, ""):
1437
+ connector_field = data.get("connector")
1438
+ if connector_field in (None, "") and connector_value is not None:
1439
+ connector_field = connector_value
1440
+ if connector_field not in (None, ""):
1441
+ try:
1442
+ trigger_connector = int(connector_field)
1443
+ except (TypeError, ValueError):
1444
+ return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
1445
+ if trigger_connector <= 0:
1446
+ return JsonResponse({"detail": "connectorId must be positive"}, status=400)
1447
+ payload["connectorId"] = trigger_connector
1448
+ message_id = uuid.uuid4().hex
1449
+ ocpp_action = "TriggerMessage"
1450
+ expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
1451
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
1452
+ async_to_sync(ws.send)(msg)
1453
+ store.register_pending_call(
1454
+ message_id,
1455
+ {
1456
+ "action": "TriggerMessage",
1457
+ "charger_id": cid,
1458
+ "connector_id": connector_value,
1459
+ "log_key": log_key,
1460
+ "trigger_target": trigger_target,
1461
+ "trigger_connector": trigger_connector,
1462
+ "requested_at": timezone.now(),
1463
+ },
1464
+ )
1465
+ else:
1466
+ return JsonResponse({"detail": "unknown action"}, status=400)
1467
+ log_key = store.identity_key(cid, connector_value)
1468
+ if msg is None or message_id is None or ocpp_action is None:
1469
+ return JsonResponse({"detail": "unknown action"}, status=400)
1470
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1471
+ expected_statuses = expected_statuses or CALL_EXPECTED_STATUSES.get(ocpp_action)
1472
+ success, detail, status_code = _evaluate_pending_call_result(
1473
+ message_id,
1474
+ ocpp_action,
1475
+ expected_statuses=expected_statuses,
1476
+ )
1477
+ if not success:
1478
+ return JsonResponse({"detail": detail}, status=status_code or 400)
1479
+ return JsonResponse({"sent": msg})