arthexis 0.1.22__py3-none-any.whl → 0.1.24__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,14 @@ 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.dateparse import parse_datetime
10
13
  from django.utils.html import format_html
11
14
  from django.urls import path
12
15
  from django.http import HttpResponse, HttpResponseRedirect
@@ -14,6 +17,10 @@ from django.template.response import TemplateResponse
14
17
 
15
18
  import uuid
16
19
  from asgiref.sync import async_to_sync
20
+ import requests
21
+ from requests import RequestException
22
+ from cryptography.hazmat.primitives import hashes
23
+ from cryptography.hazmat.primitives.asymmetric import padding
17
24
 
18
25
  from .models import (
19
26
  Charger,
@@ -34,6 +41,7 @@ from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
34
41
  from .views import _charger_state, _live_sessions
35
42
  from core.admin import SaveBeforeChangeAction
36
43
  from core.user_data import EntityModelAdmin
44
+ from nodes.models import Node
37
45
 
38
46
 
39
47
  class LocationAdminForm(forms.ModelForm):
@@ -208,7 +216,7 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
208
216
  @admin.register(Location)
209
217
  class LocationAdmin(EntityModelAdmin):
210
218
  form = LocationAdminForm
211
- list_display = ("name", "latitude", "longitude")
219
+ list_display = ("name", "zone", "contract_type", "latitude", "longitude")
212
220
  change_form_template = "admin/ocpp/location/change_form.html"
213
221
 
214
222
 
@@ -252,6 +260,13 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
252
260
 
253
261
  @admin.register(Charger)
254
262
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
263
+ _REMOTE_DATETIME_FIELDS = {
264
+ "availability_state_updated_at",
265
+ "availability_requested_at",
266
+ "availability_request_status_at",
267
+ "last_online_at",
268
+ }
269
+
255
270
  fieldsets = (
256
271
  (
257
272
  "General",
@@ -306,6 +321,18 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
306
321
  "Configuration",
307
322
  {"fields": ("public_display", "require_rfid", "configuration")},
308
323
  ),
324
+ (
325
+ "Network",
326
+ {
327
+ "fields": (
328
+ "node_origin",
329
+ "manager_node",
330
+ "allow_remote",
331
+ "export_transactions",
332
+ "last_online_at",
333
+ )
334
+ },
335
+ ),
309
336
  (
310
337
  "References",
311
338
  {
@@ -334,15 +361,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
334
361
  "availability_request_status_at",
335
362
  "availability_request_details",
336
363
  "configuration",
364
+ "last_online_at",
337
365
  )
338
366
  list_display = (
339
367
  "display_name_with_fallback",
340
368
  "connector_number",
341
369
  "charger_name_display",
370
+ "local_indicator",
342
371
  "require_rfid_display",
343
372
  "public_display",
344
373
  "last_heartbeat",
345
- "session_kw",
374
+ "today_kw",
346
375
  "total_kw_display",
347
376
  "page_link",
348
377
  "log_link",
@@ -364,6 +393,141 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
364
393
  "delete_selected",
365
394
  ]
366
395
 
396
+ def _prepare_remote_credentials(self, request):
397
+ local = Node.get_local()
398
+ if not local or not local.uuid:
399
+ self.message_user(
400
+ request,
401
+ "Local node is not registered; remote actions are unavailable.",
402
+ level=messages.ERROR,
403
+ )
404
+ return None, None
405
+ private_key = local.get_private_key()
406
+ if private_key is None:
407
+ self.message_user(
408
+ request,
409
+ "Local node private key is unavailable; remote actions are disabled.",
410
+ level=messages.ERROR,
411
+ )
412
+ return None, None
413
+ return local, private_key
414
+
415
+ def _call_remote_action(
416
+ self,
417
+ request,
418
+ local_node: Node,
419
+ private_key,
420
+ charger: Charger,
421
+ action: str,
422
+ extra: dict[str, Any] | None = None,
423
+ ) -> tuple[bool, dict[str, Any]]:
424
+ if not charger.node_origin:
425
+ self.message_user(
426
+ request,
427
+ f"{charger}: remote node information is missing.",
428
+ level=messages.ERROR,
429
+ )
430
+ return False, {}
431
+ origin = charger.node_origin
432
+ if not origin.address or not origin.port:
433
+ self.message_user(
434
+ request,
435
+ f"{charger}: remote node connection details are incomplete.",
436
+ level=messages.ERROR,
437
+ )
438
+ return False, {}
439
+
440
+ payload: dict[str, Any] = {
441
+ "requester": str(local_node.uuid),
442
+ "requester_mac": local_node.mac_address,
443
+ "requester_public_key": local_node.public_key,
444
+ "charger_id": charger.charger_id,
445
+ "connector_id": charger.connector_id,
446
+ "action": action,
447
+ }
448
+ if extra:
449
+ payload.update(extra)
450
+
451
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
452
+ headers = {"Content-Type": "application/json"}
453
+ try:
454
+ signature = private_key.sign(
455
+ payload_json.encode(),
456
+ padding.PKCS1v15(),
457
+ hashes.SHA256(),
458
+ )
459
+ headers["X-Signature"] = base64.b64encode(signature).decode()
460
+ except Exception:
461
+ self.message_user(
462
+ request,
463
+ "Unable to sign remote action payload; remote action aborted.",
464
+ level=messages.ERROR,
465
+ )
466
+ return False, {}
467
+
468
+ url = f"http://{origin.address}:{origin.port}/nodes/network/chargers/action/"
469
+ try:
470
+ response = requests.post(url, data=payload_json, headers=headers, timeout=5)
471
+ except RequestException as exc:
472
+ self.message_user(
473
+ request,
474
+ f"{charger}: failed to contact remote node ({exc}).",
475
+ level=messages.ERROR,
476
+ )
477
+ return False, {}
478
+
479
+ try:
480
+ data = response.json()
481
+ except ValueError:
482
+ self.message_user(
483
+ request,
484
+ f"{charger}: invalid response from remote node.",
485
+ level=messages.ERROR,
486
+ )
487
+ return False, {}
488
+
489
+ if response.status_code != 200 or data.get("status") != "ok":
490
+ detail = data.get("detail") if isinstance(data, dict) else None
491
+ if not detail:
492
+ detail = response.text or "Remote node rejected the request."
493
+ self.message_user(
494
+ request,
495
+ f"{charger}: {detail}",
496
+ level=messages.ERROR,
497
+ )
498
+ return False, {}
499
+
500
+ updates = data.get("updates", {}) if isinstance(data, dict) else {}
501
+ if not isinstance(updates, dict):
502
+ updates = {}
503
+ return True, updates
504
+
505
+ def _apply_remote_updates(self, charger: Charger, updates: dict[str, Any]) -> None:
506
+ if not updates:
507
+ return
508
+
509
+ applied: dict[str, Any] = {}
510
+ for field, value in updates.items():
511
+ if field in self._REMOTE_DATETIME_FIELDS and isinstance(value, str):
512
+ parsed = parse_datetime(value)
513
+ if parsed and timezone.is_naive(parsed):
514
+ parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
515
+ applied[field] = parsed
516
+ else:
517
+ applied[field] = value
518
+
519
+ Charger.objects.filter(pk=charger.pk).update(**applied)
520
+ for field, value in applied.items():
521
+ setattr(charger, field, value)
522
+
523
+ def get_readonly_fields(self, request, obj=None):
524
+ readonly = list(super().get_readonly_fields(request, obj))
525
+ if obj and not obj.is_local:
526
+ for field in ("allow_remote", "export_transactions"):
527
+ if field not in readonly:
528
+ readonly.append(field)
529
+ return tuple(readonly)
530
+
367
531
  def get_view_on_site_url(self, obj=None):
368
532
  return obj.get_absolute_url() if obj else None
369
533
 
@@ -462,6 +626,10 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
462
626
  return obj.location.name
463
627
  return obj.charger_id
464
628
 
629
+ @admin.display(boolean=True, description="Local")
630
+ def local_indicator(self, obj):
631
+ return obj.is_local
632
+
465
633
  def location_name(self, obj):
466
634
  return obj.location.name if obj.location else ""
467
635
 
@@ -534,51 +702,82 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
534
702
  @admin.action(description="Fetch CP configuration")
535
703
  def fetch_cp_configuration(self, request, queryset):
536
704
  fetched = 0
705
+ local_node = None
706
+ private_key = None
707
+ remote_unavailable = False
537
708
  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,
709
+ if charger.is_local:
710
+ connector_value = charger.connector_id
711
+ ws = store.get_connection(charger.charger_id, connector_value)
712
+ if ws is None:
713
+ self.message_user(
714
+ request,
715
+ f"{charger}: no active connection",
716
+ level=messages.ERROR,
717
+ )
718
+ continue
719
+ message_id = uuid.uuid4().hex
720
+ payload = {}
721
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
722
+ try:
723
+ async_to_sync(ws.send)(msg)
724
+ except Exception as exc: # pragma: no cover - network error
725
+ self.message_user(
726
+ request,
727
+ f"{charger}: failed to send GetConfiguration ({exc})",
728
+ level=messages.ERROR,
729
+ )
730
+ continue
731
+ log_key = store.identity_key(charger.charger_id, connector_value)
732
+ store.add_log(log_key, f"< {msg}", log_type="charger")
733
+ store.register_pending_call(
734
+ message_id,
735
+ {
736
+ "action": "GetConfiguration",
737
+ "charger_id": charger.charger_id,
738
+ "connector_id": connector_value,
739
+ "log_key": log_key,
740
+ "requested_at": timezone.now(),
741
+ },
545
742
  )
743
+ store.schedule_call_timeout(
744
+ message_id,
745
+ timeout=5.0,
746
+ action="GetConfiguration",
747
+ log_key=log_key,
748
+ message=(
749
+ "GetConfiguration timed out: charger did not respond"
750
+ " (operation may not be supported)"
751
+ ),
752
+ )
753
+ fetched += 1
546
754
  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
755
+
756
+ if not charger.allow_remote:
553
757
  self.message_user(
554
758
  request,
555
- f"{charger}: failed to send GetConfiguration ({exc})",
759
+ f"{charger}: remote administration is disabled.",
556
760
  level=messages.ERROR,
557
761
  )
558
762
  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
- ),
763
+ if remote_unavailable:
764
+ continue
765
+ if local_node is None:
766
+ local_node, private_key = self._prepare_remote_credentials(request)
767
+ if not local_node or not private_key:
768
+ remote_unavailable = True
769
+ continue
770
+ success, updates = self._call_remote_action(
771
+ request,
772
+ local_node,
773
+ private_key,
774
+ charger,
775
+ "get-configuration",
580
776
  )
581
- fetched += 1
777
+ if success:
778
+ self._apply_remote_updates(charger, updates)
779
+ fetched += 1
780
+
582
781
  if fetched:
583
782
  self.message_user(
584
783
  request,
@@ -589,14 +788,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
589
788
  def toggle_rfid_authentication(self, request, queryset):
590
789
  enabled = 0
591
790
  disabled = 0
791
+ local_node = None
792
+ private_key = None
793
+ remote_unavailable = False
592
794
  for charger in queryset:
593
795
  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
796
+ if charger.is_local:
797
+ Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
798
+ charger.require_rfid = new_value
799
+ if new_value:
800
+ enabled += 1
801
+ else:
802
+ disabled += 1
803
+ continue
804
+
805
+ if not charger.allow_remote:
806
+ self.message_user(
807
+ request,
808
+ f"{charger}: remote administration is disabled.",
809
+ level=messages.ERROR,
810
+ )
811
+ continue
812
+ if remote_unavailable:
813
+ continue
814
+ if local_node is None:
815
+ local_node, private_key = self._prepare_remote_credentials(request)
816
+ if not local_node or not private_key:
817
+ remote_unavailable = True
818
+ continue
819
+ success, updates = self._call_remote_action(
820
+ request,
821
+ local_node,
822
+ private_key,
823
+ charger,
824
+ "toggle-rfid",
825
+ {"enable": new_value},
826
+ )
827
+ if success:
828
+ self._apply_remote_updates(charger, updates)
829
+ if charger.require_rfid:
830
+ enabled += 1
831
+ else:
832
+ disabled += 1
833
+
600
834
  if enabled or disabled:
601
835
  changes = []
602
836
  if enabled:
@@ -611,53 +845,85 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
611
845
 
612
846
  def _dispatch_change_availability(self, request, queryset, availability_type: str):
613
847
  sent = 0
848
+ local_node = None
849
+ private_key = None
850
+ remote_unavailable = False
614
851
  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,
852
+ if charger.is_local:
853
+ connector_value = charger.connector_id
854
+ ws = store.get_connection(charger.charger_id, connector_value)
855
+ if ws is None:
856
+ self.message_user(
857
+ request,
858
+ f"{charger}: no active connection",
859
+ level=messages.ERROR,
860
+ )
861
+ continue
862
+ connector_id = connector_value if connector_value is not None else 0
863
+ message_id = uuid.uuid4().hex
864
+ payload = {"connectorId": connector_id, "type": availability_type}
865
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
866
+ try:
867
+ async_to_sync(ws.send)(msg)
868
+ except Exception as exc: # pragma: no cover - network error
869
+ self.message_user(
870
+ request,
871
+ f"{charger}: failed to send ChangeAvailability ({exc})",
872
+ level=messages.ERROR,
873
+ )
874
+ continue
875
+ log_key = store.identity_key(charger.charger_id, connector_value)
876
+ store.add_log(log_key, f"< {msg}", log_type="charger")
877
+ timestamp = timezone.now()
878
+ store.register_pending_call(
879
+ message_id,
880
+ {
881
+ "action": "ChangeAvailability",
882
+ "charger_id": charger.charger_id,
883
+ "connector_id": connector_value,
884
+ "availability_type": availability_type,
885
+ "requested_at": timestamp,
886
+ },
622
887
  )
888
+ updates = {
889
+ "availability_requested_state": availability_type,
890
+ "availability_requested_at": timestamp,
891
+ "availability_request_status": "",
892
+ "availability_request_status_at": None,
893
+ "availability_request_details": "",
894
+ }
895
+ Charger.objects.filter(pk=charger.pk).update(**updates)
896
+ for field, value in updates.items():
897
+ setattr(charger, field, value)
898
+ sent += 1
623
899
  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
900
+
901
+ if not charger.allow_remote:
631
902
  self.message_user(
632
903
  request,
633
- f"{charger}: failed to send ChangeAvailability ({exc})",
904
+ f"{charger}: remote administration is disabled.",
634
905
  level=messages.ERROR,
635
906
  )
636
907
  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
- },
908
+ if remote_unavailable:
909
+ continue
910
+ if local_node is None:
911
+ local_node, private_key = self._prepare_remote_credentials(request)
912
+ if not local_node or not private_key:
913
+ remote_unavailable = True
914
+ continue
915
+ success, updates = self._call_remote_action(
916
+ request,
917
+ local_node,
918
+ private_key,
919
+ charger,
920
+ "change-availability",
921
+ {"availability_type": availability_type},
649
922
  )
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
923
+ if success:
924
+ self._apply_remote_updates(charger, updates)
925
+ sent += 1
926
+
661
927
  if sent:
662
928
  self.message_user(
663
929
  request,
@@ -675,17 +941,49 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
675
941
  def _set_availability_state(
676
942
  self, request, queryset, availability_state: str
677
943
  ) -> None:
678
- timestamp = timezone.now()
679
944
  updated = 0
945
+ local_node = None
946
+ private_key = None
947
+ remote_unavailable = False
680
948
  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
949
+ if charger.is_local:
950
+ timestamp = timezone.now()
951
+ updates = {
952
+ "availability_state": availability_state,
953
+ "availability_state_updated_at": timestamp,
954
+ }
955
+ Charger.objects.filter(pk=charger.pk).update(**updates)
956
+ for field, value in updates.items():
957
+ setattr(charger, field, value)
958
+ updated += 1
959
+ continue
960
+
961
+ if not charger.allow_remote:
962
+ self.message_user(
963
+ request,
964
+ f"{charger}: remote administration is disabled.",
965
+ level=messages.ERROR,
966
+ )
967
+ continue
968
+ if remote_unavailable:
969
+ continue
970
+ if local_node is None:
971
+ local_node, private_key = self._prepare_remote_credentials(request)
972
+ if not local_node or not private_key:
973
+ remote_unavailable = True
974
+ continue
975
+ success, updates = self._call_remote_action(
976
+ request,
977
+ local_node,
978
+ private_key,
979
+ charger,
980
+ "set-availability-state",
981
+ {"availability_state": availability_state},
982
+ )
983
+ if success:
984
+ self._apply_remote_updates(charger, updates)
985
+ updated += 1
986
+
689
987
  if updated:
690
988
  self.message_user(
691
989
  request,
@@ -703,55 +1001,86 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
703
1001
  @admin.action(description="Remote stop active transaction")
704
1002
  def remote_stop_transaction(self, request, queryset):
705
1003
  stopped = 0
1004
+ local_node = None
1005
+ private_key = None
1006
+ remote_unavailable = False
706
1007
  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,
1008
+ if charger.is_local:
1009
+ connector_value = charger.connector_id
1010
+ ws = store.get_connection(charger.charger_id, connector_value)
1011
+ if ws is None:
1012
+ self.message_user(
1013
+ request,
1014
+ f"{charger}: no active connection",
1015
+ level=messages.ERROR,
1016
+ )
1017
+ continue
1018
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1019
+ if tx_obj is None:
1020
+ self.message_user(
1021
+ request,
1022
+ f"{charger}: no active transaction",
1023
+ level=messages.ERROR,
1024
+ )
1025
+ continue
1026
+ message_id = uuid.uuid4().hex
1027
+ payload = {"transactionId": tx_obj.pk}
1028
+ msg = json.dumps([
1029
+ 2,
1030
+ message_id,
1031
+ "RemoteStopTransaction",
1032
+ payload,
1033
+ ])
1034
+ try:
1035
+ async_to_sync(ws.send)(msg)
1036
+ except Exception as exc: # pragma: no cover - network error
1037
+ self.message_user(
1038
+ request,
1039
+ f"{charger}: failed to send RemoteStopTransaction ({exc})",
1040
+ level=messages.ERROR,
1041
+ )
1042
+ continue
1043
+ log_key = store.identity_key(charger.charger_id, connector_value)
1044
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1045
+ store.register_pending_call(
1046
+ message_id,
1047
+ {
1048
+ "action": "RemoteStopTransaction",
1049
+ "charger_id": charger.charger_id,
1050
+ "connector_id": connector_value,
1051
+ "transaction_id": tx_obj.pk,
1052
+ "log_key": log_key,
1053
+ "requested_at": timezone.now(),
1054
+ },
714
1055
  )
1056
+ stopped += 1
715
1057
  continue
716
- tx_obj = store.get_transaction(charger.charger_id, connector_value)
717
- if tx_obj is None:
1058
+
1059
+ if not charger.allow_remote:
718
1060
  self.message_user(
719
1061
  request,
720
- f"{charger}: no active transaction",
1062
+ f"{charger}: remote administration is disabled.",
721
1063
  level=messages.ERROR,
722
1064
  )
723
1065
  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
- )
1066
+ if remote_unavailable:
740
1067
  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
- },
1068
+ if local_node is None:
1069
+ local_node, private_key = self._prepare_remote_credentials(request)
1070
+ if not local_node or not private_key:
1071
+ remote_unavailable = True
1072
+ continue
1073
+ success, updates = self._call_remote_action(
1074
+ request,
1075
+ local_node,
1076
+ private_key,
1077
+ charger,
1078
+ "remote-stop",
753
1079
  )
754
- stopped += 1
1080
+ if success:
1081
+ self._apply_remote_updates(charger, updates)
1082
+ stopped += 1
1083
+
755
1084
  if stopped:
756
1085
  self.message_user(
757
1086
  request,
@@ -761,45 +1090,95 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
761
1090
  @admin.action(description="Reset charger (soft)")
762
1091
  def reset_chargers(self, request, queryset):
763
1092
  reset = 0
1093
+ local_node = None
1094
+ private_key = None
1095
+ remote_unavailable = False
764
1096
  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,
1097
+ if charger.is_local:
1098
+ connector_value = charger.connector_id
1099
+ ws = store.get_connection(charger.charger_id, connector_value)
1100
+ if ws is None:
1101
+ self.message_user(
1102
+ request,
1103
+ f"{charger}: no active connection",
1104
+ level=messages.ERROR,
1105
+ )
1106
+ continue
1107
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
1108
+ if tx_obj is not None:
1109
+ self.message_user(
1110
+ request,
1111
+ (
1112
+ f"{charger}: reset skipped because a session is active; "
1113
+ "stop the session first."
1114
+ ),
1115
+ level=messages.WARNING,
1116
+ )
1117
+ continue
1118
+ message_id = uuid.uuid4().hex
1119
+ msg = json.dumps([
1120
+ 2,
1121
+ message_id,
1122
+ "Reset",
1123
+ {"type": "Soft"},
1124
+ ])
1125
+ try:
1126
+ async_to_sync(ws.send)(msg)
1127
+ except Exception as exc: # pragma: no cover - network error
1128
+ self.message_user(
1129
+ request,
1130
+ f"{charger}: failed to send Reset ({exc})",
1131
+ level=messages.ERROR,
1132
+ )
1133
+ continue
1134
+ log_key = store.identity_key(charger.charger_id, connector_value)
1135
+ store.add_log(log_key, f"< {msg}", log_type="charger")
1136
+ store.register_pending_call(
1137
+ message_id,
1138
+ {
1139
+ "action": "Reset",
1140
+ "charger_id": charger.charger_id,
1141
+ "connector_id": connector_value,
1142
+ "log_key": log_key,
1143
+ "requested_at": timezone.now(),
1144
+ },
1145
+ )
1146
+ store.schedule_call_timeout(
1147
+ message_id,
1148
+ timeout=5.0,
1149
+ action="Reset",
1150
+ log_key=log_key,
1151
+ message="Reset timed out: charger did not respond",
772
1152
  )
1153
+ reset += 1
773
1154
  continue
774
- message_id = uuid.uuid4().hex
775
- msg = json.dumps([
776
- 2,
777
- message_id,
778
- "Reset",
779
- {"type": "Soft"},
780
- ])
781
- try:
782
- async_to_sync(ws.send)(msg)
783
- except Exception as exc: # pragma: no cover - network error
1155
+
1156
+ if not charger.allow_remote:
784
1157
  self.message_user(
785
1158
  request,
786
- f"{charger}: failed to send Reset ({exc})",
1159
+ f"{charger}: remote administration is disabled.",
787
1160
  level=messages.ERROR,
788
1161
  )
789
1162
  continue
790
- log_key = store.identity_key(charger.charger_id, connector_value)
791
- store.add_log(log_key, f"< {msg}", log_type="charger")
792
- store.register_pending_call(
793
- message_id,
794
- {
795
- "action": "Reset",
796
- "charger_id": charger.charger_id,
797
- "connector_id": connector_value,
798
- "log_key": log_key,
799
- "requested_at": timezone.now(),
800
- },
1163
+ if remote_unavailable:
1164
+ continue
1165
+ if local_node is None:
1166
+ local_node, private_key = self._prepare_remote_credentials(request)
1167
+ if not local_node or not private_key:
1168
+ remote_unavailable = True
1169
+ continue
1170
+ success, updates = self._call_remote_action(
1171
+ request,
1172
+ local_node,
1173
+ private_key,
1174
+ charger,
1175
+ "reset",
1176
+ {"reset_type": "Soft"},
801
1177
  )
802
- reset += 1
1178
+ if success:
1179
+ self._apply_remote_updates(charger, updates)
1180
+ reset += 1
1181
+
803
1182
  if reset:
804
1183
  self.message_user(
805
1184
  request,
@@ -815,13 +1194,11 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
815
1194
 
816
1195
  total_kw_display.short_description = "Total kW"
817
1196
 
818
- def session_kw(self, obj):
819
- tx = store.get_transaction(obj.charger_id, obj.connector_id)
820
- if tx:
821
- return round(tx.kw, 2)
822
- return 0.0
1197
+ def today_kw(self, obj):
1198
+ start, end = self._today_range()
1199
+ return round(obj.total_kw_for_range(start, end), 2)
823
1200
 
824
- session_kw.short_description = "Session kW"
1201
+ today_kw.short_description = "Today kW"
825
1202
 
826
1203
  def changelist_view(self, request, extra_context=None):
827
1204
  response = super().changelist_view(request, extra_context=extra_context)