arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- 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 +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- 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.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
from django.contrib import admin, messages
|
|
2
|
+
from django.contrib.admin import helpers
|
|
2
3
|
from django import forms
|
|
3
4
|
|
|
4
5
|
import asyncio
|
|
5
|
-
|
|
6
|
+
import base64
|
|
7
|
+
from datetime import datetime, time, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse, unquote
|
|
10
|
+
import contextlib
|
|
6
11
|
import json
|
|
7
|
-
|
|
12
|
+
from typing import Any
|
|
8
13
|
from django.shortcuts import redirect
|
|
9
|
-
from django.utils import timezone
|
|
10
|
-
from django.
|
|
14
|
+
from django.utils import formats, timezone, translation
|
|
15
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
16
|
+
from django.utils.dateparse import parse_datetime
|
|
17
|
+
from django.utils.html import format_html, format_html_join
|
|
18
|
+
from django.utils.text import slugify
|
|
19
|
+
from django.urls import path, reverse
|
|
11
20
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
12
21
|
from django.template.response import TemplateResponse
|
|
13
22
|
|
|
14
23
|
import uuid
|
|
15
24
|
from asgiref.sync import async_to_sync
|
|
25
|
+
import requests
|
|
26
|
+
from requests import RequestException
|
|
27
|
+
from cryptography.hazmat.primitives import hashes
|
|
28
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
29
|
+
from django.db import transaction
|
|
30
|
+
from django.core.exceptions import ValidationError
|
|
31
|
+
from django.conf import settings
|
|
16
32
|
|
|
17
33
|
from .models import (
|
|
18
34
|
Charger,
|
|
35
|
+
ChargerConfiguration,
|
|
36
|
+
ConfigurationKey,
|
|
19
37
|
Simulator,
|
|
20
38
|
MeterValue,
|
|
21
39
|
Transaction,
|
|
22
40
|
Location,
|
|
23
41
|
DataTransferMessage,
|
|
42
|
+
CPReservation,
|
|
43
|
+
CPFirmware,
|
|
44
|
+
CPFirmwareDeployment,
|
|
24
45
|
)
|
|
25
46
|
from .simulator import ChargePointSimulator
|
|
26
47
|
from . import store
|
|
@@ -29,8 +50,11 @@ from .transactions_io import (
|
|
|
29
50
|
import_transactions as import_transactions_data,
|
|
30
51
|
)
|
|
31
52
|
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
53
|
+
from .views import _charger_state, _live_sessions
|
|
32
54
|
from core.admin import SaveBeforeChangeAction
|
|
55
|
+
from core.models import RFID as CoreRFID
|
|
33
56
|
from core.user_data import EntityModelAdmin
|
|
57
|
+
from nodes.models import Node
|
|
34
58
|
|
|
35
59
|
|
|
36
60
|
class LocationAdminForm(forms.ModelForm):
|
|
@@ -63,6 +87,101 @@ class TransactionImportForm(forms.Form):
|
|
|
63
87
|
file = forms.FileField()
|
|
64
88
|
|
|
65
89
|
|
|
90
|
+
class CPReservationForm(forms.ModelForm):
|
|
91
|
+
class Meta:
|
|
92
|
+
model = CPReservation
|
|
93
|
+
fields = [
|
|
94
|
+
"location",
|
|
95
|
+
"account",
|
|
96
|
+
"rfid",
|
|
97
|
+
"id_tag",
|
|
98
|
+
"start_time",
|
|
99
|
+
"duration_minutes",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def clean(self):
|
|
103
|
+
cleaned = super().clean()
|
|
104
|
+
instance = self.instance
|
|
105
|
+
for field in self.Meta.fields:
|
|
106
|
+
if field in cleaned:
|
|
107
|
+
setattr(instance, field, cleaned[field])
|
|
108
|
+
try:
|
|
109
|
+
instance.allocate_connector(force=bool(instance.pk))
|
|
110
|
+
except ValidationError as exc:
|
|
111
|
+
if exc.message_dict:
|
|
112
|
+
for field, errors in exc.message_dict.items():
|
|
113
|
+
for error in errors:
|
|
114
|
+
self.add_error(field, error)
|
|
115
|
+
raise forms.ValidationError(
|
|
116
|
+
_("Unable to allocate a connector for the selected time window.")
|
|
117
|
+
)
|
|
118
|
+
raise forms.ValidationError(exc.messages or [str(exc)])
|
|
119
|
+
if not instance.id_tag_value:
|
|
120
|
+
message = _("Select an RFID or provide an idTag for the reservation.")
|
|
121
|
+
self.add_error("id_tag", message)
|
|
122
|
+
self.add_error("rfid", message)
|
|
123
|
+
raise forms.ValidationError(message)
|
|
124
|
+
return cleaned
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class UploadFirmwareForm(forms.Form):
|
|
128
|
+
def __init__(self, *args, **kwargs):
|
|
129
|
+
super().__init__(*args, **kwargs)
|
|
130
|
+
default_date = timezone.now() + timedelta(minutes=1)
|
|
131
|
+
if timezone.is_naive(default_date):
|
|
132
|
+
default_date = timezone.make_aware(
|
|
133
|
+
default_date, timezone.get_current_timezone()
|
|
134
|
+
)
|
|
135
|
+
self.fields["retrieve_date"].initial = timezone.localtime(default_date)
|
|
136
|
+
self.fields["chargers"].queryset = (
|
|
137
|
+
Charger.objects.filter(connector_id__isnull=True)
|
|
138
|
+
.order_by("display_name", "charger_id")
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
142
|
+
label=_("Charge points"),
|
|
143
|
+
queryset=Charger.objects.none(),
|
|
144
|
+
help_text=_("Select the EVCS units to update."),
|
|
145
|
+
)
|
|
146
|
+
retrieve_date = forms.DateTimeField(
|
|
147
|
+
label=_("Retrieve date"),
|
|
148
|
+
required=False,
|
|
149
|
+
help_text=_("When the EVCS should start downloading the firmware."),
|
|
150
|
+
)
|
|
151
|
+
retries = forms.IntegerField(
|
|
152
|
+
label=_("Retries"),
|
|
153
|
+
required=False,
|
|
154
|
+
min_value=0,
|
|
155
|
+
initial=1,
|
|
156
|
+
help_text=_("Number of download attempts before giving up."),
|
|
157
|
+
)
|
|
158
|
+
retry_interval = forms.IntegerField(
|
|
159
|
+
label=_("Retry interval (seconds)"),
|
|
160
|
+
required=False,
|
|
161
|
+
min_value=0,
|
|
162
|
+
initial=600,
|
|
163
|
+
help_text=_("Seconds between retry attempts."),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def clean_retrieve_date(self):
|
|
167
|
+
value = self.cleaned_data.get("retrieve_date")
|
|
168
|
+
if value is None:
|
|
169
|
+
return None
|
|
170
|
+
if timezone.is_naive(value):
|
|
171
|
+
value = timezone.make_aware(value, timezone.get_current_timezone())
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
def clean(self):
|
|
175
|
+
cleaned = super().clean()
|
|
176
|
+
chargers = cleaned.get("chargers")
|
|
177
|
+
if not chargers:
|
|
178
|
+
self.add_error(
|
|
179
|
+
"chargers",
|
|
180
|
+
_("Select at least one charge point to receive the firmware."),
|
|
181
|
+
)
|
|
182
|
+
return cleaned
|
|
183
|
+
|
|
184
|
+
|
|
66
185
|
class LogViewAdminMixin:
|
|
67
186
|
"""Mixin providing an admin view to display charger or simulator logs."""
|
|
68
187
|
|
|
@@ -107,11 +226,149 @@ class LogViewAdminMixin:
|
|
|
107
226
|
return TemplateResponse(request, self.log_template_name, context)
|
|
108
227
|
|
|
109
228
|
|
|
229
|
+
class ConfigurationKeyInline(admin.TabularInline):
|
|
230
|
+
model = ConfigurationKey
|
|
231
|
+
extra = 0
|
|
232
|
+
can_delete = False
|
|
233
|
+
ordering = ("position", "id")
|
|
234
|
+
readonly_fields = (
|
|
235
|
+
"position",
|
|
236
|
+
"key",
|
|
237
|
+
"readonly",
|
|
238
|
+
"value_display",
|
|
239
|
+
"extra_display",
|
|
240
|
+
)
|
|
241
|
+
fields = readonly_fields
|
|
242
|
+
show_change_link = False
|
|
243
|
+
|
|
244
|
+
def has_add_permission(self, request, obj=None): # pragma: no cover - admin hook
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
@admin.display(description=_("Value"))
|
|
248
|
+
def value_display(self, obj):
|
|
249
|
+
if not obj.has_value:
|
|
250
|
+
return "-"
|
|
251
|
+
value = obj.value
|
|
252
|
+
if isinstance(value, (dict, list)):
|
|
253
|
+
formatted = json.dumps(value, indent=2, ensure_ascii=False)
|
|
254
|
+
return format_html("<pre>{}</pre>", formatted)
|
|
255
|
+
if value in (None, ""):
|
|
256
|
+
return "-"
|
|
257
|
+
return str(value)
|
|
258
|
+
|
|
259
|
+
@admin.display(description=_("Extra data"))
|
|
260
|
+
def extra_display(self, obj):
|
|
261
|
+
if not obj.extra_data:
|
|
262
|
+
return "-"
|
|
263
|
+
formatted = json.dumps(obj.extra_data, indent=2, ensure_ascii=False)
|
|
264
|
+
return format_html("<pre>{}</pre>", formatted)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@admin.register(ChargerConfiguration)
|
|
268
|
+
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
269
|
+
list_display = (
|
|
270
|
+
"charger_identifier",
|
|
271
|
+
"connector_display",
|
|
272
|
+
"origin_display",
|
|
273
|
+
"created_at",
|
|
274
|
+
)
|
|
275
|
+
list_filter = ("connector_id",)
|
|
276
|
+
search_fields = ("charger_identifier",)
|
|
277
|
+
readonly_fields = (
|
|
278
|
+
"charger_identifier",
|
|
279
|
+
"connector_id",
|
|
280
|
+
"origin_display",
|
|
281
|
+
"evcs_snapshot_at",
|
|
282
|
+
"created_at",
|
|
283
|
+
"updated_at",
|
|
284
|
+
"linked_chargers",
|
|
285
|
+
"unknown_keys_display",
|
|
286
|
+
"raw_payload_display",
|
|
287
|
+
)
|
|
288
|
+
inlines = (ConfigurationKeyInline,)
|
|
289
|
+
fieldsets = (
|
|
290
|
+
(
|
|
291
|
+
None,
|
|
292
|
+
{
|
|
293
|
+
"fields": (
|
|
294
|
+
"charger_identifier",
|
|
295
|
+
"connector_id",
|
|
296
|
+
"origin_display",
|
|
297
|
+
"evcs_snapshot_at",
|
|
298
|
+
"linked_chargers",
|
|
299
|
+
"created_at",
|
|
300
|
+
"updated_at",
|
|
301
|
+
)
|
|
302
|
+
},
|
|
303
|
+
),
|
|
304
|
+
(
|
|
305
|
+
"Payload",
|
|
306
|
+
{
|
|
307
|
+
"fields": (
|
|
308
|
+
"unknown_keys_display",
|
|
309
|
+
"raw_payload_display",
|
|
310
|
+
)
|
|
311
|
+
},
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@admin.display(description="Connector")
|
|
316
|
+
def connector_display(self, obj):
|
|
317
|
+
if obj.connector_id is None:
|
|
318
|
+
return "All"
|
|
319
|
+
return obj.connector_id
|
|
320
|
+
|
|
321
|
+
@admin.display(description="Linked charge points")
|
|
322
|
+
def linked_chargers(self, obj):
|
|
323
|
+
if obj.pk is None:
|
|
324
|
+
return ""
|
|
325
|
+
linked = [charger.identity_slug() for charger in obj.chargers.all()]
|
|
326
|
+
if not linked:
|
|
327
|
+
return "-"
|
|
328
|
+
return ", ".join(sorted(linked))
|
|
329
|
+
|
|
330
|
+
def _render_json(self, data):
|
|
331
|
+
from django.utils.html import format_html
|
|
332
|
+
|
|
333
|
+
if not data:
|
|
334
|
+
return "-"
|
|
335
|
+
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
|
336
|
+
return format_html("<pre>{}</pre>", formatted)
|
|
337
|
+
|
|
338
|
+
@admin.display(description="unknownKey")
|
|
339
|
+
def unknown_keys_display(self, obj):
|
|
340
|
+
return self._render_json(obj.unknown_keys)
|
|
341
|
+
|
|
342
|
+
@admin.display(description="Raw payload")
|
|
343
|
+
def raw_payload_display(self, obj):
|
|
344
|
+
return self._render_json(obj.raw_payload)
|
|
345
|
+
|
|
346
|
+
@admin.display(description="Origin")
|
|
347
|
+
def origin_display(self, obj):
|
|
348
|
+
if obj.evcs_snapshot_at:
|
|
349
|
+
return "EVCS"
|
|
350
|
+
return "Local"
|
|
351
|
+
|
|
352
|
+
def save_model(self, request, obj, form, change):
|
|
353
|
+
obj.evcs_snapshot_at = None
|
|
354
|
+
super().save_model(request, obj, form, change)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@admin.register(ConfigurationKey)
|
|
358
|
+
class ConfigurationKeyAdmin(admin.ModelAdmin):
|
|
359
|
+
list_display = ("configuration", "key", "position", "readonly")
|
|
360
|
+
ordering = ("configuration", "position", "id")
|
|
361
|
+
|
|
362
|
+
def get_model_perms(self, request): # pragma: no cover - admin hook
|
|
363
|
+
return {}
|
|
364
|
+
|
|
365
|
+
|
|
110
366
|
@admin.register(Location)
|
|
111
367
|
class LocationAdmin(EntityModelAdmin):
|
|
112
368
|
form = LocationAdminForm
|
|
113
|
-
list_display = ("name", "latitude", "longitude")
|
|
369
|
+
list_display = ("name", "zone", "contract_type", "latitude", "longitude")
|
|
114
370
|
change_form_template = "admin/ocpp/location/change_form.html"
|
|
371
|
+
search_fields = ("name",)
|
|
115
372
|
|
|
116
373
|
|
|
117
374
|
@admin.register(DataTransferMessage)
|
|
@@ -152,8 +409,561 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
|
|
|
152
409
|
)
|
|
153
410
|
|
|
154
411
|
|
|
412
|
+
class CPFirmwareDeploymentInline(admin.TabularInline):
|
|
413
|
+
model = CPFirmwareDeployment
|
|
414
|
+
extra = 0
|
|
415
|
+
can_delete = False
|
|
416
|
+
ordering = ("-requested_at",)
|
|
417
|
+
readonly_fields = (
|
|
418
|
+
"charger",
|
|
419
|
+
"node",
|
|
420
|
+
"status",
|
|
421
|
+
"status_info",
|
|
422
|
+
"status_timestamp",
|
|
423
|
+
"retrieve_date",
|
|
424
|
+
"retry_count",
|
|
425
|
+
"retry_interval",
|
|
426
|
+
"download_token",
|
|
427
|
+
"download_token_expires_at",
|
|
428
|
+
"downloaded_at",
|
|
429
|
+
"requested_at",
|
|
430
|
+
"completed_at",
|
|
431
|
+
"ocpp_message_id",
|
|
432
|
+
)
|
|
433
|
+
show_change_link = True
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _format_failure_message(result: dict, *, action_label: str) -> str:
|
|
437
|
+
error_code = str(result.get("error_code") or "").strip()
|
|
438
|
+
error_description = str(result.get("error_description") or "").strip()
|
|
439
|
+
details = result.get("error_details")
|
|
440
|
+
parts: list[str] = []
|
|
441
|
+
if error_code:
|
|
442
|
+
parts.append(_("code=%(code)s") % {"code": error_code})
|
|
443
|
+
if error_description:
|
|
444
|
+
parts.append(
|
|
445
|
+
_("description=%(description)s") % {"description": error_description}
|
|
446
|
+
)
|
|
447
|
+
if details:
|
|
448
|
+
try:
|
|
449
|
+
details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
|
|
450
|
+
except TypeError:
|
|
451
|
+
details_text = str(details)
|
|
452
|
+
if details_text:
|
|
453
|
+
parts.append(_("details=%(details)s") % {"details": details_text})
|
|
454
|
+
if parts:
|
|
455
|
+
return _("%(action)s failed: %(details)s") % {
|
|
456
|
+
"action": action_label,
|
|
457
|
+
"details": ", ".join(parts),
|
|
458
|
+
}
|
|
459
|
+
return _("%(action)s failed.") % {"action": action_label}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@admin.register(CPFirmware)
|
|
463
|
+
class CPFirmwareAdmin(EntityModelAdmin):
|
|
464
|
+
list_display = (
|
|
465
|
+
"name",
|
|
466
|
+
"filename",
|
|
467
|
+
"content_type",
|
|
468
|
+
"payload_size",
|
|
469
|
+
"downloaded_at",
|
|
470
|
+
"source_node",
|
|
471
|
+
"source_charger",
|
|
472
|
+
)
|
|
473
|
+
list_filter = ("source", "content_type")
|
|
474
|
+
search_fields = (
|
|
475
|
+
"name",
|
|
476
|
+
"filename",
|
|
477
|
+
"source_charger__charger_id",
|
|
478
|
+
"source_charger__display_name",
|
|
479
|
+
)
|
|
480
|
+
readonly_fields = (
|
|
481
|
+
"source",
|
|
482
|
+
"source_node",
|
|
483
|
+
"source_charger",
|
|
484
|
+
"payload_size",
|
|
485
|
+
"checksum",
|
|
486
|
+
"download_vendor_id",
|
|
487
|
+
"download_message_id",
|
|
488
|
+
"downloaded_at",
|
|
489
|
+
"created_at",
|
|
490
|
+
"updated_at",
|
|
491
|
+
"metadata",
|
|
492
|
+
)
|
|
493
|
+
fieldsets = (
|
|
494
|
+
(
|
|
495
|
+
None,
|
|
496
|
+
{
|
|
497
|
+
"fields": (
|
|
498
|
+
"name",
|
|
499
|
+
"description",
|
|
500
|
+
"filename",
|
|
501
|
+
"content_type",
|
|
502
|
+
"payload_encoding",
|
|
503
|
+
"payload_size",
|
|
504
|
+
"checksum",
|
|
505
|
+
)
|
|
506
|
+
},
|
|
507
|
+
),
|
|
508
|
+
(
|
|
509
|
+
_("Source"),
|
|
510
|
+
{
|
|
511
|
+
"fields": (
|
|
512
|
+
"source",
|
|
513
|
+
"source_node",
|
|
514
|
+
"source_charger",
|
|
515
|
+
"download_vendor_id",
|
|
516
|
+
"download_message_id",
|
|
517
|
+
"downloaded_at",
|
|
518
|
+
)
|
|
519
|
+
},
|
|
520
|
+
),
|
|
521
|
+
(
|
|
522
|
+
_("Metadata"),
|
|
523
|
+
{"fields": ("metadata", "created_at", "updated_at")},
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
actions = ["upload_evcs_firmware"]
|
|
527
|
+
inlines = [CPFirmwareDeploymentInline]
|
|
528
|
+
|
|
529
|
+
def _format_pending_failure(self, result: dict) -> str:
|
|
530
|
+
return _format_failure_message(result, action_label=_("Update firmware"))
|
|
531
|
+
|
|
532
|
+
def _dispatch_firmware_update(
|
|
533
|
+
self,
|
|
534
|
+
request,
|
|
535
|
+
firmware: CPFirmware,
|
|
536
|
+
charger: Charger,
|
|
537
|
+
retrieve_date: datetime | None,
|
|
538
|
+
retries: int | None,
|
|
539
|
+
retry_interval: int | None,
|
|
540
|
+
) -> bool:
|
|
541
|
+
connection = store.get_connection(charger.charger_id, charger.connector_id)
|
|
542
|
+
if connection is None:
|
|
543
|
+
self.message_user(
|
|
544
|
+
request,
|
|
545
|
+
_("%(charger)s is not currently connected to the platform.")
|
|
546
|
+
% {"charger": charger},
|
|
547
|
+
level=messages.ERROR,
|
|
548
|
+
)
|
|
549
|
+
return False
|
|
550
|
+
|
|
551
|
+
if not firmware.has_binary and not firmware.has_json:
|
|
552
|
+
self.message_user(
|
|
553
|
+
request,
|
|
554
|
+
_("%(firmware)s does not contain any payload to upload.")
|
|
555
|
+
% {"firmware": firmware},
|
|
556
|
+
level=messages.ERROR,
|
|
557
|
+
)
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
start_time = retrieve_date or (timezone.now() + timedelta(seconds=30))
|
|
561
|
+
if timezone.is_naive(start_time):
|
|
562
|
+
start_time = timezone.make_aware(
|
|
563
|
+
start_time, timezone.get_current_timezone()
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
message_id = uuid.uuid4().hex
|
|
567
|
+
deployment = CPFirmwareDeployment.objects.create(
|
|
568
|
+
firmware=firmware,
|
|
569
|
+
charger=charger,
|
|
570
|
+
node=charger.node_origin,
|
|
571
|
+
ocpp_message_id=message_id,
|
|
572
|
+
status="Pending",
|
|
573
|
+
status_info=_("Awaiting charge point response."),
|
|
574
|
+
status_timestamp=timezone.now(),
|
|
575
|
+
retrieve_date=start_time,
|
|
576
|
+
retry_count=int(retries or 0),
|
|
577
|
+
retry_interval=int(retry_interval or 0),
|
|
578
|
+
request_payload={},
|
|
579
|
+
is_user_data=True,
|
|
580
|
+
)
|
|
581
|
+
token = deployment.issue_download_token(lifetime=timedelta(hours=4))
|
|
582
|
+
download_url = request.build_absolute_uri(
|
|
583
|
+
reverse("cp-firmware-download", args=[deployment.pk, token])
|
|
584
|
+
)
|
|
585
|
+
payload = {
|
|
586
|
+
"location": download_url,
|
|
587
|
+
"retrieveDate": start_time.isoformat(),
|
|
588
|
+
}
|
|
589
|
+
if retries is not None:
|
|
590
|
+
payload["retries"] = int(retries)
|
|
591
|
+
if retry_interval:
|
|
592
|
+
payload["retryInterval"] = int(retry_interval)
|
|
593
|
+
if firmware.checksum:
|
|
594
|
+
payload["checksum"] = firmware.checksum
|
|
595
|
+
deployment.request_payload = payload
|
|
596
|
+
deployment.save(update_fields=["request_payload", "updated_at"])
|
|
597
|
+
|
|
598
|
+
frame = json.dumps([2, message_id, "UpdateFirmware", payload])
|
|
599
|
+
async_to_sync(connection.send)(frame)
|
|
600
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
601
|
+
store.add_log(
|
|
602
|
+
log_key,
|
|
603
|
+
_("Dispatched UpdateFirmware request."),
|
|
604
|
+
log_type="charger",
|
|
605
|
+
)
|
|
606
|
+
store.register_pending_call(
|
|
607
|
+
message_id,
|
|
608
|
+
{
|
|
609
|
+
"action": "UpdateFirmware",
|
|
610
|
+
"charger_id": charger.charger_id,
|
|
611
|
+
"connector_id": charger.connector_id,
|
|
612
|
+
"deployment_pk": deployment.pk,
|
|
613
|
+
"log_key": log_key,
|
|
614
|
+
},
|
|
615
|
+
)
|
|
616
|
+
store.schedule_call_timeout(
|
|
617
|
+
message_id, action="UpdateFirmware", log_key=log_key
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
result = store.wait_for_pending_call(message_id, timeout=15.0)
|
|
621
|
+
if result is None:
|
|
622
|
+
deployment.mark_status("Timeout", _("No response received."))
|
|
623
|
+
deployment.completed_at = timezone.now()
|
|
624
|
+
deployment.save(update_fields=["completed_at", "updated_at"])
|
|
625
|
+
self.message_user(
|
|
626
|
+
request,
|
|
627
|
+
_(
|
|
628
|
+
"The charge point did not respond to the UpdateFirmware request."
|
|
629
|
+
),
|
|
630
|
+
level=messages.ERROR,
|
|
631
|
+
)
|
|
632
|
+
return False
|
|
633
|
+
if not result.get("success", True):
|
|
634
|
+
detail = self._format_pending_failure(result)
|
|
635
|
+
deployment.mark_status("Error", detail, response=result.get("payload"))
|
|
636
|
+
deployment.completed_at = timezone.now()
|
|
637
|
+
deployment.save(update_fields=["completed_at", "updated_at"])
|
|
638
|
+
self.message_user(request, detail, level=messages.ERROR)
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
payload_data = result.get("payload") or {}
|
|
642
|
+
status_value = str(payload_data.get("status") or "").strip() or "Accepted"
|
|
643
|
+
timestamp = timezone.now()
|
|
644
|
+
deployment.mark_status(status_value, "", timestamp, response=payload_data)
|
|
645
|
+
if status_value.lower() != "accepted":
|
|
646
|
+
self.message_user(
|
|
647
|
+
request,
|
|
648
|
+
_(
|
|
649
|
+
"UpdateFirmware for %(charger)s was %(status)s."
|
|
650
|
+
)
|
|
651
|
+
% {"charger": charger, "status": status_value},
|
|
652
|
+
level=messages.ERROR,
|
|
653
|
+
)
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
self.message_user(
|
|
657
|
+
request,
|
|
658
|
+
_("Queued firmware installation for %(charger)s.")
|
|
659
|
+
% {"charger": charger},
|
|
660
|
+
level=messages.SUCCESS,
|
|
661
|
+
)
|
|
662
|
+
return True
|
|
663
|
+
|
|
664
|
+
@admin.action(description=_("Upload EVCS firmware"))
|
|
665
|
+
def upload_evcs_firmware(self, request, queryset):
|
|
666
|
+
selected_ids = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
|
|
667
|
+
if selected_ids:
|
|
668
|
+
firmware_qs = CPFirmware.objects.filter(pk__in=selected_ids)
|
|
669
|
+
firmware_map = {str(obj.pk): obj for obj in firmware_qs}
|
|
670
|
+
firmware_list = [
|
|
671
|
+
firmware_map[value]
|
|
672
|
+
for value in selected_ids
|
|
673
|
+
if value in firmware_map
|
|
674
|
+
]
|
|
675
|
+
else:
|
|
676
|
+
firmware_list = list(queryset)
|
|
677
|
+
selected_ids = [str(obj.pk) for obj in firmware_list]
|
|
678
|
+
|
|
679
|
+
if not firmware_list:
|
|
680
|
+
self.message_user(
|
|
681
|
+
request,
|
|
682
|
+
_("Select at least one firmware record to upload."),
|
|
683
|
+
level=messages.ERROR,
|
|
684
|
+
)
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
form = UploadFirmwareForm(request.POST or None)
|
|
688
|
+
if request.method == "POST" and form.is_valid():
|
|
689
|
+
chargers = list(form.cleaned_data["chargers"])
|
|
690
|
+
retrieve_date = form.cleaned_data.get("retrieve_date")
|
|
691
|
+
retries = form.cleaned_data.get("retries")
|
|
692
|
+
retry_interval = form.cleaned_data.get("retry_interval")
|
|
693
|
+
success_count = 0
|
|
694
|
+
for firmware in firmware_list:
|
|
695
|
+
for charger in chargers:
|
|
696
|
+
if self._dispatch_firmware_update(
|
|
697
|
+
request,
|
|
698
|
+
firmware,
|
|
699
|
+
charger,
|
|
700
|
+
retrieve_date,
|
|
701
|
+
retries,
|
|
702
|
+
retry_interval,
|
|
703
|
+
):
|
|
704
|
+
success_count += 1
|
|
705
|
+
if success_count:
|
|
706
|
+
self.message_user(
|
|
707
|
+
request,
|
|
708
|
+
ngettext(
|
|
709
|
+
"Queued %(count)d firmware upload.",
|
|
710
|
+
"Queued %(count)d firmware uploads.",
|
|
711
|
+
success_count,
|
|
712
|
+
)
|
|
713
|
+
% {"count": success_count},
|
|
714
|
+
level=messages.SUCCESS,
|
|
715
|
+
)
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
context = {
|
|
719
|
+
**self.admin_site.each_context(request),
|
|
720
|
+
"opts": self.model._meta,
|
|
721
|
+
"title": _("Upload EVCS firmware"),
|
|
722
|
+
"firmware_list": firmware_list,
|
|
723
|
+
"selected_ids": selected_ids,
|
|
724
|
+
"action_name": request.POST.get("action", "upload_evcs_firmware"),
|
|
725
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
726
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
727
|
+
"adminform": helpers.AdminForm(
|
|
728
|
+
form,
|
|
729
|
+
[
|
|
730
|
+
(
|
|
731
|
+
None,
|
|
732
|
+
{
|
|
733
|
+
"fields": (
|
|
734
|
+
"chargers",
|
|
735
|
+
"retrieve_date",
|
|
736
|
+
"retries",
|
|
737
|
+
"retry_interval",
|
|
738
|
+
)
|
|
739
|
+
},
|
|
740
|
+
)
|
|
741
|
+
],
|
|
742
|
+
{},
|
|
743
|
+
),
|
|
744
|
+
"form": form,
|
|
745
|
+
"media": self.media + form.media,
|
|
746
|
+
}
|
|
747
|
+
return TemplateResponse(
|
|
748
|
+
request, "admin/ocpp/cpfirmware/upload_evcs.html", context
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@admin.register(CPFirmwareDeployment)
|
|
753
|
+
class CPFirmwareDeploymentAdmin(EntityModelAdmin):
|
|
754
|
+
list_display = (
|
|
755
|
+
"firmware",
|
|
756
|
+
"charger",
|
|
757
|
+
"status",
|
|
758
|
+
"status_timestamp",
|
|
759
|
+
"requested_at",
|
|
760
|
+
"completed_at",
|
|
761
|
+
)
|
|
762
|
+
list_filter = ("status",)
|
|
763
|
+
search_fields = (
|
|
764
|
+
"firmware__name",
|
|
765
|
+
"charger__charger_id",
|
|
766
|
+
"ocpp_message_id",
|
|
767
|
+
)
|
|
768
|
+
readonly_fields = (
|
|
769
|
+
"firmware",
|
|
770
|
+
"charger",
|
|
771
|
+
"node",
|
|
772
|
+
"ocpp_message_id",
|
|
773
|
+
"status",
|
|
774
|
+
"status_info",
|
|
775
|
+
"status_timestamp",
|
|
776
|
+
"requested_at",
|
|
777
|
+
"completed_at",
|
|
778
|
+
"retrieve_date",
|
|
779
|
+
"retry_count",
|
|
780
|
+
"retry_interval",
|
|
781
|
+
"download_token",
|
|
782
|
+
"download_token_expires_at",
|
|
783
|
+
"downloaded_at",
|
|
784
|
+
"request_payload",
|
|
785
|
+
"response_payload",
|
|
786
|
+
"created_at",
|
|
787
|
+
"updated_at",
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
@admin.register(CPReservation)
|
|
791
|
+
class CPReservationAdmin(EntityModelAdmin):
|
|
792
|
+
form = CPReservationForm
|
|
793
|
+
actions = ("cancel_reservations",)
|
|
794
|
+
list_display = (
|
|
795
|
+
"location",
|
|
796
|
+
"connector_side_display",
|
|
797
|
+
"start_time",
|
|
798
|
+
"end_time_display",
|
|
799
|
+
"account",
|
|
800
|
+
"id_tag_display",
|
|
801
|
+
"evcs_status",
|
|
802
|
+
"evcs_confirmed",
|
|
803
|
+
)
|
|
804
|
+
list_filter = ("location", "evcs_confirmed")
|
|
805
|
+
search_fields = (
|
|
806
|
+
"location__name",
|
|
807
|
+
"connector__charger_id",
|
|
808
|
+
"connector__display_name",
|
|
809
|
+
"account__name",
|
|
810
|
+
"id_tag",
|
|
811
|
+
"rfid__rfid",
|
|
812
|
+
)
|
|
813
|
+
date_hierarchy = "start_time"
|
|
814
|
+
ordering = ("-start_time",)
|
|
815
|
+
autocomplete_fields = ("location", "account", "rfid")
|
|
816
|
+
readonly_fields = (
|
|
817
|
+
"connector_identity",
|
|
818
|
+
"connector_side_display",
|
|
819
|
+
"evcs_status",
|
|
820
|
+
"evcs_error",
|
|
821
|
+
"evcs_confirmed",
|
|
822
|
+
"evcs_confirmed_at",
|
|
823
|
+
"ocpp_message_id",
|
|
824
|
+
"created_on",
|
|
825
|
+
"updated_on",
|
|
826
|
+
)
|
|
827
|
+
fieldsets = (
|
|
828
|
+
(
|
|
829
|
+
None,
|
|
830
|
+
{
|
|
831
|
+
"fields": (
|
|
832
|
+
"location",
|
|
833
|
+
"account",
|
|
834
|
+
"rfid",
|
|
835
|
+
"id_tag",
|
|
836
|
+
"start_time",
|
|
837
|
+
"duration_minutes",
|
|
838
|
+
)
|
|
839
|
+
},
|
|
840
|
+
),
|
|
841
|
+
(
|
|
842
|
+
_("Assigned connector"),
|
|
843
|
+
{"fields": ("connector_identity", "connector_side_display")},
|
|
844
|
+
),
|
|
845
|
+
(
|
|
846
|
+
_("EVCS response"),
|
|
847
|
+
{
|
|
848
|
+
"fields": (
|
|
849
|
+
"evcs_confirmed",
|
|
850
|
+
"evcs_status",
|
|
851
|
+
"evcs_confirmed_at",
|
|
852
|
+
"evcs_error",
|
|
853
|
+
"ocpp_message_id",
|
|
854
|
+
)
|
|
855
|
+
},
|
|
856
|
+
),
|
|
857
|
+
(
|
|
858
|
+
_("Metadata"),
|
|
859
|
+
{"fields": ("created_on", "updated_on")},
|
|
860
|
+
),
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
def save_model(self, request, obj, form, change):
|
|
864
|
+
trigger_fields = {
|
|
865
|
+
"start_time",
|
|
866
|
+
"duration_minutes",
|
|
867
|
+
"location",
|
|
868
|
+
"id_tag",
|
|
869
|
+
"rfid",
|
|
870
|
+
"account",
|
|
871
|
+
}
|
|
872
|
+
changed_data = set(getattr(form, "changed_data", []))
|
|
873
|
+
should_send = not change or bool(trigger_fields.intersection(changed_data))
|
|
874
|
+
with transaction.atomic():
|
|
875
|
+
super().save_model(request, obj, form, change)
|
|
876
|
+
if should_send:
|
|
877
|
+
try:
|
|
878
|
+
obj.send_reservation_request()
|
|
879
|
+
except ValidationError as exc:
|
|
880
|
+
raise ValidationError(exc.message_dict or exc.messages or str(exc))
|
|
881
|
+
else:
|
|
882
|
+
self.message_user(
|
|
883
|
+
request,
|
|
884
|
+
_("Reservation request sent to %(connector)s.")
|
|
885
|
+
% {"connector": self.connector_identity(obj)},
|
|
886
|
+
messages.SUCCESS,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
@admin.display(description=_("Connector"), ordering="connector__connector_id")
|
|
890
|
+
def connector_side_display(self, obj):
|
|
891
|
+
return obj.connector_label or "-"
|
|
892
|
+
|
|
893
|
+
@admin.display(description=_("Connector identity"))
|
|
894
|
+
def connector_identity(self, obj):
|
|
895
|
+
if obj.connector_id:
|
|
896
|
+
return obj.connector.identity_slug()
|
|
897
|
+
return "-"
|
|
898
|
+
|
|
899
|
+
@admin.display(description=_("End time"))
|
|
900
|
+
def end_time_display(self, obj):
|
|
901
|
+
try:
|
|
902
|
+
value = timezone.localtime(obj.end_time)
|
|
903
|
+
except Exception:
|
|
904
|
+
value = obj.end_time
|
|
905
|
+
if not value:
|
|
906
|
+
return "-"
|
|
907
|
+
return formats.date_format(value, "DATETIME_FORMAT")
|
|
908
|
+
|
|
909
|
+
@admin.display(description=_("Id tag"))
|
|
910
|
+
def id_tag_display(self, obj):
|
|
911
|
+
value = obj.id_tag_value
|
|
912
|
+
return value or "-"
|
|
913
|
+
|
|
914
|
+
@admin.action(description=_("Cancel selected Reservations"))
|
|
915
|
+
def cancel_reservations(self, request, queryset):
|
|
916
|
+
cancelled = 0
|
|
917
|
+
for reservation in queryset:
|
|
918
|
+
try:
|
|
919
|
+
reservation.send_cancel_request()
|
|
920
|
+
except ValidationError as exc:
|
|
921
|
+
messages_list: list[str] = []
|
|
922
|
+
if getattr(exc, "message_dict", None):
|
|
923
|
+
for errors in exc.message_dict.values():
|
|
924
|
+
messages_list.extend(str(error) for error in errors)
|
|
925
|
+
elif getattr(exc, "messages", None):
|
|
926
|
+
messages_list.extend(str(error) for error in exc.messages)
|
|
927
|
+
else:
|
|
928
|
+
messages_list.append(str(exc))
|
|
929
|
+
for message in messages_list:
|
|
930
|
+
self.message_user(
|
|
931
|
+
request,
|
|
932
|
+
_("%(reservation)s: %(message)s")
|
|
933
|
+
% {"reservation": reservation, "message": message},
|
|
934
|
+
level=messages.ERROR,
|
|
935
|
+
)
|
|
936
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
937
|
+
self.message_user(
|
|
938
|
+
request,
|
|
939
|
+
_("%(reservation)s: unable to cancel reservation (%(error)s)")
|
|
940
|
+
% {"reservation": reservation, "error": exc},
|
|
941
|
+
level=messages.ERROR,
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
cancelled += 1
|
|
945
|
+
if cancelled:
|
|
946
|
+
self.message_user(
|
|
947
|
+
request,
|
|
948
|
+
ngettext(
|
|
949
|
+
"Sent %(count)d cancellation request.",
|
|
950
|
+
"Sent %(count)d cancellation requests.",
|
|
951
|
+
cancelled,
|
|
952
|
+
)
|
|
953
|
+
% {"count": cancelled},
|
|
954
|
+
level=messages.SUCCESS,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
155
958
|
@admin.register(Charger)
|
|
156
959
|
class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
960
|
+
_REMOTE_DATETIME_FIELDS = {
|
|
961
|
+
"availability_state_updated_at",
|
|
962
|
+
"availability_requested_at",
|
|
963
|
+
"availability_request_status_at",
|
|
964
|
+
"last_online_at",
|
|
965
|
+
}
|
|
966
|
+
|
|
157
967
|
fieldsets = (
|
|
158
968
|
(
|
|
159
969
|
"General",
|
|
@@ -162,6 +972,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
162
972
|
"charger_id",
|
|
163
973
|
"display_name",
|
|
164
974
|
"connector_id",
|
|
975
|
+
"language",
|
|
165
976
|
"location",
|
|
166
977
|
"last_path",
|
|
167
978
|
"last_heartbeat",
|
|
@@ -205,7 +1016,30 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
205
1016
|
),
|
|
206
1017
|
(
|
|
207
1018
|
"Configuration",
|
|
208
|
-
{"fields": ("public_display", "require_rfid")},
|
|
1019
|
+
{"fields": ("public_display", "require_rfid", "configuration")},
|
|
1020
|
+
),
|
|
1021
|
+
(
|
|
1022
|
+
"Local authorization",
|
|
1023
|
+
{
|
|
1024
|
+
"fields": (
|
|
1025
|
+
"local_auth_list_version",
|
|
1026
|
+
"local_auth_list_updated_at",
|
|
1027
|
+
)
|
|
1028
|
+
},
|
|
1029
|
+
),
|
|
1030
|
+
(
|
|
1031
|
+
"Network",
|
|
1032
|
+
{
|
|
1033
|
+
"fields": (
|
|
1034
|
+
"node_origin",
|
|
1035
|
+
"manager_node",
|
|
1036
|
+
"forwarded_to",
|
|
1037
|
+
"forwarding_watermark",
|
|
1038
|
+
"allow_remote",
|
|
1039
|
+
"export_transactions",
|
|
1040
|
+
"last_online_at",
|
|
1041
|
+
)
|
|
1042
|
+
},
|
|
209
1043
|
),
|
|
210
1044
|
(
|
|
211
1045
|
"References",
|
|
@@ -234,15 +1068,22 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
234
1068
|
"availability_request_status",
|
|
235
1069
|
"availability_request_status_at",
|
|
236
1070
|
"availability_request_details",
|
|
1071
|
+
"configuration",
|
|
1072
|
+
"local_auth_list_version",
|
|
1073
|
+
"local_auth_list_updated_at",
|
|
1074
|
+
"forwarded_to",
|
|
1075
|
+
"forwarding_watermark",
|
|
1076
|
+
"last_online_at",
|
|
237
1077
|
)
|
|
238
1078
|
list_display = (
|
|
239
|
-
"
|
|
1079
|
+
"display_name_with_fallback",
|
|
240
1080
|
"connector_number",
|
|
241
|
-
"
|
|
1081
|
+
"charger_name_display",
|
|
1082
|
+
"local_indicator",
|
|
242
1083
|
"require_rfid_display",
|
|
243
1084
|
"public_display",
|
|
244
1085
|
"last_heartbeat",
|
|
245
|
-
"
|
|
1086
|
+
"today_kw",
|
|
246
1087
|
"total_kw_display",
|
|
247
1088
|
"page_link",
|
|
248
1089
|
"log_link",
|
|
@@ -253,15 +1094,348 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
253
1094
|
actions = [
|
|
254
1095
|
"purge_data",
|
|
255
1096
|
"fetch_cp_configuration",
|
|
1097
|
+
"toggle_rfid_authentication",
|
|
1098
|
+
"send_rfid_list_to_evcs",
|
|
1099
|
+
"update_rfids_from_evcs",
|
|
1100
|
+
"recheck_charger_status",
|
|
1101
|
+
"get_diagnostics",
|
|
256
1102
|
"change_availability_operative",
|
|
257
1103
|
"change_availability_inoperative",
|
|
258
1104
|
"set_availability_state_operative",
|
|
259
1105
|
"set_availability_state_inoperative",
|
|
1106
|
+
"clear_authorization_cache",
|
|
260
1107
|
"remote_stop_transaction",
|
|
261
1108
|
"reset_chargers",
|
|
262
1109
|
"delete_selected",
|
|
263
1110
|
]
|
|
264
1111
|
|
|
1112
|
+
class DiagnosticsDownloadError(Exception):
|
|
1113
|
+
"""Raised when diagnostics downloads fail."""
|
|
1114
|
+
|
|
1115
|
+
def _diagnostics_directory_for(self, user) -> tuple[Path, Path]:
|
|
1116
|
+
username = getattr(user, "get_username", None)
|
|
1117
|
+
if callable(username):
|
|
1118
|
+
username = username()
|
|
1119
|
+
else:
|
|
1120
|
+
username = getattr(user, "username", "")
|
|
1121
|
+
if not username:
|
|
1122
|
+
username = str(getattr(user, "pk", "user"))
|
|
1123
|
+
username_component = Path(str(username)).name or "user"
|
|
1124
|
+
base_dir = Path(settings.BASE_DIR)
|
|
1125
|
+
user_dir = base_dir / "work" / username_component
|
|
1126
|
+
diagnostics_dir = user_dir / "diagnostics"
|
|
1127
|
+
diagnostics_dir.mkdir(parents=True, exist_ok=True)
|
|
1128
|
+
return diagnostics_dir, user_dir
|
|
1129
|
+
|
|
1130
|
+
def _content_disposition_filename(self, header_value: str) -> str:
|
|
1131
|
+
for part in header_value.split(";"):
|
|
1132
|
+
candidate = part.strip()
|
|
1133
|
+
lower = candidate.lower()
|
|
1134
|
+
if lower.startswith("filename*="):
|
|
1135
|
+
value = candidate.split("=", 1)[1].strip()
|
|
1136
|
+
if value.lower().startswith("utf-8''"):
|
|
1137
|
+
value = value[7:]
|
|
1138
|
+
return Path(unquote(value.strip('"'))).name
|
|
1139
|
+
if lower.startswith("filename="):
|
|
1140
|
+
value = candidate.split("=", 1)[1].strip().strip('"')
|
|
1141
|
+
return Path(value).name
|
|
1142
|
+
return ""
|
|
1143
|
+
|
|
1144
|
+
def _diagnostics_filename(self, charger: Charger, location: str, response) -> str:
|
|
1145
|
+
parsed = urlparse(location)
|
|
1146
|
+
candidate = Path(parsed.path or "").name
|
|
1147
|
+
header_name = ""
|
|
1148
|
+
content_disposition = response.headers.get("Content-Disposition") if hasattr(response, "headers") else None
|
|
1149
|
+
if content_disposition:
|
|
1150
|
+
header_name = self._content_disposition_filename(content_disposition)
|
|
1151
|
+
if header_name:
|
|
1152
|
+
candidate = header_name
|
|
1153
|
+
if not candidate:
|
|
1154
|
+
candidate = "diagnostics.log"
|
|
1155
|
+
path_candidate = Path(candidate)
|
|
1156
|
+
suffix = "".join(path_candidate.suffixes)
|
|
1157
|
+
if suffix:
|
|
1158
|
+
base_name = candidate[: -len(suffix)]
|
|
1159
|
+
else:
|
|
1160
|
+
base_name = candidate
|
|
1161
|
+
suffix = ".log"
|
|
1162
|
+
base_name = base_name.rstrip(".")
|
|
1163
|
+
if not base_name:
|
|
1164
|
+
base_name = "diagnostics"
|
|
1165
|
+
charger_slug = slugify(charger.charger_id or charger.display_name or str(charger.pk or "charger"))
|
|
1166
|
+
if not charger_slug:
|
|
1167
|
+
charger_slug = "charger"
|
|
1168
|
+
diagnostics_slug = slugify(base_name) or "diagnostics"
|
|
1169
|
+
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
|
|
1170
|
+
return f"{charger_slug}-{diagnostics_slug}-{timestamp}{suffix}"
|
|
1171
|
+
|
|
1172
|
+
def _unique_diagnostics_path(self, directory: Path, filename: str) -> Path:
|
|
1173
|
+
base_path = Path(filename)
|
|
1174
|
+
suffix = "".join(base_path.suffixes)
|
|
1175
|
+
if suffix:
|
|
1176
|
+
base_name = filename[: -len(suffix)]
|
|
1177
|
+
else:
|
|
1178
|
+
base_name = filename
|
|
1179
|
+
suffix = ""
|
|
1180
|
+
base_name = base_name.rstrip(".") or "diagnostics"
|
|
1181
|
+
candidate = directory / f"{base_name}{suffix}"
|
|
1182
|
+
counter = 1
|
|
1183
|
+
while candidate.exists():
|
|
1184
|
+
candidate = directory / f"{base_name}-{counter}{suffix}"
|
|
1185
|
+
counter += 1
|
|
1186
|
+
return candidate
|
|
1187
|
+
|
|
1188
|
+
def _download_diagnostics(
|
|
1189
|
+
self,
|
|
1190
|
+
request,
|
|
1191
|
+
charger: Charger,
|
|
1192
|
+
location: str,
|
|
1193
|
+
diagnostics_dir: Path,
|
|
1194
|
+
user_dir: Path,
|
|
1195
|
+
) -> tuple[Path, str]:
|
|
1196
|
+
parsed = urlparse(location)
|
|
1197
|
+
scheme = (parsed.scheme or "").lower()
|
|
1198
|
+
if scheme not in {"http", "https"}:
|
|
1199
|
+
raise self.DiagnosticsDownloadError(
|
|
1200
|
+
_("Diagnostics location must use HTTP or HTTPS.")
|
|
1201
|
+
)
|
|
1202
|
+
try:
|
|
1203
|
+
response = requests.get(location, stream=True, timeout=15)
|
|
1204
|
+
except RequestException as exc:
|
|
1205
|
+
raise self.DiagnosticsDownloadError(
|
|
1206
|
+
_("Failed to download diagnostics: %s") % exc
|
|
1207
|
+
) from exc
|
|
1208
|
+
try:
|
|
1209
|
+
if response.status_code != 200:
|
|
1210
|
+
raise self.DiagnosticsDownloadError(
|
|
1211
|
+
_("Diagnostics download returned status %s.")
|
|
1212
|
+
% response.status_code
|
|
1213
|
+
)
|
|
1214
|
+
filename = self._diagnostics_filename(charger, location, response)
|
|
1215
|
+
destination = self._unique_diagnostics_path(diagnostics_dir, filename)
|
|
1216
|
+
try:
|
|
1217
|
+
with destination.open("wb") as handle:
|
|
1218
|
+
for chunk in response.iter_content(chunk_size=65536):
|
|
1219
|
+
if not chunk:
|
|
1220
|
+
continue
|
|
1221
|
+
handle.write(chunk)
|
|
1222
|
+
except OSError as exc:
|
|
1223
|
+
raise self.DiagnosticsDownloadError(
|
|
1224
|
+
_("Unable to write diagnostics file: %s") % exc
|
|
1225
|
+
) from exc
|
|
1226
|
+
finally:
|
|
1227
|
+
with contextlib.suppress(Exception):
|
|
1228
|
+
response.close()
|
|
1229
|
+
relative_asset = destination.relative_to(user_dir).as_posix()
|
|
1230
|
+
asset_url = reverse(
|
|
1231
|
+
"pages:readme-asset",
|
|
1232
|
+
kwargs={"source": "work", "asset": relative_asset},
|
|
1233
|
+
)
|
|
1234
|
+
absolute_url = request.build_absolute_uri(asset_url)
|
|
1235
|
+
return destination, absolute_url
|
|
1236
|
+
|
|
1237
|
+
def _prepare_remote_credentials(self, request):
|
|
1238
|
+
local = Node.get_local()
|
|
1239
|
+
if not local or not local.uuid:
|
|
1240
|
+
self.message_user(
|
|
1241
|
+
request,
|
|
1242
|
+
"Local node is not registered; remote actions are unavailable.",
|
|
1243
|
+
level=messages.ERROR,
|
|
1244
|
+
)
|
|
1245
|
+
return None, None
|
|
1246
|
+
private_key = local.get_private_key()
|
|
1247
|
+
if private_key is None:
|
|
1248
|
+
self.message_user(
|
|
1249
|
+
request,
|
|
1250
|
+
"Local node private key is unavailable; remote actions are disabled.",
|
|
1251
|
+
level=messages.ERROR,
|
|
1252
|
+
)
|
|
1253
|
+
return None, None
|
|
1254
|
+
return local, private_key
|
|
1255
|
+
|
|
1256
|
+
def _call_remote_action(
|
|
1257
|
+
self,
|
|
1258
|
+
request,
|
|
1259
|
+
local_node: Node,
|
|
1260
|
+
private_key,
|
|
1261
|
+
charger: Charger,
|
|
1262
|
+
action: str,
|
|
1263
|
+
extra: dict[str, Any] | None = None,
|
|
1264
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
1265
|
+
if not charger.node_origin:
|
|
1266
|
+
self.message_user(
|
|
1267
|
+
request,
|
|
1268
|
+
f"{charger}: remote node information is missing.",
|
|
1269
|
+
level=messages.ERROR,
|
|
1270
|
+
)
|
|
1271
|
+
return False, {}
|
|
1272
|
+
origin = charger.node_origin
|
|
1273
|
+
if not origin.port:
|
|
1274
|
+
self.message_user(
|
|
1275
|
+
request,
|
|
1276
|
+
f"{charger}: remote node port is not configured.",
|
|
1277
|
+
level=messages.ERROR,
|
|
1278
|
+
)
|
|
1279
|
+
return False, {}
|
|
1280
|
+
|
|
1281
|
+
if not origin.get_remote_host_candidates():
|
|
1282
|
+
self.message_user(
|
|
1283
|
+
request,
|
|
1284
|
+
f"{charger}: remote node connection details are incomplete.",
|
|
1285
|
+
level=messages.ERROR,
|
|
1286
|
+
)
|
|
1287
|
+
return False, {}
|
|
1288
|
+
|
|
1289
|
+
payload: dict[str, Any] = {
|
|
1290
|
+
"requester": str(local_node.uuid),
|
|
1291
|
+
"requester_mac": local_node.mac_address,
|
|
1292
|
+
"requester_public_key": local_node.public_key,
|
|
1293
|
+
"charger_id": charger.charger_id,
|
|
1294
|
+
"connector_id": charger.connector_id,
|
|
1295
|
+
"action": action,
|
|
1296
|
+
}
|
|
1297
|
+
if extra:
|
|
1298
|
+
payload.update(extra)
|
|
1299
|
+
|
|
1300
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1301
|
+
headers = {"Content-Type": "application/json"}
|
|
1302
|
+
try:
|
|
1303
|
+
signature = private_key.sign(
|
|
1304
|
+
payload_json.encode(),
|
|
1305
|
+
padding.PKCS1v15(),
|
|
1306
|
+
hashes.SHA256(),
|
|
1307
|
+
)
|
|
1308
|
+
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
1309
|
+
except Exception:
|
|
1310
|
+
self.message_user(
|
|
1311
|
+
request,
|
|
1312
|
+
"Unable to sign remote action payload; remote action aborted.",
|
|
1313
|
+
level=messages.ERROR,
|
|
1314
|
+
)
|
|
1315
|
+
return False, {}
|
|
1316
|
+
|
|
1317
|
+
url = next(
|
|
1318
|
+
origin.iter_remote_urls("/nodes/network/chargers/action/"),
|
|
1319
|
+
"",
|
|
1320
|
+
)
|
|
1321
|
+
if not url:
|
|
1322
|
+
self.message_user(
|
|
1323
|
+
request,
|
|
1324
|
+
f"{charger}: no reachable hosts were reported for the remote node.",
|
|
1325
|
+
level=messages.ERROR,
|
|
1326
|
+
)
|
|
1327
|
+
return False, {}
|
|
1328
|
+
try:
|
|
1329
|
+
response = requests.post(url, data=payload_json, headers=headers, timeout=5)
|
|
1330
|
+
except RequestException as exc:
|
|
1331
|
+
self.message_user(
|
|
1332
|
+
request,
|
|
1333
|
+
f"{charger}: failed to contact remote node ({exc}).",
|
|
1334
|
+
level=messages.ERROR,
|
|
1335
|
+
)
|
|
1336
|
+
return False, {}
|
|
1337
|
+
|
|
1338
|
+
try:
|
|
1339
|
+
data = response.json()
|
|
1340
|
+
except ValueError:
|
|
1341
|
+
self.message_user(
|
|
1342
|
+
request,
|
|
1343
|
+
f"{charger}: invalid response from remote node.",
|
|
1344
|
+
level=messages.ERROR,
|
|
1345
|
+
)
|
|
1346
|
+
return False, {}
|
|
1347
|
+
|
|
1348
|
+
if response.status_code != 200 or data.get("status") != "ok":
|
|
1349
|
+
detail = data.get("detail") if isinstance(data, dict) else None
|
|
1350
|
+
if not detail:
|
|
1351
|
+
detail = response.text or "Remote node rejected the request."
|
|
1352
|
+
self.message_user(
|
|
1353
|
+
request,
|
|
1354
|
+
f"{charger}: {detail}",
|
|
1355
|
+
level=messages.ERROR,
|
|
1356
|
+
)
|
|
1357
|
+
return False, {}
|
|
1358
|
+
|
|
1359
|
+
updates = data.get("updates", {}) if isinstance(data, dict) else {}
|
|
1360
|
+
if not isinstance(updates, dict):
|
|
1361
|
+
updates = {}
|
|
1362
|
+
return True, updates
|
|
1363
|
+
|
|
1364
|
+
def _apply_remote_updates(self, charger: Charger, updates: dict[str, Any]) -> None:
|
|
1365
|
+
if not updates:
|
|
1366
|
+
return
|
|
1367
|
+
|
|
1368
|
+
applied: dict[str, Any] = {}
|
|
1369
|
+
for field, value in updates.items():
|
|
1370
|
+
if field in self._REMOTE_DATETIME_FIELDS and isinstance(value, str):
|
|
1371
|
+
parsed = parse_datetime(value)
|
|
1372
|
+
if parsed and timezone.is_naive(parsed):
|
|
1373
|
+
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
1374
|
+
applied[field] = parsed
|
|
1375
|
+
else:
|
|
1376
|
+
applied[field] = value
|
|
1377
|
+
|
|
1378
|
+
Charger.objects.filter(pk=charger.pk).update(**applied)
|
|
1379
|
+
for field, value in applied.items():
|
|
1380
|
+
setattr(charger, field, value)
|
|
1381
|
+
|
|
1382
|
+
@admin.action(description="Get diagnostics")
|
|
1383
|
+
def get_diagnostics(self, request, queryset):
|
|
1384
|
+
diagnostics_dir, user_dir = self._diagnostics_directory_for(request.user)
|
|
1385
|
+
successes: list[tuple[Charger, str, Path]] = []
|
|
1386
|
+
for charger in queryset:
|
|
1387
|
+
location = (charger.diagnostics_location or "").strip()
|
|
1388
|
+
if not location:
|
|
1389
|
+
self.message_user(
|
|
1390
|
+
request,
|
|
1391
|
+
_("%(charger)s: no diagnostics location reported.")
|
|
1392
|
+
% {"charger": charger},
|
|
1393
|
+
level=messages.WARNING,
|
|
1394
|
+
)
|
|
1395
|
+
continue
|
|
1396
|
+
try:
|
|
1397
|
+
destination, asset_url = self._download_diagnostics(
|
|
1398
|
+
request,
|
|
1399
|
+
charger,
|
|
1400
|
+
location,
|
|
1401
|
+
diagnostics_dir,
|
|
1402
|
+
user_dir,
|
|
1403
|
+
)
|
|
1404
|
+
except self.DiagnosticsDownloadError as exc:
|
|
1405
|
+
self.message_user(
|
|
1406
|
+
request,
|
|
1407
|
+
_("%(charger)s: %(error)s")
|
|
1408
|
+
% {"charger": charger, "error": exc},
|
|
1409
|
+
level=messages.ERROR,
|
|
1410
|
+
)
|
|
1411
|
+
continue
|
|
1412
|
+
successes.append((charger, asset_url, destination))
|
|
1413
|
+
|
|
1414
|
+
if successes:
|
|
1415
|
+
summary = ngettext(
|
|
1416
|
+
"Retrieved diagnostics for %(count)d charger.",
|
|
1417
|
+
"Retrieved diagnostics for %(count)d chargers.",
|
|
1418
|
+
len(successes),
|
|
1419
|
+
) % {"count": len(successes)}
|
|
1420
|
+
details = format_html_join(
|
|
1421
|
+
"",
|
|
1422
|
+
"<li>{}: <a href=\"{}\" target=\"_blank\">{}</a> (<code>{}</code>)</li>",
|
|
1423
|
+
(
|
|
1424
|
+
(charger, url, destination.name, destination)
|
|
1425
|
+
for charger, url, destination in successes
|
|
1426
|
+
),
|
|
1427
|
+
)
|
|
1428
|
+
message = format_html("{}<ul>{}</ul>", summary, details)
|
|
1429
|
+
self.message_user(request, message, level=messages.SUCCESS)
|
|
1430
|
+
|
|
1431
|
+
def get_readonly_fields(self, request, obj=None):
|
|
1432
|
+
readonly = list(super().get_readonly_fields(request, obj))
|
|
1433
|
+
if obj and not obj.is_local:
|
|
1434
|
+
for field in ("allow_remote", "export_transactions"):
|
|
1435
|
+
if field not in readonly:
|
|
1436
|
+
readonly.append(field)
|
|
1437
|
+
return tuple(readonly)
|
|
1438
|
+
|
|
265
1439
|
def get_view_on_site_url(self, obj=None):
|
|
266
1440
|
return obj.get_absolute_url() if obj else None
|
|
267
1441
|
|
|
@@ -317,16 +1491,14 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
317
1491
|
"charger-status-connector",
|
|
318
1492
|
args=[obj.charger_id, obj.connector_slug],
|
|
319
1493
|
)
|
|
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)
|
|
1494
|
+
tx_obj = store.get_transaction(obj.charger_id, obj.connector_id)
|
|
1495
|
+
state, _ = _charger_state(
|
|
1496
|
+
obj,
|
|
1497
|
+
tx_obj
|
|
1498
|
+
if obj.connector_id is not None
|
|
1499
|
+
else (_live_sessions(obj) or None),
|
|
1500
|
+
)
|
|
1501
|
+
return format_html('<a href="{}" target="_blank">{}</a>', url, state)
|
|
330
1502
|
|
|
331
1503
|
status_link.short_description = "Status"
|
|
332
1504
|
|
|
@@ -347,6 +1519,40 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
347
1519
|
return True
|
|
348
1520
|
return False
|
|
349
1521
|
|
|
1522
|
+
@admin.display(description="Display Name", ordering="display_name")
|
|
1523
|
+
def display_name_with_fallback(self, obj):
|
|
1524
|
+
return self._charger_display_name(obj)
|
|
1525
|
+
|
|
1526
|
+
@admin.display(description="Charger", ordering="display_name")
|
|
1527
|
+
def charger_name_display(self, obj):
|
|
1528
|
+
return self._charger_display_name(obj)
|
|
1529
|
+
|
|
1530
|
+
def _charger_display_name(self, obj):
|
|
1531
|
+
if obj.display_name:
|
|
1532
|
+
return obj.display_name
|
|
1533
|
+
if obj.location:
|
|
1534
|
+
return obj.location.name
|
|
1535
|
+
return obj.charger_id
|
|
1536
|
+
|
|
1537
|
+
def _build_local_authorization_list(self) -> list[dict[str, object]]:
|
|
1538
|
+
"""Return the payload for SendLocalList with released and allowed RFIDs."""
|
|
1539
|
+
|
|
1540
|
+
entries: list[dict[str, object]] = []
|
|
1541
|
+
queryset = (
|
|
1542
|
+
CoreRFID.objects.filter(released=True, allowed=True)
|
|
1543
|
+
.order_by("rfid")
|
|
1544
|
+
.only("rfid")
|
|
1545
|
+
)
|
|
1546
|
+
for tag in queryset.iterator():
|
|
1547
|
+
entry: dict[str, object] = {"idTag": tag.rfid}
|
|
1548
|
+
entry["idTagInfo"] = {"status": "Accepted"}
|
|
1549
|
+
entries.append(entry)
|
|
1550
|
+
return entries
|
|
1551
|
+
|
|
1552
|
+
@admin.display(boolean=True, description="Local")
|
|
1553
|
+
def local_indicator(self, obj):
|
|
1554
|
+
return obj.is_local
|
|
1555
|
+
|
|
350
1556
|
def location_name(self, obj):
|
|
351
1557
|
return obj.location.name if obj.location else ""
|
|
352
1558
|
|
|
@@ -359,9 +1565,9 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
359
1565
|
|
|
360
1566
|
purge_data.short_description = "Purge data"
|
|
361
1567
|
|
|
362
|
-
@admin.action(description="
|
|
363
|
-
def
|
|
364
|
-
|
|
1568
|
+
@admin.action(description="Re-check Charger Status")
|
|
1569
|
+
def recheck_charger_status(self, request, queryset):
|
|
1570
|
+
requested = 0
|
|
365
1571
|
for charger in queryset:
|
|
366
1572
|
connector_value = charger.connector_id
|
|
367
1573
|
ws = store.get_connection(charger.charger_id, connector_value)
|
|
@@ -372,15 +1578,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
372
1578
|
level=messages.ERROR,
|
|
373
1579
|
)
|
|
374
1580
|
continue
|
|
1581
|
+
payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
|
|
1582
|
+
trigger_connector: int | None = None
|
|
1583
|
+
if connector_value is not None:
|
|
1584
|
+
payload["connectorId"] = connector_value
|
|
1585
|
+
trigger_connector = connector_value
|
|
375
1586
|
message_id = uuid.uuid4().hex
|
|
376
|
-
|
|
377
|
-
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
1587
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
378
1588
|
try:
|
|
379
1589
|
async_to_sync(ws.send)(msg)
|
|
380
1590
|
except Exception as exc: # pragma: no cover - network error
|
|
381
1591
|
self.message_user(
|
|
382
1592
|
request,
|
|
383
|
-
f"{charger}: failed to send
|
|
1593
|
+
f"{charger}: failed to send TriggerMessage ({exc})",
|
|
384
1594
|
level=messages.ERROR,
|
|
385
1595
|
)
|
|
386
1596
|
continue
|
|
@@ -389,79 +1599,431 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
389
1599
|
store.register_pending_call(
|
|
390
1600
|
message_id,
|
|
391
1601
|
{
|
|
392
|
-
"action": "
|
|
1602
|
+
"action": "TriggerMessage",
|
|
393
1603
|
"charger_id": charger.charger_id,
|
|
394
1604
|
"connector_id": connector_value,
|
|
395
1605
|
"log_key": log_key,
|
|
1606
|
+
"trigger_target": "StatusNotification",
|
|
1607
|
+
"trigger_connector": trigger_connector,
|
|
396
1608
|
"requested_at": timezone.now(),
|
|
397
1609
|
},
|
|
398
1610
|
)
|
|
399
1611
|
store.schedule_call_timeout(
|
|
400
1612
|
message_id,
|
|
401
1613
|
timeout=5.0,
|
|
402
|
-
action="
|
|
1614
|
+
action="TriggerMessage",
|
|
403
1615
|
log_key=log_key,
|
|
404
|
-
message=
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1616
|
+
message="TriggerMessage StatusNotification timed out",
|
|
1617
|
+
)
|
|
1618
|
+
requested += 1
|
|
1619
|
+
if requested:
|
|
1620
|
+
self.message_user(
|
|
1621
|
+
request,
|
|
1622
|
+
f"Requested status update from {requested} charger(s)",
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
@admin.action(description="Fetch CP configuration")
|
|
1626
|
+
def fetch_cp_configuration(self, request, queryset):
|
|
1627
|
+
fetched = 0
|
|
1628
|
+
local_node = None
|
|
1629
|
+
private_key = None
|
|
1630
|
+
remote_unavailable = False
|
|
1631
|
+
for charger in queryset:
|
|
1632
|
+
if charger.is_local:
|
|
1633
|
+
connector_value = charger.connector_id
|
|
1634
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1635
|
+
if ws is None:
|
|
1636
|
+
self.message_user(
|
|
1637
|
+
request,
|
|
1638
|
+
f"{charger}: no active connection",
|
|
1639
|
+
level=messages.ERROR,
|
|
1640
|
+
)
|
|
1641
|
+
continue
|
|
1642
|
+
message_id = uuid.uuid4().hex
|
|
1643
|
+
payload = {}
|
|
1644
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
1645
|
+
try:
|
|
1646
|
+
async_to_sync(ws.send)(msg)
|
|
1647
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1648
|
+
self.message_user(
|
|
1649
|
+
request,
|
|
1650
|
+
f"{charger}: failed to send GetConfiguration ({exc})",
|
|
1651
|
+
level=messages.ERROR,
|
|
1652
|
+
)
|
|
1653
|
+
continue
|
|
1654
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1655
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1656
|
+
store.register_pending_call(
|
|
1657
|
+
message_id,
|
|
1658
|
+
{
|
|
1659
|
+
"action": "GetConfiguration",
|
|
1660
|
+
"charger_id": charger.charger_id,
|
|
1661
|
+
"connector_id": connector_value,
|
|
1662
|
+
"log_key": log_key,
|
|
1663
|
+
"requested_at": timezone.now(),
|
|
1664
|
+
},
|
|
1665
|
+
)
|
|
1666
|
+
store.schedule_call_timeout(
|
|
1667
|
+
message_id,
|
|
1668
|
+
timeout=5.0,
|
|
1669
|
+
action="GetConfiguration",
|
|
1670
|
+
log_key=log_key,
|
|
1671
|
+
message=(
|
|
1672
|
+
"GetConfiguration timed out: charger did not respond"
|
|
1673
|
+
" (operation may not be supported)"
|
|
1674
|
+
),
|
|
1675
|
+
)
|
|
1676
|
+
fetched += 1
|
|
1677
|
+
continue
|
|
1678
|
+
|
|
1679
|
+
if not charger.allow_remote:
|
|
1680
|
+
self.message_user(
|
|
1681
|
+
request,
|
|
1682
|
+
f"{charger}: remote administration is disabled.",
|
|
1683
|
+
level=messages.ERROR,
|
|
1684
|
+
)
|
|
1685
|
+
continue
|
|
1686
|
+
if remote_unavailable:
|
|
1687
|
+
continue
|
|
1688
|
+
if local_node is None:
|
|
1689
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1690
|
+
if not local_node or not private_key:
|
|
1691
|
+
remote_unavailable = True
|
|
1692
|
+
continue
|
|
1693
|
+
success, updates = self._call_remote_action(
|
|
1694
|
+
request,
|
|
1695
|
+
local_node,
|
|
1696
|
+
private_key,
|
|
1697
|
+
charger,
|
|
1698
|
+
"get-configuration",
|
|
408
1699
|
)
|
|
409
|
-
|
|
1700
|
+
if success:
|
|
1701
|
+
self._apply_remote_updates(charger, updates)
|
|
1702
|
+
fetched += 1
|
|
1703
|
+
|
|
410
1704
|
if fetched:
|
|
411
1705
|
self.message_user(
|
|
412
1706
|
request,
|
|
413
1707
|
f"Requested configuration from {fetched} charger(s)",
|
|
414
1708
|
)
|
|
415
1709
|
|
|
416
|
-
|
|
1710
|
+
@admin.action(description="Toggle RFID Authentication")
|
|
1711
|
+
def toggle_rfid_authentication(self, request, queryset):
|
|
1712
|
+
enabled = 0
|
|
1713
|
+
disabled = 0
|
|
1714
|
+
local_node = None
|
|
1715
|
+
private_key = None
|
|
1716
|
+
remote_unavailable = False
|
|
1717
|
+
for charger in queryset:
|
|
1718
|
+
new_value = not charger.require_rfid
|
|
1719
|
+
if charger.is_local:
|
|
1720
|
+
Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
|
|
1721
|
+
charger.require_rfid = new_value
|
|
1722
|
+
if new_value:
|
|
1723
|
+
enabled += 1
|
|
1724
|
+
else:
|
|
1725
|
+
disabled += 1
|
|
1726
|
+
continue
|
|
1727
|
+
|
|
1728
|
+
if not charger.allow_remote:
|
|
1729
|
+
self.message_user(
|
|
1730
|
+
request,
|
|
1731
|
+
f"{charger}: remote administration is disabled.",
|
|
1732
|
+
level=messages.ERROR,
|
|
1733
|
+
)
|
|
1734
|
+
continue
|
|
1735
|
+
if remote_unavailable:
|
|
1736
|
+
continue
|
|
1737
|
+
if local_node is None:
|
|
1738
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1739
|
+
if not local_node or not private_key:
|
|
1740
|
+
remote_unavailable = True
|
|
1741
|
+
continue
|
|
1742
|
+
success, updates = self._call_remote_action(
|
|
1743
|
+
request,
|
|
1744
|
+
local_node,
|
|
1745
|
+
private_key,
|
|
1746
|
+
charger,
|
|
1747
|
+
"toggle-rfid",
|
|
1748
|
+
{"enable": new_value},
|
|
1749
|
+
)
|
|
1750
|
+
if success:
|
|
1751
|
+
self._apply_remote_updates(charger, updates)
|
|
1752
|
+
if charger.require_rfid:
|
|
1753
|
+
enabled += 1
|
|
1754
|
+
else:
|
|
1755
|
+
disabled += 1
|
|
1756
|
+
|
|
1757
|
+
if enabled or disabled:
|
|
1758
|
+
changes = []
|
|
1759
|
+
if enabled:
|
|
1760
|
+
changes.append(f"enabled for {enabled} charger(s)")
|
|
1761
|
+
if disabled:
|
|
1762
|
+
changes.append(f"disabled for {disabled} charger(s)")
|
|
1763
|
+
summary = "; ".join(changes)
|
|
1764
|
+
self.message_user(
|
|
1765
|
+
request,
|
|
1766
|
+
f"Updated RFID authentication: {summary}",
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
@admin.action(description="Send RFID list to EVCS")
|
|
1770
|
+
def send_rfid_list_to_evcs(self, request, queryset):
|
|
1771
|
+
authorization_list = self._build_local_authorization_list()
|
|
1772
|
+
update_type = "Full"
|
|
417
1773
|
sent = 0
|
|
1774
|
+
local_node = None
|
|
1775
|
+
private_key = None
|
|
1776
|
+
remote_unavailable = False
|
|
418
1777
|
for charger in queryset:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1778
|
+
list_version = (charger.local_auth_list_version or 0) + 1
|
|
1779
|
+
if charger.is_local:
|
|
1780
|
+
connector_value = charger.connector_id
|
|
1781
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1782
|
+
if ws is None:
|
|
1783
|
+
self.message_user(
|
|
1784
|
+
request,
|
|
1785
|
+
f"{charger}: no active connection",
|
|
1786
|
+
level=messages.ERROR,
|
|
1787
|
+
)
|
|
1788
|
+
continue
|
|
1789
|
+
message_id = uuid.uuid4().hex
|
|
1790
|
+
payload = {
|
|
1791
|
+
"listVersion": list_version,
|
|
1792
|
+
"updateType": update_type,
|
|
1793
|
+
"localAuthorizationList": authorization_list,
|
|
1794
|
+
}
|
|
1795
|
+
msg = json.dumps([2, message_id, "SendLocalList", payload])
|
|
1796
|
+
try:
|
|
1797
|
+
async_to_sync(ws.send)(msg)
|
|
1798
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1799
|
+
self.message_user(
|
|
1800
|
+
request,
|
|
1801
|
+
f"{charger}: failed to send SendLocalList ({exc})",
|
|
1802
|
+
level=messages.ERROR,
|
|
1803
|
+
)
|
|
1804
|
+
continue
|
|
1805
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1806
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1807
|
+
store.register_pending_call(
|
|
1808
|
+
message_id,
|
|
1809
|
+
{
|
|
1810
|
+
"action": "SendLocalList",
|
|
1811
|
+
"charger_id": charger.charger_id,
|
|
1812
|
+
"connector_id": connector_value,
|
|
1813
|
+
"log_key": log_key,
|
|
1814
|
+
"list_version": list_version,
|
|
1815
|
+
"list_size": len(authorization_list),
|
|
1816
|
+
"requested_at": timezone.now(),
|
|
1817
|
+
},
|
|
1818
|
+
)
|
|
1819
|
+
store.schedule_call_timeout(
|
|
1820
|
+
message_id,
|
|
1821
|
+
action="SendLocalList",
|
|
1822
|
+
log_key=log_key,
|
|
1823
|
+
message="SendLocalList request timed out",
|
|
1824
|
+
)
|
|
1825
|
+
sent += 1
|
|
1826
|
+
continue
|
|
1827
|
+
|
|
1828
|
+
if not charger.allow_remote:
|
|
422
1829
|
self.message_user(
|
|
423
1830
|
request,
|
|
424
|
-
f"{charger}:
|
|
1831
|
+
f"{charger}: remote administration is disabled.",
|
|
425
1832
|
level=messages.ERROR,
|
|
426
1833
|
)
|
|
427
1834
|
continue
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
1835
|
+
if remote_unavailable:
|
|
1836
|
+
continue
|
|
1837
|
+
if local_node is None:
|
|
1838
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1839
|
+
if not local_node or not private_key:
|
|
1840
|
+
remote_unavailable = True
|
|
1841
|
+
continue
|
|
1842
|
+
extra = {
|
|
1843
|
+
"local_authorization_list": [entry.copy() for entry in authorization_list],
|
|
1844
|
+
"list_version": list_version,
|
|
1845
|
+
"update_type": update_type,
|
|
1846
|
+
}
|
|
1847
|
+
success, updates = self._call_remote_action(
|
|
1848
|
+
request,
|
|
1849
|
+
local_node,
|
|
1850
|
+
private_key,
|
|
1851
|
+
charger,
|
|
1852
|
+
"send-local-rfid-list",
|
|
1853
|
+
extra,
|
|
1854
|
+
)
|
|
1855
|
+
if success:
|
|
1856
|
+
self._apply_remote_updates(charger, updates)
|
|
1857
|
+
sent += 1
|
|
1858
|
+
|
|
1859
|
+
if sent:
|
|
1860
|
+
self.message_user(
|
|
1861
|
+
request,
|
|
1862
|
+
f"Sent SendLocalList to {sent} charger(s)",
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
@admin.action(description="Update RFIDs from EVCS")
|
|
1866
|
+
def update_rfids_from_evcs(self, request, queryset):
|
|
1867
|
+
requested = 0
|
|
1868
|
+
local_node = None
|
|
1869
|
+
private_key = None
|
|
1870
|
+
remote_unavailable = False
|
|
1871
|
+
for charger in queryset:
|
|
1872
|
+
if charger.is_local:
|
|
1873
|
+
connector_value = charger.connector_id
|
|
1874
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1875
|
+
if ws is None:
|
|
1876
|
+
self.message_user(
|
|
1877
|
+
request,
|
|
1878
|
+
f"{charger}: no active connection",
|
|
1879
|
+
level=messages.ERROR,
|
|
1880
|
+
)
|
|
1881
|
+
continue
|
|
1882
|
+
message_id = uuid.uuid4().hex
|
|
1883
|
+
payload: dict[str, object] = {}
|
|
1884
|
+
msg = json.dumps([2, message_id, "GetLocalListVersion", payload])
|
|
1885
|
+
try:
|
|
1886
|
+
async_to_sync(ws.send)(msg)
|
|
1887
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1888
|
+
self.message_user(
|
|
1889
|
+
request,
|
|
1890
|
+
f"{charger}: failed to send GetLocalListVersion ({exc})",
|
|
1891
|
+
level=messages.ERROR,
|
|
1892
|
+
)
|
|
1893
|
+
continue
|
|
1894
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1895
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1896
|
+
store.register_pending_call(
|
|
1897
|
+
message_id,
|
|
1898
|
+
{
|
|
1899
|
+
"action": "GetLocalListVersion",
|
|
1900
|
+
"charger_id": charger.charger_id,
|
|
1901
|
+
"connector_id": connector_value,
|
|
1902
|
+
"log_key": log_key,
|
|
1903
|
+
"requested_at": timezone.now(),
|
|
1904
|
+
},
|
|
1905
|
+
)
|
|
1906
|
+
store.schedule_call_timeout(
|
|
1907
|
+
message_id,
|
|
1908
|
+
action="GetLocalListVersion",
|
|
1909
|
+
log_key=log_key,
|
|
1910
|
+
message="GetLocalListVersion request timed out",
|
|
1911
|
+
)
|
|
1912
|
+
requested += 1
|
|
1913
|
+
continue
|
|
1914
|
+
|
|
1915
|
+
if not charger.allow_remote:
|
|
435
1916
|
self.message_user(
|
|
436
1917
|
request,
|
|
437
|
-
f"{charger}:
|
|
1918
|
+
f"{charger}: remote administration is disabled.",
|
|
438
1919
|
level=messages.ERROR,
|
|
439
1920
|
)
|
|
440
1921
|
continue
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1922
|
+
if remote_unavailable:
|
|
1923
|
+
continue
|
|
1924
|
+
if local_node is None:
|
|
1925
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
1926
|
+
if not local_node or not private_key:
|
|
1927
|
+
remote_unavailable = True
|
|
1928
|
+
continue
|
|
1929
|
+
success, updates = self._call_remote_action(
|
|
1930
|
+
request,
|
|
1931
|
+
local_node,
|
|
1932
|
+
private_key,
|
|
1933
|
+
charger,
|
|
1934
|
+
"get-local-list-version",
|
|
453
1935
|
)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
1936
|
+
if success:
|
|
1937
|
+
self._apply_remote_updates(charger, updates)
|
|
1938
|
+
requested += 1
|
|
1939
|
+
|
|
1940
|
+
if requested:
|
|
1941
|
+
self.message_user(
|
|
1942
|
+
request,
|
|
1943
|
+
f"Requested GetLocalListVersion from {requested} charger(s)",
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
def _dispatch_change_availability(self, request, queryset, availability_type: str):
|
|
1947
|
+
sent = 0
|
|
1948
|
+
local_node = None
|
|
1949
|
+
private_key = None
|
|
1950
|
+
remote_unavailable = False
|
|
1951
|
+
for charger in queryset:
|
|
1952
|
+
if charger.is_local:
|
|
1953
|
+
connector_value = charger.connector_id
|
|
1954
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1955
|
+
if ws is None:
|
|
1956
|
+
self.message_user(
|
|
1957
|
+
request,
|
|
1958
|
+
f"{charger}: no active connection",
|
|
1959
|
+
level=messages.ERROR,
|
|
1960
|
+
)
|
|
1961
|
+
continue
|
|
1962
|
+
connector_id = connector_value if connector_value is not None else 0
|
|
1963
|
+
message_id = uuid.uuid4().hex
|
|
1964
|
+
payload = {"connectorId": connector_id, "type": availability_type}
|
|
1965
|
+
msg = json.dumps([2, message_id, "ChangeAvailability", payload])
|
|
1966
|
+
try:
|
|
1967
|
+
async_to_sync(ws.send)(msg)
|
|
1968
|
+
except Exception as exc: # pragma: no cover - network error
|
|
1969
|
+
self.message_user(
|
|
1970
|
+
request,
|
|
1971
|
+
f"{charger}: failed to send ChangeAvailability ({exc})",
|
|
1972
|
+
level=messages.ERROR,
|
|
1973
|
+
)
|
|
1974
|
+
continue
|
|
1975
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1976
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1977
|
+
timestamp = timezone.now()
|
|
1978
|
+
store.register_pending_call(
|
|
1979
|
+
message_id,
|
|
1980
|
+
{
|
|
1981
|
+
"action": "ChangeAvailability",
|
|
1982
|
+
"charger_id": charger.charger_id,
|
|
1983
|
+
"connector_id": connector_value,
|
|
1984
|
+
"availability_type": availability_type,
|
|
1985
|
+
"requested_at": timestamp,
|
|
1986
|
+
},
|
|
1987
|
+
)
|
|
1988
|
+
updates = {
|
|
1989
|
+
"availability_requested_state": availability_type,
|
|
1990
|
+
"availability_requested_at": timestamp,
|
|
1991
|
+
"availability_request_status": "",
|
|
1992
|
+
"availability_request_status_at": None,
|
|
1993
|
+
"availability_request_details": "",
|
|
1994
|
+
}
|
|
1995
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1996
|
+
for field, value in updates.items():
|
|
1997
|
+
setattr(charger, field, value)
|
|
1998
|
+
sent += 1
|
|
1999
|
+
continue
|
|
2000
|
+
|
|
2001
|
+
if not charger.allow_remote:
|
|
2002
|
+
self.message_user(
|
|
2003
|
+
request,
|
|
2004
|
+
f"{charger}: remote administration is disabled.",
|
|
2005
|
+
level=messages.ERROR,
|
|
2006
|
+
)
|
|
2007
|
+
continue
|
|
2008
|
+
if remote_unavailable:
|
|
2009
|
+
continue
|
|
2010
|
+
if local_node is None:
|
|
2011
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
2012
|
+
if not local_node or not private_key:
|
|
2013
|
+
remote_unavailable = True
|
|
2014
|
+
continue
|
|
2015
|
+
success, updates = self._call_remote_action(
|
|
2016
|
+
request,
|
|
2017
|
+
local_node,
|
|
2018
|
+
private_key,
|
|
2019
|
+
charger,
|
|
2020
|
+
"change-availability",
|
|
2021
|
+
{"availability_type": availability_type},
|
|
2022
|
+
)
|
|
2023
|
+
if success:
|
|
2024
|
+
self._apply_remote_updates(charger, updates)
|
|
2025
|
+
sent += 1
|
|
2026
|
+
|
|
465
2027
|
if sent:
|
|
466
2028
|
self.message_user(
|
|
467
2029
|
request,
|
|
@@ -479,17 +2041,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
479
2041
|
def _set_availability_state(
|
|
480
2042
|
self, request, queryset, availability_state: str
|
|
481
2043
|
) -> None:
|
|
482
|
-
timestamp = timezone.now()
|
|
483
2044
|
updated = 0
|
|
2045
|
+
local_node = None
|
|
2046
|
+
private_key = None
|
|
2047
|
+
remote_unavailable = False
|
|
484
2048
|
for charger in queryset:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
2049
|
+
if charger.is_local:
|
|
2050
|
+
timestamp = timezone.now()
|
|
2051
|
+
updates = {
|
|
2052
|
+
"availability_state": availability_state,
|
|
2053
|
+
"availability_state_updated_at": timestamp,
|
|
2054
|
+
}
|
|
2055
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
2056
|
+
for field, value in updates.items():
|
|
2057
|
+
setattr(charger, field, value)
|
|
2058
|
+
updated += 1
|
|
2059
|
+
continue
|
|
2060
|
+
|
|
2061
|
+
if not charger.allow_remote:
|
|
2062
|
+
self.message_user(
|
|
2063
|
+
request,
|
|
2064
|
+
f"{charger}: remote administration is disabled.",
|
|
2065
|
+
level=messages.ERROR,
|
|
2066
|
+
)
|
|
2067
|
+
continue
|
|
2068
|
+
if remote_unavailable:
|
|
2069
|
+
continue
|
|
2070
|
+
if local_node is None:
|
|
2071
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
2072
|
+
if not local_node or not private_key:
|
|
2073
|
+
remote_unavailable = True
|
|
2074
|
+
continue
|
|
2075
|
+
success, updates = self._call_remote_action(
|
|
2076
|
+
request,
|
|
2077
|
+
local_node,
|
|
2078
|
+
private_key,
|
|
2079
|
+
charger,
|
|
2080
|
+
"set-availability-state",
|
|
2081
|
+
{"availability_state": availability_state},
|
|
2082
|
+
)
|
|
2083
|
+
if success:
|
|
2084
|
+
self._apply_remote_updates(charger, updates)
|
|
2085
|
+
updated += 1
|
|
2086
|
+
|
|
493
2087
|
if updated:
|
|
494
2088
|
self.message_user(
|
|
495
2089
|
request,
|
|
@@ -504,58 +2098,168 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
504
2098
|
def set_availability_state_inoperative(self, request, queryset):
|
|
505
2099
|
self._set_availability_state(request, queryset, "Inoperative")
|
|
506
2100
|
|
|
507
|
-
@admin.action(description="
|
|
508
|
-
def
|
|
509
|
-
|
|
2101
|
+
@admin.action(description="Clear charger authorization cache")
|
|
2102
|
+
def clear_authorization_cache(self, request, queryset):
|
|
2103
|
+
cleared = 0
|
|
2104
|
+
local_node = None
|
|
2105
|
+
private_key = None
|
|
2106
|
+
remote_unavailable = False
|
|
510
2107
|
for charger in queryset:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
2108
|
+
if charger.is_local:
|
|
2109
|
+
connector_value = charger.connector_id
|
|
2110
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
2111
|
+
if ws is None:
|
|
2112
|
+
self.message_user(
|
|
2113
|
+
request,
|
|
2114
|
+
f"{charger}: no active connection",
|
|
2115
|
+
level=messages.ERROR,
|
|
2116
|
+
)
|
|
2117
|
+
continue
|
|
2118
|
+
message_id = uuid.uuid4().hex
|
|
2119
|
+
msg = json.dumps([2, message_id, "ClearCache", {}])
|
|
2120
|
+
try:
|
|
2121
|
+
async_to_sync(ws.send)(msg)
|
|
2122
|
+
except Exception as exc: # pragma: no cover - network error
|
|
2123
|
+
self.message_user(
|
|
2124
|
+
request,
|
|
2125
|
+
f"{charger}: failed to send ClearCache ({exc})",
|
|
2126
|
+
level=messages.ERROR,
|
|
2127
|
+
)
|
|
2128
|
+
continue
|
|
2129
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
2130
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
2131
|
+
requested_at = timezone.now()
|
|
2132
|
+
store.register_pending_call(
|
|
2133
|
+
message_id,
|
|
2134
|
+
{
|
|
2135
|
+
"action": "ClearCache",
|
|
2136
|
+
"charger_id": charger.charger_id,
|
|
2137
|
+
"connector_id": connector_value,
|
|
2138
|
+
"log_key": log_key,
|
|
2139
|
+
"requested_at": requested_at,
|
|
2140
|
+
},
|
|
2141
|
+
)
|
|
2142
|
+
store.schedule_call_timeout(
|
|
2143
|
+
message_id,
|
|
2144
|
+
action="ClearCache",
|
|
2145
|
+
log_key=log_key,
|
|
518
2146
|
)
|
|
2147
|
+
cleared += 1
|
|
519
2148
|
continue
|
|
520
|
-
|
|
521
|
-
if
|
|
2149
|
+
|
|
2150
|
+
if not charger.allow_remote:
|
|
522
2151
|
self.message_user(
|
|
523
2152
|
request,
|
|
524
|
-
f"{charger}:
|
|
2153
|
+
f"{charger}: remote administration is disabled.",
|
|
525
2154
|
level=messages.ERROR,
|
|
526
2155
|
)
|
|
527
2156
|
continue
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
2157
|
+
if remote_unavailable:
|
|
2158
|
+
continue
|
|
2159
|
+
if local_node is None:
|
|
2160
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
2161
|
+
if not local_node or not private_key:
|
|
2162
|
+
remote_unavailable = True
|
|
2163
|
+
continue
|
|
2164
|
+
success, _updates = self._call_remote_action(
|
|
2165
|
+
request,
|
|
2166
|
+
local_node,
|
|
2167
|
+
private_key,
|
|
2168
|
+
charger,
|
|
2169
|
+
"clear-cache",
|
|
2170
|
+
)
|
|
2171
|
+
if success:
|
|
2172
|
+
cleared += 1
|
|
2173
|
+
|
|
2174
|
+
if cleared:
|
|
2175
|
+
self.message_user(
|
|
2176
|
+
request,
|
|
2177
|
+
f"Sent ClearCache to {cleared} charger(s)",
|
|
2178
|
+
)
|
|
2179
|
+
|
|
2180
|
+
@admin.action(description="Remote stop active transaction")
|
|
2181
|
+
def remote_stop_transaction(self, request, queryset):
|
|
2182
|
+
stopped = 0
|
|
2183
|
+
local_node = None
|
|
2184
|
+
private_key = None
|
|
2185
|
+
remote_unavailable = False
|
|
2186
|
+
for charger in queryset:
|
|
2187
|
+
if charger.is_local:
|
|
2188
|
+
connector_value = charger.connector_id
|
|
2189
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
2190
|
+
if ws is None:
|
|
2191
|
+
self.message_user(
|
|
2192
|
+
request,
|
|
2193
|
+
f"{charger}: no active connection",
|
|
2194
|
+
level=messages.ERROR,
|
|
2195
|
+
)
|
|
2196
|
+
continue
|
|
2197
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
2198
|
+
if tx_obj is None:
|
|
2199
|
+
self.message_user(
|
|
2200
|
+
request,
|
|
2201
|
+
f"{charger}: no active transaction",
|
|
2202
|
+
level=messages.ERROR,
|
|
2203
|
+
)
|
|
2204
|
+
continue
|
|
2205
|
+
message_id = uuid.uuid4().hex
|
|
2206
|
+
payload = {"transactionId": tx_obj.pk}
|
|
2207
|
+
msg = json.dumps([
|
|
2208
|
+
2,
|
|
2209
|
+
message_id,
|
|
2210
|
+
"RemoteStopTransaction",
|
|
2211
|
+
payload,
|
|
2212
|
+
])
|
|
2213
|
+
try:
|
|
2214
|
+
async_to_sync(ws.send)(msg)
|
|
2215
|
+
except Exception as exc: # pragma: no cover - network error
|
|
2216
|
+
self.message_user(
|
|
2217
|
+
request,
|
|
2218
|
+
f"{charger}: failed to send RemoteStopTransaction ({exc})",
|
|
2219
|
+
level=messages.ERROR,
|
|
2220
|
+
)
|
|
2221
|
+
continue
|
|
2222
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
2223
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
2224
|
+
store.register_pending_call(
|
|
2225
|
+
message_id,
|
|
2226
|
+
{
|
|
2227
|
+
"action": "RemoteStopTransaction",
|
|
2228
|
+
"charger_id": charger.charger_id,
|
|
2229
|
+
"connector_id": connector_value,
|
|
2230
|
+
"transaction_id": tx_obj.pk,
|
|
2231
|
+
"log_key": log_key,
|
|
2232
|
+
"requested_at": timezone.now(),
|
|
2233
|
+
},
|
|
2234
|
+
)
|
|
2235
|
+
stopped += 1
|
|
2236
|
+
continue
|
|
2237
|
+
|
|
2238
|
+
if not charger.allow_remote:
|
|
539
2239
|
self.message_user(
|
|
540
2240
|
request,
|
|
541
|
-
f"{charger}:
|
|
2241
|
+
f"{charger}: remote administration is disabled.",
|
|
542
2242
|
level=messages.ERROR,
|
|
543
2243
|
)
|
|
544
2244
|
continue
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
2245
|
+
if remote_unavailable:
|
|
2246
|
+
continue
|
|
2247
|
+
if local_node is None:
|
|
2248
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
2249
|
+
if not local_node or not private_key:
|
|
2250
|
+
remote_unavailable = True
|
|
2251
|
+
continue
|
|
2252
|
+
success, updates = self._call_remote_action(
|
|
2253
|
+
request,
|
|
2254
|
+
local_node,
|
|
2255
|
+
private_key,
|
|
2256
|
+
charger,
|
|
2257
|
+
"remote-stop",
|
|
557
2258
|
)
|
|
558
|
-
|
|
2259
|
+
if success:
|
|
2260
|
+
self._apply_remote_updates(charger, updates)
|
|
2261
|
+
stopped += 1
|
|
2262
|
+
|
|
559
2263
|
if stopped:
|
|
560
2264
|
self.message_user(
|
|
561
2265
|
request,
|
|
@@ -565,45 +2269,95 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
565
2269
|
@admin.action(description="Reset charger (soft)")
|
|
566
2270
|
def reset_chargers(self, request, queryset):
|
|
567
2271
|
reset = 0
|
|
2272
|
+
local_node = None
|
|
2273
|
+
private_key = None
|
|
2274
|
+
remote_unavailable = False
|
|
568
2275
|
for charger in queryset:
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
2276
|
+
if charger.is_local:
|
|
2277
|
+
connector_value = charger.connector_id
|
|
2278
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
2279
|
+
if ws is None:
|
|
2280
|
+
self.message_user(
|
|
2281
|
+
request,
|
|
2282
|
+
f"{charger}: no active connection",
|
|
2283
|
+
level=messages.ERROR,
|
|
2284
|
+
)
|
|
2285
|
+
continue
|
|
2286
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
2287
|
+
if tx_obj is not None:
|
|
2288
|
+
self.message_user(
|
|
2289
|
+
request,
|
|
2290
|
+
(
|
|
2291
|
+
f"{charger}: reset skipped because a session is active; "
|
|
2292
|
+
"stop the session first."
|
|
2293
|
+
),
|
|
2294
|
+
level=messages.WARNING,
|
|
2295
|
+
)
|
|
2296
|
+
continue
|
|
2297
|
+
message_id = uuid.uuid4().hex
|
|
2298
|
+
msg = json.dumps([
|
|
2299
|
+
2,
|
|
2300
|
+
message_id,
|
|
2301
|
+
"Reset",
|
|
2302
|
+
{"type": "Soft"},
|
|
2303
|
+
])
|
|
2304
|
+
try:
|
|
2305
|
+
async_to_sync(ws.send)(msg)
|
|
2306
|
+
except Exception as exc: # pragma: no cover - network error
|
|
2307
|
+
self.message_user(
|
|
2308
|
+
request,
|
|
2309
|
+
f"{charger}: failed to send Reset ({exc})",
|
|
2310
|
+
level=messages.ERROR,
|
|
2311
|
+
)
|
|
2312
|
+
continue
|
|
2313
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
2314
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
2315
|
+
store.register_pending_call(
|
|
2316
|
+
message_id,
|
|
2317
|
+
{
|
|
2318
|
+
"action": "Reset",
|
|
2319
|
+
"charger_id": charger.charger_id,
|
|
2320
|
+
"connector_id": connector_value,
|
|
2321
|
+
"log_key": log_key,
|
|
2322
|
+
"requested_at": timezone.now(),
|
|
2323
|
+
},
|
|
2324
|
+
)
|
|
2325
|
+
store.schedule_call_timeout(
|
|
2326
|
+
message_id,
|
|
2327
|
+
timeout=5.0,
|
|
2328
|
+
action="Reset",
|
|
2329
|
+
log_key=log_key,
|
|
2330
|
+
message="Reset timed out: charger did not respond",
|
|
576
2331
|
)
|
|
2332
|
+
reset += 1
|
|
577
2333
|
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
|
|
2334
|
+
|
|
2335
|
+
if not charger.allow_remote:
|
|
588
2336
|
self.message_user(
|
|
589
2337
|
request,
|
|
590
|
-
f"{charger}:
|
|
2338
|
+
f"{charger}: remote administration is disabled.",
|
|
591
2339
|
level=messages.ERROR,
|
|
592
2340
|
)
|
|
593
2341
|
continue
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
2342
|
+
if remote_unavailable:
|
|
2343
|
+
continue
|
|
2344
|
+
if local_node is None:
|
|
2345
|
+
local_node, private_key = self._prepare_remote_credentials(request)
|
|
2346
|
+
if not local_node or not private_key:
|
|
2347
|
+
remote_unavailable = True
|
|
2348
|
+
continue
|
|
2349
|
+
success, updates = self._call_remote_action(
|
|
2350
|
+
request,
|
|
2351
|
+
local_node,
|
|
2352
|
+
private_key,
|
|
2353
|
+
charger,
|
|
2354
|
+
"reset",
|
|
2355
|
+
{"reset_type": "Soft"},
|
|
605
2356
|
)
|
|
606
|
-
|
|
2357
|
+
if success:
|
|
2358
|
+
self._apply_remote_updates(charger, updates)
|
|
2359
|
+
reset += 1
|
|
2360
|
+
|
|
607
2361
|
if reset:
|
|
608
2362
|
self.message_user(
|
|
609
2363
|
request,
|
|
@@ -619,13 +2373,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
619
2373
|
|
|
620
2374
|
total_kw_display.short_description = "Total kW"
|
|
621
2375
|
|
|
622
|
-
def
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
2376
|
+
def today_kw(self, obj):
|
|
2377
|
+
start, end = self._today_range()
|
|
2378
|
+
return round(obj.total_kw_for_range(start, end), 2)
|
|
2379
|
+
|
|
2380
|
+
today_kw.short_description = "Today kW"
|
|
627
2381
|
|
|
628
|
-
|
|
2382
|
+
def changelist_view(self, request, extra_context=None):
|
|
2383
|
+
response = super().changelist_view(request, extra_context=extra_context)
|
|
2384
|
+
if hasattr(response, "context_data"):
|
|
2385
|
+
cl = response.context_data.get("cl")
|
|
2386
|
+
if cl is not None:
|
|
2387
|
+
response.context_data.update(
|
|
2388
|
+
self._charger_quick_stats_context(cl.queryset)
|
|
2389
|
+
)
|
|
2390
|
+
return response
|
|
2391
|
+
|
|
2392
|
+
def _charger_quick_stats_context(self, queryset):
|
|
2393
|
+
chargers = list(queryset)
|
|
2394
|
+
stats = {"total_kw": 0.0, "today_kw": 0.0}
|
|
2395
|
+
if not chargers:
|
|
2396
|
+
return {"charger_quick_stats": stats}
|
|
2397
|
+
|
|
2398
|
+
parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
|
|
2399
|
+
start, end = self._today_range()
|
|
2400
|
+
|
|
2401
|
+
for charger in chargers:
|
|
2402
|
+
include_totals = True
|
|
2403
|
+
if charger.connector_id is not None and charger.charger_id in parent_ids:
|
|
2404
|
+
include_totals = False
|
|
2405
|
+
if include_totals:
|
|
2406
|
+
stats["total_kw"] += charger.total_kw
|
|
2407
|
+
stats["today_kw"] += charger.total_kw_for_range(start, end)
|
|
2408
|
+
|
|
2409
|
+
stats = {key: round(value, 2) for key, value in stats.items()}
|
|
2410
|
+
return {"charger_quick_stats": stats}
|
|
2411
|
+
|
|
2412
|
+
def _today_range(self):
|
|
2413
|
+
today = timezone.localdate()
|
|
2414
|
+
start = datetime.combine(today, time.min)
|
|
2415
|
+
if timezone.is_naive(start):
|
|
2416
|
+
start = timezone.make_aware(start, timezone.get_current_timezone())
|
|
2417
|
+
end = start + timedelta(days=1)
|
|
2418
|
+
return start, end
|
|
629
2419
|
|
|
630
2420
|
|
|
631
2421
|
@admin.register(Simulator)
|
|
@@ -637,7 +2427,7 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
637
2427
|
"ws_port",
|
|
638
2428
|
"ws_url",
|
|
639
2429
|
"interval",
|
|
640
|
-
"
|
|
2430
|
+
"kw_max_display",
|
|
641
2431
|
"running",
|
|
642
2432
|
"log_link",
|
|
643
2433
|
)
|
|
@@ -677,6 +2467,26 @@ class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, EntityModelAdmin
|
|
|
677
2467
|
|
|
678
2468
|
log_type = "simulator"
|
|
679
2469
|
|
|
2470
|
+
@admin.display(description="kW Max", ordering="kw_max")
|
|
2471
|
+
def kw_max_display(self, obj):
|
|
2472
|
+
"""Display ``kw_max`` with a dot decimal separator for Spanish locales."""
|
|
2473
|
+
|
|
2474
|
+
language = translation.get_language() or ""
|
|
2475
|
+
if language.startswith("es"):
|
|
2476
|
+
return formats.number_format(
|
|
2477
|
+
obj.kw_max,
|
|
2478
|
+
decimal_pos=2,
|
|
2479
|
+
use_l10n=False,
|
|
2480
|
+
force_grouping=False,
|
|
2481
|
+
)
|
|
2482
|
+
|
|
2483
|
+
return formats.number_format(
|
|
2484
|
+
obj.kw_max,
|
|
2485
|
+
decimal_pos=2,
|
|
2486
|
+
use_l10n=True,
|
|
2487
|
+
force_grouping=False,
|
|
2488
|
+
)
|
|
2489
|
+
|
|
680
2490
|
def save_model(self, request, obj, form, change):
|
|
681
2491
|
previous_door_open = False
|
|
682
2492
|
if change and obj.pk:
|
|
@@ -830,8 +2640,10 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
830
2640
|
change_list_template = "admin/ocpp/transaction/change_list.html"
|
|
831
2641
|
list_display = (
|
|
832
2642
|
"charger",
|
|
2643
|
+
"connector_number",
|
|
833
2644
|
"account",
|
|
834
2645
|
"rfid",
|
|
2646
|
+
"vid",
|
|
835
2647
|
"meter_start",
|
|
836
2648
|
"meter_stop",
|
|
837
2649
|
"start_time",
|
|
@@ -843,6 +2655,12 @@ class TransactionAdmin(EntityModelAdmin):
|
|
|
843
2655
|
date_hierarchy = "start_time"
|
|
844
2656
|
inlines = [MeterValueInline]
|
|
845
2657
|
|
|
2658
|
+
def connector_number(self, obj):
|
|
2659
|
+
return obj.connector_id or ""
|
|
2660
|
+
|
|
2661
|
+
connector_number.short_description = "#"
|
|
2662
|
+
connector_number.admin_order_field = "connector_id"
|
|
2663
|
+
|
|
846
2664
|
def get_urls(self):
|
|
847
2665
|
urls = super().get_urls()
|
|
848
2666
|
custom = [
|