diff --git a/.editorconfig b/.editorconfig index fc6b64283e..9b49f25103 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,10 @@ charset = utf-8 end_of_line = lf insert_final_newline = true +[simpleAPI/**.php] +indent_style = space +indent_size = 4 + [*.{py,yml,lock}] indent_style = space indent_size = 4 diff --git a/.github/workflows/publish_docs_to_wiki.yml b/.github/workflows/publish_docs_to_wiki.yml index e5f71e1e23..a41a49c2ea 100644 --- a/.github/workflows/publish_docs_to_wiki.yml +++ b/.github/workflows/publish_docs_to_wiki.yml @@ -9,9 +9,9 @@ on: - master # This can be changed to any branch of your preference env: - USER_TOKEN: ${{ secrets.WIKI_ACTION_TOKEN }} # This is the repository secret - USER_NAME: LKuemmel # Enter the username of your (bot) account - USER_EMAIL: lena.kuemmel@openwb.de # Enter the e-mail of your (bot) account + WIKI_TOKEN: ${{ secrets.WIKI_ACTION_TOKEN }} # Personal Access Token with repo scope + USER_NAME: "GitHub Actions" # Enter the username of your (bot) account + USER_EMAIL: "actions@github.com" # Enter the e-mail of your (bot) account OWNER: ${{ github.event.repository.owner.name }} # This is the repository owner REPOSITORY_NAME: ${{ github.event.repository.name }} # This is the repository name @@ -31,15 +31,15 @@ jobs: mkdir tmp_wiki cd tmp_wiki git init - git config user.name $USER_NAME - git config user.email $USER_EMAIL - git pull https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git + git pull https://$WIKI_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git # 4. Synchronize differences between `docs` & `tmp_wiki` # 5. Push new Wiki content - name: Push content to wiki run: | rsync -av --delete docs/ tmp_wiki/ --exclude .git cd tmp_wiki + git config user.name "$USER_NAME" + git config user.email "$USER_EMAIL" git add . - git commit -m "Update Wiki content" - git push -f --set-upstream https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git master + git commit -m "Update Wiki content" || echo "No changes to commit" + git push -f --set-upstream https://$WIKI_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git master diff --git a/.gitignore b/.gitignore index 7226376d6d..57189a06cb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,13 @@ .vscode/* __pycache__/ node_modules/ +data/config/eebus/certs/* data/log/* data/charge_log/* data/daily_log/* data/monthly_log/* data/backup/*.tar.gz +data/backup/*.openwb-backup* data/restore/*.tar.gz data/data_migration/*.tar.gz ramdisk/* diff --git a/data/config/mosquitto/mosquitto.acl b/data/config/mosquitto/mosquitto.acl index c99f151a50..18221aa66e 100644 --- a/data/config/mosquitto/mosquitto.acl +++ b/data/config/mosquitto/mosquitto.acl @@ -1,4 +1,4 @@ -# openwb-version:2 +# openwb-version:3 # allow publishing set topics topic write openWB/set/# # allow clearing system messages @@ -11,3 +11,5 @@ topic read openWB/# topic read openWB-remote/# # allow brach "others" for devices other than openWB topic readwrite others/# +# allow read write access for simpleAPI +topic readwrite openWB/simpleAPI/# diff --git a/data/config/openwb-simpleAPI.service b/data/config/openwb-simpleAPI.service new file mode 100644 index 0000000000..b549c9223b --- /dev/null +++ b/data/config/openwb-simpleAPI.service @@ -0,0 +1,15 @@ +# openwb-version:1 +[Unit] +Description="openWB mqtt simpleAPI" +After=mosquitto.service + +[Service] +User=openwb +WorkingDirectory=/var/www/html/openWB +ExecStart=/var/www/html/openWB/simpleAPI/simpleAPI_mqtt.py +Restart=always +# extend timeout to 15min for long running atreboot +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/data/config/simpleAPI_mqtt_config.json b/data/config/simpleAPI_mqtt_config.json new file mode 100644 index 0000000000..9a7de748ec --- /dev/null +++ b/data/config/simpleAPI_mqtt_config.json @@ -0,0 +1,11 @@ +{ + "host": "localhost", + "port": 1883, + "username": null, + "password": null, + "use_tls": false, + "qos": 0, + "retain": true, + "reconnect_delay": 10, + "log_level": "INFO" +} \ No newline at end of file diff --git "a/docs/IO-Ger\303\244te & -Aktionen.md" "b/docs/IO-Ger\303\244te & -Aktionen.md" deleted file mode 100644 index 05b90d7ad9..0000000000 --- "a/docs/IO-Ger\303\244te & -Aktionen.md" +++ /dev/null @@ -1,39 +0,0 @@ -### IO-Geräte - -IO/GPIO sind analoge und digitale Ein- und Ausgänge, die man meist als Pin- oder Buchsenleiste auf der Platine findet. openWB software2 kann analoge und digitale Eingänge auslesen und analoge sowie digitale Ausgänge schalten. Die Ein- und Ausgänge befinden sich auf dem konfigurierten IO-Gerät, wie zB dem Dimm- & Control-Kit. Um festzulegen, was mit den Informationen aus den Eingängen gemacht werden soll oder welche Ausgänge geschaltet werden sollen, konfigurierst Du IO-Aktionen. Bei der IO-Aktion gibst Du an, welcher Ein- oder Ausgang dafür verwendet werden soll und ggf weitere Aktions-spezifische Einstellungen. - -#### Dimm-& Control-Kit - -Das Dimm-& Control-Kit besitzt acht analoge Eingänge (AI1-AI8), acht digitale Eingänge (DI1-DI8) und achte digitale Ausgänge (DO1-DO8). Bei den Ausgängen handelt es sich um potentialfreie Relais-Ausgänge mit 5A@28VDC/250VAC. - -#### openWB series2-Modell mit AddOn-Platine - -Die AddOn-Platine stellt 7 Eingänge und 3 Ausgänge zur Verfügung. WICHTIG: In openWB software 1.9 waren den IOs feste Aktionen zugeordnet, die auch auf der Platine beschriftet sind. Diese Zuordnung ist in software2 NICHT vorgegeben. Zur einfachen Zuordnung der Pins hier eine Übersicht: - -| Pin | Beschriftung | -|---------|---------| -| Eingang 21 | RSE 2 | -| Eingang 24 | RSE 1 | -| Eingang 31 | Taster 3 PV | -| Eingang 32 | Taster 1 Sofortladen | -| Eingang 33 | Taster 4 Stop | -| Eingang 36 | Taster 2 Min+PV | -| Eingang 40 | Taster 5 Standby | -| Ausgang 7 | LED 3 | -| Ausgang 16 | LED 2 | -| Ausgang 18 | LED 1 | - -## IO-Aktionen - -### Steuerbare Verbrauchseinrichtungen: Dimmen per EMS, Dimmung per Direkt-Steuerung, RSE - -Ausführliche Informationen findest Du im gesonderten Wiki-Beitrag [Steuerbare Einrichtungen nach § 14a EnGW und § 9 EEG](https://github.com/openWB/core/wiki/Steuerbare-Verbrauchseinrichtungen-nach-§14a) - -### Steuerbare Erzeugungseinrichtungen: Stufenweise Steuerung - -Bitte beachten: Die openWB steuert keinen Wechselrichter an. Sie zeigt lediglich den aktuellen Zustand der Beschränkung an und kann optional das Signal der Eingänge an Ausgänge durchreichen. -Ausführliche Informationen findest Du im gesonderten Wiki-Beitrag [Steuerbare Einrichtungen nach § 14a EnGW und § 9 EEG](https://github.com/openWB/core/wiki/Steuerbare-Verbrauchseinrichtungen-nach-§14a) - -## Manuelles Setzen der Ausgänge - -Die Ausgänge aller IO-Geräte können per MQTT gesetzt werden. Die Topics findet Ihr in den Einstellungen des jeweiligen Geräts als Copy-to-Clipboard-Link. Das manuelle Setzen des Ausgangs überschreibt den Wert, den zB die openWB bei einer IO-Aktion gesetzt hat. diff --git a/docs/Identifikation.md b/docs/Identifikation.md deleted file mode 100644 index f74b0b9b12..0000000000 --- a/docs/Identifikation.md +++ /dev/null @@ -1,33 +0,0 @@ -Mit den verschiedenen Identifikations-Möglichkeiten kannst du die openWB grundsätzlich vor unbefugtem Laden schützen oder fahrzeugbasierte Funktionen nutzen. Es gibt zwei grundlegende Konzepte: Das Entsperren eines Ladepunkts und das Zuordnen eines Fahrzeugs. - -Die Identifikation erfolgt über - -* RFID-Tags: Setzt einen eingebauten RFID-Reader voraus. Dieser ist als optionales Zubehör für openWB Pro und openWB series2 erhältlich. Der Tag kann nach oder max. 5 Minuten vor dem Anstecken gescannt werden. -* Eingabe einer ID am Display: Setzt ein eingebautes Display voraus. -* Fahrzeugerkennung: Setzt eine openWB Pro und ein Fahrzeug, das diese Funktion unterstützt, voraus. (Permalink zur Übersicht im Forum) Zur Identifikation wird die MAC-Adresse des Fahrzeugs verwendet. Hat die Pro auch einen RFID-Reader, hat bei der Fahrzeug-Zuordnung die MAC-Adresse die höhere Priorität. Beim Entsperren wird beides geprüft. - -Die beschriebenen Identifikationsverfahren werden in der Software gleich ausgewertet. Es sind unterschiedliche Wege je nach Hardwareausstattung, die Information an die Software zu übergeben. Wenn ID-Tags genutzt werden sollen, dann ist in der Navigationsbar unter Einstellungen - Optionale Hardware unter dem Punkt Identifikation von Fahrzeugen die Option Identifikation aktivieren auf An zu stellen. - -Zusätzlich kann das Entsperren und die Fahrzeug-Auswahl auch manuell im Web-GUI oder am Display durchgeführt werden. - -#### Ladepunkt entsperren - -Unter Einstellungen → Konfiguration → Ladepunkte → Ladepunkt-Profil kann für eine Gruppe von Ladepunkten die gültigen ID-Tags hinterlegt werden. Ist der Ladepunkt gesperrt und es wird einer der hinterlegten Tags gescannt/eingegeben, wird der Ladepunkt entsperrt. Mit der Option Sperren nach Abstecken wird nach dem Abstecken der Ladepunkt gesperrt und muss bei nächsten Abstecken erst entsperrt werden, bevor geladen werden kann. - -#### Fahrzeug zuordnen - -Im Menü Einstellungen → Konfiguration → Fahrzeuge können ID-Tags für das Fahrzeug hinterlegt werden. Wird einer dieser Tags erkannt, wird das Fahrzeug dem Ladepunkt zugeordnet. - -Im Ladeprofil kann eingestellt werden, ob nach dem Abstecken das Standard-Fahrzeug zugeordnet werden soll. Andernfalls wird nach Abstecken das letzte vorher ausgewählte Fahrzeug zugeordnet. -Die Option Standard nach Abstecken macht nur Sinn, wenn neben dem Standard-Fahrzeug mindestens ein weiteres Fahrzeug und neben dem Standard-Lade-Profil mindestens ein weiteres Lade-Profil angelegt wurde. Dabei ist dem Standard-Fahrzeug das Standard-Lade-Profil und dem weiteren Fahrzeug das weitere Lade-Profil zuzuweisen. Wenn nur mit Identifikation geladen werden soll, muss im Standard-Lade-Profil der aktive Lademodus auf Stop gestellt werden. In den Lade-Profilen der anderen Fahrzeuge muss Standard nach Abstecken aktiviert werden. -Über den ID-Tag wird ein Fahrzeug zugeordnet. Nach Abstecken wechselt die Auswahl dann auf Standardfahrzeug in den Lademodus Stop und der Ladepunkt startet keinen weiteren Ladevorgang, bis die Auswahl entweder händisch über das User Interface oder automatisch per ID-Tag auf ein Fahrzeug geändert wird, das sich z.B. im Lademodus Sofortladen befindet und laden darf. - -### Use Cases - -#### Sperre nach Abstecken - -Sperre nach Abstecken kann an einem Ladepunkt verwendet werden, welcher das Laden gegenüber fremdem Zugriff sichert. Wird der ID-Tag nur zum Sperren/Entsperren des Ladepunktes verwendet, dann startet immer das ausgewählte Fahrzeug den Ladevorgang. Dies kann im privaten Bereich mit nur einem Fahrzeug sinnvoll sein, damit nur dieses Fahrzeug auch laden darf. Die Option ist aber auch für Ladeparks sinnvoll, bei denen die Ladepunkte nur für eine Gruppe von ID-Tags freischaltbar sind und dem ID-Tag zum Entsperren auch gleichzeitig zugeordnet sind. - -#### Standard nach Abstecken - -Standard nach Abstecken kann an einem Ladepunkt verwendet werden, welcher das Laden mehrerer verschiedener Fahrzeuge ermöglichen soll. Werden mehrere Fahrzeuge mit verschiedenen Lade-Profilen und verschiedenen ID-Kennungen neben dem Standard-Fahrzeug angelegt, kann über die ID-Kennung zwischen den einzelnen Fahrzeugen gewechselt werden. Hier bietet sich beispielsweise ein privater Ladepunkt mit zwei Fahrzeugen an oder ein Ladepunkt in einer Firma mit verschiedenen Mitarbeitern. Standard nach Abstecken kann auch dazu verwendet werden, um beispielsweise zwischen zwei Fahrzeugen (und damit Fahrzeug-Profilen und Lade-Profilen) ohne ID-Tag zu wechseln, vor allem wenn nur eines der Fahrzeuge über die ID-Kennung zuverlässig erkannt wird. diff --git a/docs/Neues Modul programmieren.md b/docs/Neues Modul programmieren.md index 98e6159a20..2c3d7c2e5c 100644 --- a/docs/Neues Modul programmieren.md +++ b/docs/Neues Modul programmieren.md @@ -44,4 +44,37 @@ Bei manchen Fahrzeugen kann der SoC nicht während der Ladung abgefragt werden. Nach dreimaliger fehlgeschlagener Abfrage wird der SoC auf 0% gesetzt, damit in jedem Fall geladen wird. -_Bei Fragen programmiert Ihr das SoC-Modul vorerst, wie Ihr es versteht, und erstellt einen (Draft-)PR. Wir unterstützen Euch gerne per Review. +_Bei Fragen programmiert Ihr das SoC-Modul vorerst, wie Ihr es versteht, und erstellt einen (Draft-)PR. Wir unterstützen Euch gerne per Review._ + +### Breaking Changes und Ergänzen von neuen Einstellungen + +Die Klasse `UpdateConfig` verwaltet automatische Migrationen bei Breaking Changes und neuen Einstellungen. Das System funktioniert folgendermaßen: +- Für jede notwendige Anpassung wird eine nummerierte Upgrade-Funktion erstellt. +- Beim Systemstart werden alle noch nicht ausgeführten Upgrade-Funktionen automatisch aufgerufen +- Die Nummern der bereits ausgeführten Funktionen werden persistent gespeichert, um mehrfache Ausführung zu verhindern. Der aktuelle Migrations-Status wird im MQTT-Topic `openWB/system/datastore_version` veröffentlicht. + +Alle Upgrade-Funktionen folgen einem einheitlichen Schema: +```python +def upgrade_datastore_104(self) -> None: + """Upgrade-Funktion für Datastore-Version 104: Ergänzt fehlende aWATTar-Konfigurationsparameter""" + def upgrade(topic: str, payload) -> None: + """Prüft und migriert ein einzelnes MQTT-Topic""" + # Topic finden, das aktualisiert werden soll + if "openWB/optional/ep/flexible_tariff/provider" == topic: + provider = decode_payload(payload) + # Nur für aWATTar-Provider ausführen + if provider["type"] == "awattar": + # Prüfen, ob das "net"-Feld fehlt (neue Konfiguration) + if provider["configuration"].get("net") is None: + # Standardwerte für fehlende Konfigurationsparameter setzen + provider["configuration"]["net"] = False + provider["configuration"]["fix"] = 0.015 + provider["configuration"]["proportional"] = 0.03 + provider["configuration"]["tax"] = 0.2 + # Aktualisierte Konfiguration zurückgeben + return {topic: provider} + # Alle gespeicherten MQTT-Topics durchlaufen und Upgrade-Funktion anwenden + self._loop_all_received_topics(upgrade) + # Diese Upgrade-Funktion als ausgeführt markieren (Version 104) + self._append_datastore_version(104) +``` \ No newline at end of file diff --git "a/docs/Steuerbare Verbrauchseinrichtungen nach \302\24714a.md" "b/docs/Steuerbare Verbrauchseinrichtungen nach \302\24714a.md" deleted file mode 100644 index 77a573797b..0000000000 --- "a/docs/Steuerbare Verbrauchseinrichtungen nach \302\24714a.md" +++ /dev/null @@ -1,39 +0,0 @@ -## Steuerbare Verbrauchseinrichtungen (SteuVE) nach § 14a EnGW - -Der Gesetzgeber sieht verschiedene Möglichkeiten für steuerbare Verbrauchseinrichtungen vor. Für jede steuerbare Verbrauchseinrichtung kann eine andere Option angemeldet werden. Bei der Konfiguration muss deshalb auch immer der/die Ladepunkte angegeben werden, für die die IO-Aktion angewendet werden soll. - -### Dimmen per EMS - -Beim Dimmen wird eine maximale Bezugsleistung für alle steuerbaren Verbrauchseinrichtungen nach einer vorgegebene Formel ermittelt. Das Ergebnis dieser Formel muss bei der IO-Aktion `Dimmen` in der Einstellung `maximale Bezugsleistung` eingetragen werden. ACHTUNG: Die openWB kann aktuell nur die Ladepunkte berücksichtigen. Sind noch weitere steuerbare Verbraucher angemeldet, können diese über einen digitalen Ausgang angebunden werden. Da openWB die Leistung dieser Geräte nicht kennt, werden 4,2kW angenommen. Muss der Verbraucher seine Leistung begrenzen, wird der Ausgang auf 0V gesetzt. Für die korrekte Ermittlung der maximalen Bezugsleistung ist der Betreiber, nicht openWB oder die software2 verantwortlich. -Vorhandener Überschuss kann zusätzlich zur maximalen Bezugsleistung verwendet werden. - -### Dimmung per Direkt-Steuerung - -Bei der Dimmung per Direkt-Steuerung wird jede steuerbare Verbrauchseinrichtung separat angesteuert und ihr Leistungsbezug auf 4,2kW gedimmt. -Pro steuerbarer Verbrauchseinrichtung muss eine IO-Aktion konfiguriert werden und dort der Ladepunkt und der zugehörige Eingang angegeben werden. - -### Rundsteuer-Empfänger-Kontakt (RSE) - -Für den RSE-Kontakt können Muster aus verschiedenen Eingängen angegeben werden. Es kann frei festgelegt werden, bei welchem Muster die zugeordneten Ladepunkte Gesperrt oder freigegeben sind. - -In der abgebildeten Konfiguration werden die Ladepunkte nur freigegeben, wenn beide Kontakte DI1 und DI2 geschlossen sind. Ist auch nur einer geöffnet, wird gesperrt. - -![RSE-Beispielkonfiguration](RSE-Beispielkonfiguration.png) - -## Steuerbare Erzeugungsanlagen (EZA) nach § 9 EEG - -Bitte beachten: Die openWB steuert keinen Wechselrichter an. Sie zeigt lediglich den aktuellen Zustand der Beschränkung an und kann optional das Signal der Eingänge an Ausgänge durchreichen. - -Die Einspeise- oder Erzeugungsleistung der EZA (abhängig von der Implementierung in der EZA) wird über drei potentialfreie Signalkontakte der FNN-Steuerbox geregelt. Die openWB übernimmt dabei keine direkte Steuerung des Wechselrichters, sondern visualisiert lediglich und protokolliert den aktuellen Steuerzustand. - -Das Signalkabel der FNN-Steuerbox muss daher beispielsweise über ein Koppelrelais mit zwei separaten Schließer-/Wechselkontakten mit dem I/O-Modul der openWB und der Erzeugungsanlage verbunden werden. Falls dies nicht möglich ist, kann die Steuerbox über einfache Koppelrelais mit dem I/O-Modul der openWB verbunden werden und das empfangene Signal an vorhandene Ausgänge des I/O-Moduls (falls vorhanden) durchgereicht werden. - -Die Signalkontakte bilden folgende Zustände ab: -S1 -> 60% der EZA -S2 -> 30% der EZA -W3 -> 0% der EZA -alle Kontakte offen -> 100% der EZA - -Die Eingangsmuster sind so zu konfigurieren, dass auch bei mehreren geschlossenen Kontakten eine eindeutige Funktion gewährleistet wird. In der abgebildeten Konfiguration hat z.B. der Eingang DI5 für Begrenzung auf 0% Priorität, sodass dieses Muster auch erkannt wird, falls noch einer der Eingänge DI3 oder DI4 geschlossen sind. - -![EZA-Beispielkonfiguration](EZA-Beispielkonfiguration.png) diff --git a/docs/samples/sample_modbus/sample_modbus/bat.py b/docs/samples/sample_modbus/sample_modbus/bat.py index 2b1a20a768..33dfbe574e 100644 --- a/docs/samples/sample_modbus/sample_modbus/bat.py +++ b/docs/samples/sample_modbus/sample_modbus/bat.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from enum import IntEnum from typing import Optional, TypedDict, Any from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState @@ -15,7 +16,23 @@ class KwargsDict(TypedDict): client: ModbusTcpClient_ +class Register(IntEnum): + CURRENT_L1 = 0x06 + POWER = 0x0C + SOC = 0x46 + IMPORTED = 0x48 + EXPORTED = 0x4A + + class SampleBat(AbstractBat): + REG_MAPPING = ( + (Register.CURRENT_L1, [ModbusDataType.FLOAT_32]*3), + (Register.POWER, [ModbusDataType.FLOAT_32]*3), + (Register.SOC, ModbusDataType.FLOAT_32), + (Register.IMPORTED, ModbusDataType.FLOAT_32), + (Register.EXPORTED, ModbusDataType.FLOAT_32), + ) + def __init__(self, component_config: SampleBatSetup, **kwargs: Any) -> None: self.component_config = component_config self.kwargs: KwargsDict = kwargs @@ -29,6 +46,20 @@ def initialize(self) -> None: def update(self) -> None: unit = self.component_config.configuration.modbus_id + # Modbus-Bulk reader, liest einen Block von Registern und gibt ein Dictionary mit den Werten zurück + # read_input_registers_bulk benötigit als Parameter das Startregister, die Anzahl der Register, + # Register-Mapping und die Modbus-ID + resp = self.client.read_input_registers_bulk( + Register.CURRENT_L1, 70, mapping=self.REG_MAPPING, unit=self.id) + bat_state = BatState( + power=resp[Register.POWER], + soc=resp[Register.SOC], + imported=resp[Register.IMPORTED], + exported=resp[Register.EXPORTED], + ) + self.store.set(bat_state) + + # Einzelregister lesen (dauert länger, bei sehr weit >100 auseinanderliegenden Registern sinnvoll) power = self.client.read_holding_registers(reg, ModbusDataType.INT_32, unit=unit) soc = self.client.read_holding_registers(reg, ModbusDataType.INT_32, unit=unit) imported, exported = self.sim_counter.sim_count(power) diff --git a/docs/samples/sample_modbus/sample_modbus/counter.py b/docs/samples/sample_modbus/sample_modbus/counter.py index 206d9bdbb8..8ff6d6c516 100644 --- a/docs/samples/sample_modbus/sample_modbus/counter.py +++ b/docs/samples/sample_modbus/sample_modbus/counter.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from enum import IntEnum from typing import TypedDict, Any from modules.common.abstract_device import AbstractCounter from modules.common.component_state import CounterState @@ -15,7 +16,27 @@ class KwargsDict(TypedDict): client: ModbusTcpClient_ +class Register(IntEnum): + VOLTAGE_L1 = 0x00 + CURRENT_L1 = 0x06 + POWER_L1 = 0x0C + POWER_FACTOR_L1 = 0x1E + FREQUENCY = 0x46 + IMPORTED = 0x48 + EXPORTED = 0x4A + + class SampleCounter(AbstractCounter): + REG_MAPPING = ( + (Register.VOLTAGE_L1, [ModbusDataType.FLOAT_32]*3), + (Register.CURRENT_L1, [ModbusDataType.FLOAT_32]*3), + (Register.POWER_L1, [ModbusDataType.FLOAT_32]*3), + (Register.POWER_FACTOR_L1, [ModbusDataType.FLOAT_32]*3), + (Register.FREQUENCY, ModbusDataType.FLOAT_32), + (Register.IMPORTED, ModbusDataType.FLOAT_32), + (Register.EXPORTED, ModbusDataType.FLOAT_32), + ) + def __init__(self, component_config: SampleCounterSetup, **kwargs: Any) -> None: self.component_config = component_config self.kwargs: KwargsDict = kwargs @@ -28,6 +49,25 @@ def initialize(self) -> None: self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) def update(self): + unit = self.component_config.configuration.modbus_id + # Modbus-Bulk reader, liest einen Block von Registern und gibt ein Dictionary mit den Werten zurück + # read_input_registers_bulk benötigit als Parameter das Startregister, die Anzahl der Register, + # Register-Mapping und die Modbus-ID + resp = self.client.read_input_registers_bulk( + Register.VOLTAGE_L1, 76, mapping=self.REG_MAPPING, unit=self.id) + counter_state = CounterState( + imported=resp[Register.IMPORTED], + exported=resp[Register.EXPORTED], + power=sum(resp[Register.POWER_L1]), + voltages=resp[Register.VOLTAGE_L1], + currents=resp[Register.CURRENT_L1], + powers=resp[Register.POWER_L1], + power_factors=resp[Register.POWER_FACTOR_L1], + frequency=resp[Register.FREQUENCY], + ) + self.store.set(counter_state) + + # Einzelregister lesen (dauert länger, bei sehr weit >100 auseinanderliegenden Registern sinnvoll) power = self.client.read_holding_registers(reg, ModbusDataType.INT_32, unit=unit) imported, exported = self.sim_counter.sim_count(power) diff --git a/docs/samples/sample_modbus/sample_modbus/inverter.py b/docs/samples/sample_modbus/sample_modbus/inverter.py index 6fda28c90f..cd4cdae552 100644 --- a/docs/samples/sample_modbus/sample_modbus/inverter.py +++ b/docs/samples/sample_modbus/sample_modbus/inverter.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from enum import IntEnum from typing import TypedDict, Any from modules.common.abstract_device import AbstractInverter from modules.common.component_state import InverterState @@ -15,7 +16,21 @@ class KwargsDict(TypedDict): client: ModbusTcpClient_ +class Register(IntEnum): + CURRENT_L1 = 0x06 + POWER = 0x0C + DC_POWER = 0x48 + EXPORTED = 0x4A + + class SampleInverter(AbstractInverter): + REG_MAPPING = ( + (Register.CURRENT_L1, [ModbusDataType.FLOAT_32]*3), + (Register.POWER, [ModbusDataType.FLOAT_32]*3), + (Register.DC_POWER, ModbusDataType.FLOAT_32), + (Register.EXPORTED, ModbusDataType.FLOAT_32), + ) + def __init__(self, component_config: SampleInverterSetup, **kwargs: Any) -> None: self.component_config = component_config self.kwargs: KwargsDict = kwargs @@ -28,6 +43,21 @@ def initialize(self) -> None: self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) def update(self) -> None: + unit = self.component_config.configuration.modbus_id + # Modbus-Bulk reader, liest einen Block von Registern und gibt ein Dictionary mit den Werten zurück + # read_input_registers_bulk benötigit als Parameter das Startregister, die Anzahl der Register, + # Register-Mapping und die Modbus-ID + resp = self.client.read_input_registers_bulk( + Register.CURRENT_L1, 70, mapping=self.REG_MAPPING, unit=self.id) + inverter_state = InverterState( + power=resp[Register.POWER], + currents=resp[Register.CURRENT_L1], + dc_power=resp[Register.DC_POWER], + exported=resp[Register.EXPORTED], + ) + self.store.set(inverter_state) + + # Einzelregister lesen (dauert länger, bei sehr weit >100 auseinanderliegenden Registern sinnvoll) power = self.client.read_holding_registers(reg, ModbusDataType.INT_32, unit=unit) exported = self.sim_counter.sim_count(power)[1] diff --git a/openwb-install.sh b/openwb-install.sh index 7933f35249..3ed8cc4f0a 100755 --- a/openwb-install.sh +++ b/openwb-install.sh @@ -122,6 +122,11 @@ ln -s "${OPENWBBASEDIR}/data/config/openwb2.service" /etc/systemd/system/openwb2 systemctl daemon-reload systemctl enable openwb2 +echo "installing openwb2-simpleAPI service..." +ln -s "${OPENWBBASEDIR}/data/config/openwb-simpleAPI.service" /etc/systemd/system/openwb-simpleAPI.service +systemctl daemon-reload +systemctl enable openwb-simpleAPI + echo "installing openwb2 remote support service..." cp "${OPENWBBASEDIR}/data/config/openwbRemoteSupport.service" /etc/systemd/system/openwbRemoteSupport.service systemctl daemon-reload diff --git a/packages/conftest.py b/packages/conftest.py index abfa8dc5ea..b58a69d591 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -22,6 +22,18 @@ from modules.common.store._api import LoggingValueStore +def pytest_configure(config): + config.addinivalue_line("markers", "no_mock_full_hour: mark test to disable full_hour mocking.") + config.addinivalue_line("markers", "no_mock_quarter_hour: mark test to disable quarter_hour mocking.") + import sys + sys._called_from_test = True + + +def pytest_unconfigure(config): + import sys + del sys._called_from_test + + @pytest.fixture(autouse=True) def mock_open_file(monkeypatch) -> None: mock_config = Mock(return_value={"dc_charging": False, "openwb-version": 1, "max_c_socket": 32}) @@ -33,9 +45,12 @@ def mock_today(monkeypatch) -> None: datetime_mock = MagicMock(wraps=datetime.datetime) # Montag 16.05.2022, 8:40:52 "05/16/2022, 08:40:52" Unix: 1652683252 datetime_mock.today.return_value = datetime.datetime(2022, 5, 16, 8, 40, 52) + datetime_mock.now.return_value = datetime.datetime(2022, 5, 16, 8, 40, 52) monkeypatch.setattr(datetime, "datetime", datetime_mock) - mock_today_timestamp = Mock(return_value=1652683252) - monkeypatch.setattr(timecheck, "create_timestamp", mock_today_timestamp) + now_timestamp = Mock(return_value=1652683252) + monkeypatch.setattr(timecheck, "create_timestamp", now_timestamp) + full_hour_timestamp = Mock(return_value=int(datetime.datetime(2022, 5, 16, 8, 0, 0).timestamp())) + monkeypatch.setattr(timecheck, "create_unix_timestamp_current_full_hour", full_hour_timestamp) @pytest.fixture(autouse=True) diff --git a/packages/control/algorithm/algorithm.py b/packages/control/algorithm/algorithm.py index f507ab0817..9ad593e036 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -57,7 +57,7 @@ def _check_auto_phase_switch_delay(self) -> None: """ for cp in data.data.cp_data.values(): try: - if cp.data.set.charging_ev != -1: + if cp.data.control_parameter.required_current != 0: charging_ev = cp.data.set.charging_ev_data control_parameter = cp.data.control_parameter if cp.cp_state_hw_support_phase_switch() and control_parameter.template_phases == 0: @@ -69,6 +69,7 @@ def _check_auto_phase_switch_delay(self) -> None: cp.data.set.charge_template, cp.data.control_parameter, cp.num, + cp.data.get.evse_current, cp.data.get.currents, cp.data.get.power, cp.template.data.max_current_single_phase, diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index b30e3a65f7..52161874c9 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -2,6 +2,7 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE from control.algorithm.filter_chargepoints import get_chargepoints_by_mode +from helpermodules.phase_handling import voltages_mean log = logging.getLogger(__name__) @@ -39,7 +40,7 @@ def set_bidi(self): for index in range(0, 3): missing_currents[index] = cp.check_min_max_current(missing_currents[index], cp.data.get.phases_in_use) - grid_counter.update_surplus_values_left(missing_currents, cp.data.get.voltages) + grid_counter.update_surplus_values_left(missing_currents, voltages_mean(cp.data.get.voltages)) cp.data.set.current = missing_currents[0] log.info(f"LP{cp.num}: Stromstärke {missing_currents}A") preferenced_cps.pop(0) diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index 1cb9c0f37b..11a345c128 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -6,6 +6,7 @@ from control.algorithm.utils import get_medium_charging_current from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter +from helpermodules.phase_handling import voltages_mean from helpermodules.timecheck import check_timestamp from modules.common.component_type import ComponentType @@ -74,9 +75,13 @@ def set_current_counterdiff(diff_current: float, counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) for counter in counters: if surplus: - data.data.counter_data[counter].update_surplus_values_left(diffs, chargepoint.data.get.voltages) + data.data.counter_data[counter].update_surplus_values_left( + diffs, + voltages_mean(chargepoint.data.get.voltages)) else: - data.data.counter_data[counter].update_values_left(diffs, chargepoint.data.get.voltages) + data.data.counter_data[counter].update_values_left( + diffs, + voltages_mean(chargepoint.data.get.voltages)) data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) chargepoint.data.set.current = current @@ -146,9 +151,11 @@ def update_raw_data(preferenced_chargepoints: List[Chargepoint], counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) for counter in counters: if surplus: - data.data.counter_data[counter].update_surplus_values_left(diffs, chargepoint.data.get.voltages) + data.data.counter_data[counter].update_surplus_values_left( + diffs, + voltages_mean(chargepoint.data.get.voltages)) else: - data.data.counter_data[counter].update_values_left(diffs, chargepoint.data.get.voltages) + data.data.counter_data[counter].update_values_left(diffs, voltages_mean(chargepoint.data.get.voltages)) data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py index 2ae8679e98..707d88c0a2 100644 --- a/packages/control/algorithm/common_test.py +++ b/packages/control/algorithm/common_test.py @@ -51,6 +51,7 @@ def test_set_current_counterdiff(diff: float, cp.data.control_parameter.required_currents = required_currents cp.data.set.charging_ev_data = ev cp.data.set.current = 6 + cp.data.get.charge_state = True cp.data.get.currents = [10]*3 get_counters_to_check_mock = Mock(return_value=["cp0", "cp6"]) monkeypatch.setattr(CounterAll, "get_counters_to_check", get_counters_to_check_mock) diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index 5b519ad42d..7af2f319f3 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -25,7 +25,7 @@ def get_chargepoints_by_mode(mode_tuple: Tuple[Optional[str], str, bool]) -> Lis # enthält alle LP, auf die das Tupel zutrifft valid_chargepoints = [] for cp in data.data.cp_data.values(): - if cp.data.set.charging_ev != -1: + if cp.data.control_parameter.required_current != 0: if ((cp.data.control_parameter.prio == prio) and (cp.data.control_parameter.chargemode == mode or mode is None) and @@ -44,7 +44,7 @@ def get_preferenced_chargepoint_charging( log.info( f"LP {cp.num}: Keine Zuteilung des Mindeststroms, daher keine weitere Berücksichtigung") preferenced_chargepoints_without_set_current.append(cp) - elif max(cp.data.get.currents) == 0: + elif cp.data.get.charge_state is False: log.info( f"LP {cp.num}: Lädt nicht, daher keine weitere Berücksichtigung") preferenced_chargepoints_without_set_current.append(cp) diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index 78a27af9f8..a8b74dc8b0 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -96,42 +96,41 @@ def mock_cp(cp: Chargepoint, num: int): @pytest.mark.parametrize( - "set_mode_tuple, charging_ev_1, mode_tuple_1, charging_ev_2, mode_tuple_2, expected_valid_chargepoints", + "set_mode_tuple, required_current_1, mode_tuple_1, mode_tuple_2, expected_valid_chargepoints", [ pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 1, (Chargemode.SCHEDULED_CHARGING, + 6, (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 1, (Chargemode.SCHEDULED_CHARGING, + (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), [mock_cp1, mock_cp2], id="fits mode"), pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - -1, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - 1, (Chargemode.SCHEDULED_CHARGING, + 0, (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - [mock_cp2], id="cp1 has no charging car"), + (Chargemode.SCHEDULED_CHARGING, + Chargemode.INSTANT_CHARGING, False), + [mock_cp2], id="cp1 should not charge"), pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 1, (Chargemode.SCHEDULED_CHARGING, + 6, (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 1, (Chargemode.SCHEDULED_CHARGING, + (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, True), [mock_cp1], id="cp2 is prioritized") ]) def test_get_chargepoints_by_mode(set_mode_tuple: Tuple[Optional[str], str, bool], - charging_ev_1: int, + required_current_1: int, mode_tuple_1: Tuple[str, str, bool], - charging_ev_2: int, mode_tuple_2: Tuple[str, str, bool], expected_valid_chargepoints): # setup - def setup_cp(cp: Chargepoint, charging_ev: int, mode_tuple: Tuple[str, str, bool]) -> Chargepoint: - cp.data.set.charging_ev = charging_ev + def setup_cp(cp: Chargepoint, required_current: float, mode_tuple: Tuple[str, str, bool]) -> Chargepoint: + cp.data.control_parameter.required_current = required_current cp.data.control_parameter.prio = mode_tuple[2] cp.data.control_parameter.chargemode = mode_tuple[0] cp.data.control_parameter.submode = mode_tuple[1] return cp - data.data.cp_data = {"cp1": setup_cp(mock_cp1, charging_ev_1, mode_tuple_1), - "cp2": setup_cp(mock_cp2, charging_ev_2, mode_tuple_2)} + data.data.cp_data = {"cp1": setup_cp(mock_cp1, required_current_1, mode_tuple_1), + "cp2": setup_cp(mock_cp2, 6, mode_tuple_2)} # evaluation valid_chargepoints = filter_chargepoints.get_chargepoints_by_mode(set_mode_tuple) diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index 22eac7fd59..dd91361d37 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -27,7 +27,6 @@ def data_() -> None: for i in range(3, 6): data.data.cp_data[f"cp{i}"].template = CpTemplate() data.data.cp_data[f"cp{i}"].data.config.phase_1 = i-2 - data.data.cp_data[f"cp{i}"].data.set.charging_ev = i data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) data.data.cp_data[f"cp{i}"].data.set.charging_ev_data.ev_template.data.max_current_single_phase = 32 data.data.cp_data[f"cp{i}"].data.get.plug_state = True diff --git a/packages/control/algorithm/integration_test/instant_charging_test.py b/packages/control/algorithm/integration_test/instant_charging_test.py index 0042910933..afc9b23551 100644 --- a/packages/control/algorithm/integration_test/instant_charging_test.py +++ b/packages/control/algorithm/integration_test/instant_charging_test.py @@ -29,6 +29,7 @@ def all_cp_charging_1p(): for i in range(3, 6): data.data.cp_data[f"cp{i}"].data.get.currents = [0]*3 data.data.cp_data[f"cp{i}"].data.get.currents[i-3] = 16 + data.data.cp_data[f"cp{i}"].data.get.charge_state = True @pytest.fixture() @@ -40,6 +41,7 @@ def all_cp_instant_charging_3p(): control_parameter.required_currents = [16]*3 control_parameter.required_current = 16 control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data[f"cp{i}"].data.get.charge_state = True data.data.cp_data[f"cp{i}"].data.get.currents = [16]*3 diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py index aa7c6e25fd..105e421470 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -295,10 +295,13 @@ def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch): data.data.counter_data["counter0"].data.set.raw_power_left = cases_phase_switch[1].raw_power_left data.data.counter_data["counter0"].data.set.raw_currents_left = cases_phase_switch[1].raw_currents_left_counter0 data.data.counter_data["counter6"].data.set.raw_currents_left = cases_phase_switch[1].raw_currents_left_counter6 + data.data.cp_data["cp3"].data.get.charge_state = True data.data.cp_data["cp3"].data.get.currents = [32, 0, 0] data.data.cp_data["cp3"].data.get.power = 7360 data.data.cp_data["cp3"].data.control_parameter.timestamp_last_phase_switch = 1652682252 + data.data.cp_data["cp4"].data.get.charge_state = False data.data.cp_data["cp4"].data.get.currents = [0, 0, 0] + data.data.cp_data["cp5"].data.get.charge_state = False data.data.cp_data["cp5"].data.get.currents = [0, 0, 0] for i in range(3, 6): data.data.cp_data[f"cp{i}"].data.control_parameter.template_phases = 0 diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index cca250528d..e2e894df51 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -15,6 +15,7 @@ from control.counter import ControlRangeState, Counter from control.limiting_value import LoadmanagementLimit from control.loadmanagement import LimitingValue, Loadmanagement +from helpermodules.phase_handling import voltages_mean log = logging.getLogger(__name__) @@ -53,11 +54,13 @@ def _set(self, while len(chargepoints): cp = chargepoints[0] missing_currents, counts = common.get_missing_currents_left(chargepoints) - available_currents, limit = Loadmanagement().get_available_currents_surplus(missing_currents, - cp.data.get.voltages, - counter, - cp, - feed_in=feed_in_yield) + available_currents, limit = Loadmanagement().get_available_currents_surplus( + missing_currents, + voltages_mean(cp.data.get.voltages), + counter, + cp, + feed_in=feed_in_yield + ) cp.data.control_parameter.limit = limit available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) if counter.get_control_range_state(feed_in_yield) == ControlRangeState.MIDDLE: @@ -134,7 +137,7 @@ def phase_switch_necessary() -> bool: if cp.chargemode_changed or cp.submode_changed: if (control_parameter.state in CHARGING_STATES): if cp.data.set.charging_ev_data.ev_template.data.prevent_charge_stop is False: - threshold = evu_counter.calc_switch_off_threshold(cp)[0] + threshold = evu_counter.calc_switch_off_threshold(cp) if evu_counter.calc_raw_surplus() - cp.data.set.required_power < threshold: control_parameter.required_currents = [0]*3 control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index 5963e6d491..962eca835b 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -115,6 +115,7 @@ def test_add_unused_evse_current(evse_current: float, expected_current: float): # setup c = Chargepoint(0, None) + c.data.get.charge_state = True c.data.get.currents = [13]*3 c.data.get.evse_current = evse_current c.data.control_parameter.required_current = 16 @@ -139,9 +140,10 @@ def test_get_chargepoints_submode_pv_charging(submode_1: Chargemode, expected_chargepoints: List[Chargepoint]): # setup def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: - cp.data.set.charging_ev = Ev(0) + cp.data.set.charging_ev_data = Ev(0) cp.data.control_parameter.chargemode = Chargemode.PV_CHARGING cp.data.control_parameter.submode = submode + cp.data.control_parameter.required_current = 6 return cp data.data.cp_data = {"cp1": setup_cp(mock_cp1, submode_1), "cp2": setup_cp(mock_cp2, submode_2)} diff --git a/packages/control/auto_phase_switch_test.py b/packages/control/auto_phase_switch_test.py index dd31a25895..9a8e7575ca 100644 --- a/packages/control/auto_phase_switch_test.py +++ b/packages/control/auto_phase_switch_test.py @@ -138,6 +138,7 @@ def test_auto_phase_switch(monkeypatch, vehicle: Ev, params: Params): phases_to_use, current, message = vehicle.auto_phase_switch(ChargeTemplate(), control_parameter, 0, + max(params.get_currents), params.get_currents, params.get_power, 32, diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index d1085b0d2b..676ca7fd03 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -78,6 +78,7 @@ class Set: charging_power_left: float = field(default=0, metadata={"topic": "set/charging_power_left"}) power_limit: Optional[float] = field(default=None, metadata={"topic": "set/power_limit"}) regulate_up: bool = field(default=False, metadata={"topic": "set/regulate_up"}) + hysteresis_discharge: bool = field(default=False, metadata={"topic": "set/hysteresis_discharge"}) def set_factory() -> Set: @@ -214,7 +215,9 @@ def _get_charging_power_left(self): # Speicher sollte weder ge- noch entladen werden. charging_power_left = self.data.get.power else: + # Speicher soll geladen werden um min SoC zu erreichen if self.data.get.soc < config.min_bat_soc: + self.data.set.hysteresis_discharge = False if self.data.get.power < 0: # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge # genutzt werden. Wenn der Speicher schneller regelt als die LP, würde sonst der Speicher @@ -235,10 +238,30 @@ def _get_charging_power_left(self): # Speicher wird geladen charging_power_left = 0 self.data.set.regulate_up = True - elif int(self.data.get.soc) == config.min_bat_soc: - # Speicher sollte weder ge- noch entladen werden, um den Mindest-SoC zu halten. - charging_power_left = self.data.get.power + # Speicher zwischen min und max SoC + elif int(self.data.get.soc) >= config.min_bat_soc and int(self.data.get.soc) < config.max_bat_soc: + # Speicher soll aktiv weder ge- noch entladen werden. + # Mindest-SoC wird gehalten oder der Speicher mit weiterem vorhanden Überschuss geladen. + if self.data.set.hysteresis_discharge is False: + charging_power_left = self.data.get.power + # Speicher darf wegen Hysterese bis min_bat_soc entladen werden. + else: + if self.data.set.power_limit is None: + if config.bat_power_discharge_active: + # Wenn der Speicher mit mehr als der erlaubten Entladeleistung entladen wird, muss das + # vom Überschuss subtrahiert werden. + charging_power_left = config.bat_power_discharge + self.data.get.power + log.debug(f"Erlaubte Entlade-Leistung nutzen {charging_power_left}W") + else: + # Speicher sollte weder ge- noch entladen werden. + charging_power_left = self.data.get.power + else: + log.debug("Keine erlaubte Entladeleistung freigeben, da der Speicher mit einer vorgegeben " + "Leistung entladen wird.") + charging_power_left = 0 + # Speicher oberhalb max SoC. Darf bis min SoC entladen werden. else: + self.data.set.hysteresis_discharge = True if self.data.set.power_limit is None: if config.bat_power_discharge_active: # Wenn der Speicher mit mehr als der erlaubten Entladeleistung entladen wird, muss das diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 2256629529..1429cdd1ad 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -80,6 +80,7 @@ class Params: expected_charging_power_left: float expected_regulate_up: bool power_limit: Optional[float] = None + hysteresis_discharge: Optional[bool] = False cases = [ @@ -128,6 +129,14 @@ class Params: "Speicher-Sperre aktiv"), PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), 400, 90, 0, False, 600), + Params(("Mindest-SoC, Hysterese, EV-Vorrang, keine Speichernutzung"), + PvCharging(bat_mode="min_soc_bat_mode"), 400, 60, 400, False, hysteresis_discharge=False), + Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt"), + PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + 400, 60, 900, False, hysteresis_discharge=True), + Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt, Speicher-Sperre aktiv"), + PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + 400, 60, 0, False, 600, hysteresis_discharge=True), ] @@ -138,6 +147,7 @@ def test_get_charging_power_left(params: Params, caplog, data_, monkeypatch): b_all.data.get.power = params.power b_all.data.get.soc = params.soc b_all.data.set.power_limit = params.power_limit + b_all.data.set.hysteresis_discharge = params.hysteresis_discharge b = Bat(0) b.data.get.power = params.power data.data.bat_data["bat0"] = b diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index b8605e058d..40cc291ade 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -1,16 +1,17 @@ +import copy import datetime from enum import Enum import json import logging import os import pathlib -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from control import data from dataclass_utils import asdict -from helpermodules.measurement_logging.process_log import (CalculationType, analyse_percentage, - get_log_from_date_until_now, process_entry) -from helpermodules.measurement_logging.write_log import LegacySmartHomeLogData, LogType, create_entry +from helpermodules.measurement_logging.process_log import ( + FILE_ERRORS, CalculationType, _analyse_energy_source, + _process_entries, analyse_percentage, get_log_from_date_until_now, get_totals) from helpermodules.pub import Pub from helpermodules import timecheck from helpermodules.utils.json_file_handler import write_and_check @@ -60,6 +61,8 @@ # } log = logging.getLogger("chargelog") +MEASUREMENT_LOGGING_INTERVAL = 300 # in Sekunden + def collect_data(chargepoint): """ @@ -69,59 +72,81 @@ def collect_data(chargepoint): Ladepunkt, dessen Logdaten gesammelt werden """ try: - log_data = chargepoint.data.set.log - charging_ev = chargepoint.data.set.charging_ev_data - if chargepoint.data.get.plug_state: + now = timecheck.create_timestamp() + log_data = get_value_or_default(lambda: chargepoint.data.set.log) + charging_ev = get_value_or_default(lambda: chargepoint.data.set.charging_ev_data) + if get_value_or_default(lambda: chargepoint.data.get.plug_state, False): # Zählerstand beim Einschalten merken - if log_data.imported_at_plugtime == 0: - log_data.imported_at_plugtime = chargepoint.data.get.imported - log.debug(f"imported_at_plugtime {chargepoint.data.get.imported}") + if get_value_or_default(lambda: log_data.imported_at_plugtime, 0) == 0: + log_data.imported_at_plugtime = get_value_or_default(lambda: chargepoint.data.get.imported, 0) + log.debug(f"imported_at_plugtime {log_data.imported_at_plugtime}") # Bisher geladene Energie ermitteln log_data.imported_since_plugged = get_value_or_default( - lambda: chargepoint.data.get.imported - log_data.imported_at_plugtime) - if log_data.imported_at_mode_switch == 0: - log_data.imported_at_mode_switch = chargepoint.data.get.imported - log.debug(f"imported_at_mode_switch {chargepoint.data.get.imported}") - # Bei einem Wechsel das Lademodus wird ein neuer Eintrag erstellt. - if chargepoint.data.get.charge_state: - if log_data.timestamp_start_charging is None: - log_data.timestamp_start_charging = timecheck.create_timestamp() - if charging_ev.soc_module: - log_data.range_at_start = charging_ev.data.get.range - log_data.soc_at_start = charging_ev.data.get.soc - if chargepoint.data.control_parameter.submode == "time_charging": + lambda: chargepoint.data.get.imported - log_data.imported_at_plugtime, 0) + if get_value_or_default(lambda: log_data.exported_at_plugtime, 0) == 0: + log_data.exported_at_plugtime = get_value_or_default(lambda: chargepoint.data.get.exported, 0) + log_data.exported_since_plugged = get_value_or_default( + lambda: chargepoint.data.get.exported - log_data.exported_at_plugtime, 0) + + if get_value_or_default(lambda: log_data.imported_at_mode_switch, 0) == 0: + log_data.imported_at_mode_switch = get_value_or_default(lambda: chargepoint.data.get.imported, 0) + log.debug(f"imported_at_mode_switch {log_data.imported_at_mode_switch}") + + if get_value_or_default(lambda: log_data.exported_at_mode_switch, 0) == 0: + log_data.exported_at_mode_switch = get_value_or_default(lambda: chargepoint.data.get.exported, 0) + + if get_value_or_default(lambda: log_data.timestamp_mode_switch) is None: + log_data.timestamp_mode_switch = now + + if get_value_or_default(lambda: chargepoint.data.get.charge_state, False): + if get_value_or_default(lambda: log_data.timestamp_start_charging) is None: + log_data.timestamp_start_charging = now + submode = get_value_or_default(lambda: chargepoint.data.control_parameter.submode, "") + if submode == "time_charging": log_data.chargemode_log_entry = "time_charging" else: - log_data.chargemode_log_entry = chargepoint.data.control_parameter.chargemode.value - log_data.ev = chargepoint.data.set.charging_ev_data.num - log_data.prio = chargepoint.data.control_parameter.prio - log_data.rfid = chargepoint.data.set.rfid + log_data.chargemode_log_entry = get_value_or_default( + lambda: chargepoint.data.control_parameter.chargemode.value) + + if get_value_or_default(lambda: charging_ev.soc_module) if charging_ev else None: + if get_value_or_default(lambda: log_data.range_at_start) is None: + # manche Vehicle-Module liefern erstmal None + log_data.range_at_start = get_value_or_default(lambda: charging_ev.data.get.range) + + plug_time = get_value_or_default(lambda: chargepoint.data.set.plug_time, 0) + soc_timestamp = get_value_or_default(lambda: charging_ev.data.get.soc_timestamp, 0) + if (get_value_or_default(lambda: log_data.soc_at_start) is None and plug_time < soc_timestamp): + # SoC muss nach dem Anstecken aktualisiert worden sein + log_data.soc_at_start = get_value_or_default(lambda: charging_ev.data.get.soc) + + log_data.ev = get_value_or_default(lambda: chargepoint.data.set.charging_ev_data.num, 0) + log_data.prio = get_value_or_default(lambda: chargepoint.data.control_parameter.prio, False) + log_data.rfid = get_value_or_default(lambda: chargepoint.data.set.rfid) log_data.imported_since_mode_switch = get_value_or_default( - lambda: chargepoint.data.get.imported - log_data.imported_at_mode_switch) - # log.debug(f"imported_since_mode_switch {log_data.imported_since_mode_switch} " - # f"counter {chargepoint.data.get.imported}") - log_data.range_charged = get_value_or_default(lambda: log_data.imported_since_mode_switch / - charging_ev.ev_template.data.average_consump * 100) - log_data.time_charged = timecheck.get_difference_to_now(log_data.timestamp_start_charging)[0] + lambda: chargepoint.data.get.imported - log_data.imported_at_mode_switch, 0) + log_data.exported_since_mode_switch = get_value_or_default( + lambda: chargepoint.data.get.exported - log_data.exported_at_mode_switch, 0) + log_data.range_charged = _get_range_charged(log_data, charging_ev) + else: + timestamp_start_charging = get_value_or_default(lambda: log_data.timestamp_start_charging) + if timestamp_start_charging is not None: + time_diff = get_value_or_default(lambda: now - timestamp_start_charging, 0) + log_data.time_charged = get_value_or_default(lambda: log_data.time_charged, 0) + time_diff + log_data.timestamp_start_charging = None + log_data.end = now Pub().pub(f"openWB/set/chargepoint/{chargepoint.num}/set/log", asdict(log_data)) except Exception: log.exception("Fehler im Ladelog-Modul") -def save_interim_data(chargepoint, charging_ev, immediately: bool = True): +def save_interim_data(chargepoint, charging_ev): try: log_data = chargepoint.data.set.log # Es wurde noch nie ein Auto zugeordnet - if charging_ev == -1: - return - if log_data.timestamp_start_charging is None: + if log_data.imported_since_mode_switch == 0 and log_data.exported_since_mode_switch == 0: # Die Daten wurden schon erfasst. return - if not immediately: - if chargepoint.data.get.power != 0: - # Das Fahrzeug hat die Ladung noch nicht beendet. Der Logeintrag wird später erstellt. - return - save_data(chargepoint, charging_ev, immediately) + save_data(chargepoint, charging_ev) chargepoint.reset_log_data_chargemode_switch() except Exception: log.exception("Fehler im Ladelog-Modul") @@ -141,16 +166,14 @@ def save_and_reset_data(chargepoint, charging_ev, immediately: bool = True): Soll sofort ein Eintrag erstellt werden oder gewartet werden, bis die Ladung beendet ist. """ try: - if charging_ev == -1: - # Es wurde noch nie ein Auto zugeordnet. - return if not immediately: if chargepoint.data.get.power != 0: # Das Fahrzeug hat die Ladung noch nicht beendet. Der Logeintrag wird später erstellt. return - if chargepoint.data.set.log.timestamp_start_charging: + if (chargepoint.data.set.log.imported_since_mode_switch > 0 or + chargepoint.data.set.log.exported_since_mode_switch > 0): # Die Daten wurden noch nicht erfasst. - save_data(chargepoint, charging_ev, immediately) + save_data(chargepoint, charging_ev) chargepoint.reset_log_data() except Exception: log.exception("Fehler im Ladelog-Modul") @@ -164,7 +187,22 @@ def get_value_or_default(func, default: Optional[Any] = None): return default -def save_data(chargepoint, charging_ev, immediately: bool = True): +def _get_range_charged(log_data, charging_ev) -> float: + try: + if log_data.range_at_start is not None: + return get_value_or_default(lambda: round( + charging_ev.data.get.range - log_data.range_at_start, 2)) + else: + return get_value_or_default(lambda: round( + ((log_data.imported_since_mode_switch - log_data.exported_since_mode_switch) + * charging_ev.ev_template.data.efficiency / + charging_ev.ev_template.data.average_consump), 2)) + except Exception: + log.exception("Fehler beim Berechnen der geladenen Reichweite") + return None + + +def save_data(chargepoint, charging_ev): """ json-Objekt für den Log-Eintrag erstellen, an die Datei anhängen und die Daten, die sich auf den Ladevorgang beziehen, löschen. @@ -175,34 +213,41 @@ def save_data(chargepoint, charging_ev, immediately: bool = True): charging_ev: class EV, das an diesem Ladepunkt lädt. (Wird extra übergeben, da es u.U. noch nicht zugewiesen ist und nur die Nummer aus dem Broker in der LP-Klasse hinterlegt ist.) - reset: bool - Wenn die Daten komplett zurückgesetzt werden, wird nicht der Zwischenzählerstand für - imported_at_mode_switch notiert. Sonst schon, damit zwischen save_data und dem nächsten collect_data keine - Daten verloren gehen. """ - new_entry = _create_entry(chargepoint, charging_ev, immediately) - write_new_entry(new_entry) + if (chargepoint.data.set.log.imported_since_mode_switch != 0 or + chargepoint.data.set.log.exported_since_mode_switch != 0): + new_entry = _create_entry(chargepoint, charging_ev) + write_new_entry(new_entry) -def _create_entry(chargepoint, charging_ev, immediately: bool = True): +def _create_entry(chargepoint, charging_ev): log_data = chargepoint.data.set.log # Daten vor dem Speichern nochmal aktualisieren, auch wenn nicht mehr geladen wird. log_data.imported_since_plugged = get_value_or_default(lambda: round( chargepoint.data.get.imported - log_data.imported_at_plugtime, 2)) log_data.imported_since_mode_switch = get_value_or_default(lambda: round( chargepoint.data.get.imported - log_data.imported_at_mode_switch, 2)) - log_data.range_charged = get_value_or_default(lambda: round( - log_data.imported_since_mode_switch / charging_ev.ev_template.data.average_consump*100, 2)) - log_data.time_charged, duration = timecheck.get_difference_to_now(log_data.timestamp_start_charging) + log_data.exported_since_plugged = get_value_or_default(lambda: round( + chargepoint.data.get.exported - log_data.exported_at_plugtime, 2)) + log_data.exported_since_mode_switch = get_value_or_default(lambda: round( + chargepoint.data.get.exported - log_data.exported_at_mode_switch, 2)) + log_data.range_charged = _get_range_charged(log_data, charging_ev) power = 0 - if duration > 0: + if log_data.timestamp_start_charging: + time_charged = get_value_or_default(lambda: log_data.time_charged + + (timecheck.create_timestamp() - log_data.timestamp_start_charging), 0) + else: + time_charged = get_value_or_default(lambda: log_data.time_charged, 0) + time_charged_readable = f"{int(time_charged / 3600)}:{int((time_charged % 3600) / 60):02d}" + if time_charged > 0: # power calculation needs to be fixed if useful: # log_data.imported_since_mode_switch / (duration / 3600) - power = get_value_or_default(lambda: round(log_data.imported_since_mode_switch / duration, 2)) - calculate_charge_cost(chargepoint, True) + power = get_value_or_default(lambda: round(log_data.imported_since_mode_switch / (time_charged / 3600), 2)) + calc_energy_costs(chargepoint, True) energy_source = get_value_or_default(lambda: analyse_percentage(get_log_from_date_until_now( - log_data.timestamp_start_charging)["totals"])["energy_source"]) + log_data.timestamp_mode_switch)["totals"])["energy_source"]) costs = round(log_data.costs, 2) + new_entry = { "chargepoint": { @@ -211,11 +256,13 @@ def _create_entry(chargepoint, charging_ev, immediately: bool = True): "serial_number": get_value_or_default(lambda: chargepoint.data.get.serial_number), "imported_at_start": get_value_or_default(lambda: log_data.imported_at_mode_switch), "imported_at_end": get_value_or_default(lambda: chargepoint.data.get.imported), + "exported_at_start": get_value_or_default(lambda: log_data.exported_at_mode_switch), + "exported_at_end": get_value_or_default(lambda: chargepoint.data.get.exported), }, "vehicle": { "id": get_value_or_default(lambda: log_data.ev), - "name": get_value_or_default(lambda: _get_ev_name(log_data.ev)), + "name": get_value_or_default(lambda: data.data.ev_data[f"ev{log_data.ev}"].data.name, ""), "chargemode": get_value_or_default(lambda: log_data.chargemode_log_entry), "prio": get_value_or_default(lambda: log_data.prio), "rfid": get_value_or_default(lambda: log_data.rfid), @@ -227,14 +274,16 @@ def _create_entry(chargepoint, charging_ev, immediately: bool = True): "time": { "begin": get_value_or_default(lambda: datetime.datetime.fromtimestamp( - log_data.timestamp_start_charging).strftime("%m/%d/%Y, %H:%M:%S")), + log_data.timestamp_mode_switch).strftime("%m/%d/%Y, %H:%M:%S")), "end": get_value_or_default(lambda: datetime.datetime.fromtimestamp( timecheck.create_timestamp()).strftime("%m/%d/%Y, %H:%M:%S")), - "time_charged": get_value_or_default(lambda: log_data.time_charged) + "time_charged": time_charged_readable }, "data": { "range_charged": log_data.range_charged, + "exported_since_mode_switch": log_data.exported_since_mode_switch, + "exported_since_plugged": log_data.exported_since_plugged, "imported_since_mode_switch": log_data.imported_since_mode_switch, "imported_since_plugged": log_data.imported_since_plugged, "power": power, @@ -269,161 +318,53 @@ def write_new_entry(new_entry): log.debug(f"Neuer Ladelog-Eintrag: {new_entry}") -def _get_ev_name(ev: int) -> str: - try: - return data.data.ev_data[f"ev{ev}"].data.name - except Exception: - return "" - - -def get_log_data(request: Dict): - """ json-Objekt mit gefilterten Logdaten erstellen - - Parameter - --------- - request: dict - Infos zum Request: Monat, Jahr, Filter - """ - log_data = {"entries": [], "totals": {}} +def calc_energy_costs(cp, create_log_entry: bool = False): try: - # Datei einlesen - filepath = str(_get_parent_file() / "data" / "charge_log" / - (str(request["year"]) + str(request["month"]) + ".json")) - try: - with open(filepath, "r", encoding="utf-8") as json_file: - charge_log = json.load(json_file) - except FileNotFoundError: - log.debug("Kein Ladelog für %s gefunden!" % (str(request))) - return log_data - # Liste mit gefilterten Einträgen erstellen - for entry in charge_log: - if len(entry) > 0: - if ( - "id" in request["filter"]["chargepoint"] and - len(request["filter"]["chargepoint"]["id"]) > 0 and - entry["chargepoint"]["id"] not in request["filter"]["chargepoint"]["id"] - ): - log.debug( - "Verwerfe Eintrag wegen Ladepunkt ID: %s != %s" % - (str(entry["chargepoint"]["id"]), str(request["filter"]["chargepoint"]["id"])) - ) - continue - if ( - "id" in request["filter"]["vehicle"] and - len(request["filter"]["vehicle"]["id"]) > 0 and - entry["vehicle"]["id"] not in request["filter"]["vehicle"]["id"] - ): - log.debug( - "Verwerfe Eintrag wegen Fahrzeug ID: %s != %s" % - (str(entry["vehicle"]["id"]), str(request["filter"]["vehicle"]["id"])) - ) - continue - if ( - "tag" in request["filter"]["vehicle"] and - len(request["filter"]["vehicle"]["tag"]) > 0 and - entry["vehicle"]["rfid"] not in request["filter"]["vehicle"]["tag"] - ): - log.debug( - "Verwerfe Eintrag wegen ID Tag: %s != %s" % - (str(entry["vehicle"]["rfid"]), str(request["filter"]["vehicle"]["tag"])) - ) - continue - if ( - "chargemode" in request["filter"]["vehicle"] and - len(request["filter"]["vehicle"]["chargemode"]) > 0 and - entry["vehicle"]["chargemode"] not in request["filter"]["vehicle"]["chargemode"] - ): - log.debug( - "Verwerfe Eintrag wegen Lademodus: %s != %s" % - (str(entry["vehicle"]["chargemode"]), str(request["filter"]["vehicle"]["chargemode"])) - ) - continue - if ( - "prio" in request["filter"]["vehicle"] and - request["filter"]["vehicle"]["prio"] is not entry["vehicle"]["prio"] - ): - log.debug( - "Verwerfe Eintrag wegen Priorität: %s != %s" % - (str(entry["vehicle"]["prio"]), str(request["filter"]["vehicle"]["prio"])) - ) - continue - - # wenn wir hier ankommen, passt der Eintrag zum Filter - log_data["entries"].append(entry) - log_data["totals"] = get_totals_of_filtered_log_data(log_data) + if cp.data.set.log.imported_since_plugged != 0 and cp.data.set.log.imported_since_mode_switch != 0: + processed_entries, reference_entries = _get_reference_entries() + charged_energy_by_source = calculate_charged_energy_by_source( + cp, processed_entries, reference_entries, create_log_entry) + _add_charged_energy_by_source(cp, charged_energy_by_source) + log.debug(f"charged_energy_by_source {charged_energy_by_source} " + f"total charged_energy_by_source {cp.data.set.log.charged_energy_by_source}") + costs = _calc_costs(charged_energy_by_source, reference_entries[-1]["prices"]) + cp.data.set.log.costs += costs + Pub().pub(f"openWB/set/chargepoint/{cp.num}/set/log", asdict(cp.data.set.log)) except Exception: - log.exception("Fehler im Ladelog-Modul") - return log_data - - -def get_totals_of_filtered_log_data(log_data: Dict) -> Dict: - def get_sum(entry_name: str) -> float: - sum = 0 - try: - for entry in log_data["entries"]: - sum += entry["data"][entry_name] - return sum - except Exception: - return None - if len(log_data["entries"]) > 0: - # Summen bilden - duration_sum = "00:00" - try: - for entry in log_data["entries"]: - duration_sum = timecheck.duration_sum( - duration_sum, entry["time"]["time_charged"]) - except Exception: - duration_sum = None - range_charged_sum = get_sum("range_charged") - mode_sum = get_sum("imported_since_mode_switch") - power_sum = get_sum("power") - costs_sum = get_sum("costs") - power_sum = power_sum / len(log_data["entries"]) - return { - "time_charged": duration_sum, - "range_charged": range_charged_sum, - "imported_since_mode_switch": mode_sum, - "power": power_sum, - "costs": costs_sum, - } + log.exception(f"Fehler beim Berechnen der Ladekosten für Ladepunkt {cp.num}") -def calculate_charge_cost(cp, create_log_entry: bool = False): - content = get_todays_daily_log() +def calculate_charged_energy_by_source(cp, processed_entries, reference_entries, create_log_entry: bool = False): try: - if cp.data.set.log.imported_since_plugged != 0 and cp.data.set.log.imported_since_mode_switch != 0: - reference = _get_reference_position(cp, create_log_entry) - reference_time = get_reference_time(cp, reference) - reference_entry = _get_reference_entry(content["entries"], reference_time) - energy_entry = process_entry(reference_entry, - create_entry(LogType.DAILY, LegacySmartHomeLogData(), reference_entry), - CalculationType.ENERGY) - energy_source_entry = analyse_percentage(energy_entry) - log.debug(f"reference {reference}, reference_time {reference_time}, " - f"cp.data.set.log.imported_since_mode_switch {cp.data.set.log.imported_since_mode_switch}, " - f"cp.data.set.log.timestamp_start_charging {cp.data.set.log.timestamp_start_charging}") - log.debug(f"energy_source_entry {energy_source_entry}") - if reference == ReferenceTime.START: + reference = _get_reference_position(cp, create_log_entry) + absolut_energy_source = processed_entries["totals"]["cp"][f"cp{cp.num}"] + relative_energy_source = get_relative_energy_source(absolut_energy_source) + log.debug(f"reference {reference}, " + f"cp.data.set.log.imported_since_mode_switch {cp.data.set.log.imported_since_mode_switch}, " + f"cp.data.set.log.timestamp_start_charging {cp.data.set.log.timestamp_start_charging}") + log.debug(f"energy_source_entry {relative_energy_source}") + if reference == ReferenceTime.START: + charged_energy = cp.data.set.log.imported_since_mode_switch + elif reference == ReferenceTime.MIDDLE: + charged_energy = (reference_entries[-1]["cp"][f"cp{cp.num}"]["imported"] - + reference_entries[0]["cp"][f"cp{cp.num}"]["imported"]) + elif reference == ReferenceTime.END: + if ((timecheck.create_timestamp()-cp.data.set.log.timestamp_mode_switch) < MEASUREMENT_LOGGING_INTERVAL): charged_energy = cp.data.set.log.imported_since_mode_switch - elif reference == ReferenceTime.MIDDLE: - charged_energy = (content["entries"][-1]["cp"][f"cp{cp.num}"]["imported"] - - energy_source_entry["cp"][f"cp{cp.num}"]["imported"]) - elif reference == ReferenceTime.END: - # timestamp_before_full_hour, dann gibt es schon ein Zwischenergebnis - if timecheck.create_unix_timestamp_current_full_hour() <= cp.data.set.log.timestamp_start_charging: - charged_energy = cp.data.set.log.imported_since_mode_switch - else: - log.debug(f"cp.data.get.imported {cp.data.get.imported}") - charged_energy = cp.data.get.imported - \ - energy_entry["cp"][f"cp{cp.num}"]["imported"] else: - raise TypeError(f"Unbekannter Referenz-Zeitpunkt {reference}") - log.debug(f'power source {energy_source_entry["energy_source"]}') - log.debug(f"charged_energy {charged_energy}") - costs = _calc(energy_source_entry["energy_source"], charged_energy) - cp.data.set.log.costs += costs - log.debug(f"current costs {costs}, total costs {cp.data.set.log.costs}") - Pub().pub(f"openWB/set/chargepoint/{cp.num}/set/log", asdict(cp.data.set.log)) + log.debug(f"cp.data.get.imported {cp.data.get.imported}") + charged_energy = cp.data.get.imported - \ + reference_entries[-1]["cp"][f"cp{cp.num}"]["imported"] + else: + raise TypeError(f"Unbekannter Referenz-Zeitpunkt {reference}") + log.debug(f'power source {relative_energy_source}') + log.debug(f"charged_energy {charged_energy}") + if charged_energy < 100: + # wenn nur entladen wurde, keine Anteile berechnen + return {source: 0 for source in ENERGY_SOURCES} + return _get_charged_energy_by_source( + relative_energy_source, charged_energy) + except Exception: log.exception(f"Fehler beim Berechnen der Ladekosten für Ladepunkt {cp.num}") @@ -434,53 +375,41 @@ class ReferenceTime(Enum): END = 2 +ENERGY_SOURCES = ("bat", "cp", "grid", "pv") + + def _get_reference_position(cp, create_log_entry: bool) -> ReferenceTime: - # Referenz-Zeitpunkt ermitteln (angesteckt oder letzte volle Stunde) - # Wurde innerhalb der letzten Stunde angesteckt? if create_log_entry: - # Ladekosten für angefangene Stunde ermitteln + # Ladekosten in einem angebrochenen 5 Min Intervall ermitteln return ReferenceTime.END else: - # Wenn der Ladevorgang erst innerhalb der letzten Stunde gestartet wurde, ist das das erste Zwischenergebnis. - one_hour_back = timecheck.create_timestamp() - 3600 - if (one_hour_back - cp.data.set.log.timestamp_start_charging) < 0: + # Wenn der Ladevorgang erst innerhalb des letzten 5 Min Intervalls gestartet wurde, + # ist das das erste Zwischenergebnis. + if (timecheck.create_timestamp() - cp.data.set.log.timestamp_mode_switch) < MEASUREMENT_LOGGING_INTERVAL: return ReferenceTime.START else: return ReferenceTime.MIDDLE -def get_reference_time(cp, reference_position): - if reference_position == ReferenceTime.START: - return cp.data.set.log.timestamp_start_charging - elif reference_position == ReferenceTime.MIDDLE: - return timecheck.create_timestamp() - 3540 - elif reference_position == ReferenceTime.END: - # Wenn der Ladevorgang erst innerhalb der letzten Stunde gestartet wurde. - if timecheck.create_unix_timestamp_current_full_hour() <= cp.data.set.log.timestamp_start_charging: - return cp.data.set.log.timestamp_start_charging - else: - return timecheck.create_unix_timestamp_current_full_hour() + 60 - else: - raise TypeError(f"Unbekannter Referenz-Zeitpunkt {reference_position}") - - -def _get_reference_entry(entries: List[Dict], reference_time: float) -> Dict: - for entry in reversed(entries): - if entry["timestamp"] <= reference_time: - return entry - else: - # Tagesumbruch - content = _get_yesterdays_daily_log() - if content: - for entry in reversed(content["entries"]): - if entry["timestamp"] < reference_time: - return entry +def _get_reference_entries() -> Tuple[List[Dict], List]: + processed_entries = {} + reference_entries = [] + try: + entries = get_todays_daily_log()["entries"] + if len(entries) >= 2: + reference_entries = [entries[-2], entries[-1]] else: - return {} - - -def _get_yesterdays_daily_log(): - return get_daily_log((datetime.datetime.today()-datetime.timedelta(days=1)).strftime("%Y%m%d")) + date_day_before = (datetime.datetime.now() + datetime.timedelta(days=-1)).strftime("%Y%m%d") + entries_day_before = get_daily_log(date_day_before)["entries"] + reference_entries = [entries_day_before[-1], entries[0]] + processed_entries["entries"] = copy.deepcopy(reference_entries) + processed_entries["entries"] = _process_entries(processed_entries["entries"], CalculationType.ENERGY) + processed_entries["totals"] = get_totals(processed_entries["entries"], False) + processed_entries = _analyse_energy_source(processed_entries) + except Exception: + log.exception("Fehler beim Zusammenstellen der zwei letzten Logeinträge") + finally: + return processed_entries, reference_entries def get_todays_daily_log(): @@ -492,26 +421,48 @@ def get_daily_log(day): try: with open(filepath, "r", encoding="utf-8") as json_file: return json.load(json_file) - except FileNotFoundError: + except FILE_ERRORS: return [] -def _calc(energy_source: Dict[str, float], charged_energy_last_hour: float) -> float: - prices = data.data.general_data.data.prices +def _calc_costs(charged_energy_by_source: Dict[str, float], costs: Dict[str, float]) -> float: - bat_costs = prices.bat * charged_energy_last_hour * energy_source["bat"] - cp_costs = prices.cp * charged_energy_last_hour * energy_source["cp"] - try: - grid_costs = data.data.optional_data.et_get_current_price() * charged_energy_last_hour * energy_source["grid"] - except Exception: - grid_costs = prices.grid * charged_energy_last_hour * energy_source["grid"] - pv_costs = prices.pv * charged_energy_last_hour * energy_source["pv"] + bat_costs = costs["bat"] * charged_energy_by_source["bat"] + cp_costs = costs["cp"] * charged_energy_by_source["cp"] + grid_costs = costs["grid"] * charged_energy_by_source["grid"] + pv_costs = costs["pv"] * charged_energy_by_source["pv"] log.debug( - f'Ladepreis für die letzte Stunde: {bat_costs}€ Speicher ({energy_source["bat"]}%), {grid_costs}€ Netz ' - f'({energy_source["grid"]}%), {pv_costs}€ Pv ({energy_source["pv"]}%)') + f'Ladepreis nach Energiequelle: {bat_costs}€ Speicher ({charged_energy_by_source["bat"]/1000}kWh), ' + f'{grid_costs}€ Netz ({charged_energy_by_source["grid"]/1000}kWh),' + f' {pv_costs}€ Pv ({charged_energy_by_source["pv"]/1000}kWh), ' + f'{cp_costs}€ Ladepunkte ({charged_energy_by_source["cp"]/1000}kWh)') return round(bat_costs + cp_costs + grid_costs + pv_costs, 4) +def _get_charged_energy_by_source(energy_source, charged_energy) -> Dict[str, float]: + charged_energy_by_source = {} + for source in ENERGY_SOURCES: + charged_energy_by_source[source] = energy_source[source] * charged_energy + return charged_energy_by_source + + +def _add_charged_energy_by_source(cp, charged_energy_by_source): + for source in ENERGY_SOURCES: + cp.data.set.log.charged_energy_by_source[source] += charged_energy_by_source[source] + + +def get_relative_energy_source(absolut_energy_source: Dict[str, float]) -> Dict[str, float]: + if absolut_energy_source["energy_imported"] == 0: + return {source: 0 for source in ENERGY_SOURCES} + else: + relative_energy_source = {} + for source in ENERGY_SOURCES: + for absolute_source, value in absolut_energy_source.items(): + if source in absolute_source: + relative_energy_source[source] = value / absolut_energy_source["energy_imported"] + return relative_energy_source + + def _get_parent_file() -> pathlib.Path: return pathlib.Path(__file__).resolve().parents[3] diff --git a/packages/control/chargelog/chargelog_test.py b/packages/control/chargelog/chargelog_test.py index 1ab700af43..774bfb260a 100644 --- a/packages/control/chargelog/chargelog_test.py +++ b/packages/control/chargelog/chargelog_test.py @@ -1,46 +1,13 @@ -import datetime -from unittest.mock import Mock +import json +from unittest.mock import Mock, mock_open, patch import pytest from control import data from control.chargelog import chargelog -from control.chargelog.chargelog import calculate_charge_cost +from control.chargelog.chargelog import calc_energy_costs from control.chargepoint.chargepoint import Chargepoint -from helpermodules import timecheck -from test_utils.test_environment import running_on_github - - -def mock_daily_log_with_charging(date: str, num_of_intervalls, monkeypatch): - """erzeugt ein daily_log, im ersten Eintrag gibt es keine Änderung, danach wird bis inklusive dem letzten Beitrag - geladen""" - bat_exported = pv_exported = cp_imported = counter_imported = 2000 - date = datetime.datetime.strptime(date, "%m/%d/%Y, %H:%M") - daily_log = {"entries": []} - for i in range(0, num_of_intervalls): - if i != 0: - bat_exported += 1000 - pv_exported += 500 - cp_imported += 2000 - counter_imported += 500 - daily_log["entries"].append({'bat': {'all': {'exported': bat_exported, 'imported': 2000, 'soc': 100}, - 'bat2': {'exported': bat_exported, 'imported': 2000, 'soc': 100}}, - 'counter': {'counter0': {'exported': 2000, - 'grid': True, - 'imported': counter_imported}}, - 'cp': {'all': {'exported': 0, 'imported': cp_imported}, - 'cp4': {'exported': 0, 'imported': cp_imported}}, - 'date': date.strftime("%H:%M"), - 'ev': {'ev0': {'soc': None}}, - 'hc': {'all': {'imported': 0}}, - 'pv': {'all': {'exported': pv_exported}, 'pv1': {'exported': pv_exported}}, - 'sh': {}, - 'timestamp': date.timestamp()}) - date += datetime.timedelta(minutes=5) - mock_todays_daily_log = Mock(return_value=daily_log) - monkeypatch.setattr(chargelog, "get_todays_daily_log", mock_todays_daily_log) - return daily_log @pytest.fixture() @@ -49,106 +16,133 @@ def mock_data() -> None: data.data.optional_data.et_module = None -def mock_create_entry_reference_end(clock, daily_log, monkeypatch): - current_log = daily_log["entries"][-1] - current_log["cp"]["all"]["imported"] += 500 - current_log["cp"]["cp4"]["imported"] += 500 - current_log["counter"]["counter0"]["imported"] += 500 - current_log["date"] = clock - current_log["timestamp"] = datetime.datetime.strptime(f"05/16/2022, {clock}", "%m/%d/%Y, %H:%M").timestamp() - mock_create_entry = Mock(return_value=current_log) - monkeypatch.setattr(chargelog, "create_entry", mock_create_entry) +def mock_daily_log(monkeypatch): + daily_log = {"entries": [{'bat': {'all': {'exported': 2000, 'imported': 2000, 'soc': 100}, + 'bat2': {'exported': 2000, 'imported': 2000, 'soc': 100}}, + 'counter': {'counter0': {'exported': 2000, + 'grid': True, + 'imported': 500}}, + 'cp': {'all': {'exported': 0, 'imported': 2000}, + 'cp4': {'exported': 0, 'imported': 2000}}, + 'date': "8:35", + 'ev': {'ev0': {'soc': None}}, + 'hc': {'all': {'imported': 0}}, + 'pv': {'all': {'exported': 2000}, 'pv1': {'exported': 2000}}, + 'sh': {}, + 'timestamp': 1652682900, + 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}, + {'bat': {'all': {'exported': 3000, 'imported': 2000, 'soc': 100}, + 'bat2': {'exported': 3000, 'imported': 2000, 'soc': 100}}, + 'counter': {'counter0': {'exported': 2000, + 'grid': True, + 'imported': 2500}}, + 'cp': {'all': {'exported': 0, 'imported': 4000}, + 'cp4': {'exported': 0, 'imported': 4000}}, + 'date': "8:40", + 'ev': {'ev0': {'soc': None}}, + 'hc': {'all': {'imported': 0}}, + 'pv': {'all': {'exported': 2500}, 'pv1': {'exported': 2500}}, + 'sh': {}, + 'timestamp': 1652683200, + 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}]} + mock_todays_daily_log = Mock(return_value=daily_log) + monkeypatch.setattr(chargelog, "get_todays_daily_log", mock_todays_daily_log) + return daily_log -def init_cp(charged_energy, costs, start_hour, start_minute=47): +def test_calc_charge_cost_reference_middle(mock_data, monkeypatch): cp = Chargepoint(4, None) - cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = charged_energy - cp.data.set.log.timestamp_start_charging = datetime.datetime(2022, 5, 16, start_hour, start_minute).timestamp() - cp.data.get.imported = charged_energy + 2000 - cp.data.set.log.costs = costs - return cp - - -def test_calc_charge_cost_no_hour_change_reference_end(mock_data, monkeypatch): - cp = init_cp(6500, 0, 10, start_minute=27) - daily_log = mock_daily_log_with_charging("05/16/2022, 10:25", 4, monkeypatch) - mock_create_entry_reference_end("10:42", daily_log, monkeypatch) - - calculate_charge_cost(cp, True) - - assert cp.data.set.log.costs == 1.425 - + cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = 3950 + cp.data.set.log.timestamp_mode_switch = 1652682600 # 8:30 + cp.data.get.imported = 4050 + cp.data.set.log.charged_energy_by_source = {'bat': 100, 'cp': 0, 'grid': 100, 'pv': 100} + daily_log = mock_daily_log(monkeypatch) -def test_calc_charge_cost_first_hour_change_reference_begin(mock_data, monkeypatch): - cp = init_cp(6000, 0, 7) - daily_log = mock_daily_log_with_charging("05/16/2022, 07:45", 4, monkeypatch) - current_log = daily_log["entries"][-1] - current_log["date"] = "08:00" - current_log["timestamp"] = datetime.datetime.strptime("05/16/2022, 08:00", "%m/%d/%Y, %H:%M").timestamp() - mock_create_entry = Mock(return_value=current_log) - monkeypatch.setattr(chargelog, "create_entry", mock_create_entry) + with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): + calc_energy_costs(cp) - calculate_charge_cost(cp, False) + assert cp.data.set.log.charged_energy_by_source == { + 'grid': 1243, 'pv': 386, 'bat': 671, 'cp': 0.0} + assert round(cp.data.set.log.costs, 5) == 0.5 - assert cp.data.set.log.costs == 1.275 +def test_calc_charge_cost_reference_start(mock_data, monkeypatch): + cp = Chargepoint(4, None) + cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = 100 + cp.data.set.log.timestamp_mode_switch = 1652683230 # 8:40:30 + cp.data.get.imported = 4100 + cp.data.set.log.charged_energy_by_source = {'bat': 0, 'cp': 0, 'grid': 0, 'pv': 0} + daily_log = mock_daily_log(monkeypatch) -def test_calc_charge_cost_first_hour_change_reference_begin_day_change(mock_data, monkeypatch): - cp = init_cp(6000, 0, 23) - daily_log = mock_daily_log_with_charging("05/16/2022, 23:45", 4, monkeypatch) - current_log = daily_log["entries"][-1] - current_log["date"] = "00:00" - current_log["timestamp"] = datetime.datetime.strptime("05/17/2022, 00:00", "%m/%d/%Y, %H:%M").timestamp() - mock_create_entry = Mock(return_value=current_log) - monkeypatch.setattr(chargelog, "create_entry", mock_create_entry) - mock_today_timestamp = Mock(return_value=1652738421) - monkeypatch.setattr(timecheck, "create_timestamp", mock_today_timestamp) - - calculate_charge_cost(cp, False) - - assert cp.data.set.log.costs == 1.275 - - -def test_calc_charge_cost_one_hour_change_reference_end(mock_data, monkeypatch): - if running_on_github(): - # ToDo Zeitzonen berücksichtigen, damit Tests auf Github laufen - return - cp = init_cp(22500, 1.275, 7) - daily_log = mock_daily_log_with_charging("05/16/2022, 07:45", 12, monkeypatch) - mock_create_entry_reference_end("08:40", daily_log, monkeypatch) - - calculate_charge_cost(cp, True) + with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): + calc_energy_costs(cp) - assert cp.data.set.log.costs == 4.8248999999999995 + assert cp.data.set.log.charged_energy_by_source == { + 'bat': 28.549999999999997, 'cp': 0.0, 'grid': 57.15, 'pv': 14.299999999999999} + assert round(cp.data.set.log.costs, 5) == 0.025 -def test_calc_charge_cost_two_hour_change_reference_middle(mock_data, monkeypatch): - if running_on_github(): - # ToDo Zeitzonen berücksichtigen, damit Tests auf Github laufen - return - cp = init_cp(22500, 1.275, 6) - daily_log = mock_daily_log_with_charging("05/16/2022, 06:45", 16, monkeypatch) - current_log = daily_log["entries"][-1] - current_log["date"] = "08:00" - current_log["timestamp"] = datetime.datetime(2022, 5, 16, 8).timestamp() - mock_create_entry = Mock(return_value=current_log) - monkeypatch.setattr(chargelog, "create_entry", mock_create_entry) - mock_today_timestamp = Mock(return_value=1652680801) - monkeypatch.setattr(timecheck, "create_timestamp", mock_today_timestamp) +def test_calc_charge_cost_reference_end(mock_data, monkeypatch): + cp = Chargepoint(4, None) + cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = 3950 + cp.data.set.log.timestamp_mode_switch = 1652682600 # 8:30 + cp.data.get.imported = 4100 + cp.data.set.log.charged_energy_by_source = {'grid': 1243, 'pv': 386, 'bat': 671, 'cp': 0.0} + daily_log = mock_daily_log(monkeypatch) - calculate_charge_cost(cp, False) + with patch("builtins.open", mock_open(read_data=json.dumps(daily_log))): + calc_energy_costs(cp, True) - assert cp.data.set.log.costs == 6.375 + assert cp.data.set.log.charged_energy_by_source == {'bat': 699.55, 'cp': 0.0, 'grid': 1300.15, 'pv': 400.3} + assert round(cp.data.set.log.costs, 5) == 0.025 -def test_calc_charge_cost_two_hour_change_reference_end(mock_data, monkeypatch): - if running_on_github(): - # ToDo Zeitzonen berücksichtigen, damit Tests auf Github laufen - return - cp = init_cp(46500, 6.375, 6) - daily_log = mock_daily_log_with_charging("05/16/2022, 06:45", 24, monkeypatch) - mock_create_entry_reference_end("08:40", daily_log, monkeypatch) +def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): + cp = Chargepoint(4, None) + cp.data.set.log.imported_since_plugged = cp.data.set.log.imported_since_mode_switch = 3950 + cp.data.set.log.timestamp_mode_switch = 1652682600 # 8:30 + cp.data.get.imported = 4050 + cp.data.set.log.charged_energy_by_source = {'bat': 100, 'cp': 0, 'grid': 100, 'pv': 100} + yesterday_daily_log = {"entries": [{'bat': {'all': {'exported': 2000, 'imported': 2000, 'soc': 100}, + 'bat2': {'exported': 2000, 'imported': 2000, 'soc': 100}}, + 'counter': {'counter0': {'exported': 2000, + 'grid': True, + 'imported': 500}}, + 'cp': {'all': {'exported': 0, 'imported': 2000}, + 'cp4': {'exported': 0, 'imported': 2000}}, + 'date': "8:35", + 'ev': {'ev0': {'soc': None}}, + 'hc': {'all': {'imported': 0}}, + 'pv': {'all': {'exported': 2000}, 'pv1': {'exported': 2000}}, + 'sh': {}, + 'timestamp': 1652682900, + 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}]} + mock_yesterdays_daily_log = Mock(return_value=yesterday_daily_log) + monkeypatch.setattr(chargelog, "get_daily_log", mock_yesterdays_daily_log) + + daily_log = {"entries": [{'bat': {'all': {'exported': 3000, 'imported': 2000, 'soc': 100}, + 'bat2': {'exported': 3000, 'imported': 2000, 'soc': 100}}, + 'counter': {'counter0': {'exported': 2000, + 'grid': True, + 'imported': 2500}}, + 'cp': {'all': {'exported': 0, 'imported': 4000}, + 'cp4': {'exported': 0, 'imported': 4000}}, + 'date': "8:40", + 'ev': {'ev0': {'soc': None}}, + 'hc': {'all': {'imported': 0}}, + 'pv': {'all': {'exported': 2500}, 'pv1': {'exported': 2500}}, + 'sh': {}, + 'timestamp': 1652683200, + 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}]} + mock_todays_daily_log = Mock(return_value=daily_log) + monkeypatch.setattr(chargelog, "get_todays_daily_log", mock_todays_daily_log) - calculate_charge_cost(cp, True) + with patch("builtins.open", side_effect=[ + mock_open(read_data=json.dumps(daily_log)), + mock_open(read_data=json.dumps(yesterday_daily_log)) + ]): + calc_energy_costs(cp) - assert cp.data.set.log.costs == 9.924900000000001 + assert cp.data.set.log.charged_energy_by_source == { + 'grid': 1243, 'pv': 386, 'bat': 671, 'cp': 0.0} + assert round(cp.data.set.log.costs, 5) == 0.5 diff --git a/packages/control/chargelog/process_chargelog.py b/packages/control/chargelog/process_chargelog.py new file mode 100644 index 0000000000..9349db3e26 --- /dev/null +++ b/packages/control/chargelog/process_chargelog.py @@ -0,0 +1,125 @@ +import json +import logging +import pathlib +from typing import Dict + +from helpermodules import timecheck + + +log = logging.getLogger("chargelog") + + +def get_log_data(request: Dict): + """ json-Objekt mit gefilterten Logdaten erstellen + + Parameter + --------- + request: dict + Infos zum Request: Monat, Jahr, Filter + """ + log_data = {"entries": [], "totals": {}} + try: + # Datei einlesen + filepath = str(_get_parent_file() / "data" / "charge_log" / + (str(request["year"]) + str(request["month"]) + ".json")) + try: + with open(filepath, "r", encoding="utf-8") as json_file: + charge_log = json.load(json_file) + except FileNotFoundError: + log.debug("Kein Ladelog für %s gefunden!" % (str(request))) + return log_data + # Liste mit gefilterten Einträgen erstellen + for entry in charge_log: + if len(entry) > 0: + if ( + "id" in request["filter"]["chargepoint"] and + len(request["filter"]["chargepoint"]["id"]) > 0 and + entry["chargepoint"]["id"] not in request["filter"]["chargepoint"]["id"] + ): + log.debug( + "Verwerfe Eintrag wegen Ladepunkt ID: %s != %s" % + (str(entry["chargepoint"]["id"]), str(request["filter"]["chargepoint"]["id"])) + ) + continue + if ( + "id" in request["filter"]["vehicle"] and + len(request["filter"]["vehicle"]["id"]) > 0 and + entry["vehicle"]["id"] not in request["filter"]["vehicle"]["id"] + ): + log.debug( + "Verwerfe Eintrag wegen Fahrzeug ID: %s != %s" % + (str(entry["vehicle"]["id"]), str(request["filter"]["vehicle"]["id"])) + ) + continue + if ( + "tag" in request["filter"]["vehicle"] and + len(request["filter"]["vehicle"]["tag"]) > 0 and + entry["vehicle"]["rfid"] not in request["filter"]["vehicle"]["tag"] + ): + log.debug( + "Verwerfe Eintrag wegen ID Tag: %s != %s" % + (str(entry["vehicle"]["rfid"]), str(request["filter"]["vehicle"]["tag"])) + ) + continue + if ( + "chargemode" in request["filter"]["vehicle"] and + len(request["filter"]["vehicle"]["chargemode"]) > 0 and + entry["vehicle"]["chargemode"] not in request["filter"]["vehicle"]["chargemode"] + ): + log.debug( + "Verwerfe Eintrag wegen Lademodus: %s != %s" % + (str(entry["vehicle"]["chargemode"]), str(request["filter"]["vehicle"]["chargemode"])) + ) + continue + if ( + "prio" in request["filter"]["vehicle"] and + request["filter"]["vehicle"]["prio"] is not entry["vehicle"]["prio"] + ): + log.debug( + "Verwerfe Eintrag wegen Priorität: %s != %s" % + (str(entry["vehicle"]["prio"]), str(request["filter"]["vehicle"]["prio"])) + ) + continue + + # wenn wir hier ankommen, passt der Eintrag zum Filter + log_data["entries"].append(entry) + log_data["totals"] = get_totals_of_filtered_log_data(log_data) + except Exception: + log.exception("Fehler im Ladelog-Modul") + return log_data + + +def get_totals_of_filtered_log_data(log_data: Dict) -> Dict: + def get_sum(entry_name: str) -> float: + sum = 0 + try: + for entry in log_data["entries"]: + sum += entry["data"][entry_name] + return sum + except Exception: + return None + if len(log_data["entries"]) > 0: + # Summen bilden + duration_sum = "00:00" + try: + for entry in log_data["entries"]: + duration_sum = timecheck.duration_sum( + duration_sum, entry["time"]["time_charged"]) + except Exception: + duration_sum = None + range_charged_sum = get_sum("range_charged") + mode_sum = get_sum("imported_since_mode_switch") + power_sum = get_sum("power") + costs_sum = get_sum("costs") + power_sum = power_sum / len(log_data["entries"]) + return { + "time_charged": duration_sum, + "range_charged": range_charged_sum, + "imported_since_mode_switch": mode_sum, + "power": power_sum, + "costs": costs_sum, + } + + +def _get_parent_file() -> pathlib.Path: + return pathlib.Path(__file__).resolve().parents[3] diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index c8ceb61cbd..af3b080d48 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -1,19 +1,10 @@ -"""Ladepunkt-Logik - -charging_ev: EV, das aktuell laden darf -charging_ev_prev: EV, das vorher geladen hat. Dies wird benötigt, da wenn das EV nicht mehr laden darf, z.B. weil -Autolock aktiv ist, gewartet werden muss, bis die Ladeleistung 0 ist und dann erst der Eintrag im Protokoll erstellt -werden kann. -charging_ev = -1 zeigt an, dass der LP im Algorithmus nicht berücksichtigt werden soll. Ist das Ev abgesteckt, wird -auch charging_ev_prev -1 und im nächsten Zyklus kann ein neues Profil geladen werden. - -ID-Tag/Code-Eingabe: -Mit einem ID-Tag/Code kann optional der Ladepunkt freigeschaltet werden, es wird gleichzeitig immer ein EV damit -zugeordnet, mit dem nach der Freischaltung geladen werden soll. Wenn max 5 Min nach dem Scannen kein Auto -angesteckt wird, wird der Tag verworfen. Ebenso wenn kein EV gefunden wird. -Tag-Liste: Tags, mit denen der Ladepunkt freigeschaltet werden kann. Ist diese leer, kann mit jedem Tag der Ladepunkt -freigeschaltet werden. -""" +from helpermodules.timecheck import check_timestamp, create_timestamp +from modules.common.abstract_chargepoint import AbstractChargepoint +from modules.chargepoints.openwb_pro.chargepoint_module import EvseSignaling +from helpermodules.utils import thread_handler +from helpermodules import timecheck +from helpermodules.pub import Pub +from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase from dataclasses import asdict import dataclasses import logging @@ -36,13 +27,6 @@ from control import phase_switch from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState from control.text import BidiState -from helpermodules.phase_mapping import convert_single_evu_phase_to_cp_phase -from helpermodules.pub import Pub -from helpermodules import timecheck -from helpermodules.utils import thread_handler -from modules.chargepoints.openwb_pro.chargepoint_module import EvseSignaling -from modules.common.abstract_chargepoint import AbstractChargepoint -from helpermodules.timecheck import check_timestamp, create_timestamp def get_chargepoint_config_default() -> dict: @@ -215,41 +199,28 @@ def _process_charge_stop(self) -> None: self.data.set.ocpp_transaction_id, self.data.set.rfid) Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/ocpp_transaction_id", None) - if self.data.set.charging_ev_prev != -1: - # Daten zurücksetzen, wenn nicht geladen werden soll. - self.reset_control_parameter_at_charge_stop() - data.data.counter_all_data.get_evu_counter().reset_switch_on_off( - self, data.data.ev_data["ev"+str(self.data.set.charging_ev_prev)]) - # Abstecken - if not self.data.get.plug_state: - self.data.control_parameter = control_parameter_factory() - # Standardprofil nach Abstecken laden - if self.data.set.charge_template.data.load_default: - self.data.config.ev = 0 - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/config/ev", 0) - # Ladepunkt nach Abstecken sperren - if self.template.data.disable_after_unplug: - self.data.set.manual_lock = True - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/manual_lock", True) - log.debug("/set/manual_lock True") - # Ev wurde noch nicht aktualisiert. - # Ladeprofil aus den Einstellungen laden. - if data.data.general_data.data.temporary_charge_templates_active: - self.update_charge_template( - data.data.ev_data["ev"+str(self.data.set.charging_ev_prev)].charge_template) - chargelog.save_and_reset_data(self, data.data.ev_data["ev"+str(self.data.set.charging_ev_prev)]) - self.data.set.charging_ev_prev = -1 - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/charging_ev_prev", - self.data.set.charging_ev_prev) - self.data.set.rfid = None - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/rfid", None) - self.data.set.plug_time = None - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/plug_time", None) - self.data.set.phases_to_use = self.data.get.phases_in_use - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/phases_to_use", - self.data.set.phases_to_use) - self.data.set.charging_ev = -1 - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/charging_ev", -1) + self.reset_control_parameter_at_charge_stop() + data.data.counter_all_data.get_evu_counter().reset_switch_on_off(self) + if self.data.get.plug_state is False and self.data.set.plug_state_prev is True: + chargelog.save_and_reset_data(self, data.data.ev_data["ev"+str(self.data.config.ev)]) + self.data.control_parameter = control_parameter_factory() + if self.data.set.charge_template.data.load_default: + self.data.config.ev = 0 + Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/config/ev", 0) + if self.template.data.disable_after_unplug: + self.data.set.manual_lock = True + Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/manual_lock", True) + log.debug("/set/manual_lock True") + if data.data.general_data.data.temporary_charge_templates_active: + self.update_charge_template( + data.data.ev_data["ev"+str(self.data.config.ev)].charge_template) + self.data.set.rfid = None + Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/rfid", None) + self.data.set.plug_time = None + Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/plug_time", None) + self.data.set.phases_to_use = self.data.get.phases_in_use + Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/phases_to_use", + self.data.set.phases_to_use) self.data.set.current = 0 Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/current", 0) self.data.set.energy_to_charge = 0 @@ -304,6 +275,9 @@ def remember_previous_values(self): def reset_log_data_chargemode_switch(self) -> None: reset_log = Log() # Wenn ein Zwischeneintrag, zB bei Wechsel des Lademodus, erstellt wird, Zählerstände nicht verwerfen. + reset_log.exported_at_mode_switch = self.data.get.exported + reset_log.exported_at_plugtime = self.data.set.log.exported_at_plugtime + reset_log.exported_since_plugged = self.data.set.log.exported_since_plugged reset_log.imported_at_mode_switch = self.data.get.imported reset_log.imported_at_plugtime = self.data.set.log.imported_at_plugtime reset_log.imported_since_plugged = self.data.set.log.imported_since_plugged @@ -382,7 +356,7 @@ def _is_phase_switch_required(self) -> bool: if phase_switch_required: # Umschaltung fehlgeschlagen if self.data.set.phases_to_use != self.data.get.phases_in_use: - if data.data.general_data.data.chargemode_config.retry_failed_phase_switches: + if data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches: if self.data.control_parameter.failed_phase_switches > self.MAX_FAILED_PHASE_SWITCHES: phase_switch_required = False self.set_state_and_log( @@ -408,20 +382,7 @@ def check_phase_switch_completed(self): charging_ev = self.data.set.charging_ev_data # Umschaltung im Gange if self.data.control_parameter.state == ChargepointState.PERFORMING_PHASE_SWITCH: - phase_switch_pause = charging_ev.ev_template.data.phase_switch_pause - # Umschaltung abgeschlossen - try: - timestamp_not_expired = timecheck.check_timestamp( - self.data.control_parameter.timestamp_last_phase_switch, - 6 + phase_switch_pause - 1) - except TypeError: - # so wird in jedem Fall die erforderliche Zeit abgewartet - self.data.control_parameter.timestamp_last_phase_switch = create_timestamp() - timestamp_not_expired = timecheck.check_timestamp( - self.data.control_parameter.timestamp_last_phase_switch, - 6 + phase_switch_pause - 1) - if not timestamp_not_expired: - log.debug("phase switch running") + if phase_switch.phase_switch_thread_alive(self.num) is False: # Aktuelle Ladeleistung und Differenz wieder freigeben. if self.data.set.phases_to_use == 1: evu_counter.data.set.reserved_surplus -= charging_ev.ev_template. \ @@ -529,7 +490,7 @@ def get_phases_by_selected_chargemode(self, phases_chargemode: int) -> int: # bis der Algorithmus eine Umschaltung vorgibt, zB weil der gewählte Lademodus eine # andere Phasenzahl benötigt oder bei PV-Laden die automatische Umschaltung aktiv ist. if self.data.get.charge_state: - phases = self.data.set.phases_to_use + phases = self.data.get.phases_in_use else: if ((not charging_ev.ev_template.data.prevent_phase_switch or self.data.set.log.imported_since_plugged == 0) and @@ -577,18 +538,17 @@ def set_phases(self, phases: int, template_phases: int) -> int: if phases != self.data.get.phases_in_use: # Wenn noch kein Eintrag im Protokoll erstellt wurde, wurde noch nicht geladen und die Phase kann noch # umgeschaltet werden. - if self.data.set.log.imported_since_plugged != 0: - if charging_ev.ev_template.data.prevent_phase_switch: - log.info(f"Phasenumschaltung an Ladepunkt {self.num} nicht möglich, da bei EV" - f"{charging_ev.num} nach Ladestart nicht mehr umgeschaltet werden darf.") - if self.data.get.phases_in_use != 0: - phases = self.data.get.phases_in_use - else: - phases = self.data.control_parameter.phases - elif self.hw_supports_phase_switch() is False: - # sonst passt die Phasenzahl nicht bei Autos, die eine Phase weg schalten. - log.info(f"Phasenumschaltung an Ladepunkt {self.num} wird durch die Hardware nicht unterstützt.") - phases = phases + if self.data.set.log.imported_since_plugged != 0 and charging_ev.ev_template.data.prevent_phase_switch: + log.info(f"Phasenumschaltung an Ladepunkt {self.num} nicht möglich, da bei EV" + f"{charging_ev.num} nach Ladestart nicht mehr umgeschaltet werden darf.") + if self.data.get.phases_in_use != 0: + phases = self.data.get.phases_in_use + else: + phases = self.data.control_parameter.phases + elif self.hw_supports_phase_switch() is False: + # sonst passt die Phasenzahl nicht bei Autos, die eine Phase weg schalten. + log.info(f"Phasenumschaltung an Ladepunkt {self.num} wird durch die Hardware nicht unterstützt.") + phases = self.data.get.phases_in_use if phases != self.data.control_parameter.phases: self.data.control_parameter.phases = phases self.data.control_parameter.template_phases = template_phases @@ -706,10 +666,8 @@ def update(self, ev_list: Dict[str, Ev]) -> None: self.get_max_phase_hw(), self.hw_supports_phase_switch(), self.template.data.charging_type, - self.data.control_parameter.timestamp_chargemode_changed or create_timestamp(), self.data.set.log.imported_since_plugged, - self.hw_bidi_capable(), - self.data.get.phases_in_use) + self.hw_bidi_capable()) required_phases = self.get_phases_by_selected_chargemode(template_phases) required_phases = self.set_phases(required_phases, template_phases) self._pub_connected_vehicle(charging_ev) @@ -724,28 +682,19 @@ def update(self, ev_list: Dict[str, Ev]) -> None: self.check_phase_switch_completed() if self.chargemode_changed or self.submode_changed: - data.data.counter_all_data.get_evu_counter().reset_switch_on_off( - self, charging_ev) + data.data.counter_all_data.get_evu_counter().reset_switch_on_off(self) charging_ev.reset_phase_switch(self.data.control_parameter) if self.chargemode_changed: self.data.control_parameter.failed_phase_switches = 0 message = message_ev if message_ev else message # Ein Eintrag muss nur erstellt werden, wenn vorher schon geladen wurde und auch danach noch # geladen werden soll. - if self.chargemode_changed and self.data.set.log.imported_since_mode_switch != 0 and state: + if self.chargemode_changed and state: chargelog.save_interim_data(self, charging_ev) # Wenn die Nachrichten gesendet wurden, EV wieder löschen, wenn das EV im Algorithmus nicht # berücksichtigt werden soll. if not state: - if self.data.set.charging_ev != -1: - # Altes EV merken - self.data.set.charging_ev_prev = self.data.set.charging_ev - Pub().pub("openWB/set/chargepoint/"+str(self.num) + - "/set/charging_ev_prev", self.data.set.charging_ev_prev) - self.data.set.charging_ev = -1 - Pub().pub("openWB/set/chargepoint/" + - str(self.num)+"/set/charging_ev", -1) log.debug(f'LP {self.num}, EV: {self.data.set.charging_ev_data.data.name}' f' (EV-Nr.{vehicle}): Lademodus ' f'{self.data.set.charge_template.data.chargemode.selected}, Submodus: ' @@ -830,18 +779,16 @@ def _get_charging_ev(self, vehicle: int, ev_list: Dict[str, Ev]) -> Ev: " verwendet.") charging_ev = ev_list["ev0"] vehicle = 0 - if self.data.set.charging_ev_prev != vehicle: - Pub().pub(f"openWB/set/vehicle/{charging_ev.num}/get/force_soc_update", True) - log.debug("SoC nach EV-Wechsel") # wenn vorher kein anderes Fahrzeug zugeordnet war, Ladeprofil nicht zurücksetzen - if ((self.data.set.charging_ev_prev != vehicle and self.data.set.charging_ev_prev != -1) or + if (self.data.config.ev != vehicle or (self.data.set.charge_template.data.id != charging_ev.charge_template.data.id)): self.update_charge_template(charging_ev.charge_template) + if self.data.config.ev != vehicle: + Pub().pub(f"openWB/set/vehicle/{charging_ev.num}/get/force_soc_update", True) + log.debug("SoC nach EV-Wechsel") + self.data.config.ev = vehicle + Pub().pub(f"openWB/set/chargepoint/{self.num}/config", dataclasses.asdict(self.data.config)) self.data.set.charging_ev_data = charging_ev - self.data.set.charging_ev = vehicle - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/charging_ev", vehicle) - self.data.set.charging_ev_prev = vehicle - Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/charging_ev_prev", vehicle) return charging_ev def update_charge_template(self, charge_template: ChargeTemplate) -> None: @@ -912,9 +859,9 @@ def hw_supports_phase_switch(self) -> bool: self.data.set.log.imported_since_plugged == 0)) def failed_phase_switches_reached(self) -> bool: - if ((data.data.general_data.data.chargemode_config.retry_failed_phase_switches and + if ((data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches and self.data.control_parameter.failed_phase_switches > self.MAX_FAILED_PHASE_SWITCHES) or - (data.data.general_data.data.chargemode_config.retry_failed_phase_switches is False and + (data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches is False and self.data.control_parameter.failed_phase_switches == 1)): self.set_state_and_log( "Keine Phasenumschaltung, da die maximale Anzahl an Fehlversuchen erreicht wurde. ") diff --git a/packages/control/chargepoint/chargepoint_all.py b/packages/control/chargepoint/chargepoint_all.py index 219227b039..02de7cd17d 100644 --- a/packages/control/chargepoint/chargepoint_all.py +++ b/packages/control/chargepoint/chargepoint_all.py @@ -47,13 +47,12 @@ def no_charge(self): control_parameter = chargepoint.data.control_parameter if (not chargepoint.data.get.plug_state or # Kein EV, das Laden soll - chargepoint.data.set.charging_ev == -1 or # Kein EV, das auf das Ablaufen der Einschalt- oder Phasenumschaltverzögerung wartet - (chargepoint.data.set.charging_ev != -1 and - control_parameter.state != ChargepointState.PERFORMING_PHASE_SWITCH and + (control_parameter.state != ChargepointState.PERFORMING_PHASE_SWITCH and control_parameter.state != ChargepointState.PHASE_SWITCH_DELAY and control_parameter.state != ChargepointState.SWITCH_OFF_DELAY and - control_parameter.state != ChargepointState.SWITCH_ON_DELAY)): + control_parameter.state != ChargepointState.SWITCH_ON_DELAY and + control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED)): continue else: break diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py index 4d433a8205..a9095f4087 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -64,17 +64,28 @@ class ConnectedVehicle: soc: ConnectedSoc = field(default_factory=connected_soc_factory) +def empty_enery_source_dict_factory(): + return {'bat': 0, 'cp': 0, 'grid': 0, 'pv': 0} + + @dataclass class Log: chargemode_log_entry: str = "_" + charged_energy_by_source: Dict[str, float] = field(default_factory=empty_enery_source_dict_factory) costs: float = 0 + end: Optional[float] = None + exported_at_mode_switch: float = 0 + exported_at_plugtime: float = 0 + exported_since_mode_switch: float = 0 + exported_since_plugged: float = 0 imported_at_mode_switch: float = 0 imported_at_plugtime: float = 0 imported_since_mode_switch: float = 0 imported_since_plugged: float = 0 range_charged: float = 0 - time_charged: str = "00:00" + time_charged: float = 0 timestamp_start_charging: Optional[float] = None + timestamp_mode_switch: Optional[float] = None ev: int = -1 prio: bool = False rfid: Optional[str] = None @@ -141,8 +152,6 @@ def log_factory() -> Log: @dataclass class Set: - charging_ev: int = -1 - charging_ev_prev: int = -1 charge_template: ChargeTemplate = field(default_factory=charge_template_factory) current: float = 0 energy_to_charge: float = 0 diff --git a/packages/control/chargepoint/chargepoint_template.py b/packages/control/chargepoint/chargepoint_template.py index f028416714..741bd21eb6 100644 --- a/packages/control/chargepoint/chargepoint_template.py +++ b/packages/control/chargepoint/chargepoint_template.py @@ -87,7 +87,6 @@ def get_ev(self, rfid: str, vehicle_id: str, assigned_ev: int) -> int: message: str Status-Text """ - num = -1 message = None try: if data.data.optional_data.data.rfid.active and (rfid is not None or vehicle_id is not None): @@ -103,4 +102,4 @@ def get_ev(self, rfid: str, vehicle_id: str, assigned_ev: int) -> int: except Exception: log.exception( "Fehler in der Ladepunkt-Profil Klasse") - return num, "Keine Ladung, da ein interner Fehler aufgetreten ist: " + traceback.format_exc() + return assigned_ev, "Keine Ladung, da ein interner Fehler aufgetreten ist: " + traceback.format_exc() diff --git a/packages/control/chargepoint/chargepoint_test.py b/packages/control/chargepoint/chargepoint_test.py index 4bdac31c1d..d3de25e8a3 100644 --- a/packages/control/chargepoint/chargepoint_test.py +++ b/packages/control/chargepoint/chargepoint_test.py @@ -135,7 +135,9 @@ def test_is_phase_switch_required(params: Params): cp.data.get.charge_state = params.charge_state cp.data.control_parameter.failed_phase_switches = params.failed_phase_switches data.data_init(Mock()) - data.data.general_data.data.chargemode_config.retry_failed_phase_switches = params.retry_failed_phase_switches + data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = ( + params.retry_failed_phase_switches + ) # evaluation ret = cp._is_phase_switch_required() diff --git a/packages/control/chargepoint/get_phases_test.py b/packages/control/chargepoint/get_phases_test.py index 41d32943e2..fe0facecc3 100644 --- a/packages/control/chargepoint/get_phases_test.py +++ b/packages/control/chargepoint/get_phases_test.py @@ -152,7 +152,7 @@ def __init__(self, SetPhasesParams(name="Switch phases", phases=1, phases_in_use=3, prevent_phase_switch=False, imported_since_plugged=1, phase_switch_supported=True, expected_phases=1), SetPhasesParams(name="Phase switch not supported by cp", phases=1, phases_in_use=3, prevent_phase_switch=False, - imported_since_plugged=1, phase_switch_supported=False, expected_phases=1) + imported_since_plugged=1, phase_switch_supported=False, expected_phases=3) ] diff --git a/packages/control/chargepoint/rfid.py b/packages/control/chargepoint/rfid.py index 3a00314f0c..86bdc785d0 100644 --- a/packages/control/chargepoint/rfid.py +++ b/packages/control/chargepoint/rfid.py @@ -1,4 +1,5 @@ import logging +from dataclasses import asdict from typing import Optional from control import data @@ -65,6 +66,9 @@ def _validate_rfid(self) -> None: if self.template.data.disable_after_unplug: self.data.set.manual_lock = True Pub().pub("openWB/set/chargepoint/"+str(self.num)+"/set/manual_lock", True) + if self.data.set.charging_ev_data.charge_template.data.load_default: + self.data.config.ev = 0 + Pub().pub(f"openWB/set/chargepoint/{self.num}/config", asdict(self.data.config)) Pub().pub(f"openWB/set/chargepoint/{self.num}/get/rfid_timestamp", None) msg = ("Es ist in den letzten 5 Minuten kein EV angesteckt worden, dem " f"der ID-Tag {rfid} zugeordnet werden kann. Daher wird dieser verworfen.") @@ -76,6 +80,8 @@ def _validate_rfid(self) -> None: msg = "Identifikation von Fahrzeugen ist nicht aktiviert." self.data.get.rfid = None Pub().pub(f"openWB/set/chargepoint/{self.num}/get/rfid", None) + self.data.get.rfid_timestamp = None + Pub().pub(f"openWB/set/chargepoint/{self.num}/get/rfid_timestamp", None) self.chargepoint_module.clear_rfid() self.set_state_and_log(msg) diff --git a/packages/control/counter.py b/packages/control/counter.py index b00b0f79a1..146db1d345 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -11,13 +11,12 @@ from control.algorithm.filter_chargepoints import get_chargepoints_by_chargemodes from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode -from control.ev.ev import Ev from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from dataclass_utils.factories import currents_list_factory, voltages_list_factory from helpermodules import timecheck from helpermodules.constants import NO_ERROR -from helpermodules.phase_mapping import convert_cp_currents_to_evu_currents +from helpermodules.phase_handling import convert_cp_currents_to_evu_currents from modules.common.fault_state import FaultStateLevel from modules.common.utils.component_parser import get_component_name_by_id @@ -194,17 +193,19 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: else: self.data.set.raw_power_left = None - def update_values_left(self, diffs, cp_voltages: List[float]) -> None: + def update_values_left(self, diffs, cp_voltage: float) -> None: + # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren + # (Probleme bei einphasig angeschlossenen Wallboxen) self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_currents_left, diffs)) if self.data.set.raw_power_left: - self.data.set.raw_power_left -= sum([c * v for c, v in zip(diffs, cp_voltages)]) + self.data.set.raw_power_left -= sum([c * cp_voltage for c in diffs]) log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' f'{self.data.set.raw_power_left}W verbleibende Leistung') - def update_surplus_values_left(self, diffs, cp_voltages: List[float]) -> None: + def update_surplus_values_left(self, diffs, cp_voltage: float) -> None: self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_currents_left, diffs)) if self.data.set.surplus_power_left: - self.data.set.surplus_power_left -= sum([c * v for c, v in zip(diffs, cp_voltages)]) + self.data.set.surplus_power_left -= sum([c * cp_voltage for c in diffs]) log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' f'{self.data.set.surplus_power_left}W verbleibender Überschuss') @@ -253,7 +254,7 @@ def _control_range_offset(self): def get_usable_surplus(self, feed_in_yield: float) -> float: # verbleibender EVU-Überschuss unter Berücksichtigung der Einspeisegrenze und Speicherleistung - return (-self.calc_surplus() - self.data.set.released_surplus + + return (-self.calc_surplus() + self.data.set.released_surplus - self.data.set.reserved_surplus - feed_in_yield) SWITCH_ON_FALLEN_BELOW = "Einschaltschwelle während der Wartezeit unterschritten." @@ -386,25 +387,24 @@ def switch_off_check_timer(self, chargepoint: Chargepoint) -> None: except Exception: log.exception("Fehler im allgemeinen PV-Modul") - def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> Tuple[float, float]: + def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> float: pv_config = data.data.general_data.data.chargemode_config.pv_charging control_parameter = chargepoint.data.control_parameter if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: # Der EVU-Überschuss muss ggf um die Einspeisegrenze bereinigt werden. # Wnn die Leistung nicht Einspeisegrenze + Einschaltschwelle erreicht, darf die Ladung nicht pulsieren. # Abschaltschwelle um Einschaltschwelle reduzieren. - feed_in_yield = (-data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield - + pv_config.switch_on_threshold*control_parameter.phases) + threshold = (-data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield + + pv_config.switch_on_threshold*control_parameter.phases) else: - feed_in_yield = 0 - threshold = pv_config.switch_off_threshold + feed_in_yield - return threshold, feed_in_yield + threshold = pv_config.switch_off_threshold + return threshold def calc_switch_off(self, chargepoint: Chargepoint) -> Tuple[float, float]: switch_off_power = self.calc_surplus() - self.data.set.released_surplus - threshold, feed_in_yield = self.calc_switch_off_threshold(chargepoint) + threshold = self.calc_switch_off_threshold(chargepoint) log.debug(f'LP{chargepoint.num} Switch-Off-Threshold prüfen: {switch_off_power}W, Schwelle: {threshold}W, ' - f'freigegebener Überschuss {self.data.set.released_surplus}W, Einspeisegrenze {feed_in_yield}W') + f'freigegebener Überschuss {self.data.set.released_surplus}W') return switch_off_power, threshold def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: @@ -474,22 +474,14 @@ def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: chargepoint.set_state_and_log(msg) return charge - def reset_switch_on_off(self, chargepoint: Chargepoint, charging_ev: Ev): - """ Zeitstempel und reservierte Leistung löschen - - Parameter - --------- - chargepoint: dict - Ladepunkt, für den die Werte zurückgesetzt werden sollen - charging_ev: dict - EV, das dem Ladepunkt zugeordnet ist - """ + def reset_switch_on_off(self, chargepoint: Chargepoint): try: if chargepoint.data.control_parameter.timestamp_switch_on_off is not None: chargepoint.data.control_parameter.timestamp_switch_on_off = None evu_counter = data.data.counter_all_data.get_evu_counter() - # Wenn bereits geladen wird, freigegebene Leistung freigeben. Wenn nicht geladen wird, reservierte - # Leistung freigeben. + # Wenn bereits geladen wird, lief die Abschaltverzögerung -> Leistung, die nach Abschalten frei + # geworden wäre, nicht mehr zum zur Verfügung stehenden Überschuss zählen. + # Wenn nicht geladen wird, reservierte Leistung freigeben. pv_config = data.data.general_data.data.chargemode_config.pv_charging if not chargepoint.data.get.charge_state: evu_counter.data.set.reserved_surplus -= (pv_config.switch_on_threshold diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 66c7b94661..11c66be9d5 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -237,7 +237,7 @@ def pv_charging(self, phases = pv_charging.phases_to_use min_pv_current = (pv_charging.min_current if charging_type == ChargingType.AC.value else pv_charging.dc_min_current) - if pv_charging.limit.selected == "soc" and soc and soc > pv_charging.limit.soc: + if pv_charging.limit.selected == "soc" and soc and soc >= pv_charging.limit.soc: current = 0 sub_mode = "stop" message = self.SOC_REACHED @@ -292,8 +292,8 @@ def eco_charging(self, current = 0 sub_mode = "stop" message = self.AMOUNT_REACHED - elif data.data.optional_data.et_provider_available(): - if data.data.optional_data.et_charging_allowed(eco_charging.max_price): + elif data.data.optional_data.data.electricity_pricing.configured: + if data.data.optional_data.ep_is_charging_allowed_price_threshold(eco_charging.max_price): sub_mode = "instant_charging" message = self.CHARGING_PRICE_LOW phases = max_phases_hw @@ -309,6 +309,8 @@ def eco_charging(self, log.exception("Fehler im ev-Modul "+str(self.data.id)) return 0, "stop", "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 0 + BUFFER = -1200 # nach mehr als 20 Min Überschreitung wird der Termin als verpasst angesehen + def _find_recent_plan(self, plans: List[ScheduledChargingPlan], soc: float, @@ -317,7 +319,6 @@ def _find_recent_plan(self, max_hw_phases: int, phase_switch_supported: bool, charging_type: str, - chargemode_switch_timestamp: float, control_parameter: ControlParameter, soc_request_interval_offset: int, hw_bidi: bool): @@ -329,7 +330,7 @@ def _find_recent_plan(self, f"oder im Plan {p.name} als Begrenzung Energie einstellen.") try: plans_diff_end_date.append( - {p.id: timecheck.check_end_time(p, chargemode_switch_timestamp)}) + {p.id: timecheck.check_end_time(p, self.BUFFER)}) log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) @@ -338,33 +339,28 @@ def _find_recent_plan(self, filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] if filtered_plans: sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) - if len(sorted_plans) == 1: - plan_dict = sorted_plans[0] - elif (len(sorted_plans) > 1 and - list(sorted_plans[0].values())[0] < 0 and - list(sorted_plans[1].values())[0] < 43200): - # wenn der erste Plan in der Liste in der Vergangenheit liegt, dann den zweiten nehmen, wenn dessen - # Zielzeit weniger als 12 h entfernt ist. - plan_dict = sorted_plans[1] + for plan in sorted_plans: + if self.BUFFER < list(plan.values())[0]: + plan_dict = plan + break else: - plan_dict = sorted_plans[0] - if plan_dict: - plan_id = list(plan_dict.keys())[0] - plan_end_time = list(plan_dict.values())[0] - - for p in plans: - if p.id == plan_id: - plan = p - - remaining_time, missing_amount, phases, duration = self._calc_remaining_time( - plan, plan_end_time, soc, ev_template, used_amount, max_hw_phases, phase_switch_supported, - charging_type, control_parameter.phases, soc_request_interval_offset, hw_bidi) - - return SelectedPlan(remaining_time=remaining_time, - duration=duration, - missing_amount=missing_amount, - phases=phases, - plan=plan) + return None + plan_id = list(plan_dict.keys())[0] + plan_end_time = list(plan_dict.values())[0] + + for p in plans: + if p.id == plan_id: + plan = p + + remaining_time, missing_amount, phases, duration = self._calc_remaining_time( + plan, plan_end_time, soc, ev_template, used_amount, max_hw_phases, phase_switch_supported, + charging_type, control_parameter.phases, soc_request_interval_offset, hw_bidi) + + return SelectedPlan(remaining_time=remaining_time, + duration=duration, + missing_amount=missing_amount, + phases=phases, + plan=plan) else: return None @@ -375,7 +371,6 @@ def scheduled_charging(self, max_hw_phases: int, phase_switch_supported: bool, charging_type: str, - chargemode_switch_timestamp: float, control_parameter: ControlParameter, soc_request_interval_offset: int, bidi_state: BidiState) -> Optional[SelectedPlan]: @@ -390,7 +385,6 @@ def scheduled_charging(self, max_hw_phases, phase_switch_supported, charging_type, - chargemode_switch_timestamp, control_parameter, soc_request_interval_offset, bidi_state) @@ -421,7 +415,7 @@ def _calc_remaining_time(self, control_parameter_phases: int, soc_request_interval_offset: int, bidi_state: BidiState) -> SelectedPlan: - bidi = BidiState.BIDI_CAPABLE and plan.bidi_charging_enabled + bidi = bidi_state == BidiState.BIDI_CAPABLE and plan.bidi_charging_enabled if bidi: duration, missing_amount = self._calculate_duration( plan, soc, ev_template.data.battery_capacity, @@ -498,28 +492,34 @@ def _calculate_duration(self, duration = missing_amount/(current * phases*230) * 3600 return duration, missing_amount - SCHEDULED_REACHED_LIMIT_SOC = ("Kein Zielladen, da noch Zeit bis zum Zieltermin ist. " - "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + - "erreicht wurde. ") - SCHEDULED_CHARGING_REACHED_LIMIT_SOC = ("Kein Zielladen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" - " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") - SCHEDULED_CHARGING_REACHED_AMOUNT = "Kein Zielladen, da die Energiemenge bereits erreicht wurde. " + SCHEDULED_REACHED_MAX_SOC = ("Zielladen ausstehend, da noch Zeit bis zum Zieltermin ist. " + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + + "erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( + "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" + " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " "für Zielladen bereits erreicht wurde. ") SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " "Einspeisung erfolgt. ") - SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Keine Ladung, da keine Ziel-Termine konfiguriert sind." + SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " - SCHEDULED_CHARGING_USE_PV = "Laden startet {}. Falls vorhanden, wird mit Überschuss geladen. " + SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' 'zusätzlich EVU-Überschuss geladen. ') SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" - SCHEDULED_CHARGING_EXPENSIVE_HOUR = ("Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} " + + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") def scheduled_charging_calc_current(self, selected_plan: Optional[SelectedPlan], @@ -550,9 +550,12 @@ def scheduled_charging_calc_current(self, if plan.limit.selected != "soc": soc_request_interval_offset = 0 log.debug("Verwendeter Plan: "+str(plan.name)) - if limit.selected == "soc" and soc >= limit.soc_limit and soc >= limit.soc_scheduled: - message = self.SCHEDULED_CHARGING_REACHED_LIMIT_SOC - elif limit.selected == "soc" and limit.soc_scheduled <= soc < limit.soc_limit: + if (limit.selected == "soc" and + (soc > limit.soc_limit if (plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE) + else soc >= limit.soc_limit) and + soc >= limit.soc_scheduled): + message = self.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC + elif limit.selected == "soc" and limit.soc_scheduled <= soc <= limit.soc_limit: if plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE: message = self.SCHEDULED_CHARGING_BIDI current = min_current @@ -593,29 +596,66 @@ def scheduled_charging_calc_current(self, # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden # ist. if plan.et_active: - hour_list = data.data.optional_data.et_get_loading_hours( - selected_plan.duration, selected_plan.remaining_time) - hours_message = ("Geladen wird zu folgenden Uhrzeiten: " + - ", ".join([datetime.datetime.fromtimestamp(hour).strftime('%-H:%M') - for hour in sorted(hour_list)]) - + ".") + def get_hours_message() -> str: + def end_of_today_timestamp() -> int: + return datetime.datetime.now().replace( + hour=23, minute=59, second=59, microsecond=999000).timestamp() + + def is_loading_hour(hour: int) -> bool: + return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) + + def convert_loading_hours_to_string(hour_list: List[int]) -> str: + if 1 < len(hour_list): + times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) + return times_string + " und " + hour_list[-1].strftime('%-H:%M') + else: + return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) + midnight = end_of_today_timestamp() + loading_times_today = [datetime.datetime.fromtimestamp(hour) + for hour in sorted(hour_list) if hour <= midnight] + loading_times_today = (loading_times_today[1:] + if is_loading_hour(hour_list) else loading_times_today) + loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) + for hour in sorted(hour_list) if hour > midnight] + + parts = [] + + if is_loading_hour(hour_list): + parts.append("jetzt") + + if 0 < len(loading_times_today): + if parts: + parts.append(" und ") + parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") + + if 0 < len(loading_times_tomorrow): + if parts: + parts.append(" sowie ") + parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") + + loading_message = "Geladen wird " + "".join(parts) + return loading_message + '.' + + hour_list = data.data.optional_data.ep_get_loading_hours( + selected_plan.duration, selected_plan.duration + selected_plan.remaining_time) + log.debug(f"Günstige Ladezeiten: {hour_list}") - if timecheck.is_list_valid(hour_list): - message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(hours_message) + if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): + message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) current = plan_current submode = "instant_charging" elif ((limit.selected == "soc" and soc <= limit.soc_limit) or (limit.selected == "amount" and used_amount < limit.amount)): - message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(hours_message) + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) current = min_current submode = "pv_charging" phases = plan.phases_to_use_pv else: - message = self.SCHEDULED_REACHED_LIMIT_SOC + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format(get_hours_message()) else: # Wenn SoC-Limit erreicht wurde, soll nicht mehr mit Überschuss geladen werden if limit.selected == "soc" and soc >= limit.soc_limit: - message = self.SCHEDULED_REACHED_LIMIT_SOC + message = self.SCHEDULED_REACHED_MAX_SOC else: now = datetime.datetime.today() start_time = now + datetime.timedelta(seconds=selected_plan.remaining_time) @@ -634,7 +674,7 @@ def stop(self) -> Tuple[int, str, str]: return 0, "stop", "Keine Ladung, da der Lademodus Stop aktiv ist." def bidi_charging_allowed(self, selected_plan: int, soc: float): - # Wenn zu über den Limit-SoC geladen wurde, darf nur noch bidirektional entladen werden. + # Wenn über den Limit-SoC geladen wurde, darf nur noch bidirektional entladen werden. for plan in self.data.chargemode.scheduled_charging.plans: if plan.id == selected_plan: - return soc <= plan.limit.soc_limit + return soc < plan.limit.soc_limit diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 672915e33d..7efe22f3dc 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -14,7 +14,7 @@ from control.general import General from control.text import BidiState from helpermodules import timecheck -from helpermodules.abstract_plans import Limit, ScheduledChargingPlan, TimeChargingPlan +from helpermodules.abstract_plans import Limit, ScheduledChargingPlan, TimeChargingPlan, ScheduledLimit @pytest.fixture(autouse=True) @@ -88,7 +88,6 @@ def test_time_charging(plans: Dict[int, TimeChargingPlan], soc: float, used_amou def test_instant_charging(selected: str, current_soc: float, used_amount: float, expected: Tuple[int, str, Optional[str]]): # setup - data.data.optional_data.data.et.active = False ct = ChargeTemplate() ct.data.chargemode.instant_charging.limit.selected = selected ct.data.chargemode.instant_charging.limit.amount = 1000 @@ -196,8 +195,7 @@ def test_calculate_duration(selected: str, "end_time_mock, expected_plan_num", [ pytest.param([1000, 1500, 2000], 0, id="nächster Zieltermin Plan 0"), - pytest.param([-100, 1000, 2000], 1, id="Plan 0 abgelaufen, Plan 1 innerhalb der nächsten 12h"), - pytest.param([-100, 45000, 50000], 0, id="Plan 0 abgelaufen, Plan 1 nicht innerhalb der nächsten 12h"), + pytest.param([-100, 45000, 50000], 0, id="Plan 0 abgelaufen, nächster Tag"), pytest.param([1500, 2000, 1000], 2, id="nächster Zieltermin Plan 2"), pytest.param([None]*3, 0, id="kein Plan"), ]) @@ -218,7 +216,7 @@ def test_scheduled_charging_recent_plan(end_time_mock, # execution selected_plan = ct._find_recent_plan( - plans, 60, EvTemplate(), 200, 3, True, ChargingType.AC.value, 1652688000, control_parameter, 0, False) + plans, 60, EvTemplate(), 200, 3, True, ChargingType.AC.value, control_parameter, 0, False) # evaluation if selected_plan: @@ -233,7 +231,7 @@ def test_scheduled_charging_recent_plan(end_time_mock, pytest.param(None, 0, 0, "none", False, (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_DATE_PENDING, 3), id="no date pending"), pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, "stop", - ChargeTemplate.SCHEDULED_CHARGING_REACHED_LIMIT_SOC, 1), id="reached limit soc"), + ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC, 1), id="reached limit soc"), pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC, 0), id="reached scheduled soc"), pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, "bidi_charging", @@ -296,32 +294,96 @@ def test_scheduled_charging_calc_current_no_plans(): assert ret == (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) +LOADING_HOURS_TODAY = [datetime.datetime( + year=2022, month=5, day=16, hour=8, minute=0).timestamp()] + +LOADING_HOURS_TOMORROW = [datetime.datetime( + year=2022, month=5, day=17, hour=8, minute=0).timestamp()] + + @pytest.mark.parametrize( - "loading_hour, expected", + "is_loading_hour, current_soc, soc_scheduled, sco_limit, loading_hours, expected", [ - pytest.param(True, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( - "Geladen wird zu folgenden Uhrzeiten: 8:00."), 3)), - pytest.param(False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( - "Geladen wird zu folgenden Uhrzeiten: 8:00."), 0)), + pytest.param(True, 79, 80, 90, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, + ( + 14, + "instant_charging", + ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( + "Geladen wird jetzt sowie morgen 8:00."), + 3), + id="cheap_hour_charge_with_instant_charging"), + pytest.param(True, 79, 80, 70, LOADING_HOURS_TODAY, + ( + 14, + "instant_charging", + ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( + "Geladen wird jetzt."), + 3), + id="SOC limit reached but scheduled SOC not, no further loading hours"), + pytest.param(False, 79, 80, 90, LOADING_HOURS_TODAY, + ( + 6, + "pv_charging", + ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( + "Geladen wird heute 8:00."), + 0), + id="expensive_hour_charge_with_pv"), + pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY, + ( + 0, + "stop", + ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( + "Geladen wird heute 8:00."), + 3), + id="expensive_hour_no_charge_with_pv "), + pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, + ( + 0, + "stop", + ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( + "Geladen wird heute 8:00 sowie morgen 8:00."), + 3), + id="expensive_hour_no_charge_with_pv scheduled for tomorrow"), + pytest.param(False, 79, 60, 80, LOADING_HOURS_TODAY, + ( + 6, + "pv_charging", + ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC.format( + ""), + 0), + id="expensive_hour_pv_charging"), + pytest.param(False, 79, 60, 50, LOADING_HOURS_TODAY, + ( + 0, + "stop", + ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC.format( + ""), + 3), + id="scheduled and limit SOC reached"), ]) -def test_scheduled_charging_calc_current_electricity_tariff(loading_hour, expected, monkeypatch): +def test_scheduled_charging_calc_current_electricity_tariff( + is_loading_hour, current_soc, soc_scheduled, sco_limit, loading_hours, expected, monkeypatch): # setup + datetime_mock = Mock(wraps=datetime.datetime) + datetime_mock.now.return_value = datetime.datetime.fromtimestamp(LOADING_HOURS_TODAY[0]) + monkeypatch.setattr(datetime, "datetime", datetime_mock) + ct = ChargeTemplate() - plan = ScheduledChargingPlan(active=True) + plan = ScheduledChargingPlan(active=True, + limit=ScheduledLimit(selected="soc", soc_scheduled=soc_scheduled, soc_limit=sco_limit)) plan.et_active = True plan.limit.selected = "soc" ct.data.chargemode.scheduled_charging.plans = [plan] # für Github-Test keinen Zeitstempel verwenden - mock_et_get_loading_hours = Mock(return_value=[datetime.datetime( - year=2022, month=5, day=16, hour=8, minute=0).timestamp()]) - monkeypatch.setattr(data.data.optional_data, "et_get_loading_hours", mock_et_get_loading_hours) - mock_is_list_valid = Mock(return_value=loading_hour) - monkeypatch.setattr(timecheck, "is_list_valid", mock_is_list_valid) + mock_ep_get_loading_hours = Mock(return_value=loading_hours) + monkeypatch.setattr(data.data.optional_data, "ep_get_loading_hours", mock_ep_get_loading_hours) + mock_is_list_valid = Mock(return_value=is_loading_hour) + monkeypatch.setattr(data.data.optional_data, "ep_is_charging_allowed_hours_list", mock_is_list_valid) # execution - ret = ct.scheduled_charging_calc_current(SelectedPlan( - plan=plan, remaining_time=301, phases=3, duration=3600), - 79, 0, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) + ret = ct.scheduled_charging_calc_current( + SelectedPlan(plan=plan, remaining_time=301, phases=3, duration=3600), + current_soc, 0, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE) # evaluation assert ret == expected diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 6736002064..ae2d1aa02b 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -120,10 +120,8 @@ def get_required_current(self, max_phases_hw: int, phase_switch_supported: bool, charging_type: str, - chargemode_switch_timestamp: float, imported_since_plugged: float, - bidi: BidiState, - phases_in_use: int) -> Tuple[bool, Optional[str], str, float, int]: + bidi: BidiState) -> Tuple[bool, Optional[str], str, float, int]: """ ermittelt, ob und mit welchem Strom das EV geladen werden soll (unabhängig vom Lastmanagement) Parameter @@ -166,7 +164,6 @@ def get_required_current(self, max_phases_hw, phase_switch_supported, charging_type, - chargemode_switch_timestamp, control_parameter, soc_request_interval_offset, bidi) @@ -257,15 +254,16 @@ def check_min_max_current(self, def _check_phase_switch_conditions(self, charge_template: ChargeTemplate, control_parameter: ControlParameter, + evse_current: float, get_currents: List[float], get_power: float, max_current_cp: int, limit: LoadmanagementLimit) -> Tuple[bool, Optional[str]]: # Manche EV laden mit 6.1A bei 6A Soll-Strom - min_current = (max(control_parameter.min_current, control_parameter.required_current) + - self.ev_template.data.nominal_difference) - max_current = (min(self.ev_template.data.max_current_single_phase, max_current_cp) - - self.ev_template.data.nominal_difference) + min_current = max(control_parameter.min_current, control_parameter.required_current) + min_current_range = min_current + self.ev_template.data.nominal_difference + max_current = min(self.ev_template.data.max_current_single_phase, max_current_cp) + max_current_range = max_current - self.ev_template.data.nominal_difference phases_in_use = control_parameter.phases pv_config = data.data.general_data.data.chargemode_config.pv_charging max_phases_ev = self.ev_template.data.max_phases @@ -276,20 +274,25 @@ def _check_phase_switch_conditions(self, all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus(feed_in_yield) required_surplus = control_parameter.min_current * max_phases_ev * 230 - get_power unbalanced_load_limit_reached = limit.limiting_value == LimitingValue.UNBALANCED_LOAD - condition_1_to_3 = (((get_medium_charging_current(get_currents) > max_current and + condition_1_to_3 = (((get_medium_charging_current(get_currents) > max_current_range and all_surplus > required_surplus) or unbalanced_load_limit_reached) and phases_in_use == 1) condition_3_to_1 = get_medium_charging_current( - get_currents) < min_current and all_surplus <= 0 and phases_in_use > 1 + get_currents) < min_current_range and all_surplus <= 0 and phases_in_use > 1 if condition_1_to_3 or condition_3_to_1: return True, None else: if phases_in_use > 1 and all_surplus > 0: + # genug Leistung, um weiter mehrphasig zu laden return False, self.ENOUGH_POWER elif phases_in_use == 1 and all_surplus < required_surplus: + # nicht genug Leistung, um mehrphasig zu laden, also einphasig laden return False, self.NOT_ENOUGH_POWER - else: + elif min_current == evse_current or max_current == evse_current: + # EV lädt nicht mit dem vorgegebenen Strom +/- der erlaubten Abweichung return False, self.CURRENT_OUT_OF_NOMINAL_DIFFERENCE + else: + return False, None PHASE_SWITCH_DELAY_TEXT = '{} Phasen in {}.' @@ -297,6 +300,7 @@ def auto_phase_switch(self, charge_template: ChargeTemplate, control_parameter: ControlParameter, cp_num: int, + evse_current: float, get_currents: List[float], get_power: float, max_current_cp: int, @@ -307,13 +311,12 @@ def auto_phase_switch(self, phases_to_use = control_parameter.phases phases_in_use = control_parameter.phases pv_config = data.data.general_data.data.chargemode_config.pv_charging - cm_config = data.data.general_data.data.chargemode_config if charge_template.data.chargemode.pv_charging.feed_in_limit: feed_in_yield = pv_config.feed_in_yield else: feed_in_yield = 0 all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus(feed_in_yield) - delay = cm_config.phase_switch_delay * 60 + delay = pv_config.phase_switch_delay * 60 if phases_in_use == 1: direction_str = f"Umschaltung von 1 auf {max_phases}" required_reserved_power = (control_parameter.min_current * max_phases * 230 - @@ -337,6 +340,7 @@ def auto_phase_switch(self, if not self.ev_template.data.prevent_phase_switch: condition, condition_msg = self._check_phase_switch_conditions(charge_template, control_parameter, + evse_current, get_currents, get_power, max_current_cp, @@ -390,7 +394,6 @@ def auto_phase_switch(self, def _remaining_phase_switch_time(self, control_parameter: ControlParameter, waiting_time: float, buffer: float) -> float: - if control_parameter.timestamp_phase_switch_buffer_start is None: control_parameter.timestamp_phase_switch_buffer_start = timecheck.create_timestamp() # Wenn der Puffer seit der letzen Umschaltung abgelaufen ist, warte noch die Umschaltverzögerung ab. ODER diff --git a/packages/control/ev/ev_template.py b/packages/control/ev/ev_template.py index 0fb4e53fa1..6ce8d33ec0 100644 --- a/packages/control/ev/ev_template.py +++ b/packages/control/ev/ev_template.py @@ -9,7 +9,6 @@ class EvTemplateData: name: str = "Fahrzeug-Profil" max_current_multi_phases: int = 16 max_phases: int = 3 - phase_switch_pause: int = 2 prevent_phase_switch: bool = False prevent_charge_stop: bool = False control_pilot_interruption: bool = False diff --git a/packages/control/general.py b/packages/control/general.py index 95253b3b2f..388e83514a 100644 --- a/packages/control/general.py +++ b/packages/control/general.py @@ -34,8 +34,13 @@ class PvCharging: "topic": "chargemode_config/pv_charging/bat_power_discharge_active"}) min_bat_soc: int = field(default=50, metadata={ "topic": "chargemode_config/pv_charging/min_bat_soc"}) + max_bat_soc: int = field(default=70, metadata={ + "topic": "chargemode_config/pv_charging/max_bat_soc"}) bat_mode: BatConsiderationMode = field(default=BatConsiderationMode.EV_MODE.value, metadata={ "topic": "chargemode_config/pv_charging/bat_mode"}) + retry_failed_phase_switches: bool = field( + default=False, + metadata={"topic": "chargemode_config/pv_charging/retry_failed_phase_switches"}) switch_off_delay: int = field(default=60, metadata={ "topic": "chargemode_config/pv_charging/switch_off_delay"}) switch_off_threshold: int = field(default=0, metadata={ @@ -52,12 +57,7 @@ def pv_charging_factory() -> PvCharging: @dataclass class ChargemodeConfig: - phase_switch_delay: int = field(default=5, metadata={ - "topic": "chargemode_config/phase_switch_delay"}) pv_charging: PvCharging = field(default_factory=pv_charging_factory) - retry_failed_phase_switches: bool = field( - default=False, - metadata={"topic": "chargemode_config/retry_failed_phase_switches"}) unbalanced_load_limit: int = field( default=18, metadata={"topic": "chargemode_config/unbalanced_load_limit"}) unbalanced_load: bool = field(default=False, metadata={ diff --git a/packages/control/io_device.py b/packages/control/io_device.py index c045a80c92..c752fd3e87 100644 --- a/packages/control/io_device.py +++ b/packages/control/io_device.py @@ -4,10 +4,12 @@ from control.limiting_value import LimitingValue from helpermodules.constants import NO_ERROR from modules.common.utils.component_parser import get_io_name_by_id -from modules.io_actions.controllable_consumers.dimming.api import Dimming +from modules.io_actions.controllable_consumers.dimming.api_eebus import DimmingEebus +from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo from modules.io_actions.controllable_consumers.dimming_direct_control.api import DimmingDirectControl from modules.io_actions.controllable_consumers.ripple_control_receiver.api import RippleControlReceiver -from modules.io_actions.generator_systems.stepwise_control.api import StepwiseControl +from modules.io_actions.generator_systems.stepwise_control.api_eebus import StepwiseControlEebus +from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo @dataclass @@ -54,7 +56,8 @@ def __init__(self, num: Union[int, str]): class IoActions: def __init__(self): - self.actions: Dict[int, Union[Dimming, DimmingDirectControl, RippleControlReceiver, StepwiseControl]] = {} + self.actions: Dict[int, Union[DimmingIo, DimmingEebus, DimmingDirectControl, + RippleControlReceiver, StepwiseControlEebus, StepwiseControlIo]] = {} def setup(self): for action in self.actions.values(): @@ -66,7 +69,7 @@ def _check_fault_state_io_device(self, io_device: int) -> None: def dimming_get_import_power_left(self, device: Dict) -> Optional[float]: for action in self.actions.values(): - if isinstance(action, Dimming): + if isinstance(action, (DimmingIo, DimmingEebus)): for d in action.config.configuration.devices: if device == d: self._check_fault_state_io_device(action.config.configuration.io_device) @@ -76,7 +79,7 @@ def dimming_get_import_power_left(self, device: Dict) -> Optional[float]: def dimming_set_import_power_left(self, device: Dict, used_power: float) -> Optional[float]: for action in self.actions.values(): - if isinstance(action, Dimming): + if isinstance(action, (DimmingIo, DimmingEebus)): for d in action.config.configuration.devices: if d == device: return action.dimming_set_import_power_left(used_power) @@ -103,7 +106,7 @@ def ripple_control_receiver(self, device: Dict) -> float: def stepwise_control(self, device_id: int) -> Optional[float]: for action in self.actions.values(): - if isinstance(action, StepwiseControl): + if isinstance(action, (StepwiseControlEebus, StepwiseControlIo)): if device_id in [component["id"] for component in action.config.configuration.devices]: self._check_fault_state_io_device(action.config.configuration.io_device) return action.control_stepwise() diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index e145ea241a..a1567b32ff 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -6,6 +6,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter from control.limiting_value import LimitingValue, LoadmanagementLimit +from helpermodules.phase_handling import voltages_mean from modules.common.utils.component_parser import get_component_name_by_id @@ -35,7 +36,7 @@ def get_available_currents(self, limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, cp.data.get.voltages, counter.data.set.raw_power_left, feed_in) + counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left, feed_in) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -47,7 +48,7 @@ def get_available_currents(self, def get_available_currents_surplus(self, missing_currents: List[float], - cp_voltages: List[float], + cp_voltage: float, counter: Counter, cp: Chargepoint, feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: @@ -61,7 +62,7 @@ def get_available_currents_surplus(self, limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, cp_voltages, counter.data.set.surplus_power_left, feed_in) + counter, available_currents, cp_voltage, counter.data.set.surplus_power_left, feed_in) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -94,20 +95,22 @@ def _limit_by_unbalanced_load(self, def _limit_by_power(self, counter: Counter, available_currents: List[float], - cp_voltages: List[float], + cp_voltage: float, raw_power_left: Optional[float], feed_in: Optional[float]) -> Tuple[List[float], LoadmanagementLimit]: + # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren + # (Probleme bei einphasig angeschlossenen Wallboxen) currents = available_currents.copy() limit = LoadmanagementLimit(None, None) if raw_power_left: if feed_in: raw_power_left = raw_power_left - feed_in log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {raw_power_left}W") - if sum([c * v for c, v in zip(available_currents, cp_voltages)]) > raw_power_left: + if sum([c * cp_voltage for c in available_currents]) > raw_power_left: for i in range(0, 3): try: # Am meisten belastete Phase trägt am meisten zur Leistungsreduktion bei. - currents[i] = available_currents[i] / sum(available_currents) * raw_power_left / cp_voltages[i] + currents[i] = available_currents[i] / sum(available_currents) * raw_power_left / cp_voltage except ZeroDivisionError: # bei einphasig angeschlossenen Wallboxen ist die Spannung der anderen Phasen 0V currents[i] = 0.0 diff --git a/packages/control/loadmanagement_test.py b/packages/control/loadmanagement_test.py index 33f1acd59e..04a3d2a8dd 100644 --- a/packages/control/loadmanagement_test.py +++ b/packages/control/loadmanagement_test.py @@ -30,7 +30,7 @@ def test_limit_by_power(available_currents: List[float], counter_name_mock = Mock(return_value=COUNTER_NAME) monkeypatch.setattr(loadmanagement, "get_component_name_by_id", counter_name_mock) # evaluation - currents = Loadmanagement()._limit_by_power(Counter(0), available_currents, [230]*3, raw_power_left, None) + currents = Loadmanagement()._limit_by_power(Counter(0), available_currents, 230, raw_power_left, None) # assertion assert currents == expected_currents diff --git a/packages/control/ocpp_test.py b/packages/control/ocpp_test.py index e56eaee676..5dae1c50ff 100644 --- a/packages/control/ocpp_test.py +++ b/packages/control/ocpp_test.py @@ -37,9 +37,9 @@ def test_start_transaction(mock_data, monkeypatch): def test_stop_transaction(mock_data, monkeypatch): cp = Chargepoint(1, None) cp.data.config.ocpp_chargebox_id = "cp1" + cp.data.config.ev = 1 cp.data.get.plug_state = False cp.data.set.ocpp_transaction_id = 124 - cp.data.set.charging_ev_prev = 1 cp.chargepoint_module = ChargepointModule(Mqtt()) cp.template = CpTemplate() diff --git a/packages/control/optional.py b/packages/control/optional.py index 96c1e66c57..e3286d9603 100644 --- a/packages/control/optional.py +++ b/packages/control/optional.py @@ -3,45 +3,113 @@ import logging from math import ceil from threading import Thread -from typing import List +from typing import Dict, List, Optional as TypingOptional, Union +from datetime import datetime from control import data from control.ocpp import OcppMixin -from control.optional_data import OptionalData +from control.optional_data import TARIFF_UPDATE_HOUR, FlexibleTariff, GridFee, OptionalData, PricingGet from helpermodules import hardware_configuration from helpermodules.constants import NO_ERROR from helpermodules.pub import Pub -from helpermodules.timecheck import create_unix_timestamp_current_full_hour +from helpermodules import timecheck from helpermodules.utils import thread_handler -from modules.common.configurable_tariff import ConfigurableElectricityTariff +from modules.common.configurable_tariff import ConfigurableFlexibleTariff, ConfigurableGridFee from modules.common.configurable_monitoring import ConfigurableMonitoring log = logging.getLogger(__name__) +AS_EURO_PER_KWH = 1000.0 # Umrechnung von €/Wh in €/kWh class Optional(OcppMixin): def __init__(self): try: self.data = OptionalData() - self.et_module: ConfigurableElectricityTariff = None - self.monitoring_module: ConfigurableMonitoring = None + self._flexible_tariff_module: TypingOptional[ConfigurableFlexibleTariff] = None + self._grid_fee_module: TypingOptional[ConfigurableGridFee] = None + self.monitoring_module: TypingOptional[ConfigurableMonitoring] = None self.data.dc_charging = hardware_configuration.get_hardware_configuration_setting("dc_charging") Pub().pub("openWB/optional/dc_charging", self.data.dc_charging) except Exception: log.exception("Fehler im Optional-Modul") + @property + def flexible_tariff_module(self) -> TypingOptional[ConfigurableFlexibleTariff]: + return self._flexible_tariff_module + + @flexible_tariff_module.setter + def flexible_tariff_module(self, value: TypingOptional[ConfigurableFlexibleTariff]): + if (value is None or + (self._flexible_tariff_module and value and + self._flexible_tariff_module.config.name != value.config.name)): + self.data.electricity_pricing.flexible_tariff.get = PricingGet() + self._reset_state(self.data.electricity_pricing.flexible_tariff, "flexible_tariff") + self._flexible_tariff_module = value + self._set_ep_configured() + + @property + def grid_fee_module(self) -> TypingOptional[ConfigurableGridFee]: + return self._grid_fee_module + + @grid_fee_module.setter + def grid_fee_module(self, value: TypingOptional[ConfigurableGridFee]): + if (value is None or + (self._grid_fee_module and value and self._grid_fee_module.config.name != value.config.name)): + self.data.electricity_pricing.grid_fee.get = PricingGet() + self._reset_state(self.data.electricity_pricing.grid_fee, "grid_fee") + self._grid_fee_module = value + self._set_ep_configured() + + def _set_ep_configured(self): + if self._grid_fee_module or self._flexible_tariff_module: + self.data.electricity_pricing.configured = True + Pub().pub("openWB/set/optional/ep/configured", True) + else: + self.data.electricity_pricing.configured = False + Pub().pub("openWB/set/optional/ep/configured", False) + + def _reset_state(self, module: Union[FlexibleTariff, GridFee], module_name: str): + if (module.get.fault_state != 0 or module.get.fault_str != NO_ERROR): + module.get.fault_state = 0 + module.get.fault_str = NO_ERROR + Pub().pub(f"openWB/set/optional/ep/{module_name}/get/fault_state", 0) + Pub().pub(f"openWB/set/optional/ep/{module_name}/get/fault_str", NO_ERROR) + Pub().pub(f"openWB/set/optional/ep/{module_name}/get/prices", {}) + Pub().pub("openWB/set/optional/ep/get/prices", {}) + Pub().pub("openWB/set/optional/ep/get/next_query_time", None) + def monitoring_start(self): if self.monitoring_module is not None: self.monitoring_module.start_monitoring() def monitoring_stop(self): - if self.mon_module is not None: - self.mon_module.stop_monitoring() + if self.monitoring_module is not None: + self.monitoring_module.stop_monitoring() + + def ep_is_charging_allowed_hours_list(self, selected_hours: list[int]) -> bool: + """ prüft, ob das strompreisbasiertes Laden aktiviert und ein günstiger Zeitpunkt ist. + + Parameter + --------- + selected_hours: list[int] + Liste der ausgewählten günstigen Zeitslots (Unix-Timestamps) - def et_provider_available(self) -> bool: - return self.et_module is not None + Return + ------ + True: Der aktuelle Zeitpunkt liegt in einem ausgewählten günstigen Zeitslot + False: Der aktuelle Zeitpunkt liegt in keinem günstigen Zeitslot + """ + try: + if self.data.electricity_pricing.configured: + return self.__get_current_timeslot_start() in selected_hours + else: + log.info("Prüfe strompreisbasiertes Laden: Nicht konfiguriert") + return False + except Exception as e: + log.exception(f"Fehler im Optional-Modul: {e}") + return False - def et_charging_allowed(self, max_price: float): + def ep_is_charging_allowed_price_threshold(self, max_price: float) -> bool: """ prüft, ob der aktuelle Strompreis niedriger oder gleich der festgelegten Preisgrenze ist. Return @@ -50,73 +118,162 @@ def et_charging_allowed(self, max_price: float): False: Preis liegt darüber """ try: - if self.et_provider_available(): - if self.et_get_current_price() <= max_price: - return True - else: - return False + if self.data.electricity_pricing.configured: + current_price = self.ep_get_current_price() + log.info("Prüfe strompreisbasiertes Laden mit Preisgrenze %.5f €/kWh, aktueller Preis: %.5f €/kWh", + max_price * AS_EURO_PER_KWH, + current_price * AS_EURO_PER_KWH + ) + return current_price <= max_price else: return True - except KeyError: - log.exception("Fehler beim strompreisbasierten Laden") - self.et_get_prices() - except Exception: - log.exception("Fehler im Optional-Modul") + except KeyError as e: + log.exception("Fehler beim strompreisbasierten Laden: %s", e) return False + except Exception as e: + log.exception("Fehler im Optional-Modul: %s", e) + return False + + def __get_first_entry(self) -> tuple[str, float]: + prices = self.data.electricity_pricing.get.prices + if prices is None or len(prices) == 0: + raise Exception("Keine Preisdaten für strompreisbasiertes Laden vorhanden.") + else: + timestamp, first = next(iter(prices.items())) + return timestamp, first + + def remove_outdated_prices(self): + def remove(price_data: Dict) -> Dict: + price_timeslot_seconds = self.__calculate_price_timeslot_length(price_data) + now = timecheck.create_timestamp() + return { + price[0]: price[1] + for price in price_data.items() + if float(price[0]) > now - (price_timeslot_seconds - 1) + } + + try: + if self.data.electricity_pricing.configured: + if len(self.data.electricity_pricing.get.prices) == 0: + return + ep = self.data.electricity_pricing + ep.get.prices = remove(ep.get.prices) + Pub().pub("openWB/set/optional/ep/get/prices", ep.get.prices) + if self._flexible_tariff_module: + ep.flexible_tariff.get.prices = remove(ep.flexible_tariff.get.prices) + Pub().pub("openWB/set/optional/ep/flexible_tariff/get/prices", ep.flexible_tariff.get.prices) + if self._grid_fee_module: + ep.grid_fee.get.prices = remove(ep.grid_fee.get.prices) + Pub().pub("openWB/set/optional/ep/grid_fee/get/prices", ep.grid_fee.get.prices) + except Exception: + log.exception("Fehler beim Entfernen veralteter Preise") + + def __get_current_timeslot_start(self) -> int: + timestamp = self.__get_first_entry()[0] + return float(timestamp) - def et_get_current_price(self): - if self.et_provider_available(): - return self.data.et.get.prices[str(int(create_unix_timestamp_current_full_hour()))] + def ep_get_current_price(self) -> float: + if self.data.electricity_pricing.configured: + first = self.__get_first_entry()[1] + return first else: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") - def et_get_loading_hours(self, duration: float, remaining_time: float) -> List[int]: + def __calculate_price_timeslot_length(self, prices: dict) -> int: + first_timestamps = list(prices.keys())[:2] + return float(first_timestamps[1]) - float(first_timestamps[0]) + + def ep_get_loading_hours(self, duration: float, remaining_time: float) -> List[int]: """ Parameter --------- duration: float benötigte Ladezeit - + remaining_time: float + Restzeit bis Termin (von wo an gerechnet???) Return ------ - list: Key des Dictionary (Unix-Sekunden der günstigen Stunden) + list: Key des Dictionary (Unix-Sekunden der günstigen Zeit-Slots) """ - if self.et_provider_available() is False: + if self.data.electricity_pricing.configured is False: raise Exception("Kein Anbieter für strompreisbasiertes Laden konfiguriert.") try: - prices = self.data.et.get.prices - prices_in_scheduled_time = {} - i = 0 - for timestamp, price in prices.items(): - if i < ceil((duration+remaining_time)/3600): - prices_in_scheduled_time.update({timestamp: price}) - i += 1 - else: - break - ordered = sorted(prices_in_scheduled_time.items(), key=lambda x: x[1]) - return [int(i[0]) for i in ordered][:ceil(duration/3600)] - except Exception: - log.exception("Fehler im Optional-Modul") + prices = self.data.electricity_pricing.get.prices + price_timeslot_seconds = self.__calculate_price_timeslot_length(prices) + now = timecheck.create_timestamp() + price_candidates = { + timestamp: price + for timestamp, price in prices.items() + if ( + # is current timeslot or futur + float(timestamp) + price_timeslot_seconds > now and + # ends before plan target time + not float(timestamp) >= now + remaining_time + ) + } + log.debug("%s Preis-Kandidaten in %s Sekunden zwischen %s Uhr und %s Uhr von %s Uhr bis %s Uhr", + len(price_candidates), + duration, + datetime.fromtimestamp(now), + datetime.fromtimestamp(now + remaining_time), + datetime.fromtimestamp(float(min(price_candidates))), + datetime.fromtimestamp(float(max(price_candidates))+price_timeslot_seconds)) + ordered_by_date_reverse = reversed(sorted(price_candidates.items(), key=lambda x: x[0])) + ordered_by_price = sorted(ordered_by_date_reverse, key=lambda x: x[1]) + selected_time_slots = {float(i[0]): float(i[1]) + for i in ordered_by_price[:1 + ceil(duration/price_timeslot_seconds)]} + selected_lenght = ( + price_timeslot_seconds * (len(selected_time_slots)-1) - + (float(now) - min(selected_time_slots)) + ) + return sorted(selected_time_slots.keys() + if not (min(selected_time_slots) > now or duration <= selected_lenght) + else [timestamp[0] for timestamp in iter(selected_time_slots.items())][:-1] + ) + # if sum() sorted([int(i[0]) for i in ordered_by_price][:ceil(duration/price_timeslot_seconds)]) + except Exception as e: + log.exception("Fehler im Optional-Modul: %s", e) return [] - def et_get_prices(self): - try: - if self.et_module: - thread_handler(Thread(target=self.et_module.update, args=(), name="electricity tariff")) + def et_price_update_required(self) -> bool: + def is_tomorrow(last_timestamp: str) -> bool: + return (day_of(date=datetime.now()) < day_of(datetime.fromtimestamp(float(last_timestamp))) + or day_of(date=datetime.now()).hour < TARIFF_UPDATE_HOUR) + + def day_of(date: datetime) -> datetime: + return date.replace(hour=0, minute=0, second=0, microsecond=0) + + def get_last_entry_time_stamp() -> str: + last_known_timestamp = "0" + if self.data.electricity_pricing.get.prices is not None: + last_known_timestamp = max(self.data.electricity_pricing.get.prices) + return last_known_timestamp + self._set_ep_configured() + if self.data.electricity_pricing.configured is False: + return False + if len(self.data.electricity_pricing.get.prices) == 0: + return True + if self.data.electricity_pricing.get.next_query_time is None: + return True + if is_tomorrow(get_last_entry_time_stamp()): + if timecheck.create_timestamp() > self.data.electricity_pricing.get.next_query_time: + next_query_formatted = datetime.fromtimestamp( + self.data.electricity_pricing.get.next_query_time).strftime("%Y%m%d-%H:%M:%S") + log.info(f'Wartezeit {next_query_formatted} abgelaufen, Strompreise werden abgefragt') + return True else: - # Wenn kein Modul konfiguriert ist, Fehlerstatus zurücksetzen. - if self.data.et.get.fault_state != 0 or self.data.et.get.fault_str != NO_ERROR: - Pub().pub("openWB/set/optional/et/get/fault_state", 0) - Pub().pub("openWB/set/optional/et/get/fault_str", NO_ERROR) - except Exception: - log.exception("Fehler im Optional-Modul") + next_query_formatted = datetime.fromtimestamp( + self.data.electricity_pricing.get.next_query_time).strftime("%Y%m%d-%H:%M:%S") + log.info(f'Nächster Abruf der Strompreise {next_query_formatted}.') + return False + return False def ocpp_transfer_meter_values(self): try: if self.data.ocpp.active: thread_handler(Thread(target=self._transfer_meter_values, args=(), name="OCPP Client")) - except Exception: - log.exception("Fehler im OCPP-Optional-Modul") + except Exception as e: + log.exception("Fehler im OCPP-Optional-Modul: %s", e) def _transfer_meter_values(self): for cp in data.data.cp_data.values(): diff --git a/packages/control/optional_data.py b/packages/control/optional_data.py index 5f4b861cba..74eb0da6cd 100644 --- a/packages/control/optional_data.py +++ b/packages/control/optional_data.py @@ -1,29 +1,82 @@ from dataclasses import dataclass, field +from datetime import datetime, timedelta +import random from typing import Dict, Optional, Protocol from dataclass_utils.factories import empty_dict_factory from helpermodules.constants import NO_ERROR +from helpermodules.pub import Pub from modules.display_themes.cards.config import CardsDisplayTheme +TARIFF_UPDATE_HOUR = 14 # latest expected time for daily tariff update + @dataclass -class EtGet: +class PricingGet: fault_state: int = 0 fault_str: str = NO_ERROR prices: Dict = field(default_factory=empty_dict_factory) -def get_factory() -> EtGet: - return EtGet() +def get_factory() -> PricingGet: + return PricingGet() + + +@dataclass +class FlexibleTariff: + get: PricingGet = field(default_factory=get_factory) + + +def get_flexible_tariff_factory() -> FlexibleTariff: + return FlexibleTariff() + + +@dataclass +class GridFee: + get: PricingGet = field(default_factory=get_factory) + + +def get_grid_fee_factory() -> GridFee: + return GridFee() + + +@dataclass +class ElectricityPricingGet: + next_query_time: Optional[float] = None + _prices: Dict = field(default_factory=empty_dict_factory) + + @property + def prices(self) -> Dict: + return self._prices + + @prices.setter + def prices(self, value: Dict): + self._prices = value + if value: + next_query_time = datetime.fromtimestamp(float(max(value))).replace( + hour=TARIFF_UPDATE_HOUR, minute=0, second=0 + ) + timedelta( + # actully ET providers issue next day prices up to half an hour earlier then 14:00 + # reduce serverload on their site by trying early and randomizing query time + minutes=random.randint(1, 7) * -5 + ) + Pub().pub("openWB/set/optional/ep/get/next_query_time", next_query_time.timestamp()) + + +def electricity_pricing_get_factory() -> ElectricityPricingGet: + return ElectricityPricingGet() @dataclass -class Et: - get: EtGet = field(default_factory=get_factory) +class ElectricityPricing: + configured: bool = False + flexible_tariff: FlexibleTariff = field(default_factory=get_flexible_tariff_factory) + grid_fee: GridFee = field(default_factory=get_grid_fee_factory) + get: ElectricityPricingGet = field(default_factory=electricity_pricing_get_factory) -def et_factory() -> Et: - return Et() +def ep_factory() -> ElectricityPricing: + return ElectricityPricing() @dataclass @@ -83,7 +136,7 @@ def ocpp_factory() -> Ocpp: @dataclass class OptionalData: - et: Et = field(default_factory=et_factory) + electricity_pricing: ElectricityPricing = field(default_factory=ep_factory) int_display: InternalDisplay = field(default_factory=int_display_factory) led: Led = field(default_factory=led_factory) rfid: Rfid = field(default_factory=rfid_factory) diff --git a/packages/control/optional_test.py b/packages/control/optional_test.py index cc517ef7ae..c219868af8 100644 --- a/packages/control/optional_test.py +++ b/packages/control/optional_test.py @@ -1,31 +1,479 @@ from unittest.mock import Mock +import pytest +from helpermodules import timecheck from control.optional import Optional -def test_et_get_loading_hours(monkeypatch): +ONE_HOUR_SECONDS = 3600 +IGNORED = 0.0001 +CHEAP = 0.0002 +EXPENSIVE = 0.3000 + + +@pytest.mark.no_mock_full_hour +@pytest.mark.parametrize( + "granularity, now_ts, duration, remaining_time, price_list, expected_loading_hours", + [ + pytest.param( + "full_hour", + 1698228000, + ONE_HOUR_SECONDS, + 3 * ONE_HOUR_SECONDS, + { + "1698224400": 0.00012499, + "1698228000": 0.00011737999999999999, # matching now + "1698231600": 0.00011562000000000001, + "1698235200": 0.00012447, # last before plan target + "1698238800": 0.00013813, + "1698242400": 0.00014751, + "1698246000": 0.00015372999999999998, + "1698249600": 0.00015462, + "1698253200": 0.00015771, + "1698256800": 0.00013708, + "1698260400": 0.00012355, + "1698264000": 0.00012006, + "1698267600": 0.00011279999999999999, + }, + [1698231600], + id="select single time slot of one hour length" + ), + pytest.param( + "quarter_hour", + 1698226200, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, # current quarter hour + "1698227100": CHEAP, + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEAP, + "1698239700": CHEAP, # last before plan target + "1698240600": IGNORED, + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select 8 time slots of 15 minutes lenght, include last before plan target" + ), + pytest.param( + "quarter_hour", + 1698227100, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEAP, + "1698239700": CHEAP, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698238800, 1698239700], + id="select 8 time slots of 15 minutes lenght, include current quarter hour" + ), + pytest.param( + "quarter_hour", + 1698227900, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEAP, # current quarert hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": CHEAP, + "1698239700": CHEAP, + "1698240600": EXPENSIVE, + "1698241500": EXPENSIVE, # last before plan target + # sixth hour + "1698242400": IGNORED, + "1698243300": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, + 1698233400, 1698235200, 1698238800, 1698239700, 1698241500], + id="select additional if time elapsed in current slot makes selection too short" + ), + pytest.param( + "quarter_hour", + 1698226600, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, # current quarter hour + "1698227100": CHEAP, + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": EXPENSIVE, + # sixth hour + "1698242400": IGNORED, + "1698243300": IGNORED, + }, + [1698227100, 1698229800, 1698231600, 1698232500, 1698233400, 1698235200, 1698239700, 1698240600], + id="order in time sequence equal prices" + ), + pytest.param( + "quarter_hour", + 1698226600, + 2 * ONE_HOUR_SECONDS, + 4 * ONE_HOUR_SECONDS, + { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, # current quarter hour + "1698227100": .07, + # second hour + "1698228000": EXPENSIVE, + "1698228900": .08, + "1698229800": .05, + "1698230700": .04, + # third hour + "1698231600": .03, + "1698232500": .02, + "1698233400": .01, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": .04, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, # last before plan target + "1698240600": EXPENSIVE, + "1698241500": IGNORED, + }, + [1698227100, 1698228900, 1698229800, 1698230700, 1698231600, 1698232500, 1698233400, 1698235200], + id="order in time sequence reverse" + ), + ], +) +def test_ep_get_loading_hours(granularity, + now_ts, + duration, + remaining_time, + price_list, + expected_loading_hours, + monkeypatch): + # setup + opt = Optional() + opt.data.electricity_pricing.get.prices = price_list + opt.data.electricity_pricing.configured = True + monkeypatch.setattr( + timecheck, + "create_timestamp", + Mock(return_value=now_ts) + ) + + # execution + loading_hours = opt.ep_get_loading_hours(duration=duration, remaining_time=remaining_time) + + # evaluation + assert loading_hours == expected_loading_hours + + +@pytest.mark.parametrize( + "provider_available, current_price, max_price, expected", + [ + pytest.param(True, 0.10, 0.15, True, id="price_below_max"), + pytest.param(True, 0.15, 0.15, True, id="price_equal_max"), + pytest.param(True, 0.20, 0.15, False, id="price_above_max"), + pytest.param(False, None, 0.15, True, id="provider_not_available"), + ] +) +def test_et_charging_allowed(monkeypatch, provider_available, current_price, max_price, expected): + opt = Optional() + opt.data.electricity_pricing.configured = provider_available + if provider_available: + monkeypatch.setattr(opt, "ep_get_current_price", Mock(return_value=current_price)) + result = opt.ep_is_charging_allowed_price_threshold(max_price) + assert result == expected + + +def test_et_charging_allowed_exception(monkeypatch): + opt = Optional() + opt.data.electricity_pricing.configured = True + monkeypatch.setattr(opt, "ep_get_current_price", Mock(side_effect=Exception)) + result = opt.ep_is_charging_allowed_price_threshold(0.15) + assert result is False + + +@pytest.mark.parametrize( + "now_ts, provider_available, price_list, selected_hours , expected", + [ + pytest.param( + 1698224400, False, {}, [], + False, id="no charge if provider not available" + ), + pytest.param( + 1698224400, True, { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + False, id="no charge if provider available but before cheapest slot" + ), + pytest.param( + 1698224400, True, { + # first hour + "1698224400": IGNORED, + "1698225300": IGNORED, + "1698226200": EXPENSIVE, + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, [], + False, id="no charge if provider no charge times list" + ), + pytest.param( + 1698224400, True, { + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + False, id="no charge if current time in expensive hour" + ), + pytest.param( + 1698227100, True, { + # first hour + "1698227100": CHEAP, # current quarter hour + # second hour + "1698228000": EXPENSIVE, + "1698228900": EXPENSIVE, + "1698229800": CHEAP, + "1698230700": EXPENSIVE, + # third hour + "1698231600": CHEAP, + "1698232500": CHEAP, + "1698233400": CHEAP, + "1698234300": EXPENSIVE, + # fourth hour + "1698235200": CHEAP, + "1698236100": EXPENSIVE, + "1698237000": EXPENSIVE, + "1698237900": EXPENSIVE, + # fifth hour + "1698238800": EXPENSIVE, + "1698239700": EXPENSIVE, + "1698240600": EXPENSIVE, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + True, id="charge if provider available and matching time slot start" + ), + pytest.param( + 1698227100, True, { + # first hour + "1698227100": IGNORED, # current quarter hour + # second hour + "1698228000": IGNORED, + "1698228900": IGNORED, + "1698229800": IGNORED, + "1698230700": IGNORED, + # third hour + "1698231600": IGNORED, + "1698232500": IGNORED, + "1698233400": IGNORED, + "1698234300": IGNORED, + # fourth hour + "1698235200": IGNORED, + "1698236100": IGNORED, + "1698237000": IGNORED, + "1698237900": IGNORED, + # fifth hour + "1698238800": IGNORED, + "1698239700": IGNORED, + "1698240600": IGNORED, # last before plan target + "1698241500": IGNORED, }, + [1698227100, 1698231600, 1698232500, 1698233400, 1698235200], + True, id="charge if provider available and matching time slot start" + ), + ] +) +def test_et_charging_available(now_ts, provider_available, price_list, selected_hours, expected, monkeypatch): + monkeypatch.setattr( + timecheck, + "create_timestamp", + Mock(return_value=now_ts) + ) + opt = Optional() + opt.data.electricity_pricing.get.prices = price_list + opt.data.electricity_pricing.configured = provider_available + result = opt.ep_is_charging_allowed_hours_list(selected_hours) + assert result == expected + + +def test_et_charging_available_exception(monkeypatch): + opt = Optional() + opt.data.electricity_pricing.configured = True + + opt.data.electricity_pricing.get.prices = {} # empty prices list raises exception + result = opt.ep_is_charging_allowed_hours_list([]) + assert result is False + + +@pytest.mark.parametrize( + "prices, next_query_time, current_timestamp, expected", + [ + pytest.param( + {}, None, 1698224400, True, + id="update_required_when_no_prices" + ), + pytest.param( + {"1698224400": 0.1, "1698228000": 0.2}, 1698310800, 1698224400, False, + id="no_update_required_when_next_query_time_not_reached" + ), + pytest.param( + {"1698224400": 0.1, "1698228000": 0.2}, 1698224000, 1698310800, True, + id="update_required_when_next_query_time_passed" + ), + pytest.param( + {"1609459200": 0.1, "1609462800": 0.2}, None, 1698224400, True, + id="update_required_when_prices_from_yesterday" + ), + ] +) +def test_et_price_update_required(monkeypatch, prices, next_query_time, current_timestamp, expected): # setup opt = Optional() - opt.data.et.get.prices = PRICE_LIST - mock_et_provider_available = Mock(return_value=True) - monkeypatch.setattr(opt, "et_provider_available", mock_et_provider_available) + opt._flexible_tariff_module = Mock() + opt._grid_fee_module = Mock() + opt.data.electricity_pricing.get.prices = prices + opt.data.electricity_pricing.get.next_query_time = next_query_time + + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=current_timestamp)) + opt.data.electricity_pricing.configured = True # execution - loading_hours = opt.et_get_loading_hours(3600, 7200) + result = opt.et_price_update_required() # evaluation - assert loading_hours == [1698231600] - - -PRICE_LIST = {"1698224400": 0.00012499, - "1698228000": 0.00011737999999999999, - "1698231600": 0.00011562000000000001, - "1698235200": 0.00012447, - "1698238800": 0.00013813, - "1698242400": 0.00014751, - "1698246000": 0.00015372999999999998, - "1698249600": 0.00015462, - "1698253200": 0.00015771, - "1698256800": 0.00013708, - "1698260400": 0.00012355, - "1698264000": 0.00012006, - "1698267600": 0.00011279999999999999} + assert result == expected diff --git a/packages/control/phase_switch.py b/packages/control/phase_switch.py index ca4b3bc84b..624d7b7130 100644 --- a/packages/control/phase_switch.py +++ b/packages/control/phase_switch.py @@ -2,9 +2,7 @@ """ import logging from threading import Thread -import time -from control.ev.ev import Ev from helpermodules.utils._thread_handler import is_thread_alive, thread_handler from modules.common.abstract_chargepoint import AbstractChargepoint @@ -20,24 +18,20 @@ def thread_phase_switch(cp) -> None: return thread_handler(Thread( target=_perform_phase_switch, args=(cp.chargepoint_module, - cp.data.control_parameter.phases, - cp.data.set.charging_ev_data, - cp.data.get.charge_state), + cp.data.control_parameter.phases), name=f"phase switch cp{cp.chargepoint_module.config.id}")) except Exception: log.exception("Fehler im Phasenumschaltungs-Modul") -def _perform_phase_switch(chargepoint_module: AbstractChargepoint, phases: int, ev: Ev, charge_state: bool) -> None: +def _perform_phase_switch(chargepoint_module: AbstractChargepoint, phases: int) -> None: """ ruft das Modul zur Phasenumschaltung für das jeweilige Modul auf. """ # Stoppen der Ladung wird in start_charging bei gesetztem phase_switch_timestamp durchgeführt. # Wenn gerade geladen wird, muss vor der Umschaltung eine Pause von 5s gemacht werden. try: - if charge_state: - time.sleep(5) # Phasenumschaltung entsprechend Modul - chargepoint_module.switch_phases(phases, ev.ev_template.data.phase_switch_pause) + chargepoint_module.switch_phases(phases) except Exception: log.exception("Fehler im Phasenumschaltungs-Modul") diff --git a/packages/control/process.py b/packages/control/process.py index f4d06f68b4..3e0e7bca90 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -13,9 +13,9 @@ from helpermodules.utils._thread_handler import joined_thread_handler from modules.common.abstract_io import AbstractIoDevice from modules.common.fault_state_level import FaultStateLevel -from modules.io_actions.controllable_consumers.dimming.api import Dimming +from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo from modules.io_actions.controllable_consumers.dimming_direct_control.api import DimmingDirectControl -from modules.io_actions.generator_systems.stepwise_control.api import StepwiseControl +from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo log = logging.getLogger(__name__) @@ -31,7 +31,7 @@ def process_algorithm_results(self) -> None: for cp in data.data.cp_data.values(): try: control_parameter = cp.data.control_parameter - if cp.data.set.charging_ev != -1: + if control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED or cp.data.set.current != 0: # Ladelog-Daten müssen vor dem Setzen des Stroms gesammelt werden, # damit bei Phasenumschaltungs-empfindlichen EV sicher noch nicht geladen wurde. chargelog.collect_data(cp) @@ -42,14 +42,6 @@ def process_algorithm_results(self) -> None: self._update_state(cp) cp.set_timestamp_charge_start() else: - # LP, an denen nicht geladen werden darf - if cp.data.set.charging_ev_prev != -1: - chargelog.save_interim_data( - cp, data.data.ev_data - ["ev" + str(cp.data.set.charging_ev_prev)], - immediately=False) - cp.data.set.current = 0 - Pub().pub("openWB/set/chargepoint/"+str(cp.num)+"/set/current", 0) control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED if cp.data.get.state_str is not None: Pub().pub("openWB/set/chargepoint/"+str(cp.num)+"/get/state_str", @@ -80,13 +72,13 @@ def process_algorithm_results(self) -> None: data.data.io_states[f"io_states{d['id']}"].data.set.digital_output[d["digital_output"]] = ( action.dimming_via_direct_control() is None # active output (True) if no dimming ) - if isinstance(action, Dimming): + if isinstance(action, DimmingIo): for d in action.config.configuration.devices: if d["type"] == "io": data.data.io_states[f"io_states{d['id']}"].data.set.digital_output[d["digital_output"]] = ( not action.dimming_active() # active output (True) if no dimming ) - if isinstance(action, StepwiseControl): + if isinstance(action, StepwiseControlIo): # check if passthrough is enabled if action.config.configuration.passthrough_enabled: # find output pattern by value @@ -102,7 +94,8 @@ def process_algorithm_results(self) -> None: modules_threads.append( Thread( target=io.write, - args=(None, data.data.io_states[f"io_states{io.config.id}"].data.set.digital_output,), + args=(data.data.io_states[f"io_states{io.config.id}"].data.set.analog_output, + data.data.io_states[f"io_states{io.config.id}"].data.set.digital_output,), name=f"set output io{io.config.id}")) if modules_threads: joined_thread_handler(modules_threads, 3) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 21ad58d139..345d8bc8d0 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -12,7 +12,7 @@ from pathlib import Path import paho.mqtt.client as mqtt -from control.chargelog import chargelog +from control.chargelog.process_chargelog import get_log_data from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default @@ -23,6 +23,7 @@ from helpermodules.utils.run_command import run_command # ToDo: move to module commands if implemented from modules.backup_clouds.onedrive.api import generateMSALAuthCode, retrieveMSALTokens +from modules.io_devices.eebus.api import create_pub_cert_ski from helpermodules.broker import BrokerClient from helpermodules.data_migration.data_migration import MigrateData @@ -281,6 +282,7 @@ def setup_added_chargepoint(): " versetzen.", MessageType.ERROR) return chargepoint_config["id"] = new_id + chargepoint_config["name"] = f'{chargepoint_config["name"]} {new_id}' try: evu_counter = data.data.counter_all_data.get_id_evu_counter() data.data.counter_all_data.hierarchy_add_item_below( @@ -352,13 +354,14 @@ def removeChargepoint(self, connection_id: str, payload: dict) -> None: def addChargepointTemplate(self, connection_id: str, payload: dict) -> None: """ sendet das Topic, zu dem ein neues Ladepunkt-Profil erstellt werden soll. """ + new_id = self.max_id_chargepoint_template + 1 # check if "payload" contains "data.copy" if "data" in payload and "copy" in payload["data"]: new_chargepoint_template = asdict(data.data.cp_template_data[f'cpt{payload["data"]["copy"]}'].data).copy() new_chargepoint_template["name"] = f'Kopie von {new_chargepoint_template["name"]}' else: new_chargepoint_template = get_chargepoint_template_default() - new_id = self.max_id_chargepoint_template + 1 + new_chargepoint_template["name"] = f'{new_chargepoint_template["name"]} {new_id}' new_chargepoint_template["id"] = new_id Pub().pub(f'openWB/set/chargepoint/template/{new_id}', new_chargepoint_template) self.max_id_chargepoint_template = self.max_id_chargepoint_template + 1 @@ -461,6 +464,7 @@ def addChargeTemplate(self, connection_id: str, payload: dict) -> None: new_charge_template = asdict(new_charge_template) else: new_charge_template = get_new_charge_template() + new_charge_template["name"] = f'{new_charge_template["name"]} {new_id}' new_charge_template["id"] = new_id Pub().pub("openWB/set/command/max_id/charge_template", new_id) @@ -652,13 +656,14 @@ def removeComponent(self, connection_id: str, payload: dict) -> None: def addEvTemplate(self, connection_id: str, payload: dict) -> None: """ sendet das Topic, zu dem ein neues Fahrzeug-Profil erstellt werden soll. """ + new_id = self.max_id_ev_template + 1 # check if "payload" contains "data.copy" if "data" in payload and "copy" in payload["data"]: new_ev_template = asdict(data.data.ev_template_data[f"et{payload['data']['copy']}"].data).copy() new_ev_template["name"] = f'Kopie von {new_ev_template["name"]}' else: new_ev_template = dataclass_utils.asdict(EvTemplateData()) - new_id = self.max_id_ev_template + 1 + new_ev_template["name"] = f'{new_ev_template["name"]} {new_id}' new_ev_template["id"] = new_id self.max_id_ev_template = new_id Pub().pub(f'openWB/set/vehicle/template/ev_template/{new_id}', new_ev_template) @@ -687,6 +692,7 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: """ new_id = self.max_id_vehicle + 1 vehicle_default = ev.get_vehicle_default() + vehicle_default["name"] = f'{vehicle_default["name"]} {new_id}' for default in vehicle_default: Pub().pub(f"openWB/set/vehicle/{new_id}/{default}", vehicle_default[default]) Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/config", {"type": None, "configuration": {}}) @@ -725,7 +731,7 @@ def sendDebug(self, connection_id: str, payload: dict) -> None: pub_user_message(payload, connection_id, "Systembericht wurde versandt.", MessageType.SUCCESS) def getChargeLog(self, connection_id: str, payload: dict) -> None: - Pub().pub(f'openWB/set/log/{connection_id}/data', chargelog.get_log_data(payload["data"])) + Pub().pub(f'openWB/set/log/{connection_id}/data', get_log_data(payload["data"])) def getDailyLog(self, connection_id: str, payload: dict) -> None: Pub().pub(f'openWB/set/log/daily/{payload["data"]["date"]}', @@ -919,6 +925,9 @@ def retrieveMSALTokens(self, connection_id: str, payload: dict) -> None: result = retrieveMSALTokens(cloud_backup_config.config) pub_user_message(payload, connection_id, result["message"], result["MessageType"]) + def createEebusCert(self, connection_id: str, payload: dict) -> None: + create_pub_cert_ski(payload["data"]["io_device"]) + def factoryReset(self, connection_id: str, payload: dict) -> None: Path(Path(__file__).resolve().parents[2] / 'data' / 'restore' / 'factory_reset').touch() pub_user_message(payload, connection_id, diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index 8c95b03813..f5bb4b585c 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -33,7 +33,15 @@ def get_common_data(): ip_address = subdata.SubData.system_data["system"].data["ip_address"] except Exception: ip_address = None + try: + updateAvailable = subdata.SubData.system_data["system"].data["current_branch_commit"] and \ + subdata.SubData.system_data["system"].data["current_branch_commit"] != \ + subdata.SubData.system_data["system"].data["current_commit"] + except Exception: + updateAvailable = False + with ErrorHandlingContext(): + parsed_data += f"Firmware_deprecated: {updateAvailable}\n" with ErrorHandlingContext(): parent_file = Path(__file__).resolve().parents[2] with open(f"{parent_file}/web/version", "r") as f: @@ -103,21 +111,22 @@ def config_and_state(): if secondary is False: with ErrorHandlingContext(): chargemode_config = data.data.general_data.data.chargemode_config - parsed_data += ("\n## General Charge Config/ PV ##\n" - f"Phase_Switch_Delay: {chargemode_config.phase_switch_delay} min\n" - f"Retry_Failed_Phase_Switches: {chargemode_config.retry_failed_phase_switches}\n" - f"Control_Range: {chargemode_config.pv_charging.control_range}W\n" - f"Switch_On_Threshold: {chargemode_config.pv_charging.switch_on_threshold}W\n" - f"Switch_On_Delay: {chargemode_config.pv_charging.switch_on_delay}s\n" - f"Switch_Off_Threshold: {chargemode_config.pv_charging.switch_off_threshold}W\n" - f"Switch_Off_Delay: {chargemode_config.pv_charging.switch_off_delay}s\n" - f"Feed_In_Yield: {chargemode_config.pv_charging.feed_in_yield}W\n" - f"Bat_Mode: {chargemode_config.pv_charging.bat_mode}\n" - f"Min_Bat_SoC: {chargemode_config.pv_charging.min_bat_soc}%\n" - f"Bat_Power_Reserve_Active: {chargemode_config.pv_charging.bat_power_reserve_active}\n" - f"Bat_Power_Reserve: {chargemode_config.pv_charging.bat_power_reserve}W\n" - f"Bat_Power_Discharge_Active: {chargemode_config.pv_charging.bat_power_discharge_active}\n" - f"Bat_Power_Discharge: {chargemode_config.pv_charging.bat_power_discharge}W\n") + parsed_data += ( + "\n## General Charge Config/ PV ##\n" + f"Phase_Switch_Delay: {chargemode_config.pv_charging.phase_switch_delay} min\n" + f"Retry_Failed_Phase_Switches: {chargemode_config.pv_charging.retry_failed_phase_switches}\n" + f"Control_Range: {chargemode_config.pv_charging.control_range}W\n" + f"Switch_On_Threshold: {chargemode_config.pv_charging.switch_on_threshold}W\n" + f"Switch_On_Delay: {chargemode_config.pv_charging.switch_on_delay}s\n" + f"Switch_Off_Threshold: {chargemode_config.pv_charging.switch_off_threshold}W\n" + f"Switch_Off_Delay: {chargemode_config.pv_charging.switch_off_delay}s\n" + f"Feed_In_Yield: {chargemode_config.pv_charging.feed_in_yield}W\n" + f"Bat_Mode: {chargemode_config.pv_charging.bat_mode}\n" + f"Min_Bat_SoC: {chargemode_config.pv_charging.min_bat_soc}%\n" + f"Bat_Power_Reserve_Active: {chargemode_config.pv_charging.bat_power_reserve_active}\n" + f"Bat_Power_Reserve: {chargemode_config.pv_charging.bat_power_reserve}W\n" + f"Bat_Power_Discharge_Active: {chargemode_config.pv_charging.bat_power_discharge_active}\n" + f"Bat_Power_Discharge: {chargemode_config.pv_charging.bat_power_discharge}W\n") if secondary is False: with ErrorHandlingContext(): parsed_data += f"\n## Hierarchy ##\n{get_hierarchy(data.data.counter_all_data.data.get.hierarchy)}\n" @@ -281,15 +290,28 @@ def get_parsed_cp_data(cp: Chargepoint) -> str: except Exception: currents = "Keine Daten" voltages = "Keine Daten" - - parsed_data += (f"### LP{cp.num} ###\n" - f"CP_Type: {cp.chargepoint_module.config.type}\n" + try: + ct_id = cp.data.config.template + max_current_single_phase = data.data.cp_template_data.get(f"cpt{ct_id}").data.max_current_single_phase + max_current_multi_phases = data.data.cp_template_data.get(f"cpt{ct_id}").data.max_current_multi_phases + except Exception: + ct_id = None + max_current_single_phase = None + ev_fn = data.data.ev_data.get(f'ev{cp.data.config.ev}').data.name + + parsed_data += f"### LP{cp.num} ###\n" + if cp.chargepoint_module.config.type == "external_openwb": + parsed_data += (f"CP_Current_Branch: {cp.data.get.current_branch}\n" + f"CP_Version: {cp.data.get.version}\n") + parsed_data += (f"CP_Type: {cp.chargepoint_module.config.type}\n" f"CP_FN: {cp.chargepoint_module.config.name}\n" f"{mode}" f"CP_Phase_Switch_HW: {cp.data.config.auto_phase_switch_hw}\n" f"CP_Control_Pilot_HW: {cp.data.config.control_pilot_interruption_hw}\n" f"CP_IP: {ip}\n" f"CP_Set_Current: {cp.data.set.current} A\n" + f"CPT_Max_Current_Single_Phase: {max_current_single_phase} A\n" + f"CPT_Max_Current_Multi_Phases: {max_current_multi_phases} A\n" f"Meter_Power: {cp.data.get.power} W\n" f"Meter_Voltages: {cp.data.get.voltages} V\n" f"Meter_Currents: {cp.data.get.currents} A\n" @@ -308,6 +330,10 @@ def get_parsed_cp_data(cp: Chargepoint) -> str: # CP_SW_VERSION: 2.1.7-Patch.2 # CP_FIRMWARE: 1.2.3 (bei Pro bzw. Satellit) # CP_SIGNALING_PRO: basic iec61851 iso11518 + f"Connected_Vehicle: {ev_fn} (ID: {cp.data.config.ev})\n" + f"Charge_Template: {cp.data.set.charge_template.data.name}" + f"(ID: {cp.data.set.charge_template.data.id})\n" + # f"EV_FN2: {cp.chargepoint_module.get.connected_verhicle.info.name}\n" f"CP_Error_State: {cp.data.get.fault_str}\n" f"Additional_Meter_Voltages: \n{voltages}" f"Additional_Meter_Currents: \n{currents}\n") @@ -326,9 +352,7 @@ def get_parsed_cp_data(cp: Chargepoint) -> str: def filter_log_file(log_name, pattern, num_results=10): log_files = [f"{ramdisk_dir}/{log_name}.log.{i}" for i in range(5, 0, -1)] - print(log_files) log_files.append(f"{ramdisk_dir}/{log_name}.log") - print(log_files) lines = [] try: for log_file in log_files: @@ -389,8 +413,12 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): try: broker = BrokerContent() debug_email = input_data.get('email', '') + ticketnumber = input_data.get('ticketNumber', '') + subject = input_data.get('subject', '') header = (f"{input_data['message']}\n{debug_email}\n{input_data['serialNumber']}\n" f"{input_data['installedComponents']}\n{input_data['vehicles']}\n") + if ticketnumber is not None and ticketnumber != "": + header += f"Ticketnumber: {ticketnumber}\n" with open(debug_file, 'w+') as df: write_to_file(df, lambda: "# section: form data #") write_to_file(df, lambda: header) @@ -427,13 +455,17 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): log.info("***** uploading debug log...") with open(debug_file, 'rb') as f: data = f.read() - req.get_http_session().put("https://openwb.de/tools/debug2.php", + req.get_http_session().put("https://openwb.de/tools/debug3.php", data=data, - params={'debugemail': debug_email}, + params={ + 'debugemail': debug_email, + 'ticketnumber': ticketnumber, + 'subject': subject + }, timeout=10) log.info("***** cleanup...") - os.remove(debug_file) + # os.remove(debug_file) log.info("***** debug log end") except Exception as e: log.exception(f"Error creating debug log: {e}") @@ -472,7 +504,7 @@ def __on_connect_broker_essentials(self, client, userdata, flags, rc): client.subscribe("openWB/counter/#", 2) client.subscribe("openWB/pv/#", 2) client.subscribe("openWB/bat/#", 2) - client.subscribe("openWB/optional/et/provider", 2) + client.subscribe("openWB/optional/ep/flexible_tariff/provider", 2) def __on_connect_bridges(self, client, userdata, flags, rc): client.subscribe("openWB/system/mqtt/#", 2) diff --git a/packages/helpermodules/measurement_logging/process_log.py b/packages/helpermodules/measurement_logging/process_log.py index 60feb03e6a..25ba9d314c 100644 --- a/packages/helpermodules/measurement_logging/process_log.py +++ b/packages/helpermodules/measurement_logging/process_log.py @@ -38,9 +38,12 @@ def get_default_charge_log_columns() -> Dict: "vehicle_soc_at_end": False, "chargepoint_name": True, "chargepoint_serial_number": False, + "data_exported_since_mode_switch": False, "data_imported_since_mode_switch": True, + "chargepoint_exported_at_start": False, + "chargepoint_exported_at_end": False, "chargepoint_imported_at_start": False, - "chargepoint_imported_at_end": False + "chargepoint_imported_at_end": False, } diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index ef01486b26..0fe9447147 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -199,12 +199,13 @@ def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previou try: prices = data.data.general_data.data.prices try: - grid_price = data.data.optional_data.et_get_current_price() + grid_price = data.data.optional_data.ep_get_current_price() except Exception: grid_price = prices.grid prices_dict = {"grid": grid_price, "pv": prices.pv, - "bat": prices.bat} + "bat": prices.bat, + "cp": prices.cp} except Exception: log.exception("Fehler im Werte-Logging-Modul für Preise") prices_dict = {} diff --git a/packages/helpermodules/phase_mapping.py b/packages/helpermodules/phase_handling.py similarity index 77% rename from packages/helpermodules/phase_mapping.py rename to packages/helpermodules/phase_handling.py index d7e841d354..f0e7dec95d 100644 --- a/packages/helpermodules/phase_mapping.py +++ b/packages/helpermodules/phase_handling.py @@ -24,3 +24,11 @@ def convert_single_cp_phase_to_evu_phase(phase_1: int, cp_phase: int) -> int: def convert_single_evu_phase_to_cp_phase(phase_1: int, evu_phase: int) -> int: return EVU_TO_CP_PHASE_MAPPING[phase_1][evu_phase] + + +def voltages_mean(voltages: List[float]) -> float: + # Zoes erzeugen bei einphasiger Ladung 140V auf Phase 2 + filtered_voltages = [v for v in voltages if v > 200] + if not filtered_voltages: + return 0.0 + return sum(filtered_voltages) / len(filtered_voltages) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 029852054e..067ede9af8 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -21,6 +21,8 @@ log = logging.getLogger(__name__) mqtt_log = logging.getLogger("mqtt") +TIMESTAMP_2100 = 4102441200 # 01.01.2100 00:00:00 + class SetData: def __init__(self, @@ -386,7 +388,7 @@ def process_vehicle_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, int, [(0, float("inf"))]) elif ("/get/soc_request_timestamp" in msg.topic or "/get/soc_timestamp" in msg.topic): - self._validate_value(msg, float) + self._validate_value(msg, float, [(0, TIMESTAMP_2100)]) elif "/get/soc" in msg.topic: self._validate_value(msg, float, [(0, 100)]) elif "/get/range" in msg.topic: @@ -423,9 +425,7 @@ def process_vehicle_charge_template_topic(self, msg: mqtt.MQTTMessage): if cp.num == cp_num: # nicht an den Ladepunkt senden, der das Topic gesendet hat continue - if ((cp.data.set.charging_ev != -1 and - cp.data.set.charging_ev == vehicle.num) or - cp.data.config.ev == vehicle.num): + if cp.data.config.ev == vehicle.num: if decode_payload(msg.payload) == "": Pub().pub( f"openWB/chargepoint/{cp.num}/set/charge_template", "") @@ -470,12 +470,8 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): elif re.search("chargepoint/[0-9]+/config$", msg.topic) is not None: self._validate_value(msg, "json") elif subdata.SubData.cp_data.get(f"cp{get_index(msg.topic)}"): - if ("/set/charging_ev" in msg.topic or - "/set/charging_ev_prev" in msg.topic or - "/set/ev_prev" in msg.topic): - self._validate_value(msg, int, [(-1, float("inf"))]) - elif ("/set/current" in msg.topic or - "/set/current_prev" in msg.topic): + if ("/set/current" in msg.topic or + "/set/current_prev" in msg.topic): if hardware_configuration.get_hardware_configuration_setting("dc_charging"): self._validate_value(msg, float, [(float("-inf"), 0), (0, 0), (6, 32), (0, 450)]) else: @@ -501,6 +497,8 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, int) elif "/set/log" in msg.topic: self._validate_value(msg, "json") + elif "/set/ev_prev" in msg.topic: + self._validate_value(msg, int, [(0, float("inf"))]) elif "/config/ev" in msg.topic: self._validate_value( msg, int, [(0, float("inf"))], pub_json=True) @@ -577,14 +575,14 @@ def process_chargepoint_get_topics(self, msg): elif ("/get/evse_current" in msg.topic or "/get/max_evse_current" in msg.topic): # AC-EVSE: 0, 6-32, 600-3200, DC-EVSE 0-500 - self._validate_value(msg, float, [(0, 3200)]) + self._validate_value(msg, float, [(-3200, 3200)]) elif ("/get/version" in msg.topic or "/get/current_branch" in msg.topic or "/get/current_commit" in msg.topic): self._validate_value(msg, str) elif ("/get/error_timestamp" in msg.topic or "/get/rfid_timestamp" in msg.topic): - self._validate_value(msg, float) + self._validate_value(msg, float, [(0, TIMESTAMP_2100)]) elif ("/get/fault_str" in msg.topic or "/get/state_str" in msg.topic or "/get/heartbeat" in msg.topic or @@ -636,7 +634,8 @@ def process_pv_topic(self, msg: mqtt.MQTTMessage): "/get/yearly_exported" in msg.topic or "/get/energy" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) - elif "/get/exported" in msg.topic: + elif ("/get/exported" in msg.topic or + "/get/imported" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) elif "/get/power" in msg.topic: self._validate_value(msg, float) @@ -733,7 +732,7 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): elif "openWB/set/general/chargemode_config/unbalanced_load_limit" in msg.topic: self._validate_value(msg, int, [(10, 32)]) elif ("openWB/set/general/chargemode_config/unbalanced_load" in msg.topic or - "openWB/set/general/chargemode_config/retry_failed_phase_switches" in msg.topic or + "openWB/set/general/chargemode_config/pv_charging/retry_failed_phase_switches" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge_active" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve_active" in msg.topic): self._validate_value(msg, bool) @@ -744,11 +743,12 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, int, [(0, float("inf"))]) elif "openWB/set/general/chargemode_config/pv_charging/switch_off_threshold" in msg.topic: self._validate_value(msg, float) - elif "openWB/set/general/chargemode_config/phase_switch_delay" in msg.topic: + elif "openWB/set/general/chargemode_config/pv_charging/phase_switch_delay" in msg.topic: self._validate_value(msg, int, [(5, 60)]) elif "openWB/set/general/chargemode_config/pv_charging/control_range" in msg.topic: self._validate_value(msg, int, collection=list) - elif "openWB/set/general/chargemode_config/pv_charging/min_bat_soc" in msg.topic: + elif ("openWB/set/general/chargemode_config/pv_charging/min_bat_soc" in msg.topic or + "openWB/set/general/chargemode_config/pv_charging/max_bat_soc" in msg.topic): self._validate_value(msg, int, [(0, 100)]) elif ("openWB/set/general/chargemode_config/pv_charging/bat_power_discharge" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve" in msg.topic): @@ -762,7 +762,7 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): "openWB/set/general/mqtt_bridge" in msg.topic): self._validate_value(msg, bool) elif "openWB/set/general/grid_protection_timestamp" in msg.topic: - self._validate_value(msg, float) + self._validate_value(msg, float, [(0, TIMESTAMP_2100)]) elif "openWB/set/general/grid_protection_random_stop" in msg.topic: self._validate_value(msg, int, [(0, 90)]) elif "openWB/set/general/notifications/selected" in msg.topic: @@ -815,7 +815,7 @@ def process_io_topic(self, msg: mqtt.MQTTMessage): elif "get/fault_str" in msg.topic: self._validate_value(msg, str) elif "/timestamp" in msg.topic: - self._validate_value(msg, float) + self._validate_value(msg, float, [(0, TIMESTAMP_2100)]) else: self.__unknown_topic(msg) except Exception: @@ -843,16 +843,27 @@ def process_optional_topic(self, msg: mqtt.MQTTMessage): enthält Topic und Payload """ try: - if "openWB/set/optional/et/get/prices" in msg.topic: + pricing_regex = "openWB/set/optional/ep/(flexible_tariff|grid_fee)/" + if re.search(pricing_regex, msg.topic) is not None: + if re.search(f"{pricing_regex}provider$", msg.topic) is not None: + self._validate_value(msg, "json") + elif re.search(f"{pricing_regex}get/prices$", msg.topic) is not None: + self._validate_value(msg, "json") + elif re.search(f"{pricing_regex}get/price$", msg.topic) is not None: + self._validate_value(msg, float) + elif re.search(f"{pricing_regex}get/fault_state$", msg.topic) is not None: + self._validate_value(msg, int, [(0, 2)]) + elif re.search(f"{pricing_regex}get/fault_str$", msg.topic) is not None: + self._validate_value(msg, str) + elif "openWB/set/optional/ep/get/prices" in msg.topic: self._validate_value(msg, "json") - elif "openWB/set/optional/et/get/price" in msg.topic: + elif "openWB/set/optional/ep/get/next_query_time" in msg.topic: self._validate_value(msg, float) - elif "openWB/set/optional/et/get/fault_state" in msg.topic: - self._validate_value(msg, int, [(0, 2)]) - elif "openWB/set/optional/et/get/fault_str" in msg.topic: - self._validate_value(msg, str) - elif ("openWB/set/optional/et/provider" in msg.topic or - "openWB/set/optional/ocpp/config" in msg.topic): + elif "openWB/set/optional/ep/configured" in msg.topic: + self._validate_value(msg, bool) + elif "module_update_completed" in msg.topic: + self._validate_value(msg, bool) + elif "openWB/set/optional/ocpp/config" in msg.topic: self._validate_value(msg, "json") elif "openWB/set/optional/monitoring" in msg.topic: self._validate_value(msg, "json") @@ -1016,12 +1027,13 @@ def process_system_topic(self, msg: mqtt.MQTTMessage): "openWB/set/system/usage_terms_acknowledged" in msg.topic or "openWB/set/system/update_config_completed" in msg.topic): self._validate_value(msg, bool) - elif "openWB/set/system/version" in msg.topic: + elif ("openWB/set/system/version" in msg.topic or + "openWB/set/system/backup_password" in msg.topic): self._validate_value(msg, str) elif "openWB/set/system/time" in msg.topic: self._validate_value(msg, float) elif "openWB/set/system/datastore_version" in msg.topic: - self._validate_value(msg, int, [(0, UpdateConfig.DATASTORE_VERSION)]) + self._validate_value(msg, int, [(0, UpdateConfig.DATASTORE_VERSION)], collection=list) elif "openWB/set/system/GetRemoteSupport" in msg.topic: # Server-Topic enthält kein json-Payload. payload = msg.payload.decode("utf-8") @@ -1055,7 +1067,7 @@ def process_system_topic(self, msg: mqtt.MQTTMessage): elif "/config" in msg.topic: self._validate_value(msg, "json") elif "/error_timestamp" in msg.topic: - self._validate_value(msg, float, [(0, float("inf"))]) + self._validate_value(msg, float, [(0, TIMESTAMP_2100)]) elif "/get/fault_state" in msg.topic: self._validate_value(msg, int, [(0, 2)]) elif "/get/fault_str" in msg.topic: diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index a9999f1728..b5b2856e0c 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -27,10 +27,10 @@ from helpermodules.utils.run_command import run_command from helpermodules.utils.topic_parser import decode_payload, get_index, get_second_index from helpermodules.pub import Pub -from dataclass_utils import dataclass_from_dict +from dataclass_utils import asdict, dataclass_from_dict from modules.common.abstract_vehicle import CalculatedSocState, GeneralVehicleConfig from modules.common.configurable_backup_cloud import ConfigurableBackupCloud -from modules.common.configurable_tariff import ConfigurableElectricityTariff +from modules.common.configurable_tariff import ConfigurableFlexibleTariff, ConfigurableGridFee from modules.common.simcount.simcounter_state import SimCounterState from modules.internal_chargepoint_handler.internal_chargepoint_handler_config import ( GlobalHandlerData, InternalChargepoint, RfidData) @@ -132,7 +132,6 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/general/#", 2), ("openWB/graph/#", 2), ("openWB/internal_io/#", 2), - ("openWB/io/#", 2), ("openWB/optional/#", 2), ("openWB/counter/#", 2), ("openWB/command/command_completed", 2), @@ -148,6 +147,7 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/system/device/+/config", 2), ("openWB/system/io/#", 2), ("openWB/LegacySmartHome/Status/wattnichtHaus", 2), + ("openWB/io/#", 2), ]) self.processing_counter.add_task() Pub().pub("openWB/system/subdata_initialized", True) @@ -356,8 +356,7 @@ def process_vehicle_charge_template_topic(self, var: Dict[str, ChargeTemplate], for vehicle in self.ev_data.values(): if vehicle.data.charge_template == int(index): for cp in self.cp_data.values(): - if (cp.chargepoint.data.set.charging_ev == vehicle.num or - cp.chargepoint.data.config.ev == vehicle.num): + if cp.chargepoint.data.config.ev == vehicle.num: # UI sendet immer alle Topics, auch nicht geänderte. Damit die temporären Topics # nicht mehrfach gepbulished werden, muss das publishen der temporären Topics 1:1 # erfolgen. @@ -439,13 +438,8 @@ def process_chargepoint_topic(self, var: Dict[str, chargepoint.Chargepoint], msg self.set_json_payload_class(var["cp"+index].chargepoint.data.get.connected_vehicle, msg) elif (re.search("/chargepoint/[0-9]+/get/soc$", msg.topic) is not None and decode_payload(msg.payload) != var["cp"+index].chargepoint.data.get.soc): - # Wenn das Auto noch nicht zugeordnet ist, wird der SoC nach der Zuordnung aktualisiert - if var["cp"+index].chargepoint.data.set.charging_ev > -1: - Pub().pub(f'openWB/set/vehicle/{var["cp"+index].chargepoint.data.set.charging_ev}' - '/get/force_soc_update', True) - elif var["cp"+index].chargepoint.data.set.charging_ev_prev > -1: - Pub().pub(f'openWB/set/vehicle/{var["cp"+index].chargepoint.data.set.charging_ev_prev}' - '/get/force_soc_update', True) + Pub().pub(f'openWB/set/vehicle/{var["cp"+index].chargepoint.data.config.ev}' + '/get/force_soc_update', True) self.set_json_payload_class(var["cp"+index].chargepoint.data.get, msg) elif (re.search("/chargepoint/[0-9]+/get/error_timestamp$", msg.topic) is not None and hasattr(var[f"cp{index}"].chargepoint.chargepoint_module, "client_error_context")): @@ -478,13 +472,15 @@ def process_chargepoint_config_topic(self, var: Dict[str, chargepoint.CpTemplate index = get_index(msg.topic) payload = decode_payload(msg.payload) if (var["cp"+index].chargepoint.chargepoint_module is None or - payload != var["cp"+index].chargepoint.chargepoint_module.config): + payload["configuration"] != asdict(var["cp"+index + ].chargepoint.chargepoint_module.config.configuration)): mod = importlib.import_module( ".chargepoints."+payload["type"]+".chargepoint_module", "modules") config = dataclass_from_dict(mod.chargepoint_descriptor.configuration_factory, payload) var["cp"+index].chargepoint.chargepoint_module = mod.ChargepointModule(config) self.set_internal_chargepoint_configured() - if payload["type"] == "internal_openwb": + if (payload["type"] == "internal_openwb" and + payload["type"] != var["cp"+index].chargepoint.chargepoint_module.config.type): log.debug("Neustart des Handlers für den internen Ladepunkt.") self.event_stop_internal_chargepoint.set() self.event_start_internal_chargepoint.set() @@ -680,7 +676,8 @@ def process_io_topic(self, var: Dict[str, Union[io_device.IoActions, io_device.I mod = importlib.import_module( f".io_actions.{payload['group']}.{payload['type']}.api", "modules") config = dataclass_from_dict(mod.device_descriptor.configuration_factory, payload) - var.actions[f"io_action{index}"] = mod.create_action(config) + var.actions[f"io_action{index}"] = mod.create_action( + config, self.system_data[f"io{config.configuration.io_device}"].config.type) elif re.search("/io/action/[0-9]+/timestamp", msg.topic) is not None: index = get_index(msg.topic) self.set_json_payload_class(var.actions[f"io_action{index}"], msg) @@ -712,23 +709,42 @@ def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage): run_command([ str(Path(__file__).resolve().parents[2] / "runs" / "update_local_display.sh") ], process_exception=True) - elif re.search("/optional/et/", msg.topic) is not None: - if re.search("/optional/et/get/prices", msg.topic) is not None: - var.data.et.get.prices = decode_payload(msg.payload) - elif re.search("/optional/et/get/", msg.topic) is not None: - self.set_json_payload_class(var.data.et.get, msg) - elif re.search("/optional/et/provider$", msg.topic) is not None: + elif re.search("/optional/ep/(flexible_tariff|grid_fee)/", msg.topic) is not None: + if re.search("/optional/ep/flexible_tariff/provider$", msg.topic) is not None: config_dict = decode_payload(msg.payload) if config_dict["type"] is None: - var.et_module = None + var.flexible_tariff_module = None else: mod = importlib.import_module( - f".electricity_tariffs.{config_dict['type']}.tariff", "modules") + f".electricity_pricing.flexible_tariffs.{config_dict['type']}.tariff", "modules") config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) - var.et_module = ConfigurableElectricityTariff(config, mod.create_electricity_tariff) - var.et_get_prices() - else: - self.set_json_payload_class(var.data.et, msg) + var.flexible_tariff_module = ConfigurableFlexibleTariff( + config, mod.create_electricity_tariff) + elif re.search("/optional/ep/flexible_tariff/get/prices", msg.topic) is not None: + var.data.electricity_pricing.flexible_tariff.get.prices = decode_payload(msg.payload) + elif re.search("/optional/ep/flexible_tariff/get/", msg.topic) is not None: + self.set_json_payload_class(var.data.electricity_pricing.flexible_tariff.get, msg) + elif re.search("/optional/ep/grid_fee/provider$", msg.topic) is not None: + config_dict = decode_payload(msg.payload) + if config_dict["type"] is None: + var.grid_fee_module = None + else: + mod = importlib.import_module( + f".electricity_pricing.grid_fees.{config_dict['type']}.tariff", "modules") + config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict) + var.grid_fee_module = ConfigurableGridFee(config, mod.create_electricity_tariff) + elif re.search("/optional/ep/grid_fee/get/prices", msg.topic) is not None: + var.data.electricity_pricing.grid_fee.get.prices = decode_payload(msg.payload) + elif re.search("/optional/ep/grid_fee/get/", msg.topic) is not None: + self.set_json_payload_class(var.data.electricity_pricing.grid_fee.get, msg) + elif re.search("/optional/ep/get/prices", msg.topic) is not None: + var.data.electricity_pricing.get.prices = decode_payload(msg.payload) + elif re.search("/optional/ep/get/", msg.topic) is not None: + self.set_json_payload_class(var.data.electricity_pricing.get, msg) + elif re.search("/optional/ep/", msg.topic) is not None: + self.set_json_payload_class(var.data.electricity_pricing, msg) + elif "module_update_completed" in msg.topic: + self.event_module_update_completed.set() elif re.search("/optional/ocpp/", msg.topic) is not None: config_dict = decode_payload(msg.payload) var.data.ocpp = dataclass_from_dict(Ocpp, config_dict) @@ -828,7 +844,7 @@ def process_system_topic(self, client: mqtt.Client, var: dict, msg: mqtt.MQTTMes decode_payload(msg.payload)) elif re.search("^.+/device/[0-9]+/error_timestamp$", msg.topic) is not None: index = get_index(msg.topic) - var["device"+index].client_error_context.error_timestamp = decode_payload(msg.payload) + var["device"+index].error_timestamp = decode_payload(msg.payload) elif re.search("^.+/device/[0-9]+/component/[0-9]+/config$", msg.topic) is not None: index = get_index(msg.topic) index_second = get_second_index(msg.topic) @@ -887,6 +903,18 @@ def process_system_topic(self, client: mqtt.Client, var: dict, msg: mqtt.MQTTMes user = splitted[2] if len(splitted) > 2 else "getsupport" run_command([str(Path(__file__).resolve().parents[2] / "runs" / "start_remote_support.sh"), token, port, user], process_exception=True) + elif "openWB/system/backup_password" in msg.topic: + if self.event_subdata_initialized.is_set(): + key_file = Path.home() / "backup.key" + payload = decode_payload(msg.payload) + if payload is None or payload == "": + # delete key file + if key_file.exists(): + key_file.unlink() + else: + # write key file + with key_file.open("w") as file: + file.write(payload) elif "openWB/system/backup_cloud/config" in msg.topic: config_dict = decode_payload(msg.payload) if config_dict["type"] is None: diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index 8b42a97d8e..a65ff63018 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -2,6 +2,7 @@ """ import logging import datetime +import re from typing import List, Optional, Tuple, TypeVar, Union from helpermodules.utils.error_handling import ImportErrorContext @@ -113,18 +114,14 @@ def is_timeframe_valid(now: datetime.datetime, begin: datetime.datetime, end: da def check_end_time(plan: ScheduledChargingPlan, - chargemode_switch_timestamp: Optional[float]) -> Optional[float]: - """ prüft, ob der in angegebene Zeitpunkt abzüglich der Dauer jetzt ist. + buffer: Optional[float]) -> Optional[float]: + """ gibt die verbleibende Zeit in Sekunden zurück. Return ------ neg: Zeitpunkt vorbei pos: verbleibende Sekunden """ - def missed_date_still_active(remaining_time: float) -> bool: - return (chargemode_switch_timestamp and - remaining_time.total_seconds() < 0 and - end.timestamp() < chargemode_switch_timestamp) now = datetime.datetime.today() end = datetime.datetime.strptime(plan.time, '%H:%M') remaining_time = None @@ -135,7 +132,7 @@ def missed_date_still_active(remaining_time: float) -> bool: elif plan.frequency.selected == "daily": end = end.replace(now.year, now.month, now.day) remaining_time = end - now - if missed_date_still_active(remaining_time): + if remaining_time.total_seconds() < buffer: # Wenn auf Zielladen umgeschaltet wurde und der Termin noch nicht vorbei war, noch auf diesen Termin laden. end = end + datetime.timedelta(days=1) remaining_time = end - now @@ -145,17 +142,13 @@ def missed_date_still_active(remaining_time: float) -> bool: end = end.replace(now.year, now.month, now.day) end += datetime.timedelta(days=_get_next_charging_day(plan.frequency.weekly, now.weekday())) remaining_time = end - now - if missed_date_still_active(remaining_time): + if remaining_time.total_seconds() < buffer: end = end.replace(now.year, now.month, now.day) end += datetime.timedelta(days=_get_next_charging_day(plan.frequency.weekly, now.weekday()+1)+1) remaining_time = end - now else: raise TypeError(f'Unbekannte Häufigkeit {plan.frequency.selected}') - if chargemode_switch_timestamp and end.timestamp() < chargemode_switch_timestamp: - # Als auf Zielladen umgeschaltet wurde, war der Termin schon vorbei - return None - else: - return remaining_time.total_seconds() + return remaining_time.total_seconds() def _get_next_charging_day(weekly: List[bool], weekday: int) -> int: @@ -300,14 +293,14 @@ def duration_sum(first: str, second: str) -> str: return "00:00" -def __get_timedelta_obj(time: str) -> datetime.timedelta: +def __get_timedelta_obj(time_str: str) -> datetime.timedelta: """ erstellt aus einem String ein timedelta-Objekt. Parameter --------- - time: str + time_str: str Zeitstrings HH:MM ggf DD:HH:MM """ - time_charged = time.split(":") + time_charged = time_str.split(":") if len(time_charged) == 2: delta = datetime.timedelta(hours=int(time_charged[0]), minutes=int(time_charged[1])) @@ -316,7 +309,7 @@ def __get_timedelta_obj(time: str) -> datetime.timedelta: hours=int(time_charged[1]), minutes=int(time_charged[2])) else: - raise Exception("Unknown charge duration: "+time) + raise Exception(f"Unknown charge duration: {time_str}") return delta @@ -336,3 +329,31 @@ def convert_timestamp_delta_to_time_string(timestamp: int, delta: int) -> str: return f"{minute_diff} Min." elif seconds_diff > 0: return f"{seconds_diff} Sek." + + +def convert_to_timestamp(timestring: str) -> int: + return int(datetime.datetime.fromisoformat(timestring).timestamp()) + + +def parse_iso8601_duration(duration: str) -> float: + """ + Parst eine ISO-8601 Duration wie 'PT3723S', 'P1DT2H30M', etc. + Gibt ein timedelta zurück. + """ + pattern = re.compile( + r'P' # beginnt immer mit P + r'(?:(?P\d+)D)?' # Tage + r'(?:T' # Zeit-Teil beginnt mit T + r'(?:(?P\d+)H)?' # Stunden + r'(?:(?P\d+)M)?' # Minuten + r'(?:(?P\d+)S)?' # Sekunden + r')?$' + ) + + match = pattern.fullmatch(duration) + if not match: + raise ValueError(f"Ungültiges ISO-8601 Duration Format: {duration}") + + parts = {name: int(val) if val else 0 for name, val in match.groupdict().items()} + return datetime.timedelta(days=parts["days"], hours=parts["hours"], + minutes=parts["minutes"], seconds=parts["seconds"]).total_seconds() diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index d45f0b8227..38cb6d605e 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -22,9 +22,7 @@ def __init__(self, name: str, @pytest.mark.parametrize("time, selected, date, expected_remaining_time", [pytest.param("9:00", "once", "2022-05-16", 1148, id="once"), - pytest.param("7:55", "once", "2022-05-16", None, id="missed date, plugged before"), - pytest.param("8:05", "once", "2022-05-16", - - 2152, id="once missed date, plugged after"), + pytest.param("8:05", "once", "2022-05-16", -2152, id="once missed date"), pytest.param("12:00", "daily", [], 11948, id="daily today"), pytest.param("2:00", "daily", [], 62348, id="daily missed today, use next day"), pytest.param("7:55", "weekly", [True, False, False, False, @@ -45,9 +43,7 @@ def test_check_end_time(time: str, setattr(plan.frequency, selected, date) # execution - remaining_time = timecheck.check_end_time( - plan, - chargemode_switch_timestamp=datetime.datetime.strptime("5/16/2022 8:00", "%m/%d/%Y %H:%M").timestamp()) + remaining_time = timecheck.check_end_time(plan, 1200) # evaluation assert remaining_time == expected_remaining_time diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 2007bd6e63..a4791b4962 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -40,14 +40,14 @@ from control.ev.charge_template import EcoCharging, get_charge_template_default from control.ev import ev from control.ev.ev_template import EvTemplateData -from control.general import ChargemodeConfig, Prices +from control.general import Prices, PvCharging from control.optional_data import Ocpp from modules.common.abstract_vehicle import GeneralVehicleConfig from modules.common.component_type import ComponentType from modules.devices.sungrow.sungrow.version import Version from modules.display_themes.cards.config import CardsDisplayTheme from modules.io_actions.controllable_consumers.ripple_control_receiver.config import RippleControlReceiverSetup -from modules.web_themes.standard_legacy.config import StandardLegacyWebTheme +from modules.web_themes.koala.config import KoalaWebTheme from modules.devices.good_we.good_we.version import GoodWeVersion log = logging.getLogger(__name__) @@ -57,7 +57,7 @@ class UpdateConfig: - DATASTORE_VERSION = 99 + DATASTORE_VERSION = 106 valid_topic = [ "^openWB/bat/config/bat_control_permitted$", @@ -143,7 +143,6 @@ class UpdateConfig: "^openWB/chargepoint/[0-9]+/get/connected_vehicle/config$", "^openWB/chargepoint/[0-9]+/get/rfid$", "^openWB/chargepoint/[0-9]+/get/rfid_timestamp$", - "^openWB/chargepoint/[0-9]+/set/charging_ev$", "^openWB/chargepoint/[0-9]+/set/charge_template$", "^openWB/chargepoint/[0-9]+/set/current$", "^openWB/chargepoint/[0-9]+/set/energy_to_charge$", @@ -154,7 +153,6 @@ class UpdateConfig: "^openWB/chargepoint/[0-9]+/set/rfid$", "^openWB/chargepoint/[0-9]+/set/log$", "^openWB/chargepoint/[0-9]+/set/phases_to_use$", - "^openWB/chargepoint/[0-9]+/set/charging_ev_prev$", "^openWB/chargepoint/[0-9]+/set/ocpp_transaction_id$", "^openWB/chargepoint/[0-9]+/set/ocpp_transaction_active$", @@ -230,14 +228,15 @@ class UpdateConfig: "^openWB/general/chargemode_config/pv_charging/switch_on_delay$", "^openWB/general/chargemode_config/pv_charging/switch_off_threshold$", "^openWB/general/chargemode_config/pv_charging/switch_off_delay$", - "^openWB/general/chargemode_config/phase_switch_delay$", + "^openWB/general/chargemode_config/pv_charging/phase_switch_delay$", "^openWB/general/chargemode_config/pv_charging/control_range$", "^openWB/general/chargemode_config/pv_charging/min_bat_soc$", + "^openWB/general/chargemode_config/pv_charging/max_bat_soc$", "^openWB/general/chargemode_config/pv_charging/bat_power_discharge$", "^openWB/general/chargemode_config/pv_charging/bat_power_discharge_active$", "^openWB/general/chargemode_config/pv_charging/bat_power_reserve$", "^openWB/general/chargemode_config/pv_charging/bat_power_reserve_active$", - "^openWB/general/chargemode_config/retry_failed_phase_switches$", + "^openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches$", # obsolet, Daten hieraus müssen nach prices/ überführt werden "^openWB/general/price_kwh$", "^openWB/general/prices/bat$", @@ -283,10 +282,6 @@ class UpdateConfig: "^openWB/internal_chargepoint/[0-1]/get/rfid$", "^openWB/internal_chargepoint/[0-1]/get/rfid_timestamp$", - "^openWB/io/states/[0-9]+/get/digital_input$", - "^openWB/io/states/[0-9]+/get/analog_input$", - "^openWB/io/states/[0-9]+/set/digital_output$", - "^openWB/io/states/[0-9]+/set/analog_output$", "^openWB/io/action/[0-9]+/config$", "^openWB/io/action/[0-9]+/timestamp$", @@ -313,10 +308,14 @@ class UpdateConfig: "^openWB/set/log/request", "^openWB/set/log/data", - "^openWB/optional/et/get/fault_state$", - "^openWB/optional/et/get/fault_str$", - "^openWB/optional/et/get/prices$", - "^openWB/optional/et/provider$", + "^openWB/optional/ep/flexible_tariff/get/fault_state$", + "^openWB/optional/ep/flexible_tariff/get/fault_str$", + "^openWB/optional/ep/flexible_tariff/get/prices$", + "^openWB/optional/ep/flexible_tariff/provider$", + "^openWB/optional/ep/grid_fee/get/fault_state$", + "^openWB/optional/ep/grid_fee/get/fault_str$", + "^openWB/optional/ep/grid_fee/get/prices$", + "^openWB/optional/ep/grid_fee/provider$", "^openWB/optional/int_display/active$", "^openWB/optional/int_display/detected$", "^openWB/optional/int_display/on_if_plugged_in$", @@ -470,10 +469,12 @@ class UpdateConfig: "^openWB/system/boot_done$", "^openWB/system/configurable/backup_clouds$", "^openWB/system/backup_cloud/backup_before_update$", + "^openWB/system/backup_password$", "^openWB/system/configurable/chargepoints$", "^openWB/system/configurable/chargepoints_internal$", "^openWB/system/configurable/devices_components$", - "^openWB/system/configurable/electricity_tariffs$", + "^openWB/system/configurable/flexible_tariffs$", + "^openWB/system/configurable/grid_fees$", "^openWB/system/configurable/display_themes$", "^openWB/system/configurable/io_actions$", "^openWB/system/configurable/io_devices$", @@ -536,6 +537,7 @@ class UpdateConfig: ("openWB/general/chargemode_config/pv_charging/bat_power_discharge", 1000), ("openWB/general/chargemode_config/pv_charging/bat_power_discharge_active", True), ("openWB/general/chargemode_config/pv_charging/min_bat_soc", 50), + ("openWB/general/chargemode_config/pv_charging/max_bat_soc", 70), ("openWB/general/chargemode_config/pv_charging/bat_power_reserve", 200), ("openWB/general/chargemode_config/pv_charging/bat_power_reserve_active", True), ("openWB/general/chargemode_config/pv_charging/control_range", [0, 230]), @@ -544,9 +546,9 @@ class UpdateConfig: ("openWB/general/chargemode_config/pv_charging/switch_on_delay", 30), ("openWB/general/chargemode_config/pv_charging/switch_on_threshold", 1500), ("openWB/general/chargemode_config/pv_charging/feed_in_yield", 0), - ("openWB/general/chargemode_config/phase_switch_delay", 7), - ("openWB/general/chargemode_config/retry_failed_phase_switches", - ChargemodeConfig().retry_failed_phase_switches), + ("openWB/general/chargemode_config/pv_charging/phase_switch_delay", 7), + ("openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches", + PvCharging().retry_failed_phase_switches), ("openWB/general/chargemode_config/unbalanced_load", False), ("openWB/general/chargemode_config/unbalanced_load_limit", 18), ("openWB/general/control_interval", 10), @@ -567,11 +569,12 @@ class UpdateConfig: ("openWB/general/prices/pv", Prices().pv), ("openWB/general/range_unit", "km"), ("openWB/general/temporary_charge_templates_active", False), - ("openWB/general/web_theme", dataclass_utils.asdict(StandardLegacyWebTheme())), + ("openWB/general/web_theme", dataclass_utils.asdict(KoalaWebTheme())), ("openWB/graph/config/duration", 120), ("openWB/internal_chargepoint/0/data/parent_cp", None), ("openWB/internal_chargepoint/1/data/parent_cp", None), - ("openWB/optional/et/provider", NO_MODULE), + ("openWB/optional/ep/flexible_tariff/provider", NO_MODULE), + ("openWB/optional/ep/grid_fee/provider", NO_MODULE), ("openWB/optional/int_display/active", True), ("openWB/optional/int_display/detected", True), ("openWB/optional/int_display/on_if_plugged_in", True), @@ -585,11 +588,14 @@ class UpdateConfig: ("openWB/optional/monitoring/config", NO_MODULE), ("openWB/optional/ocpp/config", dataclass_utils.asdict(Ocpp())), ("openWB/optional/rfid/active", False), + ("openWB/system/backup_password", None), ("openWB/system/backup_cloud/config", NO_MODULE), ("openWB/system/backup_cloud/backup_before_update", True), + ("openWB/system/current_branch", None), + ("openWB/system/current_commit", None), ("openWB/system/installAssistantDone", False), ("openWB/system/dataprotection_acknowledged", False), - ("openWB/system/datastore_version", DATASTORE_VERSION), + ("openWB/system/datastore_version", list(range(DATASTORE_VERSION))), ("openWB/system/usage_terms_acknowledged", False), ("openWB/system/debug_level", 30), ("openWB/system/device/module_update_completed", True), @@ -703,16 +709,20 @@ def __update_version(self): self.__update_topic("openWB/system/version", version) def __solve_breaking_changes(self) -> None: - """ solve breaking changes in the datastore - """ - datastore_version = (decode_payload(self.all_received_topics.get("openWB/system/datastore_version")) or - self.DATASTORE_VERSION) - log.debug(f"current datastore version: {datastore_version}") + """ datastore_version ist eine Liste mit allen durchgeführten datastore-Upgrades, damit bei Patch-Versionen + einzelne Upgrades übersprungen werden können und bei einem anschließenden Major-Upgrade alle fehlenden Upgrades + durchgeführt werden.""" + datastore_versions = decode_payload(self.all_received_topics.get("openWB/system/datastore_version")) + if datastore_versions is None or isinstance(datastore_versions, int): + datastore_versions = list(range(datastore_versions or self.DATASTORE_VERSION+1)) + self.__update_topic("openWB/system/datastore_version", datastore_versions) + log.debug(f"current datastore version: {datastore_versions}") log.debug(f"target datastore version: {self.DATASTORE_VERSION}") - for version in range(datastore_version, self.DATASTORE_VERSION): + for version in list(range(self.DATASTORE_VERSION+1)): try: - log.debug(f"upgrading datastore from version '{version}' to '{version + 1}'") - getattr(self, f"upgrade_datastore_{version}")() + if version not in datastore_versions: + log.debug(f"upgrading datastore version '{version}'") + getattr(self, f"upgrade_datastore_{version}")() except AttributeError: log.error(f"missing upgrade function! '{version}'") except Exception: @@ -732,6 +742,12 @@ def _loop_all_received_topics(self, callback) -> None: for topic, payload in modified_topics.items(): self.__update_topic(topic, payload) + def _append_datastore_version(self, version: int) -> None: + datastore_versions = decode_payload(self.all_received_topics.get("openWB/system/datastore_version")) + if version not in datastore_versions: + datastore_versions.append(version) + self.__update_topic("openWB/system/datastore_version", datastore_versions) + def upgrade_datastore_0(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: modified_topics = {} @@ -829,7 +845,7 @@ def upgrade_logs() -> None: self._loop_all_received_topics(upgrade) upgrade_logs() - self.__update_topic("openWB/system/datastore_version", 1) + self._append_datastore_version(0) def upgrade_datastore_1(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -856,7 +872,7 @@ def get_decoded_value(name: str) -> float: modified_topics[f"{simulation_topic}/present_exported"] = "" return modified_topics self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 2) + self._append_datastore_version(1) def upgrade_datastore_2(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -871,7 +887,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload["limit"].pop("soc") return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 3) + self._append_datastore_version(2) def upgrade_datastore_3(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -884,14 +900,14 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload["limit"] = {"selected": "soc", "amount": 1000, "soc": 70} return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 4) + self._append_datastore_version(3) def upgrade_datastore_4(self) -> None: moved_file = False for path in Path("/etc/mosquitto/conf.d").glob('99-bridge-openwb-*.conf'): run_command(["sudo", "mv", str(path), str(path).replace("conf.d", "conf_local.d")], process_exception=True) moved_file = True - self.__update_topic("openWB/system/datastore_version", 5) + self._append_datastore_version(4) if moved_file: time.sleep(1) run_command([str(self.base_path / "runs" / "reboot.sh")], process_exception=True) @@ -906,7 +922,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload["max_current_multi_phases"] = 32 return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 6) + self._append_datastore_version(5) def upgrade_datastore_6(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -916,7 +932,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["autolock"].pop("plans") return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 7) + self._append_datastore_version(6) def upgrade_datastore_7(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -926,7 +942,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["keep_charge_active_duration"] = EvTemplateData().keep_charge_active_duration return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 8) + self._append_datastore_version(7) def upgrade_datastore_8(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -942,7 +958,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload.pop("power_module") return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 9) + self._append_datastore_version(8) def upgrade_datastore_9(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -953,7 +969,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: log.debug("cloud bridge configuration upgraded") return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 10) + self._append_datastore_version(9) def upgrade_datastore_10(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -964,7 +980,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload["average_consump"] = payload["average_consump"] * 1000 return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 11) + self._append_datastore_version(10) def upgrade_datastore_11(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -975,7 +991,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["configuration"].update({"duo_num": 0}) return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 12) + self._append_datastore_version(11) def upgrade_datastore_12(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -985,7 +1001,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {f"openWB/set/vehicle/{index}/soc_module/interval_config": dataclass_utils.asdict(GeneralVehicleConfig())} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 13) + self._append_datastore_version(12) def upgrade_datastore_13(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -996,7 +1012,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload["configuration"]["duo_num"] = payload["configuration"]["duo_num"] - 1 return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 14) + self._append_datastore_version(13) def upgrade_datastore_14(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1007,7 +1023,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["configuration"].pop("ip_adress") return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 15) + self._append_datastore_version(14) def upgrade_datastore_15(self) -> None: files = glob.glob(str(self.base_path / "data" / "daily_log") + "/*") @@ -1038,7 +1054,7 @@ def upgrade_datastore_15(self) -> None: log.debug(f"Format der Logdatei '{file}' aktualisiert.") except Exception: log.exception(f"Logdatei '{file}' konnte nicht konvertiert werden.") - self.__update_topic("openWB/system/datastore_version", 16) + self._append_datastore_version(15) def upgrade_datastore_16(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1059,7 +1075,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: modified_topics[topic_component] = payload_component return modified_topics self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 17) + self._append_datastore_version(16) def upgrade_datastore_17(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1080,7 +1096,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: modified_topics[topic_component] = payload_inverter return modified_topics self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 18) + self._append_datastore_version(17) def upgrade_datastore_18(self) -> None: def convert_file(file): @@ -1103,7 +1119,7 @@ def convert_file(file): log.exception(f"Logdatei '{file}' konnte nicht konvertiert werden.") convert_file(f"{str(self.base_path / 'data' / 'daily_log')}/{timecheck.create_timestamp_YYYYMMDD()}.json") convert_file(f"{str(self.base_path / 'data' / 'monthly_log')}/{timecheck.create_timestamp_YYYYMM()}.json") - self.__update_topic("openWB/system/datastore_version", 19) + self._append_datastore_version(18) def upgrade_datastore_19(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1118,7 +1134,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: else: return {topic: None} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 20) + self._append_datastore_version(19) def upgrade_datastore_20(self) -> None: max_c_socket = get_hardware_configuration_setting("max_c_socket") @@ -1126,7 +1142,7 @@ def upgrade_datastore_20(self) -> None: update_hardware_configuration({"max_c_socket": int(max_c_socket)}) elif max_c_socket is None: update_hardware_configuration({"max_c_socket": 32}) - self.__update_topic("openWB/system/datastore_version", 21) + self._append_datastore_version(20) def upgrade_datastore_21(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1151,7 +1167,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: topic: config_payload } self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 22) + self._append_datastore_version(21) def upgrade_datastore_22(self) -> None: files = glob.glob(str(self.base_path / "data" / "charge_log") + "/*") @@ -1172,7 +1188,7 @@ def upgrade_datastore_22(self) -> None: log.debug(f"Format des Ladeprotokolls '{file}' aktualisiert.") except Exception: log.exception(f"Ladeprotokoll '{file}' konnte nicht aktualisiert werden.") - self.__update_topic("openWB/system/datastore_version", 23) + self._append_datastore_version(22) def upgrade_datastore_23(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1186,7 +1202,7 @@ def upgrade(topic: str, payload) -> None: f"'{bridge_configuration['name']}' ({index})") pub_system_message(payload, result, MessageType.SUCCESS) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 24) + self._append_datastore_version(23) def upgrade_datastore_24(self) -> None: # Wenn mehrere EV eine Fahrzeug-Vorlage nutzen, wird die Effizienz des letzten für alle in der Vorlage gesetzt. @@ -1210,7 +1226,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: modified_topics[ev_template_topic] = ev_template return modified_topics self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 25) + self._append_datastore_version(24) def upgrade_datastore_25(self) -> None: files = glob.glob(str(self.base_path / "data" / "charge_log") + "/*") @@ -1229,7 +1245,7 @@ def upgrade_datastore_25(self) -> None: log.debug(f"Format des Ladeprotokolls '{file}' aktualisiert.") except Exception: log.exception(f"Ladeprotokoll '{file}' konnte nicht aktualisiert werden.") - self.__update_topic("openWB/system/datastore_version", 26) + self._append_datastore_version(25) def upgrade_datastore_26(self) -> None: # module kostal_pico_old: rename "ip_address" in configuration to "url" as we need a complete url @@ -1246,7 +1262,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: f"http://{configuration_payload['configuration']['url']}") return {topic: configuration_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 27) + self._append_datastore_version(26) def upgrade_datastore_27(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1263,7 +1279,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: configuration_payload.update({"official": True}) return {topic: configuration_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 28) + self._append_datastore_version(27) def upgrade_datastore_28(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1273,12 +1289,12 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload.pop("request_start_soc") return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 29) + self._append_datastore_version(28) def upgrade_datastore_29(self) -> None: """ moved to upgrade_datastore_32 """ - self.__update_topic("openWB/system/datastore_version", 30) + self._append_datastore_version(29) def upgrade_datastore_30(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1289,7 +1305,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["request_interval_not_charging"] = payload["request_interval_not_charging"]*60 return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 31) + self._append_datastore_version(30) def upgrade_datastore_31(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1299,7 +1315,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload = datetime.datetime.strptime(payload, "%m/%d/%Y, %H:%M:%S").timestamp() return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 32) + self._append_datastore_version(31) def upgrade_datastore_32(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1317,7 +1333,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: "openWB/set/general/prices/pv": price } self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 33) + self._append_datastore_version(32) def upgrade_datastore_33(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1330,7 +1346,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload = payload/1000 # €/kWh -> €/Wh return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 34) + self._append_datastore_version(33) def upgrade_datastore_34(self) -> None: def convert_file(file): @@ -1364,7 +1380,7 @@ def convert_file(file): convert_file(file) # next upgrade only fixes a bug introduced in an earlier version of this method # so we can skip upgrade_datastore_35() if this fixed version has run - self.__update_topic("openWB/system/datastore_version", 36) + self._append_datastore_version(35) def upgrade_datastore_35(self) -> None: def convert_file(file): @@ -1390,13 +1406,13 @@ def convert_file(file): files.sort() for file in files: convert_file(file) - self.__update_topic("openWB/system/datastore_version", 36) + self._append_datastore_version(35) # def upgrade_datastore_36(self) -> None: # if hardware_configuration.get_hardware_configuration_setting("ripple_control_receiver_configured", False): # Pub().pub("openWB/set/general/ripple_control_receiver/module", dataclass_utils.asdict(GpioRcr())) # hardware_configuration.remove_setting_hardware_configuration("ripple_control_receiver_configured") - # self.__update_topic("openWB/system/datastore_version", 37) + # self._append_datastore_version(36) def upgrade_datastore_37(self) -> None: def collect_names(topic: str, payload) -> None: @@ -1441,7 +1457,7 @@ def convert_file(file): files.sort() for file in files: convert_file(file) - self.__update_topic("openWB/system/datastore_version", 38) + self._append_datastore_version(37) def upgrade_datastore_38(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1454,7 +1470,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload.update({"timestamp_start_charging": converted_timestamp}) return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 39) + self._append_datastore_version(38) def upgrade_datastore_39(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1545,7 +1561,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: elif "openWB/general/chargemode_config/pv_charging/charging_power_reserve" == topic: return {"openWB/general/chargemode_config/pv_charging/bat_power_reserve": decode_payload(payload)} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 42) + self._append_datastore_version(41) def upgrade_datastore_42(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1556,7 +1572,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {"openWB/general/chargemode_config/pv_charging/bat_power_reserve_active": decode_payload( payload) > 0} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 43) + self._append_datastore_version(42) def upgrade_datastore_43(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1648,7 +1664,7 @@ def upgrade_datastore_44(self) -> None: log.exception(f"Logdatei '{filepath}' konnte nicht konvertiert werden.") except Exception: log.exception("Fehler beim Konvertieren der Logdateien") - self.__update_topic("openWB/system/datastore_version", 45) + self._append_datastore_version(44) def upgrade_datastore_45(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1658,7 +1674,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: "openWB/general/chargemode_config/phase_switch_delay": delay, } self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 46) + self._append_datastore_version(45) def upgrade_datastore_46(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1676,7 +1692,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload.pop("rfid_enabling") return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 47) + self._append_datastore_version(46) def upgrade_datastore_47(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1687,7 +1703,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload.update({"disable_after_unplug": False}) return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 48) + self._append_datastore_version(47) def upgrade_datastore_48(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1699,7 +1715,7 @@ def upgrade(topic: str, payload) -> None: payload["configuration"].update({"version": GoodWeVersion.V_1_7}) Pub().pub(topic, payload) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 49) + self._append_datastore_version(48) def upgrade_datastore_49(self) -> None: Pub().pub("openWB/system/installAssistantDone", True) @@ -1718,7 +1734,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: else: return {topic: ""} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 51) + self._append_datastore_version(50) def upgrade_datastore_51(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1729,11 +1745,11 @@ def upgrade(topic: str, payload) -> None: payload["configuration"].pop("device_type") Pub().pub(topic, payload) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 52) + self._append_datastore_version(51) def upgrade_datastore_52(self) -> None: # PR reverted - self.__update_topic("openWB/system/datastore_version", 53) + self._append_datastore_version(52) def upgrade_datastore_53(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1754,7 +1770,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: }) return {topic: configuration_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 54) + self._append_datastore_version(53) def upgrade_datastore_54(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1762,7 +1778,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload = decode_payload(payload) return {"openWB/counter/config/consider_less_charging": payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 55) + self._append_datastore_version(54) def upgrade_datastore_55(self) -> None: if hardware_configuration.exists_hardware_configuration_setting("dc_charging") is False: @@ -1776,7 +1792,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: updated_payload["charging_type"] = ChargingType.AC.value return {topic: updated_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 56) + self._append_datastore_version(55) def upgrade_datastore_56(self) -> None: # es gibt noch Topics von Komponenten gelöschter Geräte @@ -1800,7 +1816,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: log.debug(f"Entferne Topic von gelöschter Komponente {topic}") return {topic: ""} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 57) + self._append_datastore_version(56) def upgrade_datastore_57(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1812,7 +1828,7 @@ def upgrade(topic: str, payload) -> None: payload["configuration"].update({"factor": 1}) Pub().pub(topic, payload) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 58) + self._append_datastore_version(57) def upgrade_datastore_58(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1828,7 +1844,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: config_payload.update({"info": {"manufacturer": None, "model": None}}) return {topic: config_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 59) + self._append_datastore_version(58) def upgrade_datastore_59(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1867,7 +1883,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: log.debug(f"Device configuration: {device_config}") return {topic: device_config} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 60) + self._append_datastore_version(59) def upgrade_datastore_60(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1881,7 +1897,7 @@ def upgrade(topic: str, payload) -> None: payload["configuration"].update({"factor": -1}) Pub().pub(topic, payload) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 61) + self._append_datastore_version(60) def upgrade_datastore_61(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1891,14 +1907,14 @@ def upgrade(topic: str, payload) -> Optional[dict]: max_power_errorcase = get_counter_default_config()["max_power_errorcase"] return {f"openWB/counter/{index}/config/max_power_errorcase": max_power_errorcase} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 62) + self._append_datastore_version(61) def upgrade_datastore_62(self) -> None: pub_system_message( {}, "Bei einem Zählerausfall werden nun 7kW für diesen Zähler freigegeben. Bisher wurde im " "Fehlerfall die Ladung gestoppt. Du kannst die maximale Leistung im Fehlerfall für jeden Zähler" " unter Einstellungen -> Konfiguration -> Lastmanagement anpassen.", MessageType.WARNING) - self.__update_topic("openWB/system/datastore_version", 63) + self._append_datastore_version(62) def upgrade_datastore_63(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1907,18 +1923,18 @@ def upgrade(topic: str, payload) -> Optional[dict]: if f"openWB/bat/{index}/get/power_limit_controllable" not in self.all_received_topics.keys(): return {f"openWB/bat/{index}/get/power_limit_controllable": False} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 64) + self._append_datastore_version(63) def upgrade_datastore_64(self) -> None: pub_system_message( {}, 'Garantieverlängerung für die openWB verfügbar -> ' 'https://wb-solution.de/shop/', MessageType.INFO) - self.__update_topic("openWB/system/datastore_version", 65) + self._append_datastore_version(64) def upgrade_datastore_65(self) -> None: # sungrow version fixed in upgrade_datastore_71 - self.__update_topic("openWB/system/datastore_version", 66) + self._append_datastore_version(65) def upgrade_datastore_66(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1929,7 +1945,7 @@ def upgrade(topic: str, payload) -> None: payload["configuration"].update({"type": "s_dongle"}) Pub().pub(topic, payload) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 67) + self._append_datastore_version(66) def upgrade_datastore_67(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1937,7 +1953,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: if decode_payload(payload) < 5: return {"openWB/general/chargemode_config/phase_switch_delay": 5} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 68) + self._append_datastore_version(67) def upgrade_datastore_68(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1953,7 +1969,7 @@ def upgrade(topic: str, payload) -> None: config_payload.update({"info": {"manufacturer": None, "model": None}}) return {component_topic: config_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 69) + self._append_datastore_version(68) def upgrade_datastore_69(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -1965,7 +1981,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["id"] = int(get_second_index(topic)) return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 70) + self._append_datastore_version(69) def upgrade_datastore_70(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1976,7 +1992,7 @@ def upgrade(topic: str, payload) -> None: payload = NO_MODULE return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 71) + self._append_datastore_version(70) def upgrade_datastore_71(self) -> None: def upgrade(topic: str, payload) -> None: @@ -1992,7 +2008,7 @@ def upgrade(topic: str, payload) -> None: payload["configuration"]["firmware"] = "v2" return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 72) + self._append_datastore_version(71) def upgrade_datastore_72(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2003,7 +2019,7 @@ def upgrade(topic: str, payload) -> None: payload = NO_MODULE return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 73) + self._append_datastore_version(72) def upgrade_datastore_73(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2014,7 +2030,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: config_payload.update({"info": {"manufacturer": None, "model": None}}) return {topic: config_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 74) + self._append_datastore_version(73) def upgrade_datastore_74(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2026,7 +2042,7 @@ def upgrade(topic: str, payload) -> None: payload["configuration"].update({"version": "g3"}) return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 75) + self._append_datastore_version(74) def upgrade_datastore_75(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2069,14 +2085,14 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {'openWB/system/io/0/config': dataclass_utils.asdict(io_device), 'openWB/io/action/0/config': dataclass_utils.asdict(action)} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 76) + self._append_datastore_version(75) def upgrade_datastore_76(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: if re.search("openWB/chargepoint/[0-9]+/control_parameter/limit", topic) is not None: return {topic: dataclass_utils.asdict(LoadmanagementLimit(None, None))} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 77) + self._append_datastore_version(76) def upgrade_datastore_77(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2087,7 +2103,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: configuration_payload.update({"official": True}) return {topic: configuration_payload} # add "official" flag to selected electricity tariff provider - if re.search("openWB/optional/et/provider", topic) is not None: + if re.search("openWB/optional/ep/flexible_tariff/provider", topic) is not None: configuration_payload = decode_payload(payload) official_providers = ["awattar", "energycharts", "rabot", "tibber", "voltego"] if configuration_payload.get("type") in official_providers: @@ -2107,7 +2123,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: configuration_payload.update({"official": True}) return {topic: configuration_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 78) + self._append_datastore_version(77) def upgrade_datastore_78(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2122,7 +2138,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: # Nachricht nur einmal senden break self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 79) + self._append_datastore_version(78) def upgrade_datastore_79(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2139,7 +2155,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {(f"openWB/system/device/{device_config['id']}/component/" f"{component_config['id']}/simulation"): ""} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 80) + self._append_datastore_version(79) # moved and corrected to 87 @@ -2159,7 +2175,7 @@ def upgrade(topic: str, payload) -> None: decode_payload(template_payload)}) return topics self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 82) + self._append_datastore_version(81) def upgrade_datastore_82(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2233,7 +2249,7 @@ def get_new_phases_to_use(topic) -> int: charge_template.pop("et") return topics self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 83) + self._append_datastore_version(82) def upgrade_datastore_83(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2252,7 +2268,7 @@ def upgrade(topic: str, payload) -> None: }) return {component_topic: config_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 84) + self._append_datastore_version(83) def upgrade_datastore_84(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2307,7 +2323,7 @@ def cp_upgrade(topic: str, payload) -> Optional[dict]: return {f'openWB/chargepoint/{index}/set/charge_template': charge_template} self._loop_all_received_topics(upgrade) self._loop_all_received_topics(cp_upgrade) - self.__update_topic("openWB/system/datastore_version", 85) + self._append_datastore_version(84) def upgrade_datastore_85(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2326,7 +2342,7 @@ def upgrade(topic: str, payload) -> None: }) return {component_topic: config_payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 86) + self._append_datastore_version(85) def upgrade_datastore_86(self) -> None: if "openWB/bat/get/power_limit_controllable" not in self.all_received_topics: @@ -2338,7 +2354,7 @@ def upgrade_datastore_86(self) -> None: "rechtlichen Hinweise " "für die Speichersteuerung. Die Speichersteuerung war bisher bereits verfügbar, ist" " jedoch bis zum Akzeptieren standardmäßig deaktiviert.", MessageType.WARNING) - self.__update_topic("openWB/system/datastore_version", 87) + self._append_datastore_version(86) def upgrade_datastore_87(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2349,12 +2365,12 @@ def upgrade(topic: str, payload) -> None: payload.update({"id": index}) return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 88) + self._append_datastore_version(87) def upgrade_datastore_88(self) -> None: pub_system_message({}, "Es gibt ein neues Theme: das Koala-Theme! Smarthpone-optimiert und mit " "Energiefluss-Diagramm & Karten-Ansicht der Ladepunkte", MessageType.INFO) - self.__update_topic("openWB/system/datastore_version", 89) + self._append_datastore_version(88) def upgrade_datastore_89(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2376,7 +2392,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: log.debug(f"Updated IO action configuration: {topic}: {payload}") return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 90) + self._append_datastore_version(89) def upgrade_datastore_90(self) -> None: def upgrade(topic: str, payload) -> None: @@ -2401,7 +2417,7 @@ def upgrade(topic: str, payload) -> None: no_json=True) time.sleep(2) self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 91) + self._append_datastore_version(90) def upgrade_datastore_91(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2417,7 +2433,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: plan.update({"bidi": False, "bidi_power": 10000}) return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 92) + self._append_datastore_version(91) def upgrade_datastore_92(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2430,7 +2446,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: plan.update({"bidi_charging_enabled": bidi_charging_enabled}) return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 93) + self._append_datastore_version(92) def upgrade_datastore_93(self) -> None: # Pläne die keinen plans Key haben, id=None @@ -2517,7 +2533,7 @@ def upgrade_datastore_93(self) -> None: modified_topics[f"openWB/chargepoint/template/{get_index(topic)}"] = payload for topic, payload in modified_topics.items(): self.__update_topic(topic, payload) - self.__update_topic("openWB/system/datastore_version", 94) + self._append_datastore_version(93) def upgrade_datastore_94(self): def upgrade(topic, payload): @@ -2540,7 +2556,7 @@ def upgrade(topic, payload): plan["id"] = max_id return {topic: payload, "openWB/command/max_id/charge_template_scheduled_plan": max_id} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 95) + self._append_datastore_version(94) def upgrade_datastore_95(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: @@ -2558,7 +2574,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: payload["id"] = topic_index return {topic: payload} self._loop_all_received_topics(upgrade) - self.__update_topic("openWB/system/datastore_version", 96) + self._append_datastore_version(95) def upgrade_datastore_98(self) -> None: version_str = decode_payload( @@ -2576,4 +2592,100 @@ def upgrade_datastore_98(self) -> None: " -> Darstellung & Bedienung angewendet werden.", MessageType.INFO, ) - self.__update_topic("openWB/system/datastore_version", 99) + self._append_datastore_version(98) + + def upgrade_datastore_99(self) -> None: + # bei Aktualisierung den max_bat_soc auf min_bat_soc setzen + # Regelung verhält sich dadurch wie bisher konfiguriert + # max_bat_soc kann nicht kleiner als min_bat_soc werden + min_bat_soc = decode_payload(self.all_received_topics[ + "openWB/general/chargemode_config/pv_charging/min_bat_soc"]) + + self.__update_topic("openWB/general/chargemode_config/pv_charging/max_bat_soc", min_bat_soc) + self._append_datastore_version(99) + + def upgrade_datastore_100(self) -> None: + def upgrade(topic: str, payload) -> Optional[dict]: + if "openWB/general/chargemode_config/retry_failed_phase_switches" == topic: + return {"openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches": + decode_payload(payload)} + if "openWB/general/chargemode_config/phase_switch_delay" == topic: + return {"openWB/general/chargemode_config/pv_charging/phase_switch_delay": + decode_payload(payload)} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(100) + + def upgrade_datastore_101(self) -> None: + def upgrade(topic: str, payload) -> None: + if re.search("openWB/system/device/[0-9]+/config$", topic) is not None: + payload = decode_payload(payload) + # add phase + if payload.get("type") == "shelly" and "phase" not in payload["configuration"]: + payload["configuration"].update({"phase": 1}) + Pub().pub(topic, payload) + self._loop_all_received_topics(upgrade) + self._append_datastore_version(101) + + def upgrade_datastore_102(self) -> None: + def upgrade(topic: str, payload) -> None: + if "openWB/optional/et/provider" == topic: + return {"openWB/optional/ep/flexible_tariff/provider": decode_payload(payload)} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(102) + + def upgrade_datastore_103(self) -> None: + def upgrade(topic: str, payload) -> None: + if re.search("openWB/system/device/[0-9]+", topic) is not None: + payload = decode_payload(payload) + index = get_index(topic) + if payload.get("type") == "victron": + for component_topic, component_payload in self.all_received_topics.items(): + if re.search(f"openWB/system/device/{index}/component/[0-9]+/config$", + component_topic) is not None: + config_payload = decode_payload(component_payload) + if (config_payload["type"] == "bat" and + config_payload["configuration"].get("vebus_id") is None): + config_payload["configuration"].update({ + "vebus_id": 228, + }) + return {component_topic: config_payload} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(103) + + def upgrade_datastore_104(self) -> None: + def upgrade(topic: str, payload) -> None: + if "openWB/optional/ep/flexible_tariff/provider" == topic: + provider = decode_payload(payload) + if provider["type"] == "awattar": + if provider["configuration"].get("net") is None: + provider["configuration"]["net"] = False + provider["configuration"]["fix"] = 0.015 + provider["configuration"]["proportional"] = 0.03 + provider["configuration"]["tax"] = 0.2 + return {topic: provider} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(104) + + def upgrade_datastore_105(self) -> None: + def upgrade(topic: str, payload) -> None: + if "openWB/general/charge_log_data_config" == topic: + config = decode_payload(payload) + if config.get("data_exported_since_mode_switch") is None: + config["data_exported_since_mode_switch"] = False + if config.get("chargepoint_exported_at_start") is None: + config["chargepoint_exported_at_start"] = False + if config.get("chargepoint_exported_at_end") is None: + config["chargepoint_exported_at_end"] = False + return {topic: config} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(105) + + def upgrade_datastore_106(self) -> None: + def upgrade(topic: str, payload) -> None: + if re.search("openWB/vehicle/[0-9]+/soc_module/config", topic) is not None: + config = decode_payload(payload) + if config.get("type") == "http" or config.get("type") == "mqtt": + config["configuration"]["calculate_soc"] = False + return {topic: config} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(106) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 3b2addd873..7101720081 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -45,6 +45,7 @@ def test_upgrade_datastore_94(index_test_template, expected_index): with open(Path(__file__).resolve().parents[0]/"upgrade_datastore_94.json", "r") as f: test_data = f.read() update_con.all_received_topics.update(json.loads(test_data)[index_test_template]) + update_con.all_received_topics["openWB/system/datastore_version"] = list(range(93)) update_con.upgrade_datastore_94() diff --git a/packages/main.py b/packages/main.py index 2fb8b4038b..1650b10fb8 100755 --- a/packages/main.py +++ b/packages/main.py @@ -19,7 +19,7 @@ import time from threading import Event, Thread, enumerate import traceback -from control.chargelog.chargelog import calculate_charge_cost +from control.chargelog.chargelog import calc_energy_costs, calculate_charged_energy_by_source from control import data, prepare, process from control.algorithm import algorithm @@ -182,12 +182,17 @@ def handler5MinAlgorithm(self): totals = save_log(LogType.DAILY) update_daily_yields(totals) update_pv_monthly_yearly_yields() + for cp in data.data.cp_data.values(): + calc_energy_costs(cp) data.data.general_data.grid_protection() data.data.optional_data.ocpp_transfer_meter_values() data.data.counter_all_data.validate_hierarchy() + data.data.optional_data.remove_outdated_prices() + loadvars_.ep_get_prices() except Exception: log.exception("Fehler im Main-Modul") + @__with_handler_lock(error_threshold=60) def handler5Min(self): """ Handler, der alle 5 Minuten aufgerufen wird und die Heartbeats der Threads überprüft und die Aufgaben @@ -248,10 +253,6 @@ def handler_hour(self): """ Handler, der jede Stunde aufgerufen wird und die Aufgaben ausführt, die nur jede Stunde ausgeführt werden müssen. """ try: - with ChangedValuesContext(loadvars_.event_module_update_completed): - for cp in data.data.cp_data.values(): - calculate_charge_cost(cp) - data.data.optional_data.et_get_prices() logger.clear_in_memory_log_handler(None) except Exception: log.exception("Fehler im Main-Modul") diff --git a/packages/modules/backup_clouds/samba/backup_cloud.py b/packages/modules/backup_clouds/samba/backup_cloud.py index 398c5d01f6..49b1145343 100644 --- a/packages/modules/backup_clouds/samba/backup_cloud.py +++ b/packages/modules/backup_clouds/samba/backup_cloud.py @@ -29,31 +29,75 @@ def is_port_open(host: str, port: int): def upload_backup(config: SambaBackupCloudConfiguration, backup_filename: str, backup_file: bytes) -> None: - conn = SMBConnection(config.smb_user, config.smb_password, os.uname()[1], config.smb_server, use_ntlm_v2=True) - found_invalid_chars = re.search(r'[\\\:\*\?\"\<\>\|]+', config.smb_path) - host_is_reachable = is_port_open(config.smb_server, 139) - - if found_invalid_chars: - log.warning("Folgenden ungültige Zeichen im Pfad gefunden: {}".format(found_invalid_chars.group())) - log.warning("Sicherung nicht erfolgreich.") - send_file = False - else: - send_file = True + SMB_PORT_445 = 445 + SMB_PORT_139 = 139 + + # Pfad prüfen + if re.search(r'[\\\:\*\?\"\<\>\|]+', config.smb_path): + raise Exception("Ungültige Zeichen im Pfad. Sicherung nicht erfolgreich.") + + # ------------------------------------------------------------ + # 1) SMB2/3 über Port 445 testen + # ------------------------------------------------------------ + if is_port_open(config.smb_server, SMB_PORT_445): + conn = SMBConnection( + config.smb_user, + config.smb_password, + os.uname()[1], + config.smb_server, + use_ntlm_v2=True, + is_direct_tcp=True + ) + + if conn.connect(config.smb_server, SMB_PORT_445): + try: + log.info("SMB-Verbindung über Port 445 erfolgreich.") + full_file_path = f"{config.smb_path.rstrip('/')}/{backup_filename}" + log.info(f"Backup nach //{config.smb_server}/{config.smb_share}/{full_file_path}") + + conn.storeFile(config.smb_share, full_file_path, io.BytesIO(backup_file)) + + return + except Exception as error: + raise Exception("Freigabe oder Unterordner existiert möglicherweise nicht. "+str(error).split('\n')[0]) + finally: + conn.close() + else: + raise Exception("SMB-Verbindungsaufbau über Port 445 nicht möglich.") - if host_is_reachable and conn.connect(config.smb_server, 139) and send_file: - log.info("SMB Verbindungsaufbau erfolgreich.") - full_file_path = config.smb_path + backup_filename if config.smb_path is not None else backup_filename - log.info("Backup nach //" + config.smb_server + '/' + config.smb_share + '/' + full_file_path) + # ------------------------------------------------------------ + # 2) Fallback: SMB1 über Port 139 + # ------------------------------------------------------------ + if not is_port_open(config.smb_server, SMB_PORT_139): + raise Exception( + f"Host {config.smb_server} und/oder Port {SMB_PORT_139} und {SMB_PORT_445} nicht erreichbar." + ) + + conn = SMBConnection( + config.smb_user, + config.smb_password, + os.uname()[1], + config.smb_server, + use_ntlm_v2=True + ) + + if conn.connect(config.smb_server, SMB_PORT_139): try: + log.info("SMB Verbindungsaufbau über Port 139 erfolgreich.") + full_file_path = f"{config.smb_path.rstrip('/')}/{backup_filename}" + log.info(f"Backup nach //{config.smb_server}/{config.smb_share}/{full_file_path}") + conn.storeFile(config.smb_share, full_file_path, io.BytesIO(backup_file)) except Exception as error: - log.error(error.__str__().split('\n')[0]) - log.error("Möglicherweise ist die Freigabe oder ein Unterordner nicht vorhanden.") - conn.close() - elif send_file: - log.warning("SMB Verbindungsaufbau fehlgeschlagen.") - elif not host_is_reachable: - log.warning("Host {} und/oder Port 139 nicht zu erreichen.".format(config.smb_server)) + raise Exception( + "Möglicherweise ist die Freigabe oder ein Unterordner nicht vorhanden." + + str(error).split("\n")[0] + ) + + finally: + conn.close() + else: + raise Exception("SMB Verbindungsaufbau über Port 139 fehlgeschlagen.") def create_backup_cloud(config: SambaBackupCloud): diff --git a/packages/modules/chargepoints/external_openwb/chargepoint_module.py b/packages/modules/chargepoints/external_openwb/chargepoint_module.py index 9ece0a86b5..ffda83d504 100644 --- a/packages/modules/chargepoints/external_openwb/chargepoint_module.py +++ b/packages/modules/chargepoints/external_openwb/chargepoint_module.py @@ -124,8 +124,17 @@ def on_message(client, userdata, message): "Daten nach dem Start oder Ladepunkt nicht erreichbar.") self.client_error_context.reset_error_counter() - - def switch_phases(self, phases_to_use: int, duration: int) -> None: + if self.client_error_context.error_counter_exceeded(): + chargepoint_state = ChargepointState(plug_state=None, + charge_state=False, + imported=None, + exported=None, + currents=[0]*3, + phases_in_use=0, + power=0) + self.store.set(chargepoint_state) + + def switch_phases(self, phases_to_use: int) -> None: with SingleComponentUpdateContext(self.fault_state, update_always=False): with self.client_error_context: pub.pub_single( @@ -138,7 +147,6 @@ def switch_phases(self, phases_to_use: int, duration: int) -> None: self.config.configuration.ip_address) pub.pub_single("openWB/set/isss/U1p3p", phases_to_use, self.config.configuration.ip_address) - time.sleep(6+duration-1) def interrupt_cp(self, duration: int) -> None: with SingleComponentUpdateContext(self.fault_state, update_always=False): diff --git a/packages/modules/chargepoints/mqtt/chargepoint_module.py b/packages/modules/chargepoints/mqtt/chargepoint_module.py index 8bbd7f0deb..92526a020a 100644 --- a/packages/modules/chargepoints/mqtt/chargepoint_module.py +++ b/packages/modules/chargepoints/mqtt/chargepoint_module.py @@ -98,8 +98,11 @@ def on_message(client, userdata, message): "veraltete, abwärtskompatible Topics verwendet. Bitte die Doku in den " "Einstellungen beachten.") - def switch_phases(self, phases_to_use: int, duration: int) -> None: + def switch_phases(self, phases_to_use: int) -> None: Pub().pub(f"openWB/mqtt/chargepoint/{self.config.id}/set/phases_to_use", phases_to_use) + def clear_rfid(self) -> None: + Pub().pub(f"openWB/mqtt/chargepoint/{self.config.id}/get/rfid", "") + chargepoint_descriptor = DeviceDescriptor(configuration_factory=Mqtt) diff --git a/packages/modules/chargepoints/openwb_dc_adapter/chargepoint_module.py b/packages/modules/chargepoints/openwb_dc_adapter/chargepoint_module.py index 9bb09a9fe3..583d606747 100644 --- a/packages/modules/chargepoints/openwb_dc_adapter/chargepoint_module.py +++ b/packages/modules/chargepoints/openwb_dc_adapter/chargepoint_module.py @@ -119,8 +119,17 @@ def get_values(self) -> None: json_rsp["state"] == ChargingStatus.FINISHING.value or json_rsp["state"] == ChargingStatus.UNAVAILABLE_CONN_OBJ.value): raise Exception(f"Ladepunkt nicht verfügbar. Status: {ChargingStatus(json_rsp['state'])}") - self.store.set(chargepoint_state) self.client_error_context.reset_error_counter() + if self.client_error_context.error_counter_exceeded(): + chargepoint_state = ChargepointState(plug_state=None, + charge_state=False, + imported=None, + exported=None, + currents=[0]*3, + phases_in_use=0, + power=0) + + self.store.set(chargepoint_state) chargepoint_descriptor = DeviceDescriptor(configuration_factory=OpenWBDcAdapter) diff --git a/packages/modules/chargepoints/openwb_pro/chargepoint_module.py b/packages/modules/chargepoints/openwb_pro/chargepoint_module.py index 5ae7fad614..9788437a70 100644 --- a/packages/modules/chargepoints/openwb_pro/chargepoint_module.py +++ b/packages/modules/chargepoints/openwb_pro/chargepoint_module.py @@ -1,6 +1,5 @@ import logging -import time from helpermodules.utils.error_handling import CP_ERROR, ErrorTimerContext from modules.chargepoints.openwb_pro.config import OpenWBPro @@ -24,6 +23,11 @@ class EvseSignaling: PWM = "PWM" +class CpInterruptionVersion: + CP_SIGNAL_0V = "0V" + CP_SIGNAL_minus12V = "-12V" + + class ChargepointModule(AbstractChargepoint): WRONG_CHARGE_STATE = "Lade-Status ist nicht aktiv, aber Strom fließt." WRONG_PLUG_STATE = "Ladepunkt ist nicht angesteckt, aber es wird geladen." @@ -40,7 +44,7 @@ def __init__(self, config: OpenWBPro) -> None: with SingleComponentUpdateContext(self.fault_state, update_always=False): self.__session.post( - 'http://' + self.config.configuration.ip_address + '/connect.php', + f'http://{self.config.configuration.ip_address}/connect.php', data={'heartbeatenabled': '1'}) def set_internal_context_handlers(self, hierarchy_id: int, internal_cp: InternalChargepoint): @@ -58,8 +62,8 @@ def set_current(self, current: float) -> None: current = 0 with SingleComponentUpdateContext(self.fault_state, update_always=False): with self.client_error_context: - ip_address = self.config.configuration.ip_address - self.__session.post('http://'+ip_address+'/connect.php', data={'ampere': current}) + self.__session.post( + f'http://{self.config.configuration.ip_address}/connect.php', data={'ampere': current}) def get_values(self) -> None: with SingleComponentUpdateContext(self.fault_state): @@ -70,16 +74,16 @@ def get_values(self) -> None: self.store.set(chargepoint_state) except Exception as e: if self.client_error_context.error_counter_exceeded(): - chargepoint_state = ChargepointState(plug_state=False, charge_state=False, imported=None, - # bei im-/exported None werden keine Werte gepublished - exported=None, phases_in_use=0, power=0, currents=[0]*3) + chargepoint_state = ChargepointState( + plug_state=None, charge_state=False, imported=None, + # bei im-/exported None werden keine Werte gepublished + exported=None, phases_in_use=0, power=0, currents=[0]*3) self.store.set(chargepoint_state) raise e def request_values(self) -> ChargepointState: with self.client_error_context: - ip_address = self.config.configuration.ip_address - json_rsp = self.__session.get('http://'+ip_address+'/connect.php').json() + json_rsp = self.__session.get(f'http://{self.config.configuration.ip_address}/connect.php').json() chargepoint_state = ChargepointState( power=json_rsp["power_all"], @@ -128,19 +132,22 @@ def validate_values(self, chargepoint_state: ChargepointState) -> None: if chargepoint_state.plug_state is False and chargepoint_state.power > 20: raise ValueError(self.WRONG_PLUG_STATE) - def switch_phases(self, phases_to_use: int, duration: int) -> None: + def switch_phases(self, phases_to_use: int) -> None: with SingleComponentUpdateContext(self.fault_state, update_always=False): with self.client_error_context: - ip_address = self.config.configuration.ip_address - response = self.__session.get('http://'+ip_address+'/connect.php') + response = self.__session.get(f'http://{self.config.configuration.ip_address}/connect.php') if response.json()["phases_target"] != phases_to_use: - ip_address = self.config.configuration.ip_address - self.__session.post('http://'+ip_address+'/connect.php', + self.__session.post(f'http://{self.config.configuration.ip_address}/connect.php', data={'phasetarget': str(1 if phases_to_use == 1 else 3)}) - time.sleep(duration) def clear_rfid(self) -> None: pass + def interrupt_cp(self, duration: int) -> None: + self.__session.post(f'http://{self.config.configuration.ip_address}/connect.php', + data={'cp_interrupt': True, + 'cp_interrupt_version': CpInterruptionVersion.CP_SIGNAL_0V, + 'cp_interrupt_duration': duration}) + chargepoint_descriptor = DeviceDescriptor(configuration_factory=OpenWBPro) diff --git a/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py b/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py index a004fe483b..3a6b63979d 100644 --- a/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py +++ b/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py @@ -42,6 +42,7 @@ def __init__(self, config: OpenWBseries2Satellit) -> None: f"openWB/set/chargepoint/{self.config.id}/get/error_timestamp", CP_ERROR, hide_exception=True) self._create_client() self._validate_version() + self.old_phases_in_use = 3 def delay_second_cp(self, delay: float): if self.config.configuration.duo_num == 0: @@ -82,12 +83,15 @@ def get_values(self) -> None: if self.version is False: self._validate_version() - currents = counter_state.currents - phases_in_use = sum(1 for current in currents if current > 3) + phases_in_use = sum(1 for current in counter_state.currents if current > 3) + if phases_in_use == 0: + phases_in_use = self.old_phases_in_use + else: + self.old_phases_in_use = phases_in_use chargepoint_state = ChargepointState( power=counter_state.power, - currents=currents, + currents=counter_state.currents, imported=counter_state.imported, exported=0, voltages=counter_state.voltages, @@ -95,7 +99,9 @@ def get_values(self) -> None: charge_state=evse_state.charge_state, phases_in_use=phases_in_use, serial_number=counter_state.serial_number, - max_evse_current=evse_state.max_current + max_evse_current=evse_state.max_current, + evse_current=evse_state.set_current + ) self.store.set(chargepoint_state) self.client_error_context.reset_error_counter() @@ -103,6 +109,11 @@ def get_values(self) -> None: if self.client_error_context.error_counter_exceeded(): run_command(f"{Path(__file__).resolve().parents[3]}/modules/chargepoints/" "openwb_series2_satellit/restart_protoss_satellite") + chargepoint_state = ChargepointState( + plug_state=None, charge_state=False, imported=None, + # bei im-/exported None werden keine Werte gepublished + exported=None, phases_in_use=self.old_phases_in_use, power=0, currents=[0]*3) + self.store.set(chargepoint_state) except AttributeError: self._create_client() self._validate_version() @@ -127,12 +138,14 @@ def set_current(self, current: float) -> None: self._create_client() self._validate_version() - def switch_phases(self, phases_to_use: int, duration: int) -> None: + def switch_phases(self, phases_to_use: int) -> None: if self.version is not None: with SingleComponentUpdateContext(self.fault_state, update_always=False): with self.client_error_context: try: with self._client.client: + self._client.evse_client.set_current(0) + time.sleep(5) if phases_to_use == 1: self._client.client.delegate.write_register( 0x0001, 256, unit=self.ID_PHASE_SWITCH_UNIT) diff --git a/packages/modules/chargepoints/smartwb/chargepoint_module.py b/packages/modules/chargepoints/smartwb/chargepoint_module.py index ac6f7b7b76..1fe9028660 100644 --- a/packages/modules/chargepoints/smartwb/chargepoint_module.py +++ b/packages/modules/chargepoints/smartwb/chargepoint_module.py @@ -90,8 +90,16 @@ def get_values(self) -> None: max_evse_current=max_evse_current ) - self.store.set(chargepoint_state) self.client_error_context.reset_error_counter() + if self.client_error_context.error_counter_exceeded(): + chargepoint_state = ChargepointState(plug_state=None, + charge_state=False, + imported=None, + exported=None, + currents=[0]*3, + phases_in_use=0, + power=0) + self.store.set(chargepoint_state) def clear_rfid(self) -> None: with SingleComponentUpdateContext(self.fault_state): diff --git a/packages/modules/common/abstract_chargepoint.py b/packages/modules/common/abstract_chargepoint.py index 540fe75cb7..71c560cf40 100644 --- a/packages/modules/common/abstract_chargepoint.py +++ b/packages/modules/common/abstract_chargepoint.py @@ -18,7 +18,7 @@ def get_values(self) -> None: pass @abstractmethod - def switch_phases(self, phases_to_use: int, duration: int) -> None: + def switch_phases(self, phases_to_use: int) -> None: pass @abstractmethod diff --git a/packages/modules/common/abstract_vehicle.py b/packages/modules/common/abstract_vehicle.py index 3772b4c63d..1e15f42877 100644 --- a/packages/modules/common/abstract_vehicle.py +++ b/packages/modules/common/abstract_vehicle.py @@ -5,13 +5,15 @@ @dataclass class VehicleUpdateData: plug_state: bool = False + plug_time: float = 0.0 charge_state: bool = False imported: float = 0 battery_capacity: float = 82 efficiency: float = 90 soc_from_cp: Optional[float] = None timestamp_soc_from_cp: Optional[int] = None - soc_timestamp: Optional[int] = None + last_soc_timestamp: Optional[int] = None + last_soc: float = None @dataclass @@ -24,6 +26,5 @@ class GeneralVehicleConfig: @dataclass class CalculatedSocState: - imported_start: Optional[float] = 0 # don't show in UI + last_imported: Optional[float] = 0 # don't show in UI manual_soc: Optional[int] = None # don't show in UI - soc_start: float = 0 # don't show in UI diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 7eb3ff5990..ce5b687d10 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -177,7 +177,7 @@ def __init__(self, power: float, currents: List[float], charge_state: bool, - plug_state: bool, + plug_state: Optional[bool], serial_number: str = "", charging_current: Optional[float] = 0, charging_voltage: Optional[float] = 0, @@ -232,7 +232,8 @@ def __init__(self, @auto_str class TariffState: def __init__(self, - prices: Optional[Dict[int, float]] = None) -> None: + prices: Optional[Dict[str, float]] = None + ) -> None: self.prices = prices diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py index d7a3b4b4bc..a759d3c533 100644 --- a/packages/modules/common/component_type.py +++ b/packages/modules/common/component_type.py @@ -7,7 +7,8 @@ class ComponentType(Enum): BAT = "bat" CHARGEPOINT = "cp" COUNTER = "counter" - ELECTRICITY_TARIFF = "electricity_tariff" + FLEXIBLE_TARIFF = "dynamic_tariff" + GRID_FEE = "grid_tariff" INVERTER = "inverter" IO = "io" @@ -32,8 +33,10 @@ def type_to_topic_mapping(component_type: str) -> str: return "counter" elif "inverter" in component_type: return "pv" - elif ComponentType.ELECTRICITY_TARIFF.value in component_type: - return "optional/et" + elif ComponentType.FLEXIBLE_TARIFF.value in component_type: + return "optional/ep/flexible_tariff" + elif ComponentType.GRID_FEE.value in component_type: + return "optional/ep/grid_fee" elif ComponentType.IO.value in component_type: return "io/states" else: diff --git a/packages/modules/common/configurable_device.py b/packages/modules/common/configurable_device.py index 6de11f6413..18d5995f4f 100644 --- a/packages/modules/common/configurable_device.py +++ b/packages/modules/common/configurable_device.py @@ -120,4 +120,11 @@ def update(self): for component in self.components.values(): if hasattr(component, "initialized") and component.initialized: initialized_components.append(component) + else: + try: + component.initialize() + component.initialized = True + initialized_components.append(component) + except Exception: + log.exception(f"Initialisierung der Komponente {component} fehlgeschlagen") self.__component_updater(initialized_components, self.error_handler) diff --git a/packages/modules/common/configurable_io.py b/packages/modules/common/configurable_io.py index 981bdfeb7d..8ce075c603 100644 --- a/packages/modules/common/configurable_io.py +++ b/packages/modules/common/configurable_io.py @@ -1,6 +1,7 @@ import logging from typing import Dict, Optional, TypeVar, Generic, Callable, Union +from helpermodules import timecheck from helpermodules.pub import Pub from modules.common import store from modules.common.abstract_io import AbstractIoDevice @@ -19,23 +20,47 @@ def __init__(self, config: T_IO_CONFIG, component_reader: Callable[[], IoState], component_writer: Callable[[Dict[int, Union[float, int]]], Optional[IoState]], - initializer: Callable = lambda: None) -> None: + initializer: Callable = lambda: None, + error_handler: Callable = lambda: None) -> None: self.config = config + self.__error_handler = error_handler self.fault_state = FaultState(ComponentInfo(self.config.id, self.config.name, ComponentType.IO.value)) self.store = store.get_io_value_store(self.config.id) self.set_manual: Dict = {"analog_output": {}, "digital_output": {}} + self.error_timestamp = None with SingleComponentUpdateContext(self.fault_state): self.component_reader = component_reader self.component_writer = component_writer initializer() + def error_handler(self) -> None: + error_timestamp_topic = f"openWB/set/system/device/{self.config.id}/error_timestamp" + if self.error_timestamp is None: + self.error_timestamp = timecheck.create_timestamp() + Pub().pub(error_timestamp_topic, self.error_timestamp) + log.debug( + f"Fehler bei Gerät {self.config.name} aufgetreten, Fehlerzeitstempel: {self.error_timestamp}") + if timecheck.check_timestamp(self.error_timestamp, 60) is False: + try: + self.__error_handler() + except Exception: + log.exception(f"Fehlerbehandlung für Gerät {self.config.name} fehlgeschlagen") + else: + log.debug(f"Fehlerbehandlung für Gerät {self.config.name} wurde durchgeführt.") + + self.error_timestamp = None + Pub().pub(error_timestamp_topic, self.error_timestamp) + def read(self): if hasattr(self, "component_reader"): # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten - with SingleComponentUpdateContext(self.fault_state): - io_state = self.component_reader() - self.store.set(io_state) + try: + with SingleComponentUpdateContext(self.fault_state, reraise=True): + io_state = self.component_reader() + self.store.set(io_state) + except Exception: + self.error_handler() def update_manual_output(self, manual: Dict[str, bool], output: Dict[str, bool], string: str, topic_suffix: str): if len(manual) > 0: @@ -48,12 +73,16 @@ def update_manual_output(self, manual: Dict[str, bool], output: Dict[str, bool], def write(self, analog_output, digital_output): if hasattr(self, "component_writer"): # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten - with SingleComponentUpdateContext(self.fault_state): - self.update_manual_output(self.set_manual["analog_output"], analog_output, "analoge", "analog_output") - self.update_manual_output(self.set_manual["digital_output"], - digital_output, "digitale", "digital_output") - if ((analog_output and self.store.delegate.state.analog_output != analog_output) or - (digital_output and self.store.delegate.state.digital_output != digital_output)): - io_state = self.component_writer(analog_output, digital_output) - if io_state is not None: - self.store.set(io_state) + try: + with SingleComponentUpdateContext(self.fault_state, update_always=False, reraise=True): + self.update_manual_output(self.set_manual["analog_output"], + analog_output, "analoge", "analog_output") + self.update_manual_output(self.set_manual["digital_output"], + digital_output, "digitale", "digital_output") + if ((analog_output and self.store.delegate.state.analog_output != analog_output) or + (digital_output and self.store.delegate.state.digital_output != digital_output)): + io_state = self.component_writer(analog_output, digital_output) + if io_state is not None: + self.store.set(io_state) + except Exception: + self.error_handler() diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index afb933eacd..8a01320959 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -1,6 +1,6 @@ from typing import TypeVar, Generic, Callable -from helpermodules.timecheck import create_unix_timestamp_current_full_hour - +from helpermodules import timecheck +import logging from modules.common import store from modules.common.component_context import SingleComponentUpdateContext from modules.common.component_state import TariffState @@ -9,38 +9,90 @@ T_TARIFF_CONFIG = TypeVar("T_TARIFF_CONFIG") +TARIFF_UPDATE_HOUR = 14 # latest expected time for daily tariff update +ONE_HOUR_SECONDS: int = 3600 +log = logging.getLogger(__name__) -class ConfigurableElectricityTariff(Generic[T_TARIFF_CONFIG]): +class ConfigurableTariff(Generic[T_TARIFF_CONFIG]): def __init__(self, config: T_TARIFF_CONFIG, component_initializer: Callable[[], float]) -> None: self.config = config - self.store = store.get_electricity_tariff_value_store() - self.fault_state = FaultState(ComponentInfo(None, self.config.name, ComponentType.ELECTRICITY_TARIFF.value)) + # nach Init auf NO_ERROR setzen, damit der Fehlerstatus beim Modulwechsel gelöscht wird self.fault_state.no_error() self.fault_state.store_error() with SingleComponentUpdateContext(self.fault_state): self._component_updater = component_initializer(config) - def update(self): + def update(self) -> None: if hasattr(self, "_component_updater"): - # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten with SingleComponentUpdateContext(self.fault_state): - tariff_state = self._remove_outdated_prices(self._component_updater()) - self.store.set(tariff_state) - self.store.update() - if len(tariff_state.prices) < 24: - self.fault_state.no_error( - f'Die Preisliste hat nicht 24, sondern {len(tariff_state.prices)} Einträge. ' - 'Die Strompreise werden vom Anbieter erst um 14:00 für den Folgetag aktualisiert.') - - def _remove_outdated_prices(self, tariff_state: TariffState) -> TariffState: - current_hour = str(int(create_unix_timestamp_current_full_hour())) - for timestamp in list(tariff_state.prices.keys()): - if timestamp < current_hour: - self.fault_state.warning( - 'Die Preisliste startet nicht mit der aktuellen Stunde. Abgelaufene Einträge wurden entfernt.') - tariff_state.prices.pop(timestamp) + tariff_state, timeslot_length_seconds = self.__update_et_provider_data() + self.__store_and_publish_updated_data(tariff_state) + self.__log_and_publish_progress(timeslot_length_seconds, tariff_state) + + def __update_et_provider_data(self) -> tuple[TariffState, int]: + tariff_state = self._component_updater() + timeslot_length_seconds = self.__calculate_price_timeslot_length(tariff_state) + tariff_state = self._remove_outdated_prices(tariff_state, timeslot_length_seconds) + return tariff_state, timeslot_length_seconds + + def __log_and_publish_progress(self, timeslot_length_seconds, tariff_state): + def publish_info(message_extension: str) -> None: + self.fault_state.no_error( + f'Die Preisliste hat {message_extension}{len(tariff_state.prices)} Einträge. ') + expected_time_slots = int(24 * ONE_HOUR_SECONDS / timeslot_length_seconds) + publish_info(f'nicht {expected_time_slots}, sondern ' + if len(tariff_state.prices) < expected_time_slots + else '' + ) + + def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None: + self.store.set(tariff_state) + self.store.update() + + def __calculate_price_timeslot_length(self, tariff_state: TariffState) -> int: + if (tariff_state is None or + tariff_state.prices is None or + len(tariff_state.prices) < 2): + self.fault_state.error("not enough price entries to calculate timeslot length") + return 1 + else: + first_timestamps = list(tariff_state.prices.keys())[:2] + return int(first_timestamps[1]) - int(first_timestamps[0]) + + def _remove_outdated_prices(self, tariff_state: TariffState, timeslot_length_seconds: int) -> TariffState: + if tariff_state.prices is None: + self.fault_state.error("no prices to show") + else: + now = timecheck.create_timestamp() + removed = False + for timestamp in list(tariff_state.prices.keys()): + if int(timestamp) < now - (timeslot_length_seconds - 1): # keep current time slot + tariff_state.prices.pop(timestamp) + removed = True + if removed: + log.debug( + 'Die Preisliste startet nicht mit der aktuellen Stunde. ' + f'Abgelaufene Eintraäge wurden entfernt: {tariff_state.prices}') return tariff_state + + +class ConfigurableFlexibleTariff(ConfigurableTariff): + def __init__(self, + config: T_TARIFF_CONFIG, + component_initializer: Callable[[], float]) -> None: + self.store = store.get_flexible_tariff_value_store() + self.fault_state = FaultState(ComponentInfo(None, config.name, ComponentType.FLEXIBLE_TARIFF.value)) + super().__init__(config, component_initializer) + + +class ConfigurableGridFee(ConfigurableTariff): + def __init__(self, + config: T_TARIFF_CONFIG, + component_initializer: Callable[[], float]) -> None: + self.store = store.get_grid_fee_value_store() + self.fault_state = FaultState(ComponentInfo(None, config.name, ComponentType.GRID_FEE.value)) + super().__init__(config, component_initializer) diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index c2b0f7e3c8..b5f274e162 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -1,34 +1,120 @@ - from unittest.mock import Mock +from helpermodules import timecheck import pytest from modules.common.component_state import TariffState -from modules.common.configurable_tariff import ConfigurableElectricityTariff -from modules.electricity_tariffs.awattar.config import AwattarTariff +from modules.common.configurable_tariff import ConfigurableFlexibleTariff +from modules.electricity_pricing.flexible_tariffs.awattar.config import AwattarTariff @pytest.mark.parametrize( - "tariff_state, expected", + "now, tariff_state, expected", [ - pytest.param(TariffState(prices={"1652680800": -5.87e-06, - "1652684400": 5.467e-05, - "1652688000": 10.72e-05}), - TariffState(prices={"1652680800": -5.87e-06, - "1652684400": 5.467e-05, - "1652688000": 10.72e-05}), id="keine veralteten Einträge"), - pytest.param(TariffState(prices={"1652677200": -5.87e-06, - "1652680800": 5.467e-05, - "1652684400": 10.72e-05}), - TariffState(prices={"1652680800": 5.467e-05, - "1652684400": 10.72e-05}), id="Lösche ersten Eintrag"), + pytest.param( + 1652680800, + TariffState( + prices={ + "1652680800": -5.87e-06, + "1652684400": 5.467e-05, + "1652688000": 10.72e-05, + } + ), + TariffState( + prices={ + "1652680800": -5.87e-06, + "1652684400": 5.467e-05, + "1652688000": 10.72e-05, + } + ), + id="keine veralteten Einträge", + ), + pytest.param( + 1652680800, + TariffState( + prices={ + "1652677200": -5.87e-06, + "1652680800": 5.467e-05, + "1652684400": 10.72e-05, + } + ), + TariffState(prices={"1652680800": 5.467e-05, "1652684400": 10.72e-05}), + id="Lösche ersten Eintrag", + ), + pytest.param( + 1652684000, + TariffState( + prices={ + "1652680800": -5.87e-06, + "1652684400": 5.467e-05, + "1652688000": 10.72e-05, + } + ), + TariffState( + prices={ + "1652680800": -5.87e-06, + "1652684400": 5.467e-05, + "1652688000": 10.72e-05, + } + ), + id="erster time slot noch nicht zu Ende", + ), + pytest.param( + 1652684000, + TariffState( + prices={ + "1652680000": -5.87e-06, + "1652681200": 5.467e-05, + "1652682400": 10.72e-05, + "1652683600": 10.72e-05, + "1652684800": 10.72e-05, + "1652686000": 10.72e-05, + "1652687200": 10.72e-05, + } + ), + TariffState( + prices={ + "1652683600": 10.72e-05, + "1652684800": 10.72e-05, + "1652686000": 10.72e-05, + "1652687200": 10.72e-05, + } + ), + id="20 Minuten time slots", + ), ], ) -def test_remove_outdated_prices(tariff_state: TariffState, expected: TariffState, monkeypatch): +def test_remove_outdated_prices( + now: int, tariff_state: TariffState, expected: TariffState, monkeypatch +): # setup - tariff = ConfigurableElectricityTariff(AwattarTariff(), Mock()) + tariff = ConfigurableFlexibleTariff(AwattarTariff(), Mock()) + time_slot_seconds = [int(timestamp) for timestamp in tariff_state.prices.keys()][:2] + + # Montag 16.05.2022, 8:40:52 "05/16/2022, 08:40:52" Unix: 1652683252 + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=now)) # test - result = tariff._remove_outdated_prices(tariff_state) + result = tariff._remove_outdated_prices( + tariff_state, time_slot_seconds[1] - time_slot_seconds[0] + ) # assert assert result.prices == expected.prices + + +def test_accept_no_prices_at_start(monkeypatch): + # setup + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), + Mock( + return_value=TariffState( + prices={"5": 10.72e-05, "6": 10.72e-05, "7": 10.72e-05, "8": 10.72e-05} + ) + ), + ) + + # Montag 16.05.2022, 8:40:52 "05/16/2022, 08:40:52" Unix: 1652683252 + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=5)) + + # test - do not fail + tariff._remove_outdated_prices(TariffState(), 1) diff --git a/packages/modules/common/configurable_vehicle.py b/packages/modules/common/configurable_vehicle.py index f829b7e40f..c54e0f480e 100644 --- a/packages/modules/common/configurable_vehicle.py +++ b/packages/modules/common/configurable_vehicle.py @@ -1,5 +1,6 @@ from enum import Enum import logging +# import time from typing import Optional, TypeVar, Generic, Callable from helpermodules import timecheck @@ -14,6 +15,7 @@ from modules.vehicles.manual.config import ManualSoc from modules.vehicles.mqtt.config import MqttSocSetup + T_VEHICLE_CONFIG = TypeVar("T_VEHICLE_CONFIG") log = logging.getLogger(__name__) @@ -69,6 +71,10 @@ def update(self, vehicle_update_data: VehicleUpdateData): log.debug(f"General Config {self.general_config}") with SingleComponentUpdateContext(self.fault_state, self.__initializer): + if vehicle_update_data.imported is None: + self.calculated_soc_state.last_imported = None + Pub().pub(f"openWB/set/vehicle/{self.vehicle}/soc_module/calculated_soc_state", + asdict(self.calculated_soc_state)) source = self._get_carstate_source(vehicle_update_data) if source == SocSource.NO_UPDATE: log.debug("No soc update necessary.") @@ -78,26 +84,18 @@ def update(self, vehicle_update_data: VehicleUpdateData): log.debug("Mqtt uses legacy topics.") return log.debug(f"Requested start soc from {source.value}: {car_state.soc}%") - - if (source != SocSource.CALCULATION or - (vehicle_update_data.imported and self.calculated_soc_state.imported_start is None)): - # Wenn nicht berechnet wurde, SoC als Start merken. - self.calculated_soc_state.imported_start = vehicle_update_data.imported - self.calculated_soc_state.soc_start = car_state.soc - Pub().pub(f"openWB/set/vehicle/{self.vehicle}/soc_module/calculated_soc_state", - asdict(self.calculated_soc_state)) - if (vehicle_update_data.soc_timestamp is None or - vehicle_update_data.soc_timestamp <= car_state.soc_timestamp + 60): + if (vehicle_update_data.last_soc_timestamp is None or + vehicle_update_data.last_soc_timestamp <= car_state.soc_timestamp + 60): # Nur wenn der SoC neuer ist als der bisherige, diesen setzen. # Manche Fahrzeuge liefern in Ladepausen zwar einen SoC, aber manchmal einen alten. # Die Pro liefert manchmal den SoC nicht, bis nach dem Anstecken das SoC-Update getriggert wird. # Wenn Sie dann doch noch den alten SoC liefert, darf dieser nicht verworfen werden. self.store.set(car_state) - elif vehicle_update_data.soc_timestamp > 1e10: - # car_state ist in ms geschrieben, dieser kann überschrieben werden - self.store.set(car_state) else: log.debug("Not updating SoC, because timestamp is older.") + self.calculated_soc_state.last_imported = vehicle_update_data.imported + Pub().pub(f"openWB/set/vehicle/{self.vehicle}/soc_module/calculated_soc_state", + asdict(self.calculated_soc_state)) def _get_carstate_source(self, vehicle_update_data: VehicleUpdateData) -> SocSource: # Kein SoC vom LP vorhanden oder erwünscht @@ -106,7 +104,7 @@ def _get_carstate_source(self, vehicle_update_data: VehicleUpdateData) -> SocSou self.calculated_soc_state.manual_soc is not None): if isinstance(self.vehicle_config, ManualSoc): # Wenn ein manueller SoC gesetzt wurde, diesen als neuen Start merken. - if self.calculated_soc_state.manual_soc is not None or self.calculated_soc_state.imported_start is None: + if self.calculated_soc_state.manual_soc is not None: return SocSource.MANUAL else: if vehicle_update_data.plug_state: @@ -129,13 +127,39 @@ def _get_carstate_source(self, vehicle_update_data: VehicleUpdateData) -> SocSou def _get_carstate_by_source(self, vehicle_update_data: VehicleUpdateData, source: SocSource) -> CarState: if source == SocSource.API: - return self.__component_updater(vehicle_update_data) + try: + _carState = self.__component_updater(vehicle_update_data) + except Exception as e: + if vehicle_update_data.plug_state and\ + vehicle_update_data.last_soc and\ + vehicle_update_data.last_soc_timestamp >= vehicle_update_data.plug_time and\ + (self.calculated_soc_state.last_imported or vehicle_update_data.imported): + _txt1 = "SoC FALLBACK: SoC wird berechnet, da ein Fehler bei der Abfrage aufgetreten ist:" + self.fault_state.warning(f"{_txt1} {e}") + _carState = CarState(soc=calc_soc.calc_soc( + vehicle_update_data, + vehicle_update_data.efficiency, + self.calculated_soc_state.last_imported or vehicle_update_data.imported, + vehicle_update_data.battery_capacity)) + else: + if not vehicle_update_data.plug_state: + reason = ", weil kein Fahrzeug eingesteckt ist." + elif not vehicle_update_data.last_soc: + reason = ", weil kein SOC-Wert verfügbar ist." + elif vehicle_update_data.last_soc_timestamp < vehicle_update_data.plug_time: + reason = ", da der SOC-Zeitstempel vor dem Einstecken liegt." + elif not (self.calculated_soc_state.last_imported or vehicle_update_data.imported): + reason = ", weil Daten zum Berechnen des SOC fehlen." + else: + reason = "" + _txt1 = "Die Berechnung vom letzten bekannten Soc ist nicht möglich" + raise Exception(f"Der SoC kann nicht ausgelesen werden: {e}. {_txt1}{reason}") + return _carState elif source == SocSource.CALCULATION: return CarState(soc=calc_soc.calc_soc( vehicle_update_data, vehicle_update_data.efficiency, - self.calculated_soc_state.imported_start or vehicle_update_data.imported, - self.calculated_soc_state.soc_start, + self.calculated_soc_state.last_imported or vehicle_update_data.imported, vehicle_update_data.battery_capacity)) elif source == SocSource.CP: return CarState(soc=vehicle_update_data.soc_from_cp, @@ -144,7 +168,7 @@ def _get_carstate_by_source(self, vehicle_update_data: VehicleUpdateData, source if self.calculated_soc_state.manual_soc is not None: soc = self.calculated_soc_state.manual_soc else: - soc = self.calculated_soc_state.soc_start + raise ValueError("Manual soc source selected, but no manual soc set.") self.calculated_soc_state.manual_soc = None return CarState(soc) diff --git a/packages/modules/common/configurable_vehicle_test.py b/packages/modules/common/configurable_vehicle_test.py index f31bcf6603..90fed2a5a3 100644 --- a/packages/modules/common/configurable_vehicle_test.py +++ b/packages/modules/common/configurable_vehicle_test.py @@ -91,19 +91,19 @@ def conf_vehicle_mqtt(): [ pytest.param(conf_vehicle_manual(), False, VehicleUpdateData(), CalculatedSocState( manual_soc=34), SocSource.MANUAL, id="Manuell, neuer Start-SoC"), - pytest.param(conf_vehicle_manual(), False, VehicleUpdateData(plug_state=True), CalculatedSocState( - soc_start=34), SocSource.CALCULATION, id="Manuell berechnen"), - pytest.param(conf_vehicle_manual(), False, VehicleUpdateData(), CalculatedSocState( - soc_start=34), SocSource.NO_UPDATE, id="Manuell nicht aktualisieren, da nicht angesteckt"), + pytest.param(conf_vehicle_manual(), False, VehicleUpdateData(plug_state=True, last_soc=34), CalculatedSocState( + ), SocSource.CALCULATION, id="Manuell berechnen"), + pytest.param(conf_vehicle_manual(), False, VehicleUpdateData(last_soc=34), CalculatedSocState(), + SocSource.NO_UPDATE, id="Manuell nicht aktualisieren, da nicht angesteckt"), pytest.param(conf_vehicle_manual_from_cp(), True, VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_INVALID), CalculatedSocState(manual_soc=34), SocSource.MANUAL, id="Manuell mit SoC vom LP, neuer Start-SoC"), pytest.param(conf_vehicle_manual_from_cp(), True, - VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_VALID), - CalculatedSocState(soc_start=34), SocSource.CP, id="Manuell mit SoC vom LP, neuer LP-SoC"), + VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_VALID, last_soc=34), + CalculatedSocState(), SocSource.CP, id="Manuell mit SoC vom LP, neuer LP-SoC"), pytest.param(conf_vehicle_manual_from_cp(), True, - VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_INVALID), - CalculatedSocState(soc_start=34), SocSource.CALCULATION, + VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_INVALID, last_soc=34), + CalculatedSocState(), SocSource.CALCULATION, id="Manuell mit SoC vom LP, LP-SoC berechnen"), pytest.param(conf_vehicle_api(), True, VehicleUpdateData(), CalculatedSocState(), SocSource.API, id="API"), pytest.param(conf_vehicle_api_from_cp(), True, VehicleUpdateData( @@ -112,8 +112,8 @@ def conf_vehicle_mqtt(): pytest.param(conf_vehicle_api_from_cp(), True, VehicleUpdateData(soc_from_cp=None), CalculatedSocState(), SocSource.API, id="API mit SoC vom LP, kein LP-SoC"), pytest.param(conf_vehicle_api_from_cp(), True, - VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_INVALID), - CalculatedSocState(soc_start=34), SocSource.CALCULATION, + VehicleUpdateData(soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_INVALID, last_soc=34), + CalculatedSocState(), SocSource.CALCULATION, id="API mit SoC vom LP, LP-SoC berechnen"), pytest.param(conf_vehicle_api_while_charging(), False, VehicleUpdateData(), CalculatedSocState(), SocSource.API, id="API mit Berechnung, keine Ladung"), @@ -141,11 +141,11 @@ def test_get_carstate_source(conf_vehicle: ConfigurableVehicle, @pytest.mark.parametrize( "vehicle_update_data, use_soc_from_cp, expected_calculated_soc_state, expected_call_count", [ - pytest.param(VehicleUpdateData(), False, CalculatedSocState(soc_start=42), 1, id="request only from api"), - pytest.param(VehicleUpdateData(imported=150), True, CalculatedSocState( - soc_start=42, imported_start=150), 1, id="request from api, not plugged"), - pytest.param(VehicleUpdateData(imported=200, plug_state=True), True, CalculatedSocState( - soc_start=42, imported_start=200), 1, id="request from api, recently plugged"), + pytest.param(VehicleUpdateData(last_soc=42), False, CalculatedSocState(), 1, id="request only from api"), + pytest.param(VehicleUpdateData(imported=150, last_soc=42), True, CalculatedSocState( + last_imported=150), 1, id="request from api, not plugged"), + pytest.param(VehicleUpdateData(imported=200, plug_state=True, last_soc=42), True, + CalculatedSocState(last_imported=200), 1, id="request from api, recently plugged"), ]) def test_update_api(vehicle_update_data, use_soc_from_cp, @@ -191,7 +191,7 @@ def test_1(monkeypatch): # evaluation assert mock_value_store.set.call_args[0][0].soc == 45 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=45) + assert c.calculated_soc_state == CalculatedSocState(manual_soc=None) def test_2(monkeypatch): @@ -213,8 +213,6 @@ def test_2(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[3][0][0].soc == 47 - assert mock_calc_soc.call_args[0][3] == 45 # soc - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=45) def test_3(monkeypatch): @@ -237,7 +235,6 @@ def test_3(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[3][0][0].soc == 44 assert mock_calc_soc.call_count == 2 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) def test_4(monkeypatch): @@ -259,7 +256,6 @@ def test_4(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[2][0][0].soc == 44 assert mock_calc_soc.call_count == 2 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) def test_5(monkeypatch): @@ -281,7 +277,6 @@ def test_5(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[2][0][0].soc == 44 assert mock_calc_soc.call_count == 1 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) def test_6(monkeypatch): @@ -292,7 +287,6 @@ def test_6(monkeypatch): mock_value_store = Mock(name="value_store") monkeypatch.setattr(store, "get_car_value_store", Mock(return_value=mock_value_store)) c = conf_vehicle_manual_from_cp() - c.calculated_soc_state = CalculatedSocState(manual_soc=None, soc_start=42) # execution c.update(VehicleUpdateData(plug_state=True, soc_from_cp=45, timestamp_soc_from_cp=TIMESTAMP_SOC_INVALID)) @@ -300,7 +294,6 @@ def test_6(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[0][0][0].soc == 44 assert mock_calc_soc.call_count == 1 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) def test_7(monkeypatch): @@ -318,7 +311,6 @@ def test_7(monkeypatch): # evaluation assert mock_value_store.set.call_args[0][0].soc == 42 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) def test_8(monkeypatch): @@ -336,7 +328,6 @@ def test_8(monkeypatch): # evaluation assert mock_value_store.set.call_args[0][0].soc == 42 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) def test_9(monkeypatch): @@ -355,7 +346,6 @@ def test_9(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[1][0][0].soc == 46 assert mock_calc_soc.call_count == 1 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=45) def test_10(monkeypatch): @@ -370,7 +360,6 @@ def test_10(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[0][0][0].soc == 42 - assert c.calculated_soc_state == CalculatedSocState(soc_start=42) def test_11(monkeypatch): @@ -390,4 +379,3 @@ def test_11(monkeypatch): # evaluation assert mock_value_store.set.call_args_list[1][0][0].soc == 44 assert mock_calc_soc.call_count == 1 - assert c.calculated_soc_state == CalculatedSocState(manual_soc=None, soc_start=42) diff --git a/packages/modules/common/fault_state.py b/packages/modules/common/fault_state.py index 7c4756174a..ec944c1385 100644 --- a/packages/modules/common/fault_state.py +++ b/packages/modules/common/fault_state.py @@ -47,7 +47,8 @@ def store_error(self) -> None: self.fault_str + ", Traceback: \n" + traceback.format_exc()) topic = component_type.type_to_topic_mapping(self.component_info.type) - if self.component_info.type == component_type.ComponentType.ELECTRICITY_TARIFF.value: + if (self.component_info.type == component_type.ComponentType.FLEXIBLE_TARIFF.value or + self.component_info.type == component_type.ComponentType.GRID_FEE.value): topic_prefix = f"openWB/set/{topic}" else: topic_prefix = f"openWB/set/{topic}/{self.component_info.id}" diff --git a/packages/modules/common/hardware_check.py b/packages/modules/common/hardware_check.py index 01abc892da..6ea7dc4da9 100644 --- a/packages/modules/common/hardware_check.py +++ b/packages/modules/common/hardware_check.py @@ -1,3 +1,4 @@ +import logging import pymodbus from typing import Any, Optional, Protocol, Tuple, Union @@ -6,6 +7,9 @@ from modules.common.fault_state import FaultState from modules.common.modbus import ModbusSerialClient_, ModbusTcpClient_ +log = logging.getLogger(__name__) + + EVSE_MIN_FIRMWARE = 7 OPEN_TICKET = (" Bitte nehme bei anhaltenden Problemen über die Support-Funktion in den Einstellungen Kontakt mit " + @@ -97,9 +101,7 @@ def request_and_check_hardware(self: ClientHandlerProtocol, else: raise Exception(meter_error_msg + OPEN_TICKET) elif evse_check_passed and meter_check_passed and meter_error_msg is not None: - if meter_error_msg != METER_NO_SERIAL_NUMBER: - meter_error_msg += OPEN_TICKET - fault_state.warning(meter_error_msg) + fault_state.warning(meter_error_msg + OPEN_TICKET) if evse_check_passed is False: if meter_error_msg is not None: raise Exception(EVSE_BROKEN + " " + meter_error_msg + OPEN_TICKET) @@ -112,7 +114,7 @@ def check_meter(self: ClientHandlerProtocol) -> Tuple[bool, Optional[str], Count with self.client: counter_state = self.meter_client.get_counter_state() if counter_state.serial_number == "0" or counter_state.serial_number is None: - return True, METER_NO_SERIAL_NUMBER, counter_state + log.warning(METER_NO_SERIAL_NUMBER) return True, _check_meter_values(counter_state), counter_state except Exception: return False, METER_PROBLEM, None diff --git a/packages/modules/common/hardware_check_test.py b/packages/modules/common/hardware_check_test.py index d4d2b13e44..07336b5a1a 100644 --- a/packages/modules/common/hardware_check_test.py +++ b/packages/modules/common/hardware_check_test.py @@ -8,8 +8,7 @@ from modules.common.component_state import CounterState, EvseState from modules.common.evse import Evse from modules.common.hardware_check import ( - EVSE_BROKEN, LAN_ADAPTER_BROKEN, METER_BROKEN_VOLTAGES, METER_NO_SERIAL_NUMBER, - METER_PROBLEM, OPEN_TICKET, USB_ADAPTER_BROKEN, + EVSE_BROKEN, LAN_ADAPTER_BROKEN, METER_BROKEN_VOLTAGES, METER_PROBLEM, OPEN_TICKET, USB_ADAPTER_BROKEN, SeriesHardwareCheckMixin, _check_meter_values) from modules.common.modbus import NO_CONNECTION, ModbusSerialClient_, ModbusTcpClient_ from modules.conftest import SAMPLE_IP, SAMPLE_PORT @@ -112,8 +111,7 @@ def test_check_meter_values_voltages(voltages, power, expected_msg, monkeypatch) @patch('modules.common.hardware_check.ClientHandlerProtocol') @pytest.mark.parametrize("serial_number, voltages, expected", - [("0", [230]*3, (True, METER_NO_SERIAL_NUMBER, CounterState)), - (12345, [230]*3, (True, None, CounterState)), + [(12345, [230]*3, (True, None, CounterState)), (Exception(), [230]*3, (False, METER_PROBLEM, None))]) def test_check_meter( MockClientHandlerProtocol: Mock, diff --git a/packages/modules/common/modbus.py b/packages/modules/common/modbus.py index 49fbaa4ac9..3ef4bdd7bb 100644 --- a/packages/modules/common/modbus.py +++ b/packages/modules/common/modbus.py @@ -192,6 +192,77 @@ def write_registers(self, address: int, value: Any, **kwargs): def write_single_coil(self, address: int, value: Any, **kwargs): self._delegate.write_coil(address, value, **kwargs) + def __read_bulk(self, + read_register_method: Callable, + start_address: int, + count: int, + mapping: list[tuple[int, Union[ModbusDataType, Iterable[ModbusDataType]]]], + byteorder: Endian = Endian.Big, wordorder: Endian = Endian.Big, **kwargs): + """ + Liest einen Registerbereich und gibt ein dict mit reg als Key und dekodiertem Wert als Value zurück. + mapping: Liste von Tupeln (reg, ModbusDataType) + """ + if self.is_socket_open() is False: + self.connect() + try: + response = read_register_method(start_address, count, **kwargs) + if response.isError(): + raise Exception(__name__+" "+str(response)) + decoder = BinaryPayloadDecoder.fromRegisters(response.registers, byteorder, wordorder) + results = {} + for register_address, data_type in mapping: + multiple_register_requested = isinstance(data_type, Iterable) + if not multiple_register_requested: + data_type = [data_type] + offset = register_address - start_address + decoder.reset() + decoder.skip_bytes(offset * 2) + val = [struct.unpack(">e", struct.pack(">H", decoder.decode_16bit_uint())) if t == + ModbusDataType.FLOAT_16 else getattr(decoder, t.decoding_method)() for t in data_type] + results[register_address] = val if multiple_register_requested else val[0] + return results + except pymodbus.exceptions.ConnectionException as e: + self.close() + e.args += (NO_CONNECTION.format(self.address, self.port),) + raise e + except pymodbus.exceptions.ModbusIOException as e: + self.close() + e.args += (NO_VALUES.format(self.address, self.port),) + raise e + except Exception as e: + self.close() + raise Exception(__name__+" "+str(type(e))+" " + str(e)) from e + + def read_input_registers_bulk(self, + start_address: int, + count: int, + mapping: list[tuple[int, Union[ModbusDataType, Iterable[ModbusDataType]]]], + byteorder: Endian = Endian.Big, + wordorder: Endian = Endian.Big, + **kwargs): + return self.__read_bulk(self._delegate.read_input_registers, + start_address, + count, + mapping, + byteorder, + wordorder, + **kwargs) + + def read_holding_registers_bulk(self, + start_address: int, + count: int, + mapping: list[tuple[int, Union[ModbusDataType, Iterable[ModbusDataType]]]], + byteorder: Endian = Endian.Big, + wordorder: Endian = Endian.Big, + **kwargs): + return self.__read_bulk(self._delegate.read_holding_registers, + start_address, + count, + mapping, + byteorder, + wordorder, + **kwargs) + class ModbusTcpClient_(ModbusClient): def __init__(self, diff --git a/packages/modules/common/sdm.py b/packages/modules/common/sdm.py index b77eec9eac..88d0d8400e 100644 --- a/packages/modules/common/sdm.py +++ b/packages/modules/common/sdm.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from enum import IntEnum import time from typing import List, Tuple @@ -14,115 +15,122 @@ class Sdm(AbstractCounter): def __init__(self, modbus_id: int, client: modbus.ModbusTcpClient_) -> None: self.client = client self.id = modbus_id - self.last_query = self._get_time_ms() - self.WAIT_MS_BETWEEN_QUERIES = 100 with client: self.serial_number = str(self.client.read_holding_registers(0xFC00, ModbusDataType.UINT_32, unit=self.id)) def get_imported(self) -> float: - self._ensure_min_time_between_queries() + # smarthome legacy + time.sleep(0.1) return self.client.read_input_registers(0x0048, ModbusDataType.FLOAT_32, unit=self.id) * 1000 - def get_exported(self) -> float: - self._ensure_min_time_between_queries() - return self.client.read_input_registers(0x004a, ModbusDataType.FLOAT_32, unit=self.id) * 1000 - def get_frequency(self) -> float: - self._ensure_min_time_between_queries() - frequency = self.client.read_input_registers(0x46, ModbusDataType.FLOAT_32, unit=self.id) - if frequency > 100: - frequency = frequency / 10 - return frequency - - # These meters require some minimum time between subsequent Modbus reads. Some Eastron papers recommend 100 ms. - # Sometimes the time between calls to the get_* methods are much shorter so we forcibly wait for the remaining time. - def _ensure_min_time_between_queries(self) -> None: - current_time = self._get_time_ms() - elapsed_time = current_time - self.last_query - if elapsed_time < self.WAIT_MS_BETWEEN_QUERIES: - time.sleep((self.WAIT_MS_BETWEEN_QUERIES - elapsed_time) / 1e3) - self.last_query = current_time - - def _get_time_ms(self) -> float: - return time.time_ns() / 1e6 - - def get_serial_number(self) -> str: - return self.serial_number +class SdmRegister(IntEnum): + VOLTAGE_L1 = 0x00 + CURRENT_L1 = 0x06 + POWER_L1 = 0x0C + POWER_FACTOR_L1 = 0x1E + FREQUENCY = 0x46 + IMPORTED = 0x48 + EXPORTED = 0x4A class Sdm630_72(Sdm): + REG_MAPPING_BULK_1 = ( + (SdmRegister.VOLTAGE_L1, [ModbusDataType.FLOAT_32]*3), + (SdmRegister.CURRENT_L1, [ModbusDataType.FLOAT_32]*3), + (SdmRegister.POWER_L1, [ModbusDataType.FLOAT_32]*3), + (SdmRegister.POWER_FACTOR_L1, [ModbusDataType.FLOAT_32]*3) + ) + REG_MAPPING_BULK_2 = ( + (SdmRegister.FREQUENCY, ModbusDataType.FLOAT_32), + (SdmRegister.IMPORTED, ModbusDataType.FLOAT_32), + (SdmRegister.EXPORTED, ModbusDataType.FLOAT_32), + ) + def __init__(self, modbus_id: int, client: modbus.ModbusTcpClient_, fault_state: FaultState) -> None: super().__init__(modbus_id, client) self.fault_state = fault_state - def get_currents(self) -> List[float]: - self._ensure_min_time_between_queries() - return self.client.read_input_registers(0x06, [ModbusDataType.FLOAT_32]*3, unit=self.id) - - def get_power_factors(self) -> List[float]: - self._ensure_min_time_between_queries() - return self.client.read_input_registers(0x1E, [ModbusDataType.FLOAT_32]*3, unit=self.id) - def get_power(self) -> Tuple[List[float], float]: - self._ensure_min_time_between_queries() + # smarthome legacy + time.sleep(0.1) powers = self.client.read_input_registers(0x0C, [ModbusDataType.FLOAT_32]*3, unit=self.id) power = sum(powers) return powers, power - def get_voltages(self) -> List[float]: - self._ensure_min_time_between_queries() - return self.client.read_input_registers(0x00, [ModbusDataType.FLOAT_32]*3, unit=self.id) - def get_counter_state(self) -> CounterState: - powers, power = self.get_power() + # entgegen der Doku können nicht bei allen SDM72 80 Register auf einmal gelesen werden, + # manche können auch nur 50 + time.sleep(0.1) + bulk_1 = self.client.read_input_registers_bulk( + SdmRegister.VOLTAGE_L1, 38, mapping=self.REG_MAPPING_BULK_1, unit=self.id) + time.sleep(0.1) + bulk_2 = self.client.read_input_registers_bulk( + SdmRegister.FREQUENCY, 8, mapping=self.REG_MAPPING_BULK_2, unit=self.id) + resp = {**bulk_1, **bulk_2} + frequency = resp[SdmRegister.FREQUENCY] + if frequency > 100: + frequency = frequency / 10 counter_state = CounterState( - imported=self.get_imported(), - exported=self.get_exported(), - power=power, - voltages=self.get_voltages(), - currents=self.get_currents(), - powers=powers, - power_factors=self.get_power_factors(), - frequency=self.get_frequency(), - serial_number=self.get_serial_number() + imported=resp[SdmRegister.IMPORTED]*1000, + exported=resp[SdmRegister.EXPORTED]*1000, + power=sum(resp[SdmRegister.POWER_L1]), + voltages=resp[SdmRegister.VOLTAGE_L1], + currents=resp[SdmRegister.CURRENT_L1], + powers=resp[SdmRegister.POWER_L1], + power_factors=resp[SdmRegister.POWER_FACTOR_L1], + frequency=frequency, + serial_number=self.serial_number ) check_meter_values(counter_state, self.fault_state) return counter_state class Sdm120(Sdm): + REG_MAPPING_BULK_1 = ( + (SdmRegister.VOLTAGE_L1, ModbusDataType.FLOAT_32), + (SdmRegister.CURRENT_L1, ModbusDataType.FLOAT_32), + (SdmRegister.POWER_L1, ModbusDataType.FLOAT_32), + (SdmRegister.POWER_FACTOR_L1, ModbusDataType.FLOAT_32) + ) + REG_MAPPING_BULK_2 = ( + (SdmRegister.FREQUENCY, ModbusDataType.FLOAT_32), + (SdmRegister.IMPORTED, ModbusDataType.FLOAT_32), + (SdmRegister.EXPORTED, ModbusDataType.FLOAT_32), + ) + def __init__(self, modbus_id: int, client: modbus.ModbusTcpClient_, fault_state: FaultState) -> None: super().__init__(modbus_id, client) self.fault_state = fault_state def get_power(self) -> Tuple[List[float], float]: - self._ensure_min_time_between_queries() + # smarthome legacy + time.sleep(0.1) power = self.client.read_input_registers(0x0C, ModbusDataType.FLOAT_32, unit=self.id) return [power, 0, 0], power - def get_currents(self) -> List[float]: - self._ensure_min_time_between_queries() - return [self.client.read_input_registers(0x06, ModbusDataType.FLOAT_32, unit=self.id), 0.0, 0.0] - - def get_voltages(self) -> List[float]: - self._ensure_min_time_between_queries() - voltage = self.client.read_input_registers(0x00, ModbusDataType.FLOAT_32, unit=self.id) - return [voltage, 0.0, 0.0] - - def get_power_factors(self) -> List[float]: - self._ensure_min_time_between_queries() - return [self.client.read_input_registers(0x1E, ModbusDataType.FLOAT_32, unit=self.id), 0.0, 0.0] - def get_counter_state(self) -> CounterState: - powers, power = self.get_power() + # beim SDM120 steht nichts von Bulk-Reads in der Doku, daher auch auf 50 Register limitiert + time.sleep(0.1) + bulk_1 = self.client.read_input_registers_bulk( + SdmRegister.VOLTAGE_L1, 32, mapping=self.REG_MAPPING_BULK_1, unit=self.id) + time.sleep(0.1) + bulk_2 = self.client.read_input_registers_bulk( + SdmRegister.FREQUENCY, 8, mapping=self.REG_MAPPING_BULK_2, unit=self.id) + resp = {**bulk_1, **bulk_2} + frequency = resp[SdmRegister.FREQUENCY] + if frequency > 100: + frequency = frequency / 10 counter_state = CounterState( - imported=self.get_imported(), - exported=self.get_exported(), - power=power, - currents=self.get_currents(), - powers=powers, - frequency=self.get_frequency(), - serial_number=self.get_serial_number() + imported=resp[SdmRegister.IMPORTED]*1000, + exported=resp[SdmRegister.EXPORTED]*1000, + power=resp[SdmRegister.POWER_L1], + voltages=[resp[SdmRegister.VOLTAGE_L1], 0, 0], + currents=[resp[SdmRegister.CURRENT_L1], 0, 0], + powers=[resp[SdmRegister.POWER_L1], 0, 0], + power_factors=[resp[SdmRegister.POWER_FACTOR_L1], 0, 0], + frequency=frequency, + serial_number=self.serial_number ) check_meter_values(counter_state, self.fault_state) return counter_state diff --git a/packages/modules/common/store/__init__.py b/packages/modules/common/store/__init__.py index 451f2ce2c1..4c76f0c1fd 100644 --- a/packages/modules/common/store/__init__.py +++ b/packages/modules/common/store/__init__.py @@ -6,6 +6,6 @@ from modules.common.store._counter import get_counter_value_store from modules.common.store._inverter import get_inverter_value_store from modules.common.store._io import get_io_value_store -from modules.common.store._tariff import get_electricity_tariff_value_store +from modules.common.store._tariff import get_flexible_tariff_value_store, get_grid_fee_value_store from modules.common.store.ramdisk.io import ramdisk_write, ramdisk_read, ramdisk_read_float, ramdisk_read_int, \ RAMDISK_PATH diff --git a/packages/modules/common/store/_battery.py b/packages/modules/common/store/_battery.py index da4bf72d57..41ded60c93 100644 --- a/packages/modules/common/store/_battery.py +++ b/packages/modules/common/store/_battery.py @@ -1,6 +1,5 @@ from helpermodules import compatibility from modules.common.component_state import BatState -from modules.common.fault_state import FaultState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker @@ -12,13 +11,10 @@ def __init__(self, component_num: int) -> None: self.num = component_num def set(self, bat_state: BatState): - try: - files.battery.power.write(bat_state.power) - files.battery.soc.write(bat_state.soc) - files.battery.energy_imported.write(bat_state.imported) - files.battery.energy_exported.write(bat_state.exported) - except Exception as e: - raise FaultState.from_exception(e) + files.battery.power.write(bat_state.power) + files.battery.soc.write(bat_state.soc) + files.battery.energy_imported.write(bat_state.imported) + files.battery.energy_exported.write(bat_state.exported) class BatteryValueStoreBroker(ValueStore[BatState]): @@ -29,15 +25,12 @@ def set(self, bat_state: BatState): self.state = bat_state def update(self): - try: - pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/currents", self.state.currents, 2) - pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/power", self.state.power, 2) - pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/soc", self.state.soc, 0) - if self.state.imported is not None and self.state.exported is not None: - pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/imported", self.state.imported, 2) - pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/exported", self.state.exported, 2) - except Exception as e: - raise FaultState.from_exception(e) + pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/currents", self.state.currents, 2) + pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/power", self.state.power, 2) + pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/soc", self.state.soc, 0) + if self.state.imported is not None and self.state.exported is not None: + pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/imported", self.state.imported, 2) + pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/exported", self.state.exported, 2) class PurgeBatteryState: diff --git a/packages/modules/common/store/_car.py b/packages/modules/common/store/_car.py index 6933ba7b9f..37c7d64a30 100644 --- a/packages/modules/common/store/_car.py +++ b/packages/modules/common/store/_car.py @@ -1,6 +1,5 @@ from helpermodules import compatibility from modules.common.component_state import CarState -from modules.common.fault_state import FaultState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker @@ -23,15 +22,9 @@ def set(self, state: CarState) -> None: self.state = state def update(self): - try: - pub_to_broker("openWB/set/vehicle/"+str(self.vehicle_id)+"/get/soc", self.state.soc, 2) - if self.state.range: - pub_to_broker("openWB/set/vehicle/"+str(self.vehicle_id)+"/get/range", self.state.range, 2) - if self.state.soc_timestamp: - pub_to_broker("openWB/set/vehicle/"+str(self.vehicle_id)+"/get/soc_timestamp", self.state.soc_timestamp) - - except Exception as e: - raise FaultState.from_exception(e) + pub_to_broker("openWB/set/vehicle/"+str(self.vehicle_id)+"/get/soc", self.state.soc, 2) + pub_to_broker("openWB/set/vehicle/"+str(self.vehicle_id)+"/get/range", self.state.range, 2) + pub_to_broker("openWB/set/vehicle/"+str(self.vehicle_id)+"/get/soc_timestamp", self.state.soc_timestamp) def get_car_value_store(id: int) -> ValueStore[CarState]: diff --git a/packages/modules/common/store/_chargepoint.py b/packages/modules/common/store/_chargepoint.py index 8743646f41..3efbf7a1d3 100644 --- a/packages/modules/common/store/_chargepoint.py +++ b/packages/modules/common/store/_chargepoint.py @@ -46,7 +46,8 @@ def update(self): if self.state.phases_in_use: pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/phases_in_use", self.state.phases_in_use, 2) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/charge_state", self.state.charge_state, 2) - pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/plug_state", self.state.plug_state, 2) + if self.state.plug_state is not None: + pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/plug_state", self.state.plug_state, 2) pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/rfid", self.state.rfid) if self.state.rfid_timestamp is not None: pub_to_broker("openWB/set/chargepoint/" + str(self.num) + "/get/rfid_timestamp", self.state.rfid_timestamp) diff --git a/packages/modules/common/store/_chargepoint_internal.py b/packages/modules/common/store/_chargepoint_internal.py index a6acb53824..593ab6dd8c 100644 --- a/packages/modules/common/store/_chargepoint_internal.py +++ b/packages/modules/common/store/_chargepoint_internal.py @@ -23,7 +23,8 @@ def update(self): pub_to_broker(f"{topic_prefix}/powers", self.state.powers, 2) pub_to_broker(f"{topic_prefix}/phases_in_use", self.state.phases_in_use, 2) pub_to_broker(f"{topic_prefix}/charge_state", self.state.charge_state, 2) - pub_to_broker(f"{topic_prefix}/plug_state", self.state.plug_state, 2) + if self.state.plug_state is not None: + pub_to_broker(f"{topic_prefix}/plug_state", self.state.plug_state, 2) pub_to_broker(f"{topic_prefix}/vehicle_id", self.state.vehicle_id) pub_to_broker(f"{topic_prefix}/rfid", self.state.rfid) pub_to_broker(f"{topic_prefix}/serial_number", self.state.serial_number) diff --git a/packages/modules/common/store/_counter.py b/packages/modules/common/store/_counter.py index 8ddff768a1..5e97045b6d 100644 --- a/packages/modules/common/store/_counter.py +++ b/packages/modules/common/store/_counter.py @@ -1,13 +1,12 @@ import logging from operator import add -from typing import Optional +from typing import Dict, Optional from control import data from helpermodules import compatibility -from helpermodules.phase_mapping import convert_cp_currents_to_evu_currents +from helpermodules.phase_handling import convert_cp_currents_to_evu_currents from modules.common.component_state import CounterState from modules.common.component_type import ComponentType -from modules.common.fault_state import FaultState from modules.common.simcount._simcounter import SimCounter from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore @@ -20,18 +19,15 @@ class CounterValueStoreRamdisk(ValueStore[CounterState]): def set(self, counter_state: CounterState): - try: - files.evu.voltages.write(counter_state.voltages) - if counter_state.currents: - files.evu.currents.write(counter_state.currents) - files.evu.powers_import.write([int(p) for p in counter_state.powers]) - files.evu.power_factors.write(counter_state.power_factors) - files.evu.energy_import.write(counter_state.imported) - files.evu.energy_export.write(counter_state.exported) - files.evu.power_import.write(int(counter_state.power)) - files.evu.frequency.write(counter_state.frequency) - except Exception as e: - raise FaultState.from_exception(e) + files.evu.voltages.write(counter_state.voltages) + if counter_state.currents: + files.evu.currents.write(counter_state.currents) + files.evu.powers_import.write([int(p) for p in counter_state.powers]) + files.evu.power_factors.write(counter_state.power_factors) + files.evu.energy_import.write(counter_state.imported) + files.evu.energy_export.write(counter_state.exported) + files.evu.power_import.write(int(counter_state.power)) + files.evu.frequency.write(counter_state.frequency) class CounterValueStoreBroker(ValueStore[CounterState]): @@ -75,54 +71,101 @@ def calc_virtual(self, state: CounterState) -> CounterState: if self.add_child_values: self.currents = state.currents if state.currents else [0.0]*3 self.power = state.power + self.imported = state.imported if state.imported else 0 + self.exported = state.exported if state.exported else 0 self.incomplete_currents = False - - def add_current_power(element): - if hasattr(element, "currents") and element.currents is not None: - if sum(element.currents) == 0 and element.power != 0: - self.currents = [0, 0, 0] - self.incomplete_currents = True - else: - self.currents = list(map(add, self.currents, element.currents)) - else: - self.currents = [0, 0, 0] - self.incomplete_currents = True - self.power += element.power - counter_all = data.data.counter_all_data elements = counter_all.get_elements_for_downstream_calculation(self.delegate.delegate.num) - for element in elements: - try: - if element["type"] == ComponentType.CHARGEPOINT.value: - chargepoint = data.data.cp_data[f"cp{element['id']}"] - chargepoint_state = chargepoint.chargepoint_module.store.delegate.state - try: - self.currents = list(map(add, - self.currents, - convert_cp_currents_to_evu_currents( - chargepoint.data.config.phase_1, - chargepoint_state.currents))) - except KeyError: - raise KeyError("Für den virtuellen Zähler muss der Anschluss der Phasen von Ladepunkt" - f" {chargepoint.data.config.name} an die Phasen des EVU Zählers " - "angegeben werden.") - self.power += chargepoint_state.power - else: - component = get_component_obj_by_id(element['id']) - add_current_power(component.store.delegate.delegate.state) - except Exception: - log.exception(f"Fehler beim Hinzufügen der Werte für Element {element}") - - imported, exported = self.sim_counter.sim_count(self.power) - if self.incomplete_currents: - self.currents = None - return CounterState(currents=self.currents, - power=self.power, - exported=exported, - imported=imported) + if len(elements) == 0: + return self.calc_uncounted_consumption() + else: + return self.calc_consumers(elements) else: return state + def _add_values(self, element, calc_imported_exported: bool): + if hasattr(element, "currents") and element.currents is not None: + if sum(element.currents) == 0 and element.power != 0: + self.currents = [0, 0, 0] + self.incomplete_currents = True + else: + self.currents = list(map(add, self.currents, element.currents)) + else: + self.currents = [0, 0, 0] + self.incomplete_currents = True + if calc_imported_exported: + if hasattr(element, "imported") and element.imported is not None: + self.imported += element.imported + if hasattr(element, "exported") and element.exported is not None: + self.exported += element.exported + self.power += element.power + + def calc_consumers(self, elements: Dict, calc_imported_exported: bool = False) -> CounterState: + for element in elements: + try: + if element["type"] == ComponentType.CHARGEPOINT.value: + chargepoint = data.data.cp_data[f"cp{element['id']}"] + chargepoint_state = chargepoint.chargepoint_module.store.delegate.state + try: + self.currents = list(map(add, + self.currents, + convert_cp_currents_to_evu_currents( + chargepoint.data.config.phase_1, + chargepoint_state.currents))) + except KeyError: + raise KeyError("Für den virtuellen Zähler muss der Anschluss der Phasen von Ladepunkt" + f" {chargepoint.data.config.name} an die Phasen des EVU Zählers " + "angegeben werden.") + self.power += chargepoint_state.power + if calc_imported_exported: + self.imported += chargepoint_state.imported + self.exported += chargepoint_state.exported + else: + component = get_component_obj_by_id(element['id']) + self._add_values(component.store.delegate.delegate.state, calc_imported_exported) + except Exception: + log.exception(f"Fehler beim Hinzufügen der Werte für Element {element}") + + if calc_imported_exported is False or self.imported is None or self.exported is None: + if self.imported is None and calc_imported_exported: + log.debug("Mind eine Komponente liefert keinen Zählestand für den Bezug, berechne Zählerstände") + if self.exported is None and calc_imported_exported: + log.debug("Mind eine Komponente liefert keinen Zählestand für die Einspeisung, berechne Zählerstände") + self.imported, self.exported = self.sim_counter.sim_count(self.power) + if self.incomplete_currents: + self.currents = None + return CounterState(currents=self.currents, + power=self.power, + exported=self.exported, + imported=self.imported) + + def calc_uncounted_consumption(self) -> CounterState: + """Berechnet den nicht-gezählten Verbrauch für einen virtuellen Zähler. + Dazu wird der Zählerstand des übergeordneten Zählers herangezogen und davon die + Werte aller anderen untergeordneten Komponenten abgezogen.""" + parent_id = data.data.counter_all_data.get_entry_of_parent(self.delegate.delegate.num)["id"] + parent_component = get_component_obj_by_id(parent_id) + if "counter" not in parent_component.component_config.type: + raise Exception("Die übergeordnete Komponente des virtuellen Zählers muss ein Zähler sein.") + if parent_component.store.add_child_values: + raise Exception("Der übergeordnete Zähler des virtuellen Zählers darf nicht " + "auch ein virtueller Zähler sein.") + elements = data.data.counter_all_data.get_elements_for_downstream_calculation(parent_id) + # entferne den eigenen Zähler aus der Liste + elements = [el for el in elements if el["id"] != self.delegate.delegate.num] + self.calc_consumers(elements, calc_imported_exported=True) + log.debug(f"Erfasster Verbrauch virtueller Zähler {self.delegate.delegate.num}: " + f"{self.currents}A, {self.power}W, {self.exported}Wh, {self.imported}Wh") + parent_counter_get = data.data.counter_data[f"counter{parent_id}"].data.get + return CounterState( + currents=[parent_counter_get.currents[i] - self.currents[i] + for i in range(0, 3)] if self.currents is not None else None, + power=parent_counter_get.power - self.power, + exported=0, + imported=(parent_counter_get.imported + self.exported - self.imported - + parent_counter_get.exported) if self.imported is not None else None + ) + def get_counter_value_store(component_num: int, add_child_values: bool = False, diff --git a/packages/modules/common/store/_counter_test.py b/packages/modules/common/store/_counter_test.py index b9e20cd649..b9c8c292d8 100644 --- a/packages/modules/common/store/_counter_test.py +++ b/packages/modules/common/store/_counter_test.py @@ -16,7 +16,7 @@ from modules.common.store import _counter from modules.common.store._api import LoggingValueStore from modules.common.store._battery import BatteryValueStoreBroker, PurgeBatteryState -from modules.common.store._counter import PurgeCounterState +from modules.common.store._counter import CounterValueStoreBroker, PurgeCounterState from modules.common.store._inverter import InverterValueStoreBroker, PurgeInverterState from modules.devices.generic.mqtt.bat import MqttBat from modules.devices.generic.mqtt.counter import MqttCounter @@ -138,3 +138,125 @@ def test_calc_virtual(params: Params, monkeypatch): # evaluation assert vars(state) == vars(params.expected_state) + + +def test_calc_uncounted_consumption(monkeypatch): + """ + Test für calc_uncounted_consumption mit folgendem Szenario: + - Übergeordnete Ebene: Ein Zähler (id=0, parent counter) + - Gleiche Ebene wie virtueller Zähler: Ein Ladepunkt (id=1) und ein weiterer Zähler (id=2) + - Virtueller Zähler: id=3 (soll nicht-gezählten Verbrauch berechnen) + + Hierarchie: + Counter 0 (parent, 8000W, 1kWh importiert, 0.5kWh exportiert) + ├── Chargepoint 1 (3000W, 150Wh importiert, 0Wh exportiert) + ├── Counter 2 (2000W, 300Wh importiert, 100Wh exportiert) + └── Virtual Counter 3 (uncounted: 8000 - 3000 - 2000 = 3000W, 0.15kWh imp, 0kWh exp) + """ + # setup + data.data_init(Mock()) + data.data.counter_all_data = CounterAll() + data.data.counter_all_data.data.get.hierarchy = [ + { + "id": 0, + "type": "counter", + "children": [ + {"id": 1, "type": "cp", "children": []}, + {"id": 2, "type": "counter", "children": []}, + {"id": 3, "type": "counter", "children": []} + ] + } + ] + + data.data.counter_data["counter0"] = Mock( + spec=Counter, + data=Mock( + spec=CounterData, + get=Mock( + spec=Get, + power=8000, + exported=500, + imported=1000, + currents=[20.0, 22.0, 18.0] + ) + ) + ) + + add_chargepoint(1) + data.data.cp_data["cp1"].data.get.power = 3000 + data.data.cp_data["cp1"].data.get.currents = [8.0, 9.0, 7.0] + data.data.cp_data["cp1"].chargepoint_module.store.delegate.state.power = 3000 + data.data.cp_data["cp1"].chargepoint_module.store.delegate.state.currents = [8.0, 9.0, 7.0] + data.data.cp_data["cp1"].chargepoint_module.store.delegate.state.imported = 150 + data.data.cp_data["cp1"].chargepoint_module.store.delegate.state.exported = 0 + + data.data.counter_data["counter2"] = Mock( + spec=Counter, + data=Mock( + spec=CounterData, + get=Mock( + spec=Get, + power=2000, + exported=100, + imported=300, + currents=[5.0, 6.0, 4.0] + ) + ) + ) + + parent_counter_component = Mock() + parent_counter_component.component_config.type = "counter" + parent_counter_component.store.add_child_values = False + + regular_counter_component = Mock( + spec=MqttCounter, + store=Mock( + spec=PurgeCounterState, + delegate=Mock( + spec=LoggingValueStore, + delegate=Mock( + spec=CounterValueStoreBroker, + state=CounterState( + power=2000, + exported=100, + imported=300, + currents=[5.0, 6.0, 4.0] + ) + ) + ) + ) + ) + + def mock_get_component_obj_by_id(component_id): + if component_id == 0: # Parent counter + return parent_counter_component + elif component_id == 2: # Regular counter + return regular_counter_component + return None + + monkeypatch.setattr(_counter, "get_component_obj_by_id", mock_get_component_obj_by_id) + + virtual_counter_purge = PurgeCounterState( + delegate=Mock(delegate=Mock(num=3)), + add_child_values=True, + simcounter=SimCounter(0, 0, prefix="virtual") + ) + + # execution + result_state = virtual_counter_purge.calc_virtual(CounterState()) + + # evaluation + # Erwartete Werte: Parent Counter - (Chargepoint + Regular Counter) + # Power: 8000 - (3000 + 2000) = 3000W + # Currents: [20.0, 22.0, 18.0] - ([8.0, 9.0, 7.0] + [5.0, 6.0, 4.0]) = [7.0, 7.0, 7.0] + # Imported: 1000 - (150 + 300) = 550 + # Exported: 500 - (0 + 100) = 400 + + expected_state = CounterState( + power=3000, + currents=[7.0, 7.0, 7.0], + imported=150, + exported=0 + ) + + assert vars(result_state) == vars(expected_state) diff --git a/packages/modules/common/store/_inverter.py b/packages/modules/common/store/_inverter.py index 30380bb37b..0cfab0a947 100644 --- a/packages/modules/common/store/_inverter.py +++ b/packages/modules/common/store/_inverter.py @@ -3,7 +3,6 @@ from control import data from helpermodules import compatibility from modules.common.component_state import InverterState -from modules.common.fault_state import FaultState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker @@ -17,14 +16,11 @@ def __init__(self, component_num: int) -> None: self.__pv = files.pv[component_num - 1] def set(self, inverter_state: InverterState): - try: - self.__pv.power.write(int(inverter_state.power)) - self.__pv.energy.write(inverter_state.exported) - self.__pv.energy_k.write(inverter_state.exported / 1000) - if inverter_state.currents: - self.__pv.currents.write(inverter_state.currents) - except Exception as e: - raise FaultState.from_exception(e) + self.__pv.power.write(int(inverter_state.power)) + self.__pv.energy.write(inverter_state.exported) + self.__pv.energy_k.write(inverter_state.exported / 1000) + if inverter_state.currents: + self.__pv.currents.write(inverter_state.currents) class InverterValueStoreBroker(ValueStore[InverterState]): @@ -60,8 +56,8 @@ def update(self) -> None: def filter_peaks(self, state: InverterState) -> InverterState: inverter = data.data.pv_data[f"pv{self.delegate.delegate.num}"] max_ac_out = inverter.data.config.max_ac_out - if max_ac_out > 0 and state.power > max_ac_out: - state.power = max_ac_out + if max_ac_out > 0 and abs(state.power) > max_ac_out: + state.power = max_ac_out if state.power > 0 else -max_ac_out return state def fix_hybrid_values(self, state: InverterState) -> InverterState: diff --git a/packages/modules/common/store/_inverter_test.py b/packages/modules/common/store/_inverter_test.py index 46b2c58092..380c0820c3 100644 --- a/packages/modules/common/store/_inverter_test.py +++ b/packages/modules/common/store/_inverter_test.py @@ -50,3 +50,33 @@ def test_fix_hybrid_values(params): # evaluation assert vars(state) == vars(params.expected_state) + + +FilterPeaksParams = NamedTuple("FilterPeaksParams", [( + "name", str), ("max_ac_out", int), ("input_power", float), ("expected_power", float)]) +filter_peaks_cases = [ + FilterPeaksParams("no_limit", 0, -5000, -5000), # max_ac_out = 0 -> keine Begrenzung + FilterPeaksParams("within_limit", 10000, -5000, -5000), # innerhalb der Grenze + FilterPeaksParams("exceeds_positive", 3000, 5000, 3000), # überschreitet positive Grenze + FilterPeaksParams("exceeds_negative", 3000, -5000, -3000), # überschreitet negative Grenze (behält Vorzeichen) + FilterPeaksParams("at_limit_positive", 5000, 5000, 5000), # genau an der positiven Grenze + FilterPeaksParams("at_limit_negative", 5000, -5000, -5000), # genau an der negativen Grenze +] + + +@pytest.mark.parametrize("params", filter_peaks_cases, ids=[c.name for c in filter_peaks_cases]) +def test_filter_peaks(params): + # setup + mock_inverter = Mock() + mock_inverter.data.config.max_ac_out = params.max_ac_out + data.data.pv_data = {"pv1": mock_inverter} + + purge = PurgeInverterState(delegate=Mock(delegate=Mock(num=1))) + + # execution + input_state = InverterState(power=params.input_power, exported=1000) + result_state = purge.filter_peaks(input_state) + + # evaluation + assert result_state.power == params.expected_power + assert result_state.exported == 1000 # exported sollte unverändert bleiben diff --git a/packages/modules/common/store/_io.py b/packages/modules/common/store/_io.py index f083c4d46f..9bbaf27c6f 100644 --- a/packages/modules/common/store/_io.py +++ b/packages/modules/common/store/_io.py @@ -1,6 +1,5 @@ from control import data from modules.common.component_state import IoState -from modules.common.fault_state import FaultState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker @@ -15,31 +14,28 @@ def set(self, state: IoState) -> None: self.state = state def update(self): - try: - if self.state.digital_input: - pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_input_prev", - data.data.io_states[f"io_states{self.num}"].data.get.digital_input) - pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_input", self.state.digital_input) - if self.state.analog_input: - pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_input_prev", - data.data.io_states[f"io_states{self.num}"].data.get.analog_input) - pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_input", self.state.analog_input) - if self.state.digital_output: - pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_output_prev", - data.data.io_states[f"io_states{self.num}"].data.get.digital_output) - pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_output", self.state.digital_output) - pub_to_broker(f"openWB/set/io/states/{self.num}/set/digital_output_prev", - data.data.io_states[f"io_states{self.num}"].data.set.digital_output) - pub_to_broker(f"openWB/set/io/states/{self.num}/set/digital_output", self.state.digital_output) - if self.state.analog_output: - pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_output_prev", - data.data.io_states[f"io_states{self.num}"].data.get.analog_output) - pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_output", self.state.analog_output) - pub_to_broker(f"openWB/set/io/states/{self.num}/set/analog_output_prev", - data.data.io_states[f"io_states{self.num}"].data.set.analog_output) - pub_to_broker(f"openWB/set/io/states/{self.num}/set/analog_output", self.state.analog_output) - except Exception as e: - raise FaultState.from_exception(e) + if self.state.digital_input: + pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_input_prev", + data.data.io_states[f"io_states{self.num}"].data.get.digital_input) + pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_input", self.state.digital_input) + if self.state.analog_input: + pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_input_prev", + data.data.io_states[f"io_states{self.num}"].data.get.analog_input) + pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_input", self.state.analog_input) + if self.state.digital_output: + pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_output_prev", + data.data.io_states[f"io_states{self.num}"].data.get.digital_output) + pub_to_broker(f"openWB/set/io/states/{self.num}/get/digital_output", self.state.digital_output) + pub_to_broker(f"openWB/set/io/states/{self.num}/set/digital_output_prev", + data.data.io_states[f"io_states{self.num}"].data.set.digital_output) + pub_to_broker(f"openWB/set/io/states/{self.num}/set/digital_output", self.state.digital_output) + if self.state.analog_output: + pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_output_prev", + data.data.io_states[f"io_states{self.num}"].data.get.analog_output) + pub_to_broker(f"openWB/set/io/states/{self.num}/get/analog_output", self.state.analog_output) + pub_to_broker(f"openWB/set/io/states/{self.num}/set/analog_output_prev", + data.data.io_states[f"io_states{self.num}"].data.set.analog_output) + pub_to_broker(f"openWB/set/io/states/{self.num}/set/analog_output", self.state.analog_output) def get_io_value_store(num: int) -> ValueStore[IoState]: diff --git a/packages/modules/common/store/_io_internal.py b/packages/modules/common/store/_io_internal.py index b3f191f76b..652355cee0 100644 --- a/packages/modules/common/store/_io_internal.py +++ b/packages/modules/common/store/_io_internal.py @@ -1,5 +1,4 @@ from modules.common.component_state import IoState -from modules.common.fault_state import FaultState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker @@ -13,17 +12,14 @@ def set(self, state: IoState) -> None: self.state = state def update(self): - try: - if self.state.digital_input: - pub_to_broker("openWB/set/internal_io/states/get/digital_input", self.state.digital_input) - if self.state.analog_input: - pub_to_broker("openWB/set/internal_io/states/get/analog_input", self.state.analog_input) - if self.state.digital_output: - pub_to_broker("openWB/set/internal_io/states/get/digital_output", self.state.digital_output) - if self.state.analog_output: - pub_to_broker("openWB/set/internal_io/states/get/analog_output", self.state.analog_output) - except Exception as e: - raise FaultState.from_exception(e) + if self.state.digital_input: + pub_to_broker("openWB/set/internal_io/states/get/digital_input", self.state.digital_input) + if self.state.analog_input: + pub_to_broker("openWB/set/internal_io/states/get/analog_input", self.state.analog_input) + if self.state.digital_output: + pub_to_broker("openWB/set/internal_io/states/get/digital_output", self.state.digital_output) + if self.state.analog_output: + pub_to_broker("openWB/set/internal_io/states/get/analog_output", self.state.analog_output) def get_internal_io_value_store() -> ValueStore[IoState]: diff --git a/packages/modules/common/store/_tariff.py b/packages/modules/common/store/_tariff.py index a6509a91c2..7d35171cb7 100644 --- a/packages/modules/common/store/_tariff.py +++ b/packages/modules/common/store/_tariff.py @@ -1,11 +1,33 @@ +from datetime import timedelta +from control import data from modules.common.component_state import TariffState -from modules.common.fault_state import FaultState from modules.common.store import ValueStore from modules.common.store._api import LoggingValueStore from modules.common.store._broker import pub_to_broker +import logging -class TariffValueStoreBroker(ValueStore[TariffState]): +log = logging.getLogger(__name__) + + +class FlexibleTariffValueStore(ValueStore[TariffState]): + def __init__(self): + pass + + def set(self, state: TariffState) -> None: + self.state = state + + def update(self): + prices = self.state.prices + pub_to_broker("openWB/set/optional/ep/flexible_tariff/get/prices", prices) + log.debug(f"published prices list to MQTT having {len(prices)} entries") + + +def get_flexible_tariff_value_store() -> ValueStore[TariffState]: + return LoggingValueStore(FlexibleTariffValueStore()) + + +class GridFeeValueStore(ValueStore[TariffState]): def __init__(self): pass @@ -13,11 +35,75 @@ def set(self, state: TariffState) -> None: self.state = state def update(self): - try: - pub_to_broker("openWB/set/optional/et/get/prices", self.state.prices) - except Exception as e: - raise FaultState.from_exception(e) + prices = self.state.prices + pub_to_broker("openWB/set/optional/ep/grid_fee/get/prices", prices) + log.debug(f"published grid tariff prices list to MQTT having {len(prices)} entries") + + +def get_grid_fee_value_store() -> ValueStore[TariffState]: + return LoggingValueStore(GridFeeValueStore()) + + +class PriceValueStore(ValueStore[TariffState]): + def __init__(self): + pass + + def update(self): + pub_to_broker("openWB/set/optional/ep/get/prices", self.sum_prices()) + + def sum_prices(self): + flexible_tariff_prices = data.data.optional_data.data.electricity_pricing.flexible_tariff.get.prices + if len(flexible_tariff_prices) == 0 and data.data.optional_data.flexible_tariff_module is not None: + raise ValueError("Keine Preise für konfigurierten dynamischen Stromtarif vorhanden.") + grid_fee_prices = data.data.optional_data.data.electricity_pricing.grid_fee.get.prices + if len(grid_fee_prices) == 0 and data.data.optional_data.grid_fee_module is not None: + raise ValueError("Keine Preise für konfigurierten Netzentgelttarif vorhanden.") + flexible_tariff_prices = {float(k): v for k, v in flexible_tariff_prices.items()} + grid_fee_prices = {float(k): v for k, v in grid_fee_prices.items()} + if len(flexible_tariff_prices) == 0 and len(grid_fee_prices) > 0: + return grid_fee_prices + if len(grid_fee_prices) == 0 and len(flexible_tariff_prices) > 0: + return flexible_tariff_prices + + grid_fee_keys = sorted(grid_fee_prices.keys()) + flexible_tariff_keys = sorted(flexible_tariff_prices.keys()) + + def median_delta(keys): + """Typische Schrittweite bestimmen (Median der Deltas)""" + if len(keys) < 2: + return timedelta.max + deltas = [(keys[i+1] - keys[i]) for i in range(len(keys)-1)] + deltas.sort() + return timedelta(seconds=deltas[len(deltas)//2]) + grid_fee_delta = median_delta(grid_fee_keys) + electricity_tariff_delta = median_delta(flexible_tariff_keys) + # Feinere und gröbere Auflösung bestimmen + if grid_fee_delta < electricity_tariff_delta: + fine_dict, coarse_dict = grid_fee_prices, flexible_tariff_prices + else: + fine_dict, coarse_dict = flexible_tariff_prices, grid_fee_prices + # Intervallgrenzen für das gröbere Dict + coarse_keys = sorted(coarse_dict.keys()) + intervalle = [] + for i, start in enumerate(coarse_keys): + if i+1 < len(coarse_keys): + ende = coarse_keys[i+1] + else: + ende = max(fine_dict.keys()) + 1 + intervalle.append((start, ende)) + # Für jeden feinen Zeitstempel das passende grobe Intervall suchen und addieren + result = {} + for ts_fine, preis_fine in fine_dict.items(): + coarse_value = None + for start, ende in intervalle: + if start <= ts_fine < ende: + coarse_value = coarse_dict[start] + break + if coarse_value is None: + raise ValueError(f"Kein passendes Intervall für {ts_fine}") + result[ts_fine] = preis_fine + coarse_value + return result -def get_electricity_tariff_value_store() -> ValueStore[TariffState]: - return LoggingValueStore(TariffValueStoreBroker()) +def get_price_value_store() -> ValueStore[TariffState]: + return PriceValueStore() diff --git a/packages/modules/common/store/_tariff_test.py b/packages/modules/common/store/_tariff_test.py new file mode 100644 index 0000000000..d880a9bccc --- /dev/null +++ b/packages/modules/common/store/_tariff_test.py @@ -0,0 +1,66 @@ +from datetime import timedelta +import datetime +from typing import Dict +from unittest.mock import Mock + +import pytest + +from control import data +from control.optional import Optional +from modules.common.store._tariff import PriceValueStore + + +def make_prices(count, step, base): + start = datetime.datetime.fromtimestamp(1761127200.0) # 2025-10-22 12:00 + # json.loads macht keys immer zu str + return {str((start + timedelta(minutes=step*i)).timestamp()): base+i for i in range(count)} + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + data.data.optional_data = Optional() + + +@pytest.mark.parametrize("flexible_tariff, grid_fee, expected_prices", [ + pytest.param(make_prices(4, 15, 10), make_prices(12, 5, 1), {1761127200: 11, + 1761127500: 12, + 1761127800: 13, + 1761128100: 15, + 1761128400: 16, + 1761128700: 17, + 1761129000: 19, + 1761129300: 20, + 1761129600: 21, + 1761129900: 23, + 1761130200: 24, + 1761130500: 25}, + id="grid_fee_finer"), # grid_fee: 12x5min, flexible_tariff: 4x15min + pytest.param(make_prices(12, 5, 1), make_prices(4, 14, 10), {1761127200: 11, + 1761127500: 12, + 1761127800: 13, + 1761128100: 15, + 1761128400: 16, + 1761128700: 17, + 1761129000: 19, + 1761129300: 20, + 1761129600: 21, + 1761129900: 23, + 1761130200: 24, + 1761130500: 25}, + id="flexible tariff finer"), # flexible_tariff: 12x5min, grid_fee: 4x14min + pytest.param(make_prices(4, 15, 1), make_prices(4, 15, 10), {1761127200: 11, + 1761128100: 13, + 1761129000: 15, + 1761129900: 17}, + id="same resolution"), # flexible_tariff & grid_fee: 4x15min +]) +def test_sum_prices(flexible_tariff: Dict[int, float], + grid_fee: Dict[int, float], + expected_prices: Dict[int, float]): + value_Store = PriceValueStore() + data.data.optional_data.data.electricity_pricing.flexible_tariff.get.prices = flexible_tariff + data.data.optional_data.data.electricity_pricing.grid_fee.get.prices = grid_fee + summed = value_Store.sum_prices() + for timestamp, price in summed.items(): + assert price == expected_prices[timestamp] diff --git a/packages/modules/configuration.py b/packages/modules/configuration.py index ba1aa23150..1f1026a99b 100644 --- a/packages/modules/configuration.py +++ b/packages/modules/configuration.py @@ -6,6 +6,7 @@ import dataclass_utils from helpermodules.pub import Pub from modules.io_actions.groups import READABLE_GROUP_NAME, ActionGroup +import sys log = logging.getLogger(__name__) @@ -15,7 +16,7 @@ def pub_configurable(): _pub_configurable_backup_clouds() _pub_configurable_web_themes() _pub_configurable_display_themes() - _pub_configurable_electricity_tariffs() + _pub_configurable_tariffs() _pub_configurable_soc_modules() _pub_configurable_devices_components() _pub_configurable_chargepoints() @@ -109,40 +110,44 @@ def _pub_configurable_display_themes() -> None: log.exception("Fehler im configuration-Modul") -def _pub_configurable_electricity_tariffs() -> None: - try: - electricity_tariffs: List[Dict] = [] - path_list = Path(_get_packages_path()/"modules"/"electricity_tariffs").glob('**/tariff.py') - for path in path_list: - try: - if path.name.endswith("_test.py"): - # Tests überspringen - continue - dev_defaults = importlib.import_module( - f".electricity_tariffs.{path.parts[-2]}.tariff", - "modules").device_descriptor.configuration_factory() - electricity_tariffs.append({ - "value": dev_defaults.type, - "text": dev_defaults.name, - "defaults": dataclass_utils.asdict(dev_defaults) - }) - except Exception: - log.exception("Fehler im configuration-Modul") - electricity_tariffs = sorted(electricity_tariffs, key=lambda d: d['text'].upper()) - # "leeren" Eintrag an erster Stelle einfügen - electricity_tariffs.insert(0, - { - "value": None, - "text": "- kein Anbieter -", - "defaults": { - "type": None, - "configuration": {} - } - }) - - Pub().pub("openWB/set/system/configurable/electricity_tariffs", electricity_tariffs) - except Exception: - log.exception("Fehler im configuration-Modul") +def _pub_configurable_tariffs() -> None: + def pub(source: str): + try: + tariffs: List[Dict] = [] + path_list = Path(_get_packages_path()/"modules"/"electricity_pricing" / + f"{source}").glob('**/tariff.py') + for path in path_list: + try: + if path.name.endswith("_test.py"): + # Tests überspringen + continue + dev_defaults = importlib.import_module( + f".electricity_pricing.{source}.{path.parts[-2]}.tariff", + "modules").device_descriptor.configuration_factory() + tariffs.append({ + "value": dev_defaults.type, + "text": dev_defaults.name, + "defaults": dataclass_utils.asdict(dev_defaults) + }) + except Exception: + log.exception("Fehler im configuration-Modul") + tariffs = sorted(tariffs, key=lambda d: d['text'].upper()) + # "leeren" Eintrag an erster Stelle einfügen + tariffs.insert(0, + { + "value": None, + "text": "- kein Anbieter -", + "defaults": { + "type": None, + "configuration": {} + } + }) + + Pub().pub(f"openWB/set/system/configurable/{source}", tariffs) + except Exception: + log.exception("Fehler im configuration-Modul") + pub("flexible_tariffs") + pub("grid_fees") def _pub_configurable_soc_modules() -> None: @@ -161,8 +166,10 @@ def _pub_configurable_soc_modules() -> None: "text": dev_defaults.name, "defaults": dataclass_utils.asdict(dev_defaults) }) - except Exception: - log.exception("Fehler im configuration-Modul") + except Exception as e: + log.exception(f"Fehler {e} im configuration-Modul {path}") + if hasattr(sys, '_called_from_test'): + print(f"Fehler {e} im configuration-Modul {path}") soc_modules = sorted(soc_modules, key=lambda d: d['text'].upper()) # "leeren" Eintrag an erster Stelle einfügen soc_modules.insert(0, @@ -175,8 +182,10 @@ def _pub_configurable_soc_modules() -> None: } }) Pub().pub("openWB/set/system/configurable/soc_modules", soc_modules) - except Exception: - log.exception("Fehler im configuration-Modul") + except Exception as e: + log.exception(f"Fehler {e} im configuration-Modul {path}") + if hasattr(sys, '_called_from_test'): + print(f"Fehler {e} im configuration-Modul {path}") def _pub_configurable_devices_components() -> None: diff --git a/packages/modules/devices/alpha_ess/alpha_ess/bat.py b/packages/modules/devices/alpha_ess/alpha_ess/bat.py index 3d332d0f85..a1e8afdba3 100644 --- a/packages/modules/devices/alpha_ess/alpha_ess/bat.py +++ b/packages/modules/devices/alpha_ess/alpha_ess/bat.py @@ -1,6 +1,6 @@ import logging import time -from typing import TypedDict, Any +from typing import TypedDict, Any, Optional from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor @@ -57,5 +57,28 @@ def update(self) -> None: ) self.store.set(bat_state) + def set_power_limit(self, power_limit: Optional[int]) -> None: + unit = self.__modbus_id + + if power_limit is None: + # Kein Powerlimit gefordert, externe Steuerung deaktivieren + log.debug("Keine Batteriesteuerung gefordert, deaktiviere externe Steuerung.") + self.__tcp_client.write_registers(2127, [0], data_type=ModbusDataType.UINT_16, unit=unit) + elif power_limit <= 0: + # AlphaESS kann die Entladung nur über den SoC verhindern (komplette Entladesperre) + # Netzladung mit geringen Ziel SoC verhindert auch Entladung (Default 10%) + # Zeiten für Netzladung müssen im Wechselrichter aktiviert werden + log.debug("Aktive Batteriesteuerung vorhanden. Setze externe Steuerung.") + self.__tcp_client.write_registers(2127, [1], data_type=ModbusDataType.UINT_16, unit=unit) + self.__tcp_client.write_registers(2133, [10], data_type=ModbusDataType.UINT_16, unit=unit) + else: + # Aktive Ladung + log.debug("Aktive Batteriesteuerung vorhanden. Setze externe Steuerung.") + self.__tcp_client.write_registers(2127, [1], data_type=ModbusDataType.UINT_16, unit=unit) + self.__tcp_client.write_registers(2133, [100], data_type=ModbusDataType.UINT_16, unit=unit) + + def power_limit_controllable(self) -> bool: + return True + component_descriptor = ComponentDescriptor(configuration_factory=AlphaEssBatSetup) diff --git a/packages/modules/devices/alpha_ess/alpha_ess/device.py b/packages/modules/devices/alpha_ess/alpha_ess/device.py index 693a2edc12..e8d5f59dc0 100644 --- a/packages/modules/devices/alpha_ess/alpha_ess/device.py +++ b/packages/modules/devices/alpha_ess/alpha_ess/device.py @@ -61,7 +61,12 @@ def initializer(): device_config.configuration.ip_address, device_config.configuration.port) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + if device_config.configuration.source == 0: + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + "192.168.193.125"]) + else: + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + device_config.configuration.ip_address]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/avm/avm/counter.py b/packages/modules/devices/avm/avm/counter.py index fb73a393c6..2c5ea65678 100644 --- a/packages/modules/devices/avm/avm/counter.py +++ b/packages/modules/devices/avm/avm/counter.py @@ -34,7 +34,7 @@ def update(self, deviceListElementTree: Element): if voltageInfo is not None: voltages = [float(voltageInfo.text)/1000, 0, 0] # AVM returns Wh - imported = powermeterBlock.find("energy").text + imported = float(powermeterBlock.find("energy").text) counter_state = CounterState( imported=imported, diff --git a/packages/modules/devices/avm/avm/device.py b/packages/modules/devices/avm/avm/device.py index 942c39e5da..db1fb7c51a 100644 --- a/packages/modules/devices/avm/avm/device.py +++ b/packages/modules/devices/avm/avm/device.py @@ -42,7 +42,11 @@ def update_components(components: Iterable[AvmCounter]): def get_session_id(): # checking existing sessionID - response = req.get_http_session().post(f"http://{device_config.configuration.ip_address}/login_sid.lua") + data = { + 'content-type': 'application/text' + } + response = req.get_http_session().post( + f"http://{device_config.configuration.ip_address}/login_sid.lua", data=data, timeout=5) challengeResponse = ET.fromstring(response.content) session_id = challengeResponse.find('SID').text if session_id != INVALID_SESSIONID: diff --git a/packages/modules/electricity_tariffs/awattar/__init__.py b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/__init__.py similarity index 100% rename from packages/modules/electricity_tariffs/awattar/__init__.py rename to packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/__init__.py diff --git a/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/config.py b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/config.py new file mode 100644 index 0000000000..b05c3b6ac0 --- /dev/null +++ b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/config.py @@ -0,0 +1,38 @@ +from typing import Optional + +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +class ZCS3PConfiguration: + def __init__(self, modbus_id: int = 1, ip_address: Optional[str] = None, port: int = 502): + self.modbus_id = modbus_id + self.ip_address = ip_address + self.port = port + + +class ZCS3P: + def __init__(self, + name: str = "Azzurro - ZCS 3PH 12KTL", + type: str = "azzurro_zcs_3p", + id: int = 0, + configuration: ZCS3PConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or ZCS3PConfiguration() + + +class ZCSPvInverterConfiguration: + def __init__(self): + pass + + +class ZCSPvInverterSetup(ComponentSetup[ZCSPvInverterConfiguration]): + def __init__(self, + name: str = "ZCS Azzurro Wechselrichter", + type: str = "pv_inverter", + id: int = 0, + configuration: ZCSPvInverterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or ZCSPvInverterConfiguration()) diff --git a/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/device.py b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/device.py new file mode 100644 index 0000000000..a276877b47 --- /dev/null +++ b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/device.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.azzurro_zcs.azzurro_zcs_3p.config import ZCS3P, ZCSPvInverterSetup +from modules.devices.azzurro_zcs.azzurro_zcs_3p.pv_inverter import ZCSPvInverter + +log = logging.getLogger(__name__) + + +def create_device(device_config: ZCS3P): + client = None + + def create_pv_inverter_component(component_config: ZCSPvInverterSetup): + nonlocal client + return ZCSPvInverter(component_config=component_config, + modbus_id=device_config.configuration.modbus_id, + client=client) + + def update_components(components: Iterable[ZCSPvInverter]): + nonlocal client + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + pv_inverter=create_pv_inverter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=ZCS3P) diff --git a/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/pv_inverter.py b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/pv_inverter.py new file mode 100644 index 0000000000..8b878fac4b --- /dev/null +++ b/packages/modules/devices/azzurro_zcs/azzurro_zcs_3p/pv_inverter.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +import logging +from typing import TypedDict, Any +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.store import get_inverter_value_store +from modules.devices.azzurro_zcs.azzurro_zcs_3p.config import ZCSPvInverterSetup + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + client: ModbusTcpClient_ + modbus_id: int + + +class ZCSPvInverter(AbstractInverter): + def __init__(self, component_config: ZCSPvInverterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__modbus_id: int = self.kwargs['modbus_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.store = get_inverter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + power = exported = 0 + currents = None + try: + power = self.client.read_holding_registers(0x0485, ModbusDataType.INT_16, unit=self.__modbus_id)*10 + exported = self.client.read_holding_registers(0x0684, ModbusDataType.UINT_32, unit=self.__modbus_id)*10 + currents = [ + self.client.read_holding_registers(0x48E, ModbusDataType.INT_16, unit=self.__modbus_id)*0.01, + self.client.read_holding_registers(0x499, ModbusDataType.INT_16, unit=self.__modbus_id)*0.01, + self.client.read_holding_registers(0x4A4, ModbusDataType.INT_16, unit=self.__modbus_id)*0.01 + ] + except Exception: + log.debug("Modbus could not be read.") + + inverter_state = InverterState( + currents=currents, + power=power, + exported=exported + ) + self.store.set(inverter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=ZCSPvInverterSetup) diff --git a/packages/modules/devices/batterx/batterx/bat.py b/packages/modules/devices/batterx/batterx/bat.py index 2a5b6949a8..4fedc16f4d 100644 --- a/packages/modules/devices/batterx/batterx/bat.py +++ b/packages/modules/devices/batterx/batterx/bat.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -from typing import Dict, TypedDict, Any +import logging +from typing import Dict, TypedDict, Any, Optional from modules.devices.batterx.batterx.config import BatterXBatSetup from modules.common.abstract_device import AbstractBat @@ -8,10 +9,14 @@ from modules.common.fault_state import ComponentInfo, FaultState from modules.common.simcount import SimCounter from modules.common.store import get_bat_value_store +from modules.common import req + +log = logging.getLogger(__name__) class KwargsDict(TypedDict): device_id: int + ip_address: str class BatterXBat(AbstractBat): @@ -21,9 +26,11 @@ def __init__(self, component_config: BatterXBatSetup, **kwargs: Any) -> None: def initialize(self) -> None: self.__device_id: int = self.kwargs['device_id'] + self.__ip_address: str = self.kwargs['ip_address'] self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="speicher") self.store = get_bat_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self.last_mode = 'Undefined' def update(self, resp: Dict) -> None: power = resp["1121"]["1"] @@ -37,5 +44,57 @@ def update(self, resp: Dict) -> None: ) self.store.set(bat_state) + def set_power_limit(self, power_limit: Optional[int]) -> None: + log.debug(f'last_mode: {self.last_mode}') + + if power_limit is None: + # Kein Powerlimit gefordert, externe Steuerung deaktivieren + log.debug("Keine Batteriesteuerung gefordert, deaktiviere externe Steuerung.") + if self.last_mode is not None: + # Battery Charge AC - OFF + req.get_http_session().get( + f"http://{self.__ip_address}/api.php?set=command&type=20738&text1=3&text2=0", + timeout=5 + ) + # Battery Discharging - ON + req.get_http_session().get( + f"http://{self.__ip_address}/api.php?set=command&type=20738&text1=4&text2=1", + timeout=5 + ) + self.last_mode = None + elif power_limit <= 0: + # BatterX kann Entladung nur komplett sperren + log.debug("Aktive Batteriesteuerung angestoßen. Setze Entladesperre.") + if self.last_mode != 'stop': + # Battery Charge AC - OFF + req.get_http_session().get( + f"http://{self.__ip_address}/api.php?set=command&type=20738&text1=3&text2=0", + timeout=5 + ) + # Battery Discharging - OFF + req.get_http_session().get( + f"http://{self.__ip_address}/api.php?set=command&type=20738&text1=4&text2=0", + timeout=5 + ) + self.last_mode = 'stop' + else: + # Aktive Ladung + log.debug("Aktive Batteriesteuerung angestoßen. Setze aktive Ladung.") + if self.last_mode != 'charge': + # Battery Charge AC - ON + req.get_http_session().get( + f"http://{self.__ip_address}/api.php?set=command&type=20738&text1=3&text2=1", + timeout=5 + ) + # Battery Discharging - OFF + req.get_http_session().get( + f"http://{self.__ip_address}/api.php?set=command&type=20738&text1=4&text2=0", + timeout=5 + ) + self.last_mode = 'charge' + + def power_limit_controllable(self) -> bool: + return True + component_descriptor = ComponentDescriptor(configuration_factory=BatterXBatSetup) diff --git a/packages/modules/devices/batterx/batterx/device.py b/packages/modules/devices/batterx/batterx/device.py index d015deb74b..adde58e310 100644 --- a/packages/modules/devices/batterx/batterx/device.py +++ b/packages/modules/devices/batterx/batterx/device.py @@ -23,7 +23,9 @@ def create_device(device_config: BatterX): def create_bat_component(component_config: BatterXBatSetup): - return bat.BatterXBat(component_config=component_config, device_id=device_config.id) + return bat.BatterXBat(component_config=component_config, + device_id=device_config.id, + ip_address=device_config.configuration.ip_address) def create_counter_component(component_config: BatterXCounterSetup): return counter.BatterXCounter(component_config=component_config, device_id=device_config.id) diff --git a/packages/modules/electricity_tariffs/energycharts/__init__.py b/packages/modules/devices/chint/__init__ .py similarity index 100% rename from packages/modules/electricity_tariffs/energycharts/__init__.py rename to packages/modules/devices/chint/__init__ .py diff --git a/packages/modules/electricity_tariffs/fixed_hours/__init__.py b/packages/modules/devices/chint/chint/__init__.py similarity index 100% rename from packages/modules/electricity_tariffs/fixed_hours/__init__.py rename to packages/modules/devices/chint/chint/__init__.py diff --git a/packages/modules/devices/chint/chint/config.py b/packages/modules/devices/chint/chint/config.py new file mode 100644 index 0000000000..377124ab62 --- /dev/null +++ b/packages/modules/devices/chint/chint/config.py @@ -0,0 +1,42 @@ +from typing import Optional +from helpermodules.auto_str import auto_str +from modules.common.component_setup import ComponentSetup + +from ..vendor import vendor_descriptor + + +@auto_str +class CHINTConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: int = 8899): + self.ip_address = ip_address + self.port = port + + +@auto_str +class CHINT: + def __init__(self, + name: str = "CHINT", + type: str = "chint", + id: int = 0, + configuration: CHINTConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or CHINTConfiguration() + + +@auto_str +class CHINTCounterConfiguration: + def __init__(self, modbus_id: int = 1): + self.modbus_id = modbus_id + + +@auto_str +class CHINTCounterSetup(ComponentSetup[CHINTCounterConfiguration]): + def __init__(self, + name: str = "CHINT DTSU666 Zähler", + type: str = "counter", + id: int = 0, + configuration: CHINTCounterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or CHINTCounterConfiguration()) diff --git a/packages/modules/devices/chint/chint/counter.py b/packages/modules/devices/chint/chint/counter.py new file mode 100644 index 0000000000..8b0189d453 --- /dev/null +++ b/packages/modules/devices/chint/chint/counter.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import logging +from typing import TypedDict, Any +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.store import get_counter_value_store +from modules.devices.chint.chint.config import CHINTCounterSetup + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + client: ModbusTcpClient_ + + +class CHINTCounter(AbstractCounter): + def __init__(self, component_config: CHINTCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.store = get_counter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self.__modbus_id = self.component_config.configuration.modbus_id + + def update(self): + powers = voltages = currents = power_factors = None + imported_ep = exported_ep = power = frequency = 0 + irat = self.client.read_holding_registers(0x0006, ModbusDataType.INT_16, unit=self.__modbus_id) + urat = self.client.read_holding_registers(0x0007, ModbusDataType.INT_16, unit=self.__modbus_id) + power_ratio = urat*0.1*irat*0.1 + + frequency = self.client.read_holding_registers(0x2044, ModbusDataType.FLOAT_32, unit=self.__modbus_id)/100 + power = self.client.read_holding_registers(0x2012, + ModbusDataType.FLOAT_32, unit=self.__modbus_id) * power_ratio + powers = [self.client.read_holding_registers(reg, ModbusDataType.FLOAT_32, unit=self.__modbus_id) * power_ratio + for reg in [0x2014, 0x2016, 0x2018]] + voltage_ratio = urat*0.1*0.1 + voltages = [self.client.read_holding_registers( + reg, ModbusDataType.FLOAT_32, unit=self.__modbus_id) * voltage_ratio + for reg in [0x2006, 0x2008, 0x200A]] + current_ratio = irat*0.001 + currents = [self.client.read_holding_registers( + reg, ModbusDataType.FLOAT_32, unit=self.__modbus_id) * current_ratio + for reg in [0x200C, 0x200E, 0x2010]] + power_factors = [self.client.read_holding_registers(reg, ModbusDataType.FLOAT_32, unit=self.__modbus_id) * 0.001 + for reg in [0x202C, 0x202E, 0x2030]] + ep_ratio = irat * urat * 100 + imported_ep = self.client.read_holding_registers(0x401E, + ModbusDataType.FLOAT_32, unit=self.__modbus_id) * ep_ratio + exported_ep = self.client.read_holding_registers(0x4028, + ModbusDataType.FLOAT_32, unit=self.__modbus_id) * ep_ratio + + counter_state = CounterState( + currents=currents, + imported=imported_ep, + exported=exported_ep, + power=power, + frequency=frequency, + power_factors=power_factors, + powers=powers, + voltages=voltages + ) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=CHINTCounterSetup) diff --git a/packages/modules/devices/chint/chint/device.py b/packages/modules/devices/chint/chint/device.py new file mode 100644 index 0000000000..ff7b982add --- /dev/null +++ b/packages/modules/devices/chint/chint/device.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable, Union + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.chint.chint.config import CHINT, CHINTCounterSetup +from modules.devices.chint.chint.counter import CHINTCounter + +log = logging.getLogger(__name__) + + +def create_device(device_config: CHINT): + client = None + + def create_counter_component(component_config: CHINTCounterSetup): + nonlocal client + return CHINTCounter(component_config=component_config, client=client) + + def update_components(components: Iterable[Union[CHINTCounter]]): + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + counter=create_counter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=CHINT) diff --git a/packages/modules/devices/chint/vendor.py b/packages/modules/devices/chint/vendor.py new file mode 100644 index 0000000000..fa72f537e2 --- /dev/null +++ b/packages/modules/devices/chint/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "CHINT" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/devices/elgris/elgris/elgris.py b/packages/modules/devices/elgris/elgris/elgris.py index 74d5e8272c..422afca3ee 100644 --- a/packages/modules/devices/elgris/elgris/elgris.py +++ b/packages/modules/devices/elgris/elgris/elgris.py @@ -7,7 +7,5 @@ class Elgris(Sdm630_72): def __init__(self, modbus_id: int, client: modbus.ModbusTcpClient_, fault_state: FaultState) -> None: self.client = client self.id = modbus_id - self.last_query = self._get_time_ms() - self.WAIT_MS_BETWEEN_QUERIES = 100 self.serial_number = "" self.fault_state = fault_state diff --git a/packages/modules/devices/fronius/fronius/config.py b/packages/modules/devices/fronius/fronius/config.py index 4f6953ccb3..c8e4f1a500 100644 --- a/packages/modules/devices/fronius/fronius/config.py +++ b/packages/modules/devices/fronius/fronius/config.py @@ -112,3 +112,18 @@ def __init__(self, id: int = 0, configuration: FroniusSecondaryInverterConfiguration = None) -> None: super().__init__(name, type, id, configuration or FroniusSecondaryInverterConfiguration()) + + +class FroniusProductionMeterConfiguration: + def __init__(self, meter_id: int = 0, variant: int = 0): + self.meter_id = meter_id + self.variant = variant + + +class FroniusProductionMeterSetup(ComponentSetup[FroniusProductionMeterConfiguration]): + def __init__(self, + name: str = "Fronius Erzeugerzähler", + type: str = "inverter_production_meter", + id: int = 0, + configuration: FroniusProductionMeterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or FroniusProductionMeterConfiguration()) diff --git a/packages/modules/devices/fronius/fronius/device.py b/packages/modules/devices/fronius/fronius/device.py index a8eb412a7b..842c213cfb 100644 --- a/packages/modules/devices/fronius/fronius/device.py +++ b/packages/modules/devices/fronius/fronius/device.py @@ -10,16 +10,17 @@ from modules.devices.fronius.fronius.bat import FroniusBat from modules.devices.fronius.fronius.config import (Fronius, FroniusBatSetup, FroniusSecondaryInverterSetup, FroniusSmCounterSetup, FroniusS0CounterSetup, - FroniusInverterSetup) + FroniusProductionMeterSetup, FroniusInverterSetup) from modules.devices.fronius.fronius.counter_s0 import FroniusS0Counter from modules.devices.fronius.fronius.counter_sm import FroniusSmCounter from modules.devices.fronius.fronius.inverter import FroniusInverter from modules.devices.fronius.fronius.inverter_secondary import FroniusSecondaryInverter +from modules.devices.fronius.fronius.inverter_production_meter import FroniusProductionMeter log = logging.getLogger(__name__) -fronius_component_classes = Union[FroniusBat, FroniusSmCounter, - FroniusS0Counter, FroniusInverter, FroniusSecondaryInverter] +fronius_component_classes = Union[FroniusBat, FroniusSmCounter, FroniusS0Counter, + FroniusInverter, FroniusSecondaryInverter, FroniusProductionMeter] def create_device(device_config: Fronius): @@ -46,6 +47,11 @@ def create_inverter_secondary_component(component_config: FroniusSecondaryInvert return FroniusSecondaryInverter(component_config=component_config, device_id=device_config.id) + def create_inverter_production_meter_component(component_config: FroniusProductionMeterSetup): + return FroniusProductionMeter(component_config=component_config, + device_id=device_config.id, + device_config=device_config.configuration) + def update_components(components: Iterable[fronius_component_classes]): inverter_response = None for component in components: @@ -80,6 +86,7 @@ def update_components(components: Iterable[fronius_component_classes]): counter_s0=create_counter_s0_component, inverter=create_inverter_component, inverter_secondary=create_inverter_secondary_component, + inverter_production_meter=create_inverter_production_meter_component, ), component_updater=MultiComponentUpdater(update_components) ) diff --git a/packages/modules/devices/fronius/fronius/inverter_production_meter.py b/packages/modules/devices/fronius/fronius/inverter_production_meter.py new file mode 100644 index 0000000000..a806eb1efe --- /dev/null +++ b/packages/modules/devices/fronius/fronius/inverter_production_meter.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +import logging +from typing import TypedDict, Any + +from requests import Session + +from modules.common import req +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount import SimCounter +from modules.common.store import get_inverter_value_store +from modules.devices.fronius.fronius.config import FroniusConfiguration, MeterLocation +from modules.devices.fronius.fronius.config import FroniusProductionMeterSetup + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + device_id: int + device_config: FroniusConfiguration + + +class FroniusProductionMeter(AbstractInverter): + def __init__(self, component_config: FroniusProductionMeterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.device_config: FroniusConfiguration = self.kwargs['device_config'] + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="pv") + self.store = get_inverter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + session = req.get_http_session() + variant = self.component_config.configuration.variant + if variant == 0 or variant == 1: + inverter_state = self.__update_variant_0_1(session) + elif variant == 2: + inverter_state = self.__update_variant_2(session) + else: + raise ValueError("Unbekannte Variante: "+str(variant)) + self.store.set(inverter_state) + + def __update_variant_0_1(self, session: Session) -> InverterState: + variant = self.component_config.configuration.variant + meter_id = self.component_config.configuration.meter_id + if variant == 0: + params = ( + ('Scope', 'Device'), + ('DeviceId', meter_id), + ) + elif variant == 1: + params = ( + ('Scope', 'Device'), + ('DeviceId', meter_id), + ('DataCollection', 'MeterRealtimeData'), + ) + else: + raise ValueError("Unbekannte Generation: "+str(variant)) + response = session.get( + 'http://' + self.device_config.ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', + params=params, + timeout=5) + response_json_id = response.json()["Body"]["Data"] + + meter_location = MeterLocation.get(response_json_id["Meter_Location_Current"]) + log.debug("Einbauort: "+str(meter_location)) + + powers = [response_json_id["PowerReal_P_Phase_"+str(num)] for num in range(1, 4)] + if meter_location == MeterLocation.grid: + raise ValueError("Fehler: Dieser Zähler ist kein Erzeugerzähler.") + else: + power = response_json_id["PowerReal_P_Sum"] * -1 + voltages = [response_json_id["Voltage_AC_Phase_"+str(num)] for num in range(1, 4)] + currents = [powers[i] / voltages[i] for i in range(0, 3)] + _, exported = self.sim_counter.sim_count(power) + return InverterState( + currents=currents, + power=power, + exported=exported + ) + + def __update_variant_2(self, session: Session) -> InverterState: + meter_id = str(self.component_config.configuration.meter_id) + response = session.get( + 'http://' + self.device_config.ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', + params=(('Scope', 'System'),), + timeout=5) + response_json_id = dict(response.json()["Body"]["Data"]).get(meter_id) + + meter_location = MeterLocation.get(response_json_id["SMARTMETER_VALUE_LOCATION_U16"]) + log.debug("Einbauort: "+str(meter_location)) + + powers = [response_json_id["SMARTMETER_POWERACTIVE_MEAN_0"+str(num)+"_F64"] for num in range(1, 4)] + if meter_location == MeterLocation.grid: + raise ValueError("Fehler: Dieser Zähler ist kein Erzeugerzähler.") + else: + power = response_json_id["SMARTMETER_POWERACTIVE_MEAN_SUM_F64"] + voltages = [response_json_id["SMARTMETER_VOLTAGE_0"+str(num)+"_F64"] for num in range(1, 4)] + currents = [powers[i] / voltages[i] for i in range(0, 3)] + _, exported = self.sim_counter.sim_count(power) + return InverterState( + currents=currents, + power=power, + exported=exported + ) + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusProductionMeterSetup) diff --git a/packages/modules/devices/fronius/fronius/inverter_production_meter_test.py b/packages/modules/devices/fronius/fronius/inverter_production_meter_test.py new file mode 100644 index 0000000000..8fddaafb65 --- /dev/null +++ b/packages/modules/devices/fronius/fronius/inverter_production_meter_test.py @@ -0,0 +1,121 @@ +from unittest.mock import Mock + +import pytest +import requests_mock + +from dataclass_utils import dataclass_from_dict +from helpermodules import compatibility +from modules.conftest import SAMPLE_IP +from modules.common.component_state import InverterState +from modules.devices.fronius.fronius import inverter_production_meter +from modules.devices.fronius.fronius.config import FroniusConfiguration, FroniusProductionMeterSetup +from test_utils.mock_ramdisk import MockRamdisk + + +@pytest.fixture +def mock_ramdisk(monkeypatch): + monkeypatch.setattr(compatibility, "is_ramdisk_in_use", lambda: True) + return MockRamdisk(monkeypatch) + + +def test_production_count(monkeypatch, requests_mock: requests_mock.mock): + mock_inverter_value_store = Mock() + monkeypatch.setattr(inverter_production_meter, "get_inverter_value_store", + Mock(return_value=mock_inverter_value_store)) + requests_mock.get(f"http://{SAMPLE_IP}/solar_api/v1/GetMeterRealtimeData.cgi", json=json_ext_var2) + mock_inverter_value_store = Mock() + monkeypatch.setattr(inverter_production_meter, "get_inverter_value_store", + Mock(return_value=mock_inverter_value_store)) + + component_config = FroniusProductionMeterSetup() + component_config.configuration.variant = 2 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + component_config.configuration.meter_id = 1 + i = inverter_production_meter.FroniusProductionMeter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + i.initialize() + + # execution + i.update() + + # evaluation + assert vars(mock_inverter_value_store.set.call_args[0][0]) == vars(SAMPLE_INVERTER_STATE) + + +SAMPLE_INVERTER_STATE = InverterState(power=3809.4, + currents=[-5.373121093182142, -5.664436188811191, -5.585225225225224], + exported=200) + + +json_ext_var2 = { + "Body": { + "Data": { + "1": { + "ACBRIDGE_CURRENT_ACTIVE_MEAN_01_F32": -8.4849999999999994, + "ACBRIDGE_CURRENT_ACTIVE_MEAN_02_F32": -8.5009999999999994, + "ACBRIDGE_CURRENT_ACTIVE_MEAN_03_F32": -8.5350000000000001, + "ACBRIDGE_CURRENT_AC_SUM_NOW_F64": -25.520999999999997, + "ACBRIDGE_VOLTAGE_MEAN_12_F32": 396.69999999999999, + "ACBRIDGE_VOLTAGE_MEAN_23_F32": 396.80000000000001, + "ACBRIDGE_VOLTAGE_MEAN_31_F32": 397.19999999999999, + "COMPONENTS_MODE_ENABLE_U16": 1.0, + "COMPONENTS_MODE_VISIBLE_U16": 1.0, + "COMPONENTS_TIME_STAMP_U64": 1611650230.0, + "Details": { + "Manufacturer": "Fronius", + "Model": "Smart Meter TS 65A-3", + "Serial": "1234567890" + }, + "GRID_FREQUENCY_MEAN_F32": 49.899999999999999, + "SMARTMETER_ENERGYACTIVE_ABSOLUT_MINUS_F64": 28233.0, + "SMARTMETER_ENERGYACTIVE_ABSOLUT_PLUS_F64": 5094426.0, + "SMARTMETER_ENERGYACTIVE_CONSUMED_SUM_F64": 28233.0, + "SMARTMETER_ENERGYACTIVE_PRODUCED_SUM_F64": 5094426.0, + "SMARTMETER_ENERGYREACTIVE_CONSUMED_SUM_F64": 5905771.0, + "SMARTMETER_ENERGYREACTIVE_PRODUCED_SUM_F64": 31815.0, + "SMARTMETER_FACTOR_POWER_01_F64": 0.64300000000000002, + "SMARTMETER_FACTOR_POWER_02_F64": 0.68000000000000005, + "SMARTMETER_FACTOR_POWER_03_F64": 0.66700000000000004, + "SMARTMETER_FACTOR_POWER_SUM_F64": 0.66300000000000003, + "SMARTMETER_POWERACTIVE_01_F64": 1229.7, + "SMARTMETER_POWERACTIVE_02_F64": 1298.0999999999999, + "SMARTMETER_POWERACTIVE_03_F64": 1281.5, + "SMARTMETER_POWERACTIVE_MEAN_01_F64": -1232.0566666666653, + "SMARTMETER_POWERACTIVE_MEAN_02_F64": -1296.0230000000006, + "SMARTMETER_POWERACTIVE_MEAN_03_F64": -1281.2506666666663, + "SMARTMETER_POWERACTIVE_MEAN_SUM_F64": 3809.4000000000001, + "SMARTMETER_POWERAPPARENT_01_F64": 1911.8, + "SMARTMETER_POWERAPPARENT_02_F64": 1910.0999999999999, + "SMARTMETER_POWERAPPARENT_03_F64": 1922.3, + "SMARTMETER_POWERAPPARENT_MEAN_01_F64": 1910.7656666666664, + "SMARTMETER_POWERAPPARENT_MEAN_02_F64": 1904.090666666666, + "SMARTMETER_POWERAPPARENT_MEAN_03_F64": 1923.9343333333331, + "SMARTMETER_POWERAPPARENT_MEAN_SUM_F64": 5744.3000000000002, + "SMARTMETER_POWERREACTIVE_01_F64": 1463.8, + "SMARTMETER_POWERREACTIVE_02_F64": 1401.0999999999999, + "SMARTMETER_POWERREACTIVE_03_F64": 1432.8, + "SMARTMETER_POWERREACTIVE_MEAN_SUM_F64": 4297.8999999999996, + "SMARTMETER_VALUE_LOCATION_U16": 3.0, + "SMARTMETER_VOLTAGE_01_F64": 229.30000000000001, + "SMARTMETER_VOLTAGE_02_F64": 228.80000000000001, + "SMARTMETER_VOLTAGE_03_F64": 229.40000000000001, + "SMARTMETER_VOLTAGE_MEAN_01_F64": 228.8716666666669, + "SMARTMETER_VOLTAGE_MEAN_02_F64": 228.90133333333321, + "SMARTMETER_VOLTAGE_MEAN_03_F64": 229.3593333333333 + } + } + }, + "Head": { + "RequestArguments": { + "DeviceClass": "Meter", + "Scope": "System" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-01-26T08:37:11+00:00" + } +} diff --git a/packages/modules/devices/generic/mqtt/bat.py b/packages/modules/devices/generic/mqtt/bat.py index bbb1026387..08d8b94e02 100644 --- a/packages/modules/devices/generic/mqtt/bat.py +++ b/packages/modules/devices/generic/mqtt/bat.py @@ -34,8 +34,8 @@ def parse_received_topics(value: str): currents = parse_received_topics("currents") power = received_topics[f"{topic_prefix}power"] soc = received_topics[f"{topic_prefix}soc"] - if (received_topics.get(f"{topic_prefix}imported") and - received_topics.get(f"{topic_prefix}exported")): + if (received_topics.get(f"{topic_prefix}imported") is not None and + received_topics.get(f"{topic_prefix}exported") is not None): imported = received_topics[f"{topic_prefix}imported"] exported = received_topics[f"{topic_prefix}exported"] else: diff --git a/packages/modules/devices/generic/mqtt/inverter.py b/packages/modules/devices/generic/mqtt/inverter.py index 26593035d4..a29a0980ce 100644 --- a/packages/modules/devices/generic/mqtt/inverter.py +++ b/packages/modules/devices/generic/mqtt/inverter.py @@ -31,10 +31,16 @@ def parse_received_topics(value: str): # [] für erforderliche Topics, .get() für optionale Topics topic_prefix = f"openWB/mqtt/pv/{self.component_config.id}/get/" power = received_topics[f"{topic_prefix}power"] - if received_topics.get(f"openWB/mqtt/pv/{self.component_config.id}/get/exported"): - exported = received_topics[f"{topic_prefix}exported"] + + if received_topics.get(f"{topic_prefix}exported") is None: + imported, exported = self.sim_counter.sim_count(power) else: - exported = self.sim_counter.sim_count(power)[1] + exported = received_topics[f"{topic_prefix}exported"] + if received_topics.get(f"{topic_prefix}imported") is None: + imported = 0 + else: + imported = received_topics[f"{topic_prefix}imported"] + currents = parse_received_topics("currents") dc_power = parse_received_topics("dc_power") @@ -42,6 +48,7 @@ def parse_received_topics(value: str): currents=currents, power=power, exported=exported, + imported=imported, dc_power=dc_power ) self.store.set(inverter_state) diff --git a/packages/modules/devices/growatt/growatt/device.py b/packages/modules/devices/growatt/growatt/device.py index 139c168b4b..bcc91b8c9b 100644 --- a/packages/modules/devices/growatt/growatt/device.py +++ b/packages/modules/devices/growatt/growatt/device.py @@ -53,7 +53,8 @@ def initializer(): client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + device_config.configuration.ip_address]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/huawei/huawei/device.py b/packages/modules/devices/huawei/huawei/device.py index 7869c6c637..b8a9f68b27 100644 --- a/packages/modules/devices/huawei/huawei/device.py +++ b/packages/modules/devices/huawei/huawei/device.py @@ -63,7 +63,12 @@ def initializer(): device_config.configuration.port) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + if HuaweiType(device_config.configuration.type) == HuaweiType.Huawei_Kit: + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + "192.168.193.126"]) + else: + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + device_config.configuration.ip_address]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/huawei/huawei_emma/device.py b/packages/modules/devices/huawei/huawei_emma/device.py index 3ccd4e04b1..d31699e6db 100644 --- a/packages/modules/devices/huawei/huawei_emma/device.py +++ b/packages/modules/devices/huawei/huawei_emma/device.py @@ -54,7 +54,8 @@ def initializer(): device_config.configuration.port) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + device_config.configuration.ip_address]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/electricity_tariffs/ostrom/__init__.py b/packages/modules/devices/idm/__init__.py similarity index 100% rename from packages/modules/electricity_tariffs/ostrom/__init__.py rename to packages/modules/devices/idm/__init__.py diff --git a/packages/modules/electricity_tariffs/rabot/__init__.py b/packages/modules/devices/idm/idm/__init__.py similarity index 100% rename from packages/modules/electricity_tariffs/rabot/__init__.py rename to packages/modules/devices/idm/idm/__init__.py diff --git a/packages/modules/devices/idm/idm/config.py b/packages/modules/devices/idm/idm/config.py new file mode 100644 index 0000000000..99edcdddc8 --- /dev/null +++ b/packages/modules/devices/idm/idm/config.py @@ -0,0 +1,42 @@ +from typing import Optional +from helpermodules.auto_str import auto_str +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +@auto_str +class IDMConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: int = 502, modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class IDM: + def __init__(self, + name: str = "IDM", + type: str = "idm", + id: int = 0, + configuration: IDMConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or IDMConfiguration() + + +@auto_str +class IDMCounterConfiguration: + def __init__(self): + pass + + +@auto_str +class IDMCounterSetup(ComponentSetup[IDMCounterConfiguration]): + def __init__(self, + name: str = "IDM Zähler", + type: str = "counter", + id: int = 0, + configuration: IDMCounterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or IDMCounterConfiguration()) diff --git a/packages/modules/devices/idm/idm/counter.py b/packages/modules/devices/idm/idm/counter.py new file mode 100644 index 0000000000..44ff2ab5b9 --- /dev/null +++ b/packages/modules/devices/idm/idm/counter.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any +from pymodbus.constants import Endian + +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount import SimCounter +from modules.common.store import get_counter_value_store +from modules.devices.idm.idm.config import IDMCounterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + modbus_id: int + + +class IDMCounter(AbstractCounter): + def __init__(self, component_config: IDMCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.modbus_id: int = self.kwargs['modbus_id'] + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="bezug") + self.store = get_counter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self): + unit = self.modbus_id + power = self.client.read_input_registers(4122, ModbusDataType.FLOAT_32, + wordorder=Endian.Little, unit=unit) * 1000 + imported, exported = self.sim_counter.sim_count(power) + + counter_state = CounterState( + imported=imported, + exported=exported, + power=power, + ) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=IDMCounterSetup) diff --git a/packages/modules/devices/idm/idm/device.py b/packages/modules/devices/idm/idm/device.py new file mode 100644 index 0000000000..c7c2983201 --- /dev/null +++ b/packages/modules/devices/idm/idm/device.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.idm.idm.config import IDM, IDMCounterSetup +from modules.devices.idm.idm.counter import IDMCounter + +log = logging.getLogger(__name__) + + +def create_device(device_config: IDM): + client = None + + def create_counter_component(component_config: IDMCounterSetup): + nonlocal client + return IDMCounter(component_config, + device_id=device_config.id, + client=client, + modbus_id=device_config.configuration.modbus_id) + + def update_components(components: Iterable[IDMCounter]): + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + counter=create_counter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=IDM) diff --git a/packages/modules/devices/idm/vendor.py b/packages/modules/devices/idm/vendor.py new file mode 100644 index 0000000000..e2d6437048 --- /dev/null +++ b/packages/modules/devices/idm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "IDM" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/devices/kaco/kaco_nh/bat.py b/packages/modules/devices/kaco/kaco_nh/bat.py new file mode 100644 index 0000000000..01ca5c7159 --- /dev/null +++ b/packages/modules/devices/kaco/kaco_nh/bat.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +from typing import Any, TypedDict + +from modules.common import req +from modules.common.abstract_device import AbstractBat +from modules.common.component_state import BatState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount import SimCounter +from modules.common.store import get_bat_value_store +from modules.devices.kaco.kaco_nh.config import KacoNHBatSetup +from modules.devices.kaco.kaco_nh.config import KacoNHConfiguration + + +class KwargsDict(TypedDict): + device_config: KacoNHConfiguration + device_id: int + + +class KacoNHBat(AbstractBat): + def __init__(self, component_config: KacoNHBatSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.device_config: KacoNHConfiguration = self.kwargs['device_config'] + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="speicher") + self.store = get_bat_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + response = req.get_http_session().get( + 'http://' + self.device_config.ip_address + ':' + str(self.device_config.port) + '/getdevdata.cgi?device=' + + str(self.component_config.configuration.id) + '&sn=' + self.device_config.serial_number, + timeout=5).json() + power = int(response["pb"]) * -1 + soc = float(response["soc"]) + + imported, exported = self.sim_counter.sim_count(power) + bat_state = BatState( + power=power, + soc=soc, + imported=imported, + exported=exported + ) + self.store.set(bat_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=KacoNHBatSetup) diff --git a/packages/modules/devices/kaco/kaco_nh/config.py b/packages/modules/devices/kaco/kaco_nh/config.py new file mode 100644 index 0000000000..75a2010a36 --- /dev/null +++ b/packages/modules/devices/kaco/kaco_nh/config.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +from typing import Optional + +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +class KacoNHConfiguration: + def __init__(self, ip_address: Optional[str] = None, + port: Optional[int] = None, + serial_number: Optional[str] = None): + self.ip_address = ip_address + self.port = port + self.serial_number = serial_number + + +class KacoNH: + def __init__(self, + name: str = "Kaco NH", + type: str = "kaco_nh", + id: int = 0, + configuration: KacoNHConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or KacoNHConfiguration() + + +class KacoNHBatConfiguration: + def __init__(self, id: int = 0): + self.id = id + + +class KacoNHBatSetup(ComponentSetup[KacoNHBatConfiguration]): + def __init__(self, + name: str = "Kaco NH Speicher", + type: str = "bat", + id: int = 0, + configuration: KacoNHBatConfiguration = None) -> None: + super().__init__(name, type, id, configuration or KacoNHBatConfiguration()) + + +class KacoNHCounterConfiguration: + def __init__(self, id: int = 0): + self.id = id + + +class KacoNHCounterSetup(ComponentSetup[KacoNHCounterConfiguration]): + def __init__(self, + name: str = "Kaco NH Zähler", + type: str = "counter", + id: int = 0, + configuration: KacoNHCounterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or KacoNHCounterConfiguration()) + + +class KacoNHInverterConfiguration: + def __init__(self, id: int = 0): + self.id = id + + +class KacoNHInverterSetup(ComponentSetup[KacoNHInverterConfiguration]): + def __init__(self, + name: str = "KacoNH Wechselrichter", + type: str = "inverter", + id: int = 0, + configuration: KacoNHInverterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or KacoNHInverterConfiguration()) diff --git a/packages/modules/devices/kaco/kaco_nh/counter.py b/packages/modules/devices/kaco/kaco_nh/counter.py new file mode 100644 index 0000000000..f52a6e75b4 --- /dev/null +++ b/packages/modules/devices/kaco/kaco_nh/counter.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from modules.common import req +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.store import get_counter_value_store +from modules.devices.kaco.kaco_nh.config import KacoNHConfiguration, KacoNHCounterSetup + + +class KwargsDict(TypedDict): + device_config: KacoNHConfiguration + + +class KacoNHCounter(AbstractCounter): + def __init__(self, component_config: KacoNHCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.device_config: KacoNHConfiguration = self.kwargs['device_config'] + self.store = get_counter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + response = req.get_http_session().get( + 'http://' + self.device_config.ip_address + ':' + str(self.device_config.port) + '/getdevdata.cgi?device=' + + str(self.component_config.configuration.id) + '&sn=' + self.device_config.serial_number, + timeout=5).json() + power = float(response["pac"]) + imported = float(response["iet"]) * 100 + exported = float(response["oet"]) * 100 + + counter_state = CounterState( + imported=imported, + exported=exported, + power=power + ) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=KacoNHCounterSetup) diff --git a/packages/modules/devices/kaco/kaco_nh/device.py b/packages/modules/devices/kaco/kaco_nh/device.py new file mode 100644 index 0000000000..243fa864e6 --- /dev/null +++ b/packages/modules/devices/kaco/kaco_nh/device.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable, Union + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater +from modules.devices.kaco.kaco_nh.bat import KacoNHBat +from modules.devices.kaco.kaco_nh.config import (KacoNH, KacoNHBatSetup, + KacoNHCounterSetup, KacoNHInverterSetup) +from modules.devices.kaco.kaco_nh.counter import KacoNHCounter +from modules.devices.kaco.kaco_nh.inverter import KacoNHInverter + +log = logging.getLogger(__name__) + + +def create_device(device_config: KacoNH): + def create_bat_component(component_config: KacoNHBatSetup): + return KacoNHBat(component_config=component_config, + device_config=device_config.configuration, + device_id=device_config.id) + + def create_counter_component(component_config: KacoNHCounterSetup): + return KacoNHCounter(component_config=component_config, + device_config=device_config.configuration) + + def create_inverter_component(component_config: KacoNHInverterSetup): + return KacoNHInverter(component_config=component_config, + device_config=device_config.configuration) + + def update_components(components: Iterable[Union[KacoNHBat, KacoNHCounter, KacoNHInverter]]): + for component in components: + component.update() + + return ConfigurableDevice( + device_config=device_config, + component_factory=ComponentFactoryByType( + bat=create_bat_component, + counter=create_counter_component, + inverter=create_inverter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=KacoNH) diff --git a/packages/modules/devices/kaco/kaco_nh/inverter.py b/packages/modules/devices/kaco/kaco_nh/inverter.py new file mode 100644 index 0000000000..ca2d0c075e --- /dev/null +++ b/packages/modules/devices/kaco/kaco_nh/inverter.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from modules.common import req +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.store import get_inverter_value_store +from modules.devices.kaco.kaco_nh.config import KacoNHInverterSetup +from modules.devices.kaco.kaco_nh.config import KacoNHConfiguration + + +class KwargsDict(TypedDict): + device_config: KacoNHConfiguration + + +class KacoNHInverter(AbstractInverter): + def __init__(self, component_config: KacoNHInverterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.device_config: KacoNHConfiguration = self.kwargs['device_config'] + self.store = get_inverter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + response = req.get_http_session().get( + 'http://' + self.device_config.ip_address + ':' + str(self.device_config.port) + '/getdevdata.cgi?device=' + + str(self.component_config.configuration.id) + '&sn=' + self.device_config.serial_number, + timeout=5).json() + + power = float(response["pac"]) * -1 + exported = float(response["eto"]) * 100 + + self.store.set(InverterState( + power=power, + exported=exported + )) + + +component_descriptor = ComponentDescriptor(configuration_factory=KacoNHInverterSetup) diff --git a/packages/modules/devices/kostal/kostal_piko_ci/config.py b/packages/modules/devices/kostal/kostal_piko_ci/config.py new file mode 100644 index 0000000000..5cb6b05116 --- /dev/null +++ b/packages/modules/devices/kostal/kostal_piko_ci/config.py @@ -0,0 +1,58 @@ +from typing import Optional + +from helpermodules.auto_str import auto_str +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +class KostalPikoCiConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502): + self.ip_address = ip_address + self.port = port + + +class KostalPikoCi: + def __init__(self, + name: str = "Kostal Piko CI", + type: str = "kostal_piko_ci", + id: int = 0, + configuration: KostalPikoCiConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or KostalPikoCiConfiguration() + + +@auto_str +class KostalPikoCiCounterConfiguration: + def __init__(self, modbus_id: int = 75): + self.modbus_id = modbus_id + + +@auto_str +class KostalPikoCiCounterSetup(ComponentSetup[KostalPikoCiCounterConfiguration]): + def __init__(self, + name: str = "Kostal Piko CI Zähler", + type: str = "counter", + id: int = 0, + configuration: KostalPikoCiCounterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or KostalPikoCiCounterConfiguration()) + + +@auto_str +class KostalPikoCiInverterConfiguration: + def __init__(self, modbus_id: int = 75): + self.modbus_id = modbus_id + + +@auto_str +class KostalPikoCiInverterSetup(ComponentSetup[KostalPikoCiInverterConfiguration]): + def __init__(self, + name: str = "Kostal Piko CI Wechselrichter", + type: str = "inverter", + id: int = 0, + configuration: KostalPikoCiInverterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or KostalPikoCiInverterConfiguration()) diff --git a/packages/modules/devices/kostal/kostal_piko_ci/counter.py b/packages/modules/devices/kostal/kostal_piko_ci/counter.py new file mode 100644 index 0000000000..3552783c8a --- /dev/null +++ b/packages/modules/devices/kostal/kostal_piko_ci/counter.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount import SimCounter +from modules.common.store import get_counter_value_store +from modules.devices.kostal.kostal_piko_ci.config import KostalPikoCiCounterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + + +class KostalPikoCiCounter(AbstractCounter): + def __init__(self, component_config: KostalPikoCiCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.store = get_counter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="bezug") + + def update(self) -> None: + unit = self.component_config.configuration.modbus_id + + power = self.client.read_holding_registers(252, ModbusDataType.FLOAT_32, unit=unit) + powers = [self.client.read_holding_registers( + reg, ModbusDataType.FLOAT_32, unit=unit) for reg in [224, 234, 244]] + frequency = self.client.read_holding_registers(220, ModbusDataType.FLOAT_32, unit=unit) + imported, exported = self.sim_counter.sim_count(power) + + counter_state = CounterState( + power=power, + powers=powers, + frequency=frequency, + imported=imported, + exported=exported, + ) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=KostalPikoCiCounterSetup) diff --git a/packages/modules/devices/kostal/kostal_piko_ci/device.py b/packages/modules/devices/kostal/kostal_piko_ci/device.py new file mode 100644 index 0000000000..85e57d2a1c --- /dev/null +++ b/packages/modules/devices/kostal/kostal_piko_ci/device.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable, Union + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.kostal.kostal_piko_ci.counter import KostalPikoCiCounter +from modules.devices.kostal.kostal_piko_ci.inverter import KostalPikoCiInverter +from modules.devices.kostal.kostal_piko_ci.config import (KostalPikoCi, KostalPikoCiCounterSetup, + KostalPikoCiInverterSetup) + +log = logging.getLogger(__name__) + + +def create_device(device_config: KostalPikoCi): + client = None + + def create_counter_component(component_config: KostalPikoCiCounterSetup): + nonlocal client + return KostalPikoCiCounter(component_config, device_id=device_config.id, client=client) + + def create_inverter_component(component_config: KostalPikoCiInverterSetup): + nonlocal client + return KostalPikoCiInverter(component_config, device_id=device_config.id, client=client) + + def update_components(components: Iterable[Union[KostalPikoCiCounter, KostalPikoCiInverter]]): + nonlocal client + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + counter=create_counter_component, + inverter=create_inverter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=KostalPikoCi) diff --git a/packages/modules/devices/kostal/kostal_piko_ci/inverter.py b/packages/modules/devices/kostal/kostal_piko_ci/inverter.py new file mode 100644 index 0000000000..2965dfd76b --- /dev/null +++ b/packages/modules/devices/kostal/kostal_piko_ci/inverter.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.store import get_inverter_value_store +from modules.devices.kostal.kostal_piko_ci.config import KostalPikoCiInverterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + + +class KostalPikoCiInverter(AbstractInverter): + def __init__(self, component_config: KostalPikoCiInverterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.store = get_inverter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + unit = self.component_config.configuration.modbus_id + + power = self.client.read_holding_registers(172, ModbusDataType.FLOAT_32, unit=unit) * -1 + currents = [self.client.read_holding_registers( + reg, ModbusDataType.FLOAT_32, unit=unit) for reg in [154, 160, 166]] + exported = self.client.read_holding_registers(320, ModbusDataType.FLOAT_32, unit=unit) + + inverter_state = InverterState( + power=power, + currents=currents, + exported=exported + ) + self.store.set(inverter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=KostalPikoCiInverterSetup) diff --git a/packages/modules/devices/kostal/kostal_plenticore/bat.py b/packages/modules/devices/kostal/kostal_plenticore/bat.py index 92202b51df..bec16469fe 100644 --- a/packages/modules/devices/kostal/kostal_plenticore/bat.py +++ b/packages/modules/devices/kostal/kostal_plenticore/bat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import logging -from typing import TypedDict, Any +from typing import TypedDict, Any, Optional from pymodbus.constants import Endian from modules.common.abstract_device import AbstractBat @@ -54,5 +54,36 @@ def update(self) -> None: ) self.store.set(bat_state) + # 0x40A 1034 Battery charge power (DC) setpoint, absolute - W Float 2 RW + # negative Werte: laden, positive Werte: entladen + # Kostal setzt das Register autmatisch nach Timeout zurück auf Eigensteuerung. + # Timeout kann im Kostal UI geändert werden. Standardwert 30s + + def set_power_limit(self, power_limit: Optional[int]) -> None: + unit = self.device_config.configuration.modbus_id + log.debug(f'last_mode: {self.last_mode}') + + if power_limit is None: + # Wert wird nur einmal gesetzt damit die Eigenregelung nach Timeout greift + log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") + if self.last_mode is not None: + self.__tcp_client.write_registers(1034, [0], data_type=ModbusDataType.FLOAT_32, unit=unit) + self.last_mode = None + elif power_limit == 0: + # wiederholt auf Stop setzen damit sich Register nicht zurücksetzt + log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") + self.__tcp_client.write_registers(1034, [0], data_type=ModbusDataType.FLOAT_32, unit=unit) + self.last_mode = 'stop' + elif power_limit < 0: + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_limit} W entladen für den Hausverbrauch") + # Die maximale Entladeleistung begrenzen auf 7000W + power_value = int(min(abs(power_limit), 7000)) * -1 + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_value} W entladen für den Hausverbrauch") + self.__tcp_client.write_registers(1034, [power_value], data_type=ModbusDataType.FLOAT_32, unit=unit) + self.last_mode = 'discharge' + + def power_limit_controllable(self) -> bool: + return True + component_descriptor = ComponentDescriptor(configuration_factory=KostalPlenticoreBatSetup) diff --git a/packages/modules/devices/lg/lg/JSON-Beispiele.txt b/packages/modules/devices/lg/lg/JSON-Beispiele.txt index 40b347d623..49da0b2167 100644 --- a/packages/modules/devices/lg/lg/JSON-Beispiele.txt +++ b/packages/modules/devices/lg/lg/JSON-Beispiele.txt @@ -27,6 +27,98 @@ $ curl -k -d '{"auth_key":"67567d76-0c83-11ea-8a59-d84fb802005a"}' -H "Content-T % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 909 0 858 100 51 858 51 0:00:01 --:--:-- 0:00:01 4835 +{ + "statistics": + { + "pcs_pv_total_power": "0", + "batconv_power": "372", + "bat_use": "1", + "bat_status": "2", + "bat_user_soc": "14.58333", + "load_power": "361", + "load_today": "0.0", + "grid_power": "11", + "current_day_self_consumption": "94.8", + "current_pv_generation_sum": "7415", + "current_grid_feed_in_energy": "385" + }, + "direction": + { + "is_direct_consuming_": "0", + "is_battery_charging_": "0", + "is_battery_discharging_": "1", + "is_grid_selling_": "0", + "is_grid_buying_": "0", + "is_charging_from_grid_": "0" + }, + "operation": + { + "status": "start", + "mode": "1" + }, + "wintermode": + { + "winter_status": "on" + }, + "pcs_fault": + { + "pcs_status": "pcs_ok" + } +} + +### LG Home 15 ### +{ + "statistics": { + "pv_total_power_01kW": 36, + "batt_conv_power_01kW": -34, + "grid_power_01kW": 0, + "load_power_01kW": 2, + "ac_active_power_01kW": 3, + "bat_use": 1, + "bat_status": 1, + "bat_user_soc": 46 + }, + "direction": { + "is_direct_consuming_": 1, + "is_battery_charging_": 1, + "is_battery_discharging_": 0, + "is_grid_selling_": 0, + "is_grid_buying_": 0, + "is_charging_from_grid_": 0, + "is_discharging_to_grid_": 0 + }, + "operation": { + "status": "start", + "mode": 1, + "oper_status": 3, + "simple_status": 1, + "backup_mode": 1, + "remote_mode": 0, + "demo_mode": 0, + "tou_period": 1, + "ripple_control": "0", + "ripple_level": "0" + }, + "pcs_fault": { + "pcs_status": "pcs_ok", + "grid_fault": "0" + }, + "heatpump": { + "heatpump_protocol": "2", + "heatpump_installed": "no", + "heatpump_activate": "off", + "current_temp": 0, + "heatpump_working": "off" + }, + "evcharger": { + "ev_activate": "off", + "ev_power": 0 + }, + "gridWaitingTime": 0, + "coredump": "none", + "factory_test_mode": "none" +} + { "statistics": { diff --git a/packages/modules/devices/lg/lg/bat.py b/packages/modules/devices/lg/lg/bat.py index 0890f187c6..a7be8f812e 100644 --- a/packages/modules/devices/lg/lg/bat.py +++ b/packages/modules/devices/lg/lg/bat.py @@ -26,9 +26,12 @@ def initialize(self) -> None: self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) def update(self, response) -> None: - power = float(response["statistics"]["batconv_power"]) - if response["direction"]["is_battery_discharging_"] == "1": - power = power * -1 + if 'batconv_power' in response['statistics']: + power = float(response["statistics"]["batconv_power"]) + if response["direction"]["is_battery_discharging_"] == "1": + power = power * -1 + else: + power = float(response["statistics"]["batt_conv_power_01kW"]) * -100 # Home 15 try: soc = float(response["statistics"]["bat_user_soc"]) except ValueError: diff --git a/packages/modules/devices/lg/lg/counter.py b/packages/modules/devices/lg/lg/counter.py index fba44df091..c7e471673f 100644 --- a/packages/modules/devices/lg/lg/counter.py +++ b/packages/modules/devices/lg/lg/counter.py @@ -26,10 +26,15 @@ def initialize(self) -> None: self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) def update(self, response) -> None: - power = float(response["statistics"]["grid_power"]) + if 'grid_power' in response['statistics']: + power = float(response["statistics"]["grid_power"]) + if response["direction"]["is_grid_selling_"] == "1": + power = power*-1 + else: + power = float(response["statistics"]["grid_power_01kW"]) * 100 # Home 15 + if response["direction"]["is_grid_selling_"] == "1": power = power*-1 - imported, exported = self.sim_counter.sim_count(power) counter_state = CounterState( imported=imported, diff --git a/packages/modules/devices/lg/lg/inverter.py b/packages/modules/devices/lg/lg/inverter.py index c94b747de1..7242c52260 100644 --- a/packages/modules/devices/lg/lg/inverter.py +++ b/packages/modules/devices/lg/lg/inverter.py @@ -26,7 +26,10 @@ def initialize(self) -> None: self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) def update(self, response: Dict) -> None: - power = float(response["statistics"]["pcs_pv_total_power"]) * -1 + if 'pcs_pv_total_power' in response['statistics']: + power = float(response["statistics"]["pcs_pv_total_power"]) * -1 + else: + power = float(response["statistics"]["pv_total_power_01kW"]) * -100 # Home 15 _, exported = self.sim_counter.sim_count(power) inverter_state = InverterState( exported=exported, diff --git a/packages/modules/electricity_tariffs/tibber/__init__.py b/packages/modules/devices/marstek/__init__.py similarity index 100% rename from packages/modules/electricity_tariffs/tibber/__init__.py rename to packages/modules/devices/marstek/__init__.py diff --git a/packages/modules/devices/marstek/vendor.py b/packages/modules/devices/marstek/vendor.py new file mode 100644 index 0000000000..20f362f9e9 --- /dev/null +++ b/packages/modules/devices/marstek/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Marstek" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/electricity_tariffs/voltego/__init__.py b/packages/modules/devices/marstek/venus_c_e/__init__.py similarity index 100% rename from packages/modules/electricity_tariffs/voltego/__init__.py rename to packages/modules/devices/marstek/venus_c_e/__init__.py diff --git a/packages/modules/devices/marstek/venus_c_e/bat.py b/packages/modules/devices/marstek/venus_c_e/bat.py new file mode 100644 index 0000000000..1f57a668d4 --- /dev/null +++ b/packages/modules/devices/marstek/venus_c_e/bat.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from typing import Optional, TypedDict, Any, Union +from modules.common.abstract_device import AbstractBat +from modules.common.component_state import BatState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount import SimCounter +from modules.common.store import get_bat_value_store +from modules.devices.marstek.venus_c_e.config import VenusCEBatSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + + +class VenusCEBat(AbstractBat): + def __init__(self, component_config: VenusCEBatSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="speicher") + self.store = get_bat_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def _read_reg(self, addr: int, type_: ModbusDataType) -> Union[int, float]: + return self.client.read_holding_registers(addr, type_, unit=self.component_config.configuration.modbus_id) + + def _write_reg(self, addr: int, val: int) -> None: + # Marstek Venus does not work with write_registers! + self.client._delegate.write_register(addr, val, unit=self.component_config.configuration.modbus_id) + + def update(self) -> None: + power = -self._read_reg(32202, ModbusDataType.INT_32) + soc = self._read_reg(32104, ModbusDataType.UINT_16) + + # Marstek Venus has internal counter but it's buggy, hence we cannot use it + imported, exported = self.sim_counter.sim_count(power) + + bat_state = BatState( + power=power, + soc=soc, + imported=imported, + exported=exported + ) + self.store.set(bat_state) + + def set_power_limit(self, power_limit: Optional[int]) -> None: + # Wenn der Speicher die Steuerung der Ladeleistung unterstützt, muss bei Übergabe einer Zahl auf aktive + # Speichersteurung umgeschaltet werden, sodass der Speicher mit der übergebenen Leistung lädt/entlädt. Wird + # None übergeben, muss der Speicher die Null-Punkt-Ausregelung selbst übernehmen. + if (power_limit is None): + self._write_reg(42000, 0x55bb) + else: + self._write_reg(42000, 0x55aa) + if power_limit < 0: + self._write_reg(42010, 2) + self._write_reg(42021, int(min(-power_limit, 2500))) + elif power_limit > 0: + self._write_reg(42010, 1) + self._write_reg(42020, int(min(power_limit, 2500))) + else: + self._write_reg(42010, 0) + + def power_limit_controllable(self) -> bool: + # Wenn der Speicher die Steuerung der Ladeleistung unterstützt, muss True zurückgegeben werden. + return True + + +component_descriptor = ComponentDescriptor(configuration_factory=VenusCEBatSetup) diff --git a/packages/modules/devices/marstek/venus_c_e/config.py b/packages/modules/devices/marstek/venus_c_e/config.py new file mode 100644 index 0000000000..76f1a877ee --- /dev/null +++ b/packages/modules/devices/marstek/venus_c_e/config.py @@ -0,0 +1,42 @@ +from typing import Optional +from helpermodules.auto_str import auto_str +from modules.common.component_setup import ComponentSetup + +from ..vendor import vendor_descriptor + + +@auto_str +class VenusCEConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: int = 502): + self.ip_address = ip_address + self.port = port + + +@auto_str +class VenusCE: + def __init__(self, + name: str = "Marstek Venus C, E", + type: str = "venus_c_e", + id: int = 0, + configuration: VenusCEConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or VenusCEConfiguration() + + +@auto_str +class VenusCEBatConfiguration: + def __init__(self, modbus_id: int = 1): + self.modbus_id = modbus_id + + +@auto_str +class VenusCEBatSetup(ComponentSetup[VenusCEBatConfiguration]): + def __init__(self, + name: str = "Marstek Venus C, E Speicher", + type: str = "bat", + id: int = 0, + configuration: VenusCEBatConfiguration = None) -> None: + super().__init__(name, type, id, configuration or VenusCEBatConfiguration()) diff --git a/packages/modules/devices/marstek/venus_c_e/device.py b/packages/modules/devices/marstek/venus_c_e/device.py new file mode 100644 index 0000000000..6b402bad0e --- /dev/null +++ b/packages/modules/devices/marstek/venus_c_e/device.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.marstek.venus_c_e.bat import VenusCEBat +from modules.devices.marstek.venus_c_e.config import VenusCE, VenusCEBatSetup + +log = logging.getLogger(__name__) + + +def create_device(device_config: VenusCE): + client = None + + def create_bat_component(component_config: VenusCEBatSetup): + nonlocal client + return VenusCEBat(component_config, device_id=device_config.id, client=client) + + def update_components(components: Iterable[VenusCEBat]): + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + bat=create_bat_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=VenusCE) diff --git a/packages/modules/devices/mtec/mtec/counter.py b/packages/modules/devices/mtec/mtec/counter.py index 87049381e7..c0b08d3bdd 100644 --- a/packages/modules/devices/mtec/mtec/counter.py +++ b/packages/modules/devices/mtec/mtec/counter.py @@ -31,8 +31,9 @@ def initialize(self) -> None: def update(self) -> None: unit = self.component_config.configuration.modbus_id - power = self.client.read_holding_registers(11000, ModbusDataType.INT_32, unit=unit) + power = self.client.read_holding_registers(11000, ModbusDataType.INT_32, unit=unit) * -1 powers = self.client.read_holding_registers(10994, [ModbusDataType.INT_32]*3, unit=unit) + powers = [value * -1 for value in powers] imported, exported = self.sim_counter.sim_count(power) counter_state = CounterState( diff --git a/packages/modules/devices/openwb/openwb_bat_kit/device.py b/packages/modules/devices/openwb/openwb_bat_kit/device.py index f1cf0f1f4f..cebed73e88 100644 --- a/packages/modules/devices/openwb/openwb_bat_kit/device.py +++ b/packages/modules/devices/openwb/openwb_bat_kit/device.py @@ -32,7 +32,8 @@ def initializer(): client = modbus.ModbusTcpClient_("192.168.193.19", 8899) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + "192.168.193.19"]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/openwb/openwb_evu_kit/device.py b/packages/modules/devices/openwb/openwb_evu_kit/device.py index 5e99c22d33..f975a0c801 100644 --- a/packages/modules/devices/openwb/openwb_evu_kit/device.py +++ b/packages/modules/devices/openwb/openwb_evu_kit/device.py @@ -43,7 +43,8 @@ def initializer(): client = modbus.ModbusTcpClient_("192.168.193.15", 8899) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + "192.168.193.15"]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/openwb/openwb_flex/device.py b/packages/modules/devices/openwb/openwb_flex/device.py index 351b019a14..350a6dd0df 100644 --- a/packages/modules/devices/openwb/openwb_flex/device.py +++ b/packages/modules/devices/openwb/openwb_flex/device.py @@ -50,7 +50,8 @@ def initializer(): client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + device_config.configuration.ip_address]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/openwb/openwb_pv_kit/device.py b/packages/modules/devices/openwb/openwb_pv_kit/device.py index f2cd8cf71b..6562467134 100644 --- a/packages/modules/devices/openwb/openwb_pv_kit/device.py +++ b/packages/modules/devices/openwb/openwb_pv_kit/device.py @@ -32,7 +32,8 @@ def initializer(): client = modbus.ModbusTcpClient_("192.168.193.13", 8899) def error_handler(): - run_command(f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin") + run_command([f"{Path(__file__).resolve().parents[4]}/modules/common/restart_protoss_admin", + "192.168.193.13"]) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/saxpower/saxpower/config.py b/packages/modules/devices/saxpower/saxpower/config.py index ba92818059..b34e20f3ab 100644 --- a/packages/modules/devices/saxpower/saxpower/config.py +++ b/packages/modules/devices/saxpower/saxpower/config.py @@ -24,6 +24,20 @@ def __init__(self, self.configuration = configuration or SaxpowerConfiguration() +class SaxpowerCounterConfiguration: + def __init__(self): + pass + + +class SaxpowerCounterSetup(ComponentSetup[SaxpowerCounterConfiguration]): + def __init__(self, + name: str = "Saxpower Zähler", + type: str = "counter", + id: int = 0, + configuration: SaxpowerCounterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or SaxpowerCounterConfiguration()) + + class SaxpowerBatConfiguration: def __init__(self): pass diff --git a/packages/modules/devices/saxpower/saxpower/counter.py b/packages/modules/devices/saxpower/saxpower/counter.py new file mode 100644 index 0000000000..7e05815e3d --- /dev/null +++ b/packages/modules/devices/saxpower/saxpower/counter.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount import SimCounter +from modules.common.store import get_counter_value_store +from modules.devices.saxpower.saxpower.config import SaxpowerCounterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + modbus_id: int + + +class SaxpowerCounter(AbstractCounter): + def __init__(self, component_config: SaxpowerCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.__modbus_id: int = self.kwargs['modbus_id'] + self.store = get_counter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="bezug") + + def update(self) -> None: + with self.__tcp_client: + power = self.__tcp_client.read_holding_registers(48, [ModbusDataType.INT_16]*2, unit=self.__modbus_id) + power = power * -1 + 16384 + imported, exported = self.sim_counter.sim_count(power) + + counter_state = CounterState( + imported=imported, + exported=exported, + power=power + ) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=SaxpowerCounterSetup) diff --git a/packages/modules/devices/saxpower/saxpower/device.py b/packages/modules/devices/saxpower/saxpower/device.py index 6914ac931a..e5cf479f67 100644 --- a/packages/modules/devices/saxpower/saxpower/device.py +++ b/packages/modules/devices/saxpower/saxpower/device.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 import logging -from typing import Iterable +from typing import Iterable, Union from modules.common import modbus 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.devices.saxpower.saxpower.bat import SaxpowerBat -from modules.devices.saxpower.saxpower.config import Saxpower, SaxpowerBatSetup +from modules.devices.saxpower.saxpower.counter import SaxpowerCounter +from modules.devices.saxpower.saxpower.config import Saxpower, SaxpowerBatSetup, SaxpowerCounterSetup log = logging.getLogger(__name__) @@ -22,7 +23,13 @@ def create_bat_component(component_config: SaxpowerBatSetup): client=client, modbus_id=device_config.configuration.modbus_id) - def update_components(components: Iterable[SaxpowerBat]): + def create_counter_component(component_config: SaxpowerCounterSetup): + return SaxpowerCounter(component_config, + device_id=device_config.id, + client=client, + modbus_id=device_config.configuration.modbus_id) + + def update_components(components: Iterable[Union[SaxpowerBat, SaxpowerCounter]]): nonlocal client with client: for component in components: @@ -38,6 +45,7 @@ def initializer(): initializer=initializer, component_factory=ComponentFactoryByType( bat=create_bat_component, + counter=create_counter_component ), component_updater=MultiComponentUpdater(update_components) ) diff --git a/packages/modules/devices/shelly/shelly/bat.py b/packages/modules/devices/shelly/shelly/bat.py index c5e9f8c337..510c88b641 100644 --- a/packages/modules/devices/shelly/shelly/bat.py +++ b/packages/modules/devices/shelly/shelly/bat.py @@ -17,6 +17,7 @@ class KwargsDict(TypedDict): device_id: int ip_address: str factor: int + phase: int generation: Optional[int] @@ -29,6 +30,7 @@ def initialize(self) -> None: self.__device_id: int = self.kwargs['device_id'] self.address: str = self.kwargs['ip_address'] self.factor: int = self.kwargs['factor'] + self.phase: int = self.kwargs['phase'] self.generation: Optional[int] = self.kwargs['generation'] self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="speicher") self.store = get_bat_value_store(self.component_config.id) @@ -43,38 +45,56 @@ def update(self) -> None: status = req.get_http_session().get(status_url, timeout=3).json() try: - if self.generation == 1: - if 'meters' in status: - meters = status['meters'] # shelly - else: - meters = status['emeters'] # shellyEM & shelly3EM - # shellyEM has one meter, shelly3EM has three meters: - for meter in meters: - power = power + meter['power'] - currents = [0, 0, 0] + alphabetical_index = ['a', 'b', 'c'] + currents = [0.0, 0.0, 0.0] + # GEN 1 + if "meters" in status: + meters = status['meters'] # einphasiger shelly? + for i in range(len(meters)): + currents[(i+self.phase-1) % 3] = ((float(meters[i]['power']) * self.factor) / 230 + if meters[i].get('power') else 0) + power = power + (float(meters[i]['power'] * self.factor)) + elif "emeters" in status: + meters = status['emeters'] # shellyEM & shelly3EM + # shellyEM has one meter, shelly3EM has three meters + for i in range(len(meters)): + currents[(i+self.phase-1) % 3] = (float(meters[i]['current']) * self.factor + if meters[i].get('current') else 0) + power = power + (float(meters[i]['power'] * self.factor)) + # GEN 2+ + # shelly Pro3EM + elif "em:0" in status: + meters = status['em:0'] + for i in range(0, 3): + if meters.get(f'{alphabetical_index[i]}_current') is None: + continue + currents[(i+self.phase-1) % 3] = (float(meters[f'{alphabetical_index[i]}_current']) * self.factor + if meters.get(f'{alphabetical_index[i]}_current') else 0) + power = float(meters['total_act_power']) * self.factor + # Shelly MiniPM G3 + elif "pm1:0" in status: + log.debug("single phase shelly") + meters = status['pm1:0'] + currents[self.phase-1] = meters['current'] * self.factor + power = meters['apower'] * self.factor + elif 'switch:0' in status and 'apower' in status['switch:0']: + log.debug("single phase shelly") + meters = status['switch:0'] + currents[self.phase-1] = meters['current'] * self.factor + power = meters['apower'] * self.factor else: - if 'switch:0' in status and 'apower' in status['switch:0']: - power = status['switch:0']['apower'] - currents = [status['switch:0']['current'], 0, 0] - elif 'em1:0' in status: - power = status['em1:0']['act_power'] # shelly Pro EM Gen 2 - currents = [status['em1:0']['current'], 0, 0] - elif 'pm1:0' in status: - power = status['pm1:0']['apower'] # shelly PM Mini Gen 3 - currents = [status['pm1:0']['current'], 0, 0] - else: - power = status['em:0']['total_act_power'] # shelly Pro3EM - currents = [status['em:0'][f'{i}_current'] for i in 'abc'] - - power = power * self.factor + log.debug("single phase shelly") + meters = status['em1:0'] + currents[self.phase-1] = meters['current'] * self.factor + power = meters['act_power'] * self.factor # shelly Pro EM Gen 2 imported, exported = self.sim_counter.sim_count(power) + bat_state = BatState( power=power, + currents=currents, imported=imported, exported=exported ) - if 'currents' in locals(): - bat_state.currents = currents self.store.set(bat_state) except KeyError: log.exception("unsupported shelly device.") diff --git a/packages/modules/devices/shelly/shelly/config.py b/packages/modules/devices/shelly/shelly/config.py index 1e5570f33a..b4c87adcfc 100644 --- a/packages/modules/devices/shelly/shelly/config.py +++ b/packages/modules/devices/shelly/shelly/config.py @@ -7,9 +7,10 @@ @auto_str class ShellyConfiguration: - def __init__(self, ip_address: Optional[str] = None, factor: Optional[int] = -1): + def __init__(self, ip_address: Optional[str] = None, factor: Optional[int] = -1, phase: Optional[int] = 1): self.ip_address = ip_address self.factor = factor + self.phase = phase @auto_str diff --git a/packages/modules/devices/shelly/shelly/counter.py b/packages/modules/devices/shelly/shelly/counter.py index 3c4ebadf41..a9f10fcfce 100644 --- a/packages/modules/devices/shelly/shelly/counter.py +++ b/packages/modules/devices/shelly/shelly/counter.py @@ -17,6 +17,7 @@ class KwargsDict(TypedDict): device_id: int ip_address: str factor: int + phase: int generation: Optional[int] @@ -29,6 +30,7 @@ def initialize(self) -> None: self.__device_id: int = self.kwargs['device_id'] self.address: str = self.kwargs['ip_address'] self.factor: int = self.kwargs['factor'] + self.phase: int = self.kwargs['phase'] self.generation: Optional[int] = self.kwargs['generation'] self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="bezug") self.store = get_counter_value_store(self.component_config.id) @@ -42,69 +44,112 @@ def update(self) -> None: status_url = "http://" + self.address + "/rpc/Shelly.GetStatus" status = req.get_http_session().get(status_url, timeout=3).json() try: - if self.generation == 1: # shelly3EM - meters = status['emeters'] - # shelly3EM has three meters: - for meter in meters: - power = power + meter['power'] - power = power * self.factor + # GEN 1 + alphabetical_index = ['a', 'b', 'c'] + if "meters" in status: + powers = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + meters = status['meters'] # einphasiger shelly? + for i in range(len(meters)): + powers[(i+self.phase-1) % 3] = (float(meters[i]['power']) * self.factor + if meters[i].get('power') else 0) + voltages[(i+self.phase-1) % 3] = 230 + power = sum(powers) + elif "emeters" in status: + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['emeters'] # shellyEM & shelly3EM + # shellyEM has one meter, shelly3EM has three meters + for i in range(len(meters)): + powers[(i+self.phase-1) % 3] = (float(meters[i]['power']) * self.factor + if meters[i].get('power') else 0) + currents[(i+self.phase-1) % 3] = (float(meters[i]['current']) * self.factor + if meters[i].get('current') else 0) + voltages[(i+self.phase-1) % 3] = (float(meters[i]['voltage']) + if meters[i].get('voltage') else 0) + power_factors[(i+self.phase-1) % 3] = (float(meters[i]['pf']) + if meters[i].get('pf') else 0) + power = sum(powers) - voltages = [status['emeters'][i]['voltage'] for i in range(0, 3)] - currents = [status['emeters'][i]['current'] for i in range(0, 3)] - powers = [status['emeters'][i]['power'] for i in range(0, 3)] - power_factors = [status['emeters'][i]['pf'] for i in range(0, 3)] + # GEN 2+ + # shelly Pro3EM + elif "em:0" in status: + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['em:0'] + for i in range(0, 3): + if meters.get(f'{alphabetical_index[i]}_act_power') is None: + continue + powers[(i+self.phase-1) % 3] = (float(meters[f'{alphabetical_index[i]}_act_power']) * self.factor + if meters.get(f'{alphabetical_index[i]}_act_power') else 0) + voltages[(i+self.phase-1) % 3] = (float(meters[f'{alphabetical_index[i]}_voltage']) + if meters.get(f'{alphabetical_index[i]}_voltage') else 0) + currents[(i+self.phase-1) % 3] = (float(meters[f'{alphabetical_index[i]}_current']) * self.factor + if meters.get(f'{alphabetical_index[i]}_current') else 0) + power_factors[(i+self.phase-1) % 3] = (float(meters[f'{alphabetical_index[i]}_pf']) + if meters.get(f'{alphabetical_index[i]}_pf') else 0) + power = float(meters['total_act_power']) * self.factor + # Shelly MiniPM G3 + elif "pm1:0" in status: + log.debug("single phase shelly") + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['pm1:0'] + powers[self.phase-1] = meters['apower'] * self.factor + voltages[self.phase-1] = meters['voltage'] + currents[self.phase-1] = meters['current'] * self.factor + power_factors[self.phase-1] = meters['pf'] if meters.get('pf') else 0 + power = meters['apower'] * self.factor + frequency = meters['freq'] + elif 'switch:0' in status and 'apower' in status['switch:0']: + log.debug("single phase shelly") + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['switch:0'] + powers[self.phase-1] = meters['apower'] * self.factor + voltages[self.phase-1] = meters['voltage'] + currents[self.phase-1] = meters['current'] * self.factor + # power_factors[self.phase-1] = meters['pf'] + power = meters['apower'] * self.factor + frequency = meters['freq'] else: - # shelly Pro3EM - if "em:0" in status: - meter = status['em:0'] - voltages = [meter[f'{i}_voltage'] for i in 'abc'] - currents = [meter[f'{i}_current'] for i in 'abc'] - powers = [meter[f'{i}_act_power'] for i in 'abc'] - power_factors = [meter[f'{i}_pf'] for i in 'abc'] - power = meter['total_act_power'] * self.factor - # Shelly MiniPM G3 - elif "pm1:0" in status: - log.debug("single phase shelly") - meter = status['pm1:0'] - voltages = [meter['voltage'], 0, 0] - currents = [meter['current'], 0, 0] - power = meter['apower'] - frequency = meter['freq'] - powers = [meter['apower'], 0, 0] - elif 'switch:0' in status and 'apower' in status['switch:0']: - log.debug("single phase shelly") - meter = status['switch:0'] - power = meter['apower'] - voltages = [meter['voltage'], 0, 0] - currents = [meter['current'], 0, 0] - frequency = meter['freq'] - power_factors = [meter['pf'], 0, 0] - powers = [meter['apower'], 0, 0] - else: - log.debug("single phase shelly") - meter = status['em1:0'] - power = meter['act_power'] # shelly Pro EM Gen 2 - voltages = [meter['voltage'], 0, 0] - currents = [meter['current'], 0, 0] - frequency = meter['freq'] - power_factors = [meter['pf'], 0, 0] - powers = [meter['act_power'], 0, 0] + log.debug("single phase shelly") + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['em1:0'] + powers[self.phase-1] = meters['act_power'] + voltages[self.phase-1] = meters['voltage'] + currents[self.phase-1] = meters['current'] * self.factor + power_factors[self.phase-1] = meters['pf'] + power = meters['act_power'] # shelly Pro EM Gen 2 + frequency = meters['freq'] imported, exported = self.sim_counter.sim_count(power) counter_state = CounterState( - voltages=voltages, - currents=currents, imported=imported, exported=exported, + powers=powers, power=power ) if 'frequency' in locals(): counter_state.frequency = frequency if "power_factors" in locals(): counter_state.power_factors = power_factors - if "powers" in locals(): - counter_state.powers = powers + if "voltages" in locals(): + counter_state.voltages = voltages + if "currents" in locals(): + counter_state.currents = currents self.store.set(counter_state) except KeyError: log.exception("unsupported shelly device?") diff --git a/packages/modules/devices/shelly/shelly/device.py b/packages/modules/devices/shelly/shelly/device.py index 47097ad383..303f5b6818 100644 --- a/packages/modules/devices/shelly/shelly/device.py +++ b/packages/modules/devices/shelly/shelly/device.py @@ -22,6 +22,7 @@ def create_counter_component(component_config: ShellyCounterSetup) -> ShellyCoun device_id=device_config.id, ip_address=device_config.configuration.ip_address, factor=device_config.configuration.factor, + phase=device_config.configuration.phase, generation=generation) def create_inverter_component(component_config: ShellyInverterSetup) -> ShellyInverter: @@ -30,6 +31,7 @@ def create_inverter_component(component_config: ShellyInverterSetup) -> ShellyIn device_id=device_config.id, ip_address=device_config.configuration.ip_address, factor=device_config.configuration.factor, + phase=device_config.configuration.phase, generation=generation) def create_bat_component(component_config: ShellyBatSetup) -> ShellyBat: @@ -38,6 +40,7 @@ def create_bat_component(component_config: ShellyBatSetup) -> ShellyBat: device_id=device_config.id, ip_address=device_config.configuration.ip_address, factor=device_config.configuration.factor, + phase=device_config.configuration.phase, generation=generation) def initializer() -> None: diff --git a/packages/modules/devices/shelly/shelly/inverter.py b/packages/modules/devices/shelly/shelly/inverter.py index 96a7fa2662..ab1ac1bb20 100644 --- a/packages/modules/devices/shelly/shelly/inverter.py +++ b/packages/modules/devices/shelly/shelly/inverter.py @@ -17,6 +17,7 @@ class KwargsDict(TypedDict): device_id: int ip_address: str factor: int + phase: int generation: Optional[int] @@ -29,6 +30,7 @@ def initialize(self) -> None: self.__device_id: int = self.kwargs['device_id'] self.address: str = self.kwargs['ip_address'] self.factor: int = self.kwargs['factor'] + self.phase: int = self.kwargs['phase'] self.generation: Optional[int] = self.kwargs['generation'] self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="pv") self.store = get_inverter_value_store(self.component_config.id) @@ -42,36 +44,53 @@ def update(self) -> None: status_url = "http://" + self.address + "/rpc/Shelly.GetStatus" status = req.get_http_session().get(status_url, timeout=3).json() try: - if self.generation == 1: - if 'meters' in status: - meters = status['meters'] # shelly - else: - meters = status['emeters'] # shellyEM & shelly3EM - # shellyEM has one meter, shelly3EM has three meters: - for meter in meters: - power = power + meter['power'] + alphabetical_index = ['a', 'b', 'c'] + currents = [0.0, 0.0, 0.0] + # GEN 1 + if "meters" in status: + meters = status['meters'] # einphasiger shelly? + for i in range(len(meters)): + currents[(i+self.phase-1) % 3] += ((float(meters[i]['power']) * self.factor) / 230 + if meters[i].get('power') else 0) + power = power + (float(meters[i]['power'] * self.factor)) + elif "emeters" in status: + meters = status['emeters'] # shellyEM & shelly3EM + # shellyEM has one meter, shelly3EM has three meters + for i in range(len(meters)): + currents[(i+self.phase-1) % 3] = (float(meters[i]['current']) * self.factor + if meters[i].get('current') else 0) + power = power + (float(meters[i]['power'] * self.factor)) + # GEN 2+ + # shelly Pro3EM + elif "em:0" in status: + meters = status['em:0'] + for i in range(len(meters)): + currents[(i+self.phase-1) % 3] = (float(meters[f'{alphabetical_index[i]}_current']) * self.factor + if meters.get(f'{alphabetical_index[i]}_current') else 0) + power = float(meters['total_act_power']) * self.factor + # Shelly MiniPM G3 + elif "pm1:0" in status: + log.debug("single phase shelly") + meters = status['pm1:0'] + currents[self.phase-1] = meters['current'] * self.factor + power = meters['apower'] * self.factor + elif 'switch:0' in status and 'apower' in status['switch:0']: + log.debug("single phase shelly") + meters = status['switch:0'] + currents[self.phase-1] = meters['current'] * self.factor + power = meters['apower'] * self.factor else: - if 'switch:0' in status and 'apower' in status['switch:0']: - power = status['switch:0']['apower'] - currents = [status['switch:0']['current'], 0, 0] - elif 'em1:0' in status: - power = status['em1:0']['act_power'] # shelly Pro EM Gen 2 - currents = [status['em1:0']['current'], 0, 0] - elif 'pm1:0' in status: - power = status['pm1:0']['apower'] # shelly PM Mini Gen 3 - currents = [status['pm1:0']['current'], 0, 0] - else: - power = status['em:0']['total_act_power'] # shelly Pro3EM - currents = [status['em:0'][f'{i}_current'] for i in 'abc'] - - power = power * self.factor + log.debug("single phase shelly") + meters = status['em1:0'] + currents[self.phase-1] = meters['current'] * self.factor + power = meters['act_power'] * self.factor # shelly Pro EM Gen 2 _, exported = self.sim_counter.sim_count(power) + inverter_state = InverterState( power=power, + currents=currents, exported=exported ) - if 'currents' in locals(): - inverter_state.currents = currents self.store.set(inverter_state) except KeyError: log.exception("unsupported shelly device.") diff --git a/packages/modules/devices/shelly/shelly/shelly_test.py b/packages/modules/devices/shelly/shelly/shelly_test.py index 8be2aa50e5..9721957ade 100644 --- a/packages/modules/devices/shelly/shelly/shelly_test.py +++ b/packages/modules/devices/shelly/shelly/shelly_test.py @@ -1,12 +1,97 @@ from unittest.mock import Mock import requests_mock -from modules.common.component_state import CounterState -from modules.conftest import SAMPLE_IP -from modules.devices.shelly.shelly.config import ShellyCounterSetup -from modules.devices.shelly.shelly import counter +from dataclasses import dataclass +from typing import Optional +import pytest +from modules.common.component_state import CounterState, InverterState, BatState +from modules.conftest import SAMPLE_IP +from modules.devices.shelly.shelly.config import ShellyCounterSetup, ShellyInverterSetup, ShellyBatSetup +from modules.devices.shelly.shelly import counter, inverter, bat +# Shelly PLUG G1 +DATA_PLUG_G1 = { + "meters": [{ + "power": 230, + "overpower": 0.00, + "is_valid": True, + "timestamp": 1756902511, + "counters": [400.467, 1127.491, 1046.330], + "total": 3981113 + }] +} +# Shelly EM 3 G1 +DATA_EM_3_G1 = { + # [...] + "emeters": [{ + "power": 2300.00, + "pf": 1.00, + "current": 10.00, + "voltage": 220.00, + "is_valid": True, + "total": 3000000.0, + "total_returned": 0.0 + }, { + "power": 460.00, + "pf": 1.00, + "current": 2.00, + "voltage": 230.00, + "is_valid": True, + "total": 1000000.0, + "total_returned": 0.0 + }, { + "power": 230.00, + "pf": 1.00, + "current": 1.00, + "voltage": 240.00, + "is_valid": True, + "total": 400000.0, + "total_returned": 0.0 + }], + "total_power": 2990.00, + # [...] +} +# Shelly Pro 3EM G2 +DATA_PRO_3EM_G2 = { + "em:0": { + "id": 0, + "a_current": 1.0, + "a_voltage": 220.0, + "a_act_power": 230.0, + "a_aprt_power": 71.2, + "a_pf": 0.50, + "a_freq": 50.0, + "b_current": 2.0, + "b_voltage": 230.0, + "b_act_power": 460.0, + "b_aprt_power": 58.8, + "b_pf": 1.00, + "b_freq": 50.0, + "c_current": 10.0, + "c_voltage": 240.0, + "c_act_power": 2300.0, + "c_aprt_power": 56.5, + "c_pf": 1.50, + "c_freq": 50.0, + "n_current": "null", + "total_current": 0.812, + "total_act_power": 2990.00, + "total_aprt_power": 186.512, + "user_calibrated_phase": [] + }, + "emdata:0": { + "id": 0, + "a_total_act_energy": 24169.51, + "a_total_act_ret_energy": 1356754.52, + "b_total_act_energy": 19485.50, + "b_total_act_ret_energy": 1256348.10, + "c_total_act_energy": 18670.00, + "c_total_act_ret_energy": 1211805.10, + "total_act": 62325.00, + "total_act_ret": 3824907.72 + }, +} # Shelly MiniPM G3 https://forum.openwb.de/viewtopic.php?p=117309#p117309 DATA_MINPM_G3 = { # [..] @@ -18,42 +103,262 @@ "freq": 51, "aenergy": { "total": 3195.88, - "by_minute": [ - 0, - 0, - 0 - ], + "by_minute": [0, 0, 0], "minute_ts": 1727857620 }, "ret_aenergy": { "total": 0, - "by_minute": [ - 0, - 0, - 0 - ], + "by_minute": [0, 0, 0], "minute_ts": 1727857620 } }, # [..] } +# Shelly 1PM G4 +DATA_1PM_G4 = { + # [...] + "switch:0": { + "id": 0, + "source": "init", + "output": True, + "apower": 117.9, + "voltage": 227.3, + "freq": 52.0, + "current": 0.65, + "aenergy": {"total": 185774.593, "by_minute": [2394.379, 1795.784, 1995.316], "minute_ts": 1756903980}, + "ret_aenergy": {"total": 185618.039, "by_minute": [2394.379, 1795.784, 1995.316], "minute_ts": 1756903980}, + "temperature": {"tC": 54.8, "tF": 130.6} + }, + # [...] +} + + +@dataclass +class CounterParams: + name: str + json_data: str + factor: int = 1 + phase: int = 1 + generation: int = 1 + expected_counter_state: Optional[CounterState] = None + +cases = [ + CounterParams(name="G1 - Shelly Plug Counter - Phase 1", + json_data=DATA_PLUG_G1, factor=1, phase=1, generation=1, + expected_counter_state=CounterState( + voltages=[230, 0, 0], power=230, currents=[1, 0, 0], + frequency=50, imported=100, exported=200, powers=[230, 0, 0])), + CounterParams(name="G1 - Shelly Plug Counter - Phase 2", + json_data=DATA_PLUG_G1, factor=1, phase=2, generation=1, + expected_counter_state=CounterState( + voltages=[0, 230, 0], power=230, currents=[0, 1, 0], + frequency=50, imported=100, exported=200, powers=[0, 230, 0])), + CounterParams(name="G1 - Shelly Plug Counter - Phase 3, Faktor -1", + json_data=DATA_PLUG_G1, factor=-1, phase=3, generation=1, + expected_counter_state=CounterState( + voltages=[0, 0, 230], power=-230, currents=[0, 0, -1], + frequency=50, imported=100, exported=200, powers=[0, 0, -230])), + CounterParams(name="G1 - Shelly EM3 Counter - Phase 1, Faktor -1", + json_data=DATA_EM_3_G1, factor=1, phase=1, generation=1, + expected_counter_state=CounterState( + voltages=[220.0, 230.0, 240.0], power=2990.00, currents=[10.0, 2.0, 1.0], frequency=50, + imported=100, exported=200, powers=[2300, 460, 230], power_factors=[1.0, 1.0, 1.0])), + CounterParams(name="G1 - Shelly EM3 Counter - Phase 2", + json_data=DATA_EM_3_G1, factor=-1, phase=2, generation=1, + expected_counter_state=CounterState( + voltages=[240.0, 220.0, 230.0], power=-2990.00, currents=[-1.0, -10.0, -2.0], frequency=50, + imported=100, exported=200, powers=[-230, -2300, -460], power_factors=[1.0, 1.0, 1.0])), + CounterParams(name="G1 - Shelly EM3 Counter - Phase 3", + json_data=DATA_EM_3_G1, factor=1, phase=3, generation=1, + expected_counter_state=CounterState( + voltages=[230.0, 240.0, 220.0], power=2990.00, currents=[2.0, 1.0, 10.0], frequency=50, + imported=100, exported=200, powers=[460, 230, 2300], power_factors=[1.0, 1.0, 1.0])), + CounterParams(name="G2 - Shelly Pro3 EM Counter - Phase 1", + json_data=DATA_PRO_3EM_G2, factor=1, phase=1, generation=2, + expected_counter_state=CounterState( + voltages=[220.0, 230.0, 240.0], power=2990.00, currents=[1.0, 2.0, 10.0], frequency=50, + imported=100, exported=200, powers=[230, 460, 2300], power_factors=[0.5, 1.0, 1.5])), + CounterParams(name="G2 - Shelly Pro3 EM Counter - Phase 2, Faktor -1", + json_data=DATA_PRO_3EM_G2, factor=-1, phase=2, generation=2, + expected_counter_state=CounterState( + voltages=[240.0, 220.0, 230.0], power=-2990.00, currents=[-10.0, -1.0, -2.0], frequency=50, + imported=100, exported=200, powers=[-2300, -230, -460], power_factors=[1.5, 0.5, 1.0])), + CounterParams(name="G2 - Shelly Pro3 EM Counter - Phase 3", + json_data=DATA_PRO_3EM_G2, factor=1, phase=3, generation=2, + expected_counter_state=CounterState( + voltages=[230.0, 240.0, 220.0], power=2990.00, currents=[2.0, 10.0, 1.0], frequency=50, + imported=100, exported=200, powers=[460, 2300, 230], power_factors=[1.0, 1.5, 0.5])), + CounterParams(name="G3 - Shelly Mini PM Counter - Phase 1", + json_data=DATA_MINPM_G3, factor=1, phase=1, generation=3, + expected_counter_state=CounterState( + voltages=[230.9, 0, 0], power=230, currents=[1, 0, 0], frequency=51, + imported=100, exported=200, powers=[230, 0, 0])), + CounterParams(name="G3 - Shelly Mini PM Counter - Phase 2", + json_data=DATA_MINPM_G3, factor=1, phase=2, generation=3, + expected_counter_state=CounterState( + voltages=[0, 230.9, 0], power=230, currents=[0, 1, 0], frequency=51, + imported=100, exported=200, powers=[0, 230, 0])), + CounterParams(name="G3 - Shelly Mini PM Counter - Phase 3", + json_data=DATA_MINPM_G3, factor=1, phase=3, generation=3, + expected_counter_state=CounterState( + voltages=[0, 0, 230.9], power=230, currents=[0, 0, 1], frequency=51, + imported=100, exported=200, powers=[0, 0, 230])), + CounterParams(name="G4 - Shelly 1PM Counter - Phase 1", + json_data=DATA_1PM_G4, factor=1, phase=1, generation=4, + expected_counter_state=CounterState( + voltages=[227.3, 0, 0], power=117.9, currents=[0.65, 0, 0], frequency=52, + imported=100, exported=200, powers=[117.9, 0, 0])), + CounterParams(name="G4 - Shelly 1PM Counter - Phase 2", + json_data=DATA_1PM_G4, factor=1, phase=2, generation=4, + expected_counter_state=CounterState( + voltages=[0, 227.3, 0], power=117.9, currents=[0, 0.65, 0], frequency=52, + imported=100, exported=200, powers=[0, 117.9, 0])), + CounterParams(name="G4 - Shelly 1PM Counter - Phase 3", + json_data=DATA_1PM_G4, factor=1, phase=3, generation=4, + expected_counter_state=CounterState( + voltages=[0, 0, 227.3], power=117.9, currents=[0, 0, 0.65], frequency=52, + imported=100, exported=200, powers=[0, 0, 117.9])), +] -def test_counter_shelly_minipm_g3(monkeypatch, requests_mock: requests_mock.mock): + +@pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) +def test_counter(params: CounterParams, monkeypatch, requests_mock: requests_mock.mock): mock_counter_value_store = Mock() monkeypatch.setattr(counter, "get_counter_value_store", Mock(return_value=mock_counter_value_store)) - requests_mock.get(f"http://{SAMPLE_IP}/rpc/Shelly.GetStatus", json=DATA_MINPM_G3) + if params.generation == 1: + requests_mock.get(f"http://{SAMPLE_IP}/status", json=params.json_data) + else: + requests_mock.get(f"http://{SAMPLE_IP}/rpc/Shelly.GetStatus", json=params.json_data) mock_counter_value_store = Mock() monkeypatch.setattr(counter, "get_counter_value_store", Mock(return_value=mock_counter_value_store)) - c = counter.ShellyCounter(ShellyCounterSetup(), device_id=0, ip_address=SAMPLE_IP, factor=1, generation=2) + c = counter.ShellyCounter( + ShellyCounterSetup(), device_id=0, ip_address=SAMPLE_IP, + factor=params.factor, phase=params.phase, generation=params.generation) + c.initialize() + + # execution + c.update() + + # evaluation + assert vars(mock_counter_value_store.set.call_args[0][0]) == vars(params.expected_counter_state) + + +@dataclass +class InverterParams: + name: str + json_data: str + factor: int = 1 + phase: int = 1 + generation: int = 1 + expected_inverter_state: Optional[InverterState] = None + + +cases = [ + InverterParams(name="G1 - Shelly Plug Inverter - Phase 1", + json_data=DATA_PLUG_G1, factor=1, phase=1, generation=1, + expected_inverter_state=InverterState( + power=230, currents=[1, 0, 0], exported=200)), + InverterParams(name="G1 - Shelly Plug Inverter - Phase 3, Faktor -1", + json_data=DATA_PLUG_G1, factor=-1, phase=3, generation=1, + expected_inverter_state=InverterState( + power=-230, currents=[0, 0, -1], exported=200)), + InverterParams(name="G1 - Shelly EM3 Inverter - Phase 2, Faktor -1", + json_data=DATA_EM_3_G1, factor=-1, phase=2, generation=1, + expected_inverter_state=InverterState( + power=-2990.00, currents=[-1.0, -10.0, -2.0], exported=200)), + InverterParams(name="G3 - Shelly Mini PM Inverter - Phase 1, Faktor -1", + json_data=DATA_MINPM_G3, factor=-1, phase=1, generation=3, + expected_inverter_state=InverterState( + power=-230, currents=[-1, 0, 0], exported=200)), + InverterParams(name="G3 - Shelly Mini PM Inverter - Phase 3", + json_data=DATA_MINPM_G3, factor=1, phase=3, generation=3, + expected_inverter_state=InverterState( + power=230, currents=[0, 0, 1], exported=200)), + InverterParams(name="G4 - Shelly 1PM Inverter - Phase 2, Faktor -1", + json_data=DATA_1PM_G4, factor=-1, phase=2, generation=4, + expected_inverter_state=InverterState( + power=-117.9, currents=[0, -0.65, 0], exported=200)), +] + + +@pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) +def test_inverter(params: InverterParams, monkeypatch, requests_mock: requests_mock.mock): + mock_inverter_value_store = Mock() + monkeypatch.setattr(inverter, "get_inverter_value_store", Mock(return_value=mock_inverter_value_store)) + if params.generation == 1: + requests_mock.get(f"http://{SAMPLE_IP}/status", json=params.json_data) + else: + requests_mock.get(f"http://{SAMPLE_IP}/rpc/Shelly.GetStatus", json=params.json_data) + mock_inverter_value_store = Mock() + monkeypatch.setattr(inverter, "get_inverter_value_store", Mock(return_value=mock_inverter_value_store)) + c = inverter.ShellyInverter( + ShellyInverterSetup(), device_id=0, ip_address=SAMPLE_IP, + factor=params.factor, phase=params.phase, generation=params.generation) c.initialize() # execution c.update() # evaluation - assert vars(mock_counter_value_store.set.call_args[0][0]) == vars(SAMPLE_COUNTER_STATE) + assert vars(mock_inverter_value_store.set.call_args[0][0]) == vars(params.expected_inverter_state) + +@dataclass +class BatParams: + name: str + json_data: str + factor: int = 1 + phase: int = 1 + generation: int = 1 + expected_bat_state: Optional[BatState] = None -SAMPLE_COUNTER_STATE = CounterState(voltages=[230.9, 0, 0], power=230, currents=[ - 1, 0, 0], frequency=51, imported=100, exported=200, powers=[230, 0, 0]) + +cases = [ + BatParams(name="G1 - Shelly Plug Bat - Phase 1", + json_data=DATA_PLUG_G1, factor=1, phase=1, generation=1, + expected_bat_state=BatState( + power=230, currents=[1, 0, 0], imported=100, exported=200)), + BatParams(name="G1 - Shelly Plug Bat - Phase 3, Faktor -1", + json_data=DATA_PLUG_G1, factor=-1, phase=3, generation=1, + expected_bat_state=BatState( + power=-230, currents=[0, 0, -1], imported=100, exported=200)), + BatParams(name="G1 - Shelly EM3 Bat - Phase 2, Faktor -1", + json_data=DATA_EM_3_G1, factor=-1, phase=2, generation=1, + expected_bat_state=BatState( + power=-2990.00, currents=[-1.0, -10.0, -2.0], imported=100, exported=200)), + BatParams(name="G3 - Shelly Mini PM Bat - Phase 1, Faktor -1", + json_data=DATA_MINPM_G3, factor=-1, phase=1, generation=3, + expected_bat_state=BatState( + power=-230, currents=[-1, 0, 0], imported=100, exported=200)), + BatParams(name="G3 - Shelly Mini PM Bat - Phase 3", + json_data=DATA_MINPM_G3, factor=1, phase=3, generation=3, + expected_bat_state=BatState( + power=230, currents=[0, 0, 1], imported=100, exported=200)), + BatParams(name="G4 - Shelly 1PM Bat - Phase 2, Faktor -1", + json_data=DATA_1PM_G4, factor=-1, phase=2, generation=4, + expected_bat_state=BatState( + power=-117.9, currents=[0, -0.65, 0], imported=100, exported=200)), +] + + +@pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) +def test_bat(params: BatParams, monkeypatch, requests_mock: requests_mock.mock): + mock_bat_value_store = Mock() + monkeypatch.setattr(bat, "get_bat_value_store", Mock(return_value=mock_bat_value_store)) + if params.generation == 1: + requests_mock.get(f"http://{SAMPLE_IP}/status", json=params.json_data) + else: + requests_mock.get(f"http://{SAMPLE_IP}/rpc/Shelly.GetStatus", json=params.json_data) + mock_bat_value_store = Mock() + monkeypatch.setattr(bat, "get_bat_value_store", Mock(return_value=mock_bat_value_store)) + c = bat.ShellyBat( + ShellyBatSetup(), device_id=0, ip_address=SAMPLE_IP, + factor=params.factor, phase=params.phase, generation=params.generation) + c.initialize() + + # execution + c.update() + + # evaluation + assert vars(mock_bat_value_store.set.call_args[0][0]) == vars(params.expected_bat_state) diff --git a/packages/modules/devices/sigenergy/sigenergy/bat.py b/packages/modules/devices/sigenergy/sigenergy/bat.py index 55adf95bd1..b27756e92a 100644 --- a/packages/modules/devices/sigenergy/sigenergy/bat.py +++ b/packages/modules/devices/sigenergy/sigenergy/bat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 import logging -from typing import TypedDict, Any +from typing import TypedDict, Any, Optional from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor @@ -29,6 +29,7 @@ def initialize(self) -> None: self.store = get_bat_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="speicher") + self.last_mode = 'Undefined' def update(self) -> None: unit = self.component_config.configuration.modbus_id @@ -46,5 +47,25 @@ def update(self) -> None: ) self.store.set(bat_state) + def set_power_limit(self, power_limit: Optional[int]) -> None: + unit = self.component_config.configuration.modbus_id + log.debug(f'last_mode: {self.last_mode}') + # Steuerung erfolgt über SoC (mit Faktor 10) + if power_limit is None: + log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") + if self.last_mode is not None: + # Entladesperre ab 5%, Ansonsten Eigenregelung + self.__tcp_client.write_registers(40048, [50], data_type=ModbusDataType.UINT_16, unit=unit) + self.last_mode = None + else: + log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") + if self.last_mode != 'stop': + # Entladesperre auch bei 100% SoC + self.__tcp_client.write_registers(40048, [1000], data_type=ModbusDataType.UINT_16, unit=unit) + self.last_mode = 'stop' + + def power_limit_controllable(self) -> bool: + return True + component_descriptor = ComponentDescriptor(configuration_factory=SigenergyBatSetup) diff --git a/packages/modules/devices/sma/sma_sunny_boy/inverter.py b/packages/modules/devices/sma/sma_sunny_boy/inverter.py index f19f13557e..c391d9aabf 100644 --- a/packages/modules/devices/sma/sma_sunny_boy/inverter.py +++ b/packages/modules/devices/sma/sma_sunny_boy/inverter.py @@ -68,7 +68,13 @@ def read(self) -> InverterState: power_total = self.tcp_client.read_holding_registers(40084, ModbusDataType.INT_16, unit=unit) * 10 # Gesamtertrag (Wh) [E-Total] SF=2! energy = self.tcp_client.read_holding_registers(40094, ModbusDataType.UINT_32, unit=unit) * 100 + # Power dc_power = self.tcp_client.read_holding_registers(40101, ModbusDataType.UINT_32, unit=unit) * 100 + # Phasenstöme + current_L1 = self.tcp_client.read_holding_registers(30977, ModbusDataType.INT_32, unit=unit) * -1 + current_L2 = self.tcp_client.read_holding_registers(30979, ModbusDataType.INT_32, unit=unit) * -1 + current_L3 = self.tcp_client.read_holding_registers(30981, ModbusDataType.INT_32, unit=unit) * -1 + currents = [current_L1 / 1000, current_L2 / 1000, current_L3 / 1000] elif self.component_config.configuration.version == SmaInverterVersion.datamanager: # AC Wirkleistung über alle Phasen (W) [Pac] power_total = self.tcp_client.read_holding_registers(30775, ModbusDataType.INT_32, unit=unit) @@ -78,6 +84,9 @@ def read(self) -> InverterState: # daher ist wie bei SmaInverterVersion.default keine Prüfung auf DC-Leistung notwendig. # Aus kompatibilitätsgründen wird dc_power auf den Wert der AC-Wirkleistung gesetzt. dc_power = power_total + # Der Data-Manager/Cluster-Controller bietet keine Modbus-Register mit Phasenströmen an. + # Daher die Phasenströme berechnen (es wird davon ausgegangen, dass eine symmetrische Erzeugung erfolgt) + currents = [(power_total / 3 / 230) * -1] * 3 else: raise ValueError("Unbekannte Version "+str(self.component_config.configuration.version)) if power_total == self.SMA_INT32_NAN or power_total == self.SMA_NAN: diff --git a/packages/modules/devices/sofar/sofar/inverter.py b/packages/modules/devices/sofar/sofar/inverter.py index 8cb7878b50..6c1f873c84 100644 --- a/packages/modules/devices/sofar/sofar/inverter.py +++ b/packages/modules/devices/sofar/sofar/inverter.py @@ -26,7 +26,7 @@ def initialize(self) -> None: self.store = get_inverter_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) - def update(self, client: ModbusTcpClient_) -> None: + def update(self) -> None: # 0x05C4 Power_PV_Total UINT16 in kW accuracy 0,1 power = self.client.read_holding_registers(0x05C4, ModbusDataType.UINT_16, unit=self.__modbus_id) * -100 exported = self.client.read_holding_registers(0x0686, ModbusDataType.UINT_32, unit=self.__modbus_id) * 100 diff --git a/packages/modules/devices/solakon/__init__.py b/packages/modules/devices/solakon/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/solakon/solakon_one/__init__.py b/packages/modules/devices/solakon/solakon_one/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/solakon/solakon_one/bat.py b/packages/modules/devices/solakon/solakon_one/bat.py new file mode 100644 index 0000000000..4d8d2c5342 --- /dev/null +++ b/packages/modules/devices/solakon/solakon_one/bat.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import logging +from typing import TypedDict, Any +from modules.common.abstract_device import AbstractBat +from modules.common.component_state import BatState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.store import get_bat_value_store +from modules.devices.solakon.solakon_one.config import SolakonOneBatSetup + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + client: ModbusTcpClient_ + + +class SolakonOneBat(AbstractBat): + def __init__(self, component_config: SolakonOneBatSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.store = get_bat_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + unit = self.component_config.configuration.modbus_id + + # AC Leistung am Stecker, Batterie aus dem Netz aufladen hat positive Werte, + # Leistung aus der Batterie und/oder aus PV ins Netz abgeben hat negative Werte + power = self.client.read_holding_registers(39134, ModbusDataType.INT_32, unit=unit) * -1 + # SoC Ladezustand der Batterie in % + soc = self.client.read_holding_registers(39424, ModbusDataType.INT_16, unit=unit) + # gesamte DC Ladung der Batterie in Wh + imported = self.client.read_holding_registers(39605, ModbusDataType.UINT_32, unit=unit) * 10 + # gesamte DC Entladung der Batterie in Wh + exported = self.client.read_holding_registers(39609, ModbusDataType.UINT_32, unit=unit) * 10 + + bat_state = BatState( + power=power, + soc=soc, + imported=imported, + exported=exported + ) + self.store.set(bat_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=SolakonOneBatSetup) diff --git a/packages/modules/devices/solakon/solakon_one/config.py b/packages/modules/devices/solakon/solakon_one/config.py new file mode 100644 index 0000000000..bdcb9f4db9 --- /dev/null +++ b/packages/modules/devices/solakon/solakon_one/config.py @@ -0,0 +1,58 @@ +from typing import Optional + +from helpermodules.auto_str import auto_str +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +class SolakonOneConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502): + self.ip_address = ip_address + self.port = port + + +class SolakonOne: + def __init__(self, + name: str = "Solakon One", + type: str = "solakon_one", + id: int = 0, + configuration: SolakonOneConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or SolakonOneConfiguration() + + +@auto_str +class SolakonOneBatConfiguration: + def __init__(self, modbus_id: int = 1): + self.modbus_id = modbus_id + + +@auto_str +class SolakonOneBatSetup(ComponentSetup[SolakonOneBatConfiguration]): + def __init__(self, + name: str = "Solakon One Speicher", + type: str = "bat", + id: int = 0, + configuration: SolakonOneBatConfiguration = None) -> None: + super().__init__(name, type, id, configuration or SolakonOneBatConfiguration()) + + +@auto_str +class SolakonOneInverterConfiguration: + def __init__(self, modbus_id: int = 1): + self.modbus_id = modbus_id + + +@auto_str +class SolakonOneInverterSetup(ComponentSetup[SolakonOneInverterConfiguration]): + def __init__(self, + name: str = "Solakon One Wechselrichter", + type: str = "inverter", + id: int = 0, + configuration: SolakonOneInverterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or SolakonOneInverterConfiguration()) diff --git a/packages/modules/devices/solakon/solakon_one/device.py b/packages/modules/devices/solakon/solakon_one/device.py new file mode 100644 index 0000000000..fcc8c2ca57 --- /dev/null +++ b/packages/modules/devices/solakon/solakon_one/device.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable, Union + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.solakon.solakon_one.bat import SolakonOneBat +from modules.devices.solakon.solakon_one.inverter import SolakonOneInverter +from modules.devices.solakon.solakon_one.config import SolakonOne, SolakonOneBatSetup, SolakonOneInverterSetup + +log = logging.getLogger(__name__) + + +def create_device(device_config: SolakonOne): + client = None + + def create_bat_component(component_config: SolakonOneBatSetup): + nonlocal client + return SolakonOneBat(component_config=component_config, client=client) + + def create_inverter_component(component_config: SolakonOneInverterSetup): + nonlocal client + return SolakonOneInverter(component_config=component_config, client=client) + + def update_components(components: Iterable[Union[SolakonOneBat, SolakonOneInverter]]): + nonlocal client + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + bat=create_bat_component, + inverter=create_inverter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=SolakonOne) diff --git a/packages/modules/devices/solakon/solakon_one/inverter.py b/packages/modules/devices/solakon/solakon_one/inverter.py new file mode 100644 index 0000000000..130bb10e1b --- /dev/null +++ b/packages/modules/devices/solakon/solakon_one/inverter.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.store import get_inverter_value_store +from modules.devices.solakon.solakon_one.config import SolakonOneInverterSetup + + +class KwargsDict(TypedDict): + client: ModbusTcpClient_ + + +class SolakonOneInverter(AbstractInverter): + def __init__(self, component_config: SolakonOneInverterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.store = get_inverter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + unit = self.component_config.configuration.modbus_id + # Gesamte DC PV Leistung aller vier MPPT in W + power = self.client.read_holding_registers(39118, ModbusDataType.INT_32, unit=unit) + # Gesamte DC PV Produktion in Wh + exported = self.client.read_holding_registers(39601, ModbusDataType.UINT_32, unit=unit) * 10 + + inverter_state = InverterState( + power=power, + exported=exported + ) + self.store.set(inverter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=SolakonOneInverterSetup) diff --git a/packages/modules/devices/solakon/vendor.py b/packages/modules/devices/solakon/vendor.py new file mode 100644 index 0000000000..a2e2d9c374 --- /dev/null +++ b/packages/modules/devices/solakon/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Solakon" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/devices/solaredge/solaredge/counter.py b/packages/modules/devices/solaredge/solaredge/counter.py index 03ea24a0a6..be654e2ef4 100644 --- a/packages/modules/devices/solaredge/solaredge/counter.py +++ b/packages/modules/devices/solaredge/solaredge/counter.py @@ -10,7 +10,7 @@ from modules.common.modbus import ModbusDataType from modules.common.store import get_counter_value_store from modules.devices.solaredge.solaredge.config import SolaredgeCounterSetup -from modules.devices.solaredge.solaredge.scale import create_scaled_reader +from modules.devices.solaredge.solaredge.scale import scale_registers from modules.devices.solaredge.solaredge.meter import SolaredgeMeterRegisters, set_component_registers log = logging.getLogger(__name__) @@ -36,31 +36,37 @@ def initialize(self) -> None: components.append(self) set_component_registers(self.component_config, self.__tcp_client, components) - self._read_scaled_int16 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.INT_16 - ) - self._read_scaled_uint32 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.UINT_32 + def update(self): + reg_mapping = ( + (self.registers.voltages, [ModbusDataType.INT_16]*3), + (self.registers.voltages_scale, ModbusDataType.INT_16), + (self.registers.currents, [ModbusDataType.INT_16]*3), + (self.registers.currents_scale, ModbusDataType.INT_16), + (self.registers.powers, [ModbusDataType.INT_16]*3), + (self.registers.power, ModbusDataType.INT_16), + (self.registers.powers_scale, ModbusDataType.INT_16), + (self.registers.power_factors, [ModbusDataType.INT_16]*3), + (self.registers.power_factors_scale, ModbusDataType.INT_16), + (self.registers.frequency, ModbusDataType.INT_16), + (self.registers.frequency_scale, ModbusDataType.INT_16), + (self.registers.imported, ModbusDataType.UINT_32), + (self.registers.exported, ModbusDataType.UINT_32), + (self.registers.imp_exp_scale, ModbusDataType.INT_16), ) + resp = self.__tcp_client.read_holding_registers_bulk( + self.registers.currents, 52, mapping=reg_mapping, unit=self.component_config.configuration.modbus_id) - def update(self): - powers = [-power for power in self._read_scaled_int16(self.registers.powers, 4)] - currents = self._read_scaled_int16(self.registers.currents, 3) - voltages = self._read_scaled_int16(self.registers.voltages, 7)[:3] - frequency = self._read_scaled_int16(self.registers.frequency, 1)[0] - power_factors = [power_factor / - 100 for power_factor in self._read_scaled_int16(self.registers.power_factors, 3)] - counter_values = self._read_scaled_uint32(self.registers.imp_exp, 8) - counter_exported, counter_imported = [counter_values[i] for i in [0, 4]] counter_state = CounterState( - imported=counter_imported, - exported=counter_exported, - power=powers[0], - powers=powers[1:], - voltages=voltages, - currents=currents, - power_factors=power_factors, - frequency=frequency + imported=scale_registers(resp[self.registers.imported], resp[self.registers.imp_exp_scale]), + exported=scale_registers(resp[self.registers.exported], resp[self.registers.imp_exp_scale]), + power=scale_registers(resp[self.registers.power], resp[self.registers.powers_scale]) * -1, + powers=[-power for power in scale_registers(resp[self.registers.powers], + resp[self.registers.powers_scale])], + voltages=scale_registers(resp[self.registers.voltages], resp[self.registers.voltages_scale]), + currents=scale_registers(resp[self.registers.currents], resp[self.registers.currents_scale]), + power_factors=[power_factor / 100 for power_factor in scale_registers( + resp[self.registers.power_factors], resp[self.registers.power_factors_scale])], + frequency=scale_registers(resp[self.registers.frequency], resp[self.registers.frequency_scale]), ) self.store.set(counter_state) diff --git a/packages/modules/devices/solaredge/solaredge/external_inverter.py b/packages/modules/devices/solaredge/solaredge/external_inverter.py index e4f4185d62..668b3b9d42 100644 --- a/packages/modules/devices/solaredge/solaredge/external_inverter.py +++ b/packages/modules/devices/solaredge/solaredge/external_inverter.py @@ -10,7 +10,7 @@ from modules.common.modbus import ModbusDataType from modules.common.store import get_inverter_value_store from modules.devices.solaredge.solaredge.config import SolaredgeExternalInverterSetup -from modules.devices.solaredge.solaredge.scale import create_scaled_reader +from modules.devices.solaredge.solaredge.scale import scale_registers from modules.devices.solaredge.solaredge.meter import SolaredgeMeterRegisters, set_component_registers log = logging.getLogger(__name__) @@ -38,26 +38,26 @@ def initialize(self) -> None: components.append(self) set_component_registers(self.component_config, self.__tcp_client, components) - self._read_scaled_int16 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.INT_16 - ) - self._read_scaled_uint32 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.UINT_32 - ) - def update(self) -> None: self.store.set(self.read_state()) def read_state(self) -> InverterState: - factor = self.component_config.configuration.factor - power = self._read_scaled_int16(self.registers.powers, 4)[0] * factor - exported = self._read_scaled_uint32(self.registers.imp_exp, 8)[0] - currents = self._read_scaled_int16(self.registers.currents, 3) + reg_mapping = ( + (self.registers.currents, [ModbusDataType.INT_16]*3), + (self.registers.currents_scale, ModbusDataType.INT_16), + (self.registers.power, ModbusDataType.INT_16), + (self.registers.powers_scale, ModbusDataType.INT_16), + (self.registers.exported, ModbusDataType.UINT_32), + (self.registers.imp_exp_scale, ModbusDataType.INT_16), + ) + resp = self.__tcp_client.read_holding_registers_bulk( + self.registers.currents, 52, mapping=reg_mapping, unit=self.component_config.configuration.modbus_id) + factor = self.component_config.configuration.factor return InverterState( - exported=exported, - power=power, - currents=currents + exported=scale_registers(resp[self.registers.exported], resp[self.registers.imp_exp_scale]), + power=scale_registers(resp[self.registers.power], resp[self.registers.powers_scale]) * factor, + currents=scale_registers(resp[self.registers.currents], resp[self.registers.currents_scale]) ) diff --git a/packages/modules/devices/solaredge/solaredge/inverter.py b/packages/modules/devices/solaredge/solaredge/inverter.py index 708e45ed4c..9009a26a10 100644 --- a/packages/modules/devices/solaredge/solaredge/inverter.py +++ b/packages/modules/devices/solaredge/solaredge/inverter.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from enum import IntEnum from typing import TypedDict, Any from modules.common import modbus @@ -9,7 +10,7 @@ from modules.common.modbus import ModbusDataType from modules.common.store import get_inverter_value_store from modules.devices.solaredge.solaredge.config import SolaredgeInverterSetup -from modules.devices.solaredge.solaredge.scale import create_scaled_reader +from modules.devices.solaredge.solaredge.scale import scale_registers from modules.common.simcount import SimCounter @@ -18,7 +19,29 @@ class KwargsDict(TypedDict): device_id: int +class Register(IntEnum): + POWER = 40083 + POWER_SCALE = 40084 + EXPORTED = 40093 + EXPORTED_SCALE = 40095 + CURRENTS = 40072 + CURRENTS_SCALE = 40075 + DC_POWER = 40100 + DC_POWER_SCALE = 40101 + + class SolaredgeInverter(AbstractInverter): + REG_MAPPING = ( + (Register.POWER, ModbusDataType.INT_16), + (Register.POWER_SCALE, ModbusDataType.INT_16), + (Register.EXPORTED, ModbusDataType.UINT_32), + (Register.EXPORTED_SCALE, ModbusDataType.INT_16), + (Register.CURRENTS, [ModbusDataType.UINT_16]*3), + (Register.CURRENTS_SCALE, ModbusDataType.INT_16), + (Register.DC_POWER, ModbusDataType.INT_16), + (Register.DC_POWER_SCALE, ModbusDataType.INT_16), + ) + def __init__(self, component_config: SolaredgeInverterSetup, **kwargs: Any) -> None: @@ -29,43 +52,23 @@ def initialize(self) -> None: self.__tcp_client = self.kwargs['client'] self.store = get_inverter_value_store(self.component_config.id) self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) - self._read_scaled_int16 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.INT_16 - ) - self._read_scaled_uint16 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.UINT_16 - ) - self._read_scaled_uint32 = create_scaled_reader( - self.__tcp_client, self.component_config.configuration.modbus_id, ModbusDataType.UINT_32 - ) self.sim_counter = SimCounter(self.kwargs['device_id'], self.component_config.id, prefix="Wechselrichter") def update(self) -> None: self.store.set(self.read_state()) def read_state(self): - # 40083 = AC Power value (Watt) - # 40084 = AC Power scale factor - power = self._read_scaled_int16(40083, 1)[0] * -1 - - # 40093 = AC Lifetime Energy production (Watt hours) - # 40095 = AC Lifetime scale factor - exported = self._read_scaled_uint32(40093, 1)[0] - # 40072/40073/40074 = AC Phase A/B/C Current value (Amps) - # 40075 = AC Current scale factor - currents = self._read_scaled_uint16(40072, 3) - # 40100 = DC Power value (Watt) - # 40101 = DC Power scale factor - # Wenn bei Hybrid-Systemen der Speicher aus dem Netz geladen wird, ist die DC-Leistung negativ. - dc_power = self._read_scaled_int16(40100, 1)[0] * -1 + resp = self.__tcp_client.read_holding_registers_bulk( + Register.CURRENTS, 30, mapping=self.REG_MAPPING, unit=self.component_config.configuration.modbus_id) + power = scale_registers(resp[Register.POWER], resp[Register.POWER_SCALE]) * -1 imported, _ = self.sim_counter.sim_count(power) return InverterState( power=power, - exported=exported, - currents=currents, - dc_power=dc_power, + exported=scale_registers(resp[Register.EXPORTED], resp[Register.EXPORTED_SCALE]), + currents=scale_registers(resp[Register.CURRENTS], resp[Register.CURRENTS_SCALE]), + dc_power=scale_registers(resp[Register.DC_POWER], resp[Register.DC_POWER_SCALE]) * -1, imported=imported, ) diff --git a/packages/modules/devices/solaredge/solaredge/inverter_test.py b/packages/modules/devices/solaredge/solaredge/inverter_test.py index 496ad865ae..b1f4f83cf7 100644 --- a/packages/modules/devices/solaredge/solaredge/inverter_test.py +++ b/packages/modules/devices/solaredge/solaredge/inverter_test.py @@ -9,14 +9,14 @@ def test_read_state(): # setup - mock_read_holding_registers = Mock(side_effect=[ - [14152, -1], - [8980404, 0], - [616, 65535, 65535, -2], - [14368, -1] - ]) + mock_read_holding_registers_bulk = Mock(side_effect=[{ + 40083: 14152, 40084: -1, + 40093: 8980404, 40095: 0, + 40072: [616, 65535, 65535], 40075: -2, + 40100: 14368, 40101: -1, + }]) inverter = SolaredgeInverter(SolaredgeInverterSetup(), client=Mock( - spec=ModbusTcpClient_, read_holding_registers=mock_read_holding_registers), device_id=1) + spec=ModbusTcpClient_, read_holding_registers_bulk=mock_read_holding_registers_bulk), device_id=1) inverter.initialize() # execution diff --git a/packages/modules/devices/solaredge/solaredge/meter.py b/packages/modules/devices/solaredge/solaredge/meter.py index ebf7d2c257..cd127ddce0 100644 --- a/packages/modules/devices/solaredge/solaredge/meter.py +++ b/packages/modules/devices/solaredge/solaredge/meter.py @@ -2,7 +2,7 @@ import logging from typing import Iterable, Union, List -from modules.common import modbus +from modules.common.modbus import ModbusDataType from modules.devices.solaredge.solaredge.config import (SolaredgeBatSetup, SolaredgeCounterSetup, SolaredgeExternalInverterSetup, SolaredgeInverterSetup) log = logging.getLogger(__name__) @@ -16,25 +16,33 @@ def __init__(self, internal_meter_id: int = 1, synergy_units: int = 1): # 40206: Total Real Power (sum of active phases) # 40207/40208/40209: Real Power by phase # 40210: AC Real Power Scale Factor - self.powers = 40206 + self.power = 40206 + self.powers = 40207 + self.powers_scale = 40210 # 40191/40192/40193: AC Current by phase # 40194: AC Current Scale Factor self.currents = 40191 + self.currents_scale = 40194 # 40196/40197/40198: Voltage per phase # 40203: AC Voltage Scale Factor self.voltages = 40196 + self.voltages_scale = 40203 # 40204: AC Frequency # 40205: AC Frequency Scale Factor self.frequency = 40204 + self.frequency_scale = 40205 # 40222/40223/40224: Power factor by phase (unit=%) # 40225: AC Power Factor Scale Factor self.power_factors = 40222 + self.power_factors_scale = 40225 # 40226: Total Exported Real Energy # 40228/40230/40232: Total Exported Real Energy Phase (not used) # 40234: Total Imported Real Energy # 40236/40238/40240: Total Imported Real Energy Phase (not used) # 40242: Real Energy Scale Factor - self.imp_exp = 40226 + self.exported = 40226 + self.imported = 40234 + self.imp_exp_scale = 40242 # 40155: C_Option Export + Import, Production, consumption, self.option = 40155 self._update_offset_meter_id(internal_meter_id) @@ -87,14 +95,14 @@ def _get_synergy_units(component_config: Union[SolaredgeBatSetup, SolaredgeInverterSetup, SolaredgeExternalInverterSetup], client) -> int: - if client.read_holding_registers(40121, modbus.ModbusDataType.UINT_16, + if client.read_holding_registers(40121, ModbusDataType.UINT_16, unit=component_config.configuration.modbus_id ) == synergy_unit_identifier: # Snyergy-Units vom Haupt-WR des angeschlossenen Meters ermitteln. Es kann mehrere Haupt-WR mit # unterschiedlichen Modbus-IDs im Verbund geben. log.debug("Synergy Units supported") synergy_units = int(client.read_holding_registers( - 40129, modbus.ModbusDataType.UINT_16, + 40129, ModbusDataType.UINT_16, unit=component_config.configuration.modbus_id)) or 1 log.debug( f"Synergy Units detected for Modbus ID {component_config.configuration.modbus_id}: {synergy_units}") diff --git a/packages/modules/devices/solaredge/solaredge/meter_test.py b/packages/modules/devices/solaredge/solaredge/meter_test.py index 02223c9d26..4aa8c15836 100644 --- a/packages/modules/devices/solaredge/solaredge/meter_test.py +++ b/packages/modules/devices/solaredge/solaredge/meter_test.py @@ -26,7 +26,7 @@ def test_meter(params: Params): registers = SolaredgeMeterRegisters(params.meter_id, params.synergy_units) # assert - assert registers.powers == params.expected_power_register + assert registers.power == params.expected_power_register Params = NamedTuple("Params", [("configured_meter_ids", List[int])]) @@ -61,5 +61,5 @@ def test_set_component_registers_assigns_effective_meter_regs(params: Params): _set_registers(components_list, synergy_units=1, modbus_id=1) # evaluation - assert components_list[0].registers.powers == 40206 - assert components_list[1].registers.powers == 40380 + assert components_list[0].registers.power == 40206 + assert components_list[1].registers.power == 40380 diff --git a/packages/modules/devices/solaredge/solaredge/scale.py b/packages/modules/devices/solaredge/solaredge/scale.py index bfe78d2ddd..3f45b15dc9 100644 --- a/packages/modules/devices/solaredge/solaredge/scale.py +++ b/packages/modules/devices/solaredge/solaredge/scale.py @@ -1,8 +1,8 @@ import logging import math -from typing import List +from typing import Iterable, List, Union -from modules.common.modbus import ModbusDataType, ModbusTcpClient_, Number +from modules.common.modbus import Number log = logging.getLogger(__name__) @@ -12,16 +12,9 @@ UINT16_UNSUPPORTED = 0xFFFF -def scale_registers(registers: List[Number]) -> List[float]: - log.debug("Registers %s, Scale %s", registers[:-1], registers[-1]) - scale = math.pow(10, registers[-1]) - return [register * scale if register != UINT16_UNSUPPORTED else 0 for register in registers[:-1]] - - -def create_scaled_reader(client: ModbusTcpClient_, modbus_id: int, type: ModbusDataType): - def scaled_reader(address: int, count: int): - return scale_registers( - client.read_holding_registers(address, [type] * count + [ModbusDataType.INT_16], unit=modbus_id) - ) - - return scaled_reader +def scale_registers(registers: Union[List[Number], Number], scale: float) -> List[float]: + log.debug("Registers %s, Scale %s", registers, scale) + if not isinstance(registers, Iterable): + return registers * math.pow(10, scale) if registers != UINT16_UNSUPPORTED else 0 + else: + return [register * math.pow(10, scale) if register != UINT16_UNSUPPORTED else 0 for register in registers] diff --git a/packages/modules/devices/solarmax/solarmax/bat.py b/packages/modules/devices/solarmax/solarmax/bat.py index c15135050e..97e91d9f79 100644 --- a/packages/modules/devices/solarmax/solarmax/bat.py +++ b/packages/modules/devices/solarmax/solarmax/bat.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -from typing import TypedDict, Any +import logging +from typing import TypedDict, Any, Optional +from pymodbus.constants import Endian from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor @@ -10,6 +12,8 @@ from modules.common.store import get_bat_value_store from modules.devices.solarmax.solarmax.config import SolarmaxBatSetup +log = logging.getLogger(__name__) + class KwargsDict(TypedDict): device_id: int @@ -30,8 +34,8 @@ def initialize(self) -> None: def update(self) -> None: unit = self.component_config.configuration.modbus_id - power = self.client.read_holding_registers(114, ModbusDataType.INT_32, unit=unit) - soc = self.client.read_holding_registers(122, ModbusDataType.INT_16, unit=unit) + power = self.client.read_input_registers(114, ModbusDataType.INT_32, unit=unit, wordorder=Endian.Little) + soc = self.client.read_input_registers(122, ModbusDataType.INT_16, unit=unit) imported, exported = self.sim_counter.sim_count(power) bat_state = BatState( @@ -42,5 +46,33 @@ def update(self) -> None: ) self.store.set(bat_state) + def set_power_limit(self, power_limit: Optional[int]) -> None: + unit = self.component_config.configuration.modbus_id + log.debug(f'last_mode: {self.last_mode}') + # reg 142 is automatically reset every 60s so needs to be written continuously + if power_limit is None: + log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") + if self.last_mode is not None: + self.__tcp_client.write_registers(142, [0], data_type=ModbusDataType.INT_16, unit=unit) + self.last_mode = None + elif power_limit == 0: + log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") + self.__tcp_client.write_registers(140, [0], data_type=ModbusDataType.INT_16, unit=unit) + self.__tcp_client.write_registers(141, [0], data_type=ModbusDataType.INT_16, unit=unit) + self.__tcp_client.write_registers(142, [1], data_type=ModbusDataType.INT_16, unit=unit) + self.last_mode = 'stop' + elif power_limit < 0: + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_limit} W entladen für den Hausverbrauch") + self.__tcp_client.write_registers(142, [1], data_type=ModbusDataType.INT_16, unit=unit) + self.last_mode = 'discharge' + # Die maximale Entladeleistung begrenzen auf 5000W, maximaler Wertebereich Modbusregister. + power_value = int(min(abs(power_limit), 7000)) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_value} W entladen für den Hausverbrauch") + self.__tcp_client.write_registers(140, [power_value], data_type=ModbusDataType.INT_16, unit=unit) + self.__tcp_client.write_registers(141, [power_value], data_type=ModbusDataType.INT_16, unit=unit) + + def power_limit_controllable(self) -> bool: + return self.component_config.configuration.power_limit_controllable + component_descriptor = ComponentDescriptor(configuration_factory=SolarmaxBatSetup) diff --git a/packages/modules/devices/solarmax/solarmax/config.py b/packages/modules/devices/solarmax/solarmax/config.py index 32596ef821..b0efd51e34 100644 --- a/packages/modules/devices/solarmax/solarmax/config.py +++ b/packages/modules/devices/solarmax/solarmax/config.py @@ -23,11 +23,26 @@ def __init__(self, self.configuration = configuration or SolarmaxConfiguration() -class SolarmaxBatConfiguration: +class SolarmaxMsCounterConfiguration: def __init__(self, modbus_id: int = 1): self.modbus_id = modbus_id +class SolarmaxMsCounterSetup(ComponentSetup[SolarmaxMsCounterConfiguration]): + def __init__(self, + name: str = "Solarmax MAX.STORAGE / MAX.STORAGE Ultimate Zähler", + type: str = "counter_maxstorage", + id: Optional[int] = 0, + configuration: SolarmaxMsCounterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or SolarmaxMsCounterConfiguration()) + + +class SolarmaxBatConfiguration: + def __init__(self, modbus_id: int = 1, power_limit_controllable: bool = False): + self.modbus_id = modbus_id + self.power_limit_controllable = power_limit_controllable + + class SolarmaxBatSetup(ComponentSetup[SolarmaxBatConfiguration]): def __init__(self, name: str = "Solarmax MAX.STORAGE / MAX.STORAGE Ultimate Speicher", @@ -37,6 +52,20 @@ def __init__(self, super().__init__(name, type, id, configuration or SolarmaxBatConfiguration()) +class SolarmaxMsInverterConfiguration: + def __init__(self, modbus_id: int = 1): + self.modbus_id = modbus_id + + +class SolarmaxMsInverterSetup(ComponentSetup[SolarmaxMsInverterConfiguration]): + def __init__(self, + name: str = "Solarmax MAX.STORAGE / MAX.STORAGE Ultimate Wechselrichter", + type: str = "inverter_maxstorage", + id: int = 0, + configuration: SolarmaxMsInverterConfiguration = None) -> None: + super().__init__(name, type, id, configuration or SolarmaxMsInverterConfiguration()) + + class SolarmaxInverterConfiguration: def __init__(self, modbus_id: int = 1): self.modbus_id = modbus_id diff --git a/packages/modules/devices/solarmax/solarmax/counter_maxstorage.py b/packages/modules/devices/solarmax/solarmax/counter_maxstorage.py new file mode 100644 index 0000000000..8d524f4856 --- /dev/null +++ b/packages/modules/devices/solarmax/solarmax/counter_maxstorage.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from pymodbus.constants import Endian +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount import SimCounter +from modules.common.store import get_counter_value_store +from modules.devices.solarmax.solarmax.config import SolarmaxMsCounterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + + +class SolarmaxMsCounter(AbstractCounter): + def __init__(self, + component_config: SolarmaxMsCounterSetup, + **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="bezug") + self.store = get_counter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + unit = self.component_config.configuration.modbus_id + power = self.client.read_input_registers(118, ModbusDataType.INT_32, unit=unit, wordorder=Endian.Little) * -1 + imported, exported = self.sim_counter.sim_count(power) + + counter_state = CounterState( + power=power, + imported=imported, + exported=exported + ) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=SolarmaxMsCounterSetup) diff --git a/packages/modules/devices/solarmax/solarmax/device.py b/packages/modules/devices/solarmax/solarmax/device.py index 3fb514d09d..75181d9f23 100644 --- a/packages/modules/devices/solarmax/solarmax/device.py +++ b/packages/modules/devices/solarmax/solarmax/device.py @@ -8,9 +8,13 @@ from modules.common.component_context import SingleComponentUpdateContext from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater from modules.devices.solarmax.solarmax import inverter +from modules.devices.solarmax.solarmax.inverter import SolarmaxInverter from modules.devices.solarmax.solarmax.bat import SolarmaxBat -from modules.devices.solarmax.solarmax.config import ( - Solarmax, SolarmaxBatSetup, SolarmaxConfiguration, SolarmaxInverterSetup) +from modules.devices.solarmax.solarmax.counter_maxstorage import SolarmaxMsCounter +from modules.devices.solarmax.solarmax.inverter_maxstorage import SolarmaxMsInverter +from modules.devices.solarmax.solarmax.config import (Solarmax, SolarmaxConfiguration, + SolarmaxBatSetup, SolarmaxMsCounterSetup, + SolarmaxInverterSetup, SolarmaxMsInverterSetup) log = logging.getLogger(__name__) @@ -24,9 +28,18 @@ def create_bat_component(component_config: SolarmaxBatSetup): def create_inverter_component(component_config: SolarmaxInverterSetup): nonlocal client - return inverter.SolarmaxInverter(component_config, device_id=device_config.id, client=client) + return SolarmaxInverter(component_config, device_id=device_config.id, client=client) - def update_components(components: Iterable[Union[SolarmaxBat, inverter.SolarmaxInverter]]): + def create_inverter_ms_component(component_config: SolarmaxMsInverterSetup): + nonlocal client + return SolarmaxMsInverter(component_config, device_id=device_config.id, client=client) + + def create_counter_ms_component(component_config: SolarmaxMsCounterSetup): + nonlocal client + return SolarmaxMsCounter(component_config, device_id=device_config.id, client=client) + + def update_components(components: Iterable[Union[SolarmaxBat, SolarmaxInverter, + SolarmaxMsCounter, SolarmaxMsInverter]]): nonlocal client with client: for component in components: @@ -43,6 +56,9 @@ def initializer(): component_factory=ComponentFactoryByType( bat=create_bat_component, inverter=create_inverter_component, + counter_maxstorage=create_counter_ms_component, + inverter_maxstorage=create_inverter_ms_component, + ), component_updater=MultiComponentUpdater(update_components) ) diff --git a/packages/modules/devices/solarmax/solarmax/inverter_maxstorage.py b/packages/modules/devices/solarmax/solarmax/inverter_maxstorage.py new file mode 100644 index 0000000000..d8d9722380 --- /dev/null +++ b/packages/modules/devices/solarmax/solarmax/inverter_maxstorage.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +from typing import TypedDict, Any + +from pymodbus.constants import Endian +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount import SimCounter +from modules.common.store import get_inverter_value_store +from modules.devices.solarmax.solarmax.config import SolarmaxMsInverterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: ModbusTcpClient_ + + +class SolarmaxMsInverter(AbstractInverter): + def __init__(self, + component_config: SolarmaxMsInverterSetup, + **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.client: ModbusTcpClient_ = self.kwargs['client'] + self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="pv") + self.store = get_inverter_value_store(self.component_config.id) + self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) + + def update(self) -> None: + unit = self.component_config.configuration.modbus_id + power = self.client.read_input_registers(120, ModbusDataType.INT_32, unit=unit, wordorder=Endian.Little) * -1 + _, exported = self.sim_counter.sim_count(power) + + inverter_state = InverterState( + power=power, + exported=exported + ) + self.store.set(inverter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=SolarmaxMsInverterSetup) diff --git a/packages/modules/devices/tasmota/tasmota/counter.py b/packages/modules/devices/tasmota/tasmota/counter.py index b0330725ba..4a9419ab06 100644 --- a/packages/modules/devices/tasmota/tasmota/counter.py +++ b/packages/modules/devices/tasmota/tasmota/counter.py @@ -50,26 +50,30 @@ def update(self): power_factors[self.__phase-1] = float(response['StatusSNS']['ENERGY']['Factor']) imported = float(response['StatusSNS']['ENERGY']['Total']*1000) _, exported = self.sim_counter.sim_count(power) - - counter_state = CounterState( - power=power, - voltages=voltages, - currents=currents, - powers=powers, - power_factors=power_factors, - imported=imported, - exported=exported - ) - else: + elif 'Itron' in response['StatusSNS']: power = float(response['StatusSNS']['Itron']['Power']) imported = float(response['StatusSNS']['Itron']['E_in']*1000) exported = float(response['StatusSNS']['Itron']['E_out']*1000) + elif 'MT681' in response['StatusSNS']: + power = float(response['StatusSNS']['MT681']['Watt_summe']) + imported = float(response['StatusSNS']['MT681']['Total_in']*1000) + exported = float(response['StatusSNS']['MT681']['Total_out']*1000) + else: + raise ValueError("Nicht unterstützter Tasmota Zählertyp. Bitte an den Support wenden.") - counter_state = CounterState( - power=power, - imported=imported, - exported=exported - ) + counter_state = CounterState( + power=power, + imported=imported, + exported=exported + ) + if 'voltages' in locals(): + counter_state.voltages = voltages + if 'currents' in locals(): + counter_state.currents = currents + if 'powers' in locals(): + counter_state.powers = powers + if 'power_factors' in locals(): + counter_state.power_factors = power_factors self.store.set(counter_state) diff --git a/packages/modules/devices/victron/victron/bat.py b/packages/modules/devices/victron/victron/bat.py index d0fd546fb3..3cfdf58048 100644 --- a/packages/modules/devices/victron/victron/bat.py +++ b/packages/modules/devices/victron/victron/bat.py @@ -50,37 +50,51 @@ def update(self) -> None: def set_power_limit(self, power_limit: Optional[int]) -> None: modbus_id = self.component_config.configuration.modbus_id - + vebus_id = self.component_config.configuration.vebus_id # Wenn Victron Dynamic ESS aktiv, erfolgt keine weitere Regelung in openWB dynamic_ess_mode = self.__tcp_client.read_holding_registers(5400, ModbusDataType.UINT_16, unit=modbus_id) if dynamic_ess_mode == 1: log.debug("Dynamic ESS Mode ist aktiv, daher erfolgt keine Regelung des Speichers durch openWB") return + phases = self.__tcp_client.read_holding_registers(28, ModbusDataType.UINT_16, unit=vebus_id) + if phases == 1: + log.debug("Einphasiger Speicher erkannt, Speichersteuerung nur auf der ersten Phase.") + else: + log.debug("Mehrphasiger Speicher erkannt, Speichersteuerung auf 3 Phasen.") if power_limit is None: log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") if self.last_mode is not None: # ESS Mode 1 für Selbstregelung mit Phasenkompensation setzen - self.__tcp_client.write_registers(39, [0], data_type=ModbusDataType.UINT_16, unit=228) self.__tcp_client.write_registers(2902, [1], data_type=ModbusDataType.UINT_16, unit=modbus_id) + self.__tcp_client.write_registers(39, [0], data_type=ModbusDataType.UINT_16, unit=vebus_id) self.last_mode = None elif power_limit == 0: log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") if self.last_mode != 'stop': # ESS Mode 3 für externe Steuerung und keine Entladung self.__tcp_client.write_registers(2902, [3], data_type=ModbusDataType.UINT_16, unit=modbus_id) - self.__tcp_client.write_registers(39, [1], data_type=ModbusDataType.UINT_16, unit=228) + self.__tcp_client.write_registers(39, [1], data_type=ModbusDataType.UINT_16, unit=vebus_id) self.last_mode = 'stop' elif power_limit < 0: if self.last_mode != 'discharge': # ESS Mode 3 für externe Steuerung und auf L1 wird entladen self.__tcp_client.write_registers(2902, [3], data_type=ModbusDataType.UINT_16, unit=modbus_id) - self.__tcp_client.write_registers(39, [0], data_type=ModbusDataType.UINT_16, unit=228) + self.__tcp_client.write_registers(39, [0], data_type=ModbusDataType.UINT_16, unit=vebus_id) self.last_mode = 'discharge' # Die maximale Entladeleistung begrenzen auf 5000W + if phases == 3: + power_limit = power_limit / 3 power_value = int(min(power_limit, 5000)) - log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_value} W entladen") - self.__tcp_client.write_registers(37, [power_value & 0xFFFF], data_type=ModbusDataType.INT_16, unit=228) + log.debug(f"Aktive Batteriesteuerung. Victron mit {phases} Phase(n). " + f"Batterie wird mit {power_value} W pro Phase entladen.") + self.__tcp_client.write_registers( + 37, [power_value & 0xFFFF], data_type=ModbusDataType.INT_16, unit=vebus_id) + if phases == 3: + self.__tcp_client.write_registers( + 40, [power_value & 0xFFFF], data_type=ModbusDataType.INT_16, unit=vebus_id) + self.__tcp_client.write_registers( + 41, [power_value & 0xFFFF], data_type=ModbusDataType.INT_16, unit=vebus_id) def power_limit_controllable(self) -> bool: return True diff --git a/packages/modules/devices/victron/victron/config.py b/packages/modules/devices/victron/victron/config.py index 808ac01526..b6b3ddd1e1 100644 --- a/packages/modules/devices/victron/victron/config.py +++ b/packages/modules/devices/victron/victron/config.py @@ -24,8 +24,9 @@ def __init__(self, class VictronBatConfiguration: - def __init__(self, modbus_id: int = 100): + def __init__(self, modbus_id: int = 100, vebus_id: int = 228): self.modbus_id = modbus_id + self.vebus_id = vebus_id class VictronBatSetup(ComponentSetup[VictronBatConfiguration]): diff --git a/packages/modules/display_themes/cards/source/public/icons/owbBattery40.svg b/packages/modules/display_themes/cards/source/public/icons/owbBattery40.svg new file mode 100644 index 0000000000..fad5527591 --- /dev/null +++ b/packages/modules/display_themes/cards/source/public/icons/owbBattery40.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/modules/display_themes/cards/source/src/App.vue b/packages/modules/display_themes/cards/source/src/App.vue index 3a1efb2390..104594f8f9 100644 --- a/packages/modules/display_themes/cards/source/src/App.vue +++ b/packages/modules/display_themes/cards/source/src/App.vue @@ -54,8 +54,9 @@ export default { "openWB/counter/+/get/power", "openWB/counter/get/hierarchy", "openWB/counter/set/home_consumption", - "openWB/optional/et/provider", - "openWB/optional/et/get/prices", + "openWB/general/chargemode_config/pv_charging/bat_mode", + "openWB/optional/ep/configured", + "openWB/optional/ep/get/prices", "openWB/optional/int_display/theme", "openWB/optional/int_display/standby", "openWB/optional/rfid/active", diff --git a/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue b/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue new file mode 100644 index 0000000000..d1bfb9f81e --- /dev/null +++ b/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue @@ -0,0 +1,75 @@ + + + diff --git a/packages/modules/display_themes/cards/source/src/components/ChargePoints/ChargeModeModal.vue b/packages/modules/display_themes/cards/source/src/components/ChargePoints/ChargeModeModal.vue index 0035905721..d78420dabc 100644 --- a/packages/modules/display_themes/cards/source/src/components/ChargePoints/ChargeModeModal.vue +++ b/packages/modules/display_themes/cards/source/src/components/ChargePoints/ChargeModeModal.vue @@ -4,7 +4,7 @@ import { useMqttStore } from "@/stores/mqtt.js"; export default { name: "ChargeModeModal", props: { - modelValue: { required: true, type: Boolean, default: false }, + modelValue: { required: true, type: Boolean }, chargePointId: { type: Number, required: true, @@ -138,11 +138,3 @@ export default { - - diff --git a/packages/modules/display_themes/cards/source/src/components/ChargePoints/ManualSocInput.vue b/packages/modules/display_themes/cards/source/src/components/ChargePoints/ManualSocInput.vue index ee36a455c8..100786a062 100644 --- a/packages/modules/display_themes/cards/source/src/components/ChargePoints/ManualSocInput.vue +++ b/packages/modules/display_themes/cards/source/src/components/ChargePoints/ManualSocInput.vue @@ -11,8 +11,8 @@ export default { NumberPad, }, props: { - modelValue: { required: true, type: Boolean, default: false }, - vehicleId: { required: true, type: Number, default: 0 }, + modelValue: { required: true, type: Boolean }, + vehicleId: { required: true, type: Number }, }, emits: ["update:modelValue"], data() { diff --git a/packages/modules/display_themes/cards/source/src/components/ChargePoints/SimpleChargePointCard.vue b/packages/modules/display_themes/cards/source/src/components/ChargePoints/SimpleChargePointCard.vue index 8c88374470..952fd8ed1a 100644 --- a/packages/modules/display_themes/cards/source/src/components/ChargePoints/SimpleChargePointCard.vue +++ b/packages/modules/display_themes/cards/source/src/components/ChargePoints/SimpleChargePointCard.vue @@ -289,12 +289,6 @@ export default { cursor: pointer; } -.large-button { - height: 3.75rem; - font-size: 1.5rem; - padding: 0.75rem 1.5rem; -} - .button-group-wrapper { display: flex; flex-direction: column; diff --git a/packages/modules/display_themes/cards/source/src/components/ChargePoints/VehicleSelectModal.vue b/packages/modules/display_themes/cards/source/src/components/ChargePoints/VehicleSelectModal.vue index 0cee34cee8..e1f18abd4a 100644 --- a/packages/modules/display_themes/cards/source/src/components/ChargePoints/VehicleSelectModal.vue +++ b/packages/modules/display_themes/cards/source/src/components/ChargePoints/VehicleSelectModal.vue @@ -4,7 +4,7 @@ import { useMqttStore } from "@/stores/mqtt.js"; export default { name: "VehicleSelectModal", props: { - modelValue: { required: true, type: Boolean, default: false }, + modelValue: { required: true, type: Boolean }, chargePointId: { type: Number, required: true, @@ -94,10 +94,4 @@ export default { max-height: 72vh; overflow-y: scroll; } - -.large-button { - height: 3.5rem; - font-size: 1.5rem; - padding: 0.75rem 1.5rem; -} diff --git a/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue b/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue index e9bd936a92..3b4a03aa9b 100644 --- a/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue +++ b/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue @@ -2,12 +2,14 @@ import { useMqttStore } from "@/stores/mqtt.js"; import DashboardCard from "@/components/DashboardCard.vue"; import ChargeModeModal from "../ChargePoints/ChargeModeModal.vue"; +import BatteryModeModal from "../Battery/BatteryModeModal.vue"; export default { name: "DashboardFlowCard", components: { DashboardCard, ChargeModeModal, + BatteryModeModal, }, props: { changesLocked: { required: false, type: Boolean, default: false }, @@ -27,6 +29,7 @@ export default { numColumns: 3, }, modalChargeModeSettingsVisible: false, + modalBatteryModeSettingsVisible: false, modalChargePointId: 0, }; }, @@ -232,6 +235,15 @@ export default { chargePoint3Discharging() { return this.chargePoint3Power.value < 0; }, + batteryModeIcon() { + const mode = this.mqttStore.getBatteryMode; + switch (mode) { + case "ev_mode": return "icons/owbVehicle.svg"; + case "bat_mode": return "icons/owbBattery.svg"; + case "min_soc_bat_mode": return "icons/owbBattery40.svg"; + default: return "---"; + } + }, svgComponents() { var components = []; // add grid component @@ -314,6 +326,9 @@ export default { label: ["Speicher", this.absoluteValue(this.batteryPower).textValue], soc: this.batterySoc, icon: "icons/owbBattery.svg", + clicked: () => { + this.selectBatteryMode(); + }, }); } // charge point and vehicle components @@ -491,6 +506,7 @@ export default { // hide all modals if lock is kicking in if (oldValue !== true && newValue === true) { this.modalChargeModeSettingsVisible = false; + this.modalBatteryModeSettingsVisible = false; } }, }, @@ -569,6 +585,11 @@ export default { this.modalChargeModeSettingsVisible = true; } }, + selectBatteryMode() { + if (!this.changesLocked) { + this.modalBatteryModeSettingsVisible = true; + } + }, }, }; @@ -578,6 +599,9 @@ export default { v-model="modalChargeModeSettingsVisible" :charge-point-id="modalChargePointId" /> + -
-
-
Wiederholungen
- - - - - -
- -
- -
-
- -
-
-
Strompreisbasiert laden
-
Anzahl Phasen Zielladen
-
- - - -
-
- Anzahl Phasen bei PV-Überschuss -
-
- - - -
-
+
- - - - - - -
Begrenzung
- - - - - -
- -
- - - - -
-
Wiederholungen
+
+
Wiederholungen
- -
Anzahl Phasen
+ + + + +
Anzahl Phasen
+
Begrenzung
+ + + + + +
+ +
+ + +