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.

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.models import Charger
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:8000/nodes/info/",
782
- "visitor_register_url": "http://localhost:8000/nodes/register/",
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
@@ -21,7 +21,7 @@ def _startup_notification() -> None:
21
21
 
22
22
  host = socket.gethostname()
23
23
 
24
- port = os.environ.get("PORT", "8000")
24
+ port = os.environ.get("PORT", "8888")
25
25
 
26
26
  version = ""
27
27
  ver_path = Path(settings.BASE_DIR) / "VERSION"
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"]