Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 124 additions & 14 deletions packages/modules/devices/tesla/tesla/counter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import logging
import math
from requests import HTTPError

from modules.common.abstract_device import AbstractCounter
Expand All @@ -21,29 +22,138 @@
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default Werte werden zentral im CounterState gesetzt, damit sie konsistent sind.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bitte die Methode entfernen. Wenn Werte in einem Zyklus fehlen, wird eine Exception geworfen.
Es wird inkonsistent und der Code in jedem Modul sehr umfangreich, wenn überall default-Werte gesetzt werden.

try:
if val is None:
return default
return float(val)
except (TypeError, ValueError):
return default

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Die Methode und die Aufrufe kann entfallen, das ist zentral umgesetzt.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Danke, ich entferne _safe_float() und verlasse mich auf die zentrale Behandlung. Mein ursprünglicher Gedanke war, unvollständige Tesla-Antworten robuster abzufangen.


@staticmethod
def _nearly_zero(x: float, eps: float = 1e-9) -> bool:
return abs(x) < eps

def _calc_currents_and_pf_from_pqu(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diese Berechnung erfolgt zentral im CounterState, wenn keine Ströme gesetzt sind, aber Phasenspannungen und Phasenleistungen vorhanden sind. Die Berechnung soll nicht in jedem Modul erfolgen.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im CounterState sind jedoch nur die Wirkleistungen vorhanden. Reale Ströme können dann nicht berechnet werden. Das sollte in einem anderen PR grundlegend überarbeitet werden.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Danke für den Hinweis.

Dann würde ich für diesen PR die lokale Stromberechnung im Tesla-Counter beibehalten. In meinem Setup liefert Tesla/Neurio für die Phase currents nur 0-Werte, und ohne diese Ableitung funktioniert das Lastmanagement nicht zuverlässig.

Den generischen Ansatz zur Berechnung realer currents im CounterState würde ich nicht in diesem PR lösen. Das wäre aus meiner Sicht ein separates Thema, wie von dir vorgeschlagen.

Ich reduziere den PR daher weiter auf:

reduzierte /api/status-Nutzung
schlankes fail-fast
Tesla-spezifische Stromableitung nur dort, wo die API keine brauchbaren currents liefert

Ist das in eurem Interesse?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ich habe das bei mir umgesetzt und teste die Änderungen über Ostern. Anschließend würde ich den PR aktualisieren...

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)

if self._nearly_zero(s):
currents[i] = 0.0
pfs[i] = 0.0
continue

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
status = client.get_json("/api/status")
log.debug('Firmware: ' + status["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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ist es nicht sinnvoller, die Firmware-Version in der initializer-Methode der device.py aufzurufen?
Die Firmware-Version wird ohnehin nur im Log aufgerufen und hat keinen weiteren Nutzen.

@NicoP-codeing NicoP-codeing Apr 12, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ja, das ist aus meiner Sicht sinnvoller. Die Firmware-Version ist nicht regelungsrelevant und sollte daher nicht im regulären Update-Zyklus abgefragt werden.

Ich nehme die /api/status-Abfrage aus dem Counter-Update heraus und verschiebe sie in die initializer-Methode.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Danke für den Hinweis. Ich nehme die lokale Default-Wert-Behandlung an dieser Stelle wieder heraus, damit fehlende oder unvollständige Werte konsistent über den bestehenden Exception-/Fallback-Pfad behandelt werden.

Mein Ziel war hier vor allem, auf inkonsistente Tesla/Neurio-Antworten robuster zu reagieren. Ich richte mich im PR aber nach der zentralen openWB-Logik und reduziere die lokale Sonderbehandlung wieder.

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: 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,
)


Check warning on line 118 in packages/modules/devices/tesla/tesla/counter.py

View workflow job for this annotation

GitHub Actions / build

blank line contains whitespace

Check failure on line 118 in packages/modules/devices/tesla/tesla/counter.py

View workflow job for this annotation

GitHub Actions / build

indentation contains mixed spaces and tabs

Check warning on line 118 in packages/modules/devices/tesla/tesla/counter.py

View workflow job for this annotation

GitHub Actions / build

indentation contains tabs
if all(self._nearly_zero(i) for i in api_currents):

Check failure on line 119 in packages/modules/devices/tesla/tesla/counter.py

View workflow job for this annotation

GitHub Actions / build

too many blank lines (2)
currents = calculated_currents
log.debug(
"Tesla/Neurio phase currents missing (all 0). "
"Calculated currents locally from P/Q and U."
)
else:
currents = api_currents
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=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=round(freq, 2),
serial_number=serial_number,
)
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)


Expand Down
55 changes: 42 additions & 13 deletions packages/modules/devices/tesla/tesla/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,13 +17,20 @@
log = logging.getLogger(__name__)


def __update_components(client: PowerwallHttpClient,
components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]):
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)

# FAIL-FAST: abort remaining components if any critical request failed in this cycle
if client.cycle_failed:
break
Comment on lines +27 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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, reraise=True):
component.update(client, aggregate)

Das bricht mit der bestehenden Logik. Wenn eine Komponente nicht auslesbar ist, müssen alle anderen trotzdem ausgelesen werden, sonst kommen sie nicht in den Fehlerzustand oder wenn sie Werte liefern, braucht die Regelung diese.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ich verstehe den Einwand zum break im Komponentenloop. Ich würde das Verhalten gern präzisieren:

Bei Parsing-/Datenfehlern innerhalb einer einzelnen Komponente soll kein fail-fast erfolgen; dann soll die jeweilige Komponente über den bestehenden Fehlerpfad fehlschlagen und die übrigen Komponenten sollen weiter verarbeitet werden.

Fail-fast soll nur bei Gateway-/Transportfehlern greifen, also wenn die Powerwall/Gateway-Kommunikation selbst fehlschlägt. In diesem Fall hängen alle Tesla-Komponenten an derselben nicht erreichbaren Quelle, weitere Abfragen im selben Zyklus erzeugen nur zusätzliche Timeouts und verzögern die Regelschleife.

Ich würde die Änderung entsprechend anpassen, sodass nur Verbindungs-/Gatewayfehler den Tesla-Zyklus abbrechen, nicht aber komponentenspezifische Parsing- oder Datenfehler. Ist das in eurem Interesse?



def _authenticate(session: requests.Session, url: str, email: str, password: str):
"""
Expand All @@ -33,15 +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,
)
log.debug("Authentication endpoint send cookies %s", str(response.cookies))
response.raise_for_status()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
response.raise_for_status()

Wird über einen Hook aufgerufen.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Werde ich entfernen!


return {"AuthCookie": response.cookies["AuthCookie"], "UserRecord": response.cookies["UserRecord"]}


def create_device(device_config: Tesla):
http_client = None
session = None
http_client: Optional[PowerwallHttpClient] = None
session: Optional[requests.Session] = None

def create_bat_component(component_config: TeslaBatSetup):
return TeslaBat(component_config)
Expand All @@ -52,27 +60,48 @@ 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bitte entfernen, wird zentral gelöst.
Wenn bei einer Exception der initializer aufgerufen werden soll, in Z. 113 den Parameter error_handler mit initializer initialisieren.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Danke für den Hinweis. Ich entferne die lokale Initialisierungs-Hilfslogik wieder, damit das Modul näher an der zentralen openWB-Struktur bleibt.

Falls eine Reinitialisierung bei Fehlern weiterhin nötig ist, binde ich das wie vorgeschlagen über den error_handler mit initializer an, statt es lokal im Modul zu behandeln.

def update_components(components: Iterable[Union[TeslaBat, TeslaCounter, TeslaInverter]]):
log.debug("Beginning update")
nonlocal http_client, session

address = device_config.configuration.ip_address
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

# 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:
raise e
log.warning("Login to powerwall with existing cookie failed. Will retry with new cookie...")
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...",
status,
)

http_client.cookies = _authenticate(session, address, email, password)
http_client.mark_cookie_renewed()
__update_components(http_client, components)
log.debug("Update completed successfully")

def initializer():
nonlocal http_client, session
Expand All @@ -87,7 +116,7 @@ def initializer():
counter=create_counter_component,
inverter=create_inverter_component,
),
component_updater=MultiComponentUpdater(update_components)
component_updater=MultiComponentUpdater(update_components),
)


Expand Down
45 changes: 43 additions & 2 deletions packages/modules/devices/tesla/tesla/http_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,53 @@
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

def get_json(self, relative_url: str):
# 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
return self.__session.get(url, cookies=self.cookies, verify=False, timeout=5).json()

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

Check warning on line 53 in packages/modules/devices/tesla/tesla/http_client.py

View workflow job for this annotation

GitHub Actions / build

no newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bitte die Änderungen entfernen, diese blähen den Code stark auf, ohne einen wirklichen Mehrwert zu bieten.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verstanden. Ich reduziere die Änderungen an dieser Stelle wieder deutlich.

Mein Ziel war hier, konkrete Probleme im Tesla-/Powerwall-Modul zu adressieren. Für den PR lasse ich aber nur die Teile stehen, die dafür funktional wirklich notwendig sind, und nehme die übrigen Erweiterungen wieder heraus.

6 changes: 3 additions & 3 deletions packages/modules/devices/tesla/tesla/tesla.php
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ function teslaLogin () {
?>
<div class="alert alert-success">
Anmeldung erfolgreich!<br>
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im UI wird Du als Ansprache verwendet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Werde ich ändern!

</div>
<?php
} else {
Expand All @@ -354,7 +354,7 @@ function teslaLogin () {
}
?>
<div class="alert alert-success">
Gespeicherte Anmeldedaten wurden entfernt. Du kannst diese Seite jetzt schließen.
Gespeicherte Anmeldedaten wurden entfernt. Sie können diese Seite jetzt schließen.
</div>
<?php
break;
Expand All @@ -369,4 +369,4 @@ function teslaLogin () {

</div> <!-- container -->
</body>
</html>
</html>
Loading