arthexis 0.1.16__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -2,25 +2,38 @@ from django.contrib import admin, messages
|
|
|
2
2
|
from django import forms
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
-
|
|
5
|
+
import base64
|
|
6
|
+
from datetime import datetime, time, timedelta
|
|
6
7
|
import json
|
|
8
|
+
from typing import Any
|
|
7
9
|
|
|
8
10
|
from django.shortcuts import redirect
|
|
9
|
-
from django.utils import timezone
|
|
11
|
+
from django.utils import formats, timezone, translation
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
13
|
+
from django.utils.dateparse import parse_datetime
|
|
14
|
+
from django.utils.html import format_html
|
|
10
15
|
from django.urls import path
|
|
11
16
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
17
|
from django.template.response import TemplateResponse
|
|
13
18
|
|
|
14
19
|
import uuid
|
|
15
20
|
from asgiref.sync import async_to_sync
|
|
21
|
+
import requests
|
|
22
|
+
from requests import RequestException
|
|
23
|
+
from cryptography.hazmat.primitives import hashes
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
25
|
+
from django.db import transaction
|
|
26
|
+
from django.core.exceptions import ValidationError
|
|
16
27
|
|
|
17
28
|
from .models import (
|
|
18
29
|
Charger,
|
|
30
|
+
ChargerConfiguration,
|
|
19
31
|
Simulator,
|
|
20
32
|
MeterValue,
|
|
21
33
|
Transaction,
|
|
22
34
|
Location,
|
|
23
35
|
DataTransferMessage,
|
|
36
|
+
CPReservation,
|
|
24
37
|
)
|
|
25
38
|
from .simulator import ChargePointSimulator
|
|
26
39
|
from . import store
|
|
@@ -29,8 +42,10 @@ from .transactions_io import (
|
|
|
29
42
|
import_transactions as import_transactions_data,
|
|
30
43
|
)
|
|
31
44
|
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
45
|
+
from .views import _charger_state, _live_sessions
|
|
32
46
|
from core.admin import SaveBeforeChangeAction
|
|
33
47
|
from core.user_data import EntityModelAdmin
|
|
48
|
+
from nodes.models import Node
|
|
34
49
|
|
|
35
50
|
|
|
36
51
|
class LocationAdminForm(forms.ModelForm):
|
|
@@ -63,6 +78,43 @@ class TransactionImportForm(forms.Form):
|
|
|
63
78
|
file = forms.FileField()
|
|
64
79
|
|
|
65
80
|
|
|
81
|
+
class CPReservationForm(forms.ModelForm):
|
|
82
|
+
class Meta:
|
|
83
|
+
model = CPReservation
|
|
84
|
+
fields = [
|
|
85
|
+
"location",
|
|
86
|
+
"account",
|
|
87
|
+
"rfid",
|
|
88
|
+
"id_tag",
|
|
89
|
+
"start_time",
|
|
90
|
+
"duration_minutes",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def clean(self):
|
|
94
|
+
cleaned = super().clean()
|
|
95
|
+
instance = self.instance
|
|
96
|
+
for field in self.Meta.fields:
|
|
97
|
+
if field in cleaned:
|
|
98
|
+
setattr(instance, field, cleaned[field])
|
|
99
|
+
try:
|
|
100
|
+
instance.allocate_connector(force=bool(instance.pk))
|
|
101
|
+
except ValidationError as exc:
|
|
102
|
+
if exc.message_dict:
|
|
103
|
+
for field, errors in exc.message_dict.items():
|
|
104
|
+
for error in errors:
|
|
105
|
+
self.add_error(field, error)
|
|
106
|
+
raise forms.ValidationError(
|
|
107
|
+
_("Unable to allocate a connector for the selected time window.")
|
|
108
|
+
)
|
|
109
|
+
raise forms.ValidationError(exc.messages or [str(exc)])
|
|
110
|
+
if not instance.id_tag_value:
|
|
111
|
+
message = _("Select an RFID or provide an idTag for the reservation.")
|
|
112
|
+
self.add_error("id_tag", message)
|
|
113
|
+
self.add_error("rfid", message)
|
|
114
|
+
raise forms.ValidationError(message)
|
|
115
|
+
return cleaned
|
|
116
|
+
|
|
117
|
+
|
|
66
118
|
class LogViewAdminMixin:
|
|
67
119
|
"""Mixin providing an admin view to display charger or simulator logs."""
|
|
68
120
|
|
|
@@ -107,11 +159,107 @@ class LogViewAdminMixin:
|
|
|
107
159
|
return TemplateResponse(request, self.log_template_name, context)
|
|
108
160
|
|
|
109
161
|
|
|
162
|
+
@admin.register(ChargerConfiguration)
|
|
163
|
+
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
164
|
+
list_display = (
|
|
165
|
+
"charger_identifier",
|
|
166
|
+
"connector_display",
|
|
167
|
+
"origin_display",
|
|
168
|
+
"created_at",
|
|
169
|
+
)
|
|
170
|
+
list_filter = ("connector_id",)
|
|
171
|
+
search_fields = ("charger_identifier",)
|
|
172
|
+
readonly_fields = (
|
|
173
|
+
"charger_identifier",
|
|
174
|
+
"connector_id",
|
|
175
|
+
"origin_display",
|
|
176
|
+
"evcs_snapshot_at",
|
|
177
|
+
"created_at",
|
|
178
|
+
"updated_at",
|
|
179
|
+
"linked_chargers",
|
|
180
|
+
"configuration_keys_display",
|
|
181
|
+
"unknown_keys_display",
|
|
182
|
+
"raw_payload_display",
|
|
183
|
+
)
|
|
184
|
+
fieldsets = (
|
|
185
|
+
(
|
|
186
|
+
None,
|
|
187
|
+
{
|
|
188
|
+
"fields": (
|
|
189
|
+
"charger_identifier",
|
|
190
|
+
"connector_id",
|
|
191
|
+
"origin_display",
|
|
192
|
+
"evcs_snapshot_at",
|
|
193
|
+
"linked_chargers",
|
|
194
|
+
"created_at",
|
|
195
|
+
"updated_at",
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
(
|
|
200
|
+
"Payload",
|
|
201
|
+
{
|
|
202
|
+
"fields": (
|
|
203
|
+
"configuration_keys_display",
|
|
204
|
+
"unknown_keys_display",
|
|
205
|
+
"raw_payload_display",
|
|
206
|
+
)
|
|
207
|
+
},
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@admin.display(description="Connector")
|
|
212
|
+
def connector_display(self, obj):
|
|
213
|
+
if obj.connector_id is None:
|
|
214
|
+
return "All"
|
|
215
|
+
return obj.connector_id
|
|
216
|
+
|
|
217
|
+
@admin.display(description="Linked charge points")
|
|
218
|
+
def linked_chargers(self, obj):
|
|
219
|
+
if obj.pk is None:
|
|
220
|
+
return ""
|
|
221
|
+
linked = [charger.identity_slug() for charger in obj.chargers.all()]
|
|
222
|
+
if not linked:
|
|
223
|
+
return "-"
|
|
224
|
+
return ", ".join(sorted(linked))
|
|
225
|
+
|
|
226
|
+
def _render_json(self, data):
|
|
227
|
+
from django.utils.html import format_html
|
|
228
|
+
|
|
229
|
+
if not data:
|
|
230
|
+
return "-"
|
|
231
|
+
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
|
232
|
+
return format_html("<pre>{}</pre>", formatted)
|
|
233
|
+
|
|
234
|
+
@admin.display(description="configurationKey")
|
|
235
|
+
def configuration_keys_display(self, obj):
|
|
236
|
+
return self._render_json(obj.configuration_keys)
|
|
237
|
+
|
|
238
|
+
@admin.display(description="unknownKey")
|
|
239
|
+
def unknown_keys_display(self, obj):
|
|
240
|
+
return self._render_json(obj.unknown_keys)
|
|
241
|
+
|
|
242
|
+
@admin.display(description="Raw payload")
|
|
243
|
+
def raw_payload_display(self, obj):
|
|
244
|
+
return self._render_json(obj.raw_payload)
|
|
245
|
+
|
|
246
|
+
@admin.display(description="Origin")
|
|
247
|
+
def origin_display(self, obj):
|
|
248
|
+
if obj.evcs_snapshot_at:
|
|
249
|
+
return "EVCS"
|
|
250
|
+
return "Local"
|
|
251
|
+
|
|
252
|
+
def save_model(self, request, obj, form, change):
|
|
253
|
+
obj.evcs_snapshot_at = None
|
|
254
|
+
super().save_model(request, obj, form, change)
|
|
255
|
+
|
|
256
|
+
|
|
110
257
|
@admin.register(Location)
|
|
111
258
|
class LocationAdmin(EntityModelAdmin):
|
|
112
259
|
form = LocationAdminForm
|
|
113
|
-
list_display = ("name", "latitude", "longitude")
|
|
260
|
+
list_display = ("name", "zone", "contract_type", "latitude", "longitude")
|
|
114
261
|
change_form_template = "admin/ocpp/location/change_form.html"
|
|
262
|
+
search_fields = ("name",)
|
|
115
263
|
|
|
116
264
|
|
|
117
265
|
@admin.register(DataTransferMessage)
|
|
@@ -152,8 +300,139 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
|
|
|
152
300
|
)
|
|
153
301
|
|
|
154
302
|
|
|
303
|
+
@admin.register(CPReservation)
|
|
304
|
+
class CPReservationAdmin(EntityModelAdmin):
|
|
305
|
+
form = CPReservationForm
|
|
306
|
+
list_display = (
|
|
307
|
+
"location",
|
|
308
|
+
"connector_side_display",
|
|
309
|
+
"start_time",
|
|
310
|
+
"end_time_display",
|
|
311
|
+
"account",
|
|
312
|
+
"id_tag_display",
|
|
313
|
+
"evcs_status",
|
|
314
|
+
"evcs_confirmed",
|
|
315
|
+
)
|
|
316
|
+
list_filter = ("location", "evcs_confirmed")
|
|
317
|
+
search_fields = (
|
|
318
|
+
"location__name",
|
|
319
|
+
"connector__charger_id",
|
|
320
|
+
"connector__display_name",
|
|
321
|
+
"account__name",
|
|
322
|
+
"id_tag",
|
|
323
|
+
"rfid__rfid",
|
|
324
|
+
)
|
|
325
|
+
date_hierarchy = "start_time"
|
|
326
|
+
ordering = ("-start_time",)
|
|
327
|
+
autocomplete_fields = ("location", "account", "rfid")
|
|
328
|
+
readonly_fields = (
|
|
329
|
+
"connector_identity",
|
|
330
|
+
"connector_side_display",
|
|
331
|
+
"evcs_status",
|
|
332
|
+
"evcs_error",
|
|
333
|
+
"evcs_confirmed",
|
|
334
|
+
"evcs_confirmed_at",
|
|
335
|
+
"ocpp_message_id",
|
|
336
|
+
"created_on",
|
|
337
|
+
"updated_on",
|
|
338
|
+
)
|
|
339
|
+
fieldsets = (
|
|
340
|
+
(
|
|
341
|
+
None,
|
|
342
|
+
{
|
|
343
|
+
"fields": (
|
|
344
|
+
"location",
|
|
345
|
+
"account",
|
|
346
|
+
"rfid",
|
|
347
|
+
"id_tag",
|
|
348
|
+
"start_time",
|
|
349
|
+
"duration_minutes",
|
|
350
|
+
)
|
|
351
|
+
},
|
|
352
|
+
),
|
|
353
|
+
(
|
|
354
|
+
_("Assigned connector"),
|
|
355
|
+
{"fields": ("connector_identity", "connector_side_display")},
|
|
356
|
+
),
|
|
357
|
+
(
|
|
358
|
+
_("EVCS response"),
|
|
359
|
+
{
|
|
360
|
+
"fields": (
|
|
361
|
+
"evcs_confirmed",
|
|
362
|
+
"evcs_status",
|
|
363
|
+
"evcs_confirmed_at",
|
|
364
|
+
"evcs_error",
|
|
365
|
+
"ocpp_message_id",
|
|
366
|
+
)
|
|
367
|
+
},
|
|
368
|
+
),
|
|
369
|
+
(
|
|
370
|
+
_("Metadata"),
|
|
371
|
+
{"fields": ("created_on", "updated_on")},
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def save_model(self, request, obj, form, change):
|
|
376
|
+
trigger_fields = {
|
|
377
|
+
"start_time",
|
|
378
|
+
"duration_minutes",
|
|
379
|
+
"location",
|
|
380
|
+
"id_tag",
|
|
381
|
+
"rfid",
|
|
382
|
+
"account",
|
|
383
|
+
}
|
|
384
|
+
changed_data = set(getattr(form, "changed_data", []))
|
|
385
|
+
should_send = not change or bool(trigger_fields.intersection(changed_data))
|
|
386
|
+
with transaction.atomic():
|
|
387
|
+
super().save_model(request, obj, form, change)
|
|
388
|
+
if should_send:
|
|
389
|
+
try:
|
|
390
|
+
obj.send_reservation_request()
|
|
391
|
+
except ValidationError as exc:
|
|
392
|
+
raise ValidationError(exc.message_dict or exc.messages or str(exc))
|
|
393
|
+
else:
|
|
394
|
+
self.message_user(
|
|
395
|
+
request,
|
|
396
|
+
_("Reservation request sent to %(connector)s.")
|
|
397
|
+
% {"connector": self.connector_identity(obj)},
|
|
398
|
+
messages.SUCCESS,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
@admin.display(description=_("Connector"), ordering="connector__connector_id")
|
|
402
|
+
def connector_side_display(self, obj):
|
|
403
|
+
return obj.connector_label or "-"
|
|
404
|
+
|
|
405
|
+
@admin.display(description=_("Connector identity"))
|
|
406
|
+
def connector_identity(self, obj):
|
|
407
|
+
if obj.connector_id:
|
|
408
|
+
return obj.connector.identity_slug()
|
|
409
|
+
return "-"
|
|
410
|
+
|
|
411
|
+
@admin.display(description=_("End time"))
|
|
412
|
+
def end_time_display(self, obj):
|
|
413
|
+
try:
|
|
414
|
+
value = timezone.localtime(obj.end_time)
|
|
415
|
+
except Exception:
|
|
416
|
+
value = obj.end_time
|
|
417
|
+
if not value:
|
|
418
|
+
return "-"
|
|
419
|
+
return formats.date_format(value, "DATETIME_FORMAT")
|
|
420
|
+
|
|
421
|
+
@admin.display(description=_("Id tag"))
|
|
422
|
+
def id_tag_display(self, obj):
|
|
423
|
+
value = obj.id_tag_value
|
|
424
|
+
return value or "-"
|
|
425
|
+
|
|
426
|
+
|
|
155
427
|
@admin.register(Charger)
|
|
156
428
|
class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
429
|
+
_REMOTE_DATETIME_FIELDS = {
|
|
430
|
+
"availability_state_updated_at",
|
|
431
|
+
"availability_requested_at",
|
|
432
|
+
"availability_request_status_at",
|
|
433
|
+
"last_online_at",
|
|
434
|
+
}
|
|
435
|
+
|
|
157
436
|
fieldsets = (
|
|
158
437
|
(
|
|
159
438
|
"General",
|
|
@@ -162,6 +441,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
162
441
|
"charger_id",
|
|
163
442
|
"display_name",
|
|
164
443
|
"connector_id",
|
|
444
|
+
"language",
|
|
165
445
|
"location",
|
|
166
446
|
"last_path",
|
|
167
447
|
"last_heartbeat",
|
|
@@ -205,7 +485,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
205
485
|
),
|
|
206
486
|
(
|
|
207
487
|
"Configuration",
|
|
208
|
-
{"fields": ("public_display", "require_rfid")},
|
|
488
|
+
{"fields": ("public_display", "require_rfid", "configuration")},
|
|
489
|
+
),
|
|
490
|
+
(
|
|
491
|
+
"Network",
|
|
492
|
+
{
|
|
493
|
+
"fields": (
|
|
494
|
+
"node_origin",
|
|
495
|
+
"manager_node",
|
|
496
|
+
"forwarded_to",
|
|
497
|
+
"forwarding_watermark",
|
|
498
|
+
"allow_remote",
|
|
499
|
+
"export_transactions",
|
|
500
|
+
"last_online_at",
|
|
501
|
+
)
|
|
502
|
+
},
|
|
209
503
|
),
|
|
210
504
|
(
|
|
211
505
|
"References",
|
|
@@ -234,15 +528,20 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
234
528
|
"availability_request_status",
|
|
235
529
|
"availability_request_status_at",
|
|
236
530
|
"availability_request_details",
|
|
531
|
+
"configuration",
|
|
532
|
+
"forwarded_to",
|
|
533
|
+
"forwarding_watermark",
|
|
534
|
+
"last_online_at",
|
|
237
535
|
)
|
|
238
536
|
list_display = (
|
|
239
|
-
"
|
|
537
|
+
"display_name_with_fallback",
|
|
240
538
|
"connector_number",
|
|
241
|
-
"
|
|
539
|
+
"charger_name_display",
|
|
540
|
+
"local_indicator",
|
|
242
541
|
"require_rfid_display",
|
|
243
542
|
"public_display",
|
|
244
543
|
"last_heartbeat",
|
|
245
|
-
"
|
|
544
|
+
"today_kw",
|
|
246
545
|
"total_kw_display",
|
|
247
546
|
"page_link",
|
|
248
547
|
"log_link",
|
|
@@ -253,6 +552,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
253
552
|
actions = [
|
|
254
553
|
"purge_data",
|
|
255
554
|
"fetch_cp_configuration",
|
|
555
|
+
"toggle_rfid_authentication",
|
|
556
|
+
"recheck_charger_status",
|
|
256
557
|
"change_availability_operative",
|
|
257
558
|
"change_availability_inoperative",
|
|
258
559
|
"set_availability_state_operative",
|
|
@@ -262,6 +563,159 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
262
563
|
"delete_selected",
|
|
263
564
|
]
|
|
264
565
|
|
|
566
|
+
def _prepare_remote_credentials(self, request):
|
|
567
|
+
local = Node.get_local()
|
|
568
|
+
if not local or not local.uuid:
|
|
569
|
+
self.message_user(
|
|
570
|
+
request,
|
|
571
|
+
"Local node is not registered; remote actions are unavailable.",
|
|
572
|
+
level=messages.ERROR,
|
|
573
|
+
)
|
|
574
|
+
return None, None
|
|
575
|
+
private_key = local.get_private_key()
|
|
576
|
+
if private_key is None:
|
|
577
|
+
self.message_user(
|
|
578
|
+
request,
|
|
579
|
+
"Local node private key is unavailable; remote actions are disabled.",
|
|
580
|
+
level=messages.ERROR,
|
|
581
|
+
)
|
|
582
|
+
return None, None
|
|
583
|
+
return local, private_key
|
|
584
|
+
|
|
585
|
+
def _call_remote_action(
|
|
586
|
+
self,
|
|
587
|
+
request,
|
|
588
|
+
local_node: Node,
|
|
589
|
+
private_key,
|
|
590
|
+
charger: Charger,
|
|
591
|
+
action: str,
|
|
592
|
+
extra: dict[str, Any] | None = None,
|
|
593
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
594
|
+
if not charger.node_origin:
|
|
595
|
+
self.message_user(
|
|
596
|
+
request,
|
|
597
|
+
f"{charger}: remote node information is missing.",
|
|
598
|
+
level=messages.ERROR,
|
|
599
|
+
)
|
|
600
|
+
return False, {}
|
|
601
|
+
origin = charger.node_origin
|
|
602
|
+
if not origin.port:
|
|
603
|
+
self.message_user(
|
|
604
|
+
request,
|
|
605
|
+
f"{charger}: remote node port is not configured.",
|
|
606
|
+
level=messages.ERROR,
|
|
607
|
+
)
|
|
608
|
+
return False, {}
|
|
609
|
+
|
|
610
|
+
if not origin.get_remote_host_candidates():
|
|
611
|
+
self.message_user(
|
|
612
|
+
request,
|
|
613
|
+
f"{charger}: remote node connection details are incomplete.",
|
|
614
|
+
level=messages.ERROR,
|
|
615
|
+
)
|
|
616
|
+
return False, {}
|
|
617
|
+
|
|
618
|
+
payload: dict[str, Any] = {
|
|
619
|
+
"requester": str(local_node.uuid),
|
|
620
|
+
"requester_mac": local_node.mac_address,
|
|
621
|
+
"requester_public_key": local_node.public_key,
|
|
622
|
+
"charger_id": charger.charger_id,
|
|
623
|
+
"connector_id": charger.connector_id,
|
|
624
|
+
"action": action,
|
|
625
|
+
}
|
|
626
|
+
if extra:
|
|
627
|
+
payload.update(extra)
|
|
628
|
+
|
|
629
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
630
|
+
headers = {"Content-Type": "application/json"}
|
|
631
|
+
try:
|
|
632
|
+
signature = private_key.sign(
|
|
633
|
+
payload_json.encode(),
|
|
634
|
+
padding.PKCS1v15(),
|
|
635
|
+
hashes.SHA256(),
|
|
636
|
+
)
|
|
637
|
+
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
638
|
+
except Exception:
|
|
639
|
+
self.message_user(
|
|
640
|
+
request,
|
|
641
|
+
"Unable to sign remote action payload; remote action aborted.",
|
|
642
|
+
level=messages.ERROR,
|
|
643
|
+
)
|
|
644
|
+
return False, {}
|
|
645
|
+
|
|
646
|
+
url = next(
|
|
647
|
+
origin.iter_remote_urls("/nodes/network/chargers/action/"),
|
|
648
|
+
"",
|
|
649
|
+
)
|
|
650
|
+
if not url:
|
|
651
|
+
self.message_user(
|
|
652
|
+
request,
|
|
653
|
+
f"{charger}: no reachable hosts were reported for the remote node.",
|
|
654
|
+
level=messages.ERROR,
|
|
655
|
+
)
|
|
656
|
+
return False, {}
|
|
657
|
+
try:
|
|
658
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
659
|
+
except RequestException as exc:
|
|
660
|
+
self.message_user(
|
|
661
|
+
request,
|
|
662
|
+
f"{charger}: failed to contact remote node ({exc}).",
|
|
663
|
+
level=messages.ERROR,
|
|
664
|
+
)
|
|
665
|
+
return False, {}
|
|
666
|
+
|
|
667
|
+
try:
|
|
668
|
+
data = response.json()
|
|
669
|
+
except ValueError:
|
|
670
|
+
self.message_user(
|
|
671
|
+
request,
|
|
672
|
+
f"{charger}: invalid response from remote node.",
|
|
673
|
+
level=messages.ERROR,
|
|
674
|
+
)
|
|
675
|
+
return False, {}
|
|
676
|
+
|
|
677
|
+
if response.status_code != 200 or data.get("status") != "ok":
|
|
678
|
+
detail = data.get("detail") if isinstance(data, dict) else None
|
|
679
|
+
if not detail:
|
|
680
|
+
detail = response.text or "Remote node rejected the request."
|
|
681
|
+
self.message_user(
|
|
682
|
+
request,
|
|
683
|
+
f"{charger}: {detail}",
|
|
684
|
+
level=messages.ERROR,
|
|
685
|
+
)
|
|
686
|
+
return False, {}
|
|
687
|
+
|
|
688
|
+
updates = data.get("updates", {}) if isinstance(data, dict) else {}
|
|
689
|
+
if not isinstance(updates, dict):
|
|
690
|
+
updates = {}
|
|
691
|
+
return True, updates
|
|
692
|
+
|
|
693
|
+
def _apply_remote_updates(self, charger: Charger, updates: dict[str, Any]) -> None:
|
|
694
|
+
if not updates:
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
applied: dict[str, Any] = {}
|
|
698
|
+
for field, value in updates.items():
|
|
699
|
+
if field in self._REMOTE_DATETIME_FIELDS and isinstance(value, str):
|
|
700
|
+
parsed = parse_datetime(value)
|
|
701
|
+
if parsed and timezone.is_naive(parsed):
|
|
702
|
+
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
703
|
+
applied[field] = parsed
|
|
704
|
+
else:
|
|
705
|
+
applied[field] = value
|
|
706
|
+
|
|
707
|
+
Charger.objects.filter(pk=charger.pk).update(**applied)
|
|
708
|
+
for field, value in applied.items():
|
|
709
|
+
setattr(charger, field, value)
|
|
710
|
+
|
|
711
|
+
def get_readonly_fields(self, request, obj=None):
|
|
712
|
+
readonly = list(super().get_readonly_fields(request, obj))
|
|
713
|
+
if obj and not obj.is_local:
|
|
714
|
+
for field in ("allow_remote", "export_transactions"):
|
|
715
|
+
if field not in readonly:
|
|
716
|
+
readonly.append(field)
|
|
717
|
+
return tuple(readonly)
|
|
718
|
+
|
|
265
719
|
def get_view_on_site_url(self, obj=None):
|
|
266
720
|
return obj.get_absolute_url() if obj else None
|
|
267
721
|
|
|
@@ -317,16 +771,14 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
317
771
|
"charger-status-connector",
|
|
318
772
|
args=[obj.charger_id, obj.connector_slug],
|
|
319
773
|
)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
328
|
-
label = STATUS_BADGE_MAP["charging"][0]
|
|
329
|
-
return format_html('<a href="{}" target="_blank">{}</a>', url, label)
|
|
774
|
+
tx_obj = store.get_transaction(obj.charger_id, obj.connector_id)
|
|
775
|
+
state, _ = _charger_state(
|
|
776
|
+
obj,
|
|
777
|
+
tx_obj
|
|
778
|
+
if obj.connector_id is not None
|
|
779
|
+
else (_live_sessions(obj) or None),
|
|
780
|
+
)
|
|
781
|
+
return format_html('<a href="{}" target="_blank">{}</a>', url, state)
|
|
330
782
|
|
|
331
783
|
status_link.short_description = "Status"
|
|
332
784
|
|
|
@@ -347,6 +799,25 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
347
799
|
return True
|
|
348
800
|
return False
|
|
349
801
|
|
|
802
|
+
@admin.display(description="Display Name", ordering="display_name")
|
|
803
|
+
def display_name_with_fallback(self, obj):
|
|
804
|
+
return self._charger_display_name(obj)
|
|
805
|
+
|
|
806
|
+
@admin.display(description="Charger", ordering="display_name")
|
|
807
|
+
def charger_name_display(self, obj):
|
|
808
|
+
return self._charger_display_name(obj)
|
|
809
|
+
|
|
810
|
+
def _charger_display_name(self, obj):
|
|
811
|
+
if obj.display_name:
|
|
812
|
+
return obj.display_name
|
|
813
|
+
if obj.location:
|
|
814
|
+
return obj.location.name
|
|
815
|
+
return obj.charger_id
|
|
816
|
+
|
|
817
|
+
@admin.display(boolean=True, description="Local")
|
|
818
|
+
def local_indicator(self, obj):
|
|
819
|
+
return obj.is_local
|
|
820
|
+
|
|
350
821
|
def location_name(self, obj):
|
|
351
822
|
return obj.location.name if obj.location else ""
|
|
352
823
|
|
|
@@ -359,9 +830,9 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
359
830
|
|
|
360
831
|
purge_data.short_description = "Purge data"
|
|
361
832
|
|
|
362
|
-
@admin.action(description="
|
|
363
|
-
def
|
|
364
|
-
|
|
833
|
+
@admin.action(description="Re-check Charger Status")
|
|
834
|
+
def recheck_charger_status(self, request, queryset):
|
|
835
|
+
requested = 0
|
|
365
836
|
for charger in queryset:
|
|
366
837
|
connector_value = charger.connector_id
|
|
367
838
|
ws = store.get_connection(charger.charger_id, connector_value)
|
|
@@ -372,15 +843,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
372
843
|
level=messages.ERROR,
|
|
373
844
|
)
|
|
374
845
|
continue
|
|
846
|
+
payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
|
|
847
|
+
trigger_connector: int | None = None
|
|
848
|
+
if connector_value is not None:
|
|
849
|
+
payload["connectorId"] = connector_value
|
|
850
|
+
trigger_connector = connector_value
|
|
375
851
|
message_id = uuid.uuid4().hex
|
|
376
|
-
|
|
377
|
-
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
852
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
378
853
|
try:
|
|
379
854
|
async_to_sync(ws.send)(msg)
|
|
380
855
|
except Exception as exc: # pragma: no cover - network error
|
|
381
856
|
self.message_user(
|
|
382
857
|
request,
|
|
383
|
-
f"{charger}: failed to send
|
|
858
|
+
f"{charger}: failed to send TriggerMessage ({exc})",
|
|
384
859
|
level=messages.ERROR,
|
|
385
860
|
)
|
|
386
861
|
continue
|
|
@@ -389,79 +864,254 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
389
864
|
store.register_pending_call(
|
|
390
865
|
message_id,
|
|
391
866
|
{
|
|
392
|
-
"action": "
|
|
867
|
+
"action": "TriggerMessage",
|
|
393
868
|
"charger_id": charger.charger_id,
|
|
394
869
|
"connector_id": connector_value,
|
|
395
870
|
"log_key": log_key,
|
|
871
|
+
"trigger_target": "StatusNotification",
|
|
872
|
+
"trigger_connector": trigger_connector,
|
|
396
873
|
"requested_at": timezone.now(),
|
|
397
874
|
},
|
|
398
875
|
)
|
|
399
876
|
store.schedule_call_timeout(
|
|
400
877
|
message_id,
|
|
401
878
|
timeout=5.0,
|
|
402
|
-
action="
|
|
879
|
+
action="TriggerMessage",
|
|
403
880
|
log_key=log_key,
|
|
404
|
-
message=
|
|
405
|
-
"GetConfiguration timed out: charger did not respond"
|
|
406
|
-
" (operation may not be supported)"
|
|
407
|
-
),
|
|
881
|
+
message="TriggerMessage StatusNotification timed out",
|
|
408
882
|
)
|
|
409
|
-
|
|
883
|
+
requested += 1
|
|
884
|
+
if requested:
|
|
885
|
+
self.message_user(
|
|
886
|
+
request,
|
|
887
|
+
f"Requested status update from {requested} charger(s)",
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
@admin.action(description="Fetch CP configuration")
|
|
891
|
+
def fetch_cp_configuration(self, request, queryset):
|
|
892
|
+
fetched = 0
|
|
893
|
+
local_node = None
|
|
894
|
+
private_key = None
|
|
895
|
+
remote_unavailable = False
|
|
896
|
+
for charger in queryset:
|
|
897
|
+
if charger.is_local:
|
|
898
|
+
connector_value = charger.connector_id
|
|
899
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
900
|
+
if ws is None:
|
|
901
|
+
self.message_user(
|
|
902
|
+
request,
|
|
903
|
+
f"{charger}: no active connection",
|
|
904
|
+
level=messages.ERROR,
|
|
905
|
+
)
|
|
906
|
+
continue
|
|
907
|
+
message_id = uuid.uuid4().hex
|
|
908
|
+
payload = {}
|
|
909
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
910
|
+
try:
|
|
911
|
+
async_to_sync(ws.send)(msg)
|
|
912
|
+
except Exception as exc: # pragma: no cover - network error
|
|
913
|
+
self.message_user(
|
|
914
|
+
request,
|
|
915
|
+
f"{charger}: failed to send GetConfiguration ({exc})",
|
|
916
|
+
level=messages.ERROR,
|
|
917
|
+
)
|
|
918
|
+
continue
|
|
919
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
920
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
921
|
+
store.register_pending_call(
|
|
922
|
+
message_id,
|
|
923
|
+
{
|
|
924
|
+
"action": "GetConfiguration",
|
|
925
|
+
"charger_id": charger.charger_id,
|
|
926
|
+
"connector_id": connector_value,
|
|
927
|
+
"log_key": log_key,
|
|
928
|
+
"requested_at": timezone.now(),
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
store.schedule_call_timeout(
|
|
932
|
+
message_id,
|
|
933
|
+
timeout=5.0,
|
|
934
|
+
action="GetConfiguration",
|
|
935
|
+
log_key=log_key,
|
|
936
|
+
message=(
|
|
937
|
+
"GetConfiguration timed out: charger did not respond"
|
|
938
|
+
" (operation may not be supported)"
|
|
939
|
+
),
|
|
940
|
+
)
|
|
941
|
+
fetched += 1
|
|
942
|
+
continue
|
|
943
|
+
|
|
944
|
+
if not charger.allow_remote:
|
|
945
|
+
self.message_user(
|
|
946
|
+
request,
|
|
947
|
+
f"{charger}: remote administration is disabled.",
|
|
948
|
+
level=messages.ERROR,
|
|
949
|
+
)
|
|
950
|
+
continue
|
|
951
|
+
if remote_unavailable:
|
|
952
|
+
continue
|
|
953
|
+
if local_node is None:
|
|
954
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
955
|
+
if not local_node or not private_key:
|
|
956
|
+
remote_unavailable = True
|
|
957
|
+
continue
|
|
958
|
+
success, updates = self._call_remote_action(
|
|
959
|
+
request,
|
|
960
|
+
local_node,
|
|
961
|
+
private_key,
|
|
962
|
+
charger,
|
|
963
|
+
"get-configuration",
|
|
964
|
+
)
|
|
965
|
+
if success:
|
|
966
|
+
self._apply_remote_updates(charger, updates)
|
|
967
|
+
fetched += 1
|
|
968
|
+
|
|
410
969
|
if fetched:
|
|
411
970
|
self.message_user(
|
|
412
971
|
request,
|
|
413
972
|
f"Requested configuration from {fetched} charger(s)",
|
|
414
973
|
)
|
|
415
974
|
|
|
416
|
-
|
|
417
|
-
|
|
975
|
+
@admin.action(description="Toggle RFID Authentication")
|
|
976
|
+
def toggle_rfid_authentication(self, request, queryset):
|
|
977
|
+
enabled = 0
|
|
978
|
+
disabled = 0
|
|
979
|
+
local_node = None
|
|
980
|
+
private_key = None
|
|
981
|
+
remote_unavailable = False
|
|
418
982
|
for charger in queryset:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
983
|
+
new_value = not charger.require_rfid
|
|
984
|
+
if charger.is_local:
|
|
985
|
+
Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
|
|
986
|
+
charger.require_rfid = new_value
|
|
987
|
+
if new_value:
|
|
988
|
+
enabled += 1
|
|
989
|
+
else:
|
|
990
|
+
disabled += 1
|
|
991
|
+
continue
|
|
992
|
+
|
|
993
|
+
if not charger.allow_remote:
|
|
422
994
|
self.message_user(
|
|
423
995
|
request,
|
|
424
|
-
f"{charger}:
|
|
996
|
+
f"{charger}: remote administration is disabled.",
|
|
425
997
|
level=messages.ERROR,
|
|
426
998
|
)
|
|
427
999
|
continue
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
1000
|
+
if remote_unavailable:
|
|
1001
|
+
continue
|
|
1002
|
+
if local_node is None:
|
|
1003
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1004
|
+
if not local_node or not private_key:
|
|
1005
|
+
remote_unavailable = True
|
|
1006
|
+
continue
|
|
1007
|
+
success, updates = self._call_remote_action(
|
|
1008
|
+
request,
|
|
1009
|
+
local_node,
|
|
1010
|
+
private_key,
|
|
1011
|
+
charger,
|
|
1012
|
+
"toggle-rfid",
|
|
1013
|
+
{"enable": new_value},
|
|
1014
|
+
)
|
|
1015
|
+
if success:
|
|
1016
|
+
self._apply_remote_updates(charger, updates)
|
|
1017
|
+
if charger.require_rfid:
|
|
1018
|
+
enabled += 1
|
|
1019
|
+
else:
|
|
1020
|
+
disabled += 1
|
|
1021
|
+
|
|
1022
|
+
if enabled or disabled:
|
|
1023
|
+
changes = []
|
|
1024
|
+
if enabled:
|
|
1025
|
+
changes.append(f"enabled for {enabled} charger(s)")
|
|
1026
|
+
if disabled:
|
|
1027
|
+
changes.append(f"disabled for {disabled} charger(s)")
|
|
1028
|
+
summary = "; ".join(changes)
|
|
1029
|
+
self.message_user(
|
|
1030
|
+
request,
|
|
1031
|
+
f"Updated RFID authentication: {summary}",
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
def _dispatch_change_availability(self, request, queryset, availability_type: str):
|
|
1035
|
+
sent = 0
|
|
1036
|
+
local_node = None
|
|
1037
|
+
private_key = None
|
|
1038
|
+
remote_unavailable = False
|
|
1039
|
+
for charger in queryset:
|
|
1040
|
+
if charger.is_local:
|
|
1041
|
+
connector_value = charger.connector_id
|
|
1042
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1043
|
+
if ws is None:
|
|
1044
|
+
self.message_user(
|
|
1045
|
+
request,
|
|
1046
|
+
f"{charger}: no active connection",
|
|
1047
|
+
level=messages.ERROR,
|
|
1048
|
+
)
|
|
1049
|
+
continue
|
|
1050
|
+
connector_id = connector_value if connector_value is not None else 0
|
|
1051
|
+
message_id = uuid.uuid4().hex
|
|
1052
|
+
payload = {"connectorId": connector_id, "type": availability_type}
|
|
1053
|
+
msg = json.dumps([2, message_id, "ChangeAvailability", payload])
|
|
1054
|
+
try:
|
|
1055
|
+
async_to_sync(ws.send)(msg)
|
|
1056
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1057
|
+
self.message_user(
|
|
1058
|
+
request,
|
|
1059
|
+
f"{charger}: failed to send ChangeAvailability ({exc})",
|
|
1060
|
+
level=messages.ERROR,
|
|
1061
|
+
)
|
|
1062
|
+
continue
|
|
1063
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1064
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1065
|
+
timestamp = timezone.now()
|
|
1066
|
+
store.register_pending_call(
|
|
1067
|
+
message_id,
|
|
1068
|
+
{
|
|
1069
|
+
"action": "ChangeAvailability",
|
|
1070
|
+
"charger_id": charger.charger_id,
|
|
1071
|
+
"connector_id": connector_value,
|
|
1072
|
+
"availability_type": availability_type,
|
|
1073
|
+
"requested_at": timestamp,
|
|
1074
|
+
},
|
|
1075
|
+
)
|
|
1076
|
+
updates = {
|
|
1077
|
+
"availability_requested_state": availability_type,
|
|
1078
|
+
"availability_requested_at": timestamp,
|
|
1079
|
+
"availability_request_status": "",
|
|
1080
|
+
"availability_request_status_at": None,
|
|
1081
|
+
"availability_request_details": "",
|
|
1082
|
+
}
|
|
1083
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1084
|
+
for field, value in updates.items():
|
|
1085
|
+
setattr(charger, field, value)
|
|
1086
|
+
sent += 1
|
|
1087
|
+
continue
|
|
1088
|
+
|
|
1089
|
+
if not charger.allow_remote:
|
|
435
1090
|
self.message_user(
|
|
436
1091
|
request,
|
|
437
|
-
f"{charger}:
|
|
1092
|
+
f"{charger}: remote administration is disabled.",
|
|
438
1093
|
level=messages.ERROR,
|
|
439
1094
|
)
|
|
440
1095
|
continue
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1096
|
+
if remote_unavailable:
|
|
1097
|
+
continue
|
|
1098
|
+
if local_node is None:
|
|
1099
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1100
|
+
if not local_node or not private_key:
|
|
1101
|
+
remote_unavailable = True
|
|
1102
|
+
continue
|
|
1103
|
+
success, updates = self._call_remote_action(
|
|
1104
|
+
request,
|
|
1105
|
+
local_node,
|
|
1106
|
+
private_key,
|
|
1107
|
+
charger,
|
|
1108
|
+
"change-availability",
|
|
1109
|
+
{"availability_type": availability_type},
|
|
453
1110
|
)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
1111
|
+
if success:
|
|
1112
|
+
self._apply_remote_updates(charger, updates)
|
|
1113
|
+
sent += 1
|
|
1114
|
+
|
|
465
1115
|
if sent:
|
|
466
1116
|
self.message_user(
|
|
467
1117
|
request,
|
|
@@ -479,17 +1129,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
479
1129
|
def _set_availability_state(
|
|
480
1130
|
self, request, queryset, availability_state: str
|
|
481
1131
|
) -> None:
|
|
482
|
-
timestamp = timezone.now()
|
|
483
1132
|
updated = 0
|
|
1133
|
+
local_node = None
|
|
1134
|
+
private_key = None
|
|
1135
|
+
remote_unavailable = False
|
|
484
1136
|
for charger in queryset:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1137
|
+
if charger.is_local:
|
|
1138
|
+
timestamp = timezone.now()
|
|
1139
|
+
updates = {
|
|
1140
|
+
"availability_state": availability_state,
|
|
1141
|
+
"availability_state_updated_at": timestamp,
|
|
1142
|
+
}
|
|
1143
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1144
|
+
for field, value in updates.items():
|
|
1145
|
+
setattr(charger, field, value)
|
|
1146
|
+
updated += 1
|
|
1147
|
+
continue
|
|
1148
|
+
|
|
1149
|
+
if not charger.allow_remote:
|
|
1150
|
+
self.message_user(
|
|
1151
|
+
request,
|
|
1152
|
+
f"{charger}: remote administration is disabled.",
|
|
1153
|
+
level=messages.ERROR,
|
|
1154
|
+
)
|
|
1155
|
+
continue
|
|
1156
|
+
if remote_unavailable:
|
|
1157
|
+
continue
|
|
1158
|
+
if local_node is None:
|
|
1159
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1160
|
+
if not local_node or not private_key:
|
|
1161
|
+
remote_unavailable = True
|
|
1162
|
+
continue
|
|
1163
|
+
success, updates = self._call_remote_action(
|
|
1164
|
+
request,
|
|
1165
|
+
local_node,
|
|
1166
|
+
private_key,
|
|
1167
|
+
charger,
|
|
1168
|
+
"set-availability-state",
|
|
1169
|
+
{"availability_state": availability_state},
|
|
1170
|
+
)
|
|
1171
|
+
if success:
|
|
1172
|
+
self._apply_remote_updates(charger, updates)
|
|
1173
|
+
updated += 1
|
|
1174
|
+
|
|
493
1175
|
if updated:
|
|
494
1176
|
self.message_user(
|
|
495
1177
|
request,
|
|
@@ -507,55 +1189,86 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
507
1189
|
@admin.action(description="Remote stop active transaction")
|
|
508
1190
|
def remote_stop_transaction(self, request, queryset):
|
|
509
1191
|
stopped = 0
|
|
1192
|
+
local_node = None
|
|
1193
|
+
private_key = None
|
|
1194
|
+
remote_unavailable = False
|
|
510
1195
|
for charger in queryset:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1196
|
+
if charger.is_local:
|
|
1197
|
+
connector_value = charger.connector_id
|
|
1198
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1199
|
+
if ws is None:
|
|
1200
|
+
self.message_user(
|
|
1201
|
+
request,
|
|
1202
|
+
f"{charger}: no active connection",
|
|
1203
|
+
level=messages.ERROR,
|
|
1204
|
+
)
|
|
1205
|
+
continue
|
|
1206
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
1207
|
+
if tx_obj is None:
|
|
1208
|
+
self.message_user(
|
|
1209
|
+
request,
|
|
1210
|
+
f"{charger}: no active transaction",
|
|
1211
|
+
level=messages.ERROR,
|
|
1212
|
+
)
|
|
1213
|
+
continue
|
|
1214
|
+
message_id = uuid.uuid4().hex
|
|
1215
|
+
payload = {"transactionId": tx_obj.pk}
|
|
1216
|
+
msg = json.dumps([
|
|
1217
|
+
2,
|
|
1218
|
+
message_id,
|
|
1219
|
+
"RemoteStopTransaction",
|
|
1220
|
+
payload,
|
|
1221
|
+
])
|
|
1222
|
+
try:
|
|
1223
|
+
async_to_sync(ws.send)(msg)
|
|
1224
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1225
|
+
self.message_user(
|
|
1226
|
+
request,
|
|
1227
|
+
f"{charger}: failed to send RemoteStopTransaction ({exc})",
|
|
1228
|
+
level=messages.ERROR,
|
|
1229
|
+
)
|
|
1230
|
+
continue
|
|
1231
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1232
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1233
|
+
store.register_pending_call(
|
|
1234
|
+
message_id,
|
|
1235
|
+
{
|
|
1236
|
+
"action": "RemoteStopTransaction",
|
|
1237
|
+
"charger_id": charger.charger_id,
|
|
1238
|
+
"connector_id": connector_value,
|
|
1239
|
+
"transaction_id": tx_obj.pk,
|
|
1240
|
+
"log_key": log_key,
|
|
1241
|
+
"requested_at": timezone.now(),
|
|
1242
|
+
},
|
|
518
1243
|
)
|
|
1244
|
+
stopped += 1
|
|
519
1245
|
continue
|
|
520
|
-
|
|
521
|
-
if
|
|
1246
|
+
|
|
1247
|
+
if not charger.allow_remote:
|
|
522
1248
|
self.message_user(
|
|
523
1249
|
request,
|
|
524
|
-
f"{charger}:
|
|
1250
|
+
f"{charger}: remote administration is disabled.",
|
|
525
1251
|
level=messages.ERROR,
|
|
526
1252
|
)
|
|
527
1253
|
continue
|
|
528
|
-
|
|
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
|
-
)
|
|
1254
|
+
if remote_unavailable:
|
|
544
1255
|
continue
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
},
|
|
1256
|
+
if local_node is None:
|
|
1257
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1258
|
+
if not local_node or not private_key:
|
|
1259
|
+
remote_unavailable = True
|
|
1260
|
+
continue
|
|
1261
|
+
success, updates = self._call_remote_action(
|
|
1262
|
+
request,
|
|
1263
|
+
local_node,
|
|
1264
|
+
private_key,
|
|
1265
|
+
charger,
|
|
1266
|
+
"remote-stop",
|
|
557
1267
|
)
|
|
558
|
-
|
|
1268
|
+
if success:
|
|
1269
|
+
self._apply_remote_updates(charger, updates)
|
|
1270
|
+
stopped += 1
|
|
1271
|
+
|
|
559
1272
|
if stopped:
|
|
560
1273
|
self.message_user(
|
|
561
1274
|
request,
|
|
@@ -565,45 +1278,95 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
565
1278
|
@admin.action(description="Reset charger (soft)")
|
|
566
1279
|
def reset_chargers(self, request, queryset):
|
|
567
1280
|
reset = 0
|
|
1281
|
+
local_node = None
|
|
1282
|
+
private_key = None
|
|
1283
|
+
remote_unavailable = False
|
|
568
1284
|
for charger in queryset:
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
1285
|
+
if charger.is_local:
|
|
1286
|
+
connector_value = charger.connector_id
|
|
1287
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1288
|
+
if ws is None:
|
|
1289
|
+
self.message_user(
|
|
1290
|
+
request,
|
|
1291
|
+
f"{charger}: no active connection",
|
|
1292
|
+
level=messages.ERROR,
|
|
1293
|
+
)
|
|
1294
|
+
continue
|
|
1295
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
1296
|
+
if tx_obj is not None:
|
|
1297
|
+
self.message_user(
|
|
1298
|
+
request,
|
|
1299
|
+
(
|
|
1300
|
+
f"{charger}: reset skipped because a session is active; "
|
|
1301
|
+
"stop the session first."
|
|
1302
|
+
),
|
|
1303
|
+
level=messages.WARNING,
|
|
1304
|
+
)
|
|
1305
|
+
continue
|
|
1306
|
+
message_id = uuid.uuid4().hex
|
|
1307
|
+
msg = json.dumps([
|
|
1308
|
+
2,
|
|
1309
|
+
message_id,
|
|
1310
|
+
"Reset",
|
|
1311
|
+
{"type": "Soft"},
|
|
1312
|
+
])
|
|
1313
|
+
try:
|
|
1314
|
+
async_to_sync(ws.send)(msg)
|
|
1315
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1316
|
+
self.message_user(
|
|
1317
|
+
request,
|
|
1318
|
+
f"{charger}: failed to send Reset ({exc})",
|
|
1319
|
+
level=messages.ERROR,
|
|
1320
|
+
)
|
|
1321
|
+
continue
|
|
1322
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1323
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1324
|
+
store.register_pending_call(
|
|
1325
|
+
message_id,
|
|
1326
|
+
{
|
|
1327
|
+
"action": "Reset",
|
|
1328
|
+
"charger_id": charger.charger_id,
|
|
1329
|
+
"connector_id": connector_value,
|
|
1330
|
+
"log_key": log_key,
|
|
1331
|
+
"requested_at": timezone.now(),
|
|
1332
|
+
},
|
|
1333
|
+
)
|
|
1334
|
+
store.schedule_call_timeout(
|
|
1335
|
+
message_id,
|
|
1336
|
+
timeout=5.0,
|
|
1337
|
+
action="Reset",
|
|
1338
|
+
log_key=log_key,
|
|
1339
|
+
message="Reset timed out: charger did not respond",
|
|
576
1340
|
)
|
|
1341
|
+
reset += 1
|
|
577
1342
|
continue
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
1343
|
+
|
|
1344
|
+
if not charger.allow_remote:
|
|
588
1345
|
self.message_user(
|
|
589
1346
|
request,
|
|
590
|
-
f"{charger}:
|
|
1347
|
+
f"{charger}: remote administration is disabled.",
|
|
591
1348
|
level=messages.ERROR,
|
|
592
1349
|
)
|
|
593
1350
|
continue
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1351
|
+
if remote_unavailable:
|
|
1352
|
+
continue
|
|
1353
|
+
if local_node is None:
|
|
1354
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1355
|
+
if not local_node or not private_key:
|
|
1356
|
+
remote_unavailable = True
|
|
1357
|
+
continue
|
|
1358
|
+
success, updates = self._call_remote_action(
|
|
1359
|
+
request,
|
|
1360
|
+
local_node,
|
|
1361
|
+
private_key,
|
|
1362
|
+
charger,
|
|
1363
|
+
"reset",
|
|
1364
|
+
{"reset_type": "Soft"},
|
|
605
1365
|
)
|
|
606
|
-
|
|
1366
|
+
if success:
|
|
1367
|
+
self._apply_remote_updates(charger, updates)
|
|
1368
|
+
reset += 1
|
|
1369
|
+
|
|
607
1370
|
if reset:
|
|
608
1371
|
self.message_user(
|
|
609
1372
|
request,
|
|
@@ -619,13 +1382,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
619
1382
|
|
|
620
1383
|
total_kw_display.short_description = "Total kW"
|
|
621
1384
|
|
|
622
|
-
def
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1385
|
+
def today_kw(self, obj):
|
|
1386
|
+
start, end = self._today_range()
|
|
1387
|
+
return round(obj.total_kw_for_range(start, end), 2)
|
|
1388
|
+
|
|
1389
|
+
today_kw.short_description = "Today kW"
|
|
1390
|
+
|
|
1391
|
+
def changelist_view(self, request, extra_context=None):
|
|
1392
|
+
response = super().changelist_view(request, extra_context=extra_context)
|
|
1393
|
+
if hasattr(response, "context_data"):
|
|
1394
|
+
cl = response.context_data.get("cl")
|
|
1395
|
+
if cl is not None:
|
|
1396
|
+
response.context_data.update(
|
|
1397
|
+
self._charger_quick_stats_context(cl.queryset)
|
|
1398
|
+
)
|
|
1399
|
+
return response
|
|
1400
|
+
|
|
1401
|
+
def _charger_quick_stats_context(self, queryset):
|
|
1402
|
+
chargers = list(queryset)
|
|
1403
|
+
stats = {"total_kw": 0.0, "today_kw": 0.0}
|
|
1404
|
+
if not chargers:
|
|
1405
|
+
return {"charger_quick_stats": stats}
|
|
627
1406
|
|
|
628
|
-
|
|
1407
|
+
parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
|
|
1408
|
+
start, end = self._today_range()
|
|
1409
|
+
|
|
1410
|
+
for charger in chargers:
|
|
1411
|
+
include_totals = True
|
|
1412
|
+
if charger.connector_id is not None and charger.charger_id in parent_ids:
|
|
1413
|
+
include_totals = False
|
|
1414
|
+
if include_totals:
|
|
1415
|
+
stats["total_kw"] += charger.total_kw
|
|
1416
|
+
stats["today_kw"] += charger.total_kw_for_range(start, end)
|
|
1417
|
+
|
|
1418
|
+
stats = {key: round(value, 2) for key, value in stats.items()}
|
|
1419
|
+
return {"charger_quick_stats": stats}
|
|
1420
|
+
|
|
1421
|
+
def _today_range(self):
|
|
1422
|
+
today = timezone.localdate()
|
|
1423
|
+
start = datetime.combine(today, time.min)
|
|
1424
|
+
if timezone.is_naive(start):
|
|
1425
|
+
start = timezone.make_aware(start, timezone.get_current_timezone())
|
|
1426
|
+
end = start + timedelta(days=1)
|
|
1427
|
+
return start, end
|
|
629
1428
|
|
|
630
1429
|
|
|
631
1430
|
@admin.register(Simulator)
|
|
@@ -637,7 +1436,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
637
1436
|
"ws_port",
|
|
638
1437
|
"ws_url",
|
|
639
1438
|
"interval",
|
|
640
|
-
"
|
|
1439
|
+
"kw_max_display",
|
|
641
1440
|
"running",
|
|
642
1441
|
"log_link",
|
|
643
1442
|
)
|
|
@@ -677,6 +1476,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
677
1476
|
|
|
678
1477
|
log_type = "simulator"
|
|
679
1478
|
|
|
1479
|
+
@admin.display(description="kW Max", ordering="kw_max")
|
|
1480
|
+
def kw_max_display(self, obj):
|
|
1481
|
+
"""Display ``kw_max`` with a dot decimal separator for Spanish locales."""
|
|
1482
|
+
|
|
1483
|
+
language = translation.get_language() or ""
|
|
1484
|
+
if language.startswith("es"):
|
|
1485
|
+
return formats.number_format(
|
|
1486
|
+
obj.kw_max,
|
|
1487
|
+
decimal_pos=2,
|
|
1488
|
+
use_l10n=False,
|
|
1489
|
+
force_grouping=False,
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
return formats.number_format(
|
|
1493
|
+
obj.kw_max,
|
|
1494
|
+
decimal_pos=2,
|
|
1495
|
+
use_l10n=True,
|
|
1496
|
+
force_grouping=False,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
680
1499
|
def save_model(self, request, obj, form, change):
|
|
681
1500
|
previous_door_open = False
|
|
682
1501
|
if change and obj.pk:
|
|
@@ -830,8 +1649,10 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
830
1649
|
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
831
1650
|
list_display = (
|
|
832
1651
|
"charger",
|
|
1652
|
+
"connector_number",
|
|
833
1653
|
"account",
|
|
834
1654
|
"rfid",
|
|
1655
|
+
"vid",
|
|
835
1656
|
"meter_start",
|
|
836
1657
|
"meter_stop",
|
|
837
1658
|
"start_time",
|
|
@@ -843,6 +1664,12 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
843
1664
|
date_hierarchy = "start_time"
|
|
844
1665
|
inlines = [MeterValueInline]
|
|
845
1666
|
|
|
1667
|
+
def connector_number(self, obj):
|
|
1668
|
+
return obj.connector_id or ""
|
|
1669
|
+
|
|
1670
|
+
connector_number.short_description = "#"
|
|
1671
|
+
connector_number.admin_order_field = "connector_id"
|
|
1672
|
+
|
|
846
1673
|
def get_urls(self):
|
|
847
1674
|
urls = super().get_urls()
|
|
848
1675
|
custom = [
|