arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
- arthexis-0.1.15.dist-info/RECORD +110 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -43
- config/auth_app.py +7 -7
- config/celery.py +32 -32
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -682
- config/settings_helpers.py +109 -109
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3795 -2809
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +149 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -75
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3637 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +840 -368
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +952 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2168 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2201 -1417
- core/widgets.py +213 -94
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -1161
- nodes/apps.py +87 -85
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1764 -1597
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3830 -3116
- nodes/urls.py +15 -14
- nodes/utils.py +121 -105
- nodes/views.py +683 -619
- ocpp/admin.py +948 -948
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1459
- ocpp/evcs.py +844 -844
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -917
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -11
- ocpp/simulator.py +745 -745
- ocpp/status_display.py +26 -26
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -4094
- ocpp/transactions_io.py +189 -189
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1251
- pages/admin.py +769 -539
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -198
- pages/middleware.py +209 -153
- pages/models.py +643 -426
- pages/tasks.py +74 -0
- pages/tests.py +3025 -2200
- pages/urls.py +26 -25
- pages/utils.py +23 -12
- pages/views.py +1176 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -1,948 +1,948 @@
|
|
|
1
|
-
from django.contrib import admin, messages
|
|
2
|
-
from django import forms
|
|
3
|
-
|
|
4
|
-
import asyncio
|
|
5
|
-
from datetime import timedelta
|
|
6
|
-
import json
|
|
7
|
-
|
|
8
|
-
from django.shortcuts import redirect
|
|
9
|
-
from django.utils import timezone
|
|
10
|
-
from django.urls import path
|
|
11
|
-
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
|
-
from django.template.response import TemplateResponse
|
|
13
|
-
|
|
14
|
-
import uuid
|
|
15
|
-
from asgiref.sync import async_to_sync
|
|
16
|
-
|
|
17
|
-
from .models import (
|
|
18
|
-
Charger,
|
|
19
|
-
Simulator,
|
|
20
|
-
MeterValue,
|
|
21
|
-
Transaction,
|
|
22
|
-
Location,
|
|
23
|
-
DataTransferMessage,
|
|
24
|
-
)
|
|
25
|
-
from .simulator import ChargePointSimulator
|
|
26
|
-
from . import store
|
|
27
|
-
from .transactions_io import (
|
|
28
|
-
export_transactions,
|
|
29
|
-
import_transactions as import_transactions_data,
|
|
30
|
-
)
|
|
31
|
-
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
32
|
-
from core.admin import SaveBeforeChangeAction
|
|
33
|
-
from core.user_data import EntityModelAdmin
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class LocationAdminForm(forms.ModelForm):
|
|
37
|
-
class Meta:
|
|
38
|
-
model = Location
|
|
39
|
-
fields = "__all__"
|
|
40
|
-
|
|
41
|
-
widgets = {
|
|
42
|
-
"latitude": forms.NumberInput(attrs={"step": "any"}),
|
|
43
|
-
"longitude": forms.NumberInput(attrs={"step": "any"}),
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
class Media:
|
|
47
|
-
css = {"all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)}
|
|
48
|
-
js = (
|
|
49
|
-
"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
|
|
50
|
-
"ocpp/charger_map.js",
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TransactionExportForm(forms.Form):
|
|
55
|
-
start = forms.DateTimeField(required=False)
|
|
56
|
-
end = forms.DateTimeField(required=False)
|
|
57
|
-
chargers = forms.ModelMultipleChoiceField(
|
|
58
|
-
queryset=Charger.objects.all(), required=False
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class TransactionImportForm(forms.Form):
|
|
63
|
-
file = forms.FileField()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class LogViewAdminMixin:
|
|
67
|
-
"""Mixin providing an admin view to display charger or simulator logs."""
|
|
68
|
-
|
|
69
|
-
log_type = "charger"
|
|
70
|
-
log_template_name = "admin/ocpp/log_view.html"
|
|
71
|
-
|
|
72
|
-
def get_log_identifier(self, obj): # pragma: no cover - mixin hook
|
|
73
|
-
raise NotImplementedError
|
|
74
|
-
|
|
75
|
-
def get_log_title(self, obj):
|
|
76
|
-
return f"Log for {obj}"
|
|
77
|
-
|
|
78
|
-
def get_urls(self):
|
|
79
|
-
urls = super().get_urls()
|
|
80
|
-
info = self.model._meta.app_label, self.model._meta.model_name
|
|
81
|
-
custom = [
|
|
82
|
-
path(
|
|
83
|
-
"<path:object_id>/log/",
|
|
84
|
-
self.admin_site.admin_view(self.log_view),
|
|
85
|
-
name=f"{info[0]}_{info[1]}_log",
|
|
86
|
-
),
|
|
87
|
-
]
|
|
88
|
-
return custom + urls
|
|
89
|
-
|
|
90
|
-
def log_view(self, request, object_id):
|
|
91
|
-
obj = self.get_object(request, object_id)
|
|
92
|
-
if obj is None:
|
|
93
|
-
self.message_user(request, "Log is not available.", messages.ERROR)
|
|
94
|
-
return redirect("..")
|
|
95
|
-
identifier = self.get_log_identifier(obj)
|
|
96
|
-
log_entries = store.get_logs(identifier, log_type=self.log_type)
|
|
97
|
-
log_file = store._file_path(identifier, log_type=self.log_type)
|
|
98
|
-
context = {
|
|
99
|
-
**self.admin_site.each_context(request),
|
|
100
|
-
"opts": self.model._meta,
|
|
101
|
-
"original": obj,
|
|
102
|
-
"title": self.get_log_title(obj),
|
|
103
|
-
"log_entries": log_entries,
|
|
104
|
-
"log_file": str(log_file),
|
|
105
|
-
"log_identifier": identifier,
|
|
106
|
-
}
|
|
107
|
-
return TemplateResponse(request, self.log_template_name, context)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@admin.register(Location)
|
|
111
|
-
class LocationAdmin(EntityModelAdmin):
|
|
112
|
-
form = LocationAdminForm
|
|
113
|
-
list_display = ("name", "latitude", "longitude")
|
|
114
|
-
change_form_template = "admin/ocpp/location/change_form.html"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@admin.register(DataTransferMessage)
|
|
118
|
-
class DataTransferMessageAdmin(admin.ModelAdmin):
|
|
119
|
-
list_display = (
|
|
120
|
-
"charger",
|
|
121
|
-
"connector_id",
|
|
122
|
-
"direction",
|
|
123
|
-
"vendor_id",
|
|
124
|
-
"message_id",
|
|
125
|
-
"status",
|
|
126
|
-
"created_at",
|
|
127
|
-
"responded_at",
|
|
128
|
-
)
|
|
129
|
-
list_filter = ("direction", "status")
|
|
130
|
-
search_fields = (
|
|
131
|
-
"charger__charger_id",
|
|
132
|
-
"ocpp_message_id",
|
|
133
|
-
"vendor_id",
|
|
134
|
-
"message_id",
|
|
135
|
-
)
|
|
136
|
-
readonly_fields = (
|
|
137
|
-
"charger",
|
|
138
|
-
"connector_id",
|
|
139
|
-
"direction",
|
|
140
|
-
"ocpp_message_id",
|
|
141
|
-
"vendor_id",
|
|
142
|
-
"message_id",
|
|
143
|
-
"payload",
|
|
144
|
-
"status",
|
|
145
|
-
"response_data",
|
|
146
|
-
"error_code",
|
|
147
|
-
"error_description",
|
|
148
|
-
"error_details",
|
|
149
|
-
"responded_at",
|
|
150
|
-
"created_at",
|
|
151
|
-
"updated_at",
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
@admin.register(Charger)
|
|
156
|
-
class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
157
|
-
fieldsets = (
|
|
158
|
-
(
|
|
159
|
-
"General",
|
|
160
|
-
{
|
|
161
|
-
"fields": (
|
|
162
|
-
"charger_id",
|
|
163
|
-
"display_name",
|
|
164
|
-
"connector_id",
|
|
165
|
-
"location",
|
|
166
|
-
"last_path",
|
|
167
|
-
"last_heartbeat",
|
|
168
|
-
"last_meter_values",
|
|
169
|
-
)
|
|
170
|
-
},
|
|
171
|
-
),
|
|
172
|
-
(
|
|
173
|
-
"Firmware",
|
|
174
|
-
{
|
|
175
|
-
"fields": (
|
|
176
|
-
"firmware_status",
|
|
177
|
-
"firmware_status_info",
|
|
178
|
-
"firmware_timestamp",
|
|
179
|
-
)
|
|
180
|
-
},
|
|
181
|
-
),
|
|
182
|
-
(
|
|
183
|
-
"Diagnostics",
|
|
184
|
-
{
|
|
185
|
-
"fields": (
|
|
186
|
-
"diagnostics_status",
|
|
187
|
-
"diagnostics_timestamp",
|
|
188
|
-
"diagnostics_location",
|
|
189
|
-
)
|
|
190
|
-
},
|
|
191
|
-
),
|
|
192
|
-
(
|
|
193
|
-
"Availability",
|
|
194
|
-
{
|
|
195
|
-
"fields": (
|
|
196
|
-
"availability_state",
|
|
197
|
-
"availability_state_updated_at",
|
|
198
|
-
"availability_requested_state",
|
|
199
|
-
"availability_requested_at",
|
|
200
|
-
"availability_request_status",
|
|
201
|
-
"availability_request_status_at",
|
|
202
|
-
"availability_request_details",
|
|
203
|
-
)
|
|
204
|
-
},
|
|
205
|
-
),
|
|
206
|
-
(
|
|
207
|
-
"Configuration",
|
|
208
|
-
{"fields": ("public_display", "require_rfid")},
|
|
209
|
-
),
|
|
210
|
-
(
|
|
211
|
-
"References",
|
|
212
|
-
{
|
|
213
|
-
"fields": ("reference",),
|
|
214
|
-
},
|
|
215
|
-
),
|
|
216
|
-
(
|
|
217
|
-
"Owner",
|
|
218
|
-
{
|
|
219
|
-
"fields": ("owner_users", "owner_groups"),
|
|
220
|
-
"classes": ("collapse",),
|
|
221
|
-
},
|
|
222
|
-
),
|
|
223
|
-
)
|
|
224
|
-
readonly_fields = (
|
|
225
|
-
"last_heartbeat",
|
|
226
|
-
"last_meter_values",
|
|
227
|
-
"firmware_status",
|
|
228
|
-
"firmware_status_info",
|
|
229
|
-
"firmware_timestamp",
|
|
230
|
-
"availability_state",
|
|
231
|
-
"availability_state_updated_at",
|
|
232
|
-
"availability_requested_state",
|
|
233
|
-
"availability_requested_at",
|
|
234
|
-
"availability_request_status",
|
|
235
|
-
"availability_request_status_at",
|
|
236
|
-
"availability_request_details",
|
|
237
|
-
)
|
|
238
|
-
list_display = (
|
|
239
|
-
"charger_id",
|
|
240
|
-
"connector_number",
|
|
241
|
-
"location_name",
|
|
242
|
-
"require_rfid_display",
|
|
243
|
-
"public_display",
|
|
244
|
-
"last_heartbeat",
|
|
245
|
-
"session_kw",
|
|
246
|
-
"total_kw_display",
|
|
247
|
-
"page_link",
|
|
248
|
-
"log_link",
|
|
249
|
-
"status_link",
|
|
250
|
-
)
|
|
251
|
-
search_fields = ("charger_id", "connector_id", "location__name")
|
|
252
|
-
filter_horizontal = ("owner_users", "owner_groups")
|
|
253
|
-
actions = [
|
|
254
|
-
"purge_data",
|
|
255
|
-
"fetch_cp_configuration",
|
|
256
|
-
"change_availability_operative",
|
|
257
|
-
"change_availability_inoperative",
|
|
258
|
-
"set_availability_state_operative",
|
|
259
|
-
"set_availability_state_inoperative",
|
|
260
|
-
"remote_stop_transaction",
|
|
261
|
-
"reset_chargers",
|
|
262
|
-
"delete_selected",
|
|
263
|
-
]
|
|
264
|
-
|
|
265
|
-
def get_view_on_site_url(self, obj=None):
|
|
266
|
-
return obj.get_absolute_url() if obj else None
|
|
267
|
-
|
|
268
|
-
def require_rfid_display(self, obj):
|
|
269
|
-
return obj.require_rfid
|
|
270
|
-
|
|
271
|
-
require_rfid_display.boolean = True
|
|
272
|
-
require_rfid_display.short_description = "RFID Auth"
|
|
273
|
-
|
|
274
|
-
def page_link(self, obj):
|
|
275
|
-
from django.utils.html import format_html
|
|
276
|
-
|
|
277
|
-
return format_html(
|
|
278
|
-
'<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
page_link.short_description = "Landing"
|
|
282
|
-
|
|
283
|
-
def qr_link(self, obj):
|
|
284
|
-
from django.utils.html import format_html
|
|
285
|
-
|
|
286
|
-
if obj.reference and obj.reference.image:
|
|
287
|
-
return format_html(
|
|
288
|
-
'<a href="{}" target="_blank">qr</a>', obj.reference.image.url
|
|
289
|
-
)
|
|
290
|
-
return ""
|
|
291
|
-
|
|
292
|
-
qr_link.short_description = "QR Code"
|
|
293
|
-
|
|
294
|
-
def log_link(self, obj):
|
|
295
|
-
from django.utils.html import format_html
|
|
296
|
-
from django.urls import reverse
|
|
297
|
-
|
|
298
|
-
url = reverse("admin:ocpp_charger_log", args=[obj.pk])
|
|
299
|
-
return format_html('<a href="{}" target="_blank">view</a>', url)
|
|
300
|
-
|
|
301
|
-
log_link.short_description = "Log"
|
|
302
|
-
|
|
303
|
-
def get_log_identifier(self, obj):
|
|
304
|
-
return store.identity_key(obj.charger_id, obj.connector_id)
|
|
305
|
-
|
|
306
|
-
def connector_number(self, obj):
|
|
307
|
-
return obj.connector_id if obj.connector_id is not None else ""
|
|
308
|
-
|
|
309
|
-
connector_number.short_description = "#"
|
|
310
|
-
connector_number.admin_order_field = "connector_id"
|
|
311
|
-
|
|
312
|
-
def status_link(self, obj):
|
|
313
|
-
from django.utils.html import format_html
|
|
314
|
-
from django.urls import reverse
|
|
315
|
-
|
|
316
|
-
url = reverse(
|
|
317
|
-
"charger-status-connector",
|
|
318
|
-
args=[obj.charger_id, obj.connector_slug],
|
|
319
|
-
)
|
|
320
|
-
label = (obj.last_status or "status").strip() or "status"
|
|
321
|
-
status_key = label.lower()
|
|
322
|
-
error_code = (obj.last_error_code or "").strip().lower()
|
|
323
|
-
if (
|
|
324
|
-
self._has_active_session(obj)
|
|
325
|
-
and error_code in ERROR_OK_VALUES
|
|
326
|
-
and (status_key not in STATUS_BADGE_MAP or status_key == "available")
|
|
327
|
-
):
|
|
328
|
-
label = STATUS_BADGE_MAP["charging"][0]
|
|
329
|
-
return format_html('<a href="{}" target="_blank">{}</a>', url, label)
|
|
330
|
-
|
|
331
|
-
status_link.short_description = "Status"
|
|
332
|
-
|
|
333
|
-
def _has_active_session(self, charger: Charger) -> bool:
|
|
334
|
-
"""Return whether ``charger`` currently has an active session."""
|
|
335
|
-
|
|
336
|
-
if store.get_transaction(charger.charger_id, charger.connector_id):
|
|
337
|
-
return True
|
|
338
|
-
if charger.connector_id is not None:
|
|
339
|
-
return False
|
|
340
|
-
sibling_connectors = (
|
|
341
|
-
Charger.objects.filter(charger_id=charger.charger_id)
|
|
342
|
-
.exclude(pk=charger.pk)
|
|
343
|
-
.values_list("connector_id", flat=True)
|
|
344
|
-
)
|
|
345
|
-
for connector_id in sibling_connectors:
|
|
346
|
-
if store.get_transaction(charger.charger_id, connector_id):
|
|
347
|
-
return True
|
|
348
|
-
return False
|
|
349
|
-
|
|
350
|
-
def location_name(self, obj):
|
|
351
|
-
return obj.location.name if obj.location else ""
|
|
352
|
-
|
|
353
|
-
location_name.short_description = "Location"
|
|
354
|
-
|
|
355
|
-
def purge_data(self, request, queryset):
|
|
356
|
-
for charger in queryset:
|
|
357
|
-
charger.purge()
|
|
358
|
-
self.message_user(request, "Data purged for selected chargers")
|
|
359
|
-
|
|
360
|
-
purge_data.short_description = "Purge data"
|
|
361
|
-
|
|
362
|
-
@admin.action(description="Fetch CP configuration")
|
|
363
|
-
def fetch_cp_configuration(self, request, queryset):
|
|
364
|
-
fetched = 0
|
|
365
|
-
for charger in queryset:
|
|
366
|
-
connector_value = charger.connector_id
|
|
367
|
-
ws = store.get_connection(charger.charger_id, connector_value)
|
|
368
|
-
if ws is None:
|
|
369
|
-
self.message_user(
|
|
370
|
-
request,
|
|
371
|
-
f"{charger}: no active connection",
|
|
372
|
-
level=messages.ERROR,
|
|
373
|
-
)
|
|
374
|
-
continue
|
|
375
|
-
message_id = uuid.uuid4().hex
|
|
376
|
-
payload = {}
|
|
377
|
-
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
378
|
-
try:
|
|
379
|
-
async_to_sync(ws.send)(msg)
|
|
380
|
-
except Exception as exc: # pragma: no cover - network error
|
|
381
|
-
self.message_user(
|
|
382
|
-
request,
|
|
383
|
-
f"{charger}: failed to send GetConfiguration ({exc})",
|
|
384
|
-
level=messages.ERROR,
|
|
385
|
-
)
|
|
386
|
-
continue
|
|
387
|
-
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
388
|
-
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
389
|
-
store.register_pending_call(
|
|
390
|
-
message_id,
|
|
391
|
-
{
|
|
392
|
-
"action": "GetConfiguration",
|
|
393
|
-
"charger_id": charger.charger_id,
|
|
394
|
-
"connector_id": connector_value,
|
|
395
|
-
"log_key": log_key,
|
|
396
|
-
"requested_at": timezone.now(),
|
|
397
|
-
},
|
|
398
|
-
)
|
|
399
|
-
store.schedule_call_timeout(
|
|
400
|
-
message_id,
|
|
401
|
-
timeout=5.0,
|
|
402
|
-
action="GetConfiguration",
|
|
403
|
-
log_key=log_key,
|
|
404
|
-
message=(
|
|
405
|
-
"GetConfiguration timed out: charger did not respond"
|
|
406
|
-
" (operation may not be supported)"
|
|
407
|
-
),
|
|
408
|
-
)
|
|
409
|
-
fetched += 1
|
|
410
|
-
if fetched:
|
|
411
|
-
self.message_user(
|
|
412
|
-
request,
|
|
413
|
-
f"Requested configuration from {fetched} charger(s)",
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
def _dispatch_change_availability(self, request, queryset, availability_type: str):
|
|
417
|
-
sent = 0
|
|
418
|
-
for charger in queryset:
|
|
419
|
-
connector_value = charger.connector_id
|
|
420
|
-
ws = store.get_connection(charger.charger_id, connector_value)
|
|
421
|
-
if ws is None:
|
|
422
|
-
self.message_user(
|
|
423
|
-
request,
|
|
424
|
-
f"{charger}: no active connection",
|
|
425
|
-
level=messages.ERROR,
|
|
426
|
-
)
|
|
427
|
-
continue
|
|
428
|
-
connector_id = connector_value if connector_value is not None else 0
|
|
429
|
-
message_id = uuid.uuid4().hex
|
|
430
|
-
payload = {"connectorId": connector_id, "type": availability_type}
|
|
431
|
-
msg = json.dumps([2, message_id, "ChangeAvailability", payload])
|
|
432
|
-
try:
|
|
433
|
-
async_to_sync(ws.send)(msg)
|
|
434
|
-
except Exception as exc: # pragma: no cover - network error
|
|
435
|
-
self.message_user(
|
|
436
|
-
request,
|
|
437
|
-
f"{charger}: failed to send ChangeAvailability ({exc})",
|
|
438
|
-
level=messages.ERROR,
|
|
439
|
-
)
|
|
440
|
-
continue
|
|
441
|
-
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
442
|
-
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
443
|
-
timestamp = timezone.now()
|
|
444
|
-
store.register_pending_call(
|
|
445
|
-
message_id,
|
|
446
|
-
{
|
|
447
|
-
"action": "ChangeAvailability",
|
|
448
|
-
"charger_id": charger.charger_id,
|
|
449
|
-
"connector_id": connector_value,
|
|
450
|
-
"availability_type": availability_type,
|
|
451
|
-
"requested_at": timestamp,
|
|
452
|
-
},
|
|
453
|
-
)
|
|
454
|
-
updates = {
|
|
455
|
-
"availability_requested_state": availability_type,
|
|
456
|
-
"availability_requested_at": timestamp,
|
|
457
|
-
"availability_request_status": "",
|
|
458
|
-
"availability_request_status_at": None,
|
|
459
|
-
"availability_request_details": "",
|
|
460
|
-
}
|
|
461
|
-
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
462
|
-
for field, value in updates.items():
|
|
463
|
-
setattr(charger, field, value)
|
|
464
|
-
sent += 1
|
|
465
|
-
if sent:
|
|
466
|
-
self.message_user(
|
|
467
|
-
request,
|
|
468
|
-
f"Sent ChangeAvailability ({availability_type}) to {sent} charger(s)",
|
|
469
|
-
)
|
|
470
|
-
|
|
471
|
-
@admin.action(description="Set availability to Operative")
|
|
472
|
-
def change_availability_operative(self, request, queryset):
|
|
473
|
-
self._dispatch_change_availability(request, queryset, "Operative")
|
|
474
|
-
|
|
475
|
-
@admin.action(description="Set availability to Inoperative")
|
|
476
|
-
def change_availability_inoperative(self, request, queryset):
|
|
477
|
-
self._dispatch_change_availability(request, queryset, "Inoperative")
|
|
478
|
-
|
|
479
|
-
def _set_availability_state(
|
|
480
|
-
self, request, queryset, availability_state: str
|
|
481
|
-
) -> None:
|
|
482
|
-
timestamp = timezone.now()
|
|
483
|
-
updated = 0
|
|
484
|
-
for charger in queryset:
|
|
485
|
-
updates = {
|
|
486
|
-
"availability_state": availability_state,
|
|
487
|
-
"availability_state_updated_at": timestamp,
|
|
488
|
-
}
|
|
489
|
-
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
490
|
-
for field, value in updates.items():
|
|
491
|
-
setattr(charger, field, value)
|
|
492
|
-
updated += 1
|
|
493
|
-
if updated:
|
|
494
|
-
self.message_user(
|
|
495
|
-
request,
|
|
496
|
-
f"Updated availability to {availability_state} for {updated} charger(s)",
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
@admin.action(description="Mark availability as Operative")
|
|
500
|
-
def set_availability_state_operative(self, request, queryset):
|
|
501
|
-
self._set_availability_state(request, queryset, "Operative")
|
|
502
|
-
|
|
503
|
-
@admin.action(description="Mark availability as Inoperative")
|
|
504
|
-
def set_availability_state_inoperative(self, request, queryset):
|
|
505
|
-
self._set_availability_state(request, queryset, "Inoperative")
|
|
506
|
-
|
|
507
|
-
@admin.action(description="Remote stop active transaction")
|
|
508
|
-
def remote_stop_transaction(self, request, queryset):
|
|
509
|
-
stopped = 0
|
|
510
|
-
for charger in queryset:
|
|
511
|
-
connector_value = charger.connector_id
|
|
512
|
-
ws = store.get_connection(charger.charger_id, connector_value)
|
|
513
|
-
if ws is None:
|
|
514
|
-
self.message_user(
|
|
515
|
-
request,
|
|
516
|
-
f"{charger}: no active connection",
|
|
517
|
-
level=messages.ERROR,
|
|
518
|
-
)
|
|
519
|
-
continue
|
|
520
|
-
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
521
|
-
if tx_obj is None:
|
|
522
|
-
self.message_user(
|
|
523
|
-
request,
|
|
524
|
-
f"{charger}: no active transaction",
|
|
525
|
-
level=messages.ERROR,
|
|
526
|
-
)
|
|
527
|
-
continue
|
|
528
|
-
message_id = uuid.uuid4().hex
|
|
529
|
-
payload = {"transactionId": tx_obj.pk}
|
|
530
|
-
msg = json.dumps([
|
|
531
|
-
2,
|
|
532
|
-
message_id,
|
|
533
|
-
"RemoteStopTransaction",
|
|
534
|
-
payload,
|
|
535
|
-
])
|
|
536
|
-
try:
|
|
537
|
-
async_to_sync(ws.send)(msg)
|
|
538
|
-
except Exception as exc: # pragma: no cover - network error
|
|
539
|
-
self.message_user(
|
|
540
|
-
request,
|
|
541
|
-
f"{charger}: failed to send RemoteStopTransaction ({exc})",
|
|
542
|
-
level=messages.ERROR,
|
|
543
|
-
)
|
|
544
|
-
continue
|
|
545
|
-
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
546
|
-
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
547
|
-
store.register_pending_call(
|
|
548
|
-
message_id,
|
|
549
|
-
{
|
|
550
|
-
"action": "RemoteStopTransaction",
|
|
551
|
-
"charger_id": charger.charger_id,
|
|
552
|
-
"connector_id": connector_value,
|
|
553
|
-
"transaction_id": tx_obj.pk,
|
|
554
|
-
"log_key": log_key,
|
|
555
|
-
"requested_at": timezone.now(),
|
|
556
|
-
},
|
|
557
|
-
)
|
|
558
|
-
stopped += 1
|
|
559
|
-
if stopped:
|
|
560
|
-
self.message_user(
|
|
561
|
-
request,
|
|
562
|
-
f"Sent RemoteStopTransaction to {stopped} charger(s)",
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
@admin.action(description="Reset charger (soft)")
|
|
566
|
-
def reset_chargers(self, request, queryset):
|
|
567
|
-
reset = 0
|
|
568
|
-
for charger in queryset:
|
|
569
|
-
connector_value = charger.connector_id
|
|
570
|
-
ws = store.get_connection(charger.charger_id, connector_value)
|
|
571
|
-
if ws is None:
|
|
572
|
-
self.message_user(
|
|
573
|
-
request,
|
|
574
|
-
f"{charger}: no active connection",
|
|
575
|
-
level=messages.ERROR,
|
|
576
|
-
)
|
|
577
|
-
continue
|
|
578
|
-
message_id = uuid.uuid4().hex
|
|
579
|
-
msg = json.dumps([
|
|
580
|
-
2,
|
|
581
|
-
message_id,
|
|
582
|
-
"Reset",
|
|
583
|
-
{"type": "Soft"},
|
|
584
|
-
])
|
|
585
|
-
try:
|
|
586
|
-
async_to_sync(ws.send)(msg)
|
|
587
|
-
except Exception as exc: # pragma: no cover - network error
|
|
588
|
-
self.message_user(
|
|
589
|
-
request,
|
|
590
|
-
f"{charger}: failed to send Reset ({exc})",
|
|
591
|
-
level=messages.ERROR,
|
|
592
|
-
)
|
|
593
|
-
continue
|
|
594
|
-
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
595
|
-
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
596
|
-
store.register_pending_call(
|
|
597
|
-
message_id,
|
|
598
|
-
{
|
|
599
|
-
"action": "Reset",
|
|
600
|
-
"charger_id": charger.charger_id,
|
|
601
|
-
"connector_id": connector_value,
|
|
602
|
-
"log_key": log_key,
|
|
603
|
-
"requested_at": timezone.now(),
|
|
604
|
-
},
|
|
605
|
-
)
|
|
606
|
-
reset += 1
|
|
607
|
-
if reset:
|
|
608
|
-
self.message_user(
|
|
609
|
-
request,
|
|
610
|
-
f"Sent Reset to {reset} charger(s)",
|
|
611
|
-
)
|
|
612
|
-
|
|
613
|
-
def delete_queryset(self, request, queryset):
|
|
614
|
-
for obj in queryset:
|
|
615
|
-
obj.delete()
|
|
616
|
-
|
|
617
|
-
def total_kw_display(self, obj):
|
|
618
|
-
return round(obj.total_kw, 2)
|
|
619
|
-
|
|
620
|
-
total_kw_display.short_description = "Total kW"
|
|
621
|
-
|
|
622
|
-
def session_kw(self, obj):
|
|
623
|
-
tx = store.get_transaction(obj.charger_id, obj.connector_id)
|
|
624
|
-
if tx:
|
|
625
|
-
return round(tx.kw, 2)
|
|
626
|
-
return 0.0
|
|
627
|
-
|
|
628
|
-
session_kw.short_description = "Session kW"
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
@admin.register(Simulator)
|
|
632
|
-
class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
|
|
633
|
-
list_display = (
|
|
634
|
-
"name",
|
|
635
|
-
"cp_path",
|
|
636
|
-
"host",
|
|
637
|
-
"ws_port",
|
|
638
|
-
"ws_url",
|
|
639
|
-
"interval",
|
|
640
|
-
"kw_max",
|
|
641
|
-
"running",
|
|
642
|
-
"log_link",
|
|
643
|
-
)
|
|
644
|
-
fieldsets = (
|
|
645
|
-
(
|
|
646
|
-
None,
|
|
647
|
-
{
|
|
648
|
-
"fields": (
|
|
649
|
-
"name",
|
|
650
|
-
"cp_path",
|
|
651
|
-
("host", "ws_port"),
|
|
652
|
-
"rfid",
|
|
653
|
-
("duration", "interval", "pre_charge_delay"),
|
|
654
|
-
"kw_max",
|
|
655
|
-
("repeat", "door_open"),
|
|
656
|
-
("username", "password"),
|
|
657
|
-
)
|
|
658
|
-
},
|
|
659
|
-
),
|
|
660
|
-
(
|
|
661
|
-
"Configuration",
|
|
662
|
-
{
|
|
663
|
-
"fields": (
|
|
664
|
-
"configuration_keys",
|
|
665
|
-
"configuration_unknown_keys",
|
|
666
|
-
),
|
|
667
|
-
"classes": ("collapse",),
|
|
668
|
-
"description": (
|
|
669
|
-
"Provide JSON lists for configurationKey entries and "
|
|
670
|
-
"unknownKey values returned by GetConfiguration."
|
|
671
|
-
),
|
|
672
|
-
},
|
|
673
|
-
),
|
|
674
|
-
)
|
|
675
|
-
actions = ("start_simulator", "stop_simulator", "send_open_door")
|
|
676
|
-
change_actions = ["start_simulator_action", "stop_simulator_action"]
|
|
677
|
-
|
|
678
|
-
log_type = "simulator"
|
|
679
|
-
|
|
680
|
-
def save_model(self, request, obj, form, change):
|
|
681
|
-
previous_door_open = False
|
|
682
|
-
if change and obj.pk:
|
|
683
|
-
previous_door_open = (
|
|
684
|
-
type(obj)
|
|
685
|
-
.objects.filter(pk=obj.pk)
|
|
686
|
-
.values_list("door_open", flat=True)
|
|
687
|
-
.first()
|
|
688
|
-
or False
|
|
689
|
-
)
|
|
690
|
-
super().save_model(request, obj, form, change)
|
|
691
|
-
if obj.door_open and not previous_door_open:
|
|
692
|
-
triggered = self._queue_door_open(request, obj)
|
|
693
|
-
if not triggered:
|
|
694
|
-
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
695
|
-
obj.door_open = False
|
|
696
|
-
|
|
697
|
-
def _queue_door_open(self, request, obj) -> bool:
|
|
698
|
-
sim = store.simulators.get(obj.pk)
|
|
699
|
-
if not sim:
|
|
700
|
-
self.message_user(
|
|
701
|
-
request,
|
|
702
|
-
f"{obj.name}: simulator is not running",
|
|
703
|
-
level=messages.ERROR,
|
|
704
|
-
)
|
|
705
|
-
return False
|
|
706
|
-
type(obj).objects.filter(pk=obj.pk).update(door_open=True)
|
|
707
|
-
obj.door_open = True
|
|
708
|
-
store.add_log(
|
|
709
|
-
obj.cp_path,
|
|
710
|
-
"Door open event requested from admin",
|
|
711
|
-
log_type="simulator",
|
|
712
|
-
)
|
|
713
|
-
if hasattr(sim, "trigger_door_open"):
|
|
714
|
-
sim.trigger_door_open()
|
|
715
|
-
else: # pragma: no cover - unexpected condition
|
|
716
|
-
self.message_user(
|
|
717
|
-
request,
|
|
718
|
-
f"{obj.name}: simulator cannot send door open event",
|
|
719
|
-
level=messages.ERROR,
|
|
720
|
-
)
|
|
721
|
-
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
722
|
-
obj.door_open = False
|
|
723
|
-
return False
|
|
724
|
-
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
725
|
-
obj.door_open = False
|
|
726
|
-
self.message_user(
|
|
727
|
-
request,
|
|
728
|
-
f"{obj.name}: DoorOpen status notification sent",
|
|
729
|
-
)
|
|
730
|
-
return True
|
|
731
|
-
|
|
732
|
-
def running(self, obj):
|
|
733
|
-
return obj.pk in store.simulators
|
|
734
|
-
|
|
735
|
-
running.boolean = True
|
|
736
|
-
|
|
737
|
-
@admin.action(description="Send Open Door")
|
|
738
|
-
def send_open_door(self, request, queryset):
|
|
739
|
-
for obj in queryset:
|
|
740
|
-
self._queue_door_open(request, obj)
|
|
741
|
-
|
|
742
|
-
def start_simulator(self, request, queryset):
|
|
743
|
-
from django.urls import reverse
|
|
744
|
-
from django.utils.html import format_html
|
|
745
|
-
|
|
746
|
-
for obj in queryset:
|
|
747
|
-
if obj.pk in store.simulators:
|
|
748
|
-
self.message_user(request, f"{obj.name}: already running")
|
|
749
|
-
continue
|
|
750
|
-
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
751
|
-
obj.door_open = False
|
|
752
|
-
store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
|
|
753
|
-
sim = ChargePointSimulator(obj.as_config())
|
|
754
|
-
started, status, log_file = sim.start()
|
|
755
|
-
if started:
|
|
756
|
-
store.simulators[obj.pk] = sim
|
|
757
|
-
log_url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
|
|
758
|
-
self.message_user(
|
|
759
|
-
request,
|
|
760
|
-
format_html(
|
|
761
|
-
'{}: {}. Log: <code>{}</code> (<a href="{}" target="_blank">View Log</a>)',
|
|
762
|
-
obj.name,
|
|
763
|
-
status,
|
|
764
|
-
log_file,
|
|
765
|
-
log_url,
|
|
766
|
-
),
|
|
767
|
-
)
|
|
768
|
-
|
|
769
|
-
start_simulator.short_description = "Start selected simulators"
|
|
770
|
-
|
|
771
|
-
def stop_simulator(self, request, queryset):
|
|
772
|
-
async def _stop(objs):
|
|
773
|
-
for obj in objs:
|
|
774
|
-
sim = store.simulators.pop(obj.pk, None)
|
|
775
|
-
if sim:
|
|
776
|
-
await sim.stop()
|
|
777
|
-
|
|
778
|
-
objs = list(queryset)
|
|
779
|
-
try:
|
|
780
|
-
loop = asyncio.get_running_loop()
|
|
781
|
-
except RuntimeError:
|
|
782
|
-
asyncio.run(_stop(objs))
|
|
783
|
-
else:
|
|
784
|
-
loop.create_task(_stop(objs))
|
|
785
|
-
self.message_user(request, "Stopping simulators")
|
|
786
|
-
|
|
787
|
-
stop_simulator.short_description = "Stop selected simulators"
|
|
788
|
-
|
|
789
|
-
def start_simulator_action(self, request, obj):
|
|
790
|
-
queryset = type(obj).objects.filter(pk=obj.pk)
|
|
791
|
-
self.start_simulator(request, queryset)
|
|
792
|
-
|
|
793
|
-
def stop_simulator_action(self, request, obj):
|
|
794
|
-
queryset = type(obj).objects.filter(pk=obj.pk)
|
|
795
|
-
self.stop_simulator(request, queryset)
|
|
796
|
-
|
|
797
|
-
def log_link(self, obj):
|
|
798
|
-
from django.utils.html import format_html
|
|
799
|
-
from django.urls import reverse
|
|
800
|
-
|
|
801
|
-
url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
|
|
802
|
-
return format_html('<a href="{}" target="_blank">view</a>', url)
|
|
803
|
-
|
|
804
|
-
log_link.short_description = "Log"
|
|
805
|
-
|
|
806
|
-
def get_log_identifier(self, obj):
|
|
807
|
-
return obj.cp_path
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
class MeterValueInline(admin.TabularInline):
|
|
811
|
-
model = MeterValue
|
|
812
|
-
extra = 0
|
|
813
|
-
fields = (
|
|
814
|
-
"timestamp",
|
|
815
|
-
"context",
|
|
816
|
-
"energy",
|
|
817
|
-
"voltage",
|
|
818
|
-
"current_import",
|
|
819
|
-
"current_offered",
|
|
820
|
-
"temperature",
|
|
821
|
-
"soc",
|
|
822
|
-
"connector_id",
|
|
823
|
-
)
|
|
824
|
-
readonly_fields = fields
|
|
825
|
-
can_delete = False
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
@admin.register(Transaction)
|
|
829
|
-
class TransactionAdmin(EntityModelAdmin):
|
|
830
|
-
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
831
|
-
list_display = (
|
|
832
|
-
"charger",
|
|
833
|
-
"account",
|
|
834
|
-
"rfid",
|
|
835
|
-
"meter_start",
|
|
836
|
-
"meter_stop",
|
|
837
|
-
"start_time",
|
|
838
|
-
"stop_time",
|
|
839
|
-
"kw",
|
|
840
|
-
)
|
|
841
|
-
readonly_fields = ("kw", "received_start_time", "received_stop_time")
|
|
842
|
-
list_filter = ("charger", "account")
|
|
843
|
-
date_hierarchy = "start_time"
|
|
844
|
-
inlines = [MeterValueInline]
|
|
845
|
-
|
|
846
|
-
def get_urls(self):
|
|
847
|
-
urls = super().get_urls()
|
|
848
|
-
custom = [
|
|
849
|
-
path(
|
|
850
|
-
"export/",
|
|
851
|
-
self.admin_site.admin_view(self.export_view),
|
|
852
|
-
name="ocpp_transaction_export",
|
|
853
|
-
),
|
|
854
|
-
path(
|
|
855
|
-
"import/",
|
|
856
|
-
self.admin_site.admin_view(self.import_view),
|
|
857
|
-
name="ocpp_transaction_import",
|
|
858
|
-
),
|
|
859
|
-
]
|
|
860
|
-
return custom + urls
|
|
861
|
-
|
|
862
|
-
def export_view(self, request):
|
|
863
|
-
if request.method == "POST":
|
|
864
|
-
form = TransactionExportForm(request.POST)
|
|
865
|
-
if form.is_valid():
|
|
866
|
-
chargers = form.cleaned_data["chargers"]
|
|
867
|
-
data = export_transactions(
|
|
868
|
-
start=form.cleaned_data["start"],
|
|
869
|
-
end=form.cleaned_data["end"],
|
|
870
|
-
chargers=[c.charger_id for c in chargers] if chargers else None,
|
|
871
|
-
)
|
|
872
|
-
response = HttpResponse(
|
|
873
|
-
json.dumps(data, indent=2, ensure_ascii=False),
|
|
874
|
-
content_type="application/json",
|
|
875
|
-
)
|
|
876
|
-
response["Content-Disposition"] = (
|
|
877
|
-
"attachment; filename=transactions.json"
|
|
878
|
-
)
|
|
879
|
-
return response
|
|
880
|
-
else:
|
|
881
|
-
form = TransactionExportForm()
|
|
882
|
-
context = self.admin_site.each_context(request)
|
|
883
|
-
context["form"] = form
|
|
884
|
-
return TemplateResponse(request, "admin/ocpp/transaction/export.html", context)
|
|
885
|
-
|
|
886
|
-
def import_view(self, request):
|
|
887
|
-
if request.method == "POST":
|
|
888
|
-
form = TransactionImportForm(request.POST, request.FILES)
|
|
889
|
-
if form.is_valid():
|
|
890
|
-
data = json.load(form.cleaned_data["file"])
|
|
891
|
-
imported = import_transactions_data(data)
|
|
892
|
-
self.message_user(request, f"Imported {imported} transactions")
|
|
893
|
-
return HttpResponseRedirect("../")
|
|
894
|
-
else:
|
|
895
|
-
form = TransactionImportForm()
|
|
896
|
-
context = self.admin_site.each_context(request)
|
|
897
|
-
context["form"] = form
|
|
898
|
-
return TemplateResponse(request, "admin/ocpp/transaction/import.html", context)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
class MeterValueDateFilter(admin.SimpleListFilter):
|
|
902
|
-
title = "Timestamp"
|
|
903
|
-
parameter_name = "timestamp_range"
|
|
904
|
-
|
|
905
|
-
def lookups(self, request, model_admin):
|
|
906
|
-
return [
|
|
907
|
-
("today", "Today"),
|
|
908
|
-
("7days", "Last 7 days"),
|
|
909
|
-
("30days", "Last 30 days"),
|
|
910
|
-
("older", "Older than 30 days"),
|
|
911
|
-
]
|
|
912
|
-
|
|
913
|
-
def queryset(self, request, queryset):
|
|
914
|
-
value = self.value()
|
|
915
|
-
now = timezone.now()
|
|
916
|
-
if value == "today":
|
|
917
|
-
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
918
|
-
end = start + timedelta(days=1)
|
|
919
|
-
return queryset.filter(timestamp__gte=start, timestamp__lt=end)
|
|
920
|
-
if value == "7days":
|
|
921
|
-
start = now - timedelta(days=7)
|
|
922
|
-
return queryset.filter(timestamp__gte=start)
|
|
923
|
-
if value == "30days":
|
|
924
|
-
start = now - timedelta(days=30)
|
|
925
|
-
return queryset.filter(timestamp__gte=start)
|
|
926
|
-
if value == "older":
|
|
927
|
-
cutoff = now - timedelta(days=30)
|
|
928
|
-
return queryset.filter(timestamp__lt=cutoff)
|
|
929
|
-
return queryset
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
@admin.register(MeterValue)
|
|
933
|
-
class MeterValueAdmin(EntityModelAdmin):
|
|
934
|
-
list_display = (
|
|
935
|
-
"charger",
|
|
936
|
-
"timestamp",
|
|
937
|
-
"context",
|
|
938
|
-
"energy",
|
|
939
|
-
"voltage",
|
|
940
|
-
"current_import",
|
|
941
|
-
"current_offered",
|
|
942
|
-
"temperature",
|
|
943
|
-
"soc",
|
|
944
|
-
"connector_id",
|
|
945
|
-
"transaction",
|
|
946
|
-
)
|
|
947
|
-
date_hierarchy = "timestamp"
|
|
948
|
-
list_filter = ("charger", MeterValueDateFilter)
|
|
1
|
+
from django.contrib import admin, messages
|
|
2
|
+
from django import forms
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from django.shortcuts import redirect
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
from django.urls import path
|
|
11
|
+
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
|
+
from django.template.response import TemplateResponse
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from asgiref.sync import async_to_sync
|
|
16
|
+
|
|
17
|
+
from .models import (
|
|
18
|
+
Charger,
|
|
19
|
+
Simulator,
|
|
20
|
+
MeterValue,
|
|
21
|
+
Transaction,
|
|
22
|
+
Location,
|
|
23
|
+
DataTransferMessage,
|
|
24
|
+
)
|
|
25
|
+
from .simulator import ChargePointSimulator
|
|
26
|
+
from . import store
|
|
27
|
+
from .transactions_io import (
|
|
28
|
+
export_transactions,
|
|
29
|
+
import_transactions as import_transactions_data,
|
|
30
|
+
)
|
|
31
|
+
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
32
|
+
from core.admin import SaveBeforeChangeAction
|
|
33
|
+
from core.user_data import EntityModelAdmin
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocationAdminForm(forms.ModelForm):
|
|
37
|
+
class Meta:
|
|
38
|
+
model = Location
|
|
39
|
+
fields = "__all__"
|
|
40
|
+
|
|
41
|
+
widgets = {
|
|
42
|
+
"latitude": forms.NumberInput(attrs={"step": "any"}),
|
|
43
|
+
"longitude": forms.NumberInput(attrs={"step": "any"}),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class Media:
|
|
47
|
+
css = {"all": ("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",)}
|
|
48
|
+
js = (
|
|
49
|
+
"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
|
|
50
|
+
"ocpp/charger_map.js",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TransactionExportForm(forms.Form):
|
|
55
|
+
start = forms.DateTimeField(required=False)
|
|
56
|
+
end = forms.DateTimeField(required=False)
|
|
57
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
58
|
+
queryset=Charger.objects.all(), required=False
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TransactionImportForm(forms.Form):
|
|
63
|
+
file = forms.FileField()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LogViewAdminMixin:
|
|
67
|
+
"""Mixin providing an admin view to display charger or simulator logs."""
|
|
68
|
+
|
|
69
|
+
log_type = "charger"
|
|
70
|
+
log_template_name = "admin/ocpp/log_view.html"
|
|
71
|
+
|
|
72
|
+
def get_log_identifier(self, obj): # pragma: no cover - mixin hook
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
def get_log_title(self, obj):
|
|
76
|
+
return f"Log for {obj}"
|
|
77
|
+
|
|
78
|
+
def get_urls(self):
|
|
79
|
+
urls = super().get_urls()
|
|
80
|
+
info = self.model._meta.app_label, self.model._meta.model_name
|
|
81
|
+
custom = [
|
|
82
|
+
path(
|
|
83
|
+
"<path:object_id>/log/",
|
|
84
|
+
self.admin_site.admin_view(self.log_view),
|
|
85
|
+
name=f"{info[0]}_{info[1]}_log",
|
|
86
|
+
),
|
|
87
|
+
]
|
|
88
|
+
return custom + urls
|
|
89
|
+
|
|
90
|
+
def log_view(self, request, object_id):
|
|
91
|
+
obj = self.get_object(request, object_id)
|
|
92
|
+
if obj is None:
|
|
93
|
+
self.message_user(request, "Log is not available.", messages.ERROR)
|
|
94
|
+
return redirect("..")
|
|
95
|
+
identifier = self.get_log_identifier(obj)
|
|
96
|
+
log_entries = store.get_logs(identifier, log_type=self.log_type)
|
|
97
|
+
log_file = store._file_path(identifier, log_type=self.log_type)
|
|
98
|
+
context = {
|
|
99
|
+
**self.admin_site.each_context(request),
|
|
100
|
+
"opts": self.model._meta,
|
|
101
|
+
"original": obj,
|
|
102
|
+
"title": self.get_log_title(obj),
|
|
103
|
+
"log_entries": log_entries,
|
|
104
|
+
"log_file": str(log_file),
|
|
105
|
+
"log_identifier": identifier,
|
|
106
|
+
}
|
|
107
|
+
return TemplateResponse(request, self.log_template_name, context)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@admin.register(Location)
|
|
111
|
+
class LocationAdmin(EntityModelAdmin):
|
|
112
|
+
form = LocationAdminForm
|
|
113
|
+
list_display = ("name", "latitude", "longitude")
|
|
114
|
+
change_form_template = "admin/ocpp/location/change_form.html"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@admin.register(DataTransferMessage)
|
|
118
|
+
class DataTransferMessageAdmin(admin.ModelAdmin):
|
|
119
|
+
list_display = (
|
|
120
|
+
"charger",
|
|
121
|
+
"connector_id",
|
|
122
|
+
"direction",
|
|
123
|
+
"vendor_id",
|
|
124
|
+
"message_id",
|
|
125
|
+
"status",
|
|
126
|
+
"created_at",
|
|
127
|
+
"responded_at",
|
|
128
|
+
)
|
|
129
|
+
list_filter = ("direction", "status")
|
|
130
|
+
search_fields = (
|
|
131
|
+
"charger__charger_id",
|
|
132
|
+
"ocpp_message_id",
|
|
133
|
+
"vendor_id",
|
|
134
|
+
"message_id",
|
|
135
|
+
)
|
|
136
|
+
readonly_fields = (
|
|
137
|
+
"charger",
|
|
138
|
+
"connector_id",
|
|
139
|
+
"direction",
|
|
140
|
+
"ocpp_message_id",
|
|
141
|
+
"vendor_id",
|
|
142
|
+
"message_id",
|
|
143
|
+
"payload",
|
|
144
|
+
"status",
|
|
145
|
+
"response_data",
|
|
146
|
+
"error_code",
|
|
147
|
+
"error_description",
|
|
148
|
+
"error_details",
|
|
149
|
+
"responded_at",
|
|
150
|
+
"created_at",
|
|
151
|
+
"updated_at",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@admin.register(Charger)
|
|
156
|
+
class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
157
|
+
fieldsets = (
|
|
158
|
+
(
|
|
159
|
+
"General",
|
|
160
|
+
{
|
|
161
|
+
"fields": (
|
|
162
|
+
"charger_id",
|
|
163
|
+
"display_name",
|
|
164
|
+
"connector_id",
|
|
165
|
+
"location",
|
|
166
|
+
"last_path",
|
|
167
|
+
"last_heartbeat",
|
|
168
|
+
"last_meter_values",
|
|
169
|
+
)
|
|
170
|
+
},
|
|
171
|
+
),
|
|
172
|
+
(
|
|
173
|
+
"Firmware",
|
|
174
|
+
{
|
|
175
|
+
"fields": (
|
|
176
|
+
"firmware_status",
|
|
177
|
+
"firmware_status_info",
|
|
178
|
+
"firmware_timestamp",
|
|
179
|
+
)
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
(
|
|
183
|
+
"Diagnostics",
|
|
184
|
+
{
|
|
185
|
+
"fields": (
|
|
186
|
+
"diagnostics_status",
|
|
187
|
+
"diagnostics_timestamp",
|
|
188
|
+
"diagnostics_location",
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
),
|
|
192
|
+
(
|
|
193
|
+
"Availability",
|
|
194
|
+
{
|
|
195
|
+
"fields": (
|
|
196
|
+
"availability_state",
|
|
197
|
+
"availability_state_updated_at",
|
|
198
|
+
"availability_requested_state",
|
|
199
|
+
"availability_requested_at",
|
|
200
|
+
"availability_request_status",
|
|
201
|
+
"availability_request_status_at",
|
|
202
|
+
"availability_request_details",
|
|
203
|
+
)
|
|
204
|
+
},
|
|
205
|
+
),
|
|
206
|
+
(
|
|
207
|
+
"Configuration",
|
|
208
|
+
{"fields": ("public_display", "require_rfid")},
|
|
209
|
+
),
|
|
210
|
+
(
|
|
211
|
+
"References",
|
|
212
|
+
{
|
|
213
|
+
"fields": ("reference",),
|
|
214
|
+
},
|
|
215
|
+
),
|
|
216
|
+
(
|
|
217
|
+
"Owner",
|
|
218
|
+
{
|
|
219
|
+
"fields": ("owner_users", "owner_groups"),
|
|
220
|
+
"classes": ("collapse",),
|
|
221
|
+
},
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
readonly_fields = (
|
|
225
|
+
"last_heartbeat",
|
|
226
|
+
"last_meter_values",
|
|
227
|
+
"firmware_status",
|
|
228
|
+
"firmware_status_info",
|
|
229
|
+
"firmware_timestamp",
|
|
230
|
+
"availability_state",
|
|
231
|
+
"availability_state_updated_at",
|
|
232
|
+
"availability_requested_state",
|
|
233
|
+
"availability_requested_at",
|
|
234
|
+
"availability_request_status",
|
|
235
|
+
"availability_request_status_at",
|
|
236
|
+
"availability_request_details",
|
|
237
|
+
)
|
|
238
|
+
list_display = (
|
|
239
|
+
"charger_id",
|
|
240
|
+
"connector_number",
|
|
241
|
+
"location_name",
|
|
242
|
+
"require_rfid_display",
|
|
243
|
+
"public_display",
|
|
244
|
+
"last_heartbeat",
|
|
245
|
+
"session_kw",
|
|
246
|
+
"total_kw_display",
|
|
247
|
+
"page_link",
|
|
248
|
+
"log_link",
|
|
249
|
+
"status_link",
|
|
250
|
+
)
|
|
251
|
+
search_fields = ("charger_id", "connector_id", "location__name")
|
|
252
|
+
filter_horizontal = ("owner_users", "owner_groups")
|
|
253
|
+
actions = [
|
|
254
|
+
"purge_data",
|
|
255
|
+
"fetch_cp_configuration",
|
|
256
|
+
"change_availability_operative",
|
|
257
|
+
"change_availability_inoperative",
|
|
258
|
+
"set_availability_state_operative",
|
|
259
|
+
"set_availability_state_inoperative",
|
|
260
|
+
"remote_stop_transaction",
|
|
261
|
+
"reset_chargers",
|
|
262
|
+
"delete_selected",
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
def get_view_on_site_url(self, obj=None):
|
|
266
|
+
return obj.get_absolute_url() if obj else None
|
|
267
|
+
|
|
268
|
+
def require_rfid_display(self, obj):
|
|
269
|
+
return obj.require_rfid
|
|
270
|
+
|
|
271
|
+
require_rfid_display.boolean = True
|
|
272
|
+
require_rfid_display.short_description = "RFID Auth"
|
|
273
|
+
|
|
274
|
+
def page_link(self, obj):
|
|
275
|
+
from django.utils.html import format_html
|
|
276
|
+
|
|
277
|
+
return format_html(
|
|
278
|
+
'<a href="{}" target="_blank">open</a>', obj.get_absolute_url()
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
page_link.short_description = "Landing"
|
|
282
|
+
|
|
283
|
+
def qr_link(self, obj):
|
|
284
|
+
from django.utils.html import format_html
|
|
285
|
+
|
|
286
|
+
if obj.reference and obj.reference.image:
|
|
287
|
+
return format_html(
|
|
288
|
+
'<a href="{}" target="_blank">qr</a>', obj.reference.image.url
|
|
289
|
+
)
|
|
290
|
+
return ""
|
|
291
|
+
|
|
292
|
+
qr_link.short_description = "QR Code"
|
|
293
|
+
|
|
294
|
+
def log_link(self, obj):
|
|
295
|
+
from django.utils.html import format_html
|
|
296
|
+
from django.urls import reverse
|
|
297
|
+
|
|
298
|
+
url = reverse("admin:ocpp_charger_log", args=[obj.pk])
|
|
299
|
+
return format_html('<a href="{}" target="_blank">view</a>', url)
|
|
300
|
+
|
|
301
|
+
log_link.short_description = "Log"
|
|
302
|
+
|
|
303
|
+
def get_log_identifier(self, obj):
|
|
304
|
+
return store.identity_key(obj.charger_id, obj.connector_id)
|
|
305
|
+
|
|
306
|
+
def connector_number(self, obj):
|
|
307
|
+
return obj.connector_id if obj.connector_id is not None else ""
|
|
308
|
+
|
|
309
|
+
connector_number.short_description = "#"
|
|
310
|
+
connector_number.admin_order_field = "connector_id"
|
|
311
|
+
|
|
312
|
+
def status_link(self, obj):
|
|
313
|
+
from django.utils.html import format_html
|
|
314
|
+
from django.urls import reverse
|
|
315
|
+
|
|
316
|
+
url = reverse(
|
|
317
|
+
"charger-status-connector",
|
|
318
|
+
args=[obj.charger_id, obj.connector_slug],
|
|
319
|
+
)
|
|
320
|
+
label = (obj.last_status or "status").strip() or "status"
|
|
321
|
+
status_key = label.lower()
|
|
322
|
+
error_code = (obj.last_error_code or "").strip().lower()
|
|
323
|
+
if (
|
|
324
|
+
self._has_active_session(obj)
|
|
325
|
+
and error_code in ERROR_OK_VALUES
|
|
326
|
+
and (status_key not in STATUS_BADGE_MAP or status_key == "available")
|
|
327
|
+
):
|
|
328
|
+
label = STATUS_BADGE_MAP["charging"][0]
|
|
329
|
+
return format_html('<a href="{}" target="_blank">{}</a>', url, label)
|
|
330
|
+
|
|
331
|
+
status_link.short_description = "Status"
|
|
332
|
+
|
|
333
|
+
def _has_active_session(self, charger: Charger) -> bool:
|
|
334
|
+
"""Return whether ``charger`` currently has an active session."""
|
|
335
|
+
|
|
336
|
+
if store.get_transaction(charger.charger_id, charger.connector_id):
|
|
337
|
+
return True
|
|
338
|
+
if charger.connector_id is not None:
|
|
339
|
+
return False
|
|
340
|
+
sibling_connectors = (
|
|
341
|
+
Charger.objects.filter(charger_id=charger.charger_id)
|
|
342
|
+
.exclude(pk=charger.pk)
|
|
343
|
+
.values_list("connector_id", flat=True)
|
|
344
|
+
)
|
|
345
|
+
for connector_id in sibling_connectors:
|
|
346
|
+
if store.get_transaction(charger.charger_id, connector_id):
|
|
347
|
+
return True
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
def location_name(self, obj):
|
|
351
|
+
return obj.location.name if obj.location else ""
|
|
352
|
+
|
|
353
|
+
location_name.short_description = "Location"
|
|
354
|
+
|
|
355
|
+
def purge_data(self, request, queryset):
|
|
356
|
+
for charger in queryset:
|
|
357
|
+
charger.purge()
|
|
358
|
+
self.message_user(request, "Data purged for selected chargers")
|
|
359
|
+
|
|
360
|
+
purge_data.short_description = "Purge data"
|
|
361
|
+
|
|
362
|
+
@admin.action(description="Fetch CP configuration")
|
|
363
|
+
def fetch_cp_configuration(self, request, queryset):
|
|
364
|
+
fetched = 0
|
|
365
|
+
for charger in queryset:
|
|
366
|
+
connector_value = charger.connector_id
|
|
367
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
368
|
+
if ws is None:
|
|
369
|
+
self.message_user(
|
|
370
|
+
request,
|
|
371
|
+
f"{charger}: no active connection",
|
|
372
|
+
level=messages.ERROR,
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
message_id = uuid.uuid4().hex
|
|
376
|
+
payload = {}
|
|
377
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
378
|
+
try:
|
|
379
|
+
async_to_sync(ws.send)(msg)
|
|
380
|
+
except Exception as exc: # pragma: no cover - network error
|
|
381
|
+
self.message_user(
|
|
382
|
+
request,
|
|
383
|
+
f"{charger}: failed to send GetConfiguration ({exc})",
|
|
384
|
+
level=messages.ERROR,
|
|
385
|
+
)
|
|
386
|
+
continue
|
|
387
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
388
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
389
|
+
store.register_pending_call(
|
|
390
|
+
message_id,
|
|
391
|
+
{
|
|
392
|
+
"action": "GetConfiguration",
|
|
393
|
+
"charger_id": charger.charger_id,
|
|
394
|
+
"connector_id": connector_value,
|
|
395
|
+
"log_key": log_key,
|
|
396
|
+
"requested_at": timezone.now(),
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
store.schedule_call_timeout(
|
|
400
|
+
message_id,
|
|
401
|
+
timeout=5.0,
|
|
402
|
+
action="GetConfiguration",
|
|
403
|
+
log_key=log_key,
|
|
404
|
+
message=(
|
|
405
|
+
"GetConfiguration timed out: charger did not respond"
|
|
406
|
+
" (operation may not be supported)"
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
fetched += 1
|
|
410
|
+
if fetched:
|
|
411
|
+
self.message_user(
|
|
412
|
+
request,
|
|
413
|
+
f"Requested configuration from {fetched} charger(s)",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _dispatch_change_availability(self, request, queryset, availability_type: str):
|
|
417
|
+
sent = 0
|
|
418
|
+
for charger in queryset:
|
|
419
|
+
connector_value = charger.connector_id
|
|
420
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
421
|
+
if ws is None:
|
|
422
|
+
self.message_user(
|
|
423
|
+
request,
|
|
424
|
+
f"{charger}: no active connection",
|
|
425
|
+
level=messages.ERROR,
|
|
426
|
+
)
|
|
427
|
+
continue
|
|
428
|
+
connector_id = connector_value if connector_value is not None else 0
|
|
429
|
+
message_id = uuid.uuid4().hex
|
|
430
|
+
payload = {"connectorId": connector_id, "type": availability_type}
|
|
431
|
+
msg = json.dumps([2, message_id, "ChangeAvailability", payload])
|
|
432
|
+
try:
|
|
433
|
+
async_to_sync(ws.send)(msg)
|
|
434
|
+
except Exception as exc: # pragma: no cover - network error
|
|
435
|
+
self.message_user(
|
|
436
|
+
request,
|
|
437
|
+
f"{charger}: failed to send ChangeAvailability ({exc})",
|
|
438
|
+
level=messages.ERROR,
|
|
439
|
+
)
|
|
440
|
+
continue
|
|
441
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
442
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
443
|
+
timestamp = timezone.now()
|
|
444
|
+
store.register_pending_call(
|
|
445
|
+
message_id,
|
|
446
|
+
{
|
|
447
|
+
"action": "ChangeAvailability",
|
|
448
|
+
"charger_id": charger.charger_id,
|
|
449
|
+
"connector_id": connector_value,
|
|
450
|
+
"availability_type": availability_type,
|
|
451
|
+
"requested_at": timestamp,
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
updates = {
|
|
455
|
+
"availability_requested_state": availability_type,
|
|
456
|
+
"availability_requested_at": timestamp,
|
|
457
|
+
"availability_request_status": "",
|
|
458
|
+
"availability_request_status_at": None,
|
|
459
|
+
"availability_request_details": "",
|
|
460
|
+
}
|
|
461
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
462
|
+
for field, value in updates.items():
|
|
463
|
+
setattr(charger, field, value)
|
|
464
|
+
sent += 1
|
|
465
|
+
if sent:
|
|
466
|
+
self.message_user(
|
|
467
|
+
request,
|
|
468
|
+
f"Sent ChangeAvailability ({availability_type}) to {sent} charger(s)",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
@admin.action(description="Set availability to Operative")
|
|
472
|
+
def change_availability_operative(self, request, queryset):
|
|
473
|
+
self._dispatch_change_availability(request, queryset, "Operative")
|
|
474
|
+
|
|
475
|
+
@admin.action(description="Set availability to Inoperative")
|
|
476
|
+
def change_availability_inoperative(self, request, queryset):
|
|
477
|
+
self._dispatch_change_availability(request, queryset, "Inoperative")
|
|
478
|
+
|
|
479
|
+
def _set_availability_state(
|
|
480
|
+
self, request, queryset, availability_state: str
|
|
481
|
+
) -> None:
|
|
482
|
+
timestamp = timezone.now()
|
|
483
|
+
updated = 0
|
|
484
|
+
for charger in queryset:
|
|
485
|
+
updates = {
|
|
486
|
+
"availability_state": availability_state,
|
|
487
|
+
"availability_state_updated_at": timestamp,
|
|
488
|
+
}
|
|
489
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
490
|
+
for field, value in updates.items():
|
|
491
|
+
setattr(charger, field, value)
|
|
492
|
+
updated += 1
|
|
493
|
+
if updated:
|
|
494
|
+
self.message_user(
|
|
495
|
+
request,
|
|
496
|
+
f"Updated availability to {availability_state} for {updated} charger(s)",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
@admin.action(description="Mark availability as Operative")
|
|
500
|
+
def set_availability_state_operative(self, request, queryset):
|
|
501
|
+
self._set_availability_state(request, queryset, "Operative")
|
|
502
|
+
|
|
503
|
+
@admin.action(description="Mark availability as Inoperative")
|
|
504
|
+
def set_availability_state_inoperative(self, request, queryset):
|
|
505
|
+
self._set_availability_state(request, queryset, "Inoperative")
|
|
506
|
+
|
|
507
|
+
@admin.action(description="Remote stop active transaction")
|
|
508
|
+
def remote_stop_transaction(self, request, queryset):
|
|
509
|
+
stopped = 0
|
|
510
|
+
for charger in queryset:
|
|
511
|
+
connector_value = charger.connector_id
|
|
512
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
513
|
+
if ws is None:
|
|
514
|
+
self.message_user(
|
|
515
|
+
request,
|
|
516
|
+
f"{charger}: no active connection",
|
|
517
|
+
level=messages.ERROR,
|
|
518
|
+
)
|
|
519
|
+
continue
|
|
520
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
521
|
+
if tx_obj is None:
|
|
522
|
+
self.message_user(
|
|
523
|
+
request,
|
|
524
|
+
f"{charger}: no active transaction",
|
|
525
|
+
level=messages.ERROR,
|
|
526
|
+
)
|
|
527
|
+
continue
|
|
528
|
+
message_id = uuid.uuid4().hex
|
|
529
|
+
payload = {"transactionId": tx_obj.pk}
|
|
530
|
+
msg = json.dumps([
|
|
531
|
+
2,
|
|
532
|
+
message_id,
|
|
533
|
+
"RemoteStopTransaction",
|
|
534
|
+
payload,
|
|
535
|
+
])
|
|
536
|
+
try:
|
|
537
|
+
async_to_sync(ws.send)(msg)
|
|
538
|
+
except Exception as exc: # pragma: no cover - network error
|
|
539
|
+
self.message_user(
|
|
540
|
+
request,
|
|
541
|
+
f"{charger}: failed to send RemoteStopTransaction ({exc})",
|
|
542
|
+
level=messages.ERROR,
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
546
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
547
|
+
store.register_pending_call(
|
|
548
|
+
message_id,
|
|
549
|
+
{
|
|
550
|
+
"action": "RemoteStopTransaction",
|
|
551
|
+
"charger_id": charger.charger_id,
|
|
552
|
+
"connector_id": connector_value,
|
|
553
|
+
"transaction_id": tx_obj.pk,
|
|
554
|
+
"log_key": log_key,
|
|
555
|
+
"requested_at": timezone.now(),
|
|
556
|
+
},
|
|
557
|
+
)
|
|
558
|
+
stopped += 1
|
|
559
|
+
if stopped:
|
|
560
|
+
self.message_user(
|
|
561
|
+
request,
|
|
562
|
+
f"Sent RemoteStopTransaction to {stopped} charger(s)",
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
@admin.action(description="Reset charger (soft)")
|
|
566
|
+
def reset_chargers(self, request, queryset):
|
|
567
|
+
reset = 0
|
|
568
|
+
for charger in queryset:
|
|
569
|
+
connector_value = charger.connector_id
|
|
570
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
571
|
+
if ws is None:
|
|
572
|
+
self.message_user(
|
|
573
|
+
request,
|
|
574
|
+
f"{charger}: no active connection",
|
|
575
|
+
level=messages.ERROR,
|
|
576
|
+
)
|
|
577
|
+
continue
|
|
578
|
+
message_id = uuid.uuid4().hex
|
|
579
|
+
msg = json.dumps([
|
|
580
|
+
2,
|
|
581
|
+
message_id,
|
|
582
|
+
"Reset",
|
|
583
|
+
{"type": "Soft"},
|
|
584
|
+
])
|
|
585
|
+
try:
|
|
586
|
+
async_to_sync(ws.send)(msg)
|
|
587
|
+
except Exception as exc: # pragma: no cover - network error
|
|
588
|
+
self.message_user(
|
|
589
|
+
request,
|
|
590
|
+
f"{charger}: failed to send Reset ({exc})",
|
|
591
|
+
level=messages.ERROR,
|
|
592
|
+
)
|
|
593
|
+
continue
|
|
594
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
595
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
596
|
+
store.register_pending_call(
|
|
597
|
+
message_id,
|
|
598
|
+
{
|
|
599
|
+
"action": "Reset",
|
|
600
|
+
"charger_id": charger.charger_id,
|
|
601
|
+
"connector_id": connector_value,
|
|
602
|
+
"log_key": log_key,
|
|
603
|
+
"requested_at": timezone.now(),
|
|
604
|
+
},
|
|
605
|
+
)
|
|
606
|
+
reset += 1
|
|
607
|
+
if reset:
|
|
608
|
+
self.message_user(
|
|
609
|
+
request,
|
|
610
|
+
f"Sent Reset to {reset} charger(s)",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def delete_queryset(self, request, queryset):
|
|
614
|
+
for obj in queryset:
|
|
615
|
+
obj.delete()
|
|
616
|
+
|
|
617
|
+
def total_kw_display(self, obj):
|
|
618
|
+
return round(obj.total_kw, 2)
|
|
619
|
+
|
|
620
|
+
total_kw_display.short_description = "Total kW"
|
|
621
|
+
|
|
622
|
+
def session_kw(self, obj):
|
|
623
|
+
tx = store.get_transaction(obj.charger_id, obj.connector_id)
|
|
624
|
+
if tx:
|
|
625
|
+
return round(tx.kw, 2)
|
|
626
|
+
return 0.0
|
|
627
|
+
|
|
628
|
+
session_kw.short_description = "Session kW"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@admin.register(Simulator)
|
|
632
|
+
class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin):
|
|
633
|
+
list_display = (
|
|
634
|
+
"name",
|
|
635
|
+
"cp_path",
|
|
636
|
+
"host",
|
|
637
|
+
"ws_port",
|
|
638
|
+
"ws_url",
|
|
639
|
+
"interval",
|
|
640
|
+
"kw_max",
|
|
641
|
+
"running",
|
|
642
|
+
"log_link",
|
|
643
|
+
)
|
|
644
|
+
fieldsets = (
|
|
645
|
+
(
|
|
646
|
+
None,
|
|
647
|
+
{
|
|
648
|
+
"fields": (
|
|
649
|
+
"name",
|
|
650
|
+
"cp_path",
|
|
651
|
+
("host", "ws_port"),
|
|
652
|
+
"rfid",
|
|
653
|
+
("duration", "interval", "pre_charge_delay"),
|
|
654
|
+
"kw_max",
|
|
655
|
+
("repeat", "door_open"),
|
|
656
|
+
("username", "password"),
|
|
657
|
+
)
|
|
658
|
+
},
|
|
659
|
+
),
|
|
660
|
+
(
|
|
661
|
+
"Configuration",
|
|
662
|
+
{
|
|
663
|
+
"fields": (
|
|
664
|
+
"configuration_keys",
|
|
665
|
+
"configuration_unknown_keys",
|
|
666
|
+
),
|
|
667
|
+
"classes": ("collapse",),
|
|
668
|
+
"description": (
|
|
669
|
+
"Provide JSON lists for configurationKey entries and "
|
|
670
|
+
"unknownKey values returned by GetConfiguration."
|
|
671
|
+
),
|
|
672
|
+
},
|
|
673
|
+
),
|
|
674
|
+
)
|
|
675
|
+
actions = ("start_simulator", "stop_simulator", "send_open_door")
|
|
676
|
+
change_actions = ["start_simulator_action", "stop_simulator_action"]
|
|
677
|
+
|
|
678
|
+
log_type = "simulator"
|
|
679
|
+
|
|
680
|
+
def save_model(self, request, obj, form, change):
|
|
681
|
+
previous_door_open = False
|
|
682
|
+
if change and obj.pk:
|
|
683
|
+
previous_door_open = (
|
|
684
|
+
type(obj)
|
|
685
|
+
.objects.filter(pk=obj.pk)
|
|
686
|
+
.values_list("door_open", flat=True)
|
|
687
|
+
.first()
|
|
688
|
+
or False
|
|
689
|
+
)
|
|
690
|
+
super().save_model(request, obj, form, change)
|
|
691
|
+
if obj.door_open and not previous_door_open:
|
|
692
|
+
triggered = self._queue_door_open(request, obj)
|
|
693
|
+
if not triggered:
|
|
694
|
+
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
695
|
+
obj.door_open = False
|
|
696
|
+
|
|
697
|
+
def _queue_door_open(self, request, obj) -> bool:
|
|
698
|
+
sim = store.simulators.get(obj.pk)
|
|
699
|
+
if not sim:
|
|
700
|
+
self.message_user(
|
|
701
|
+
request,
|
|
702
|
+
f"{obj.name}: simulator is not running",
|
|
703
|
+
level=messages.ERROR,
|
|
704
|
+
)
|
|
705
|
+
return False
|
|
706
|
+
type(obj).objects.filter(pk=obj.pk).update(door_open=True)
|
|
707
|
+
obj.door_open = True
|
|
708
|
+
store.add_log(
|
|
709
|
+
obj.cp_path,
|
|
710
|
+
"Door open event requested from admin",
|
|
711
|
+
log_type="simulator",
|
|
712
|
+
)
|
|
713
|
+
if hasattr(sim, "trigger_door_open"):
|
|
714
|
+
sim.trigger_door_open()
|
|
715
|
+
else: # pragma: no cover - unexpected condition
|
|
716
|
+
self.message_user(
|
|
717
|
+
request,
|
|
718
|
+
f"{obj.name}: simulator cannot send door open event",
|
|
719
|
+
level=messages.ERROR,
|
|
720
|
+
)
|
|
721
|
+
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
722
|
+
obj.door_open = False
|
|
723
|
+
return False
|
|
724
|
+
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
725
|
+
obj.door_open = False
|
|
726
|
+
self.message_user(
|
|
727
|
+
request,
|
|
728
|
+
f"{obj.name}: DoorOpen status notification sent",
|
|
729
|
+
)
|
|
730
|
+
return True
|
|
731
|
+
|
|
732
|
+
def running(self, obj):
|
|
733
|
+
return obj.pk in store.simulators
|
|
734
|
+
|
|
735
|
+
running.boolean = True
|
|
736
|
+
|
|
737
|
+
@admin.action(description="Send Open Door")
|
|
738
|
+
def send_open_door(self, request, queryset):
|
|
739
|
+
for obj in queryset:
|
|
740
|
+
self._queue_door_open(request, obj)
|
|
741
|
+
|
|
742
|
+
def start_simulator(self, request, queryset):
|
|
743
|
+
from django.urls import reverse
|
|
744
|
+
from django.utils.html import format_html
|
|
745
|
+
|
|
746
|
+
for obj in queryset:
|
|
747
|
+
if obj.pk in store.simulators:
|
|
748
|
+
self.message_user(request, f"{obj.name}: already running")
|
|
749
|
+
continue
|
|
750
|
+
type(obj).objects.filter(pk=obj.pk).update(door_open=False)
|
|
751
|
+
obj.door_open = False
|
|
752
|
+
store.register_log_name(obj.cp_path, obj.name, log_type="simulator")
|
|
753
|
+
sim = ChargePointSimulator(obj.as_config())
|
|
754
|
+
started, status, log_file = sim.start()
|
|
755
|
+
if started:
|
|
756
|
+
store.simulators[obj.pk] = sim
|
|
757
|
+
log_url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
|
|
758
|
+
self.message_user(
|
|
759
|
+
request,
|
|
760
|
+
format_html(
|
|
761
|
+
'{}: {}. Log: <code>{}</code> (<a href="{}" target="_blank">View Log</a>)',
|
|
762
|
+
obj.name,
|
|
763
|
+
status,
|
|
764
|
+
log_file,
|
|
765
|
+
log_url,
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
start_simulator.short_description = "Start selected simulators"
|
|
770
|
+
|
|
771
|
+
def stop_simulator(self, request, queryset):
|
|
772
|
+
async def _stop(objs):
|
|
773
|
+
for obj in objs:
|
|
774
|
+
sim = store.simulators.pop(obj.pk, None)
|
|
775
|
+
if sim:
|
|
776
|
+
await sim.stop()
|
|
777
|
+
|
|
778
|
+
objs = list(queryset)
|
|
779
|
+
try:
|
|
780
|
+
loop = asyncio.get_running_loop()
|
|
781
|
+
except RuntimeError:
|
|
782
|
+
asyncio.run(_stop(objs))
|
|
783
|
+
else:
|
|
784
|
+
loop.create_task(_stop(objs))
|
|
785
|
+
self.message_user(request, "Stopping simulators")
|
|
786
|
+
|
|
787
|
+
stop_simulator.short_description = "Stop selected simulators"
|
|
788
|
+
|
|
789
|
+
def start_simulator_action(self, request, obj):
|
|
790
|
+
queryset = type(obj).objects.filter(pk=obj.pk)
|
|
791
|
+
self.start_simulator(request, queryset)
|
|
792
|
+
|
|
793
|
+
def stop_simulator_action(self, request, obj):
|
|
794
|
+
queryset = type(obj).objects.filter(pk=obj.pk)
|
|
795
|
+
self.stop_simulator(request, queryset)
|
|
796
|
+
|
|
797
|
+
def log_link(self, obj):
|
|
798
|
+
from django.utils.html import format_html
|
|
799
|
+
from django.urls import reverse
|
|
800
|
+
|
|
801
|
+
url = reverse("admin:ocpp_simulator_log", args=[obj.pk])
|
|
802
|
+
return format_html('<a href="{}" target="_blank">view</a>', url)
|
|
803
|
+
|
|
804
|
+
log_link.short_description = "Log"
|
|
805
|
+
|
|
806
|
+
def get_log_identifier(self, obj):
|
|
807
|
+
return obj.cp_path
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class MeterValueInline(admin.TabularInline):
|
|
811
|
+
model = MeterValue
|
|
812
|
+
extra = 0
|
|
813
|
+
fields = (
|
|
814
|
+
"timestamp",
|
|
815
|
+
"context",
|
|
816
|
+
"energy",
|
|
817
|
+
"voltage",
|
|
818
|
+
"current_import",
|
|
819
|
+
"current_offered",
|
|
820
|
+
"temperature",
|
|
821
|
+
"soc",
|
|
822
|
+
"connector_id",
|
|
823
|
+
)
|
|
824
|
+
readonly_fields = fields
|
|
825
|
+
can_delete = False
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
@admin.register(Transaction)
|
|
829
|
+
class TransactionAdmin(EntityModelAdmin):
|
|
830
|
+
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
831
|
+
list_display = (
|
|
832
|
+
"charger",
|
|
833
|
+
"account",
|
|
834
|
+
"rfid",
|
|
835
|
+
"meter_start",
|
|
836
|
+
"meter_stop",
|
|
837
|
+
"start_time",
|
|
838
|
+
"stop_time",
|
|
839
|
+
"kw",
|
|
840
|
+
)
|
|
841
|
+
readonly_fields = ("kw", "received_start_time", "received_stop_time")
|
|
842
|
+
list_filter = ("charger", "account")
|
|
843
|
+
date_hierarchy = "start_time"
|
|
844
|
+
inlines = [MeterValueInline]
|
|
845
|
+
|
|
846
|
+
def get_urls(self):
|
|
847
|
+
urls = super().get_urls()
|
|
848
|
+
custom = [
|
|
849
|
+
path(
|
|
850
|
+
"export/",
|
|
851
|
+
self.admin_site.admin_view(self.export_view),
|
|
852
|
+
name="ocpp_transaction_export",
|
|
853
|
+
),
|
|
854
|
+
path(
|
|
855
|
+
"import/",
|
|
856
|
+
self.admin_site.admin_view(self.import_view),
|
|
857
|
+
name="ocpp_transaction_import",
|
|
858
|
+
),
|
|
859
|
+
]
|
|
860
|
+
return custom + urls
|
|
861
|
+
|
|
862
|
+
def export_view(self, request):
|
|
863
|
+
if request.method == "POST":
|
|
864
|
+
form = TransactionExportForm(request.POST)
|
|
865
|
+
if form.is_valid():
|
|
866
|
+
chargers = form.cleaned_data["chargers"]
|
|
867
|
+
data = export_transactions(
|
|
868
|
+
start=form.cleaned_data["start"],
|
|
869
|
+
end=form.cleaned_data["end"],
|
|
870
|
+
chargers=[c.charger_id for c in chargers] if chargers else None,
|
|
871
|
+
)
|
|
872
|
+
response = HttpResponse(
|
|
873
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
874
|
+
content_type="application/json",
|
|
875
|
+
)
|
|
876
|
+
response["Content-Disposition"] = (
|
|
877
|
+
"attachment; filename=transactions.json"
|
|
878
|
+
)
|
|
879
|
+
return response
|
|
880
|
+
else:
|
|
881
|
+
form = TransactionExportForm()
|
|
882
|
+
context = self.admin_site.each_context(request)
|
|
883
|
+
context["form"] = form
|
|
884
|
+
return TemplateResponse(request, "admin/ocpp/transaction/export.html", context)
|
|
885
|
+
|
|
886
|
+
def import_view(self, request):
|
|
887
|
+
if request.method == "POST":
|
|
888
|
+
form = TransactionImportForm(request.POST, request.FILES)
|
|
889
|
+
if form.is_valid():
|
|
890
|
+
data = json.load(form.cleaned_data["file"])
|
|
891
|
+
imported = import_transactions_data(data)
|
|
892
|
+
self.message_user(request, f"Imported {imported} transactions")
|
|
893
|
+
return HttpResponseRedirect("../")
|
|
894
|
+
else:
|
|
895
|
+
form = TransactionImportForm()
|
|
896
|
+
context = self.admin_site.each_context(request)
|
|
897
|
+
context["form"] = form
|
|
898
|
+
return TemplateResponse(request, "admin/ocpp/transaction/import.html", context)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
class MeterValueDateFilter(admin.SimpleListFilter):
|
|
902
|
+
title = "Timestamp"
|
|
903
|
+
parameter_name = "timestamp_range"
|
|
904
|
+
|
|
905
|
+
def lookups(self, request, model_admin):
|
|
906
|
+
return [
|
|
907
|
+
("today", "Today"),
|
|
908
|
+
("7days", "Last 7 days"),
|
|
909
|
+
("30days", "Last 30 days"),
|
|
910
|
+
("older", "Older than 30 days"),
|
|
911
|
+
]
|
|
912
|
+
|
|
913
|
+
def queryset(self, request, queryset):
|
|
914
|
+
value = self.value()
|
|
915
|
+
now = timezone.now()
|
|
916
|
+
if value == "today":
|
|
917
|
+
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
918
|
+
end = start + timedelta(days=1)
|
|
919
|
+
return queryset.filter(timestamp__gte=start, timestamp__lt=end)
|
|
920
|
+
if value == "7days":
|
|
921
|
+
start = now - timedelta(days=7)
|
|
922
|
+
return queryset.filter(timestamp__gte=start)
|
|
923
|
+
if value == "30days":
|
|
924
|
+
start = now - timedelta(days=30)
|
|
925
|
+
return queryset.filter(timestamp__gte=start)
|
|
926
|
+
if value == "older":
|
|
927
|
+
cutoff = now - timedelta(days=30)
|
|
928
|
+
return queryset.filter(timestamp__lt=cutoff)
|
|
929
|
+
return queryset
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
@admin.register(MeterValue)
|
|
933
|
+
class MeterValueAdmin(EntityModelAdmin):
|
|
934
|
+
list_display = (
|
|
935
|
+
"charger",
|
|
936
|
+
"timestamp",
|
|
937
|
+
"context",
|
|
938
|
+
"energy",
|
|
939
|
+
"voltage",
|
|
940
|
+
"current_import",
|
|
941
|
+
"current_offered",
|
|
942
|
+
"temperature",
|
|
943
|
+
"soc",
|
|
944
|
+
"connector_id",
|
|
945
|
+
"transaction",
|
|
946
|
+
)
|
|
947
|
+
date_hierarchy = "timestamp"
|
|
948
|
+
list_filter = ("charger", MeterValueDateFilter)
|