From e2d18247e6767c36a34065914b1b703a33418645 Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Sun, 22 Mar 2026 18:12:02 +0100 Subject: [PATCH 1/7] tesla/powerwall: implement load management, add fail-fast handling and gateway diagnostics - add structured HTTP error handling in PowerwallHttpClient - distinguish transport, HTTP and JSON parsing failures - support fail_fast=False for non-critical requests - track cycle_failed and cookie_renewed state across one polling cycle - add fail-fast handling in device.py - stop remaining Tesla component updates once a critical Powerwall request fails - log native TCP connection state diagnostics via /proc/net/tcp(/tcp6) - emit periodic session / connection health logs - keep cookie reuse, but mark startup + reauth cycles explicitly - reduce /api/status traffic in counter.py - fetch firmware only after startup or after a new auth cookie was negotiated - treat /api/status as non-critical so it cannot fail the whole cycle - keep import/export counters based on aggregate["site"]["energy_imported/exported"] - do not use summed per-phase energy counters as meter source - keep /api/meters/site for phase details only - add derived fallback currents and signed power factors from P/Q/U when Tesla/Neurio currents are 0 - expose additional diagnostics such as frequency / serial number - add throttled debug logging to compare aggregate counters with per-phase sums - restore aggregate-based site counters so load management works correctly again --- .../modules/devices/tesla/tesla/counter.py | 185 ++++++++++++++++-- .../modules/devices/tesla/tesla/device.py | 150 +++++++++++++- .../devices/tesla/tesla/http_client.py | 126 +++++++++++- .../modules/devices/tesla/tesla/tesla.php | 6 +- 4 files changed, 438 insertions(+), 29 deletions(-) diff --git a/packages/modules/devices/tesla/tesla/counter.py b/packages/modules/devices/tesla/tesla/counter.py index 277048c5ca..654be31c07 100644 --- a/packages/modules/devices/tesla/tesla/counter.py +++ b/packages/modules/devices/tesla/tesla/counter.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 import logging +import math +import time from requests import HTTPError from modules.common.abstract_device import AbstractCounter @@ -17,34 +19,187 @@ class TeslaCounter(AbstractCounter): def __init__(self, component_config: TeslaCounterSetup) -> None: self.component_config = component_config + # Throttle diagnostic logging (to avoid log spam in a 10s polling cycle) + self._last_energy_debug_log_ts: float = 0.0 + def initialize(self) -> None: self.store = get_counter_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + @staticmethod + def _safe_float(val, default: float = 0.0) -> float: + try: + if val is None: + return default + return float(val) + except (TypeError, ValueError): + return default + + @staticmethod + def _nearly_zero(x: float, eps: float = 1e-9) -> bool: + return abs(x) < eps + + def _calc_currents_and_pf_from_pqu( + self, voltages: list[float], p_list: list[float], q_list: list[float] + ) -> tuple[list[float], list[float]]: + """ + Calculates signed currents (A) and signed power factors per phase from P/Q/U. + Convention: + - sign of current follows sign of active power P (import +, export -) + - PF = P / S (signed) + - S = sqrt(P^2 + Q^2) + - I = S / U (signed via P) + """ + currents: list[float] = [0.0, 0.0, 0.0] + pfs: list[float] = [0.0, 0.0, 0.0] + + for i in range(3): + u = self._safe_float(voltages[i], 0.0) + p = self._safe_float(p_list[i], 0.0) + q = self._safe_float(q_list[i], 0.0) + + if self._nearly_zero(u): + currents[i] = 0.0 + pfs[i] = 0.0 + continue + + s = math.sqrt(p * p + q * q) # apparent power [VA] + + if self._nearly_zero(s): + currents[i] = 0.0 + pfs[i] = 0.0 + continue + + # signed PF: magnitude P/S, sign from P + pf = p / s + pfs[i] = pf + + # signed current: magnitude S/U, sign from P + i_mag = s / u + currents[i] = i_mag if p >= 0 else -i_mag + + return currents, pfs + def update(self, client: PowerwallHttpClient, aggregate): - # read firmware version - status = client.get_json("/api/status") - log.debug('Firmware: ' + status["version"]) + # read firmware version only at startup or after cookie renewal + if getattr(client, "cookie_renewed", False) or not getattr(self.store, "firmware", None): + try: + status = client.get_json("/api/status", fail_fast=False) + if isinstance(status, dict): + self.store.firmware = status.get("version", "") + log.debug("Firmware: %s", self.store.firmware) + except Exception: + # Non-critical: ignore status retrieval errors + pass try: - # read additional info if firmware supports meters_site = client.get_json("/api/meters/site") + cached = meters_site[0]["Cached_readings"] + + # --- voltages / powers / reactive powers (per phase) --- + voltages = [self._safe_float(cached.get(f"v_l{phase}n")) for phase in range(1, 4)] + p_list = [self._safe_float(cached.get(f"real_power_{ph}")) for ph in ["a", "b", "c"]] + q_list = [self._safe_float(cached.get(f"reactive_power_{ph}")) for ph in ["a", "b", "c"]] + + # --- currents from API (often all 0 on Neurio/Tesla) --- + api_currents = [self._safe_float(cached.get(f"i_{ph}_current")) for ph in ["a", "b", "c"]] + + # --- energy counters --- + # IMPORTANT: + # We use ONLY aggregate["site"]["energy_imported"/"energy_exported"] as the source for + # imported/exported counters (same behaviour as the old/original module). + # + # We still *read* the per-phase energy fields (if available) ONLY for throttled diagnostic logging, + # to see whether per-phase sums diverge from aggregate counters. + def has_phase_energy(prefix: str) -> bool: + return all((prefix + s) in cached for s in ["_a", "_b", "_c"]) + + def sum_phase_energy_wh(prefix: str) -> float: + # Keep as Wh (openWB uses Wh in many places; your logs match that) + return ( + self._safe_float(cached.get(prefix + "_a")) + + self._safe_float(cached.get(prefix + "_b")) + + self._safe_float(cached.get(prefix + "_c")) + ) + + imported = self._safe_float(aggregate["site"]["energy_imported"]) + exported = self._safe_float(aggregate["site"]["energy_exported"]) + + # --- throttled diagnostics (1x per hour) --- + # Log aggregate vs. per-phase sums to spot counter mismatches without affecting behaviour. + now = time.time() + if (now - self._last_energy_debug_log_ts) >= 3600: + self._last_energy_debug_log_ts = now + + phase_imported = None + phase_exported = None + if has_phase_energy("energy_imported"): + phase_imported = sum_phase_energy_wh("energy_imported") + if has_phase_energy("energy_exported"): + phase_exported = sum_phase_energy_wh("energy_exported") + + # These totals are present in Cached_readings too, but in some firmwares they can be absurd. + cached_total_imported = cached.get("energy_imported") + cached_total_exported = cached.get("energy_exported") + + # Deltas only make sense if units match; still useful to see large divergences. + delta_imported = (phase_imported - imported) if phase_imported is not None else None + delta_exported = (phase_exported - exported) if phase_exported is not None else None + + log.info( + "Powerwall energy debug (1h): aggregate_imported=%s aggregate_exported=%s " + "phase_sum_imported=%s phase_sum_exported=%s delta_imported=%s delta_exported=%s " + "cached_total_imported=%s cached_total_exported=%s", + imported, + exported, + phase_imported, + phase_exported, + delta_imported, + delta_exported, + cached_total_imported, + cached_total_exported, + ) + + # --- calculate PF + fallback currents if missing --- + calculated_currents, power_factors = self._calc_currents_and_pf_from_pqu( + voltages=voltages, p_list=p_list, q_list=q_list + ) + + # If all API currents are 0 -> use calculated currents + if all(self._nearly_zero(i) for i in api_currents): + currents = calculated_currents + log.debug( + "Tesla/Neurio phase currents missing (all 0). Calculated currents from P/Q and U." + ) + else: + currents = api_currents + # PF still useful even if currents exist + log.debug("Using phase currents from Tesla/Neurio API.") + powerwall_state = CounterState( - imported=aggregate["site"]["energy_imported"], - exported=aggregate["site"]["energy_exported"], - power=aggregate["site"]["instant_power"], - voltages=[meters_site[0]["Cached_readings"]["v_l" + str(phase) + "n"] for phase in range(1, 4)], - currents=[meters_site[0]["Cached_readings"]["i_" + phase + "_current"] for phase in ["a", "b", "c"]], - powers=[meters_site[0]["Cached_readings"]["real_power_" + phase] for phase in ["a", "b", "c"]] + imported=imported, + exported=exported, + power=self._safe_float(aggregate["site"]["instant_power"]), + voltages=voltages, + currents=currents, + powers=p_list, + power_factors=power_factors, + frequency=int(round(self._safe_float(aggregate["site"].get("frequency", 50)))), + serial_number=str(cached.get("serial_number", "")) if cached.get("serial_number") else "", ) - except (KeyError, HTTPError): + + except (KeyError, HTTPError, IndexError, TypeError) as e: log.debug( - "Firmware seems not to provide detailed phase measurements. Fallback to total power only.") + "Firmware seems not to provide detailed phase measurements. Fallback to total power only. (%s)", + str(e), + ) powerwall_state = CounterState( - imported=aggregate["site"]["energy_imported"], - exported=aggregate["site"]["energy_exported"], - power=aggregate["site"]["instant_power"] + imported=self._safe_float(aggregate["site"]["energy_imported"]), + exported=self._safe_float(aggregate["site"]["energy_exported"]), + power=self._safe_float(aggregate["site"]["instant_power"]), ) + self.store.set(powerwall_state) component_descriptor = ComponentDescriptor(configuration_factory=TeslaCounterSetup) + diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index 1bc1703084..770101475e 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -2,7 +2,7 @@ import logging import requests from requests import HTTPError -from typing import Iterable, Union +from typing import Iterable, Union, Optional from modules.common.abstract_device import DeviceDescriptor from modules.common.component_context import SingleComponentUpdateContext @@ -19,10 +19,26 @@ def __update_components(client: PowerwallHttpClient, components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): + """Update all Powerwall components. + + Fail-fast: if any request inside a component update fails, abort the remaining + component updates for this cycle to avoid stressing the gateway during reboot. + """ aggregate = client.get_json("/api/meters/aggregates") for component in components: with SingleComponentUpdateContext(component.fault_state): component.update(client, aggregate) + # If a request failed inside the component update but was swallowed by the + # component context, the HTTP client will still mark the cycle as failed. + if getattr(client, "cycle_failed", False): + device_ip = getattr(client, "_PowerwallHttpClient__host", "") + conn_stats = _get_conn_state_counts_proc(device_ip, 443) + log.warning( + "Powerwall FAIL-FAST: aborting remaining component updates for this cycle (device_ip=%s) conn-stats=%s", + device_ip, + conn_stats if conn_stats else "{}", + ) + break def _authenticate(session: requests.Session, url: str, email: str, password: str): @@ -35,13 +51,77 @@ def _authenticate(session: requests.Session, url: str, email: str, password: str verify=False, timeout=5 ) - log.debug("Authentication endpoint send cookies %s", str(response.cookies)) + response.raise_for_status() + + # Do NOT log cookie values (secrets). Only log cookie names. + try: + cookie_names = [c.name for c in response.cookies] + except Exception: + cookie_names = [] + log.debug("Powerwall login ok, cookies set: %s", cookie_names) + return {"AuthCookie": response.cookies["AuthCookie"], "UserRecord": response.cookies["UserRecord"]} +def _get_conn_state_counts_proc(dst_ip: str, dst_port: int = 443) -> dict: + """Native alternative to `ss`: counts TCP connection states via /proc/net/tcp(+6).""" + # State codes from linux tcp_states: + state_map = { + "01": "ESTABLISHED", + "02": "SYN_SENT", + "03": "SYN_RECV", + "04": "FIN_WAIT1", + "05": "FIN_WAIT2", + "06": "TIME_WAIT", + "07": "CLOSE", + "08": "CLOSE_WAIT", + "09": "LAST_ACK", + "0A": "LISTEN", + "0B": "CLOSING", + } + + def ip_to_hex(ip: str) -> str: + # /proc/net/tcp stores IPv4 in little-endian hex + parts = ip.split(".") + if len(parts) != 4: + return "" + return "".join(f"{int(o):02X}" for o in reversed(parts)) + + target_ip_hex = ip_to_hex(dst_ip) + target_port_hex = f"{dst_port:04X}" + if not target_ip_hex: + return {} + + counts = {} + + for path in ("/proc/net/tcp", "/proc/net/tcp6"): + try: + with open(path, "r", encoding="utf-8") as f: + next(f, None) # header + for line in f: + parts = line.split() + if len(parts) < 4: + continue + remote = parts[2] # rem_address + state = parts[3] + r_ip, r_port = remote.split(":") + if r_ip == target_ip_hex and r_port == target_port_hex: + name = state_map.get(state, state) + counts[name] = counts.get(name, 0) + 1 + except FileNotFoundError: + continue + except Exception: + # best-effort: if proc parsing fails, just return what we have + continue + + return counts + + def create_device(device_config: Tesla): http_client = None - session = None + session: Optional[requests.Session] = None + last_session_id: Optional[int] = None + update_counter = 0 def create_bat_component(component_config: TeslaBatSetup): return TeslaBat(component_config) @@ -53,32 +133,83 @@ def create_inverter_component(component_config: TeslaInverterSetup): return TeslaInverter(component_config) def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): - log.debug("Beginning update") - nonlocal http_client, session + """ + This is called repeatedly by openWB. We log the session id to verify + whether openWB keeps the same requests.Session across polling cycles. + """ + nonlocal http_client, session, last_session_id, update_counter + update_counter += 1 + address = device_config.configuration.ip_address email = device_config.configuration.email password = device_config.configuration.password + current_session_id = id(session) if session is not None else None + + # Log session reuse occasionally (every 60 cycles) and whenever it changes. + if current_session_id != last_session_id: + log.info( + "Powerwall session changed device_ip=%s old_session_id=%s new_session_id=%s update_counter=%s", + address, last_session_id, current_session_id, update_counter + ) + last_session_id = current_session_id + elif update_counter % 60 == 0: + log.info( + "Powerwall session ok device_ip=%s session_id=%s update_counter=%s", + address, current_session_id, update_counter + ) + conn_stats = _get_conn_state_counts_proc(address, 443) + if conn_stats: + log.info("Powerwall conn-stats device_ip=%s update_counter=%s %s", address, update_counter, conn_stats) + + log.debug("Beginning update (device_ip=%s update_counter=%s)", address, update_counter) + + # reset fail-fast flag for this polling cycle + if hasattr(http_client, "reset_cycle"): + http_client.reset_cycle() + + # First run after process start: no cookies -> authenticate once if http_client.cookies is None: http_client.cookies = _authenticate(session, address, email, password) + http_client.mark_cookie_renewed() __update_components(http_client, components) return + + # Normal operation: reuse cookie. If it fails with 401/403 -> re-auth try: __update_components(http_client, components) return except HTTPError as e: - if e.response.status_code != 401 and e.response.status_code != 403: + status = getattr(getattr(e, "response", None), "status_code", None) + if status != 401 and status != 403: raise e - log.warning("Login to powerwall with existing cookie failed. Will retry with new cookie...") + log.warning( + "Login to powerwall with existing cookie failed (status=%s). Will retry with new cookie...", + status + ) + http_client.cookies = _authenticate(session, address, email, password) + + http_client.mark_cookie_renewed() __update_components(http_client, components) - log.debug("Update completed successfully") + log.debug("Update completed successfully (device_ip=%s update_counter=%s)", address, update_counter) def initializer(): - nonlocal http_client, session + """ + Called when openWB instantiates the device. If this happens frequently, + you'll see session_id changes (and more TLS handshakes). + """ + nonlocal http_client, session, last_session_id, update_counter + update_counter = 0 session = get_http_session() http_client = PowerwallHttpClient(device_config.configuration.ip_address, session, None) + last_session_id = id(session) + log.info( + "Powerwall device initialized device_ip=%s session_id=%s http_client_id=%s", + device_config.configuration.ip_address, last_session_id, id(http_client) + ) + return ConfigurableDevice( device_config=device_config, initializer=initializer, @@ -92,3 +223,4 @@ def initializer(): device_descriptor = DeviceDescriptor(configuration_factory=Tesla) + diff --git a/packages/modules/devices/tesla/tesla/http_client.py b/packages/modules/devices/tesla/tesla/http_client.py index f02eb15094..e983740abc 100644 --- a/packages/modules/devices/tesla/tesla/http_client.py +++ b/packages/modules/devices/tesla/tesla/http_client.py @@ -1,12 +1,134 @@ +import logging +import time +import threading + import requests +from requests.exceptions import ( + ConnectionError, + RequestException, + SSLError, + Timeout, +) + +log = logging.getLogger(__name__) class PowerwallHttpClient: + """ + HTTP client wrapper for Tesla Powerwall Gateway local API calls. + + Logging goals: + - Show whether calls are executed from a single thread or multiple threads + - Clearly distinguish: + * transport errors (timeouts, connection refused, no route, TLS) + * HTTP errors (401/403/5xx) + * JSON parse errors (HTML/text instead of JSON) + """ + def __init__(self, host: str, session: requests.Session, cookies): self.__base_url = "https://" + host + self.__host = host self.cookies = cookies self.__session = session + self.cycle_failed = False # fail-fast flag, reset each polling cycle + + def reset_cycle(self): + """Reset per-update-cycle fail-fast flag.""" + self.cycle_failed = False + + self.cookie_renewed = False # set True when new auth cookie negotiated + def mark_cookie_renewed(self): + """Mark that a new auth cookie was negotiated in this update cycle.""" + self.cookie_renewed = True - def get_json(self, relative_url: str): + def get_json(self, relative_url: str, fail_fast: bool = True): url = self.__base_url + relative_url - return self.__session.get(url, cookies=self.cookies, verify=False, timeout=5).json() + t0 = time.monotonic() + thread_name = threading.current_thread().name + thread_id = threading.get_ident() + + # --- transport / network errors (no HTTP response at all) + try: + resp = self.__session.get( + url, + cookies=self.cookies, + verify=False, + timeout=5 + ) + except (Timeout, ConnectionError, SSLError) as e: + dt_ms = int((time.monotonic() - t0) * 1000) + log.warning( + "Powerwall TRANSPORT error host=%s path=%s thread=%s(%s) dt_ms=%s err=%s", + self.__host, + relative_url, + thread_name, + thread_id, + dt_ms, + repr(e), + ) + if fail_fast: + self.cycle_failed = True + raise + except RequestException as e: + dt_ms = int((time.monotonic() - t0) * 1000) + log.warning( + "Powerwall REQUEST exception host=%s path=%s thread=%s(%s) dt_ms=%s err=%s", + self.__host, + relative_url, + thread_name, + thread_id, + dt_ms, + repr(e), + ) + if fail_fast: + self.cycle_failed = True + raise + + dt_ms = int((time.monotonic() - t0) * 1000) + ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower() + clen = resp.headers.get("Content-Length") + + # --- HTTP-level errors (we did get a response) + if resp.status_code >= 400: + log.warning( + "Powerwall HTTP error host=%s path=%s status=%s thread=%s(%s) " + "dt_ms=%s ctype=%s clen=%s", + self.__host, + relative_url, + resp.status_code, + thread_name, + thread_id, + dt_ms, + ctype, + clen, + ) + if fail_fast: + self.cycle_failed = True + resp.raise_for_status() + + # --- JSON parsing + try: + return resp.json() + except ValueError as e: + preview = "" + try: + preview = (resp.text or "")[:200].replace("\n", "\\n").replace("\r", "\\r") + except Exception: + preview = "" + + log.warning( + "Powerwall JSON parse error host=%s path=%s status=%s thread=%s(%s) " + "dt_ms=%s ctype=%s preview=%s", + self.__host, + relative_url, + resp.status_code, + thread_name, + thread_id, + dt_ms, + ctype, + preview, + ) + if fail_fast: + self.cycle_failed = True + raise + diff --git a/packages/modules/devices/tesla/tesla/tesla.php b/packages/modules/devices/tesla/tesla/tesla.php index ec396b638e..b428767d68 100644 --- a/packages/modules/devices/tesla/tesla/tesla.php +++ b/packages/modules/devices/tesla/tesla/tesla.php @@ -333,7 +333,7 @@ function teslaLogin () { ?>
Anmeldung erfolgreich!
- Die erhaltenen Token wurden gespeichert. Du kannst diese Seite jetzt schließen. + Die erhaltenen Token wurden gespeichert. Sie können diese Seite jetzt schließen.
- Gespeicherte Anmeldedaten wurden entfernt. Du kannst diese Seite jetzt schließen. + Gespeicherte Anmeldedaten wurden entfernt. Sie können diese Seite jetzt schließen.
- + \ No newline at end of file From 2d479e04241f8739392b3135549b7041e6309ba9 Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Thu, 2 Apr 2026 15:10:42 +0200 Subject: [PATCH 2/7] tesla/powerwall: simplify device/http client and keep cookie reauth handling - remove session / connection diagnostics and analysis-only helpers - remove /proc-based connection stats and periodic session logging - keep fail-fast handling for the Tesla update cycle - keep cookie reuse and 401/403 reauthentication flow - keep support for non-critical requests via fail_fast=False - reduce implementation closer to upstream while preserving Powerwall-specific fixes --- .../modules/devices/tesla/tesla/counter.py | 111 ++++--------- .../modules/devices/tesla/tesla/device.py | 155 +++--------------- .../devices/tesla/tesla/http_client.py | 123 +++----------- 3 files changed, 80 insertions(+), 309 deletions(-) diff --git a/packages/modules/devices/tesla/tesla/counter.py b/packages/modules/devices/tesla/tesla/counter.py index 654be31c07..5a37b9ca48 100644 --- a/packages/modules/devices/tesla/tesla/counter.py +++ b/packages/modules/devices/tesla/tesla/counter.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging import math -import time from requests import HTTPError from modules.common.abstract_device import AbstractCounter @@ -19,9 +18,6 @@ class TeslaCounter(AbstractCounter): def __init__(self, component_config: TeslaCounterSetup) -> None: self.component_config = component_config - # Throttle diagnostic logging (to avoid log spam in a 10s polling cycle) - self._last_energy_debug_log_ts: float = 0.0 - def initialize(self) -> None: self.store = get_counter_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) @@ -44,11 +40,12 @@ def _calc_currents_and_pf_from_pqu( ) -> tuple[list[float], list[float]]: """ Calculates signed currents (A) and signed power factors per phase from P/Q/U. + Convention: - sign of current follows sign of active power P (import +, export -) - PF = P / S (signed) - S = sqrt(P^2 + Q^2) - - I = S / U (signed via P) + - I = S / U (signed via P) """ currents: list[float] = [0.0, 0.0, 0.0] pfs: list[float] = [0.0, 0.0, 0.0] @@ -63,34 +60,38 @@ def _calc_currents_and_pf_from_pqu( pfs[i] = 0.0 continue - s = math.sqrt(p * p + q * q) # apparent power [VA] + s = math.sqrt(p * p + q * q) if self._nearly_zero(s): currents[i] = 0.0 pfs[i] = 0.0 continue - # signed PF: magnitude P/S, sign from P - pf = p / s - pfs[i] = pf - - # signed current: magnitude S/U, sign from P + pfs[i] = p / s i_mag = s / u currents[i] = i_mag if p >= 0 else -i_mag return currents, pfs def update(self, client: PowerwallHttpClient, aggregate): - # read firmware version only at startup or after cookie renewal - if getattr(client, "cookie_renewed", False) or not getattr(self.store, "firmware", None): + # read firmware version + # - only once after startup (no firmware known yet) + # - and whenever a new auth cookie was negotiated (startup or reauth) + need_status = False + if getattr(client, "cookie_renewed", False): + need_status = True + elif not getattr(self.store, "firmware", ""): + need_status = True + + if need_status: try: status = client.get_json("/api/status", fail_fast=False) - if isinstance(status, dict): - self.store.firmware = status.get("version", "") - log.debug("Firmware: %s", self.store.firmware) + self.store.firmware = status.get("version", "") + log.debug("Firmware: %s", self.store.firmware) except Exception: - # Non-critical: ignore status retrieval errors + # /api/status is non-critical and must not trigger fail-fast pass + try: meters_site = client.get_json("/api/meters/site") cached = meters_site[0]["Cached_readings"] @@ -103,78 +104,33 @@ def update(self, client: PowerwallHttpClient, aggregate): # --- currents from API (often all 0 on Neurio/Tesla) --- api_currents = [self._safe_float(cached.get(f"i_{ph}_current")) for ph in ["a", "b", "c"]] - # --- energy counters --- - # IMPORTANT: - # We use ONLY aggregate["site"]["energy_imported"/"energy_exported"] as the source for - # imported/exported counters (same behaviour as the old/original module). - # - # We still *read* the per-phase energy fields (if available) ONLY for throttled diagnostic logging, - # to see whether per-phase sums diverge from aggregate counters. - def has_phase_energy(prefix: str) -> bool: - return all((prefix + s) in cached for s in ["_a", "_b", "_c"]) - - def sum_phase_energy_wh(prefix: str) -> float: - # Keep as Wh (openWB uses Wh in many places; your logs match that) - return ( - self._safe_float(cached.get(prefix + "_a")) - + self._safe_float(cached.get(prefix + "_b")) - + self._safe_float(cached.get(prefix + "_c")) - ) - + # --- energy counters: use aggregate site values as sole source --- imported = self._safe_float(aggregate["site"]["energy_imported"]) exported = self._safe_float(aggregate["site"]["energy_exported"]) - # --- throttled diagnostics (1x per hour) --- - # Log aggregate vs. per-phase sums to spot counter mismatches without affecting behaviour. - now = time.time() - if (now - self._last_energy_debug_log_ts) >= 3600: - self._last_energy_debug_log_ts = now - - phase_imported = None - phase_exported = None - if has_phase_energy("energy_imported"): - phase_imported = sum_phase_energy_wh("energy_imported") - if has_phase_energy("energy_exported"): - phase_exported = sum_phase_energy_wh("energy_exported") - - # These totals are present in Cached_readings too, but in some firmwares they can be absurd. - cached_total_imported = cached.get("energy_imported") - cached_total_exported = cached.get("energy_exported") - - # Deltas only make sense if units match; still useful to see large divergences. - delta_imported = (phase_imported - imported) if phase_imported is not None else None - delta_exported = (phase_exported - exported) if phase_exported is not None else None - - log.info( - "Powerwall energy debug (1h): aggregate_imported=%s aggregate_exported=%s " - "phase_sum_imported=%s phase_sum_exported=%s delta_imported=%s delta_exported=%s " - "cached_total_imported=%s cached_total_exported=%s", - imported, - exported, - phase_imported, - phase_exported, - delta_imported, - delta_exported, - cached_total_imported, - cached_total_exported, - ) - - # --- calculate PF + fallback currents if missing --- + # --- local fallback for Tesla/Neurio setups with missing phase currents --- calculated_currents, power_factors = self._calc_currents_and_pf_from_pqu( - voltages=voltages, p_list=p_list, q_list=q_list + voltages=voltages, + p_list=p_list, + q_list=q_list, ) - # If all API currents are 0 -> use calculated currents + if all(self._nearly_zero(i) for i in api_currents): currents = calculated_currents log.debug( - "Tesla/Neurio phase currents missing (all 0). Calculated currents from P/Q and U." + "Tesla/Neurio phase currents missing (all 0). " + "Calculated currents locally from P/Q and U." ) else: currents = api_currents - # PF still useful even if currents exist log.debug("Using phase currents from Tesla/Neurio API.") + freq = self._safe_float(aggregate["site"].get("frequency", 50.0), 50.0) + + serial = cached.get("serial_number") + serial_number = str(serial) if serial else None + powerwall_state = CounterState( imported=imported, exported=exported, @@ -183,8 +139,8 @@ def sum_phase_energy_wh(prefix: str) -> float: currents=currents, powers=p_list, power_factors=power_factors, - frequency=int(round(self._safe_float(aggregate["site"].get("frequency", 50)))), - serial_number=str(cached.get("serial_number", "")) if cached.get("serial_number") else "", + frequency=round(freq, 2), + serial_number=serial_number, ) except (KeyError, HTTPError, IndexError, TypeError) as e: @@ -202,4 +158,3 @@ def sum_phase_energy_wh(prefix: str) -> float: component_descriptor = ComponentDescriptor(configuration_factory=TeslaCounterSetup) - diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index 770101475e..b08d7cd63f 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -17,27 +17,18 @@ log = logging.getLogger(__name__) -def __update_components(client: PowerwallHttpClient, - components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): - """Update all Powerwall components. - - Fail-fast: if any request inside a component update fails, abort the remaining - component updates for this cycle to avoid stressing the gateway during reboot. - """ +def __update_components( + client: PowerwallHttpClient, + components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]], +): aggregate = client.get_json("/api/meters/aggregates") + for component in components: with SingleComponentUpdateContext(component.fault_state): component.update(client, aggregate) - # If a request failed inside the component update but was swallowed by the - # component context, the HTTP client will still mark the cycle as failed. - if getattr(client, "cycle_failed", False): - device_ip = getattr(client, "_PowerwallHttpClient__host", "") - conn_stats = _get_conn_state_counts_proc(device_ip, 443) - log.warning( - "Powerwall FAIL-FAST: aborting remaining component updates for this cycle (device_ip=%s) conn-stats=%s", - device_ip, - conn_stats if conn_stats else "{}", - ) + + # FAIL-FAST: abort remaining components if any critical request failed in this cycle + if client.cycle_failed: break @@ -49,79 +40,16 @@ def _authenticate(session: requests.Session, url: str, email: str, password: str "https://" + url + "/api/login/Basic", json={"username": "customer", "email": email, "password": password, "force_sm_off": False}, verify=False, - timeout=5 + timeout=5, ) response.raise_for_status() - # Do NOT log cookie values (secrets). Only log cookie names. - try: - cookie_names = [c.name for c in response.cookies] - except Exception: - cookie_names = [] - log.debug("Powerwall login ok, cookies set: %s", cookie_names) - return {"AuthCookie": response.cookies["AuthCookie"], "UserRecord": response.cookies["UserRecord"]} -def _get_conn_state_counts_proc(dst_ip: str, dst_port: int = 443) -> dict: - """Native alternative to `ss`: counts TCP connection states via /proc/net/tcp(+6).""" - # State codes from linux tcp_states: - state_map = { - "01": "ESTABLISHED", - "02": "SYN_SENT", - "03": "SYN_RECV", - "04": "FIN_WAIT1", - "05": "FIN_WAIT2", - "06": "TIME_WAIT", - "07": "CLOSE", - "08": "CLOSE_WAIT", - "09": "LAST_ACK", - "0A": "LISTEN", - "0B": "CLOSING", - } - - def ip_to_hex(ip: str) -> str: - # /proc/net/tcp stores IPv4 in little-endian hex - parts = ip.split(".") - if len(parts) != 4: - return "" - return "".join(f"{int(o):02X}" for o in reversed(parts)) - - target_ip_hex = ip_to_hex(dst_ip) - target_port_hex = f"{dst_port:04X}" - if not target_ip_hex: - return {} - - counts = {} - - for path in ("/proc/net/tcp", "/proc/net/tcp6"): - try: - with open(path, "r", encoding="utf-8") as f: - next(f, None) # header - for line in f: - parts = line.split() - if len(parts) < 4: - continue - remote = parts[2] # rem_address - state = parts[3] - r_ip, r_port = remote.split(":") - if r_ip == target_ip_hex and r_port == target_port_hex: - name = state_map.get(state, state) - counts[name] = counts.get(name, 0) + 1 - except FileNotFoundError: - continue - except Exception: - # best-effort: if proc parsing fails, just return what we have - continue - - return counts - - def create_device(device_config: Tesla): - http_client = None + http_client: Optional[PowerwallHttpClient] = None session: Optional[requests.Session] = None - last_session_id: Optional[int] = None - update_counter = 0 def create_bat_component(component_config: TeslaBatSetup): return TeslaBat(component_config) @@ -132,41 +60,24 @@ def create_counter_component(component_config: TeslaCounterSetup): def create_inverter_component(component_config: TeslaInverterSetup): return TeslaInverter(component_config) + def _ensure_initialized(): + nonlocal http_client, session + if session is None or http_client is None: + initializer() + if session is None or http_client is None: + raise Exception("Powerwall device initializer did not create session/http_client") + def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): - """ - This is called repeatedly by openWB. We log the session id to verify - whether openWB keeps the same requests.Session across polling cycles. - """ - nonlocal http_client, session, last_session_id, update_counter - update_counter += 1 + nonlocal http_client, session address = device_config.configuration.ip_address email = device_config.configuration.email password = device_config.configuration.password - current_session_id = id(session) if session is not None else None - - # Log session reuse occasionally (every 60 cycles) and whenever it changes. - if current_session_id != last_session_id: - log.info( - "Powerwall session changed device_ip=%s old_session_id=%s new_session_id=%s update_counter=%s", - address, last_session_id, current_session_id, update_counter - ) - last_session_id = current_session_id - elif update_counter % 60 == 0: - log.info( - "Powerwall session ok device_ip=%s session_id=%s update_counter=%s", - address, current_session_id, update_counter - ) - conn_stats = _get_conn_state_counts_proc(address, 443) - if conn_stats: - log.info("Powerwall conn-stats device_ip=%s update_counter=%s %s", address, update_counter, conn_stats) + _ensure_initialized() - log.debug("Beginning update (device_ip=%s update_counter=%s)", address, update_counter) - - # reset fail-fast flag for this polling cycle - if hasattr(http_client, "reset_cycle"): - http_client.reset_cycle() + # Reset per-cycle flags (fail-fast + cookie-renewed marker) + http_client.reset_cycle() # First run after process start: no cookies -> authenticate once if http_client.cookies is None: @@ -181,35 +92,22 @@ def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaIn return except HTTPError as e: status = getattr(getattr(e, "response", None), "status_code", None) - if status != 401 and status != 403: - raise e + if status not in (401, 403): + raise log.warning( "Login to powerwall with existing cookie failed (status=%s). Will retry with new cookie...", - status + status, ) http_client.cookies = _authenticate(session, address, email, password) - http_client.mark_cookie_renewed() __update_components(http_client, components) - log.debug("Update completed successfully (device_ip=%s update_counter=%s)", address, update_counter) def initializer(): - """ - Called when openWB instantiates the device. If this happens frequently, - you'll see session_id changes (and more TLS handshakes). - """ - nonlocal http_client, session, last_session_id, update_counter - update_counter = 0 + nonlocal http_client, session session = get_http_session() http_client = PowerwallHttpClient(device_config.configuration.ip_address, session, None) - last_session_id = id(session) - log.info( - "Powerwall device initialized device_ip=%s session_id=%s http_client_id=%s", - device_config.configuration.ip_address, last_session_id, id(http_client) - ) - return ConfigurableDevice( device_config=device_config, initializer=initializer, @@ -218,9 +116,8 @@ def initializer(): counter=create_counter_component, inverter=create_inverter_component, ), - component_updater=MultiComponentUpdater(update_components) + component_updater=MultiComponentUpdater(update_components), ) device_descriptor = DeviceDescriptor(configuration_factory=Tesla) - diff --git a/packages/modules/devices/tesla/tesla/http_client.py b/packages/modules/devices/tesla/tesla/http_client.py index e983740abc..4c6ef30350 100644 --- a/packages/modules/devices/tesla/tesla/http_client.py +++ b/packages/modules/devices/tesla/tesla/http_client.py @@ -1,14 +1,7 @@ import logging -import time -import threading import requests -from requests.exceptions import ( - ConnectionError, - RequestException, - SSLError, - Timeout, -) +from requests.exceptions import HTTPError, ConnectionError, RequestException, SSLError, Timeout log = logging.getLogger(__name__) @@ -16,119 +9,45 @@ class PowerwallHttpClient: """ HTTP client wrapper for Tesla Powerwall Gateway local API calls. - - Logging goals: - - Show whether calls are executed from a single thread or multiple threads - - Clearly distinguish: - * transport errors (timeouts, connection refused, no route, TLS) - * HTTP errors (401/403/5xx) - * JSON parse errors (HTML/text instead of JSON) """ def __init__(self, host: str, session: requests.Session, cookies): self.__base_url = "https://" + host - self.__host = host self.cookies = cookies self.__session = session - self.cycle_failed = False # fail-fast flag, reset each polling cycle + + # Fail-fast marker for the current polling cycle + self.cycle_failed = False + + # Marker: set when a new auth cookie was negotiated (startup or reauth) + self.cookie_renewed = False def reset_cycle(self): - """Reset per-update-cycle fail-fast flag.""" + """Reset per update-cycle flags.""" self.cycle_failed = False + self.cookie_renewed = False - self.cookie_renewed = False # set True when new auth cookie negotiated def mark_cookie_renewed(self): - """Mark that a new auth cookie was negotiated in this update cycle.""" + """Mark that cookies have been freshly negotiated (start or reauth).""" self.cookie_renewed = True - def get_json(self, relative_url: str, fail_fast: bool = True): + def get_json(self, relative_url: str, *, fail_fast: bool = True): + """ + :param fail_fast: + True -> errors mark this cycle as failed (device aborts remaining components) + False -> errors do NOT mark cycle_failed (for non-critical endpoints like /api/status) + """ url = self.__base_url + relative_url - t0 = time.monotonic() - thread_name = threading.current_thread().name - thread_id = threading.get_ident() - # --- transport / network errors (no HTTP response at all) try: - resp = self.__session.get( + response = self.__session.get( url, cookies=self.cookies, verify=False, - timeout=5 - ) - except (Timeout, ConnectionError, SSLError) as e: - dt_ms = int((time.monotonic() - t0) * 1000) - log.warning( - "Powerwall TRANSPORT error host=%s path=%s thread=%s(%s) dt_ms=%s err=%s", - self.__host, - relative_url, - thread_name, - thread_id, - dt_ms, - repr(e), - ) - if fail_fast: - self.cycle_failed = True - raise - except RequestException as e: - dt_ms = int((time.monotonic() - t0) * 1000) - log.warning( - "Powerwall REQUEST exception host=%s path=%s thread=%s(%s) dt_ms=%s err=%s", - self.__host, - relative_url, - thread_name, - thread_id, - dt_ms, - repr(e), - ) - if fail_fast: - self.cycle_failed = True - raise - - dt_ms = int((time.monotonic() - t0) * 1000) - ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower() - clen = resp.headers.get("Content-Length") - - # --- HTTP-level errors (we did get a response) - if resp.status_code >= 400: - log.warning( - "Powerwall HTTP error host=%s path=%s status=%s thread=%s(%s) " - "dt_ms=%s ctype=%s clen=%s", - self.__host, - relative_url, - resp.status_code, - thread_name, - thread_id, - dt_ms, - ctype, - clen, - ) - if fail_fast: - self.cycle_failed = True - resp.raise_for_status() - - # --- JSON parsing - try: - return resp.json() - except ValueError as e: - preview = "" - try: - preview = (resp.text or "")[:200].replace("\n", "\\n").replace("\r", "\\r") - except Exception: - preview = "" - - log.warning( - "Powerwall JSON parse error host=%s path=%s status=%s thread=%s(%s) " - "dt_ms=%s ctype=%s preview=%s", - self.__host, - relative_url, - resp.status_code, - thread_name, - thread_id, - dt_ms, - ctype, - preview, + timeout=5, ) + return response.json() + except (HTTPError, Timeout, ConnectionError, SSLError, RequestException, ValueError): if fail_fast: self.cycle_failed = True - raise - + raise \ No newline at end of file From 2346e0183b90669d27717d582f57c292cbf6e885 Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Sun, 12 Apr 2026 12:04:41 +0200 Subject: [PATCH 3/7] tesla/powerwall: simplify device/http client and keep local current fallback - remove analysis-only diagnostics and session tracking from device.py - remove connection stats and other debug-only helpers - simplify http_client.py to a minimal request wrapper - keep cookie reuse and 401/403 reauthentication flow in device.py - use fail-fast handling in device.py via SingleComponentUpdateContext(..., reraise=True) - move Tesla-specific robustness back into the counter module only - keep aggregate["site"]["energy_imported/exported"] as sole source for EVU counters - keep local current and power factor calculation when Neurio/Tesla returns only 0 currents - remove /api/status handling from counter.py - fix formatting / lint issues (newline at EOF, spaces instead of tabs) --- .../modules/devices/tesla/tesla/counter.py | 36 ++++++------- .../modules/devices/tesla/tesla/device.py | 34 ++++++------ .../devices/tesla/tesla/http_client.py | 52 +++---------------- 3 files changed, 44 insertions(+), 78 deletions(-) diff --git a/packages/modules/devices/tesla/tesla/counter.py b/packages/modules/devices/tesla/tesla/counter.py index 5a37b9ca48..706d5967dc 100644 --- a/packages/modules/devices/tesla/tesla/counter.py +++ b/packages/modules/devices/tesla/tesla/counter.py @@ -74,23 +74,23 @@ def _calc_currents_and_pf_from_pqu( return currents, pfs def update(self, client: PowerwallHttpClient, aggregate): - # read firmware version - # - only once after startup (no firmware known yet) - # - and whenever a new auth cookie was negotiated (startup or reauth) - need_status = False - if getattr(client, "cookie_renewed", False): - need_status = True - elif not getattr(self.store, "firmware", ""): - need_status = True - - if need_status: - try: - status = client.get_json("/api/status", fail_fast=False) - self.store.firmware = status.get("version", "") - log.debug("Firmware: %s", self.store.firmware) - except Exception: - # /api/status is non-critical and must not trigger fail-fast - pass + + + + + + + + + + + + + + + + + try: meters_site = client.get_json("/api/meters/site") @@ -115,7 +115,7 @@ def update(self, client: PowerwallHttpClient, aggregate): q_list=q_list, ) - + if all(self._nearly_zero(i) for i in api_currents): currents = calculated_currents log.debug( diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index b08d7cd63f..6b442edd4a 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -24,11 +24,13 @@ def __update_components( aggregate = client.get_json("/api/meters/aggregates") for component in components: - with SingleComponentUpdateContext(component.fault_state): - component.update(client, aggregate) - - # FAIL-FAST: abort remaining components if any critical request failed in this cycle - if client.cycle_failed: + try: + # For Tesla/Powerwall we want fail-fast behaviour: + # if one critical component update fails (especially EVU / house transition point), + # abort the remaining Tesla component updates in this cycle. + with SingleComponentUpdateContext(component.fault_state, reraise=True): + component.update(client, aggregate) + except Exception: break @@ -60,12 +62,12 @@ def create_counter_component(component_config: TeslaCounterSetup): def create_inverter_component(component_config: TeslaInverterSetup): return TeslaInverter(component_config) - def _ensure_initialized(): - nonlocal http_client, session - if session is None or http_client is None: - initializer() - if session is None or http_client is None: - raise Exception("Powerwall device initializer did not create session/http_client") + + + + + + def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): nonlocal http_client, session @@ -74,15 +76,15 @@ def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaIn email = device_config.configuration.email password = device_config.configuration.password - _ensure_initialized() + - # Reset per-cycle flags (fail-fast + cookie-renewed marker) - http_client.reset_cycle() + + # First run after process start: no cookies -> authenticate once if http_client.cookies is None: http_client.cookies = _authenticate(session, address, email, password) - http_client.mark_cookie_renewed() + __update_components(http_client, components) return @@ -100,7 +102,7 @@ def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaIn ) http_client.cookies = _authenticate(session, address, email, password) - http_client.mark_cookie_renewed() + __update_components(http_client, components) def initializer(): diff --git a/packages/modules/devices/tesla/tesla/http_client.py b/packages/modules/devices/tesla/tesla/http_client.py index 4c6ef30350..0d45803119 100644 --- a/packages/modules/devices/tesla/tesla/http_client.py +++ b/packages/modules/devices/tesla/tesla/http_client.py @@ -1,53 +1,17 @@ -import logging - import requests -from requests.exceptions import HTTPError, ConnectionError, RequestException, SSLError, Timeout - -log = logging.getLogger(__name__) class PowerwallHttpClient: - """ - HTTP client wrapper for Tesla Powerwall Gateway local API calls. - """ - def __init__(self, host: str, session: requests.Session, cookies): self.__base_url = "https://" + host self.cookies = cookies self.__session = session - # Fail-fast marker for the current polling cycle - self.cycle_failed = False - - # Marker: set when a new auth cookie was negotiated (startup or reauth) - self.cookie_renewed = False - - def reset_cycle(self): - """Reset per update-cycle flags.""" - self.cycle_failed = False - self.cookie_renewed = False - - def mark_cookie_renewed(self): - """Mark that cookies have been freshly negotiated (start or reauth).""" - self.cookie_renewed = True - - def get_json(self, relative_url: str, *, fail_fast: bool = True): - """ - :param fail_fast: - True -> errors mark this cycle as failed (device aborts remaining components) - False -> errors do NOT mark cycle_failed (for non-critical endpoints like /api/status) - """ - url = self.__base_url + relative_url - - try: - response = self.__session.get( - url, - cookies=self.cookies, - verify=False, - timeout=5, - ) - return response.json() - except (HTTPError, Timeout, ConnectionError, SSLError, RequestException, ValueError): - if fail_fast: - self.cycle_failed = True - raise \ No newline at end of file + def get_json(self, relative_url: str): + response = self.__session.get( + self.__base_url + relative_url, + cookies=self.cookies, + verify=False, + timeout=5, + ) + return response.json() From aaed2492133cf7c15511f109b894e5cf46175b39 Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Wed, 2 Sep 2026 17:39:58 +0200 Subject: [PATCH 4/7] Fix Tesla error handling and compatibility note - use central openWB component error handling - remove Tesla-specific fail-fast handling - rely on the shared HTTP response hook - remove obsolete local API compatibility warning --- .../modules/devices/tesla/tesla/device.py | 125 ++++++++++++------ 1 file changed, 87 insertions(+), 38 deletions(-) diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index 47ad9dab05..82bc85edcb 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -2,14 +2,23 @@ import logging import requests from requests import HTTPError -from typing import Iterable, Union, Optional +from typing import Iterable, Optional, Union from modules.common.abstract_device import DeviceDescriptor from modules.common.component_context import SingleComponentUpdateContext -from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater +from modules.common.configurable_device import ( + ComponentFactoryByType, + ConfigurableDevice, + MultiComponentUpdater, +) from modules.common.req import get_http_session from modules.devices.tesla.tesla.bat import TeslaBat -from modules.devices.tesla.tesla.config import Tesla, TeslaBatSetup, TeslaCounterSetup, TeslaInverterSetup +from modules.devices.tesla.tesla.config import ( + Tesla, + TeslaBatSetup, + TeslaCounterSetup, + TeslaInverterSetup, +) from modules.devices.tesla.tesla.counter import TeslaCounter from modules.devices.tesla.tesla.http_client import PowerwallHttpClient from modules.devices.tesla.tesla.inverter import TeslaInverter @@ -24,29 +33,35 @@ def __update_components( aggregate = client.get_json("/api/meters/aggregates") for component in components: - try: - # For Tesla/Powerwall we want fail-fast behaviour: - # if one critical component update fails (especially EVU / house transition point), - # abort the remaining Tesla component updates in this cycle. - with SingleComponentUpdateContext(component.fault_state, reraise=True): - component.update(client, aggregate) - except Exception: - break + with SingleComponentUpdateContext(component.fault_state): + component.update(client, aggregate) -def _authenticate(session: requests.Session, url: str, email: str, password: str): +def _authenticate( + session: requests.Session, + url: str, + email: str, + password: str, +): """ email is not yet required for login (2022/01), but we simulate the whole login page """ response = session.post( "https://" + url + "/api/login/Basic", - json={"username": "customer", "email": email, "password": password, "force_sm_off": False}, + json={ + "username": "customer", + "email": email, + "password": password, + "force_sm_off": False, + }, verify=False, timeout=5, ) - response.raise_for_status() - return {"AuthCookie": response.cookies["AuthCookie"], "UserRecord": response.cookies["UserRecord"]} + return { + "AuthCookie": response.cookies["AuthCookie"], + "UserRecord": response.cookies["UserRecord"], + } def create_device(device_config: Tesla): @@ -62,52 +77,89 @@ def create_counter_component(component_config: TeslaCounterSetup): def create_inverter_component(component_config: TeslaInverterSetup): return TeslaInverter(component_config) - - - - - - - - def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]): + def update_components( + components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]], + ): nonlocal http_client, session + log.debug("Beginning update") + address = device_config.configuration.ip_address email = device_config.configuration.email - password = device_config.configuration.password + password = device_config.configuration.password - # First run after process start: no cookies -> authenticate once if http_client.cookies is None: - http_client.cookies = _authenticate(session, address, email, password) - + http_client.cookies = _authenticate( + session, + address, + email, + password, + ) __update_components(http_client, components) return - # Normal operation: reuse cookie. If it fails with 401/403 -> re-auth try: __update_components(http_client, components) return except HTTPError as e: - status = getattr(getattr(e, "response", None), "status_code", None) + status = getattr( + getattr(e, "response", None), + "status_code", + None, + ) + if status not in (401, 403): raise + log.warning( - "Login to powerwall with existing cookie failed (status=%s). Will retry with new cookie...", + "Login to powerwall with existing cookie failed " + "(status=%s). Will retry with new cookie...", status, ) - http_client.cookies = _authenticate(session, address, email, password) - + http_client.cookies = _authenticate( + session, + address, + email, + password, + ) __update_components(http_client, components) def initializer(): nonlocal http_client, session + + address = device_config.configuration.ip_address + email = device_config.configuration.email + password = device_config.configuration.password + session = get_http_session() - http_client = PowerwallHttpClient(device_config.configuration.ip_address, session, None) + + http_client = PowerwallHttpClient( + address, + session, + None, + ) + + http_client.cookies = _authenticate( + session, + address, + email, + password, + ) + + try: + status = http_client.get_json("/api/status") + log.debug("Firmware: %s", status["version"]) + except (KeyError, requests.RequestException, ValueError): + log.debug( + "Could not read Tesla Powerwall firmware version.", + exc_info=True, + ) return ConfigurableDevice( device_config=device_config, initializer=initializer, + error_handler=initializer, component_factory=ComponentFactoryByType( bat=create_bat_component, counter=create_counter_component, @@ -118,8 +170,5 @@ def initializer(): device_descriptor = DeviceDescriptor( - configuration_factory=Tesla, - compatibility_device_note="Tesla hat die lokale API Anfang 2025 mittels Firmwareupdate abgeschaltet.\nFür " - "Neuanlagen muss aktuell auf unsere Zählerkits zurückgegriffen werden.", - special_icon="ℹ️" -) + configuration_factory=Tesla +) \ No newline at end of file From 28031273a1200794ea10d64247f0fe737b531c05 Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Wed, 2 Sep 2026 17:45:03 +0200 Subject: [PATCH 5/7] se standard error handling in Tesla counter - remove _safe_float and module-specific defaults - propagate HTTP errors to central error handling - keep compatibility fallback for missing phase data - calculate phase currents from P/Q/U when Tesla reports zero currents --- .../modules/devices/tesla/tesla/counter.py | 121 +++++++++--------- 1 file changed, 59 insertions(+), 62 deletions(-) diff --git a/packages/modules/devices/tesla/tesla/counter.py b/packages/modules/devices/tesla/tesla/counter.py index 07b1c1039d..0f20223592 100644 --- a/packages/modules/devices/tesla/tesla/counter.py +++ b/packages/modules/devices/tesla/tesla/counter.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging import math -from requests import HTTPError from modules.common.abstract_device import AbstractCounter from modules.common.component_state import CounterState @@ -19,24 +18,23 @@ def __init__(self, component_config: TeslaCounterSetup) -> None: self.component_config = component_config def initialize(self) -> None: - self.store = get_component_value_store(self.component_config.type, self.component_config.id) - self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) - - @staticmethod - def _safe_float(val, default: float = 0.0) -> float: - try: - if val is None: - return default - return float(val) - except (TypeError, ValueError): - return default + self.store = get_component_value_store( + self.component_config.type, + self.component_config.id, + ) + self.fault_state = FaultState( + ComponentInfo.from_component_config(self.component_config) + ) @staticmethod def _nearly_zero(x: float, eps: float = 1e-9) -> bool: return abs(x) < eps def _calc_currents_and_pf_from_pqu( - self, voltages: list[float], p_list: list[float], q_list: list[float] + self, + voltages: list[float], + p_list: list[float], + q_list: list[float], ) -> tuple[list[float], list[float]]: """ Calculates signed currents (A) and signed power factors per phase from P/Q/U. @@ -51,9 +49,9 @@ def _calc_currents_and_pf_from_pqu( pfs: list[float] = [0.0, 0.0, 0.0] for i in range(3): - u = self._safe_float(voltages[i], 0.0) - p = self._safe_float(p_list[i], 0.0) - q = self._safe_float(q_list[i], 0.0) + u = voltages[i] + p = p_list[i] + q = q_list[i] if self._nearly_zero(u): currents[i] = 0.0 @@ -74,48 +72,41 @@ def _calc_currents_and_pf_from_pqu( return currents, pfs def update(self, client: PowerwallHttpClient, aggregate): - - - - - - - - - - - - - - - - - - try: meters_site = client.get_json("/api/meters/site") cached = meters_site[0]["Cached_readings"] - # --- voltages / powers / reactive powers (per phase) --- - voltages = [self._safe_float(cached.get(f"v_l{phase}n")) for phase in range(1, 4)] - p_list = [self._safe_float(cached.get(f"real_power_{ph}")) for ph in ["a", "b", "c"]] - q_list = [self._safe_float(cached.get(f"reactive_power_{ph}")) for ph in ["a", "b", "c"]] - - # --- currents from API (often all 0 on Neurio/Tesla) --- - api_currents = [self._safe_float(cached.get(f"i_{ph}_current")) for ph in ["a", "b", "c"]] - - # --- energy counters: use aggregate site values as sole source --- - imported = self._safe_float(aggregate["site"]["energy_imported"]) - exported = self._safe_float(aggregate["site"]["energy_exported"]) - - # --- local fallback for Tesla/Neurio setups with missing phase currents --- - calculated_currents, power_factors = self._calc_currents_and_pf_from_pqu( - voltages=voltages, - p_list=p_list, - q_list=q_list, + voltages = [ + float(cached[f"v_l{phase}n"]) + for phase in range(1, 4) + ] + + p_list = [ + float(cached[f"real_power_{phase}"]) + for phase in ["a", "b", "c"] + ] + + q_list = [ + float(cached[f"reactive_power_{phase}"]) + for phase in ["a", "b", "c"] + ] + + api_currents = [ + float(cached[f"i_{phase}_current"]) + for phase in ["a", "b", "c"] + ] + + imported = aggregate["site"]["energy_imported"] + exported = aggregate["site"]["energy_exported"] + + calculated_currents, power_factors = ( + self._calc_currents_and_pf_from_pqu( + voltages=voltages, + p_list=p_list, + q_list=q_list, + ) ) - if all(self._nearly_zero(i) for i in api_currents): currents = calculated_currents log.debug( @@ -124,9 +115,11 @@ def update(self, client: PowerwallHttpClient, aggregate): ) else: currents = api_currents - log.debug("Using phase currents from Tesla/Neurio API.") + log.debug( + "Using phase currents from Tesla/Neurio API." + ) - freq = self._safe_float(aggregate["site"].get("frequency", 50.0), 50.0) + frequency = float(aggregate["site"]["frequency"]) serial = cached.get("serial_number") serial_number = str(serial) if serial else None @@ -134,27 +127,31 @@ def update(self, client: PowerwallHttpClient, aggregate): powerwall_state = CounterState( imported=imported, exported=exported, - power=self._safe_float(aggregate["site"]["instant_power"]), + power=aggregate["site"]["instant_power"], voltages=voltages, currents=currents, powers=p_list, power_factors=power_factors, - frequency=round(freq, 2), + frequency=round(frequency, 2), serial_number=serial_number, ) - except (KeyError, HTTPError, IndexError, TypeError) as e: + except (KeyError, IndexError, TypeError, ValueError) as e: log.debug( - "Firmware seems not to provide detailed phase measurements. Fallback to total power only. (%s)", + "Firmware seems not to provide detailed phase measurements. " + "Fallback to total power only. (%s)", str(e), ) + powerwall_state = CounterState( - imported=self._safe_float(aggregate["site"]["energy_imported"]), - exported=self._safe_float(aggregate["site"]["energy_exported"]), - power=self._safe_float(aggregate["site"]["instant_power"]), + imported=aggregate["site"]["energy_imported"], + exported=aggregate["site"]["energy_exported"], + power=aggregate["site"]["instant_power"], ) self.store.set(powerwall_state) -component_descriptor = ComponentDescriptor(configuration_factory=TeslaCounterSetup) +component_descriptor = ComponentDescriptor( + configuration_factory=TeslaCounterSetup +) \ No newline at end of file From ace965e68b7ec22a2c2abe74352c6819281b5ed3 Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Thu, 17 Sep 2026 16:22:53 +0200 Subject: [PATCH 6/7] Fix Tesla module lint errors --- packages/modules/devices/tesla/tesla/counter.py | 2 +- packages/modules/devices/tesla/tesla/device.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/modules/devices/tesla/tesla/counter.py b/packages/modules/devices/tesla/tesla/counter.py index 0f20223592..586214e9dd 100644 --- a/packages/modules/devices/tesla/tesla/counter.py +++ b/packages/modules/devices/tesla/tesla/counter.py @@ -154,4 +154,4 @@ def update(self, client: PowerwallHttpClient, aggregate): component_descriptor = ComponentDescriptor( configuration_factory=TeslaCounterSetup -) \ No newline at end of file +) diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index 82bc85edcb..21bbab52f1 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -80,8 +80,6 @@ def create_inverter_component(component_config: TeslaInverterSetup): def update_components( components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]], ): - nonlocal http_client, session - log.debug("Beginning update") address = device_config.configuration.ip_address @@ -171,4 +169,4 @@ def initializer(): device_descriptor = DeviceDescriptor( configuration_factory=Tesla -) \ No newline at end of file +) From 2a038cc31118e85626ae2ad230b32935e0c1236a Mon Sep 17 00:00:00 2001 From: NicoP-codeing Date: Sun, 27 Sep 2026 11:55:30 +0200 Subject: [PATCH 7/7] Adjust Tesla API compatibility note --- packages/modules/devices/tesla/tesla/device.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/modules/devices/tesla/tesla/device.py b/packages/modules/devices/tesla/tesla/device.py index 21bbab52f1..590089c798 100644 --- a/packages/modules/devices/tesla/tesla/device.py +++ b/packages/modules/devices/tesla/tesla/device.py @@ -168,5 +168,12 @@ def initializer(): device_descriptor = DeviceDescriptor( - configuration_factory=Tesla + configuration_factory=Tesla, + compatibility_device_note=( + "Tesla hat die lokale API mittels Firmwareupdate eingeschränkt.\n" + "Je nach Modell und Firmware-Version kann die Verwendung unserer " + "Zähler-Kits erforderlich sein, falls die direkte Anbindung nicht " + "funktioniert." + ), + special_icon="ℹ️", )