arthexis 0.1.26__py3-none-any.whl → 0.1.28__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
nodes/admin.py
CHANGED
|
@@ -21,6 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
from types import SimpleNamespace
|
|
22
22
|
from urllib.parse import urlsplit, urlunsplit, quote
|
|
23
23
|
import base64
|
|
24
|
+
import binascii
|
|
24
25
|
import json
|
|
25
26
|
import subprocess
|
|
26
27
|
import uuid
|
|
@@ -33,6 +34,7 @@ from cryptography.hazmat.primitives.asymmetric import padding
|
|
|
33
34
|
from pyperclip import PyperclipException
|
|
34
35
|
from requests import RequestException
|
|
35
36
|
import websockets
|
|
37
|
+
from asgiref.sync import async_to_sync
|
|
36
38
|
|
|
37
39
|
from .classifiers import run_default_classifiers, suppress_default_classifiers
|
|
38
40
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
@@ -61,7 +63,13 @@ from .models import (
|
|
|
61
63
|
)
|
|
62
64
|
from . import dns as dns_utils
|
|
63
65
|
from core.models import RFID
|
|
64
|
-
from ocpp
|
|
66
|
+
from ocpp import store
|
|
67
|
+
from ocpp.models import (
|
|
68
|
+
Charger,
|
|
69
|
+
CPFirmware,
|
|
70
|
+
CPFirmwareDeployment,
|
|
71
|
+
DataTransferMessage,
|
|
72
|
+
)
|
|
65
73
|
from ocpp.network import serialize_charger_for_network
|
|
66
74
|
from ocpp.tasks import push_forwarded_charge_points
|
|
67
75
|
from core.user_data import EntityModelAdmin
|
|
@@ -292,6 +300,7 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
292
300
|
"register_visitor",
|
|
293
301
|
"run_task",
|
|
294
302
|
"take_screenshots",
|
|
303
|
+
"download_evcs_firmware",
|
|
295
304
|
"start_charge_point_forwarding",
|
|
296
305
|
"stop_charge_point_forwarding",
|
|
297
306
|
"import_rfids_from_selected",
|
|
@@ -325,6 +334,26 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
325
334
|
cleaned["body"] = body
|
|
326
335
|
return cleaned
|
|
327
336
|
|
|
337
|
+
class DownloadFirmwareForm(forms.Form):
|
|
338
|
+
def __init__(self, node: Node, *args, **kwargs):
|
|
339
|
+
super().__init__(*args, **kwargs)
|
|
340
|
+
base_queryset = Charger.objects.filter(
|
|
341
|
+
node_origin=node, connector_id__isnull=True
|
|
342
|
+
).order_by("display_name", "charger_id")
|
|
343
|
+
self.fields["charger"].queryset = base_queryset
|
|
344
|
+
|
|
345
|
+
charger = forms.ModelChoiceField(
|
|
346
|
+
label=_("Charge point"),
|
|
347
|
+
queryset=Charger.objects.none(),
|
|
348
|
+
help_text=_("Select the EVCS to request firmware from."),
|
|
349
|
+
)
|
|
350
|
+
vendor_id = forms.CharField(
|
|
351
|
+
label=_("Vendor ID"),
|
|
352
|
+
max_length=255,
|
|
353
|
+
initial="org.openchargealliance.firmware",
|
|
354
|
+
help_text=_("Vendor identifier included in the DataTransfer request."),
|
|
355
|
+
)
|
|
356
|
+
|
|
328
357
|
@admin.display(description=_("Relation"), ordering="current_relation")
|
|
329
358
|
def relation(self, obj):
|
|
330
359
|
return obj.get_current_relation_display()
|
|
@@ -519,6 +548,322 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
519
548
|
request, "admin/nodes/node/send_net_message.html", context
|
|
520
549
|
)
|
|
521
550
|
|
|
551
|
+
def _coerce_metadata_value(self, value):
|
|
552
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
553
|
+
return value
|
|
554
|
+
if isinstance(value, (bytes, bytearray)):
|
|
555
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
556
|
+
if isinstance(value, Mapping):
|
|
557
|
+
return {k: self._coerce_metadata_value(v) for k, v in value.items()}
|
|
558
|
+
if isinstance(value, (list, tuple, set)):
|
|
559
|
+
return [self._coerce_metadata_value(v) for v in value]
|
|
560
|
+
return str(value)
|
|
561
|
+
|
|
562
|
+
def _decode_payload_bytes(self, value, encoding_hint: str = ""):
|
|
563
|
+
if isinstance(value, (bytes, bytearray)):
|
|
564
|
+
return bytes(value), encoding_hint or "binary"
|
|
565
|
+
if not isinstance(value, str):
|
|
566
|
+
return None, encoding_hint
|
|
567
|
+
text = value.strip()
|
|
568
|
+
if not text:
|
|
569
|
+
return b"", encoding_hint or "binary"
|
|
570
|
+
try:
|
|
571
|
+
decoded = base64.b64decode(text, validate=True)
|
|
572
|
+
return decoded, "base64"
|
|
573
|
+
except (binascii.Error, ValueError):
|
|
574
|
+
return None, encoding_hint
|
|
575
|
+
|
|
576
|
+
def _extract_firmware_payload(self, data):
|
|
577
|
+
content_type = "application/octet-stream"
|
|
578
|
+
encoding = ""
|
|
579
|
+
filename = ""
|
|
580
|
+
json_payload = None
|
|
581
|
+
binary_payload = None
|
|
582
|
+
metadata: dict[str, object] = {}
|
|
583
|
+
|
|
584
|
+
if isinstance(data, Mapping):
|
|
585
|
+
metadata = {
|
|
586
|
+
key: self._coerce_metadata_value(value)
|
|
587
|
+
for key, value in data.items()
|
|
588
|
+
if key not in {"payload", "data", "json"}
|
|
589
|
+
}
|
|
590
|
+
filename = str(data.get("filename") or data.get("name") or "").strip()
|
|
591
|
+
if data.get("contentType"):
|
|
592
|
+
content_type_candidate = str(data.get("contentType")).strip()
|
|
593
|
+
if content_type_candidate:
|
|
594
|
+
content_type = content_type_candidate
|
|
595
|
+
encoding = str(data.get("encoding") or "").strip()
|
|
596
|
+
raw_payload = data.get("payload")
|
|
597
|
+
if raw_payload is None:
|
|
598
|
+
raw_payload = data.get("data")
|
|
599
|
+
if raw_payload is not None:
|
|
600
|
+
binary_payload, encoding = self._decode_payload_bytes(
|
|
601
|
+
raw_payload, encoding
|
|
602
|
+
)
|
|
603
|
+
json_candidate = data.get("json")
|
|
604
|
+
if json_candidate is not None:
|
|
605
|
+
if isinstance(json_candidate, str):
|
|
606
|
+
try:
|
|
607
|
+
json_payload = json.loads(json_candidate)
|
|
608
|
+
except json.JSONDecodeError:
|
|
609
|
+
metadata["json_raw"] = json_candidate
|
|
610
|
+
else:
|
|
611
|
+
json_payload = json_candidate
|
|
612
|
+
if json_payload is None and binary_payload is None:
|
|
613
|
+
remaining = {
|
|
614
|
+
key: value
|
|
615
|
+
for key, value in data.items()
|
|
616
|
+
if key
|
|
617
|
+
not in {
|
|
618
|
+
"payload",
|
|
619
|
+
"data",
|
|
620
|
+
"encoding",
|
|
621
|
+
"contentType",
|
|
622
|
+
"filename",
|
|
623
|
+
"json",
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if remaining:
|
|
627
|
+
json_payload = remaining
|
|
628
|
+
elif isinstance(data, (bytes, bytearray)):
|
|
629
|
+
binary_payload = bytes(data)
|
|
630
|
+
encoding = encoding or "binary"
|
|
631
|
+
elif isinstance(data, str):
|
|
632
|
+
metadata = {"raw": data}
|
|
633
|
+
binary_payload, encoding = self._decode_payload_bytes(data, encoding)
|
|
634
|
+
if binary_payload is None:
|
|
635
|
+
try:
|
|
636
|
+
json_payload = json.loads(data)
|
|
637
|
+
except json.JSONDecodeError:
|
|
638
|
+
binary_payload = data.encode("utf-8")
|
|
639
|
+
encoding = encoding or "utf-8"
|
|
640
|
+
elif data is not None:
|
|
641
|
+
metadata = {"raw": self._coerce_metadata_value(data)}
|
|
642
|
+
json_payload = metadata.get("raw")
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
"binary": binary_payload,
|
|
646
|
+
"json": json_payload,
|
|
647
|
+
"encoding": encoding,
|
|
648
|
+
"content_type": content_type,
|
|
649
|
+
"filename": filename,
|
|
650
|
+
"metadata": metadata,
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
def _format_pending_failure(self, action: str, result: Mapping) -> str:
|
|
654
|
+
label_map = {
|
|
655
|
+
"DataTransfer": _("Data transfer"),
|
|
656
|
+
"UpdateFirmware": _("Update firmware"),
|
|
657
|
+
}
|
|
658
|
+
action_label = label_map.get(action, action)
|
|
659
|
+
error_code = str(result.get("error_code") or "").strip()
|
|
660
|
+
error_description = str(result.get("error_description") or "").strip()
|
|
661
|
+
details = result.get("error_details")
|
|
662
|
+
parts: list[str] = []
|
|
663
|
+
if error_code:
|
|
664
|
+
parts.append(_("code=%(code)s") % {"code": error_code})
|
|
665
|
+
if error_description:
|
|
666
|
+
parts.append(
|
|
667
|
+
_("description=%(description)s")
|
|
668
|
+
% {"description": error_description}
|
|
669
|
+
)
|
|
670
|
+
if details:
|
|
671
|
+
try:
|
|
672
|
+
details_text = json.dumps(
|
|
673
|
+
details, sort_keys=True, ensure_ascii=False
|
|
674
|
+
)
|
|
675
|
+
except TypeError:
|
|
676
|
+
details_text = str(details)
|
|
677
|
+
if details_text:
|
|
678
|
+
parts.append(_("details=%(details)s") % {"details": details_text})
|
|
679
|
+
if parts:
|
|
680
|
+
return _("%(action)s failed: %(details)s") % {
|
|
681
|
+
"action": action_label,
|
|
682
|
+
"details": ", ".join(parts),
|
|
683
|
+
}
|
|
684
|
+
return _("%(action)s failed.") % {"action": action_label}
|
|
685
|
+
|
|
686
|
+
def _process_firmware_download(self, request, node: Node, cleaned_data) -> bool:
|
|
687
|
+
charger: Charger = cleaned_data["charger"]
|
|
688
|
+
vendor_id = cleaned_data.get("vendor_id", "")
|
|
689
|
+
connection = store.get_connection(charger.charger_id, charger.connector_id)
|
|
690
|
+
if connection is None:
|
|
691
|
+
self.message_user(
|
|
692
|
+
request,
|
|
693
|
+
_("%(charger)s is not currently connected to the platform.")
|
|
694
|
+
% {"charger": charger},
|
|
695
|
+
level=messages.ERROR,
|
|
696
|
+
)
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
message_id = uuid.uuid4().hex
|
|
700
|
+
payload = {
|
|
701
|
+
"vendorId": vendor_id,
|
|
702
|
+
"messageId": "DownloadFirmware",
|
|
703
|
+
}
|
|
704
|
+
log_key = store.identity_key(charger.charger_id, charger.connector_id)
|
|
705
|
+
message_record = DataTransferMessage.objects.create(
|
|
706
|
+
charger=charger,
|
|
707
|
+
connector_id=charger.connector_id,
|
|
708
|
+
direction=DataTransferMessage.DIRECTION_CSMS_TO_CP,
|
|
709
|
+
ocpp_message_id=message_id,
|
|
710
|
+
vendor_id=vendor_id,
|
|
711
|
+
message_id="DownloadFirmware",
|
|
712
|
+
payload=payload,
|
|
713
|
+
status="Pending",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
frame = json.dumps([2, message_id, "DataTransfer", payload])
|
|
717
|
+
async_to_sync(connection.send)(frame)
|
|
718
|
+
store.add_log(
|
|
719
|
+
log_key,
|
|
720
|
+
_("Requested firmware download via DataTransfer."),
|
|
721
|
+
log_type="charger",
|
|
722
|
+
)
|
|
723
|
+
store.register_pending_call(
|
|
724
|
+
message_id,
|
|
725
|
+
{
|
|
726
|
+
"action": "DataTransfer",
|
|
727
|
+
"charger_id": charger.charger_id,
|
|
728
|
+
"connector_id": charger.connector_id,
|
|
729
|
+
"log_key": log_key,
|
|
730
|
+
"message_pk": message_record.pk,
|
|
731
|
+
},
|
|
732
|
+
)
|
|
733
|
+
store.schedule_call_timeout(
|
|
734
|
+
message_id, action="DataTransfer", log_key=log_key
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
result = store.wait_for_pending_call(message_id, timeout=15.0)
|
|
738
|
+
if result is None:
|
|
739
|
+
self.message_user(
|
|
740
|
+
request,
|
|
741
|
+
_("The charge point did not respond to the firmware request."),
|
|
742
|
+
level=messages.ERROR,
|
|
743
|
+
)
|
|
744
|
+
return False
|
|
745
|
+
if not result.get("success", True):
|
|
746
|
+
detail = self._format_pending_failure("DataTransfer", result)
|
|
747
|
+
self.message_user(request, detail, level=messages.ERROR)
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
payload_data = result.get("payload") or {}
|
|
751
|
+
status_value = str(payload_data.get("status") or "").strip()
|
|
752
|
+
if status_value.lower() != "accepted":
|
|
753
|
+
self.message_user(
|
|
754
|
+
request,
|
|
755
|
+
_(
|
|
756
|
+
"Firmware request for %(charger)s was %(status)s."
|
|
757
|
+
)
|
|
758
|
+
% {"charger": charger, "status": status_value or "Rejected"},
|
|
759
|
+
level=messages.ERROR,
|
|
760
|
+
)
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
data_section = payload_data.get("data")
|
|
764
|
+
extracted = self._extract_firmware_payload(data_section)
|
|
765
|
+
binary_payload = extracted["binary"]
|
|
766
|
+
json_payload = extracted["json"]
|
|
767
|
+
if binary_payload is None and json_payload is None:
|
|
768
|
+
self.message_user(
|
|
769
|
+
request,
|
|
770
|
+
_("The charge point did not include a firmware payload."),
|
|
771
|
+
level=messages.ERROR,
|
|
772
|
+
)
|
|
773
|
+
return False
|
|
774
|
+
|
|
775
|
+
now = timezone.now()
|
|
776
|
+
filename = extracted["filename"] or ""
|
|
777
|
+
if not filename:
|
|
778
|
+
suffix = ".bin" if binary_payload is not None else ".json"
|
|
779
|
+
filename = f"{charger.charger_id}_{now:%Y%m%d%H%M%S}{suffix}"
|
|
780
|
+
|
|
781
|
+
metadata = {
|
|
782
|
+
"vendor_id": vendor_id,
|
|
783
|
+
"response": self._coerce_metadata_value(payload_data),
|
|
784
|
+
}
|
|
785
|
+
metadata.update(extracted["metadata"])
|
|
786
|
+
|
|
787
|
+
firmware = CPFirmware(
|
|
788
|
+
name=f"{charger.charger_id} firmware {now:%Y-%m-%d %H:%M:%S}",
|
|
789
|
+
source=CPFirmware.Source.DOWNLOAD,
|
|
790
|
+
source_node=node,
|
|
791
|
+
source_charger=charger,
|
|
792
|
+
filename=filename,
|
|
793
|
+
payload_binary=binary_payload,
|
|
794
|
+
payload_json=json_payload,
|
|
795
|
+
payload_encoding=extracted["encoding"],
|
|
796
|
+
content_type=extracted["content_type"],
|
|
797
|
+
metadata=metadata,
|
|
798
|
+
download_vendor_id=vendor_id,
|
|
799
|
+
download_message_id=message_id,
|
|
800
|
+
downloaded_at=now,
|
|
801
|
+
is_user_data=True,
|
|
802
|
+
)
|
|
803
|
+
firmware.save()
|
|
804
|
+
|
|
805
|
+
self.message_user(
|
|
806
|
+
request,
|
|
807
|
+
_("Stored firmware from %(charger)s as %(firmware)s.")
|
|
808
|
+
% {"charger": charger, "firmware": firmware},
|
|
809
|
+
level=messages.SUCCESS,
|
|
810
|
+
)
|
|
811
|
+
return True
|
|
812
|
+
|
|
813
|
+
@admin.action(description=_("Download EVCS firmware"))
|
|
814
|
+
def download_evcs_firmware(self, request, queryset):
|
|
815
|
+
nodes = list(queryset)
|
|
816
|
+
if len(nodes) != 1:
|
|
817
|
+
self.message_user(
|
|
818
|
+
request,
|
|
819
|
+
_("Select a single node to request firmware."),
|
|
820
|
+
level=messages.ERROR,
|
|
821
|
+
)
|
|
822
|
+
return None
|
|
823
|
+
node = nodes[0]
|
|
824
|
+
|
|
825
|
+
if "apply" in request.POST:
|
|
826
|
+
form = self.DownloadFirmwareForm(node, request.POST)
|
|
827
|
+
if form.is_valid():
|
|
828
|
+
if self._process_firmware_download(request, node, form.cleaned_data):
|
|
829
|
+
return None
|
|
830
|
+
else:
|
|
831
|
+
form = self.DownloadFirmwareForm(node)
|
|
832
|
+
|
|
833
|
+
context = {
|
|
834
|
+
**self.admin_site.each_context(request),
|
|
835
|
+
"opts": self.model._meta,
|
|
836
|
+
"title": _("Download EVCS firmware"),
|
|
837
|
+
"node": node,
|
|
838
|
+
"nodes": [node],
|
|
839
|
+
"selected_ids": [str(node.pk)],
|
|
840
|
+
"action_name": request.POST.get(
|
|
841
|
+
"action", "download_evcs_firmware"
|
|
842
|
+
),
|
|
843
|
+
"select_across": request.POST.get("select_across", "0"),
|
|
844
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
845
|
+
"adminform": helpers.AdminForm(
|
|
846
|
+
form,
|
|
847
|
+
[
|
|
848
|
+
(
|
|
849
|
+
None,
|
|
850
|
+
{
|
|
851
|
+
"fields": (
|
|
852
|
+
"charger",
|
|
853
|
+
"vendor_id",
|
|
854
|
+
)
|
|
855
|
+
},
|
|
856
|
+
)
|
|
857
|
+
],
|
|
858
|
+
{},
|
|
859
|
+
),
|
|
860
|
+
"form": form,
|
|
861
|
+
"media": self.media + form.media,
|
|
862
|
+
}
|
|
863
|
+
return TemplateResponse(
|
|
864
|
+
request, "admin/nodes/node/download_firmware.html", context
|
|
865
|
+
)
|
|
866
|
+
|
|
522
867
|
def update_selected_progress(self, request):
|
|
523
868
|
if request.method != "POST":
|
|
524
869
|
return JsonResponse({"detail": "POST required"}, status=405)
|
|
@@ -778,8 +1123,8 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
778
1123
|
"token": token,
|
|
779
1124
|
"info_url": reverse("node-info"),
|
|
780
1125
|
"register_url": reverse("register-node"),
|
|
781
|
-
"visitor_info_url": "http://localhost:
|
|
782
|
-
"visitor_register_url": "http://localhost:
|
|
1126
|
+
"visitor_info_url": "http://localhost:8888/nodes/info/",
|
|
1127
|
+
"visitor_register_url": "http://localhost:8888/nodes/register/",
|
|
783
1128
|
}
|
|
784
1129
|
return render(request, "admin/nodes/node/register_visitor.html", context)
|
|
785
1130
|
|
nodes/apps.py
CHANGED
nodes/feature_checks.py
CHANGED
|
@@ -91,6 +91,36 @@ class FeatureCheckRegistry:
|
|
|
91
91
|
feature_checks = FeatureCheckRegistry()
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
@feature_checks.register("audio-capture")
|
|
95
|
+
def _check_audio_capture(feature: "NodeFeature", node: Optional["Node"]):
|
|
96
|
+
from .models import Node
|
|
97
|
+
|
|
98
|
+
target: Optional["Node"] = node or Node.get_local()
|
|
99
|
+
if target is None:
|
|
100
|
+
return FeatureCheckResult(
|
|
101
|
+
False,
|
|
102
|
+
f"No local node is registered; cannot verify {feature.display}.",
|
|
103
|
+
messages.WARNING,
|
|
104
|
+
)
|
|
105
|
+
if not Node._has_audio_capture_device():
|
|
106
|
+
return FeatureCheckResult(
|
|
107
|
+
False,
|
|
108
|
+
f"No audio recording device detected on {target.hostname} for {feature.display}.",
|
|
109
|
+
messages.WARNING,
|
|
110
|
+
)
|
|
111
|
+
if not target.has_feature("audio-capture"):
|
|
112
|
+
return FeatureCheckResult(
|
|
113
|
+
False,
|
|
114
|
+
f"{feature.display} is not enabled on {target.hostname}.",
|
|
115
|
+
messages.WARNING,
|
|
116
|
+
)
|
|
117
|
+
return FeatureCheckResult(
|
|
118
|
+
True,
|
|
119
|
+
f"{feature.display} is enabled on {target.hostname} and a recording device is available.",
|
|
120
|
+
messages.SUCCESS,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
94
124
|
@feature_checks.register_default
|
|
95
125
|
def _default_feature_check(
|
|
96
126
|
feature: "NodeFeature", node: Optional["Node"]
|