arthexis 0.1.12__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.12.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.12.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 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  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 -716
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2772
  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 -0
  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 -2672
  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 -350
  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 -1511
  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 -1382
  56. core/widgets.py +213 -51
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -898
  60. nodes/apps.py +87 -70
  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 -1416
  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 -2497
  71. nodes/urls.py +15 -13
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -451
  74. ocpp/admin.py +948 -804
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1342
  77. ocpp/evcs.py +844 -931
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -915
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -9
  82. ocpp/simulator.py +745 -724
  83. ocpp/status_display.py +26 -0
  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 -3485
  89. ocpp/transactions_io.py +189 -179
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1151
  92. pages/admin.py +708 -536
  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 -169
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2083
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1120
  104. arthexis-0.1.12.dist-info/RECORD +0 -102
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
ocpp/views.py CHANGED
@@ -1,1151 +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
30
- from .evcs import (
31
- _start_simulator,
32
- _stop_simulator,
33
- get_simulator_state,
34
- _simulator_status_json,
35
- )
36
-
37
-
38
- def _normalize_connector_slug(slug: str | None) -> tuple[int | None, str]:
39
- """Return connector value and normalized slug or raise 404."""
40
-
41
- try:
42
- value = Charger.connector_value_from_slug(slug)
43
- except ValueError as exc: # pragma: no cover - defensive guard
44
- raise Http404("Invalid connector") from exc
45
- return value, Charger.connector_slug_from_value(value)
46
-
47
-
48
- def _reverse_connector_url(name: str, serial: str, connector_slug: str) -> str:
49
- """Return URL name for connector-aware routes."""
50
-
51
- target = f"{name}-connector"
52
- if connector_slug == Charger.AGGREGATE_CONNECTOR_SLUG:
53
- try:
54
- return reverse(target, args=[serial, connector_slug])
55
- except NoReverseMatch:
56
- return reverse(name, args=[serial])
57
- return reverse(target, args=[serial, connector_slug])
58
-
59
-
60
- def _get_charger(serial: str, connector_slug: str | None) -> tuple[Charger, str]:
61
- """Return charger for the requested identity, creating if necessary."""
62
-
63
- try:
64
- serial = Charger.validate_serial(serial)
65
- except ValidationError as exc:
66
- raise Http404("Charger not found") from exc
67
- connector_value, normalized_slug = _normalize_connector_slug(connector_slug)
68
- if connector_value is None:
69
- charger, _ = Charger.objects.get_or_create(
70
- charger_id=serial,
71
- connector_id=None,
72
- )
73
- else:
74
- charger, _ = Charger.objects.get_or_create(
75
- charger_id=serial,
76
- connector_id=connector_value,
77
- )
78
- return charger, normalized_slug
79
-
80
-
81
- def _connector_set(charger: Charger) -> list[Charger]:
82
- """Return chargers sharing the same serial ordered for navigation."""
83
-
84
- siblings = list(Charger.objects.filter(charger_id=charger.charger_id))
85
- siblings.sort(key=lambda c: (c.connector_id is not None, c.connector_id or 0))
86
- return siblings
87
-
88
-
89
- def _visible_chargers(user):
90
- """Return chargers visible to ``user`` on public dashboards."""
91
-
92
- return Charger.visible_for_user(user).prefetch_related("owner_users", "owner_groups")
93
-
94
-
95
- def _ensure_charger_access(user, charger: Charger):
96
- """Raise 404 when the user cannot view the charger."""
97
-
98
- if not charger.is_visible_to(user):
99
- raise Http404("Charger not found")
100
-
101
-
102
- def _connector_overview(charger: Charger, user=None) -> list[dict]:
103
- """Return connector metadata used for navigation and summaries."""
104
-
105
- overview: list[dict] = []
106
- for sibling in _connector_set(charger):
107
- if user is not None and not sibling.is_visible_to(user):
108
- continue
109
- tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
110
- state, color = _charger_state(sibling, tx_obj)
111
- overview.append(
112
- {
113
- "charger": sibling,
114
- "slug": sibling.connector_slug,
115
- "label": sibling.connector_label,
116
- "url": _reverse_connector_url(
117
- "charger-page", sibling.charger_id, sibling.connector_slug
118
- ),
119
- "status": state,
120
- "color": color,
121
- "last_status": sibling.last_status,
122
- "last_error_code": sibling.last_error_code,
123
- "last_status_timestamp": sibling.last_status_timestamp,
124
- "last_status_vendor_info": sibling.last_status_vendor_info,
125
- "tx": tx_obj,
126
- "connected": store.is_connected(
127
- sibling.charger_id, sibling.connector_id
128
- ),
129
- }
130
- )
131
- return overview
132
-
133
-
134
- def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
135
- """Return active sessions grouped by connector for the charger."""
136
-
137
- siblings = _connector_set(charger)
138
- ordered = [c for c in siblings if c.connector_id is not None] + [
139
- c for c in siblings if c.connector_id is None
140
- ]
141
- sessions: list[tuple[Charger, Transaction]] = []
142
- seen: set[int] = set()
143
- for sibling in ordered:
144
- tx_obj = store.get_transaction(sibling.charger_id, sibling.connector_id)
145
- if not tx_obj:
146
- continue
147
- if tx_obj.pk and tx_obj.pk in seen:
148
- continue
149
- if tx_obj.pk:
150
- seen.add(tx_obj.pk)
151
- sessions.append((sibling, tx_obj))
152
- return sessions
153
-
154
-
155
- def _landing_page_translations() -> dict[str, dict[str, str]]:
156
- """Return static translations used by the charger public landing page."""
157
-
158
- catalog: dict[str, dict[str, str]] = {}
159
- for code in ("en", "es"):
160
- with translation.override(code):
161
- catalog[code] = {
162
- "serial_number_label": gettext("Serial Number"),
163
- "connector_label": gettext("Connector"),
164
- "advanced_view_label": gettext("Advanced View"),
165
- "require_rfid_label": gettext("Require RFID Authorization"),
166
- "charging_label": gettext("Charging"),
167
- "energy_label": gettext("Energy"),
168
- "started_label": gettext("Started"),
169
- "instruction_text": gettext(
170
- "Plug in your vehicle and slide your RFID card over the reader to begin charging."
171
- ),
172
- "connectors_heading": gettext("Connectors"),
173
- "no_active_transaction": gettext("No active transaction"),
174
- "connectors_active_singular": ngettext(
175
- "%(count)s connector active",
176
- "%(count)s connectors active",
177
- 1,
178
- ),
179
- "connectors_active_plural": ngettext(
180
- "%(count)s connector active",
181
- "%(count)s connectors active",
182
- 2,
183
- ),
184
- "status_reported_label": gettext("Reported status"),
185
- "status_error_label": gettext("Error code"),
186
- "status_updated_label": gettext("Last status update"),
187
- "status_vendor_label": gettext("Vendor"),
188
- "status_info_label": gettext("Info"),
189
- }
190
- return catalog
191
-
192
-
193
- STATUS_BADGE_MAP: dict[str, tuple[str, str]] = {
194
- "available": (_("Available"), "#0d6efd"),
195
- "preparing": (_("Preparing"), "#0d6efd"),
196
- "charging": (_("Charging"), "#198754"),
197
- "suspendedevse": (_("Suspended (EVSE)"), "#fd7e14"),
198
- "suspendedev": (_("Suspended (EV)"), "#fd7e14"),
199
- "finishing": (_("Finishing"), "#20c997"),
200
- "faulted": (_("Faulted"), "#dc3545"),
201
- "unavailable": (_("Unavailable"), "#6c757d"),
202
- "reserved": (_("Reserved"), "#6f42c1"),
203
- "occupied": (_("Occupied"), "#0dcaf0"),
204
- "outofservice": (_("Out of Service"), "#6c757d"),
205
- }
206
-
207
- _ERROR_OK_VALUES = {"", "noerror", "no_error"}
208
-
209
-
210
- def _charger_state(charger: Charger, tx_obj: Transaction | list | None):
211
- """Return human readable state and color for a charger."""
212
-
213
- status_value = (charger.last_status or "").strip()
214
- if status_value:
215
- key = status_value.lower()
216
- label, color = STATUS_BADGE_MAP.get(key, (status_value, "#0d6efd"))
217
- error_code = (charger.last_error_code or "").strip()
218
- if error_code and error_code.lower() not in _ERROR_OK_VALUES:
219
- label = _("%(status)s (%(error)s)") % {
220
- "status": label,
221
- "error": error_code,
222
- }
223
- color = "#dc3545"
224
- return label, color
225
-
226
- cid = charger.charger_id
227
- connected = store.is_connected(cid, charger.connector_id)
228
- has_session = bool(tx_obj)
229
- if connected and has_session:
230
- return _("Charging"), "green"
231
- if connected:
232
- return _("Available"), "blue"
233
- return _("Offline"), "grey"
234
-
235
-
236
- def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
237
- """Return diagnostics metadata for API responses."""
238
-
239
- timestamp = (
240
- charger.diagnostics_timestamp.isoformat()
241
- if charger.diagnostics_timestamp
242
- else None
243
- )
244
- status = charger.diagnostics_status or None
245
- location = charger.diagnostics_location or None
246
- return {
247
- "diagnosticsStatus": status,
248
- "diagnosticsTimestamp": timestamp,
249
- "diagnosticsLocation": location,
250
- }
251
-
252
-
253
- @api_login_required
254
- def charger_list(request):
255
- """Return a JSON list of known chargers and state."""
256
- data = []
257
- for charger in _visible_chargers(request.user):
258
- cid = charger.charger_id
259
- sessions: list[tuple[Charger, Transaction]] = []
260
- tx_obj = store.get_transaction(cid, charger.connector_id)
261
- if charger.connector_id is None:
262
- sessions = _live_sessions(charger)
263
- if sessions:
264
- tx_obj = sessions[0][1]
265
- elif tx_obj:
266
- sessions = [(charger, tx_obj)]
267
- if not tx_obj:
268
- tx_obj = (
269
- Transaction.objects.filter(charger__charger_id=cid)
270
- .order_by("-start_time")
271
- .first()
272
- )
273
- tx_data = None
274
- if tx_obj:
275
- tx_data = {
276
- "transactionId": tx_obj.pk,
277
- "meterStart": tx_obj.meter_start,
278
- "startTime": tx_obj.start_time.isoformat(),
279
- }
280
- if tx_obj.vin:
281
- tx_data["vin"] = tx_obj.vin
282
- if tx_obj.meter_stop is not None:
283
- tx_data["meterStop"] = tx_obj.meter_stop
284
- if tx_obj.stop_time is not None:
285
- tx_data["stopTime"] = tx_obj.stop_time.isoformat()
286
- active_transactions = []
287
- for session_charger, session_tx in sessions:
288
- active_payload = {
289
- "charger_id": session_charger.charger_id,
290
- "connector_id": session_charger.connector_id,
291
- "connector_slug": session_charger.connector_slug,
292
- "transactionId": session_tx.pk,
293
- "meterStart": session_tx.meter_start,
294
- "startTime": session_tx.start_time.isoformat(),
295
- }
296
- if session_tx.vin:
297
- active_payload["vin"] = session_tx.vin
298
- if session_tx.meter_stop is not None:
299
- active_payload["meterStop"] = session_tx.meter_stop
300
- if session_tx.stop_time is not None:
301
- active_payload["stopTime"] = session_tx.stop_time.isoformat()
302
- active_transactions.append(active_payload)
303
- state, color = _charger_state(
304
- charger,
305
- tx_obj if charger.connector_id is not None else (sessions if sessions else None),
306
- )
307
- entry = {
308
- "charger_id": cid,
309
- "name": charger.name,
310
- "connector_id": charger.connector_id,
311
- "connector_slug": charger.connector_slug,
312
- "connector_label": charger.connector_label,
313
- "require_rfid": charger.require_rfid,
314
- "transaction": tx_data,
315
- "activeTransactions": active_transactions,
316
- "lastHeartbeat": (
317
- charger.last_heartbeat.isoformat()
318
- if charger.last_heartbeat
319
- else None
320
- ),
321
- "lastMeterValues": charger.last_meter_values,
322
- "firmwareStatus": charger.firmware_status,
323
- "firmwareStatusInfo": charger.firmware_status_info,
324
- "firmwareTimestamp": (
325
- charger.firmware_timestamp.isoformat()
326
- if charger.firmware_timestamp
327
- else None
328
- ),
329
- "connected": store.is_connected(cid, charger.connector_id),
330
- "lastStatus": charger.last_status or None,
331
- "lastErrorCode": charger.last_error_code or None,
332
- "lastStatusTimestamp": (
333
- charger.last_status_timestamp.isoformat()
334
- if charger.last_status_timestamp
335
- else None
336
- ),
337
- "lastStatusVendorInfo": charger.last_status_vendor_info,
338
- "status": state,
339
- "statusColor": color,
340
- }
341
- entry.update(_diagnostics_payload(charger))
342
- data.append(entry)
343
- return JsonResponse({"chargers": data})
344
-
345
-
346
- @api_login_required
347
- def charger_detail(request, cid, connector=None):
348
- charger, connector_slug = _get_charger(cid, connector)
349
- _ensure_charger_access(request.user, charger)
350
-
351
- sessions: list[tuple[Charger, Transaction]] = []
352
- tx_obj = store.get_transaction(cid, charger.connector_id)
353
- if charger.connector_id is None:
354
- sessions = _live_sessions(charger)
355
- if sessions:
356
- tx_obj = sessions[0][1]
357
- elif tx_obj:
358
- sessions = [(charger, tx_obj)]
359
- if not tx_obj:
360
- tx_obj = (
361
- Transaction.objects.filter(charger__charger_id=cid)
362
- .order_by("-start_time")
363
- .first()
364
- )
365
-
366
- tx_data = None
367
- if tx_obj:
368
- tx_data = {
369
- "transactionId": tx_obj.pk,
370
- "meterStart": tx_obj.meter_start,
371
- "startTime": tx_obj.start_time.isoformat(),
372
- }
373
- if tx_obj.vin:
374
- tx_data["vin"] = tx_obj.vin
375
- if tx_obj.meter_stop is not None:
376
- tx_data["meterStop"] = tx_obj.meter_stop
377
- if tx_obj.stop_time is not None:
378
- tx_data["stopTime"] = tx_obj.stop_time.isoformat()
379
-
380
- active_transactions = []
381
- for session_charger, session_tx in sessions:
382
- payload = {
383
- "charger_id": session_charger.charger_id,
384
- "connector_id": session_charger.connector_id,
385
- "connector_slug": session_charger.connector_slug,
386
- "transactionId": session_tx.pk,
387
- "meterStart": session_tx.meter_start,
388
- "startTime": session_tx.start_time.isoformat(),
389
- }
390
- if session_tx.vin:
391
- payload["vin"] = session_tx.vin
392
- if session_tx.meter_stop is not None:
393
- payload["meterStop"] = session_tx.meter_stop
394
- if session_tx.stop_time is not None:
395
- payload["stopTime"] = session_tx.stop_time.isoformat()
396
- active_transactions.append(payload)
397
-
398
- log_key = store.identity_key(cid, charger.connector_id)
399
- log = store.get_logs(log_key, log_type="charger")
400
- state, color = _charger_state(
401
- charger,
402
- tx_obj if charger.connector_id is not None else (sessions if sessions else None),
403
- )
404
- payload = {
405
- "charger_id": cid,
406
- "connector_id": charger.connector_id,
407
- "connector_slug": connector_slug,
408
- "name": charger.name,
409
- "require_rfid": charger.require_rfid,
410
- "transaction": tx_data,
411
- "activeTransactions": active_transactions,
412
- "lastHeartbeat": (
413
- charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
414
- ),
415
- "lastMeterValues": charger.last_meter_values,
416
- "firmwareStatus": charger.firmware_status,
417
- "firmwareStatusInfo": charger.firmware_status_info,
418
- "firmwareTimestamp": (
419
- charger.firmware_timestamp.isoformat()
420
- if charger.firmware_timestamp
421
- else None
422
- ),
423
- "log": log,
424
- "lastStatus": charger.last_status or None,
425
- "lastErrorCode": charger.last_error_code or None,
426
- "lastStatusTimestamp": (
427
- charger.last_status_timestamp.isoformat()
428
- if charger.last_status_timestamp
429
- else None
430
- ),
431
- "lastStatusVendorInfo": charger.last_status_vendor_info,
432
- "status": state,
433
- "statusColor": color,
434
- }
435
- payload.update(_diagnostics_payload(charger))
436
- return JsonResponse(payload)
437
-
438
-
439
- @landing("CPMS Online Dashboard")
440
- @live_update()
441
- def dashboard(request):
442
- """Landing page listing all known chargers and their status."""
443
- node = Node.get_local()
444
- role = node.role if node else None
445
- is_constellation = bool(role and role.name == "Constellation")
446
- if not request.user.is_authenticated and not is_constellation:
447
- return redirect_to_login(
448
- request.get_full_path(), login_url=reverse("pages:login")
449
- )
450
- chargers = []
451
- for charger in _visible_chargers(request.user):
452
- tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
453
- if not tx_obj:
454
- tx_obj = (
455
- Transaction.objects.filter(charger=charger)
456
- .order_by("-start_time")
457
- .first()
458
- )
459
- state, color = _charger_state(charger, tx_obj)
460
- chargers.append({"charger": charger, "state": state, "color": color})
461
- scheme = "wss" if request.is_secure() else "ws"
462
- host = request.get_host()
463
- ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
464
- context = {
465
- "chargers": chargers,
466
- "show_demo_notice": is_constellation,
467
- "demo_ws_url": ws_url,
468
- "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
469
- }
470
- return render(request, "ocpp/dashboard.html", context)
471
-
472
-
473
- @login_required(login_url="pages:login")
474
- @landing("Charge Point Simulator")
475
- @live_update()
476
- def cp_simulator(request):
477
- """Public landing page to control the OCPP charge point simulator."""
478
- host_header = request.get_host()
479
- default_host, host_port = split_domain_port(host_header)
480
- if not default_host:
481
- default_host = "127.0.0.1"
482
- default_ws_port = request.get_port() or host_port or "8000"
483
- default_cp_paths = ["CP1", "CP2"]
484
- default_serial_numbers = default_cp_paths
485
- default_connector_id = 1
486
- default_rfid = "FFFFFFFF"
487
- default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
488
-
489
- message = ""
490
- dashboard_link: str | None = None
491
- if request.method == "POST":
492
- cp_idx = int(request.POST.get("cp") or 1)
493
- action = request.POST.get("action")
494
- if action == "start":
495
- ws_port_value = request.POST.get("ws_port")
496
- if ws_port_value is None:
497
- ws_port = int(default_ws_port) if default_ws_port else None
498
- elif ws_port_value.strip():
499
- ws_port = int(ws_port_value)
500
- else:
501
- ws_port = None
502
- sim_params = dict(
503
- host=request.POST.get("host") or default_host,
504
- ws_port=ws_port,
505
- cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
506
- serial_number=request.POST.get("serial_number")
507
- or default_serial_numbers[cp_idx - 1],
508
- connector_id=int(
509
- request.POST.get("connector_id") or default_connector_id
510
- ),
511
- rfid=request.POST.get("rfid") or default_rfid,
512
- vin=request.POST.get("vin") or default_vins[cp_idx - 1],
513
- duration=int(request.POST.get("duration") or 600),
514
- interval=float(request.POST.get("interval") or 5),
515
- kw_min=float(request.POST.get("kw_min") or 30),
516
- kw_max=float(request.POST.get("kw_max") or 60),
517
- pre_charge_delay=float(request.POST.get("pre_charge_delay") or 0),
518
- repeat=request.POST.get("repeat") or False,
519
- daemon=True,
520
- username=request.POST.get("username") or None,
521
- password=request.POST.get("password") or None,
522
- )
523
- try:
524
- started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
525
- if started:
526
- message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
527
- try:
528
- dashboard_link = reverse(
529
- "charger-status", args=[sim_params["cp_path"]]
530
- )
531
- except NoReverseMatch: # pragma: no cover - defensive
532
- dashboard_link = None
533
- else:
534
- message = f"CP{cp_idx} {status}. Logs: {log_file}"
535
- except Exception as exc: # pragma: no cover - unexpected
536
- message = f"Failed to start CP{cp_idx}: {exc}"
537
- elif action == "stop":
538
- try:
539
- _stop_simulator(cp=cp_idx)
540
- message = f"CP{cp_idx} stop requested."
541
- except Exception as exc: # pragma: no cover - unexpected
542
- message = f"Failed to stop CP{cp_idx}: {exc}"
543
- else:
544
- message = "Unknown action."
545
-
546
- states_dict = get_simulator_state()
547
- state_list = [states_dict[1], states_dict[2]]
548
- params_jsons = [
549
- json.dumps(state_list[0].get("params", {}), indent=2),
550
- json.dumps(state_list[1].get("params", {}), indent=2),
551
- ]
552
- state_jsons = [
553
- _simulator_status_json(1),
554
- _simulator_status_json(2),
555
- ]
556
-
557
- context = {
558
- "message": message,
559
- "dashboard_link": dashboard_link,
560
- "states": state_list,
561
- "default_host": default_host,
562
- "default_ws_port": default_ws_port,
563
- "default_cp_paths": default_cp_paths,
564
- "default_serial_numbers": default_serial_numbers,
565
- "default_connector_id": default_connector_id,
566
- "default_rfid": default_rfid,
567
- "default_vins": default_vins,
568
- "params_jsons": params_jsons,
569
- "state_jsons": state_jsons,
570
- }
571
- return render(request, "ocpp/cp_simulator.html", context)
572
-
573
-
574
- def charger_page(request, cid, connector=None):
575
- """Public landing page for a charger displaying usage guidance or progress."""
576
- charger, connector_slug = _get_charger(cid, connector)
577
- _ensure_charger_access(request.user, charger)
578
- overview = _connector_overview(charger, request.user)
579
- sessions = _live_sessions(charger)
580
- tx = None
581
- active_connector_count = 0
582
- if charger.connector_id is None:
583
- if sessions:
584
- total_kw = 0.0
585
- start_times = [
586
- tx_obj.start_time for _, tx_obj in sessions if tx_obj.start_time
587
- ]
588
- for _, tx_obj in sessions:
589
- if tx_obj.kw:
590
- total_kw += tx_obj.kw
591
- tx = SimpleNamespace(
592
- kw=total_kw, start_time=min(start_times) if start_times else None
593
- )
594
- active_connector_count = len(sessions)
595
- else:
596
- tx = (
597
- sessions[0][1]
598
- if sessions
599
- else store.get_transaction(cid, charger.connector_id)
600
- )
601
- if tx:
602
- active_connector_count = 1
603
- state_source = tx if charger.connector_id is not None else (sessions if sessions else None)
604
- state, color = _charger_state(charger, state_source)
605
- language_cookie = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
606
- preferred_language = "es"
607
- supported_languages = {code for code, _ in settings.LANGUAGES}
608
- if preferred_language in supported_languages and not language_cookie:
609
- translation.activate(preferred_language)
610
- request.LANGUAGE_CODE = translation.get_language()
611
- connector_links = [
612
- {
613
- "slug": item["slug"],
614
- "label": item["label"],
615
- "url": item["url"],
616
- "active": item["slug"] == connector_slug,
617
- }
618
- for item in overview
619
- ]
620
- connector_overview = [
621
- item for item in overview if item["charger"].connector_id is not None
622
- ]
623
- status_url = _reverse_connector_url("charger-status", cid, connector_slug)
624
- return render(
625
- request,
626
- "ocpp/charger_page.html",
627
- {
628
- "charger": charger,
629
- "tx": tx,
630
- "connector_slug": connector_slug,
631
- "connector_links": connector_links,
632
- "connector_overview": connector_overview,
633
- "active_connector_count": active_connector_count,
634
- "status_url": status_url,
635
- "landing_translations": _landing_page_translations(),
636
- "state": state,
637
- "color": color,
638
- },
639
- )
640
-
641
-
642
- @login_required
643
- def charger_status(request, cid, connector=None):
644
- charger, connector_slug = _get_charger(cid, connector)
645
- _ensure_charger_access(request.user, charger)
646
- session_id = request.GET.get("session")
647
- sessions = _live_sessions(charger)
648
- live_tx = None
649
- if charger.connector_id is not None and sessions:
650
- live_tx = sessions[0][1]
651
- tx_obj = live_tx
652
- past_session = False
653
- if session_id:
654
- if charger.connector_id is None:
655
- tx_obj = get_object_or_404(
656
- Transaction, pk=session_id, charger__charger_id=cid
657
- )
658
- past_session = True
659
- elif not (live_tx and str(live_tx.pk) == session_id):
660
- tx_obj = get_object_or_404(Transaction, pk=session_id, charger=charger)
661
- past_session = True
662
- state, color = _charger_state(
663
- charger,
664
- (
665
- live_tx
666
- if charger.connector_id is not None
667
- else (sessions if sessions else None)
668
- ),
669
- )
670
- if charger.connector_id is None:
671
- transactions_qs = (
672
- Transaction.objects.filter(charger__charger_id=cid)
673
- .select_related("charger")
674
- .order_by("-start_time")
675
- )
676
- else:
677
- transactions_qs = Transaction.objects.filter(charger=charger).order_by(
678
- "-start_time"
679
- )
680
- paginator = Paginator(transactions_qs, 10)
681
- page_obj = paginator.get_page(request.GET.get("page"))
682
- transactions = page_obj.object_list
683
- chart_data = {"labels": [], "datasets": []}
684
-
685
- def _series_from_transaction(tx):
686
- points: list[tuple[str, float]] = []
687
- readings = list(
688
- tx.meter_values.filter(energy__isnull=False).order_by("timestamp")
689
- )
690
- start_val = None
691
- if tx.meter_start is not None:
692
- start_val = float(tx.meter_start) / 1000.0
693
- for reading in readings:
694
- try:
695
- val = float(reading.energy)
696
- except (TypeError, ValueError):
697
- continue
698
- if start_val is None:
699
- start_val = val
700
- total = val - start_val
701
- points.append((reading.timestamp.isoformat(), max(total, 0.0)))
702
- return points
703
-
704
- if tx_obj and (charger.connector_id is not None or past_session):
705
- series_points = _series_from_transaction(tx_obj)
706
- if series_points:
707
- chart_data["labels"] = [ts for ts, _ in series_points]
708
- connector_id = None
709
- if tx_obj.charger and tx_obj.charger.connector_id is not None:
710
- connector_id = tx_obj.charger.connector_id
711
- elif charger.connector_id is not None:
712
- connector_id = charger.connector_id
713
- chart_data["datasets"].append(
714
- {
715
- "label": str(
716
- tx_obj.charger.connector_label
717
- if tx_obj.charger and tx_obj.charger.connector_id is not None
718
- else charger.connector_label
719
- ),
720
- "values": [value for _, value in series_points],
721
- "connector_id": connector_id,
722
- }
723
- )
724
- elif charger.connector_id is None:
725
- dataset_points: list[tuple[str, list[tuple[str, float]], int]] = []
726
- for sibling, sibling_tx in sessions:
727
- if sibling.connector_id is None or not sibling_tx:
728
- continue
729
- points = _series_from_transaction(sibling_tx)
730
- if not points:
731
- continue
732
- dataset_points.append(
733
- (str(sibling.connector_label), points, sibling.connector_id)
734
- )
735
- if dataset_points:
736
- all_labels: list[str] = sorted(
737
- {ts for _, points, _ in dataset_points for ts, _ in points}
738
- )
739
- chart_data["labels"] = all_labels
740
- for label, points, connector_id in dataset_points:
741
- value_map = {ts: val for ts, val in points}
742
- chart_data["datasets"].append(
743
- {
744
- "label": label,
745
- "values": [value_map.get(ts) for ts in all_labels],
746
- "connector_id": connector_id,
747
- }
748
- )
749
- overview = _connector_overview(charger, request.user)
750
- connector_links = [
751
- {
752
- "slug": item["slug"],
753
- "label": item["label"],
754
- "url": _reverse_connector_url("charger-status", cid, item["slug"]),
755
- "active": item["slug"] == connector_slug,
756
- }
757
- for item in overview
758
- ]
759
- connector_overview = [
760
- item for item in overview if item["charger"].connector_id is not None
761
- ]
762
- search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
763
- configuration_url = None
764
- if request.user.is_staff:
765
- try:
766
- configuration_url = reverse("admin:ocpp_charger_change", args=[charger.pk])
767
- except NoReverseMatch: # pragma: no cover - admin may be disabled
768
- configuration_url = None
769
- is_connected = store.is_connected(cid, charger.connector_id)
770
- has_active_session = bool(
771
- live_tx if charger.connector_id is not None else sessions
772
- )
773
- can_remote_start = (
774
- charger.connector_id is not None
775
- and is_connected
776
- and not has_active_session
777
- and not past_session
778
- )
779
- remote_start_messages = None
780
- if can_remote_start:
781
- remote_start_messages = {
782
- "required": str(_("RFID is required to start a session.")),
783
- "sending": str(_("Sending remote start request...")),
784
- "success": str(_("Remote start command queued.")),
785
- "error": str(_("Unable to send remote start request.")),
786
- }
787
- action_url = _reverse_connector_url("charger-action", cid, connector_slug)
788
- return render(
789
- request,
790
- "ocpp/charger_status.html",
791
- {
792
- "charger": charger,
793
- "tx": tx_obj,
794
- "state": state,
795
- "color": color,
796
- "transactions": transactions,
797
- "page_obj": page_obj,
798
- "chart_data": chart_data,
799
- "past_session": past_session,
800
- "connector_slug": connector_slug,
801
- "connector_links": connector_links,
802
- "connector_overview": connector_overview,
803
- "search_url": search_url,
804
- "configuration_url": configuration_url,
805
- "page_url": _reverse_connector_url("charger-page", cid, connector_slug),
806
- "is_connected": is_connected,
807
- "is_idle": is_connected and not has_active_session,
808
- "can_remote_start": can_remote_start,
809
- "remote_start_messages": remote_start_messages,
810
- "action_url": action_url,
811
- "show_chart": bool(
812
- chart_data["datasets"]
813
- and any(
814
- any(value is not None for value in dataset["values"])
815
- for dataset in chart_data["datasets"]
816
- )
817
- ),
818
- },
819
- )
820
-
821
-
822
- @login_required
823
- def charger_session_search(request, cid, connector=None):
824
- charger, connector_slug = _get_charger(cid, connector)
825
- _ensure_charger_access(request.user, charger)
826
- date_str = request.GET.get("date")
827
- transactions = None
828
- if date_str:
829
- try:
830
- date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
831
- start = datetime.combine(
832
- date_obj, datetime.min.time(), tzinfo=dt_timezone.utc
833
- )
834
- end = start + timedelta(days=1)
835
- qs = Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
836
- if charger.connector_id is None:
837
- qs = qs.filter(charger__charger_id=cid)
838
- else:
839
- qs = qs.filter(charger=charger)
840
- transactions = qs.order_by("-start_time")
841
- except ValueError:
842
- transactions = []
843
- overview = _connector_overview(charger, request.user)
844
- connector_links = [
845
- {
846
- "slug": item["slug"],
847
- "label": item["label"],
848
- "url": _reverse_connector_url("charger-session-search", cid, item["slug"]),
849
- "active": item["slug"] == connector_slug,
850
- }
851
- for item in overview
852
- ]
853
- status_url = _reverse_connector_url("charger-status", cid, connector_slug)
854
- return render(
855
- request,
856
- "ocpp/charger_session_search.html",
857
- {
858
- "charger": charger,
859
- "transactions": transactions,
860
- "date": date_str,
861
- "connector_slug": connector_slug,
862
- "connector_links": connector_links,
863
- "status_url": status_url,
864
- },
865
- )
866
-
867
-
868
- @login_required
869
- def charger_log_page(request, cid, connector=None):
870
- """Render a simple page with the log for the charger or simulator."""
871
- log_type = request.GET.get("type", "charger")
872
- connector_links = []
873
- connector_slug = None
874
- status_url = None
875
- if log_type == "charger":
876
- charger, connector_slug = _get_charger(cid, connector)
877
- _ensure_charger_access(request.user, charger)
878
- log_key = store.identity_key(cid, charger.connector_id)
879
- overview = _connector_overview(charger, request.user)
880
- connector_links = [
881
- {
882
- "slug": item["slug"],
883
- "label": item["label"],
884
- "url": _reverse_connector_url("charger-log", cid, item["slug"]),
885
- "active": item["slug"] == connector_slug,
886
- }
887
- for item in overview
888
- ]
889
- target_id = log_key
890
- status_url = _reverse_connector_url("charger-status", cid, connector_slug)
891
- else:
892
- charger = Charger.objects.filter(charger_id=cid).first() or Charger(
893
- charger_id=cid
894
- )
895
- target_id = cid
896
- log = store.get_logs(target_id, log_type=log_type)
897
- return render(
898
- request,
899
- "ocpp/charger_logs.html",
900
- {
901
- "charger": charger,
902
- "log": log,
903
- "log_type": log_type,
904
- "connector_slug": connector_slug,
905
- "connector_links": connector_links,
906
- "status_url": status_url,
907
- },
908
- )
909
-
910
-
911
- @csrf_exempt
912
- @api_login_required
913
- def dispatch_action(request, cid, connector=None):
914
- connector_value, _ = _normalize_connector_slug(connector)
915
- log_key = store.identity_key(cid, connector_value)
916
- if connector_value is None:
917
- charger_obj = (
918
- Charger.objects.filter(charger_id=cid, connector_id__isnull=True)
919
- .order_by("pk")
920
- .first()
921
- )
922
- else:
923
- charger_obj = (
924
- Charger.objects.filter(charger_id=cid, connector_id=connector_value)
925
- .order_by("pk")
926
- .first()
927
- )
928
- if charger_obj is None:
929
- if connector_value is None:
930
- charger_obj, _ = Charger.objects.get_or_create(
931
- charger_id=cid, connector_id=None
932
- )
933
- else:
934
- charger_obj, _ = Charger.objects.get_or_create(
935
- charger_id=cid, connector_id=connector_value
936
- )
937
-
938
- _ensure_charger_access(request.user, charger_obj)
939
- ws = store.get_connection(cid, connector_value)
940
- if ws is None:
941
- return JsonResponse({"detail": "no connection"}, status=404)
942
- try:
943
- data = json.loads(request.body.decode()) if request.body else {}
944
- except json.JSONDecodeError:
945
- data = {}
946
- action = data.get("action")
947
- if action == "remote_stop":
948
- tx_obj = store.get_transaction(cid, connector_value)
949
- if not tx_obj:
950
- return JsonResponse({"detail": "no transaction"}, status=404)
951
- message_id = uuid.uuid4().hex
952
- msg = json.dumps(
953
- [
954
- 2,
955
- message_id,
956
- "RemoteStopTransaction",
957
- {"transactionId": tx_obj.pk},
958
- ]
959
- )
960
- async_to_sync(ws.send)(msg)
961
- store.register_pending_call(
962
- message_id,
963
- {
964
- "action": "RemoteStopTransaction",
965
- "charger_id": cid,
966
- "connector_id": connector_value,
967
- "log_key": log_key,
968
- "transaction_id": tx_obj.pk,
969
- "requested_at": timezone.now(),
970
- },
971
- )
972
- elif action == "remote_start":
973
- id_tag = data.get("idTag")
974
- if not isinstance(id_tag, str) or not id_tag.strip():
975
- return JsonResponse({"detail": "idTag required"}, status=400)
976
- id_tag = id_tag.strip()
977
- payload: dict[str, object] = {"idTag": id_tag}
978
- connector_id = data.get("connectorId")
979
- if connector_id in ("", None):
980
- connector_id = None
981
- if connector_id is None and connector_value is not None:
982
- connector_id = connector_value
983
- if connector_id is not None:
984
- try:
985
- payload["connectorId"] = int(connector_id)
986
- except (TypeError, ValueError):
987
- payload["connectorId"] = connector_id
988
- if "chargingProfile" in data and data["chargingProfile"] is not None:
989
- payload["chargingProfile"] = data["chargingProfile"]
990
- message_id = uuid.uuid4().hex
991
- msg = json.dumps(
992
- [
993
- 2,
994
- message_id,
995
- "RemoteStartTransaction",
996
- payload,
997
- ]
998
- )
999
- async_to_sync(ws.send)(msg)
1000
- store.register_pending_call(
1001
- message_id,
1002
- {
1003
- "action": "RemoteStartTransaction",
1004
- "charger_id": cid,
1005
- "connector_id": connector_value,
1006
- "log_key": log_key,
1007
- "id_tag": id_tag,
1008
- "requested_at": timezone.now(),
1009
- },
1010
- )
1011
- elif action == "change_availability":
1012
- availability_type = data.get("type")
1013
- if availability_type not in {"Operative", "Inoperative"}:
1014
- return JsonResponse({"detail": "invalid availability type"}, status=400)
1015
- connector_payload = connector_value if connector_value is not None else 0
1016
- if "connectorId" in data:
1017
- candidate = data.get("connectorId")
1018
- if candidate not in (None, ""):
1019
- try:
1020
- connector_payload = int(candidate)
1021
- except (TypeError, ValueError):
1022
- connector_payload = candidate
1023
- message_id = uuid.uuid4().hex
1024
- payload = {"connectorId": connector_payload, "type": availability_type}
1025
- msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1026
- async_to_sync(ws.send)(msg)
1027
- requested_at = timezone.now()
1028
- store.register_pending_call(
1029
- message_id,
1030
- {
1031
- "action": "ChangeAvailability",
1032
- "charger_id": cid,
1033
- "connector_id": connector_value,
1034
- "availability_type": availability_type,
1035
- "requested_at": requested_at,
1036
- },
1037
- )
1038
- if charger_obj:
1039
- updates = {
1040
- "availability_requested_state": availability_type,
1041
- "availability_requested_at": requested_at,
1042
- "availability_request_status": "",
1043
- "availability_request_status_at": None,
1044
- "availability_request_details": "",
1045
- }
1046
- Charger.objects.filter(pk=charger_obj.pk).update(**updates)
1047
- for field, value in updates.items():
1048
- setattr(charger_obj, field, value)
1049
- elif action == "data_transfer":
1050
- vendor_id = data.get("vendorId")
1051
- if not isinstance(vendor_id, str) or not vendor_id.strip():
1052
- return JsonResponse({"detail": "vendorId required"}, status=400)
1053
- vendor_id = vendor_id.strip()
1054
- payload: dict[str, object] = {"vendorId": vendor_id}
1055
- message_identifier = ""
1056
- if "messageId" in data and data["messageId"] is not None:
1057
- message_candidate = data["messageId"]
1058
- if not isinstance(message_candidate, str):
1059
- return JsonResponse({"detail": "messageId must be a string"}, status=400)
1060
- message_identifier = message_candidate.strip()
1061
- if message_identifier:
1062
- payload["messageId"] = message_identifier
1063
- if "data" in data:
1064
- payload["data"] = data["data"]
1065
- message_id = uuid.uuid4().hex
1066
- msg = json.dumps([2, message_id, "DataTransfer", payload])
1067
- record = DataTransferMessage.objects.create(
1068
- charger=charger_obj,
1069
- connector_id=connector_value,
1070
- direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
1071
- ocpp_message_id=message_id,
1072
- vendor_id=vendor_id,
1073
- message_id=message_identifier,
1074
- payload=payload,
1075
- status="Pending",
1076
- )
1077
- async_to_sync(ws.send)(msg)
1078
- store.register_pending_call(
1079
- message_id,
1080
- {
1081
- "action": "DataTransfer",
1082
- "charger_id": cid,
1083
- "connector_id": connector_value,
1084
- "message_pk": record.pk,
1085
- "log_key": log_key,
1086
- },
1087
- )
1088
- elif action == "reset":
1089
- message_id = uuid.uuid4().hex
1090
- msg = json.dumps([2, message_id, "Reset", {"type": "Soft"}])
1091
- async_to_sync(ws.send)(msg)
1092
- store.register_pending_call(
1093
- message_id,
1094
- {
1095
- "action": "Reset",
1096
- "charger_id": cid,
1097
- "connector_id": connector_value,
1098
- "log_key": log_key,
1099
- "requested_at": timezone.now(),
1100
- },
1101
- )
1102
- elif action == "trigger_message":
1103
- trigger_target = data.get("target") or data.get("triggerTarget")
1104
- if not isinstance(trigger_target, str) or not trigger_target.strip():
1105
- return JsonResponse({"detail": "target required"}, status=400)
1106
- trigger_target = trigger_target.strip()
1107
- allowed_targets = {
1108
- "BootNotification",
1109
- "DiagnosticsStatusNotification",
1110
- "FirmwareStatusNotification",
1111
- "Heartbeat",
1112
- "MeterValues",
1113
- "StatusNotification",
1114
- }
1115
- if trigger_target not in allowed_targets:
1116
- return JsonResponse({"detail": "invalid target"}, status=400)
1117
- payload: dict[str, object] = {"requestedMessage": trigger_target}
1118
- trigger_connector = None
1119
- connector_field = data.get("connectorId")
1120
- if connector_field in (None, ""):
1121
- connector_field = data.get("connector")
1122
- if connector_field in (None, "") and connector_value is not None:
1123
- connector_field = connector_value
1124
- if connector_field not in (None, ""):
1125
- try:
1126
- trigger_connector = int(connector_field)
1127
- except (TypeError, ValueError):
1128
- return JsonResponse({"detail": "connectorId must be an integer"}, status=400)
1129
- if trigger_connector <= 0:
1130
- return JsonResponse({"detail": "connectorId must be positive"}, status=400)
1131
- payload["connectorId"] = trigger_connector
1132
- message_id = uuid.uuid4().hex
1133
- msg = json.dumps([2, message_id, "TriggerMessage", payload])
1134
- async_to_sync(ws.send)(msg)
1135
- store.register_pending_call(
1136
- message_id,
1137
- {
1138
- "action": "TriggerMessage",
1139
- "charger_id": cid,
1140
- "connector_id": connector_value,
1141
- "log_key": log_key,
1142
- "trigger_target": trigger_target,
1143
- "trigger_connector": trigger_connector,
1144
- "requested_at": timezone.now(),
1145
- },
1146
- )
1147
- else:
1148
- return JsonResponse({"detail": "unknown action"}, status=400)
1149
- log_key = store.identity_key(cid, connector_value)
1150
- store.add_log(log_key, f"< {msg}", log_type="charger")
1151
- 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})