arthexis 0.1.23__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.
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/METADATA +5 -5
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/RECORD +17 -17
- config/settings.py +4 -0
- core/admin.py +139 -27
- core/models.py +543 -204
- core/tasks.py +25 -0
- nodes/admin.py +152 -172
- nodes/tests.py +80 -129
- nodes/urls.py +6 -0
- nodes/views.py +520 -0
- ocpp/admin.py +541 -175
- ocpp/models.py +28 -0
- ocpp/tasks.py +336 -1
- pages/views.py +60 -30
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/WHEEL +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.23.dist-info → arthexis-0.1.24.dist-info}/top_level.txt +0 -0
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):
|
|
@@ -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
|
-
"
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
+
},
|
|
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
|
+
),
|
|
545
752
|
)
|
|
753
|
+
fetched += 1
|
|
546
754
|
continue
|
|
547
|
-
|
|
548
|
-
|
|
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}:
|
|
759
|
+
f"{charger}: remote administration is disabled.",
|
|
556
760
|
level=messages.ERROR,
|
|
557
761
|
)
|
|
558
762
|
continue
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
625
|
-
|
|
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}:
|
|
904
|
+
f"{charger}: remote administration is disabled.",
|
|
634
905
|
level=messages.ERROR,
|
|
635
906
|
)
|
|
636
907
|
continue
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
717
|
-
if
|
|
1058
|
+
|
|
1059
|
+
if not charger.allow_remote:
|
|
718
1060
|
self.message_user(
|
|
719
1061
|
request,
|
|
720
|
-
f"{charger}:
|
|
1062
|
+
f"{charger}: remote administration is disabled.",
|
|
721
1063
|
level=messages.ERROR,
|
|
722
1064
|
)
|
|
723
1065
|
continue
|
|
724
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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,56 +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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
+
},
|
|
772
1145
|
)
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
f"{charger}: reset skipped because a session is active; "
|
|
780
|
-
"stop the session first."
|
|
781
|
-
),
|
|
782
|
-
level=messages.WARNING,
|
|
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",
|
|
783
1152
|
)
|
|
1153
|
+
reset += 1
|
|
784
1154
|
continue
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
1155
|
+
|
|
1156
|
+
if not charger.allow_remote:
|
|
795
1157
|
self.message_user(
|
|
796
1158
|
request,
|
|
797
|
-
f"{charger}:
|
|
1159
|
+
f"{charger}: remote administration is disabled.",
|
|
798
1160
|
level=messages.ERROR,
|
|
799
1161
|
)
|
|
800
1162
|
continue
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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"},
|
|
812
1177
|
)
|
|
813
|
-
|
|
1178
|
+
if success:
|
|
1179
|
+
self._apply_remote_updates(charger, updates)
|
|
1180
|
+
reset += 1
|
|
1181
|
+
|
|
814
1182
|
if reset:
|
|
815
1183
|
self.message_user(
|
|
816
1184
|
request,
|
|
@@ -826,13 +1194,11 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
826
1194
|
|
|
827
1195
|
total_kw_display.short_description = "Total kW"
|
|
828
1196
|
|
|
829
|
-
def
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
return round(tx.kw, 2)
|
|
833
|
-
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)
|
|
834
1200
|
|
|
835
|
-
|
|
1201
|
+
today_kw.short_description = "Today kW"
|
|
836
1202
|
|
|
837
1203
|
def changelist_view(self, request, extra_context=None):
|
|
838
1204
|
response = super().changelist_view(request, extra_context=extra_context)
|