From f717f28bc27bcc43ac3042526ac1546376c91d6d Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:53:54 +0100 Subject: [PATCH 01/17] Merge master into Release (#2050) * Merge master into Beta (#2033) * build UI (#2009) * Update for Polestar auth change:client id and optional acceptance of terms and conditions * add terser * Added const for client_id * Added blank line for Flake check * fix heartbeat internal chargepoint (#2013) * Build Display Theme: Cards * Wiki (#2014) * Wiki * typos Wiki * clear browser console at midnight * Build Display Theme: Cards * fix SolarEdge synergy units (#2026) * reset boot_done before shutdown (#2027) * fix solaredge synergy units (#2030) * build * Satellit: Fix telnet (#2032) * Update version 2.1.6-RC.2 --------- Co-authored-by: PK Co-authored-by: Lutz Bender Co-authored-by: benderl * fix disable after unplugging (#2043) * fix disable after unplugging * Wiki * fix * Update version 2.1.6-Patch.1 --------- Co-authored-by: benderl Co-authored-by: Lutz Bender Co-authored-by: PK --- docs/Identifikation.md | 2 +- packages/control/chargepoint/chargepoint.py | 20 +++++++++++++------- packages/helpermodules/setdata.py | 8 +++----- web/version | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/Identifikation.md b/docs/Identifikation.md index 48054d538d..2fcb688871 100644 --- a/docs/Identifikation.md +++ b/docs/Identifikation.md @@ -1,4 +1,4 @@ -Die openWB bietet die Möglichkeit, den Ladepunkt vor dem Laden zu entsperren und/oder das Fahrzeug zuzuordnen, welches geladen wird. Es gibt zwei grundlegende Konzepte, die für sich oder in Kombination genutzt werden können: Das Entsperren eines Ladepunkts und das Zuordnen eines Fahrzeugs. +Mit den verschiedenen Identifikations-Möglichkeiten kannst du die openWB grundsätzlich vor unbefugtem Laden schützen oder fahrzeugbasierte Funktionen nutzen. Es gibt zwei grundlegende Konzepte: Das Entsperren eines Ladepunkts und das Zuordnen eines Fahrzeugs. Die Identifikation erfolgt über diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 15ad1bf812..5c1c1f955e 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -164,16 +164,21 @@ def _is_autolock_inactive(self) -> Tuple[bool, Optional[str]]: return state, message def _is_manual_lock_inactive(self) -> Tuple[bool, Optional[str]]: - if (self.data.set.manual_lock is False or - (self.data.get.rfid or - self.data.get.vehicle_id or - self.data.set.rfid) in self.template.data.valid_tags): + # Die Pro schickt je nach Timing auch nach Abstecken noch ein paar Zyklen den Tag. Dann darf der Ladepunkt + # nicht wieder entsperrt werden. + if (self.data.get.rfid or + self.data.get.vehicle_id or + self.data.set.rfid) in self.template.data.valid_tags: Pub().pub(f"openWB/set/chargepoint/{self.num}/set/manual_lock", False) - charging_possible = True - message = None - else: + elif self.template.data.disable_after_unplug and self.data.get.plug_state is False: + Pub().pub(f"openWB/set/chargepoint/{self.num}/set/manual_lock", True) + + if self.data.set.manual_lock: charging_possible = False message = "Keine Ladung, da der Ladepunkt gesperrt ist." + else: + charging_possible = True + message = None return charging_possible, message def _is_ev_plugged(self) -> Tuple[bool, Optional[str]]: @@ -231,6 +236,7 @@ def _process_charge_stop(self) -> None: if self.template.data.disable_after_unplug: self.data.set.manual_lock = True Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/manual_lock", True) + log.debug("/set/manual_lock True") # Ev wurde noch nicht aktualisiert. chargelog.save_and_reset_data(self, data.data.ev_data["ev"+str(self.data.set.charging_ev_prev)]) self.data.set.charging_ev_prev = -1 diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index ee9831b709..8dc6292998 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -623,6 +623,9 @@ def process_chargepoint_get_topics(self, msg): self._validate_value(msg, int, [(0, 2)]) elif "/get/evse_current" in msg.topic: self._validate_value(msg, float, [(0, 0), (6, 32), (600, 3200)]) + elif ("/get/error_timestamp" in msg.topic or + "/get/rfid_timestamp" in msg.topic): + self._validate_value(msg, float) elif ("/get/fault_str" in msg.topic or "/get/state_str" in msg.topic or "/get/heartbeat" in msg.topic or @@ -630,13 +633,8 @@ def process_chargepoint_get_topics(self, msg): "/get/vehicle_id" in msg.topic or "/get/serial_number" in msg.topic): self._validate_value(msg, str) - elif ("/get/error_timestamp" in msg.topic or - "/get/rfid_timestamp" in msg.topic): - self._validate_value(msg, float) elif ("/get/soc" in msg.topic): self._validate_value(msg, float, [(0, 100)]) - elif "/get/rfid_timestamp" in msg.topic: - self._validate_value(msg, float) elif "/get/simulation" in msg.topic: self._validate_value(msg, "json") else: diff --git a/web/version b/web/version index 399088bf46..052a820e50 100644 --- a/web/version +++ b/web/version @@ -1 +1 @@ -2.1.6 +2.1.6-Patch.1 From d1ccee657a530dac6b424ddb86b670379a1251d4 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:52:34 +0100 Subject: [PATCH 02/17] Revert "Merge master into Release (#2050)" This reverts commit f717f28bc27bcc43ac3042526ac1546376c91d6d. --- docs/Identifikation.md | 2 +- packages/control/chargepoint/chargepoint.py | 20 +++++++------------- packages/helpermodules/setdata.py | 8 +++++--- web/version | 2 +- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/docs/Identifikation.md b/docs/Identifikation.md index 2fcb688871..48054d538d 100644 --- a/docs/Identifikation.md +++ b/docs/Identifikation.md @@ -1,4 +1,4 @@ -Mit den verschiedenen Identifikations-Möglichkeiten kannst du die openWB grundsätzlich vor unbefugtem Laden schützen oder fahrzeugbasierte Funktionen nutzen. Es gibt zwei grundlegende Konzepte: Das Entsperren eines Ladepunkts und das Zuordnen eines Fahrzeugs. +Die openWB bietet die Möglichkeit, den Ladepunkt vor dem Laden zu entsperren und/oder das Fahrzeug zuzuordnen, welches geladen wird. Es gibt zwei grundlegende Konzepte, die für sich oder in Kombination genutzt werden können: Das Entsperren eines Ladepunkts und das Zuordnen eines Fahrzeugs. Die Identifikation erfolgt über diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 5c1c1f955e..15ad1bf812 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -164,21 +164,16 @@ def _is_autolock_inactive(self) -> Tuple[bool, Optional[str]]: return state, message def _is_manual_lock_inactive(self) -> Tuple[bool, Optional[str]]: - # Die Pro schickt je nach Timing auch nach Abstecken noch ein paar Zyklen den Tag. Dann darf der Ladepunkt - # nicht wieder entsperrt werden. - if (self.data.get.rfid or - self.data.get.vehicle_id or - self.data.set.rfid) in self.template.data.valid_tags: + if (self.data.set.manual_lock is False or + (self.data.get.rfid or + self.data.get.vehicle_id or + self.data.set.rfid) in self.template.data.valid_tags): Pub().pub(f"openWB/set/chargepoint/{self.num}/set/manual_lock", False) - elif self.template.data.disable_after_unplug and self.data.get.plug_state is False: - Pub().pub(f"openWB/set/chargepoint/{self.num}/set/manual_lock", True) - - if self.data.set.manual_lock: - charging_possible = False - message = "Keine Ladung, da der Ladepunkt gesperrt ist." - else: charging_possible = True message = None + else: + charging_possible = False + message = "Keine Ladung, da der Ladepunkt gesperrt ist." return charging_possible, message def _is_ev_plugged(self) -> Tuple[bool, Optional[str]]: @@ -236,7 +231,6 @@ def _process_charge_stop(self) -> None: if self.template.data.disable_after_unplug: self.data.set.manual_lock = True Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/manual_lock", True) - log.debug("/set/manual_lock True") # Ev wurde noch nicht aktualisiert. chargelog.save_and_reset_data(self, data.data.ev_data["ev"+str(self.data.set.charging_ev_prev)]) self.data.set.charging_ev_prev = -1 diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 8dc6292998..ee9831b709 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -623,9 +623,6 @@ def process_chargepoint_get_topics(self, msg): self._validate_value(msg, int, [(0, 2)]) elif "/get/evse_current" in msg.topic: self._validate_value(msg, float, [(0, 0), (6, 32), (600, 3200)]) - elif ("/get/error_timestamp" in msg.topic or - "/get/rfid_timestamp" in msg.topic): - self._validate_value(msg, float) elif ("/get/fault_str" in msg.topic or "/get/state_str" in msg.topic or "/get/heartbeat" in msg.topic or @@ -633,8 +630,13 @@ def process_chargepoint_get_topics(self, msg): "/get/vehicle_id" in msg.topic or "/get/serial_number" in msg.topic): self._validate_value(msg, str) + elif ("/get/error_timestamp" in msg.topic or + "/get/rfid_timestamp" in msg.topic): + self._validate_value(msg, float) elif ("/get/soc" in msg.topic): self._validate_value(msg, float, [(0, 100)]) + elif "/get/rfid_timestamp" in msg.topic: + self._validate_value(msg, float) elif "/get/simulation" in msg.topic: self._validate_value(msg, "json") else: diff --git a/web/version b/web/version index 052a820e50..399088bf46 100644 --- a/web/version +++ b/web/version @@ -1 +1 @@ -2.1.6-Patch.1 +2.1.6 From 8a8cefdb1235b974fc80b42b4737b6d77b0de3b0 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 16 May 2025 13:25:43 +0200 Subject: [PATCH 03/17] pro+: fix network setup (#2405) --- runs/setup_network.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runs/setup_network.sh b/runs/setup_network.sh index f3da7a03b3..cc45f0d98d 100755 --- a/runs/setup_network.sh +++ b/runs/setup_network.sh @@ -100,6 +100,8 @@ function setup_dhcpcd_proplus() { echo "done" echo "restarting dhcpcd" sudo systemctl restart dhcpcd + sleep 5 + sudo dhclient -1 eth0 fi } @@ -113,6 +115,8 @@ function disable_dhcpcd_proplus() { sudo sed -i "/$pattern_begin/,/$pattern_end/d" "$dhcpcd_config_target" echo "restarting dhcpcd" sudo systemctl restart dhcpcd + sleep 5 + sudo dhclient -1 eth0 else echo "no changes required" fi From cf7d39d449517384520a36db61367ca8b38f95b7 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Wed, 16 Apr 2025 12:11:37 +0200 Subject: [PATCH 04/17] update ubuntu version to latest in github action (#2337) --- .github/workflows/github-actions-python.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-python.yml b/.github/workflows/github-actions-python.yml index cff37b6e33..e942a8e893 100644 --- a/.github/workflows/github-actions-python.yml +++ b/.github/workflows/github-actions-python.yml @@ -4,13 +4,13 @@ on: pull_request jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: "3.9.2" + python-version: "3.9.12" - name: Install dependencies run: | pip3 install -r "/home/runner/work/core/core/requirements.txt" From 503b4754ff5cc5199f4a6319476274b9fdad2e83 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 16 May 2025 13:33:39 +0200 Subject: [PATCH 05/17] fix keep cloud config on startup (#2406) --- packages/helpermodules/subdata.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index f834771322..9cd2dc13fa 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -844,9 +844,11 @@ def process_system_topic(self, client: mqtt.Client, var: dict, msg: mqtt.MQTTMes self.set_json_payload(var["system"].data["backup_cloud"], msg) elif ("openWB/system/dataprotection_acknowledged" == msg.topic and decode_payload(msg.payload) is False): - Pub().pub("openWB/set/command/removeCloudBridge/todo", { - "command": "removeCloudBridge" - }) + if self.event_subdata_initialized.is_set(): + Pub().pub("openWB/set/command/removeCloudBridge/todo", + {"command": "removeCloudBridge"}) + else: + log.debug("skipping data protection message on startup") else: if "module_update_completed" in msg.topic: self.event_module_update_completed.set() From 87108ed9cb257ef5b4b4b657a9716947d6441a65 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 16 May 2025 13:34:14 +0200 Subject: [PATCH 06/17] Update version 2.1.7-Patch.2 --- web/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/version b/web/version index 7b149f70d5..b5c0842f5a 100644 --- a/web/version +++ b/web/version @@ -1 +1 @@ -2.1.7-Patch.1 +2.1.7-Patch.2 From 87ffd48f173d6f1c21c01956b45298e9d1c91ae2 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Wed, 28 May 2025 12:00:01 +0200 Subject: [PATCH 07/17] Pro+: fix soc and mac (#2424) --- packages/modules/common/store/_chargepoint_internal.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/modules/common/store/_chargepoint_internal.py b/packages/modules/common/store/_chargepoint_internal.py index e1ab78736d..aa9a9f2c65 100644 --- a/packages/modules/common/store/_chargepoint_internal.py +++ b/packages/modules/common/store/_chargepoint_internal.py @@ -33,6 +33,13 @@ def update(self): "/get/evse_current", self.state.evse_current, 2) pub_to_broker("openWB/set/internal_chargepoint/" + str(self.num) + "/get/max_evse_current", self.state.max_evse_current, 2) + if self.state.soc is not None: + pub_to_broker("openwb/set/internal_chargepoint/" + str(self.num) + "/get/soc", self.state.soc) + if self.state.soc_timestamp is not None: + pub_to_broker("openwb/set/internal_chargepoint/" + str(self.num) + + "/soc_timestamp", self.state.soc_timestamp) + if self.state.rfid_timestamp is not None: + pub_to_broker("openwb/set/internal_chargepoint/" + str(self.num) + "/vehicle_id", self.state.vehicle_id) def get_internal_chargepoint_value_store(id: int) -> ValueStore[ChargepointState]: From 8fd4fe2efe961d30858353833878e56ffb440040 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Wed, 28 May 2025 12:05:56 +0200 Subject: [PATCH 08/17] Update version 2.1.7-Patch.3 --- web/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/version b/web/version index b5c0842f5a..5555ba4582 100644 --- a/web/version +++ b/web/version @@ -1 +1 @@ -2.1.7-Patch.2 +2.1.7-Patch.3 From d7924dc71d00b5882c1b6a50d3ed304076b76a5d Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Mon, 19 May 2025 12:16:50 +0200 Subject: [PATCH 09/17] Pro+:RFID-Read plugged to Pi or Pro (#2408) --- packages/modules/internal_chargepoint_handler/pro_plus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/modules/internal_chargepoint_handler/pro_plus.py b/packages/modules/internal_chargepoint_handler/pro_plus.py index 2798be3a93..19a6922563 100644 --- a/packages/modules/internal_chargepoint_handler/pro_plus.py +++ b/packages/modules/internal_chargepoint_handler/pro_plus.py @@ -27,7 +27,8 @@ def store_state(chargepoint_state: ChargepointState) -> None: try: chargepoint_state = super().request_values() - chargepoint_state.rfid = last_tag + if last_tag is not None and last_tag != "": + chargepoint_state.rfid = last_tag except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError): raise Exception("Interner Ladepunkt ist nicht erreichbar.") From 563e25db4042b0f3d21912c695c0094946b73582 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 15 Jul 2025 11:42:24 +0200 Subject: [PATCH 10/17] update version 2.1.7-Patch.4 --- web/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/version b/web/version index 5555ba4582..f4014350a4 100644 --- a/web/version +++ b/web/version @@ -1 +1 @@ -2.1.7-Patch.3 +2.1.7-Patch.4 From f346706e8b4408d9b7cdbef6e6249fc618e1c743 Mon Sep 17 00:00:00 2001 From: benderl Date: Tue, 29 Jul 2025 11:16:19 +0200 Subject: [PATCH 11/17] Merge pull request #2605 from benderl/remote-support rewrite Remote-Support process handling --- runs/remoteSupport/remoteSupport.py | 196 +++++++++++++++------------- 1 file changed, 106 insertions(+), 90 deletions(-) diff --git a/runs/remoteSupport/remoteSupport.py b/runs/remoteSupport/remoteSupport.py index 15913eae09..331b29f92d 100755 --- a/runs/remoteSupport/remoteSupport.py +++ b/runs/remoteSupport/remoteSupport.py @@ -5,12 +5,15 @@ from datetime import datetime from subprocess import Popen from pathlib import Path +import sys +from signal import signal, Signals, SIGTERM, SIGINT from time import sleep from typing import Optional import paho.mqtt.client as mqtt import platform +VERSION = "1.0.0" API_VERSION = "1" BASE_PATH = Path(__file__).resolve().parents[2] RAMDISK_PATH = BASE_PATH / "ramdisk" @@ -26,17 +29,23 @@ mqtt_broker_host = "localhost" mqtt_broker_port = 1886 -support_tunnel: Popen = None -partner_tunnel: Popen = None -cloud_tunnel: Popen = None +support_tunnel: Optional[Popen] = None +partner_tunnel: Optional[Popen] = None +cloud_tunnel: Optional[Popen] = None valid_partner_ids: list[str] = [] logging.basicConfig( filename=str(RAMDISK_PATH / "remote_support.log"), - level=logging.DEBUG, format='%(asctime)s: %(message)s' + level=logging.DEBUG, format='%(asctime)s - {%(name)s:%(lineno)s} - {%(levelname)s:%(threadName)s}: %(message)s' ) log = logging.getLogger("RemoteSupport") +def handle_terminate(signal_number: int, frame: Optional[object]): + signal_name = Signals(signal_number).name + log.info(f"{signal_name} received, shutting down gracefully...") + sys.exit(0) + + def get_serial(): """Extract serial from cpuinfo file""" with open('/proc/cpuinfo', 'r') as f: @@ -72,6 +81,26 @@ def get_lt_executable() -> Optional[Path]: return lt_path +def stop_tunnel(tunnel: Optional[Popen], tunnel_name: str) -> None: + log.debug(f"Stopping tunnel: {tunnel_name}") + if tunnel is not None: + if tunnel.poll() is None: + log.info(f"terminating {tunnel_name} ...") + tunnel.terminate() + try: + tunnel.wait(timeout=3) + except Exception as e: + log.error(f"Error terminating {tunnel_name}: {e}") + else: + # Tunnel process is already terminated, but may not have been collected yet + try: + tunnel.wait(timeout=1) + except Exception: + pass + else: + log.error(f"tunnel {tunnel_name} is not running.") + + def on_connect(client: mqtt.Client, userdata, flags: dict, rc: int): """connect to broker and subscribe to set topics""" log.info("Connected") @@ -87,19 +116,6 @@ def on_connect(client: mqtt.Client, userdata, flags: dict, rc: int): def on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): """handle incoming messages""" - def is_tunnel_closed(tunnel: Popen) -> bool: - log.debug(str(tunnel)) - is_closed = False - if tunnel is not None: - if tunnel.poll() is None: - log.error("received start tunnel message but tunnel is already running") - else: - is_closed = True - log.info("tunnel was closed by server") - else: - is_closed = True - return is_closed - global support_tunnel global partner_tunnel global cloud_tunnel @@ -110,24 +126,21 @@ def is_tunnel_closed(tunnel: Popen) -> bool: log.debug("Topic: %s, Message: %s", msg.topic, payload) if msg.topic == REMOTE_SUPPORT_TOPIC: if payload == 'stop': - if support_tunnel is None: - log.error("received stop tunnel message but tunnel is not running") - else: - log.info("stop remote support") - support_tunnel.terminate() - support_tunnel.wait(timeout=3) - support_tunnel = None + stop_tunnel(support_tunnel, "support_tunnel") + support_tunnel = None elif re.match(r'^([^;]+)(?:;([1-9][0-9]+)(?:;([a-zA-Z0-9]+))?)?$', payload): - if is_tunnel_closed(support_tunnel): - splitted = payload.split(";") - token = splitted[0] - port = splitted[1] if len(splitted) > 1 else "2223" - user = splitted[2] if len(splitted) > 2 else "getsupport" - log.info("start remote support") - support_tunnel = Popen(["sshpass", "-p", token, "ssh", "-N", "-tt", "-o", - "StrictHostKeyChecking=no", "-o", "ServerAliveInterval 60", "-R", - f"{port}:localhost:22", f"{user}@remotesupport.openwb.de"]) - log.info(f"tunnel running with pid {support_tunnel.pid}") + # Always stop existing tunnel before starting a new one + stop_tunnel(support_tunnel, "support_tunnel") + support_tunnel = None + splitted = payload.split(";") + token = splitted[0] + port = splitted[1] if len(splitted) > 1 else "2223" + user = splitted[2] if len(splitted) > 2 else "getsupport" + log.info("start remote support") + support_tunnel = Popen(["sshpass", "-p", token, "ssh", "-N", "-tt", "-o", + "StrictHostKeyChecking=no", "-o", "ServerAliveInterval 60", "-R", + f"{port}:localhost:22", f"{user}@remotesupport.openwb.de"]) + log.info(f"tunnel running with pid {support_tunnel.pid}") else: log.info("unknown message: " + payload) clear_topic = True @@ -135,72 +148,66 @@ def is_tunnel_closed(tunnel: Popen) -> bool: valid_partner_ids = json.loads(payload) elif msg.topic == REMOTE_PARTNER_TOPIC: if payload == 'stop': - if partner_tunnel is None: - log.error("received stop tunnel message but tunnel is not running") - else: - log.info("stop partner support") - partner_tunnel.terminate() - partner_tunnel.wait(timeout=3) - partner_tunnel = None + stop_tunnel(partner_tunnel, "partner_tunnel") + partner_tunnel = None elif re.match(r'^([^;]+)(?:;((?:cnode)?[0-9]+)(?:;([\wäöüÄÖÜ-]+))?)?$', payload): - if is_tunnel_closed(partner_tunnel): - splitted = payload.split(";") - if len(splitted) != 3: - log.error("invalid number of settings received!") + # Always stop existing tunnel before starting a new one + stop_tunnel(partner_tunnel, "partner_tunnel") + partner_tunnel = None + splitted = payload.split(";") + if len(splitted) != 3: + log.error("invalid number of settings received!") + else: + token = splitted[0] + port_or_node = splitted[1] + user = splitted[2] # not used in v0, partner-id in v1 + if port_or_node.isdecimal(): + # v0 + log.info("start partner support") + partner_tunnel = Popen(["sshpass", "-p", token, "ssh", "-N", "-tt", "-o", + "StrictHostKeyChecking=no", "-o", "ServerAliveInterval 60", "-R", + f"{port_or_node}:localhost:80", f"{user}@partner.openwb.de"]) + log.info(f"tunnel running with pid {partner_tunnel.pid}") else: - token = splitted[0] - port_or_node = splitted[1] - user = splitted[2] # not used in v0, partner-id in v1 - if port_or_node.isdecimal(): - # v0 - log.info("start partner support") - partner_tunnel = Popen(["sshpass", "-p", token, "ssh", "-N", "-tt", "-o", - "StrictHostKeyChecking=no", "-o", "ServerAliveInterval 60", "-R", - f"{port_or_node}:localhost:80", f"{user}@partner.openwb.de"]) - log.info(f"tunnel running with pid {partner_tunnel.pid}") + # v1 + if lt_executable is None: + log.error("start partner tunnel requested but lt executable not found!") else: - # v1 - if lt_executable is None: - log.error("start partner tunnel requested but lt executable not found!") + if user in valid_partner_ids: + log.info("start partner support v1") + if lt_executable is not None: + partner_tunnel = Popen([f"{lt_executable}", "-h", + "https://" + port_or_node + ".openwb.de/", + "-p", "80", "-s", token]) + log.info(f"tunnel running with pid {partner_tunnel.pid}") else: - if user in valid_partner_ids: - log.info("start partner support v1") - if lt_executable is not None: - partner_tunnel = Popen([f"{lt_executable}", "-h", - "https://" + port_or_node + ".openwb.de/", - "-p", "80", "-s", token]) - log.info(f"tunnel running with pid {partner_tunnel.pid}") - else: - log.error(f"invalid partner-id: {user}") + log.error(f"invalid partner-id: {user}") else: log.info("unknown message: " + payload) clear_topic = True elif msg.topic == CLOUD_TOPIC: if payload == 'stop': - if cloud_tunnel is None: - log.error("received stop cloud message but tunnel is not running") - else: - log.info("stop cloud tunnel") - cloud_tunnel.terminate() - cloud_tunnel.wait(timeout=3) - cloud_tunnel = None + stop_tunnel(cloud_tunnel, "cloud_tunnel") + cloud_tunnel = None elif re.match(r'^([^;]+)(?:;([a-zA-Z0-9]+)(?:;([a-zA-Z0-9]+))?)?$', payload): - if is_tunnel_closed(cloud_tunnel): - splitted = payload.split(";") - if len(splitted) != 3: - log.error("invalid number of settings received!") - else: - token = splitted[0] - cloud_node = splitted[1] - user = splitted[2] + # Always stop existing tunnel before starting a new one + stop_tunnel(cloud_tunnel, "cloud_tunnel") + cloud_tunnel = None + splitted = payload.split(";") + if len(splitted) != 3: + log.error("invalid number of settings received!") + else: + token = splitted[0] + cloud_node = splitted[1] + user = splitted[2] - if lt_executable is None: - log.error("start cloud tunnel requested but lt executable not found!") - else: - log.info(f"start cloud tunnel '{token[:4]}...{token[-4:]}' on '{cloud_node}'") - cloud_tunnel = Popen([f"{lt_executable}", "-h", - "https://" + cloud_node + ".openwb.de/", "-p", "80", "-s", token]) - log.info(f"cloud tunnel running with pid {cloud_tunnel.pid}") + if lt_executable is None: + log.error("start cloud tunnel requested but lt executable not found!") + else: + log.info(f"start cloud tunnel '{token[:4]}...{token[-4:]}' on '{cloud_node}'") + cloud_tunnel = Popen([f"{lt_executable}", "-h", + "https://" + cloud_node + ".openwb.de/", "-p", "80", "-s", token]) + log.info(f"cloud tunnel running with pid {cloud_tunnel.pid}") else: log.info("unknown message: " + payload) clear_topic = True @@ -209,6 +216,11 @@ def is_tunnel_closed(tunnel: Popen) -> bool: client.publish(msg.topic, "", qos=2, retain=True) +log.info("Starting remote support client") +log.debug(f"openWB remote support client v{VERSION} (API v{API_VERSION})") +log.debug("registering signal handlers") +signal(SIGTERM, handle_terminate) # Handle SIGTERM from systemctl for graceful shutdown +signal(SIGINT, handle_terminate) # Handle SIGINT from keyboard (Strg+C) for graceful shutdown lt_executable = get_lt_executable() client = mqtt.Client(f"openWB-remote-{get_serial()}-{datetime.today().timestamp()}") client.on_connect = on_connect @@ -222,7 +234,7 @@ def is_tunnel_closed(tunnel: Popen) -> bool: try: while True: sleep(1) -except (Exception, KeyboardInterrupt) as e: +except Exception as e: log.debug(e) log.debug("terminated") finally: @@ -233,4 +245,8 @@ def is_tunnel_closed(tunnel: Popen) -> bool: client.loop_stop() client.disconnect() log.debug("disconnected") + # terminate tunnels + stop_tunnel(support_tunnel, "support_tunnel") + stop_tunnel(partner_tunnel, "partner_tunnel") + stop_tunnel(cloud_tunnel, "cloud_tunnel") log.debug("exit") From 2d946b8d7e08ee1cc00ee7cb771050fa3f2404fc Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:01:31 +0200 Subject: [PATCH 12/17] Update version 2.1.7-Patch.5 --- web/version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/version b/web/version index f4014350a4..223ca90984 100644 --- a/web/version +++ b/web/version @@ -1 +1 @@ -2.1.7-Patch.4 +2.1.7-Patch.5 From 6a96959dc6f78d9ab2edd83f2e6bdd598c7bba60 Mon Sep 17 00:00:00 2001 From: "Georg Z." Date: Fri, 10 Oct 2025 17:52:37 +0200 Subject: [PATCH 13/17] Erster Wurf --- packages/modules/smarthome/shelly/watt.py | 202 ++++++++++++---------- 1 file changed, 111 insertions(+), 91 deletions(-) diff --git a/packages/modules/smarthome/shelly/watt.py b/packages/modules/smarthome/shelly/watt.py index 166e57ff0e..2f8cacab0a 100644 --- a/packages/modules/smarthome/shelly/watt.py +++ b/packages/modules/smarthome/shelly/watt.py @@ -10,25 +10,11 @@ log = logging.getLogger(__name__) - -def totalPowerFromShellyJson(answer: Any, workchan: int) -> int: +def totalPowerFromShellyJson(answer: Any, workchan: int, component: str, count: int) -> int: if (workchan == 0): - if 'meters' in answer: - meters = answer['meters'] # shelly - else: - meters = answer['emeters'] # shellyEM & shelly3EM - total = 0 - # shellyEM has one meter, shelly3EM has three meters: - for meter in meters: - total = total + meter['power'] - return int(total) - workchan = workchan - 1 - try: - total = int(answer['meters'][workchan]['power']) # Abfrage shelly - except Exception: - total = int(answer['emeters'][workchan]['power']) # Abfrage shellyEM - return int(total) - + power_sum = sum(emeter['power'] for emeter in answer[component] if isinstance(emeter, dict) and 'power' in emeter) + return int(power_sum) + return int(answer[component][workchan-1]['power']) named_tuple = time.localtime() # getstruct_time time_string = time.strftime("%m/%d/%Y, %H:%M:%S shelly watty.py", named_tuple) @@ -49,22 +35,35 @@ def totalPowerFromShellyJson(answer: Any, workchan: int) -> int: # Insbesondere wichtig für aktuelle Leistung # Zähler wird beim Neustart auf 0 gesetzt, darf daher nicht übergeben werden. powerc = 0 -temp0 = '0.0' -temp1 = '0.0' -temp2 = '0.0' +temp = [ '0.0', '0.0', '0.0' ] aktpower = 0 relais = 0 gen = '1' model = '???' +# Shelly 3EM kennt die Profile monophase & triphase: +profile = '???' +components = {} # lesen endpoint, gen bestimmem. gen 1 hat unter Umstaenden keinen Eintrag +write_info = False +delete_info = False +device_info = {} +power_field = [ 'total_act_power', 'a_act_power', 'b_act_power', 'c_act_power' ] + fbase = '/var/www/html/openWB/ramdisk/smarthome_device_ret.' fname = fbase + str(ipadr) + '_shelly_info' -fnameg = fbase + str(ipadr) + '_shelly_infogv1' +fnameg = fbase + str(ipadr) + '_shelly_infogv2' +fnamec = fbase + str(ipadr) + '_shelly_infoc' if os.path.isfile(fnameg): - with open(fnameg, 'r') as f: - jsonin = json.loads(f.read()) - gen = str(jsonin['gen']) - model = str(jsonin['model']) + try: + with open(fnameg, 'r') as f: + device_info = json.loads(f.read()) + gen = str(device_info['gen']) + model = str(device_info['model']) + profile = str(device_info['profile']) + components = str(device_info['components']) + except Exception: + delete_info = True + pass else: aread = urllib.request.urlopen("http://" + str(ipadr) + "/shelly", timeout=3).read().decode("utf-8") @@ -73,17 +72,32 @@ def totalPowerFromShellyJson(answer: Any, workchan: int) -> int: json.dump(agen, f) if 'gen' in agen: gen = str(int(agen['gen'])) + device_info['gen'] = gen if 'model' in agen: model = str(agen['model']) elif 'type' in agen: model = str(agen['type']) - jsontype = {"gen": str(gen), "model": str(model)} - with open(fnameg, 'w') as f: - f.write(json.dumps(jsontype)) + device_info['model'] = model + if 'profile' in agen: + # Shelly mit mehreren Profilen (z.B. 3EM, 2PM) + profile = str(agen['profile']) + device_info['profile'] = profile + if gen != "1": + aread = urllib.request.urlopen("http://" + str(ipadr) + "/rpc/Shelly.ListProfiles", + timeout=3).read().decode("utf-8") + agen = json.loads(str(aread)) + with open(fnamec, 'w') as f: + json.dump(agen, f) + for item in agen['profiles'][profile]['components']: + components[item['type']] = item['count'] + device_info['components'] = components + + write_info = True + # Versuche Daten von Shelly abzurufen. try: # print("Shelly " + str(shaut) + user + pw) - if (gen == "1"): + if gen == "1": url = "http://" + str(ipadr) + "/status" if (shaut == 1): passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() @@ -102,78 +116,84 @@ def totalPowerFromShellyJson(answer: Any, workchan: int) -> int: with open('/var/www/html/openWB/ramdisk/smarthome_device_ret.' + str(ipadr) + '_shelly', 'w') as f: f.write(str(answer)) -except Exception: + if not components: + # Gen2+ - Komponenten: + prefixes = ["switch:", "em:", "emdata:", "pm1:", "em1:", "em1data:", "temperature:"] + components = { + prefix[:-1]: count # Entfernt ":" für den Schlüssel (z. B. "switch:" -> "switch") + for prefix in prefixes + if (count := sum(key.startswith(prefix) for key in answer.keys())) > 0 + } + # Gen1 - Komponenten: + prefixes = ["relays", "emeters", "meters", "ext_temperature"] + for prefix in prefixes: + if prefix in answer: + components[prefix] = len(answer.get(prefix)) + device_info['components'] = components + +except Exception as e: + print ("Fehler" + str(e)) log.debug("failed to connect to device on " + ipadr + ", setting all values to 0") # answer.update(a_dictionary) # Versuche Werte aus der Antwort zu extrahieren. -try: - if (gen == "1"): - aktpower = totalPowerFromShellyJson(answer, chan) - else: - if (chan > 0): - workchan = chan - 1 - else: - workchan = chan - sw = 'switch:' + str(workchan) - if ("SPEM-003CE" in model): - if (workchan == 1): - aktpower = int(answer['em:0']['a_act_power']) - elif (workchan == 2): - aktpower = int(answer['em:0']['b_act_power']) - elif (workchan == 3): - aktpower = int(answer['em:0']['c_act_power']) - else: - aktpower = int(answer['em:0']['total_act_power']) - elif ("PM-001PCEU16" in model): - # "SNPM-001PCEU16" (gen 2) und "S3PM-001PCEU16" (gen 3) - aktpower = int(answer['pm1:0']['apower']) - else: - aktpower = int(answer[sw]['apower']) except Exception: pass -try: - if (chan > 0): - workchan = chan - 1 - else: - workchan = chan - if (gen == "1"): - relais = int(answer['relays'][workchan]['ison']) - else: # shelly pro 3em mit add on hat fix id 100 als switch Kanal, das Device muss auf jeden fall mit separater - # Leistunsmessung erfasst werden, da die Leistung auf drei verschieden Kanäle angeliefert werden kann - if ("SPEM-003CE" in model): - workchan = 100 - sw = 'switch:' + str(workchan) - relais = int(answer[sw]['output']) -except Exception: - pass + # Leistungsmessung erfasst werden, da die Leistung auf drei verschieden Kanäle angeliefert werden kann +# if ("SPEM-003CE" in model): +# workchan = 100 +# sw = 'switch:' + str(workchan) +# relais = int(answer[sw]['output']) -try: - if gen == "1": - temp0 = str(answer['ext_temperature']['0']['tC']) - else: - temp0 = str(answer['temperature:100']['tC']) -except Exception: - pass +workchan = chan - 1 if chan > 0 else chan -try: - if gen == "1": - temp1 = str(answer['ext_temperature']['1']['tC']) +if 'switch' in components: + sw = 'switch:'+str(workchan) + if not sw in answer: + sw = 'switch:0' + relais = int(answer[sw]['output']) + aktpower = int(answer[sw]['apower']) if 'apower' in answer[sw] else 0 +if 'relays' in components: + relais = int(answer['relays'][workchan if (workchan < len(answer['relays'])) else 0]['ison']) +if 'meters' in components: + aktpower = totalPowerFromShellyJson(answer, chan, 'meters', components['meters']) +if 'pm1' in components: + sw = 'pm1:'+str(workchan) + aktpower = int(answer[sw]['apower']) +if 'em1' in components: + if (workchan == 0): + aktpower = int(sum(answer['em1:'+str(em)]['act_power'] for em in range(components['em1']))) else: - temp1 = str(answer['temperature:101']['tC']) -except Exception: - pass + sw = 'em1:'+str(workchan) + aktpower = int(answer[sw]['act_power']) +if 'em' in components: + aktpower = int(answer['em:0'][power_field[chan]]) +if 'emeters' in components: + aktpower = totalPowerFromShellyJson(answer, chan, 'emeters', components['emeters']) + +if 'ext_temperature' in components: + for i in range(len(answer['ext_temperature'])): + temp[i] = str(answer['ext_temperature'][str(i)]['tC']) +if 'temperature' in components: + for i in range(components['temperature']): + field = 'temperature:' + str(i+100) + if field in answer: + temp[i] = str(answer[field]['tC']) + +if write_info: + with open(fnameg, 'w') as f: + f.write(json.dumps(device_info)) + +if delete_info: + try: + os.remove(fname) + except Exception: + pass -try: - if gen == "1": - temp2 = str(answer['ext_temperature']['2']['tC']) - else: - temp2 = str(answer['temperature:102']['tC']) -except Exception: - pass answer = '{"power":' + str(aktpower) + ',"powerc":' + str(powerc) -answer += ',"on":' + str(relais) + ',"temp0":' + str(temp0) -answer += ',"temp1":' + str(temp1) + ',"temp2":' + str(temp2) + '}' +answer += ',"on":' + str(relais) + ',"temp0":' + str(temp[0]) +answer += ',"temp1":' + str(temp[1]) + ',"temp2":' + str(temp[2]) + '}' writeret(answer, devicenumber) +print ("Answer: " + answer) From 5715001778486cb84d503a81cf06bf550ab890ae Mon Sep 17 00:00:00 2001 From: "Georg Z." Date: Sat, 11 Oct 2025 11:01:14 +0200 Subject: [PATCH 14/17] Prefinal vor Tests --- packages/modules/smarthome/shelly/watt.py | 189 ++++++++++++---------- 1 file changed, 102 insertions(+), 87 deletions(-) diff --git a/packages/modules/smarthome/shelly/watt.py b/packages/modules/smarthome/shelly/watt.py index 2f8cacab0a..c5aa5be6b2 100644 --- a/packages/modules/smarthome/shelly/watt.py +++ b/packages/modules/smarthome/shelly/watt.py @@ -10,11 +10,11 @@ log = logging.getLogger(__name__) -def totalPowerFromShellyJson(answer: Any, workchan: int, component: str, count: int) -> int: - if (workchan == 0): - power_sum = sum(emeter['power'] for emeter in answer[component] if isinstance(emeter, dict) and 'power' in emeter) - return int(power_sum) - return int(answer[component][workchan-1]['power']) +def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: + if workchan > 0: + return int(answer[component][workchan - 1]['power']) + power_sum = sum(emeter['power'] for emeter in answer[component] if isinstance(emeter, dict) and 'power' in emeter) + return int(power_sum) named_tuple = time.localtime() # getstruct_time time_string = time.strftime("%m/%d/%Y, %H:%M:%S shelly watty.py", named_tuple) @@ -50,120 +50,135 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str, count: power_field = [ 'total_act_power', 'a_act_power', 'b_act_power', 'c_act_power' ] fbase = '/var/www/html/openWB/ramdisk/smarthome_device_ret.' -fname = fbase + str(ipadr) + '_shelly_info' -fnameg = fbase + str(ipadr) + '_shelly_infogv2' -fnamec = fbase + str(ipadr) + '_shelly_infoc' -if os.path.isfile(fnameg): - try: - with open(fnameg, 'r') as f: - device_info = json.loads(f.read()) - gen = str(device_info['gen']) - model = str(device_info['model']) - profile = str(device_info['profile']) - components = str(device_info['components']) - except Exception: - delete_info = True - pass -else: - aread = urllib.request.urlopen("http://" + str(ipadr) + "/shelly", - timeout=3).read().decode("utf-8") - agen = json.loads(str(aread)) - with open(fname, 'w') as f: - json.dump(agen, f) - if 'gen' in agen: - gen = str(int(agen['gen'])) - device_info['gen'] = gen - if 'model' in agen: - model = str(agen['model']) - elif 'type' in agen: - model = str(agen['type']) - device_info['model'] = model - if 'profile' in agen: - # Shelly mit mehreren Profilen (z.B. 3EM, 2PM) - profile = str(agen['profile']) - device_info['profile'] = profile - if gen != "1": - aread = urllib.request.urlopen("http://" + str(ipadr) + "/rpc/Shelly.ListProfiles", - timeout=3).read().decode("utf-8") - agen = json.loads(str(aread)) - with open(fnamec, 'w') as f: - json.dump(agen, f) - for item in agen['profiles'][profile]['components']: - components[item['type']] = item['count'] - device_info['components'] = components +# Response of "/shelly"-url: +fname_shellyinfo = fbase + ipadr + '_shelly_info' +# Response of "/rpc/Shelly.ListProfiles": +fname_profiles = fbase + ipadr + '_shelly_infoc' +# Internal cache for gathered device info: +fname_devcache = fbase + ipadr + '_shelly_infogv2' +# Response for "/status" or "/rpc/Shelly.GetStatus": +fname_statusrsp = fbase + ipadr + '_shelly' - write_info = True +log_pfx = "Device " + str(devicenumber) + " IP " + ipadr + ": " -# Versuche Daten von Shelly abzurufen. +# Do we have a cache of the device features? try: - # print("Shelly " + str(shaut) + user + pw) - if gen == "1": - url = "http://" + str(ipadr) + "/status" - if (shaut == 1): + if os.path.isfile(fname_devcache): + try: + with open(fname_devcache, 'r') as f: + device_info = json.loads(f.read()) + gen = str(device_info['gen']) + model = str(device_info['model']) + profile = str(device_info['profile']) + components = str(device_info['components']) + except Exception: + # Delete this cache file - it seems broken + delete_info = True + pass + else: + # New device analysis: Start with /shelly URL: + aread = urllib.request.urlopen('http://' + ipadr + '/shelly', + timeout=3).read().decode("utf-8") + log.warning(log_pfx + "/shelly response " + aread) + device_info = json.loads(aread) + agen = json.loads(str(aread)) + with open(fname_shellyinfo, 'w') as f: + json.dump(agen, f) + if 'gen' in agen: + gen = str(int(agen['gen'])) + device_info['gen'] = gen + if 'model' in agen: + model = str(agen['model']) + elif 'type' in agen: + model = str(agen['type']) + device_info['model'] = model + if 'profile' in agen: + # Shelly with multiple profiles (z.B. 3EM, 2PM) + profile = str(agen['profile']) + device_info['profile'] = profile + if gen != "1": + aread = urllib.request.urlopen('http://' + ipadr + '/rpc/Shelly.ListProfiles', + timeout=3).read().decode('utf-8') + log.warning(log_pfx + " /rpc/Shelly.ListProfiles response " + aread) + agen = json.loads(str(aread)) + with open(fname_profiles, 'w') as f: + json.dump(agen, f) + for item in agen['profiles'][profile]['components']: + components[item['type']] = item['count'] + device_info['components'] = components + # We have a new device analysis, store it: + write_info = True +except Exception as e: + log.error (log_pfx + 'Error on device analysis ' + str(e)) + pass + +# Pre-Analysis done / loaded, now get the data: +try: + # For future use: Caching of response: + # Check the last response is < 5 seconds old + if (os.path.exists(fname_statusrsp) and + os.path.getmtime(fname_statusrsp) + 4 > time.time()): + # We will use a cached Status-page + with open(fname_statusrsp, 'r') as f: + answer = json.loads(str(f.read())) + else: + # No (valid) cache: We have to fetch the data: + url = 'http://' + str(ipadr) + ('/status' if gen == '1' else '/rpc/Shelly.GetStatus') + if shaut == 1: passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() passman.add_password(None, url, user, pw) authhandler = urllib.request.HTTPBasicAuthHandler(passman) opener = urllib.request.build_opener(authhandler) urllib.request.install_opener(opener) with urllib.request.urlopen(url, timeout=3) as response: - aread = response.read().decode("utf-8") - answer = json.loads(str(aread)) - else: - aread = urllib.request.urlopen("http://"+str(ipadr) + - "/rpc/Shelly.GetStatus", - timeout=3).read().decode("utf-8") - answer = json.loads(str(aread)) - with open('/var/www/html/openWB/ramdisk/smarthome_device_ret.' + - str(ipadr) + '_shelly', 'w') as f: - f.write(str(answer)) + aread = response.read().decode('utf-8') + answer = json.loads(aread) + with open(fname_statusrsp, 'w') as f: + json.dump(answer, f) + if not components: - # Gen2+ - Komponenten: - prefixes = ["switch:", "em:", "emdata:", "pm1:", "em1:", "em1data:", "temperature:"] + # Late device analysis, based on the first response: + prefixes = ['switch:', 'em:', 'emdata:', 'pm1:', 'em1:', 'em1data:', 'temperature:'] components = { prefix[:-1]: count # Entfernt ":" für den Schlüssel (z. B. "switch:" -> "switch") for prefix in prefixes if (count := sum(key.startswith(prefix) for key in answer.keys())) > 0 } # Gen1 - Komponenten: - prefixes = ["relays", "emeters", "meters", "ext_temperature"] + prefixes = ['relays', 'emeters', 'meters', 'ext_temperature'] for prefix in prefixes: if prefix in answer: components[prefix] = len(answer.get(prefix)) device_info['components'] = components + write_info = True except Exception as e: - print ("Fehler" + str(e)) - log.debug("failed to connect to device on " + - ipadr + ", setting all values to 0") -# answer.update(a_dictionary) -# Versuche Werte aus der Antwort zu extrahieren. -except Exception: + log.error (log_pfx + 'Error on data fetch ' + str(e)) pass - # shelly pro 3em mit add on hat fix id 100 als switch Kanal, das Device muss auf jeden fall mit separater - # Leistungsmessung erfasst werden, da die Leistung auf drei verschieden Kanäle angeliefert werden kann -# if ("SPEM-003CE" in model): -# workchan = 100 -# sw = 'switch:' + str(workchan) -# relais = int(answer[sw]['output']) - +# We have the response: Start parsing: workchan = chan - 1 if chan > 0 else chan if 'switch' in components: - sw = 'switch:'+str(workchan) + # Beim Shelly Pro 3EM mit AddOn ist die Switch-ID 100, sonst ab 0: + sw = 'switch:'+str(workchan) if not 'SPEM-003CE' in model else 'switch:100' if not sw in answer: + # Typisch, wenn der Messwert auf einem höheren Kanal geholt werden soll sw = 'switch:0' relais = int(answer[sw]['output']) aktpower = int(answer[sw]['apower']) if 'apower' in answer[sw] else 0 if 'relays' in components: relais = int(answer['relays'][workchan if (workchan < len(answer['relays'])) else 0]['ison']) if 'meters' in components: - aktpower = totalPowerFromShellyJson(answer, chan, 'meters', components['meters']) + aktpower = totalPowerFromShellyJson(answer, chan, 'meters') if 'pm1' in components: - sw = 'pm1:'+str(workchan) - aktpower = int(answer[sw]['apower']) + if chan == 0: + aktpower = int(sum(answer['pm1:'+str(em)]['apower'] for em in range(components['pm1']))) + else: + sw = 'pm1:'+str(workchan) + aktpower = int(answer[sw]['apower']) if 'em1' in components: - if (workchan == 0): + if chan == 0: aktpower = int(sum(answer['em1:'+str(em)]['act_power'] for em in range(components['em1']))) else: sw = 'em1:'+str(workchan) @@ -171,7 +186,7 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str, count: if 'em' in components: aktpower = int(answer['em:0'][power_field[chan]]) if 'emeters' in components: - aktpower = totalPowerFromShellyJson(answer, chan, 'emeters', components['emeters']) + aktpower = totalPowerFromShellyJson(answer, chan, 'emeters') if 'ext_temperature' in components: for i in range(len(answer['ext_temperature'])): @@ -183,12 +198,13 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str, count: temp[i] = str(answer[field]['tC']) if write_info: - with open(fnameg, 'w') as f: + with open(fname_devcache, 'w') as f: f.write(json.dumps(device_info)) + log.warning(log_pfx + " cached info " + json.dumps(device_info)) if delete_info: try: - os.remove(fname) + os.remove(fname_shellyinfo) except Exception: pass @@ -196,4 +212,3 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str, count: answer += ',"on":' + str(relais) + ',"temp0":' + str(temp[0]) answer += ',"temp1":' + str(temp[1]) + ',"temp2":' + str(temp[2]) + '}' writeret(answer, devicenumber) -print ("Answer: " + answer) From df8d98b875794710eb77fe48f2930d08021a8192 Mon Sep 17 00:00:00 2001 From: "Georg Z." Date: Sat, 11 Oct 2025 14:56:00 +0200 Subject: [PATCH 15/17] HttpDigest-Support --- packages/modules/smarthome/shelly/watt.py | 201 ++++++++++++++-------- 1 file changed, 133 insertions(+), 68 deletions(-) diff --git a/packages/modules/smarthome/shelly/watt.py b/packages/modules/smarthome/shelly/watt.py index c5aa5be6b2..844204ca9a 100644 --- a/packages/modules/smarthome/shelly/watt.py +++ b/packages/modules/smarthome/shelly/watt.py @@ -3,7 +3,8 @@ import os import time import json -import urllib.request +import requests +import hashlib from typing import Any from smarthome.smartret import writeret import logging @@ -16,6 +17,42 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: power_sum = sum(emeter['power'] for emeter in answer[component] if isinstance(emeter, dict) and 'power' in emeter) return int(power_sum) +def build_digest_header(url: str, method: str, username: str, password: str, auth_header: str) -> str: + """Erstellt den Digest Authorization-Header für Shelly (SHA-256).""" + if not auth_header.startswith('Digest'): + raise Exception("Kein Digest-Auth-Header gefunden.") + + # Extrahiere realm, nonce, etc. + auth_fields = {} + for field in auth_header[6:].split(','): + key, value = field.strip().split('=', 1) + auth_fields[key] = value.strip('"') + + realm = auth_fields['realm'] + nonce = auth_fields['nonce'] + qop = auth_fields.get('qop', 'auth') + opaque = auth_fields.get('opaque', '') + uri = url.split('/', 3)[3] if '/' in url else '' # Extrahiere URI-Pfad + + # Digest-Berechnungen (Shelly verwendet SHA-256) + nc = "00000001" # Request-Counter + cnonce = str(int(time.time() * 1000)) # Client-Nonce + + # HA1 = SHA-256(username:realm:password) + ha1 = hashlib.sha256(f"{username}:{realm}:{password}".encode()).hexdigest() + # HA2 = SHA-256(method:uri) + ha2 = hashlib.sha256(f"{method}:/{uri}".encode()).hexdigest() + # Response = SHA-256(HA1:nonce:nc:cnonce:qop:HA2) + response = hashlib.sha256(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()).hexdigest() + + # Authorization-Header + auth_str = (f'Digest username="{username}", realm="{realm}", nonce="{nonce}", ' + f'uri="/{uri}", qop={qop}, nc={nc}, cnonce="{cnonce}", ' + f'response="{response}", algorithm=SHA-256') + if opaque: + auth_str += f', opaque="{opaque}"' + return auth_str + named_tuple = time.localtime() # getstruct_time time_string = time.strftime("%m/%d/%Y, %H:%M:%S shelly watty.py", named_tuple) devicenumber = int(sys.argv[1]) @@ -32,22 +69,19 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: user = str(sys.argv[6]) pw = str(sys.argv[7]) # Setze Default-Werte, andernfalls wird der letzte Wert ewig fortgeschrieben. -# Insbesondere wichtig für aktuelle Leistung -# Zähler wird beim Neustart auf 0 gesetzt, darf daher nicht übergeben werden. powerc = 0 -temp = [ '0.0', '0.0', '0.0' ] +temp = ['0.0', '0.0', '0.0'] aktpower = 0 relais = 0 gen = '1' model = '???' -# Shelly 3EM kennt die Profile monophase & triphase: profile = '???' components = {} # lesen endpoint, gen bestimmem. gen 1 hat unter Umstaenden keinen Eintrag write_info = False delete_info = False device_info = {} -power_field = [ 'total_act_power', 'a_act_power', 'b_act_power', 'c_act_power' ] +power_field = ['total_act_power', 'a_act_power', 'b_act_power', 'c_act_power'] fbase = '/var/www/html/openWB/ramdisk/smarthome_device_ret.' # Response of "/shelly"-url: @@ -57,7 +91,7 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: # Internal cache for gathered device info: fname_devcache = fbase + ipadr + '_shelly_infogv2' # Response for "/status" or "/rpc/Shelly.GetStatus": -fname_statusrsp = fbase + ipadr + '_shelly' +fname_statusrsp = fbase + ipadr + '_shelly_res' log_pfx = "Device " + str(devicenumber) + " IP " + ipadr + ": " @@ -70,18 +104,20 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: gen = str(device_info['gen']) model = str(device_info['model']) profile = str(device_info['profile']) - components = str(device_info['components']) + components = device_info['components'] # Kein str(), da dict except Exception: # Delete this cache file - it seems broken delete_info = True pass else: - # New device analysis: Start with /shelly URL: - aread = urllib.request.urlopen('http://' + ipadr + '/shelly', - timeout=3).read().decode("utf-8") + # New device analysis: Start with /shelly URL + url = f'http://{ipadr}/shelly' + response = requests.get(url, timeout=3) + response.raise_for_status() + aread = response.text log.warning(log_pfx + "/shelly response " + aread) device_info = json.loads(aread) - agen = json.loads(str(aread)) + agen = json.loads(aread) with open(fname_shellyinfo, 'w') as f: json.dump(agen, f) if 'gen' in agen: @@ -97,10 +133,12 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: profile = str(agen['profile']) device_info['profile'] = profile if gen != "1": - aread = urllib.request.urlopen('http://' + ipadr + '/rpc/Shelly.ListProfiles', - timeout=3).read().decode('utf-8') + url = f'http://{ipadr}/rpc/Shelly.ListProfiles' + response = requests.get(url, timeout=3) + response.raise_for_status() + aread = response.text log.warning(log_pfx + " /rpc/Shelly.ListProfiles response " + aread) - agen = json.loads(str(aread)) + agen = json.loads(aread) with open(fname_profiles, 'w') as f: json.dump(agen, f) for item in agen['profiles'][profile]['components']: @@ -109,7 +147,7 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: # We have a new device analysis, store it: write_info = True except Exception as e: - log.error (log_pfx + 'Error on device analysis ' + str(e)) + log.error(log_pfx + 'Error on device analysis ' + str(e)) pass # Pre-Analysis done / loaded, now get the data: @@ -117,30 +155,50 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: # For future use: Caching of response: # Check the last response is < 5 seconds old if (os.path.exists(fname_statusrsp) and - os.path.getmtime(fname_statusrsp) + 4 > time.time()): + os.path.getmtime(fname_statusrsp) + 4 > time.time()): # We will use a cached Status-page with open(fname_statusrsp, 'r') as f: - answer = json.loads(str(f.read())) + answer = json.loads(f.read()) else: # No (valid) cache: We have to fetch the data: - url = 'http://' + str(ipadr) + ('/status' if gen == '1' else '/rpc/Shelly.GetStatus') - if shaut == 1: - passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() - passman.add_password(None, url, user, pw) - authhandler = urllib.request.HTTPBasicAuthHandler(passman) - opener = urllib.request.build_opener(authhandler) - urllib.request.install_opener(opener) - with urllib.request.urlopen(url, timeout=3) as response: - aread = response.read().decode('utf-8') + url = f'http://{ipadr}/{"status" if gen == "1" else "rpc/Shelly.GetStatus"}' + headers = {} + try: + if shaut == 1: + if gen == "1": + # Basic Auth für Gen1 + response = requests.get(url, auth=(user, pw), timeout=3) + else: + # Digest Auth für Gen2+ + response = requests.get(url, timeout=3) + if response.status_code == 401: + auth_header = response.headers.get('WWW-Authenticate') + if auth_header: + headers['Authorization'] = build_digest_header(url, 'GET', "admin", pw, auth_header) + response = requests.get(url, headers=headers, timeout=3) + else: + raise Exception("Kein WWW-Authenticate-Header in 401-Antwort.") + elif response.status_code != 200: + raise Exception(f"Unerwarteter Statuscode: {response.status_code}") + else: + # Keine Authentifizierung erforderlich + response = requests.get(url, timeout=3) + + # Antwort verarbeiten + response.raise_for_status() + aread = response.text answer = json.loads(aread) - with open(fname_statusrsp, 'w') as f: - json.dump(answer, f) + with open(fname_statusrsp, 'w') as f: + json.dump(answer, f) + except requests.exceptions.RequestException as e: + log.error(f"{log_pfx}Error on data fetching: {str(e)}") + raise Exception(f"Fehler bei Datenabfrage: {e}") if not components: # Late device analysis, based on the first response: prefixes = ['switch:', 'em:', 'emdata:', 'pm1:', 'em1:', 'em1data:', 'temperature:'] components = { - prefix[:-1]: count # Entfernt ":" für den Schlüssel (z. B. "switch:" -> "switch") + prefix[:-1]: count for prefix in prefixes if (count := sum(key.startswith(prefix) for key in answer.keys())) > 0 } @@ -153,49 +211,56 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: write_info = True except Exception as e: - log.error (log_pfx + 'Error on data fetch ' + str(e)) + log.error(log_pfx + 'Error on data fetch ' + str(e)) pass # We have the response: Start parsing: workchan = chan - 1 if chan > 0 else chan -if 'switch' in components: - # Beim Shelly Pro 3EM mit AddOn ist die Switch-ID 100, sonst ab 0: - sw = 'switch:'+str(workchan) if not 'SPEM-003CE' in model else 'switch:100' - if not sw in answer: - # Typisch, wenn der Messwert auf einem höheren Kanal geholt werden soll - sw = 'switch:0' - relais = int(answer[sw]['output']) - aktpower = int(answer[sw]['apower']) if 'apower' in answer[sw] else 0 -if 'relays' in components: - relais = int(answer['relays'][workchan if (workchan < len(answer['relays'])) else 0]['ison']) -if 'meters' in components: - aktpower = totalPowerFromShellyJson(answer, chan, 'meters') -if 'pm1' in components: - if chan == 0: - aktpower = int(sum(answer['pm1:'+str(em)]['apower'] for em in range(components['pm1']))) - else: - sw = 'pm1:'+str(workchan) - aktpower = int(answer[sw]['apower']) -if 'em1' in components: - if chan == 0: - aktpower = int(sum(answer['em1:'+str(em)]['act_power'] for em in range(components['em1']))) - else: - sw = 'em1:'+str(workchan) - aktpower = int(answer[sw]['act_power']) -if 'em' in components: - aktpower = int(answer['em:0'][power_field[chan]]) -if 'emeters' in components: - aktpower = totalPowerFromShellyJson(answer, chan, 'emeters') - -if 'ext_temperature' in components: - for i in range(len(answer['ext_temperature'])): - temp[i] = str(answer['ext_temperature'][str(i)]['tC']) -if 'temperature' in components: - for i in range(components['temperature']): - field = 'temperature:' + str(i+100) - if field in answer: - temp[i] = str(answer[field]['tC']) +try: + if 'switch' in components: + # Beim Shelly Pro 3EM mit AddOn ist die Switch-ID 100, sonst ab 0: + sw = 'switch:' + str(workchan) if not 'SPEM-003CE' in model else 'switch:100' + if not sw in answer: + # Typisch, wenn der Messwert auf einem höheren Kanal geholt werden soll + sw = 'switch:0' + relais = int(answer[sw]['output']) + aktpower = int(answer[sw]['apower']) if 'apower' in answer[sw] else 0 + if 'relays' in components: + relais = int(answer['relays'][workchan if (workchan < len(answer['relays'])) else 0]['ison']) + if 'meters' in components: + aktpower = totalPowerFromShellyJson(answer, chan, 'meters') + if 'pm1' in components: + if chan == 0: + aktpower = int(sum(answer['pm1:' + str(em)]['apower'] for em in range(components['pm1']))) + else: + sw = 'pm1:' + str(workchan) + aktpower = int(answer[sw]['apower']) + if 'em1' in components: + if chan == 0: + aktpower = int(sum(answer['em1:' + str(em)]['act_power'] for em in range(components['em1']))) + else: + sw = 'em1:' + str(workchan) + aktpower = int(answer[sw]['act_power']) + if 'em' in components: + aktpower = int(answer['em:0'][power_field[chan]]) + if 'emeters' in components: + aktpower = totalPowerFromShellyJson(answer, chan, 'emeters') + if 'ext_temperature' in components: + for i in range(len(answer['ext_temperature'])): + temp[i] = str(answer['ext_temperature'][str(i)]['tC']) + if 'temperature' in components: + for i in range(components['temperature']): + field = 'temperature:' + str(i + 100) + if field in answer: + temp[i] = str(answer[field]['tC']) +except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + filename = exc_traceback.tb_frame.f_code.co_filename + line_number = exc_traceback.tb_lineno + function_name = exc_traceback.tb_frame.f_code.co_name + log.error( + f"{log_pfx}Error on data parsing: {str(e)} (File: {filename}, Line: {line_number}, Function: {function_name})") if write_info: with open(fname_devcache, 'w') as f: From 8b7237e2fffeee17344bda4088d39bfb4c19515c Mon Sep 17 00:00:00 2001 From: "Georg Z." Date: Sat, 11 Oct 2025 15:54:22 +0200 Subject: [PATCH 16/17] on.py und off.py nachgezogen und mit Logging ausgestattet, watt.py vereinfacht --- packages/modules/smarthome/shelly/off.py | 85 ++++++++++++---------- packages/modules/smarthome/shelly/on.py | 80 +++++++++++---------- packages/modules/smarthome/shelly/watt.py | 88 ++++++----------------- 3 files changed, 110 insertions(+), 143 deletions(-) mode change 100644 => 100755 packages/modules/smarthome/shelly/off.py diff --git a/packages/modules/smarthome/shelly/off.py b/packages/modules/smarthome/shelly/off.py old mode 100644 new mode 100755 index e9ce1b4f46..27b66a7087 --- a/packages/modules/smarthome/shelly/off.py +++ b/packages/modules/smarthome/shelly/off.py @@ -1,11 +1,14 @@ #!/usr/bin/python3 import sys -import time -import urllib.request +from logging import exception + +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth import os import json -named_tuple = time.localtime() # getstruct_time -time_string = time.strftime("%m/%d/%Y, %H:%M:%S shelly off.py", named_tuple) +import logging + +log = logging.getLogger(__name__) devicenumber = str(sys.argv[1]) ipadr = str(sys.argv[2]) uberschuss = int(sys.argv[3]) @@ -18,40 +21,44 @@ shaut = int(sys.argv[5]) user = str(sys.argv[6]) pw = str(sys.argv[7]) + fbase = '/var/www/html/openWB/ramdisk/smarthome_device_ret.' -fnameg = fbase + str(ipadr) + '_shelly_infogv1' -if os.path.isfile(fnameg): - with open(fnameg, 'r') as f: - jsonin = json.loads(f.read()) - gen = str(jsonin['gen']) - model = str(jsonin['model']) -else: - gen = "1" -if (gen == "1"): - if (chan == 0): - url = "http://" + str(ipadr) + "/relay/0?turn=off" - # urllib.request.urlopen("http://"+str(ipadr)+"/relay/0?turn=off", - # timeout=3) +fnameg = fbase + str(ipadr) + '_shelly_infogv2' +log_pfx = "Device " + str(devicenumber) + " IP " + ipadr + ": " + +try: + if os.path.isfile(fnameg): + with open(fnameg, 'r') as f: + jsonin = json.loads(f.read()) + gen = str(jsonin['gen']) + model = str(jsonin['model']) + else: + gen = "1" + + if gen == "1": + if chan == 0: + url = f"http://{ipadr}/relay/0?turn=off" + else: + chan = chan - 1 + url = f"http://{ipadr}/relay/{chan}?turn=off" + else: + if chan > 0: + chan = chan - 1 + if "SPEM-003CE" in model: + chan = 100 + url = f"http://{ipadr}/rpc/Switch.Set?id={chan}&on=false" + + if shaut == 1: + if gen == "1": + # HTTP Basic Auth für Gen 1 + auth = HTTPBasicAuth(user, pw) + else: + # HTTP Digest Auth für Gen 2 oder SPEM-003CE + auth = HTTPDigestAuth("admin", pw) + response = requests.get(url, auth=auth, timeout=3) else: - chan = chan - 1 - url = "http://" + str(ipadr) + "/relay/" + str(chan) + "?turn=off" - # urllib.request.urlopen("http://"+str(ipadr)+"/relay/" + str(chan) + - # "?turn=off", timeout=3) -else: - if (chan > 0): - chan = chan - 1 - # shelly pro 3em mit add on hat fix id 100 als switch Kanal, das Device muss auf jeden fall mit separater - # Leistunsmessung erfasst werden, da die Leistung auf drei verschiedenenen Kanälen angeliefert werden kann - if ("SPEM-003CE" in model): - chan = 100 - # gen 2 will das als off cmd IPderPro3EM/rpc/Switch.Set?id=100&on=false - url = "http://" + str(ipadr) + "/rpc/Switch.Set?id=" + str(chan) + "&on=false" -if (shaut == 1): - # print("Shelly off" + str(shaut) + user + pw) - passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() - passman.add_password(None, url, user, pw) - authhandler = urllib.request.HTTPBasicAuthHandler(passman) - opener = urllib.request.build_opener(authhandler) - urllib.request.install_opener(opener) -with urllib.request.urlopen(url) as response: - response.read().decode("utf-8") + response = requests.get(url, timeout=3) + + response.raise_for_status() # Fehler, wenn Statuscode nicht 2xx ist +except Exception as e: + log.error(f"{log_pfx}Error on changing switch: {str(e)}") diff --git a/packages/modules/smarthome/shelly/on.py b/packages/modules/smarthome/shelly/on.py index 85320a6444..11946788a5 100644 --- a/packages/modules/smarthome/shelly/on.py +++ b/packages/modules/smarthome/shelly/on.py @@ -1,8 +1,12 @@ #!/usr/bin/python3 import sys -import urllib.request +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth import os import json +import logging + +log = logging.getLogger(__name__) devicenumber = str(sys.argv[1]) ipadr = str(sys.argv[2]) uberschuss = int(sys.argv[3]) @@ -15,40 +19,44 @@ shaut = int(sys.argv[5]) user = str(sys.argv[6]) pw = str(sys.argv[7]) + fbase = '/var/www/html/openWB/ramdisk/smarthome_device_ret.' -fnameg = fbase + str(ipadr) + '_shelly_infogv1' -if os.path.isfile(fnameg): - with open(fnameg, 'r') as f: - jsonin = json.loads(f.read()) - gen = str(jsonin['gen']) - model = str(jsonin['model']) -else: - gen = "1" -if (gen == "1"): - if (chan == 0): - url = "http://" + str(ipadr) + "/relay/0?turn=on" - # urllib.request.urlopen("http://"+str(ipadr)+"/relay/0?turn=on", - # timeout=3) +fnameg = fbase + str(ipadr) + '_shelly_infogv2' +log_pfx = "Device " + str(devicenumber) + " IP " + ipadr + ": " + +try: + if os.path.isfile(fnameg): + with open(fnameg, 'r') as f: + jsonin = json.loads(f.read()) + gen = str(jsonin['gen']) + model = str(jsonin['model']) + else: + gen = "1" + + if gen == "1": + if chan == 0: + url = f"http://{ipadr}/relay/0?turn=on" + else: + chan = chan - 1 + url = f"http://{ipadr}/relay/{chan}?turn=on" + else: + if chan > 0: + chan = chan - 1 + if "SPEM-003CE" in model: + chan = 100 + url = f"http://{ipadr}/rpc/Switch.Set?id={chan}&on=true" + + if shaut == 1: + if gen == "1": + # HTTP Basic Auth für Gen 1 + auth = HTTPBasicAuth(user, pw) + else: + # HTTP Digest Auth für Gen 2 oder SPEM-003CE + auth = HTTPDigestAuth("admin", pw) + response = requests.get(url, auth=auth, timeout=3) else: - chan = chan - 1 - url = "http://" + str(ipadr) + "/relay/" + str(chan) + "?turn=on" - # urllib.request.urlopen("http://"+str(ipadr)+"/relay/" + str(chan) + - # "?turn=on", timeout=3) -else: - if (chan > 0): - chan = chan - 1 - # shelly pro 3em mit add on hat fix id 100 als switch Kanal, das Device muss auf jeden fall mit separater - # Leistunsmessung erfasst werden, da die Leistung auf drei verschiedenenen Kanälen angeliefert werden kann - if ("SPEM-003CE" in model): - chan = 100 - # gen 2 will das als on cmd /rpc/Switch.Set?id=100&on=true - url = "http://" + str(ipadr) + "/rpc/Switch.Set?id=" + str(chan) + "&on=true" -if (shaut == 1): - # print("Shelly on" + str(shaut) + user + pw) - passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() - passman.add_password(None, url, user, pw) - authhandler = urllib.request.HTTPBasicAuthHandler(passman) - opener = urllib.request.build_opener(authhandler) - urllib.request.install_opener(opener) -with urllib.request.urlopen(url) as response: - response.read().decode("utf-8") + response = requests.get(url, timeout=3) + + response.raise_for_status() # Fehler, wenn Statuscode nicht 2xx ist +except Exception as e: + log.error(f"{log_pfx}Error on changing switch: {str(e)}") diff --git a/packages/modules/smarthome/shelly/watt.py b/packages/modules/smarthome/shelly/watt.py index 844204ca9a..dcfefe7fcb 100644 --- a/packages/modules/smarthome/shelly/watt.py +++ b/packages/modules/smarthome/shelly/watt.py @@ -4,7 +4,7 @@ import time import json import requests -import hashlib +from requests.auth import HTTPBasicAuth, HTTPDigestAuth from typing import Any from smarthome.smartret import writeret import logging @@ -17,42 +17,6 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: power_sum = sum(emeter['power'] for emeter in answer[component] if isinstance(emeter, dict) and 'power' in emeter) return int(power_sum) -def build_digest_header(url: str, method: str, username: str, password: str, auth_header: str) -> str: - """Erstellt den Digest Authorization-Header für Shelly (SHA-256).""" - if not auth_header.startswith('Digest'): - raise Exception("Kein Digest-Auth-Header gefunden.") - - # Extrahiere realm, nonce, etc. - auth_fields = {} - for field in auth_header[6:].split(','): - key, value = field.strip().split('=', 1) - auth_fields[key] = value.strip('"') - - realm = auth_fields['realm'] - nonce = auth_fields['nonce'] - qop = auth_fields.get('qop', 'auth') - opaque = auth_fields.get('opaque', '') - uri = url.split('/', 3)[3] if '/' in url else '' # Extrahiere URI-Pfad - - # Digest-Berechnungen (Shelly verwendet SHA-256) - nc = "00000001" # Request-Counter - cnonce = str(int(time.time() * 1000)) # Client-Nonce - - # HA1 = SHA-256(username:realm:password) - ha1 = hashlib.sha256(f"{username}:{realm}:{password}".encode()).hexdigest() - # HA2 = SHA-256(method:uri) - ha2 = hashlib.sha256(f"{method}:/{uri}".encode()).hexdigest() - # Response = SHA-256(HA1:nonce:nc:cnonce:qop:HA2) - response = hashlib.sha256(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()).hexdigest() - - # Authorization-Header - auth_str = (f'Digest username="{username}", realm="{realm}", nonce="{nonce}", ' - f'uri="/{uri}", qop={qop}, nc={nc}, cnonce="{cnonce}", ' - f'response="{response}", algorithm=SHA-256') - if opaque: - auth_str += f', opaque="{opaque}"' - return auth_str - named_tuple = time.localtime() # getstruct_time time_string = time.strftime("%m/%d/%Y, %H:%M:%S shelly watty.py", named_tuple) devicenumber = int(sys.argv[1]) @@ -134,7 +98,11 @@ def build_digest_header(url: str, method: str, username: str, password: str, aut device_info['profile'] = profile if gen != "1": url = f'http://{ipadr}/rpc/Shelly.ListProfiles' - response = requests.get(url, timeout=3) + if shaut==1: + response = requests.get(url, timeout=3, + auth=HTTPDigestAuth("admin", pw)) + else: + response = requests.get(url, timeout=3) response.raise_for_status() aread = response.text log.warning(log_pfx + " /rpc/Shelly.ListProfiles response " + aread) @@ -162,37 +130,21 @@ def build_digest_header(url: str, method: str, username: str, password: str, aut else: # No (valid) cache: We have to fetch the data: url = f'http://{ipadr}/{"status" if gen == "1" else "rpc/Shelly.GetStatus"}' - headers = {} - try: - if shaut == 1: - if gen == "1": - # Basic Auth für Gen1 - response = requests.get(url, auth=(user, pw), timeout=3) - else: - # Digest Auth für Gen2+ - response = requests.get(url, timeout=3) - if response.status_code == 401: - auth_header = response.headers.get('WWW-Authenticate') - if auth_header: - headers['Authorization'] = build_digest_header(url, 'GET', "admin", pw, auth_header) - response = requests.get(url, headers=headers, timeout=3) - else: - raise Exception("Kein WWW-Authenticate-Header in 401-Antwort.") - elif response.status_code != 200: - raise Exception(f"Unerwarteter Statuscode: {response.status_code}") + if shaut == 1: + if gen == "1": + response = requests.get(url, timeout=3, + auth=HTTPBasicAuth(user, pw)) else: - # Keine Authentifizierung erforderlich - response = requests.get(url, timeout=3) - - # Antwort verarbeiten - response.raise_for_status() - aread = response.text - answer = json.loads(aread) - with open(fname_statusrsp, 'w') as f: - json.dump(answer, f) - except requests.exceptions.RequestException as e: - log.error(f"{log_pfx}Error on data fetching: {str(e)}") - raise Exception(f"Fehler bei Datenabfrage: {e}") + response = requests.get(url, timeout=3, + auth=HTTPDigestAuth("admin", pw)) + else: + response = requests.get(url, timeout=3) + + response.raise_for_status() + aread = response.text + answer = json.loads(aread) + with open(fname_statusrsp, 'w') as f: + json.dump(answer, f) if not components: # Late device analysis, based on the first response: From 266f944399f583b9f99ef8706985e67780997a57 Mon Sep 17 00:00:00 2001 From: "Georg Z." Date: Sat, 11 Oct 2025 16:31:23 +0200 Subject: [PATCH 17/17] Fixed flake8 warnings --- packages/modules/smarthome/shelly/off.py | 2 -- packages/modules/smarthome/shelly/watt.py | 12 +++++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/modules/smarthome/shelly/off.py b/packages/modules/smarthome/shelly/off.py index 27b66a7087..bce4a2d76b 100755 --- a/packages/modules/smarthome/shelly/off.py +++ b/packages/modules/smarthome/shelly/off.py @@ -1,7 +1,5 @@ #!/usr/bin/python3 import sys -from logging import exception - import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import os diff --git a/packages/modules/smarthome/shelly/watt.py b/packages/modules/smarthome/shelly/watt.py index dcfefe7fcb..aa80846eab 100644 --- a/packages/modules/smarthome/shelly/watt.py +++ b/packages/modules/smarthome/shelly/watt.py @@ -11,12 +11,14 @@ log = logging.getLogger(__name__) + def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: if workchan > 0: return int(answer[component][workchan - 1]['power']) power_sum = sum(emeter['power'] for emeter in answer[component] if isinstance(emeter, dict) and 'power' in emeter) return int(power_sum) + named_tuple = time.localtime() # getstruct_time time_string = time.strftime("%m/%d/%Y, %H:%M:%S shelly watty.py", named_tuple) devicenumber = int(sys.argv[1]) @@ -98,7 +100,7 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: device_info['profile'] = profile if gen != "1": url = f'http://{ipadr}/rpc/Shelly.ListProfiles' - if shaut==1: + if shaut == 1: response = requests.get(url, timeout=3, auth=HTTPDigestAuth("admin", pw)) else: @@ -133,10 +135,10 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: if shaut == 1: if gen == "1": response = requests.get(url, timeout=3, - auth=HTTPBasicAuth(user, pw)) + auth=HTTPBasicAuth(user, pw)) else: response = requests.get(url, timeout=3, - auth=HTTPDigestAuth("admin", pw)) + auth=HTTPDigestAuth("admin", pw)) else: response = requests.get(url, timeout=3) @@ -172,8 +174,8 @@ def totalPowerFromShellyJson(answer: Any, workchan: int, component: str) -> int: try: if 'switch' in components: # Beim Shelly Pro 3EM mit AddOn ist die Switch-ID 100, sonst ab 0: - sw = 'switch:' + str(workchan) if not 'SPEM-003CE' in model else 'switch:100' - if not sw in answer: + sw = 'switch:' + str(workchan) if 'SPEM-003CE' not in model else 'switch:100' + if sw not in answer: # Typisch, wenn der Messwert auf einem höheren Kanal geholt werden soll sw = 'switch:0' relais = int(answer[sw]['output'])