Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
140 changes: 125 additions & 15 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 @@ 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:
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

@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"])


















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


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 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
61 changes: 46 additions & 15 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,12 +17,21 @@
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)
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


def _authenticate(session: requests.Session, url: str, email: str, password: str):
Expand All @@ -33,15 +42,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()

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 +62,48 @@ 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]]):
log.debug("Beginning update")
nonlocal http_client, session

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

__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)

__update_components(http_client, components)
log.debug("Update completed successfully")

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


Expand Down
9 changes: 7 additions & 2 deletions packages/modules/devices/tesla/tesla/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@ def __init__(self, host: str, session: requests.Session, cookies):
self.__session = session

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