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