arthexis 0.1.23__py3-none-any.whl → 0.1.25__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.

ocpp/admin.py CHANGED
@@ -2,11 +2,15 @@ from django.contrib import admin, messages
2
2
  from django import forms
3
3
 
4
4
  import asyncio
5
+ import base64
5
6
  from datetime import datetime, time, timedelta
6
7
  import json
8
+ from typing import Any
7
9
 
8
10
  from django.shortcuts import redirect
9
11
  from django.utils import formats, timezone, translation
12
+ from django.utils.translation import gettext_lazy as _
13
+ from django.utils.dateparse import parse_datetime
10
14
  from django.utils.html import format_html
11
15
  from django.urls import path
12
16
  from django.http import HttpResponse, HttpResponseRedirect
@@ -14,6 +18,12 @@ from django.template.response import TemplateResponse
14
18
 
15
19
  import uuid
16
20
  from asgiref.sync import async_to_sync
21
+ import requests
22
+ from requests import RequestException
23
+ from cryptography.hazmat.primitives import hashes
24
+ from cryptography.hazmat.primitives.asymmetric import padding
25
+ from django.db import transaction
26
+ from django.core.exceptions import ValidationError
17
27
 
18
28
  from .models import (
19
29
  Charger,
@@ -23,6 +33,7 @@ from .models import (
23
33
  Transaction,
24
34
  Location,
25
35
  DataTransferMessage,
36
+ CPReservation,
26
37
  )
27
38
  from .simulator import ChargePointSimulator
28
39
  from . import store
@@ -34,6 +45,7 @@ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
34
45
  from .views import _charger_state, _live_sessions
35
46
  from core.admin import SaveBeforeChangeAction
36
47
  from core.user_data import EntityModelAdmin
48
+ from nodes.models import Node
37
49
 
38
50
 
39
51
  class LocationAdminForm(forms.ModelForm):
@@ -66,6 +78,43 @@ class TransactionImportForm(forms.Form):
66
78
  file = forms.FileField()
67
79
 
68
80
 
81
+ class CPReservationForm(forms.ModelForm):
82
+ class Meta:
83
+ model = CPReservation
84
+ fields = [
85
+ "location",
86
+ "account",
87
+ "rfid",
88
+ "id_tag",
89
+ "start_time",
90
+ "duration_minutes",
91
+ ]
92
+
93
+ def clean(self):
94
+ cleaned = super().clean()
95
+ instance = self.instance
96
+ for field in self.Meta.fields:
97
+ if field in cleaned:
98
+ setattr(instance, field, cleaned[field])
99
+ try:
100
+ instance.allocate_connector(force=bool(instance.pk))
101
+ except ValidationError as exc:
102
+ if exc.message_dict:
103
+ for field, errors in exc.message_dict.items():
104
+ for error in errors:
105
+ self.add_error(field, error)
106
+ raise forms.ValidationError(
107
+ _("Unable to allocate a connector for the selected time window.")
108
+ )
109
+ raise forms.ValidationError(exc.messages or [str(exc)])
110
+ if not instance.id_tag_value:
111
+ message = _("Select an RFID or provide an idTag for the reservation.")
112
+ self.add_error("id_tag", message)
113
+ self.add_error("rfid", message)
114
+ raise forms.ValidationError(message)
115
+ return cleaned
116
+
117
+
69
118
  class LogViewAdminMixin:
70
119
  """Mixin providing an admin view to display charger or simulator logs."""
71
120
 
@@ -210,6 +259,7 @@ class LocationAdmin(EntityModelAdmin):
210
259
  form = LocationAdminForm
211
260
  list_display = ("name", "zone", "contract_type", "latitude", "longitude")
212
261
  change_form_template = "admin/ocpp/location/change_form.html"
262
+ search_fields = ("name",)
213
263
 
214
264
 
215
265
  @admin.register(DataTransferMessage)
@@ -250,8 +300,139 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
250
300
  )
251
301
 
252
302
 
303
+ @admin.register(CPReservation)
304
+ class CPReservationAdmin(EntityModelAdmin):
305
+ form = CPReservationForm
306
+ list_display = (
307
+ "location",
308
+ "connector_side_display",
309
+ "start_time",
310
+ "end_time_display",
311
+ "account",
312
+ "id_tag_display",
313
+ "evcs_status",
314
+ "evcs_confirmed",
315
+ )
316
+ list_filter = ("location", "evcs_confirmed")
317
+ search_fields = (
318
+ "location__name",
319
+ "connector__charger_id",
320
+ "connector__display_name",
321
+ "account__name",
322
+ "id_tag",
323
+ "rfid__rfid",
324
+ )
325
+ date_hierarchy = "start_time"
326
+ ordering = ("-start_time",)
327
+ autocomplete_fields = ("location", "account", "rfid")
328
+ readonly_fields = (
329
+ "connector_identity",
330
+ "connector_side_display",
331
+ "evcs_status",
332
+ "evcs_error",
333
+ "evcs_confirmed",
334
+ "evcs_confirmed_at",
335
+ "ocpp_message_id",
336
+ "created_on",
337
+ "updated_on",
338
+ )
339
+ fieldsets = (
340
+ (
341
+ None,
342
+ {
343
+ "fields": (
344
+ "location",
345
+ "account",
346
+ "rfid",
347
+ "id_tag",
348
+ "start_time",
349
+ "duration_minutes",
350
+ )
351
+ },
352
+ ),
353
+ (
354
+ _("Assigned connector"),
355
+ {"fields": ("connector_identity", "connector_side_display")},
356
+ ),
357
+ (
358
+ _("EVCS response"),
359
+ {
360
+ "fields": (
361
+ "evcs_confirmed",
362
+ "evcs_status",
363
+ "evcs_confirmed_at",
364
+ "evcs_error",
365
+ "ocpp_message_id",
366
+ )
367
+ },
368
+ ),
369
+ (
370
+ _("Metadata"),
371
+ {"fields": ("created_on", "updated_on")},
372
+ ),
373
+ )
374
+
375
+ def save_model(self, request, obj, form, change):
376
+ trigger_fields = {
377
+ "start_time",
378
+ "duration_minutes",
379
+ "location",
380
+ "id_tag",
381
+ "rfid",
382
+ "account",
383
+ }
384
+ changed_data = set(getattr(form, "changed_data", []))
385
+ should_send = not change or bool(trigger_fields.intersection(changed_data))
386
+ with transaction.atomic():
387
+ super().save_model(request, obj, form, change)
388
+ if should_send:
389
+ try:
390
+ obj.send_reservation_request()
391
+ except ValidationError as exc:
392
+ raise ValidationError(exc.message_dict or exc.messages or str(exc))
393
+ else:
394
+ self.message_user(
395
+ request,
396
+ _("Reservation request sent to %(connector)s.")
397
+ % {"connector": self.connector_identity(obj)},
398
+ messages.SUCCESS,
399
+ )
400
+
401
+ @admin.display(description=_("Connector"), ordering="connector__connector_id")
402
+ def connector_side_display(self, obj):
403
+ return obj.connector_label or "-"
404
+
405
+ @admin.display(description=_("Connector identity"))
406
+ def connector_identity(self, obj):
407
+ if obj.connector_id:
408
+ return obj.connector.identity_slug()
409
+ return "-"
410
+
411
+ @admin.display(description=_("End time"))
412
+ def end_time_display(self, obj):
413
+ try:
414
+ value = timezone.localtime(obj.end_time)
415
+ except Exception:
416
+ value = obj.end_time
417
+ if not value:
418
+ return "-"
419
+ return formats.date_format(value, "DATETIME_FORMAT")
420
+
421
+ @admin.display(description=_("Id tag"))
422
+ def id_tag_display(self, obj):
423
+ value = obj.id_tag_value
424
+ return value or "-"
425
+
426
+
253
427
  @admin.register(Charger)
254
428
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
429
+ _REMOTE_DATETIME_FIELDS = {
430
+ "availability_state_updated_at",
431
+ "availability_requested_at",
432
+ "availability_request_status_at",
433
+ "last_online_at",
434
+ }
435
+
255
436
  fieldsets = (
256
437
  (
257
438
  "General",
@@ -306,6 +487,20 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
306
487
  "Configuration",
307
488
  {"fields": ("public_display", "require_rfid", "configuration")},
308
489
  ),
490
+ (
491
+ "Network",
492
+ {
493
+ "fields": (
494
+ "node_origin",
495
+ "manager_node",
496
+ "forwarded_to",
497
+ "forwarding_watermark",
498
+ "allow_remote",
499
+ "export_transactions",
500
+ "last_online_at",
501
+ )
502
+ },
503
+ ),
309
504
  (
310
505
  "References",
311
506
  {
@@ -334,15 +529,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
334
529
  "availability_request_status_at",
335
530
  "availability_request_details",
336
531
  "configuration",
532
+ "forwarded_to",
533
+ "forwarding_watermark",
534
+ "last_online_at",
337
535
  )
338
536
  list_display = (
339
537
  "display_name_with_fallback",
340
538
  "connector_number",
341
539
  "charger_name_display",
540
+ "local_indicator",
342
541
  "require_rfid_display",
343
542
  "public_display",
344
543
  "last_heartbeat",
345
- "session_kw",
544
+ "today_kw",
346
545
  "total_kw_display",
347
546
  "page_link",
348
547
  "log_link",
@@ -364,6 +563,159 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
364
563
  "delete_selected",
365
564
  ]
366
565
 
566
+ def _prepare_remote_credentials(self, request):
567
+ local = Node.get_local()
568
+ if not local or not local.uuid:
569
+ self.message_user(
570
+ request,
571
+ "Local node is not registered; remote actions are unavailable.",
572
+ level=messages.ERROR,
573
+ )
574
+ return None, None
575
+ private_key = local.get_private_key()
576
+ if private_key is None:
577
+ self.message_user(
578
+ request,
579
+ "Local node private key is unavailable; remote actions are disabled.",
580
+ level=messages.ERROR,
581
+ )
582
+ return None, None
583
+ return local, private_key
584
+
585
+ def _call_remote_action(
586
+ self,
587
+ request,
588
+ local_node: Node,
589
+ private_key,
590
+ charger: Charger,
591
+ action: str,
592
+ extra: dict[str, Any] | None = None,
593
+ ) -> tuple[bool, dict[str, Any]]:
594
+ if not charger.node_origin:
595
+ self.message_user(
596
+ request,
597
+ f"{charger}: remote node information is missing.",
598
+ level=messages.ERROR,
599
+ )
600
+ return False, {}
601
+ origin = charger.node_origin
602
+ if not origin.port:
603
+ self.message_user(
604
+ request,
605
+ f"{charger}: remote node port is not configured.",
606
+ level=messages.ERROR,
607
+ )
608
+ return False, {}
609
+
610
+ if not origin.get_remote_host_candidates():
611
+ self.message_user(
612
+ request,
613
+ f"{charger}: remote node connection details are incomplete.",
614
+ level=messages.ERROR,
615
+ )
616
+ return False, {}
617
+
618
+ payload: dict[str, Any] = {
619
+ "requester": str(local_node.uuid),
620
+ "requester_mac": local_node.mac_address,
621
+ "requester_public_key": local_node.public_key,
622
+ "charger_id": charger.charger_id,
623
+ "connector_id": charger.connector_id,
624
+ "action": action,
625
+ }
626
+ if extra:
627
+ payload.update(extra)
628
+
629
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
630
+ headers = {"Content-Type": "application/json"}
631
+ try:
632
+ signature = private_key.sign(
633
+ payload_json.encode(),
634
+ padding.PKCS1v15(),
635
+ hashes.SHA256(),
636
+ )
637
+ headers["X-Signature"] = base64.b64encode(signature).decode()
638
+ except Exception:
639
+ self.message_user(
640
+ request,
641
+ "Unable to sign remote action payload; remote action aborted.",
642
+ level=messages.ERROR,
643
+ )
644
+ return False, {}
645
+
646
+ url = next(
647
+ origin.iter_remote_urls("/nodes/network/chargers/action/"),
648
+ "",
649
+ )
650
+ if not url:
651
+ self.message_user(
652
+ request,
653
+ f"{charger}: no reachable hosts were reported for the remote node.",
654
+ level=messages.ERROR,
655
+ )
656
+ return False, {}
657
+ try:
658
+ response = requests.post(url, data=payload_json, headers=headers, timeout=5)
659
+ except RequestException as exc:
660
+ self.message_user(
661
+ request,
662
+ f"{charger}: failed to contact remote node ({exc}).",
663
+ level=messages.ERROR,
664
+ )
665
+ return False, {}
666
+
667
+ try:
668
+ data = response.json()
669
+ except ValueError:
670
+ self.message_user(
671
+ request,
672
+ f"{charger}: invalid response from remote node.",
673
+ level=messages.ERROR,
674
+ )
675
+ return False, {}
676
+
677
+ if response.status_code != 200 or data.get("status") != "ok":
678
+ detail = data.get("detail") if isinstance(data, dict) else None
679
+ if not detail:
680
+ detail = response.text or "Remote node rejected the request."
681
+ self.message_user(
682
+ request,
683
+ f"{charger}: {detail}",
684
+ level=messages.ERROR,
685
+ )
686
+ return False, {}
687
+
688
+ updates = data.get("updates", {}) if isinstance(data, dict) else {}
689
+ if not isinstance(updates, dict):
690
+ updates = {}
691
+ return True, updates
692
+
693
+ def _apply_remote_updates(self, charger: Charger, updates: dict[str, Any]) -> None:
694
+ if not updates:
695
+ return
696
+
697
+ applied: dict[str, Any] = {}
698
+ for field, value in updates.items():
699
+ if field in self._REMOTE_DATETIME_FIELDS and isinstance(value, str):
700
+ parsed = parse_datetime(value)
701
+ if parsed and timezone.is_naive(parsed):
702
+ parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
703
+ applied[field] = parsed
704
+ else:
705
+ applied[field] = value
706
+
707
+ Charger.objects.filter(pk=charger.pk).update(**applied)
708
+ for field, value in applied.items():
709
+ setattr(charger, field, value)
710
+
711
+ def get_readonly_fields(self, request, obj=None):
712
+ readonly = list(super().get_readonly_fields(request, obj))
713
+ if obj and not obj.is_local:
714
+ for field in ("allow_remote", "export_transactions"):
715
+ if field not in readonly:
716
+ readonly.append(field)
717
+ return tuple(readonly)
718
+
367
719
  def get_view_on_site_url(self, obj=None):
368
720
  return obj.get_absolute_url() if obj else None
369
721
 
@@ -462,6 +814,10 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
462
814
  return obj.location.name
463
815
  return obj.charger_id
464
816
 
817
+ @admin.display(boolean=True, description="Local")
818
+ def local_indicator(self, obj):
819
+ return obj.is_local
820
+
465
821
  def location_name(self, obj):
466
822
  return obj.location.name if obj.location else ""
467
823
 
@@ -534,51 +890,82 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
534
890
  @admin.action(description="Fetch CP configuration")
535
891
  def fetch_cp_configuration(self, request, queryset):
536
892
  fetched = 0
893
+ local_node = None
894
+ private_key = None
895
+ remote_unavailable = False
537
896
  for charger in queryset:
538
- connector_value = charger.connector_id
539
- ws = store.get_connection(charger.charger_id, connector_value)
540
- if ws is None:
541
- self.message_user(
542
- request,
543
- f"{charger}: no active connection",
544
- level=messages.ERROR,
897
+ if charger.is_local:
898
+ connector_value = charger.connector_id
899
+ ws = store.get_connection(charger.charger_id, connector_value)
900
+ if ws is None:
901
+ self.message_user(
902
+ request,
903
+ f"{charger}: no active connection",
904
+ level=messages.ERROR,
905
+ )
906
+ continue
907
+ message_id = uuid.uuid4().hex
908
+ payload = {}
909
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
910
+ try:
911
+ async_to_sync(ws.send)(msg)
912
+ except Exception as exc: # pragma: no cover - network error
913
+ self.message_user(
914
+ request,
915
+ f"{charger}: failed to send GetConfiguration ({exc})",
916
+ level=messages.ERROR,
917
+ )
918
+ continue
919
+ log_key = store.identity_key(charger.charger_id, connector_value)
920
+ store.add_log(log_key, f"< {msg}", log_type="charger")
921
+ store.register_pending_call(
922
+ message_id,
923
+ {
924
+ "action": "GetConfiguration",
925
+ "charger_id": charger.charger_id,
926
+ "connector_id": connector_value,
927
+ "log_key": log_key,
928
+ "requested_at": timezone.now(),
929
+ },
545
930
  )
931
+ store.schedule_call_timeout(
932
+ message_id,
933
+ timeout=5.0,
934
+ action="GetConfiguration",
935
+ log_key=log_key,
936
+ message=(
937
+ "GetConfiguration timed out: charger did not respond"
938
+ " (operation may not be supported)"
939
+ ),
940
+ )
941
+ fetched += 1
546
942
  continue
547
- message_id = uuid.uuid4().hex
548
- payload = {}
549
- msg = json.dumps([2, message_id, "GetConfiguration", payload])
550
- try:
551
- async_to_sync(ws.send)(msg)
552
- except Exception as exc: # pragma: no cover - network error
943
+
944
+ if not charger.allow_remote:
553
945
  self.message_user(
554
946
  request,
555
- f"{charger}: failed to send GetConfiguration ({exc})",
947
+ f"{charger}: remote administration is disabled.",
556
948
  level=messages.ERROR,
557
949
  )
558
950
  continue
559
- log_key = store.identity_key(charger.charger_id, connector_value)
560
- store.add_log(log_key, f"< {msg}", log_type="charger")
561
- store.register_pending_call(
562
- message_id,
563
- {
564
- "action": "GetConfiguration",
565
- "charger_id": charger.charger_id,
566
- "connector_id": connector_value,
567
- "log_key": log_key,
568
- "requested_at": timezone.now(),
569
- },
570
- )
571
- store.schedule_call_timeout(
572
- message_id,
573
- timeout=5.0,
574
- action="GetConfiguration",
575
- log_key=log_key,
576
- message=(
577
- "GetConfiguration timed out: charger did not respond"
578
- " (operation may not be supported)"
579
- ),
951
+ if remote_unavailable:
952
+ continue
953
+ if local_node is None:
954
+ local_node, private_key = self._prepare_remote_credentials(request)
955
+ if not local_node or not private_key:
956
+ remote_unavailable = True
957
+ continue
958
+ success, updates = self._call_remote_action(
959
+ request,
960
+ local_node,
961
+ private_key,
962
+ charger,
963
+ "get-configuration",
580
964
  )
581
- fetched += 1
965
+ if success:
966
+ self._apply_remote_updates(charger, updates)
967
+ fetched += 1
968
+
582
969
  if fetched:
583
970
  self.message_user(
584
971
  request,
@@ -589,14 +976,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
589
976
  def toggle_rfid_authentication(self, request, queryset):
590
977
  enabled = 0
591
978
  disabled = 0
979
+ local_node = None
980
+ private_key = None
981
+ remote_unavailable = False
592
982
  for charger in queryset:
593
983
  new_value = not charger.require_rfid
594
- Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
595
- charger.require_rfid = new_value
596
- if new_value:
597
- enabled += 1
598
- else:
599
- disabled += 1
984
+ if charger.is_local:
985
+ Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
986
+ charger.require_rfid = new_value
987
+ if new_value:
988
+ enabled += 1
989
+ else:
990
+ disabled += 1
991
+ continue
992
+
993
+ if not charger.allow_remote:
994
+ self.message_user(
995
+ request,
996
+ f"{charger}: remote administration is disabled.",
997
+ level=messages.ERROR,
998
+ )
999
+ continue
1000
+ if remote_unavailable:
1001
+ continue
1002
+ if local_node is None:
1003
+ local_node, private_key = self._prepare_remote_credentials(request)
1004
+ if not local_node or not private_key:
1005
+ remote_unavailable = True
1006
+ continue
1007
+ success, updates = self._call_remote_action(
1008
+ request,
1009
+ local_node,
1010
+ private_key,
1011
+ charger,
1012
+ "toggle-rfid",
1013
+ {"enable": new_value},
1014
+ )
1015
+ if success:
1016
+ self._apply_remote_updates(charger, updates)
1017
+ if charger.require_rfid:
1018
+ enabled += 1
1019
+ else:
1020
+ disabled += 1
1021
+
600
1022
  if enabled or disabled:
601
1023
  changes = []
602
1024
  if enabled:
@@ -611,53 +1033,85 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
611
1033
 
612
1034
  def _dispatch_change_availability(self, request, queryset, availability_type: str):
613
1035
  sent = 0
1036
+ local_node = None
1037
+ private_key = None
1038
+ remote_unavailable = False
614
1039
  for charger in queryset:
615
- connector_value = charger.connector_id
616
- ws = store.get_connection(charger.charger_id, connector_value)
617
- if ws is None:
618
- self.message_user(
619
- request,
620
- f"{charger}: no active connection",
621
- level=messages.ERROR,
1040
+ if charger.is_local:
1041
+ connector_value = charger.connector_id
1042
+ ws = store.get_connection(charger.charger_id, connector_value)
1043
+ if ws is None:
1044
+ self.message_user(
1045
+ request,
1046
+ f"{charger}: no active connection",
1047
+ level=messages.ERROR,
1048
+ )
1049
+ continue
1050
+ connector_id = connector_value if connector_value is not None else 0
1051
+ message_id = uuid.uuid4().hex
1052
+ payload = {"connectorId": connector_id, "type": availability_type}
1053
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
1054
+ try:
1055
+ async_to_sync(ws.send)(msg)
1056
+ except Exception as exc: # pragma: no cover - network error
1057
+ self.message_user(
1058
+ request,
1059
+ f"{charger}: failed to send ChangeAvailability ({exc})",
1060
+ level=messages.ERROR,
1061
+ )
1062
+ continue
1063
+ log_key = store.identity_key(charger.charger_id, connector_value)
1064
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1065
+ timestamp = timezone.now()
1066
+ store.register_pending_call(
1067
+ message_id,
1068
+ {
1069
+ "action": "ChangeAvailability",
1070
+ "charger_id": charger.charger_id,
1071
+ "connector_id": connector_value,
1072
+ "availability_type": availability_type,
1073
+ "requested_at": timestamp,
1074
+ },
622
1075
  )
1076
+ updates = {
1077
+ "availability_requested_state": availability_type,
1078
+ "availability_requested_at": timestamp,
1079
+ "availability_request_status": "",
1080
+ "availability_request_status_at": None,
1081
+ "availability_request_details": "",
1082
+ }
1083
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1084
+ for field, value in updates.items():
1085
+ setattr(charger, field, value)
1086
+ sent += 1
623
1087
  continue
624
- connector_id = connector_value if connector_value is not None else 0
625
- message_id = uuid.uuid4().hex
626
- payload = {"connectorId": connector_id, "type": availability_type}
627
- msg = json.dumps([2, message_id, "ChangeAvailability", payload])
628
- try:
629
- async_to_sync(ws.send)(msg)
630
- except Exception as exc: # pragma: no cover - network error
1088
+
1089
+ if not charger.allow_remote:
631
1090
  self.message_user(
632
1091
  request,
633
- f"{charger}: failed to send ChangeAvailability ({exc})",
1092
+ f"{charger}: remote administration is disabled.",
634
1093
  level=messages.ERROR,
635
1094
  )
636
1095
  continue
637
- log_key = store.identity_key(charger.charger_id, connector_value)
638
- store.add_log(log_key, f"< {msg}", log_type="charger")
639
- timestamp = timezone.now()
640
- store.register_pending_call(
641
- message_id,
642
- {
643
- "action": "ChangeAvailability",
644
- "charger_id": charger.charger_id,
645
- "connector_id": connector_value,
646
- "availability_type": availability_type,
647
- "requested_at": timestamp,
648
- },
1096
+ if remote_unavailable:
1097
+ continue
1098
+ if local_node is None:
1099
+ local_node, private_key = self._prepare_remote_credentials(request)
1100
+ if not local_node or not private_key:
1101
+ remote_unavailable = True
1102
+ continue
1103
+ success, updates = self._call_remote_action(
1104
+ request,
1105
+ local_node,
1106
+ private_key,
1107
+ charger,
1108
+ "change-availability",
1109
+ {"availability_type": availability_type},
649
1110
  )
650
- updates = {
651
- "availability_requested_state": availability_type,
652
- "availability_requested_at": timestamp,
653
- "availability_request_status": "",
654
- "availability_request_status_at": None,
655
- "availability_request_details": "",
656
- }
657
- Charger.objects.filter(pk=charger.pk).update(**updates)
658
- for field, value in updates.items():
659
- setattr(charger, field, value)
660
- sent += 1
1111
+ if success:
1112
+ self._apply_remote_updates(charger, updates)
1113
+ sent += 1
1114
+
661
1115
  if sent:
662
1116
  self.message_user(
663
1117
  request,
@@ -675,17 +1129,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
675
1129
  def _set_availability_state(
676
1130
  self, request, queryset, availability_state: str
677
1131
  ) -> None:
678
- timestamp = timezone.now()
679
1132
  updated = 0
1133
+ local_node = None
1134
+ private_key = None
1135
+ remote_unavailable = False
680
1136
  for charger in queryset:
681
- updates = {
682
- "availability_state": availability_state,
683
- "availability_state_updated_at": timestamp,
684
- }
685
- Charger.objects.filter(pk=charger.pk).update(**updates)
686
- for field, value in updates.items():
687
- setattr(charger, field, value)
688
- updated += 1
1137
+ if charger.is_local:
1138
+ timestamp = timezone.now()
1139
+ updates = {
1140
+ "availability_state": availability_state,
1141
+ "availability_state_updated_at": timestamp,
1142
+ }
1143
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1144
+ for field, value in updates.items():
1145
+ setattr(charger, field, value)
1146
+ updated += 1
1147
+ continue
1148
+
1149
+ if not charger.allow_remote:
1150
+ self.message_user(
1151
+ request,
1152
+ f"{charger}: remote administration is disabled.",
1153
+ level=messages.ERROR,
1154
+ )
1155
+ continue
1156
+ if remote_unavailable:
1157
+ continue
1158
+ if local_node is None:
1159
+ local_node, private_key = self._prepare_remote_credentials(request)
1160
+ if not local_node or not private_key:
1161
+ remote_unavailable = True
1162
+ continue
1163
+ success, updates = self._call_remote_action(
1164
+ request,
1165
+ local_node,
1166
+ private_key,
1167
+ charger,
1168
+ "set-availability-state",
1169
+ {"availability_state": availability_state},
1170
+ )
1171
+ if success:
1172
+ self._apply_remote_updates(charger, updates)
1173
+ updated += 1
1174
+
689
1175
  if updated:
690
1176
  self.message_user(
691
1177
  request,
@@ -703,55 +1189,86 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
703
1189
  @admin.action(description="Remote stop active transaction")
704
1190
  def remote_stop_transaction(self, request, queryset):
705
1191
  stopped = 0
1192
+ local_node = None
1193
+ private_key = None
1194
+ remote_unavailable = False
706
1195
  for charger in queryset:
707
- connector_value = charger.connector_id
708
- ws = store.get_connection(charger.charger_id, connector_value)
709
- if ws is None:
710
- self.message_user(
711
- request,
712
- f"{charger}: no active connection",
713
- level=messages.ERROR,
1196
+ if charger.is_local:
1197
+ connector_value = charger.connector_id
1198
+ ws = store.get_connection(charger.charger_id, connector_value)
1199
+ if ws is None:
1200
+ self.message_user(
1201
+ request,
1202
+ f"{charger}: no active connection",
1203
+ level=messages.ERROR,
1204
+ )
1205
+ continue
1206
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1207
+ if tx_obj is None:
1208
+ self.message_user(
1209
+ request,
1210
+ f"{charger}: no active transaction",
1211
+ level=messages.ERROR,
1212
+ )
1213
+ continue
1214
+ message_id = uuid.uuid4().hex
1215
+ payload = {"transactionId": tx_obj.pk}
1216
+ msg = json.dumps([
1217
+ 2,
1218
+ message_id,
1219
+ "RemoteStopTransaction",
1220
+ payload,
1221
+ ])
1222
+ try:
1223
+ async_to_sync(ws.send)(msg)
1224
+ except Exception as exc: # pragma: no cover - network error
1225
+ self.message_user(
1226
+ request,
1227
+ f"{charger}: failed to send RemoteStopTransaction ({exc})",
1228
+ level=messages.ERROR,
1229
+ )
1230
+ continue
1231
+ log_key = store.identity_key(charger.charger_id, connector_value)
1232
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1233
+ store.register_pending_call(
1234
+ message_id,
1235
+ {
1236
+ "action": "RemoteStopTransaction",
1237
+ "charger_id": charger.charger_id,
1238
+ "connector_id": connector_value,
1239
+ "transaction_id": tx_obj.pk,
1240
+ "log_key": log_key,
1241
+ "requested_at": timezone.now(),
1242
+ },
714
1243
  )
1244
+ stopped += 1
715
1245
  continue
716
- tx_obj = store.get_transaction(charger.charger_id, connector_value)
717
- if tx_obj is None:
1246
+
1247
+ if not charger.allow_remote:
718
1248
  self.message_user(
719
1249
  request,
720
- f"{charger}: no active transaction",
1250
+ f"{charger}: remote administration is disabled.",
721
1251
  level=messages.ERROR,
722
1252
  )
723
1253
  continue
724
- message_id = uuid.uuid4().hex
725
- payload = {"transactionId": tx_obj.pk}
726
- msg = json.dumps([
727
- 2,
728
- message_id,
729
- "RemoteStopTransaction",
730
- payload,
731
- ])
732
- try:
733
- async_to_sync(ws.send)(msg)
734
- except Exception as exc: # pragma: no cover - network error
735
- self.message_user(
736
- request,
737
- f"{charger}: failed to send RemoteStopTransaction ({exc})",
738
- level=messages.ERROR,
739
- )
1254
+ if remote_unavailable:
740
1255
  continue
741
- log_key = store.identity_key(charger.charger_id, connector_value)
742
- store.add_log(log_key, f"< {msg}", log_type="charger")
743
- store.register_pending_call(
744
- message_id,
745
- {
746
- "action": "RemoteStopTransaction",
747
- "charger_id": charger.charger_id,
748
- "connector_id": connector_value,
749
- "transaction_id": tx_obj.pk,
750
- "log_key": log_key,
751
- "requested_at": timezone.now(),
752
- },
1256
+ if local_node is None:
1257
+ local_node, private_key = self._prepare_remote_credentials(request)
1258
+ if not local_node or not private_key:
1259
+ remote_unavailable = True
1260
+ continue
1261
+ success, updates = self._call_remote_action(
1262
+ request,
1263
+ local_node,
1264
+ private_key,
1265
+ charger,
1266
+ "remote-stop",
753
1267
  )
754
- stopped += 1
1268
+ if success:
1269
+ self._apply_remote_updates(charger, updates)
1270
+ stopped += 1
1271
+
755
1272
  if stopped:
756
1273
  self.message_user(
757
1274
  request,
@@ -761,56 +1278,95 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
761
1278
  @admin.action(description="Reset charger (soft)")
762
1279
  def reset_chargers(self, request, queryset):
763
1280
  reset = 0
1281
+ local_node = None
1282
+ private_key = None
1283
+ remote_unavailable = False
764
1284
  for charger in queryset:
765
- connector_value = charger.connector_id
766
- ws = store.get_connection(charger.charger_id, connector_value)
767
- if ws is None:
768
- self.message_user(
769
- request,
770
- f"{charger}: no active connection",
771
- level=messages.ERROR,
1285
+ if charger.is_local:
1286
+ connector_value = charger.connector_id
1287
+ ws = store.get_connection(charger.charger_id, connector_value)
1288
+ if ws is None:
1289
+ self.message_user(
1290
+ request,
1291
+ f"{charger}: no active connection",
1292
+ level=messages.ERROR,
1293
+ )
1294
+ continue
1295
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1296
+ if tx_obj is not None:
1297
+ self.message_user(
1298
+ request,
1299
+ (
1300
+ f"{charger}: reset skipped because a session is active; "
1301
+ "stop the session first."
1302
+ ),
1303
+ level=messages.WARNING,
1304
+ )
1305
+ continue
1306
+ message_id = uuid.uuid4().hex
1307
+ msg = json.dumps([
1308
+ 2,
1309
+ message_id,
1310
+ "Reset",
1311
+ {"type": "Soft"},
1312
+ ])
1313
+ try:
1314
+ async_to_sync(ws.send)(msg)
1315
+ except Exception as exc: # pragma: no cover - network error
1316
+ self.message_user(
1317
+ request,
1318
+ f"{charger}: failed to send Reset ({exc})",
1319
+ level=messages.ERROR,
1320
+ )
1321
+ continue
1322
+ log_key = store.identity_key(charger.charger_id, connector_value)
1323
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1324
+ store.register_pending_call(
1325
+ message_id,
1326
+ {
1327
+ "action": "Reset",
1328
+ "charger_id": charger.charger_id,
1329
+ "connector_id": connector_value,
1330
+ "log_key": log_key,
1331
+ "requested_at": timezone.now(),
1332
+ },
772
1333
  )
773
- continue
774
- tx_obj = store.get_transaction(charger.charger_id, connector_value)
775
- if tx_obj is not None:
776
- self.message_user(
777
- request,
778
- (
779
- f"{charger}: reset skipped because a session is active; "
780
- "stop the session first."
781
- ),
782
- level=messages.WARNING,
1334
+ store.schedule_call_timeout(
1335
+ message_id,
1336
+ timeout=5.0,
1337
+ action="Reset",
1338
+ log_key=log_key,
1339
+ message="Reset timed out: charger did not respond",
783
1340
  )
1341
+ reset += 1
784
1342
  continue
785
- message_id = uuid.uuid4().hex
786
- msg = json.dumps([
787
- 2,
788
- message_id,
789
- "Reset",
790
- {"type": "Soft"},
791
- ])
792
- try:
793
- async_to_sync(ws.send)(msg)
794
- except Exception as exc: # pragma: no cover - network error
1343
+
1344
+ if not charger.allow_remote:
795
1345
  self.message_user(
796
1346
  request,
797
- f"{charger}: failed to send Reset ({exc})",
1347
+ f"{charger}: remote administration is disabled.",
798
1348
  level=messages.ERROR,
799
1349
  )
800
1350
  continue
801
- log_key = store.identity_key(charger.charger_id, connector_value)
802
- store.add_log(log_key, f"< {msg}", log_type="charger")
803
- store.register_pending_call(
804
- message_id,
805
- {
806
- "action": "Reset",
807
- "charger_id": charger.charger_id,
808
- "connector_id": connector_value,
809
- "log_key": log_key,
810
- "requested_at": timezone.now(),
811
- },
1351
+ if remote_unavailable:
1352
+ continue
1353
+ if local_node is None:
1354
+ local_node, private_key = self._prepare_remote_credentials(request)
1355
+ if not local_node or not private_key:
1356
+ remote_unavailable = True
1357
+ continue
1358
+ success, updates = self._call_remote_action(
1359
+ request,
1360
+ local_node,
1361
+ private_key,
1362
+ charger,
1363
+ "reset",
1364
+ {"reset_type": "Soft"},
812
1365
  )
813
- reset += 1
1366
+ if success:
1367
+ self._apply_remote_updates(charger, updates)
1368
+ reset += 1
1369
+
814
1370
  if reset:
815
1371
  self.message_user(
816
1372
  request,
@@ -826,13 +1382,11 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
826
1382
 
827
1383
  total_kw_display.short_description = "Total kW"
828
1384
 
829
- def session_kw(self, obj):
830
- tx = store.get_transaction(obj.charger_id, obj.connector_id)
831
- if tx:
832
- return round(tx.kw, 2)
833
- return 0.0
1385
+ def today_kw(self, obj):
1386
+ start, end = self._today_range()
1387
+ return round(obj.total_kw_for_range(start, end), 2)
834
1388
 
835
- session_kw.short_description = "Session kW"
1389
+ today_kw.short_description = "Today kW"
836
1390
 
837
1391
  def changelist_view(self, request, extra_context=None):
838
1392
  response = super().changelist_view(request, extra_context=extra_context)