arthexis 0.1.26__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.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/admin.py
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
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
|
|
6
7
|
from datetime import datetime, time, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse, unquote
|
|
10
|
+
import contextlib
|
|
7
11
|
import json
|
|
8
12
|
from typing import Any
|
|
9
|
-
|
|
10
13
|
from django.shortcuts import redirect
|
|
11
14
|
from django.utils import formats, timezone, translation
|
|
12
|
-
from django.utils.translation import gettext_lazy as _
|
|
15
|
+
from django.utils.translation import gettext_lazy as _, ngettext
|
|
13
16
|
from django.utils.dateparse import parse_datetime
|
|
14
|
-
from django.utils.html import format_html
|
|
15
|
-
from django.
|
|
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
|
|
16
20
|
from django.http import HttpResponse, HttpResponseRedirect
|
|
17
21
|
from django.template.response import TemplateResponse
|
|
18
22
|
|
|
@@ -24,16 +28,20 @@ from cryptography.hazmat.primitives import hashes
|
|
|
24
28
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
25
29
|
from django.db import transaction
|
|
26
30
|
from django.core.exceptions import ValidationError
|
|
31
|
+
from django.conf import settings
|
|
27
32
|
|
|
28
33
|
from .models import (
|
|
29
34
|
Charger,
|
|
30
35
|
ChargerConfiguration,
|
|
36
|
+
ConfigurationKey,
|
|
31
37
|
Simulator,
|
|
32
38
|
MeterValue,
|
|
33
39
|
Transaction,
|
|
34
40
|
Location,
|
|
35
41
|
DataTransferMessage,
|
|
36
42
|
CPReservation,
|
|
43
|
+
CPFirmware,
|
|
44
|
+
CPFirmwareDeployment,
|
|
37
45
|
)
|
|
38
46
|
from .simulator import ChargePointSimulator
|
|
39
47
|
from . import store
|
|
@@ -44,6 +52,7 @@ from .transactions_io import (
|
|
|
44
52
|
from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
|
|
45
53
|
from .views import _charger_state, _live_sessions
|
|
46
54
|
from core.admin import SaveBeforeChangeAction
|
|
55
|
+
from core.models import RFID as CoreRFID
|
|
47
56
|
from core.user_data import EntityModelAdmin
|
|
48
57
|
from nodes.models import Node
|
|
49
58
|
|
|
@@ -115,6 +124,64 @@ class CPReservationForm(forms.ModelForm):
|
|
|
115
124
|
return cleaned
|
|
116
125
|
|
|
117
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
|
+
|
|
118
185
|
class LogViewAdminMixin:
|
|
119
186
|
"""Mixin providing an admin view to display charger or simulator logs."""
|
|
120
187
|
|
|
@@ -159,6 +226,44 @@ class LogViewAdminMixin:
|
|
|
159
226
|
return TemplateResponse(request, self.log_template_name, context)
|
|
160
227
|
|
|
161
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
|
+
|
|
162
267
|
@admin.register(ChargerConfiguration)
|
|
163
268
|
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
164
269
|
list_display = (
|
|
@@ -177,10 +282,10 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
177
282
|
"created_at",
|
|
178
283
|
"updated_at",
|
|
179
284
|
"linked_chargers",
|
|
180
|
-
"configuration_keys_display",
|
|
181
285
|
"unknown_keys_display",
|
|
182
286
|
"raw_payload_display",
|
|
183
287
|
)
|
|
288
|
+
inlines = (ConfigurationKeyInline,)
|
|
184
289
|
fieldsets = (
|
|
185
290
|
(
|
|
186
291
|
None,
|
|
@@ -200,7 +305,6 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
200
305
|
"Payload",
|
|
201
306
|
{
|
|
202
307
|
"fields": (
|
|
203
|
-
"configuration_keys_display",
|
|
204
308
|
"unknown_keys_display",
|
|
205
309
|
"raw_payload_display",
|
|
206
310
|
)
|
|
@@ -231,10 +335,6 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
231
335
|
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
|
232
336
|
return format_html("<pre>{}</pre>", formatted)
|
|
233
337
|
|
|
234
|
-
@admin.display(description="configurationKey")
|
|
235
|
-
def configuration_keys_display(self, obj):
|
|
236
|
-
return self._render_json(obj.configuration_keys)
|
|
237
|
-
|
|
238
338
|
@admin.display(description="unknownKey")
|
|
239
339
|
def unknown_keys_display(self, obj):
|
|
240
340
|
return self._render_json(obj.unknown_keys)
|
|
@@ -254,6 +354,15 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
254
354
|
super().save_model(request, obj, form, change)
|
|
255
355
|
|
|
256
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
|
+
|
|
257
366
|
@admin.register(Location)
|
|
258
367
|
class LocationAdmin(EntityModelAdmin):
|
|
259
368
|
form = LocationAdminForm
|
|
@@ -300,9 +409,388 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
|
|
|
300
409
|
)
|
|
301
410
|
|
|
302
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
|
+
|
|
303
790
|
@admin.register(CPReservation)
|
|
304
791
|
class CPReservationAdmin(EntityModelAdmin):
|
|
305
792
|
form = CPReservationForm
|
|
793
|
+
actions = ("cancel_reservations",)
|
|
306
794
|
list_display = (
|
|
307
795
|
"location",
|
|
308
796
|
"connector_side_display",
|
|
@@ -423,6 +911,49 @@ class CPReservationAdmin(EntityModelAdmin):
|
|
|
423
911
|
value = obj.id_tag_value
|
|
424
912
|
return value or "-"
|
|
425
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
|
+
|
|
426
957
|
|
|
427
958
|
@admin.register(Charger)
|
|
428
959
|
class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
@@ -487,6 +1018,15 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
487
1018
|
"Configuration",
|
|
488
1019
|
{"fields": ("public_display", "require_rfid", "configuration")},
|
|
489
1020
|
),
|
|
1021
|
+
(
|
|
1022
|
+
"Local authorization",
|
|
1023
|
+
{
|
|
1024
|
+
"fields": (
|
|
1025
|
+
"local_auth_list_version",
|
|
1026
|
+
"local_auth_list_updated_at",
|
|
1027
|
+
)
|
|
1028
|
+
},
|
|
1029
|
+
),
|
|
490
1030
|
(
|
|
491
1031
|
"Network",
|
|
492
1032
|
{
|
|
@@ -529,6 +1069,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
529
1069
|
"availability_request_status_at",
|
|
530
1070
|
"availability_request_details",
|
|
531
1071
|
"configuration",
|
|
1072
|
+
"local_auth_list_version",
|
|
1073
|
+
"local_auth_list_updated_at",
|
|
532
1074
|
"forwarded_to",
|
|
533
1075
|
"forwarding_watermark",
|
|
534
1076
|
"last_online_at",
|
|
@@ -553,16 +1095,145 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
553
1095
|
"purge_data",
|
|
554
1096
|
"fetch_cp_configuration",
|
|
555
1097
|
"toggle_rfid_authentication",
|
|
1098
|
+
"send_rfid_list_to_evcs",
|
|
1099
|
+
"update_rfids_from_evcs",
|
|
556
1100
|
"recheck_charger_status",
|
|
1101
|
+
"get_diagnostics",
|
|
557
1102
|
"change_availability_operative",
|
|
558
1103
|
"change_availability_inoperative",
|
|
559
1104
|
"set_availability_state_operative",
|
|
560
1105
|
"set_availability_state_inoperative",
|
|
1106
|
+
"clear_authorization_cache",
|
|
561
1107
|
"remote_stop_transaction",
|
|
562
1108
|
"reset_chargers",
|
|
563
1109
|
"delete_selected",
|
|
564
1110
|
]
|
|
565
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
|
+
|
|
566
1237
|
def _prepare_remote_credentials(self, request):
|
|
567
1238
|
local = Node.get_local()
|
|
568
1239
|
if not local or not local.uuid:
|
|
@@ -708,6 +1379,55 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
708
1379
|
for field, value in applied.items():
|
|
709
1380
|
setattr(charger, field, value)
|
|
710
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
|
+
|
|
711
1431
|
def get_readonly_fields(self, request, obj=None):
|
|
712
1432
|
readonly = list(super().get_readonly_fields(request, obj))
|
|
713
1433
|
if obj and not obj.is_local:
|
|
@@ -814,6 +1534,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
814
1534
|
return obj.location.name
|
|
815
1535
|
return obj.charger_id
|
|
816
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
|
+
|
|
817
1552
|
@admin.display(boolean=True, description="Local")
|
|
818
1553
|
def local_indicator(self, obj):
|
|
819
1554
|
return obj.is_local
|
|
@@ -1031,6 +1766,183 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
1031
1766
|
f"Updated RFID authentication: {summary}",
|
|
1032
1767
|
)
|
|
1033
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"
|
|
1773
|
+
sent = 0
|
|
1774
|
+
local_node = None
|
|
1775
|
+
private_key = None
|
|
1776
|
+
remote_unavailable = False
|
|
1777
|
+
for charger in queryset:
|
|
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:
|
|
1829
|
+
self.message_user(
|
|
1830
|
+
request,
|
|
1831
|
+
f"{charger}: remote administration is disabled.",
|
|
1832
|
+
level=messages.ERROR,
|
|
1833
|
+
)
|
|
1834
|
+
continue
|
|
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:
|
|
1916
|
+
self.message_user(
|
|
1917
|
+
request,
|
|
1918
|
+
f"{charger}: remote administration is disabled.",
|
|
1919
|
+
level=messages.ERROR,
|
|
1920
|
+
)
|
|
1921
|
+
continue
|
|
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",
|
|
1935
|
+
)
|
|
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
|
+
|
|
1034
1946
|
def _dispatch_change_availability(self, request, queryset, availability_type: str):
|
|
1035
1947
|
sent = 0
|
|
1036
1948
|
local_node = None
|
|
@@ -1186,6 +2098,85 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
1186
2098
|
def set_availability_state_inoperative(self, request, queryset):
|
|
1187
2099
|
self._set_availability_state(request, queryset, "Inoperative")
|
|
1188
2100
|
|
|
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
|
|
2107
|
+
for charger in queryset:
|
|
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,
|
|
2146
|
+
)
|
|
2147
|
+
cleared += 1
|
|
2148
|
+
continue
|
|
2149
|
+
|
|
2150
|
+
if not charger.allow_remote:
|
|
2151
|
+
self.message_user(
|
|
2152
|
+
request,
|
|
2153
|
+
f"{charger}: remote administration is disabled.",
|
|
2154
|
+
level=messages.ERROR,
|
|
2155
|
+
)
|
|
2156
|
+
continue
|
|
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
|
+
|
|
1189
2180
|
@admin.action(description="Remote stop active transaction")
|
|
1190
2181
|
def remote_stop_transaction(self, request, queryset):
|
|
1191
2182
|
stopped = 0
|