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.

Files changed (67) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
  2. arthexis-0.1.28.dist-info/RECORD +112 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +21 -30
  6. config/settings_helpers.py +176 -1
  7. config/urls.py +69 -1
  8. core/admin.py +805 -473
  9. core/apps.py +6 -8
  10. core/auto_upgrade.py +19 -4
  11. core/backends.py +13 -3
  12. core/celery_utils.py +73 -0
  13. core/changelog.py +66 -5
  14. core/environment.py +4 -5
  15. core/models.py +1825 -218
  16. core/notifications.py +1 -1
  17. core/reference_utils.py +10 -11
  18. core/release.py +55 -7
  19. core/sigil_builder.py +2 -2
  20. core/sigil_resolver.py +1 -66
  21. core/system.py +285 -4
  22. core/tasks.py +439 -138
  23. core/test_system_info.py +43 -5
  24. core/tests.py +516 -18
  25. core/user_data.py +94 -21
  26. core/views.py +348 -186
  27. nodes/admin.py +904 -67
  28. nodes/apps.py +12 -1
  29. nodes/feature_checks.py +30 -0
  30. nodes/models.py +800 -127
  31. nodes/rfid_sync.py +1 -1
  32. nodes/tasks.py +98 -3
  33. nodes/tests.py +1381 -152
  34. nodes/urls.py +15 -1
  35. nodes/utils.py +51 -3
  36. nodes/views.py +1382 -152
  37. ocpp/admin.py +1970 -152
  38. ocpp/consumers.py +839 -34
  39. ocpp/models.py +968 -17
  40. ocpp/network.py +398 -0
  41. ocpp/store.py +411 -43
  42. ocpp/tasks.py +261 -3
  43. ocpp/test_export_import.py +1 -0
  44. ocpp/test_rfid.py +194 -6
  45. ocpp/tests.py +1918 -87
  46. ocpp/transactions_io.py +9 -1
  47. ocpp/urls.py +8 -3
  48. ocpp/views.py +700 -53
  49. pages/admin.py +262 -30
  50. pages/apps.py +35 -0
  51. pages/context_processors.py +28 -21
  52. pages/defaults.py +1 -1
  53. pages/forms.py +31 -8
  54. pages/middleware.py +6 -2
  55. pages/models.py +86 -2
  56. pages/module_defaults.py +5 -5
  57. pages/site_config.py +137 -0
  58. pages/tests.py +1050 -126
  59. pages/urls.py +14 -2
  60. pages/utils.py +70 -0
  61. pages/views.py +622 -56
  62. arthexis-0.1.16.dist-info/RECORD +0 -111
  63. core/workgroup_urls.py +0 -17
  64. core/workgroup_views.py +0 -94
  65. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
  66. {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
  67. {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
- from datetime import timedelta
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.urls import path
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
- "charger_id",
1079
+ "display_name_with_fallback",
240
1080
  "connector_number",
241
- "location_name",
1081
+ "charger_name_display",
1082
+ "local_indicator",
242
1083
  "require_rfid_display",
243
1084
  "public_display",
244
1085
  "last_heartbeat",
245
- "session_kw",
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
- label = (obj.last_status or "status").strip() or "status"
321
- status_key = label.lower()
322
- error_code = (obj.last_error_code or "").strip().lower()
323
- if (
324
- self._has_active_session(obj)
325
- and error_code in ERROR_OK_VALUES
326
- and (status_key not in STATUS_BADGE_MAP or status_key == "available")
327
- ):
328
- label = STATUS_BADGE_MAP["charging"][0]
329
- return format_html('<a href="{}" target="_blank">{}</a>', url, label)
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="Fetch CP configuration")
363
- def fetch_cp_configuration(self, request, queryset):
364
- fetched = 0
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
- payload = {}
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 GetConfiguration ({exc})",
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": "GetConfiguration",
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="GetConfiguration",
1614
+ action="TriggerMessage",
403
1615
  log_key=log_key,
404
- message=(
405
- "GetConfiguration timed out: charger did not respond"
406
- " (operation may not be supported)"
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
- fetched += 1
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
- def _dispatch_change_availability(self, request, queryset, availability_type: str):
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
- connector_value = charger.connector_id
420
- ws = store.get_connection(charger.charger_id, connector_value)
421
- if ws is None:
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}: no active connection",
1831
+ f"{charger}: remote administration is disabled.",
425
1832
  level=messages.ERROR,
426
1833
  )
427
1834
  continue
428
- connector_id = connector_value if connector_value is not None else 0
429
- message_id = uuid.uuid4().hex
430
- payload = {"connectorId": connector_id, "type": availability_type}
431
- msg = json.dumps([2, message_id, "ChangeAvailability", payload])
432
- try:
433
- async_to_sync(ws.send)(msg)
434
- except Exception as exc: # pragma: no cover - network error
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}: failed to send ChangeAvailability ({exc})",
1918
+ f"{charger}: remote administration is disabled.",
438
1919
  level=messages.ERROR,
439
1920
  )
440
1921
  continue
441
- log_key = store.identity_key(charger.charger_id, connector_value)
442
- store.add_log(log_key, f"< {msg}", log_type="charger")
443
- timestamp = timezone.now()
444
- store.register_pending_call(
445
- message_id,
446
- {
447
- "action": "ChangeAvailability",
448
- "charger_id": charger.charger_id,
449
- "connector_id": connector_value,
450
- "availability_type": availability_type,
451
- "requested_at": timestamp,
452
- },
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
- updates = {
455
- "availability_requested_state": availability_type,
456
- "availability_requested_at": timestamp,
457
- "availability_request_status": "",
458
- "availability_request_status_at": None,
459
- "availability_request_details": "",
460
- }
461
- Charger.objects.filter(pk=charger.pk).update(**updates)
462
- for field, value in updates.items():
463
- setattr(charger, field, value)
464
- sent += 1
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
- updates = {
486
- "availability_state": availability_state,
487
- "availability_state_updated_at": timestamp,
488
- }
489
- Charger.objects.filter(pk=charger.pk).update(**updates)
490
- for field, value in updates.items():
491
- setattr(charger, field, value)
492
- updated += 1
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="Remote stop active transaction")
508
- def remote_stop_transaction(self, request, queryset):
509
- stopped = 0
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
- connector_value = charger.connector_id
512
- ws = store.get_connection(charger.charger_id, connector_value)
513
- if ws is None:
514
- self.message_user(
515
- request,
516
- f"{charger}: no active connection",
517
- level=messages.ERROR,
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
- tx_obj = store.get_transaction(charger.charger_id, connector_value)
521
- if tx_obj is None:
2149
+
2150
+ if not charger.allow_remote:
522
2151
  self.message_user(
523
2152
  request,
524
- f"{charger}: no active transaction",
2153
+ f"{charger}: remote administration is disabled.",
525
2154
  level=messages.ERROR,
526
2155
  )
527
2156
  continue
528
- message_id = uuid.uuid4().hex
529
- payload = {"transactionId": tx_obj.pk}
530
- msg = json.dumps([
531
- 2,
532
- message_id,
533
- "RemoteStopTransaction",
534
- payload,
535
- ])
536
- try:
537
- async_to_sync(ws.send)(msg)
538
- except Exception as exc: # pragma: no cover - network error
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}: failed to send RemoteStopTransaction ({exc})",
2241
+ f"{charger}: remote administration is disabled.",
542
2242
  level=messages.ERROR,
543
2243
  )
544
2244
  continue
545
- log_key = store.identity_key(charger.charger_id, connector_value)
546
- store.add_log(log_key, f"< {msg}", log_type="charger")
547
- store.register_pending_call(
548
- message_id,
549
- {
550
- "action": "RemoteStopTransaction",
551
- "charger_id": charger.charger_id,
552
- "connector_id": connector_value,
553
- "transaction_id": tx_obj.pk,
554
- "log_key": log_key,
555
- "requested_at": timezone.now(),
556
- },
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
- stopped += 1
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
- connector_value = charger.connector_id
570
- ws = store.get_connection(charger.charger_id, connector_value)
571
- if ws is None:
572
- self.message_user(
573
- request,
574
- f"{charger}: no active connection",
575
- level=messages.ERROR,
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
- message_id = uuid.uuid4().hex
579
- msg = json.dumps([
580
- 2,
581
- message_id,
582
- "Reset",
583
- {"type": "Soft"},
584
- ])
585
- try:
586
- async_to_sync(ws.send)(msg)
587
- except Exception as exc: # pragma: no cover - network error
2334
+
2335
+ if not charger.allow_remote:
588
2336
  self.message_user(
589
2337
  request,
590
- f"{charger}: failed to send Reset ({exc})",
2338
+ f"{charger}: remote administration is disabled.",
591
2339
  level=messages.ERROR,
592
2340
  )
593
2341
  continue
594
- log_key = store.identity_key(charger.charger_id, connector_value)
595
- store.add_log(log_key, f"< {msg}", log_type="charger")
596
- store.register_pending_call(
597
- message_id,
598
- {
599
- "action": "Reset",
600
- "charger_id": charger.charger_id,
601
- "connector_id": connector_value,
602
- "log_key": log_key,
603
- "requested_at": timezone.now(),
604
- },
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
- reset += 1
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 session_kw(self, obj):
623
- tx = store.get_transaction(obj.charger_id, obj.connector_id)
624
- if tx:
625
- return round(tx.kw, 2)
626
- return 0.0
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
- session_kw.short_description = "Session kW"
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
- "kw_max",
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 = [