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