diff --git a/data/config/mosquitto/openwb_local.conf b/data/config/mosquitto/openwb_local.conf
index 13fbd9ec8c..8f03b56f6c 100644
--- a/data/config/mosquitto/openwb_local.conf
+++ b/data/config/mosquitto/openwb_local.conf
@@ -1,4 +1,4 @@
-# openwb-version:16
+# openwb-version:17
listener 1886 localhost
allow_anonymous true
@@ -28,6 +28,9 @@ topic openWB/chargepoint/template/# out 2
topic openWB/internal_chargepoint/# out 2
+topic openWB/io/# out 2
+topic openWB/internal_io/# out 2
+
topic openWB/pv/config/configured out 2
topic openWB/pv/get/# out 2
topic openWB/pv/+/config/# out 2
diff --git "a/docs/IO-Ger\303\244te & -Aktionen.md" "b/docs/IO-Ger\303\244te & -Aktionen.md"
new file mode 100644
index 0000000000..57186c772f
--- /dev/null
+++ "b/docs/IO-Ger\303\244te & -Aktionen.md"
@@ -0,0 +1,26 @@
+### 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 HEMS, Dimmung per Direkt-Steuerung, RSE
+Ausführliche Informationen findest Du im gesonderten Wiki-Beitrag [Steuerbare Verbrauchseinrichtungen](https://github.com/openWB/core/wiki/Steuerbare-Verbrauchseinrichtungen)
diff --git "a/docs/Steuerbare Verbrauchseinrichtungen nach \302\247 14a.md" "b/docs/Steuerbare Verbrauchseinrichtungen nach \302\247 14a.md"
new file mode 100644
index 0000000000..e19f35df9b
--- /dev/null
+++ "b/docs/Steuerbare Verbrauchseinrichtungen nach \302\247 14a.md"
@@ -0,0 +1,12 @@
+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 HEMS
+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 kann ein Muster aus verschiedenen Eingängen und ein Prozentwert, auf den die Anschlussleistung begrenzt wird, angegeben werden.
\ No newline at end of file
diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md
index 0cce569b42..2c3f03fb16 100644
--- a/docs/_Sidebar.md
+++ b/docs/_Sidebar.md
@@ -13,9 +13,11 @@
* Oberfläche
* [Anzeige - Steuerung](https://github.com/openWB/core/wiki/Anzeige-Steuerung)
* Features
+ * [Identifikation](https://github.com/openWB/core/wiki/Identifikation)
+ * [IO-Geräte & -Aktionen](https://github.com/openWB/core/wiki/IO-Geräte-&--Aktionen)
* [OCPP](https://github.com/openWB/core/wiki/OCPP)
* [Strompreisbasiertes Laden](https://github.com/openWB/core/wiki/Strompreisbasiertes-Laden)
- * [Identifikation](https://github.com/openWB/core/wiki/Identifikation)
+ * [Steuerbare Verbrauchseinrichtungen](https://github.com/openWB/core/wiki/Steuerbare-Verbrauchseinrichtungen-nach-§14a)
* Szenarien
* [Typische Anwendungsfälle](https://github.com/openWB/core/wiki/Typische-Anwendungsfälle)
* [Hybrid-System aus Wechselrichter und Speicher](https://github.com/openWB/core/wiki/Hybrid-System-aus-Wechselrichter-und-Speicher)
diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py
index ad92724e14..1e76a3f52a 100644
--- a/packages/control/algorithm/additional_current.py
+++ b/packages/control/algorithm/additional_current.py
@@ -3,11 +3,9 @@
from control.algorithm import common
from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT
from control.loadmanagement import LimitingValue, Loadmanagement
-from control.counter import Counter
from control.chargepoint.chargepoint import Chargepoint
from control.algorithm.filter_chargepoints import (get_chargepoints_by_mode_and_counter,
get_preferenced_chargepoint_charging)
-from modules.common.utils.component_parser import get_component_name_by_id
log = logging.getLogger(__name__)
@@ -28,14 +26,14 @@ def set_additional_current(self) -> None:
while len(preferenced_chargepoints):
cp = preferenced_chargepoints[0]
missing_currents, counts = common.get_missing_currents_left(preferenced_chargepoints)
- available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter)
+ available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp)
log.debug(f"cp {cp.num} available currents {available_currents} missing currents "
f"{missing_currents} limit {limit}")
cp.data.control_parameter.limit = limit
available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents)
current = common.get_current_to_set(
cp.data.set.current, available_for_cp, cp.data.set.target_current)
- self._set_loadmangement_message(current, limit, cp, counter)
+ self._set_loadmangement_message(current, limit, cp)
common.set_current_counterdiff(
cp.data.control_parameter.min_current,
current,
@@ -49,8 +47,7 @@ def set_additional_current(self) -> None:
def _set_loadmangement_message(self,
current: float,
limit: LimitingValue,
- chargepoint: Chargepoint,
- counter: Counter) -> None:
+ chargepoint: Chargepoint) -> None:
# Strom muss an diesem Zähler geändert werden
log.debug(
f"current {current} target {chargepoint.data.set.target_current} set current {chargepoint.data.set.current}"
@@ -60,4 +57,4 @@ def _set_loadmangement_message(self,
round(current, 2) != round(max(
chargepoint.data.control_parameter.required_currents), 2)):
chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden"
- f"{limit.value.format(get_component_name_by_id(counter.num))}")
+ f"{limit}")
diff --git a/packages/control/algorithm/additional_current_test.py b/packages/control/algorithm/additional_current_test.py
index 89c552aeff..c6ff09a0b4 100644
--- a/packages/control/algorithm/additional_current_test.py
+++ b/packages/control/algorithm/additional_current_test.py
@@ -1,4 +1,3 @@
-from unittest.mock import Mock
import pytest
from control.algorithm import additional_current
@@ -14,15 +13,15 @@
"set_current, limit, expected_msg",
[pytest.param(7, None, None, id="unverändert"),
pytest.param(
- 6, LimitingValue.CURRENT,
+ 6, LimitingValue.CURRENT.value.format('Garage'),
f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden{LimitingValue.CURRENT.value.format('Garage')}",
id="begrenzt durch Strom"),
pytest.param(
- 6, LimitingValue.POWER,
+ 6, LimitingValue.POWER.value.format('Garage'),
f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden{LimitingValue.POWER.value.format('Garage')}",
id="begrenzt durch Leistung"),
pytest.param(
- 6, LimitingValue.UNBALANCED_LOAD,
+ 6, LimitingValue.UNBALANCED_LOAD.value.format('Garage'),
f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden"
f"{LimitingValue.UNBALANCED_LOAD.value.format('Garage')}",
id="begrenzt durch Schieflast"),
@@ -34,11 +33,9 @@ def test_set_loadmangement_message(set_current, limit, expected_msg, monkeypatch
cp1 = Chargepoint(1, None)
cp1.data = ChargepointData(set=Set(current=set_current),
control_parameter=ControlParameter(required_currents=[8]*3))
- mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(additional_current, "get_component_name_by_id", mockget_component_name_by_id)
# execution
- additional_current.AdditionalCurrent()._set_loadmangement_message(7, limit, cp1, Mock())
+ additional_current.AdditionalCurrent()._set_loadmangement_message(7, limit, cp1)
# evaluation
assert cp1.data.get.state_str == expected_msg
diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py
index 2e5e2b66f8..d72cd84778 100644
--- a/packages/control/algorithm/common.py
+++ b/packages/control/algorithm/common.py
@@ -40,6 +40,7 @@ def mode_and_counter_generator(chargemodes: List) -> Iterable[Tuple[Tuple[Option
counter = data.data.counter_data[f"counter{element['id']}"]
yield mode_tuple, counter
+
# tested
@@ -75,6 +76,7 @@ def set_current_counterdiff(diff_curent: float,
data.data.counter_data[counter].update_surplus_values_left(diffs)
else:
data.data.counter_data[counter].update_values_left(diffs)
+ data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230)
chargepoint.data.set.current = current
log.info(f"LP{chargepoint.num}: Stromstärke {current}A")
@@ -146,6 +148,7 @@ def update_raw_data(preferenced_chargepoints: List[Chargepoint],
data.data.counter_data[counter].update_surplus_values_left(diffs)
else:
data.data.counter_data[counter].update_values_left(diffs)
+ data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230)
def consider_less_charging_chargepoint_in_loadmanagement(cp: Chargepoint, set_current: float) -> bool:
diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py
index 5435c4ef15..2ae8679e98 100644
--- a/packages/control/algorithm/common_test.py
+++ b/packages/control/algorithm/common_test.py
@@ -9,12 +9,14 @@
from control.ev.ev import Ev
from control.counter import Counter
from control.counter_all import CounterAll
+from control.io_device import IoActions
@pytest.fixture(autouse=True)
def cp() -> None:
data.data_init(Mock())
data.data.cp_data = {"cp0": Chargepoint(0, None)}
+ data.data.io_actions = IoActions()
@pytest.mark.parametrize("set_current, expected_current",
diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py
index abf0f02e18..0f9d86433c 100644
--- a/packages/control/algorithm/integration_test/conftest.py
+++ b/packages/control/algorithm/integration_test/conftest.py
@@ -10,6 +10,7 @@
from control.counter_all import CounterAll
from control.counter import Counter
from control.ev.ev import Ev
+from control.io_device import IoActions
from control.pv import Pv
from control.chargepoint.chargepoint_state import ChargepointState
from test_utils.default_hierarchies import NESTED_HIERARCHY
@@ -48,6 +49,7 @@ def data_() -> None:
data.data.counter_all_data = CounterAll()
data.data.counter_all_data.data.get.hierarchy = NESTED_HIERARCHY
data.data.counter_all_data.data.config.consider_less_charging = True
+ data.data.io_actions = IoActions()
@dataclass
diff --git a/packages/control/algorithm/integration_test/instant_charging_test.py b/packages/control/algorithm/integration_test/instant_charging_test.py
index f0582261d5..c9ab77124f 100644
--- a/packages/control/algorithm/integration_test/instant_charging_test.py
+++ b/packages/control/algorithm/integration_test/instant_charging_test.py
@@ -3,10 +3,9 @@
from unittest.mock import Mock
import pytest
-from control.algorithm import additional_current
from control.algorithm.integration_test.conftest import ParamsExpectedSetCurrent, assert_expected_current
from control.chargemode import Chargemode
-from control import data
+from control import data, loadmanagement
from control.algorithm.algorithm import Algorithm
from control.limiting_value import LimitingValue
from dataclass_utils.factories import currents_list_factory
@@ -63,8 +62,6 @@ def test_start_instant_charging(all_cp_instant_charging_1p, all_cp_not_charging,
data.data.counter_data["counter0"].data.set.raw_power_left = 21310
data.data.counter_data["counter0"].data.set.raw_currents_left = [32, 30, 31]
data.data.counter_data["counter6"].data.set.raw_currents_left = [16, 12, 14]
- mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(additional_current, "get_component_name_by_id", mockget_component_name_by_id)
# execution
Algorithm().calc_current()
@@ -123,7 +120,7 @@ def test_instant_charging_limit(params: ParamsLimit, all_cp_instant_charging_1p,
data.data.counter_data["counter0"].data.set.raw_currents_left = params.raw_currents_left_counter0
data.data.counter_data["counter6"].data.set.raw_currents_left = params.raw_currents_left_counter6
mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(additional_current, "get_component_name_by_id", mockget_component_name_by_id)
+ monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mockget_component_name_by_id)
# execution
Algorithm().calc_current()
@@ -200,7 +197,7 @@ def test_control_parameter_instant_charging(params: ParamsControlParameter, all_
data.data.counter_data["counter0"].data.set.raw_currents_left = [32]*3
data.data.counter_data["counter6"].data.set.raw_currents_left = [16]*3
mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(additional_current, "get_component_name_by_id", mockget_component_name_by_id)
+ monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mockget_component_name_by_id)
# execution
Algorithm().calc_current()
diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py
index c15eda4952..2b7383db3b 100644
--- a/packages/control/algorithm/integration_test/pv_charging_test.py
+++ b/packages/control/algorithm/integration_test/pv_charging_test.py
@@ -3,10 +3,9 @@
from unittest.mock import Mock
import pytest
-from control.algorithm import additional_current, surplus_controlled
from control.algorithm.integration_test.conftest import ParamsExpectedSetCurrent, assert_expected_current
from control.chargemode import Chargemode
-from control import data
+from control import data, loadmanagement
from control.algorithm.algorithm import Algorithm
from control.algorithm.algorithm import data as algorithm_data
from control.chargepoint.chargepoint_template import CpTemplate
@@ -121,8 +120,6 @@ def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch)
data.data.counter_data["counter0"].data.set.raw_currents_left = [32, 30, 31]
data.data.counter_data["counter6"].data.set.raw_currents_left = [16, 12, 14]
data.data.counter_data["counter0"].data.set.reserved_surplus = 0
- mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(additional_current, "get_component_name_by_id", mockget_component_name_by_id)
# execution
Algorithm().calc_current()
@@ -159,8 +156,6 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc
"cp4"].data.control_parameter.state = ChargepointState.SWITCH_ON_DELAY
data.data.cp_data[
"cp5"].data.control_parameter.timestamp_switch_on_off = None
- mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(additional_current, "get_component_name_by_id", mockget_component_name_by_id)
# execution
Algorithm().calc_current()
@@ -228,7 +223,7 @@ def test_surplus(params: ParamsSurplus, all_cp_pv_charging_3p, all_cp_charging_3
data.data.counter_data["counter0"].data.set.raw_currents_left = params.raw_currents_left_counter0
data.data.counter_data["counter6"].data.set.raw_currents_left = params.raw_currents_left_counter6
mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(surplus_controlled, "get_component_name_by_id", mockget_component_name_by_id)
+ monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mockget_component_name_by_id)
# execution
Algorithm().calc_current()
@@ -275,8 +270,6 @@ def test_phase_switch(all_cp_pv_charging_3p, all_cp_charging_3p, monkeypatch):
data.data.counter_data["counter0"].data.set.raw_power_left = cases_phase_switch[0].raw_power_left
data.data.counter_data["counter0"].data.set.raw_currents_left = cases_phase_switch[0].raw_currents_left_counter0
data.data.counter_data["counter6"].data.set.raw_currents_left = cases_phase_switch[0].raw_currents_left_counter6
- mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(surplus_controlled, "get_component_name_by_id", mockget_component_name_by_id)
mockget_get_phases_chargemode = Mock(return_value=0)
monkeypatch.setattr(algorithm_data.data.general_data, "get_phases_chargemode", mockget_get_phases_chargemode)
data.data.cp_data[
@@ -300,8 +293,6 @@ 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
- mockget_component_name_by_id = Mock(return_value="Garage")
- monkeypatch.setattr(surplus_controlled, "get_component_name_by_id", mockget_component_name_by_id)
mockget_get_phases_chargemode = Mock(return_value=0)
monkeypatch.setattr(algorithm_data.data.general_data, "get_phases_chargemode", mockget_get_phases_chargemode)
data.data.cp_data["cp3"].data.get.currents = [32, 0, 0]
diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py
index 87d7d9a134..bcb81468dc 100644
--- a/packages/control/algorithm/min_current.py
+++ b/packages/control/algorithm/min_current.py
@@ -4,7 +4,6 @@
from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_MIN_CURRENT
from control.loadmanagement import Loadmanagement
from control.algorithm.filter_chargepoints import get_chargepoints_by_mode_and_counter
-from modules.common.utils.component_parser import get_component_name_by_id
log = logging.getLogger(__name__)
@@ -24,7 +23,8 @@ def set_min_current(self) -> None:
cp = preferenced_chargepoints[0]
missing_currents, counts = common.get_min_current(cp)
if max(missing_currents) > 0:
- available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter)
+ available_currents, limit = Loadmanagement().get_available_currents(
+ missing_currents, counter, cp)
cp.data.control_parameter.limit = limit
available_for_cp = common.available_current_for_cp(
cp, counts, available_currents, missing_currents)
@@ -34,8 +34,7 @@ def set_min_current(self) -> None:
common.set_current_counterdiff(-(cp.data.set.current or 0), 0, cp)
if limit:
cp.set_state_and_log(
- "Ladung kann nicht gestartet werden"
- f"{limit.value.format(get_component_name_by_id(counter.num))}")
+ f"Ladung kann nicht gestartet werden{limit}")
else:
common.set_current_counterdiff(
cp.data.set.target_current,
diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py
index 51e0aaa1c7..aceb02d89f 100644
--- a/packages/control/algorithm/surplus_controlled.py
+++ b/packages/control/algorithm/surplus_controlled.py
@@ -10,7 +10,6 @@
from control.chargepoint.charging_type import ChargingType
from control.chargepoint.chargepoint import Chargepoint
from control.chargepoint.chargepoint_state import ChargepointState, CHARGING_STATES
-from modules.common.utils.component_parser import get_component_name_by_id
from control.counter import ControlRangeState, Counter
from control.loadmanagement import LimitingValue, Loadmanagement
@@ -53,7 +52,8 @@ def _set(self,
missing_currents, counts = common.get_missing_currents_left(chargepoints)
available_currents, limit = Loadmanagement().get_available_currents_surplus(missing_currents,
counter,
- feed_in_yield)
+ 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:
@@ -71,7 +71,7 @@ def _set(self,
current = available_for_cp
current = common.get_current_to_set(cp.data.set.current, current, cp.data.set.target_current)
- self._set_loadmangement_message(current, limit, cp, counter)
+ self._set_loadmangement_message(current, limit, cp)
limited_current = self._limit_adjust_current(cp, current)
common.set_current_counterdiff(
cp.data.control_parameter.min_current,
@@ -83,8 +83,7 @@ def _set(self,
def _set_loadmangement_message(self,
current: float,
limit: LimitingValue,
- chargepoint: Chargepoint,
- counter: Counter) -> None:
+ chargepoint: Chargepoint) -> None:
# Strom muss an diesem Zähler geändert werden
if (current != chargepoint.data.set.current and
# Strom erreicht nicht die vorgegebene Stromstärke
@@ -92,7 +91,7 @@ def _set_loadmangement_message(self,
# im PV-Laden wird der Strom immer durch die Leistung begrenzt
limit != LimitingValue.POWER):
chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden"
- f"{limit.value.format(get_component_name_by_id(counter.num))}")
+ f"{limit}")
# tested
def filter_by_feed_in_limit(self, chargepoints: List[Chargepoint]) -> Tuple[List[Chargepoint], List[Chargepoint]]:
diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py
index cf01d0cb67..4f3388c863 100644
--- a/packages/control/chargepoint/chargepoint.py
+++ b/packages/control/chargepoint/chargepoint.py
@@ -106,22 +106,6 @@ def _is_grid_protection_inactive(self) -> Tuple[bool, Optional[str]]:
message = "Ladepunkt gesperrt, da der Netzschutz aktiv ist."
return state, message
- def _is_ripple_control_receiver_inactive(self) -> Tuple[bool, Optional[str]]:
- """ prüft, dass der Rundsteuerempfängerkontakt nicht geschlossen ist.
- """
- state = True
- message = None
- general_data = data.data.general_data.data
- if general_data.ripple_control_receiver.module:
- if general_data.ripple_control_receiver.get.override_value == 0:
- state = False
- message = "Ladepunkt gesperrt, da der Rundsteuerempfängerkontakt geschlossen ist."
- elif general_data.ripple_control_receiver.get.fault_state == 2:
- state = False
- message = ("Ladepunkt gesperrt, da der Rundsteuerempfänger ein Problem meldet. "
- "Bitte im Status des RSE nachsehen.")
- return state, message
-
def _is_loadmanagement_available(self) -> Tuple[bool, Optional[str]]:
""" prüft, ob Lastmanagement verfügbar ist. Wenn keine Werte vom EVU-Zähler empfangen werden, darf nicht geladen
werden.
@@ -191,15 +175,13 @@ def is_charging_possible(self) -> Tuple[bool, Optional[str]]:
try:
charging_possible, message = self._is_grid_protection_inactive()
if charging_possible:
- charging_possible, message = self._is_ripple_control_receiver_inactive()
+ charging_possible, message = self._is_loadmanagement_available()
if charging_possible:
- charging_possible, message = self._is_loadmanagement_available()
+ charging_possible, message = self._is_manual_lock_inactive()
if charging_possible:
- charging_possible, message = self._is_manual_lock_inactive()
+ charging_possible, message = self._is_ev_plugged()
if charging_possible:
- charging_possible, message = self._is_ev_plugged()
- if charging_possible:
- charging_possible, message = self._is_autolock_inactive()
+ charging_possible, message = self._is_autolock_inactive()
except Exception:
log.exception("Fehler in der Ladepunkt-Klasse von "+str(self.num))
return False, "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc()
diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py
index 9d2f9bb02c..cef98805cc 100644
--- a/packages/control/chargepoint/chargepoint_data.py
+++ b/packages/control/chargepoint/chargepoint_data.py
@@ -107,6 +107,7 @@ class Get:
phases_in_use: int = 0
plug_state: bool = False
power: float = 0
+ powers: List[float] = field(default_factory=currents_list_factory)
rfid_timestamp: Optional[float] = None
rfid: Optional[int] = None
serial_number: Optional[str] = None
diff --git a/packages/control/data.py b/packages/control/data.py
index 68bf8b7da4..9f66f57b83 100644
--- a/packages/control/data.py
+++ b/packages/control/data.py
@@ -24,8 +24,10 @@
from control.ev.ev import Ev
from control.ev.ev_template import EvTemplate
from control.general import General
+from control.io_device import IoActions, IoStates
from control.optional import Optional
from modules.common.abstract_device import AbstractDevice
+from modules.common.abstract_io import AbstractIoDevice
log = logging.getLogger(__name__)
bat_data_lock = threading.Lock()
@@ -40,6 +42,8 @@
ev_data_lock = threading.Lock()
ev_template_data_lock = threading.Lock()
general_data_lock = threading.Lock()
+io_actions_lock = threading.Lock()
+io_states_lock = threading.Lock()
optional_data_lock = threading.Lock()
pv_data_lock = threading.Lock()
pv_all_data_lock = threading.Lock()
@@ -75,6 +79,8 @@ def __init__(self, event_module_update_completed: threading.Event):
self._ev_template_data: Dict[str, EvTemplate] = {}
self._general_data = General()
self._graph_data = Graph()
+ self._io_actions: IoActions = {}
+ self._io_states: Dict[str, IoStates] = {}
self._optional_data = Optional()
self._pv_data: Dict[str, Pv] = {}
self._pv_all_data = PvAll()
@@ -197,6 +203,24 @@ def general_data(self) -> General:
def general_data(self, value):
self._general_data = value
+ @property
+ def io_actions(self) -> IoActions:
+ return self._io_actions
+
+ @io_actions.setter
+ @locked(io_actions_lock)
+ def io_actions(self, value):
+ self._io_actions = value
+
+ @property
+ def io_states(self) -> Dict[str, IoStates]:
+ return self._io_states
+
+ @io_states.setter
+ @locked(io_states_lock)
+ def io_states(self, value):
+ self._io_states = value
+
@property
def optional_data(self) -> Optional:
return self._optional_data
@@ -247,11 +271,14 @@ def print_all(self):
log.info(f"general_data\n{self._general_data.data}")
log.info(f"general_data-display\n{self._general_data.data.extern_display_mode}")
log.info(f"graph_data\n{self._graph_data.data}")
+ self._print_io_actions(self._io_actions)
+ self._print_dictionaries(self._io_states)
log.info(f"optional_data\n{self._optional_data.data}")
self._print_dictionaries(self._pv_data)
log.info(f"pv_all_data\n{self._pv_all_data.data}")
self._print_dictionaries(self._system_data)
self._print_device_config(self._system_data)
+ self._print_io_device_config(self._system_data)
log.info("\n")
def _print_dictionaries(self, data):
@@ -284,6 +311,21 @@ def _print_device_config(self, data: Dict[str, AbstractDevice]):
except Exception:
log.exception("Fehler im Data-Modul")
+ def _print_io_device_config(self, data: Dict[str, AbstractIoDevice]):
+ for key, value in data.items():
+ try:
+ if isinstance(value, AbstractIoDevice):
+ log.info(f"{key}\n{dataclass_utils.asdict(value.config)}")
+ except Exception:
+ log.exception("Fehler im Data-Modul")
+
+ def _print_io_actions(self, data: IoActions):
+ for key, value in data.actions.items():
+ try:
+ log.info(f"{key}\n{dataclass_utils.asdict(value.config)}")
+ except Exception:
+ log.exception("Fehler im Data-Modul")
+
def copy_system_data(self) -> None:
with ModuleDataReceivedContext(self.event_module_update_completed):
self.__copy_system_data()
@@ -298,7 +340,8 @@ def __copy_system_data(self) -> None:
# werden, sodass die Nutzung einer Referenz vorerst funktioniert.
self.system_data = {
"system": copy.deepcopy(SubData.system_data["system"])} | {
- k: SubData.system_data[k] for k in SubData.system_data if "device" in k}
+ k: SubData.system_data[k] for k in SubData.system_data if "device" in k} | {
+ k: SubData.system_data[k] for k in SubData.system_data if "io" in k}
self.general_data = copy.deepcopy(SubData.general_data)
self.__copy_cp_data()
except Exception:
@@ -393,6 +436,8 @@ def copy_data(self) -> None:
with ModuleDataReceivedContext(self.event_module_update_completed):
try:
self.general_data = copy.deepcopy(SubData.general_data)
+ self.io_actions = copy.deepcopy(SubData.io_actions)
+ self.io_states = copy.deepcopy(SubData.io_states)
self.optional_data = copy.deepcopy(SubData.optional_data)
self.__copy_ev_data()
self.__copy_cp_data()
diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py
index 2f31fd1425..5f22826304 100644
--- a/packages/control/ev/ev.py
+++ b/packages/control/ev/ev.py
@@ -8,6 +8,7 @@
"""
from dataclasses import dataclass, field
import logging
+import re
from typing import List, Optional, Tuple
from control import data
@@ -308,8 +309,11 @@ def _check_phase_switch_conditions(self,
feed_in_yield = 0
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
+ unblanced_load_limit_reached = (
+ limit is not None and
+ re.search(re.escape(LimitingValue.UNBALANCED_LOAD.value).replace(r'\{\}', r'.+'), limit))
condition_1_to_3 = (((max(get_currents) > max_current and
- all_surplus > required_surplus) or limit == LimitingValue.UNBALANCED_LOAD.value) and
+ all_surplus > required_surplus) or unblanced_load_limit_reached) and
phases_in_use == 1)
condition_3_to_1 = max(get_currents) < min_current and all_surplus <= 0 and phases_in_use > 1
if condition_1_to_3 or condition_3_to_1:
diff --git a/packages/control/general.py b/packages/control/general.py
index 7950eada3d..8207891937 100644
--- a/packages/control/general.py
+++ b/packages/control/general.py
@@ -1,19 +1,14 @@
"""Allgemeine Einstellungen
"""
from dataclasses import dataclass, field
-from enum import Enum
import logging
import random
-from typing import Dict, List, Optional
+from typing import List, Optional
from control import data
from control.bat_all import BatConsiderationMode
from control.chargemode import Chargemode
-from helpermodules.constants import NO_ERROR
from helpermodules import timecheck
-from modules.common.configurable_ripple_control_receiver import ConfigurableRcr
-from modules.ripple_control_receivers.gpio.config import GpioRcr
-from modules.ripple_control_receivers.gpio.ripple_control_receiver import create_ripple_control_receiver
log = logging.getLogger(__name__)
@@ -111,42 +106,6 @@ def chargemode_config_factory() -> ChargemodeConfig:
return ChargemodeConfig()
-@dataclass
-class RippleControlReceiverGet:
- fault_state: int = field(default=0, metadata={
- "topic": "ripple_control_receiver/get/fault_state"})
- fault_str: str = field(default=NO_ERROR, metadata={
- "topic": "ripple_control_receiver/get/fault_str"})
- override_value: float = field(default=100, metadata={
- "topic": "ripple_control_receiver/get/override_value"})
-
-
-def rcr_get_factory() -> RippleControlReceiverGet:
- return RippleControlReceiverGet()
-
-
-def gpio_rcr_factory() -> ConfigurableRcr:
- return create_ripple_control_receiver(GpioRcr())
-
-
-class OverrideReference(Enum):
- EVU = "evu"
- CHARGEPOINT = "chargepoint"
-
-
-@dataclass
-class RippleControlReceiver:
- get: RippleControlReceiverGet = field(default_factory=rcr_get_factory)
- module: Optional[Dict] = field(default=None, metadata={
- "topic": "ripple_control_receiver/module"})
- override_reference: OverrideReference = field(default=OverrideReference.CHARGEPOINT, metadata={
- "topic": "ripple_control_receiver/override_reference"})
-
-
-def ripple_control_receiver_factory() -> RippleControlReceiver:
- return RippleControlReceiver()
-
-
@dataclass
class Prices:
bat: float = field(default=0.0002, metadata={"topic": "prices/bat"})
@@ -181,7 +140,6 @@ class GeneralData:
mqtt_bridge: bool = False
prices: Prices = field(default_factory=prices_factory)
range_unit: str = "km"
- ripple_control_receiver: RippleControlReceiver = field(default_factory=ripple_control_receiver_factory)
class General:
@@ -190,7 +148,6 @@ class General:
def __init__(self):
self.data: GeneralData = GeneralData()
- self.ripple_control_receiver: ConfigurableRcr = None
def get_phases_chargemode(self, chargemode: str, submode: str) -> Optional[int]:
""" gibt die Anzahl Phasen zurück, mit denen im jeweiligen Lademodus geladen wird.
diff --git a/packages/control/io_device.py b/packages/control/io_device.py
new file mode 100644
index 0000000000..e64c5aba8e
--- /dev/null
+++ b/packages/control/io_device.py
@@ -0,0 +1,95 @@
+from dataclasses import dataclass, field
+from typing import Dict, Optional, Union
+from control import data
+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_direct_control.api import DimmingDirectControl
+from modules.io_actions.controllable_consumers.ripple_control_receiver.api import RippleControlReceiver
+
+
+@dataclass
+class Get:
+ analog_input: Dict[int, float] = None
+ analog_output: Dict[int, float] = None
+ digital_input: Dict[int, bool] = None
+ digital_output: Dict[int, bool] = None
+ fault_str: str = NO_ERROR
+ fault_state: int = 0
+
+
+def get_factory():
+ return Get()
+
+
+@dataclass
+class Set:
+ analog_output: Dict[int, float] = None
+ digital_output: Dict[int, bool] = None
+
+
+def set_factory():
+ return Set()
+
+
+@dataclass
+class IoDeviceData:
+ get: Get = field(default_factory=get_factory)
+ set: Set = field(default_factory=set_factory)
+
+
+class IoStates:
+ def __init__(self, num: Union[int, str]):
+ self.num = num
+ self.data = IoDeviceData()
+
+
+class IoActions:
+ def __init__(self):
+ self.actions: Dict[int, Union[Dimming, DimmingDirectControl, RippleControlReceiver]] = {}
+
+ def setup(self):
+ for action in self.actions.values():
+ action.setup()
+
+ def _check_fault_state_io_device(self, io_device: int) -> None:
+ if data.data.io_states[f"io_states{io_device}"].data.get.fault_state == 2:
+ raise ValueError(LimitingValue.CONTROLLABLE_CONSUMERS_ERROR.value.format(get_io_name_by_id(io_device)))
+
+ def dimming_get_import_power_left(self, device: Dict) -> Optional[float]:
+ for action in self.actions.values():
+ if isinstance(action, Dimming):
+ for d in action.config.configuration.devices:
+ if device == d:
+ self._check_fault_state_io_device(action.config.configuration.io_device)
+ return action.dimming_get_import_power_left()
+ else:
+ return None
+
+ def dimming_set_import_power_left(self, device: Dict, used_power: float) -> Optional[float]:
+ for action in self.actions.values():
+ if isinstance(action, Dimming):
+ for d in action.config.configuration.devices:
+ if d == device:
+ return action.dimming_set_import_power_left(used_power)
+
+ def dimming_via_direct_control(self, device: Dict) -> Optional[float]:
+ for action in self.actions.values():
+ if isinstance(action, DimmingDirectControl):
+ for d in action.config.configuration.devices:
+ if device == d:
+ self._check_fault_state_io_device(action.config.configuration.io_device)
+ return action.dimming_via_direct_control()
+ else:
+ return None
+
+ def ripple_control_receiver(self, device: Dict) -> float:
+ for action in self.actions.values():
+ if isinstance(action, RippleControlReceiver):
+ for d in action.config.configuration.devices:
+ if device == d:
+ self._check_fault_state_io_device(action.config.configuration.io_device)
+ return action.ripple_control_receiver()
+ else:
+ return 1
diff --git a/packages/control/limiting_value.py b/packages/control/limiting_value.py
index 6d114caee1..6a12065e71 100644
--- a/packages/control/limiting_value.py
+++ b/packages/control/limiting_value.py
@@ -5,3 +5,9 @@ class LimitingValue(Enum):
CURRENT = ", da der Maximal-Strom an Zähler {} erreicht ist."
POWER = ", da die maximale Leistung an Zähler {} erreicht ist."
UNBALANCED_LOAD = ", da die maximale Schieflast an Zähler {} erreicht ist."
+ DIMMING = ", da die Dimmung die Ladeleistung bgrenzt."
+ DIMMING_VIA_DIRECT_CONTROL = ", da die Dimmung per Direkt-Steuerung die Ladeleistung auf 4,2 kW begrenzt."
+ RIPPLE_CONTROL_RECEIVER = (", da der Ladepunkt durch den RSE-Kontakt auf {}% der konfigurierten Anschlussleistung "
+ "reduziert wird.")
+ CONTROLLABLE_CONSUMERS_ERROR = (", da aufgrund eines Fehlers im IO-Gerät {} die steuerbaren Verbraucher nicht "
+ "gesteuert werden können. Bitte prüfe die Status-Seite.")
diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py
index 649a81061b..ae2108d2f8 100644
--- a/packages/control/loadmanagement.py
+++ b/packages/control/loadmanagement.py
@@ -3,8 +3,10 @@
from typing import List, Optional, Tuple
from control import data
+from control.chargepoint.chargepoint import Chargepoint
from control.counter import Counter
from control.limiting_value import LimitingValue
+from modules.common.utils.component_parser import get_component_name_by_id
log = logging.getLogger(__name__)
@@ -14,44 +16,65 @@ class Loadmanagement:
def get_available_currents(self,
missing_currents: List[float],
counter: Counter,
- feed_in: int = 0) -> Tuple[List[float], Optional[LimitingValue]]:
+ cp: Chargepoint,
+ feed_in: int = 0) -> Tuple[List[float], Optional[str]]:
raw_currents_left = counter.data.set.raw_currents_left
- available_currents, limit = self._limit_by_current(missing_currents, raw_currents_left)
- available_currents, limit_power = self._limit_by_power(
- available_currents, counter.data.set.raw_power_left, feed_in)
- if limit_power is not None:
- limit = limit_power
+ try:
+ available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp)
+
+ available_currents, new_limit = self._limit_by_dimming(available_currents, cp)
+ limit = new_limit if new_limit is not None else limit
+
+ available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, cp)
+ limit = new_limit if new_limit is not None else limit
+ except ValueError as e:
+ available_currents = [0]*3
+ limit = e.args[0]
+
+ available_currents, new_limit = self._limit_by_current(counter, available_currents, raw_currents_left)
+ limit = new_limit if new_limit is not None else limit
+
+ available_currents, new_limit = self._limit_by_power(
+ counter, available_currents, counter.data.set.raw_power_left, feed_in)
+ limit = new_limit if new_limit is not None else limit
+
if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str():
- available_currents, limit_unbalanced_load = self._limit_by_unbalanced_load(
+ available_currents, new_limit = self._limit_by_unbalanced_load(
counter, available_currents, raw_currents_left,
len([value for value in missing_currents if value != 0]))
- if limit_unbalanced_load is not None:
- limit = limit_unbalanced_load
+ limit = new_limit if new_limit is not None else limit
return available_currents, limit
def get_available_currents_surplus(self,
missing_currents: List[float],
counter: Counter,
- feed_in: int = 0) -> Tuple[List[float], Optional[LimitingValue]]:
+ cp: Chargepoint,
+ feed_in: int = 0) -> Tuple[List[float], Optional[str]]:
raw_currents_left = counter.data.set.raw_currents_left
- available_currents, limit = self._limit_by_current(missing_currents, raw_currents_left)
- available_currents, limit_power = self._limit_by_power(
- available_currents, counter.data.set.surplus_power_left, feed_in)
- if limit_power is not None:
- limit = limit_power
+ available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp)
+
+ available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, cp)
+ limit = new_limit if new_limit is not None else limit
+
+ available_currents, new_limit = self._limit_by_current(counter, available_currents, raw_currents_left)
+ limit = new_limit if new_limit is not None else limit
+
+ available_currents, new_limit = self._limit_by_power(
+ counter, available_currents, counter.data.set.surplus_power_left, feed_in)
+ limit = new_limit if new_limit is not None else limit
+
if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str():
- available_currents, limit_unbalanced_load = self._limit_by_unbalanced_load(
+ available_currents, new_limit = self._limit_by_unbalanced_load(
counter, available_currents, raw_currents_left,
len([value for value in missing_currents if value != 0]))
- if limit_unbalanced_load is not None:
- limit = limit_unbalanced_load
+ limit = new_limit if new_limit is not None else limit
return available_currents, limit
def _limit_by_unbalanced_load(self,
counter: Counter,
available_currents: List[float],
raw_currents_left: List[float],
- phases_to_use: int) -> Tuple[List[float], Optional[LimitingValue]]:
+ phases_to_use: int) -> Tuple[List[float], Optional[str]]:
raw_currents_left_charging = list(map(operator.sub, raw_currents_left, available_currents))
max_exceeding = counter.get_unbalanced_load_exceeding(raw_currents_left_charging)
limit = None
@@ -59,16 +82,17 @@ def _limit_by_unbalanced_load(self,
if phases_to_use < 3 and phases_to_use > 0:
available_currents = list(map(operator.sub, available_currents, max_exceeding))
log.debug(f"Schieflast {max_exceeding}A korrigieren: {available_currents}")
- limit = LimitingValue.UNBALANCED_LOAD
+ limit = LimitingValue.UNBALANCED_LOAD.value.format(get_component_name_by_id(counter.num))
elif phases_to_use == 3:
log.debug("Schieflastkorrektur nicht möglich, da alle Phasen genutzt werden.")
return available_currents, limit
# tested
def _limit_by_power(self,
+ counter: Counter,
available_currents: List[float],
raw_power_left: Optional[float],
- feed_in: Optional[float]) -> Tuple[List[float], Optional[LimitingValue]]:
+ feed_in: Optional[float]) -> Tuple[List[float], Optional[str]]:
currents = available_currents.copy()
limit = None
if raw_power_left:
@@ -80,18 +104,64 @@ def _limit_by_power(self,
# Am meisten belastete Phase trägt am meisten zur Leistungsreduktion bei.
currents[i] = available_currents[i] / sum(available_currents) * raw_power_left / 230
log.debug(f"Leistungsüberschreitung auf {raw_power_left}W korrigieren: {available_currents}")
- limit = LimitingValue.POWER
+ limit = LimitingValue.POWER.value.format(get_component_name_by_id(counter.num))
return currents, limit
# tested
def _limit_by_current(self,
+ counter: Counter,
missing_currents: List[float],
- raw_currents_left: List[float]) -> Tuple[List[float], Optional[LimitingValue]]:
+ raw_currents_left: List[float]) -> Tuple[List[float], Optional[str]]:
available_currents = [0.0]*3
limit = None
for i in range(0, 3):
available_currents[i] = min(missing_currents[i], raw_currents_left[i])
if available_currents != missing_currents:
log.debug(f"Stromüberschreitung {missing_currents}W korrigieren: {available_currents}")
- limit = LimitingValue.CURRENT
+ limit = LimitingValue.CURRENT.value.format(get_component_name_by_id(counter.num))
return available_currents, limit
+
+ def _limit_by_dimming_via_direct_control(self,
+ missing_currents: List[float],
+ cp: Chargepoint) -> Tuple[List[float], Optional[str]]:
+ if data.data.io_actions.dimming_via_direct_control({"type": "cp", "id": cp.num}):
+ phases = 3-missing_currents.count(0)
+ current_per_phase = 4200 / 230 / phases
+ available_currents = [current_per_phase -
+ cp.data.set.target_current if c > 0 else 0 for c in missing_currents]
+ log.debug(f"Dimmung per Direkt-Steuerung: {available_currents}A")
+ return available_currents, LimitingValue.DIMMING_VIA_DIRECT_CONTROL.value
+ else:
+ return missing_currents, None
+
+ def _limit_by_dimming(self,
+ available_currents: List[float],
+ cp: Chargepoint) -> Tuple[List[float], Optional[str]]:
+ dimming_power_left = data.data.io_actions.dimming_get_import_power_left({"type": "cp", "id": cp.num})
+ if dimming_power_left:
+ if sum(available_currents)*230 > dimming_power_left:
+ phases = 3-available_currents.count(0)
+ overload_per_phase = (sum(available_currents) - dimming_power_left/230)/phases
+ available_currents = [c - overload_per_phase if c > 0 else 0 for c in available_currents]
+ log.debug(f"Reduzierung der Ströme durch die Dimmung: {available_currents}A")
+ return available_currents, LimitingValue.DIMMING.value
+ return available_currents, None
+
+ def _limit_by_ripple_control_receiver(self,
+ available_currents: List[float],
+ cp: Chargepoint) -> Tuple[List[float], Optional[str]]:
+ value = data.data.io_actions.ripple_control_receiver({"type": "cp", "id": cp.num})
+ if value != 1:
+ phases = 3-available_currents.count(0)
+ if phases > 1:
+ max_current = cp.template.data.max_current_single_phase
+ else:
+ max_current = cp.template.data.max_current_multi_phases
+ # target_current ist das Ergebnis der letzten Iteration. Die Differenz der begrenzten Anschlussleistung und
+ # der Sollstrom der letzten Iteration dürfen daher nicht größer sein als der aktuell fehlende Strom.
+ available_currents = [min(max_current*value - cp.data.set.target_current, c)
+ if c > 0 else 0 for c in available_currents]
+ log.debug(f"Reduzierung durch RSE-Kontakt auf {value*100}%, maximal {max_current*value}A")
+ return available_currents, LimitingValue.RIPPLE_CONTROL_RECEIVER.value.format(value*100)
+ else:
+ return available_currents, None
diff --git a/packages/control/loadmanagement_test.py b/packages/control/loadmanagement_test.py
index 0d143dcda5..7bc16e0495 100644
--- a/packages/control/loadmanagement_test.py
+++ b/packages/control/loadmanagement_test.py
@@ -1,22 +1,33 @@
from typing import List
+from unittest.mock import Mock
import pytest
+from control import loadmanagement
+from control.counter import Counter
from control.loadmanagement import LimitingValue, Loadmanagement
+COUNTER_NAME = "test counter"
+
@pytest.mark.parametrize(
"available_currents, raw_power_left, expected_currents",
[
pytest.param([5, 10, 15], 6900, ([5, 10, 15], None)),
pytest.param([5, 10, 25], 1000, ([0.5434782608695652, 1.0869565217391304,
- 2.717391304347826], LimitingValue.POWER)),
+ 2.717391304347826], LimitingValue.POWER.value.format(COUNTER_NAME))),
pytest.param([5, 10, 25], 5000, ([2.717391304347826, 5.434782608695652,
- 13.58695652173913], LimitingValue.POWER)),
+ 13.58695652173913], LimitingValue.POWER.value.format(COUNTER_NAME))),
])
-def test_limit_by_power(available_currents: List[float], raw_power_left: float, expected_currents: List[float]):
- # setup & evaluation
- currents = Loadmanagement()._limit_by_power(available_currents, raw_power_left, None)
+def test_limit_by_power(available_currents: List[float],
+ raw_power_left: float,
+ expected_currents: List[float],
+ monkeypatch):
+ # setup
+ 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, raw_power_left, None)
# assertion
assert currents == expected_currents
@@ -26,12 +37,15 @@ def test_limit_by_power(available_currents: List[float], raw_power_left: float,
"missing_currents, raw_currents_left, expected_currents",
[
pytest.param([5, 10, 15], [20]*3, ([5, 10, 15], None)),
- pytest.param([5, 10, 15], [5, 8, 5], ([5, 8, 5], LimitingValue.CURRENT)),
+ pytest.param([5, 10, 15], [5, 8, 5], ([5, 8, 5], LimitingValue.CURRENT.value.format(COUNTER_NAME))),
])
def test_limit_by_current(
- missing_currents: List[float], raw_currents_left: List[float], expected_currents: List[float]):
- # setup & evaluation
- currents = Loadmanagement()._limit_by_current(missing_currents, raw_currents_left)
+ missing_currents: List[float], raw_currents_left: List[float], expected_currents: List[float], monkeypatch):
+ # setup
+ counter_name_mock = Mock(return_value=COUNTER_NAME)
+ monkeypatch.setattr(loadmanagement, "get_component_name_by_id", counter_name_mock)
+ # evaluation
+ currents = Loadmanagement()._limit_by_current(Counter(0), missing_currents, raw_currents_left)
# assertion
assert currents == expected_currents
diff --git a/packages/control/prepare.py b/packages/control/prepare.py
index 51a1466693..ddb09b471c 100644
--- a/packages/control/prepare.py
+++ b/packages/control/prepare.py
@@ -34,6 +34,7 @@ def setup_algorithm(self) -> None:
data.data.cp_all_data.get_cp_sum()
data.data.cp_all_data.no_charge()
data.data.counter_all_data.set_home_consumption()
+ data.data.io_actions.setup()
except Exception:
log.exception("Fehler im Prepare-Modul")
data.data.print_all()
diff --git a/packages/control/process.py b/packages/control/process.py
index cce967d51e..955aad06d5 100644
--- a/packages/control/process.py
+++ b/packages/control/process.py
@@ -11,7 +11,10 @@
from control.chargepoint.chargepoint_state import ChargepointState
from helpermodules.pub import Pub
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_direct_control.api import DimmingDirectControl
log = logging.getLogger(__name__)
@@ -67,7 +70,26 @@ def process_algorithm_results(self) -> None:
target=bat_component.set_power_limit,
args=(data.data.bat_data[f"bat{bat_component.component_config.id}"].data.set.power_limit,),
name=f"set power limit {bat_component.component_config.id}"))
-
+ for action in data.data.io_actions.actions.values():
+ if isinstance(action, DimmingDirectControl):
+ 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"]] = (
+ action.dimming_via_direct_control() is not None
+ )
+ if isinstance(action, Dimming):
+ 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"]] = (
+ action.dimming_active()
+ )
+ for io in data.data.system_data.values():
+ if isinstance(io, AbstractIoDevice):
+ modules_threads.append(
+ threading.Thread(
+ target=io.write,
+ args=(None, 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)
except Exception:
diff --git a/packages/dataclass_utils/_dataclass_from_dict.py b/packages/dataclass_utils/_dataclass_from_dict.py
index e26a91db23..46b7598d72 100644
--- a/packages/dataclass_utils/_dataclass_from_dict.py
+++ b/packages/dataclass_utils/_dataclass_from_dict.py
@@ -1,7 +1,7 @@
import inspect
-from inspect import FullArgSpec
+from inspect import FullArgSpec, isclass
import typing
-from typing import TypeVar, Type, Union
+from typing import TypeVar, Type, Union, get_origin
T = TypeVar('T')
@@ -15,7 +15,14 @@ def dataclass_from_dict(cls: Type[T], args: Union[dict, T]) -> T:
In case the supplied `args` is already of the desired type, `args` is returned unchanged
"""
- if isinstance(args, cls):
+ if isclass(cls):
+ if isinstance(args, cls):
+ return args
+ elif get_origin(cls):
+ # Generische Typen wie Dict[int, float]
+ if isinstance(args, get_origin(cls)):
+ return args
+ elif isinstance(args, type(cls)):
return args
arg_spec = inspect.getfullargspec(cls.__init__)
return cls(*[_get_argument_value(arg_spec, index, args) for index in range(1, len(arg_spec.args))])
@@ -41,7 +48,7 @@ def _dataclass_from_dict_recurse(value, requested_type: Type[T]):
return dataclass_from_dict(requested_type, value) \
if isinstance(value, dict) and not (
_is_optional_of_dict(requested_type) or
- issubclass(requested_type, dict)) \
+ issubclass(requested_type if isclass(requested_type) else type(bool), dict)) \
else value
diff --git a/packages/dataclass_utils/_dataclass_from_dict_test.py b/packages/dataclass_utils/_dataclass_from_dict_test.py
index 63f6ac5992..21eb10eb2c 100644
--- a/packages/dataclass_utils/_dataclass_from_dict_test.py
+++ b/packages/dataclass_utils/_dataclass_from_dict_test.py
@@ -1,4 +1,4 @@
-from typing import Generic, Optional, Type, TypeVar
+from typing import Dict, Generic, Optional, Type, TypeVar
import pytest
@@ -35,6 +35,12 @@ def __init__(self, a: str, o: Optional[dict] = None):
self.o = o
+class GnericDict:
+ def __init__(self, a: str, o: Dict[int, float] = None):
+ self.a = a
+ self.o = o
+
+
def test_from_dict_simple():
# execution
actual = dataclass_from_dict(SimpleSample, {"b": "bValue", "a": "aValue"})
@@ -82,6 +88,15 @@ def test_from_dict_extends_generic():
assert actual.a == "aValue"
+def test_generic_dict():
+ # execution
+ actual = dataclass_from_dict(GnericDict, {"a": "aValue", "o": {1: 1.0}})
+
+ # evaluation
+ assert actual.a == "aValue"
+ assert actual.o == {1: 1.0}
+
+
@pytest.mark.parametrize(["type", "invalid_parameter"], [
pytest.param(SimpleSample, "a", id="class with some default values"),
pytest.param(NestedSample, "normal", id="class with no default values"),
@@ -91,7 +106,7 @@ def test_from_dict_fails_on_invalid_properties(type: Type[T], invalid_parameter:
with pytest.raises(Exception) as e:
dataclass_from_dict(type, {"invalid": "dict"})
assert str(e.value) == "Cannot determine value for parameter " + invalid_parameter + \
- ": not given in {'invalid': 'dict'} and no default value specified"
+ ": not given in {'invalid': 'dict'} and no default value specified"
def test_from_dict_wit_optional():
diff --git a/packages/dataclass_utils/factories.py b/packages/dataclass_utils/factories.py
index 638cf9bace..8b58340c6f 100644
--- a/packages/dataclass_utils/factories.py
+++ b/packages/dataclass_utils/factories.py
@@ -15,3 +15,16 @@ def currents_list_factory() -> List[float]:
def voltages_list_factory() -> List[float]:
return [230.0]*3
+
+
+def empty_io_pattern_factory():
+ return [
+ {
+ "value": True, # dimmen
+ "input_matrix": {}
+ },
+ {
+ "value": False, # unbeschränkt
+ "input_matrix": {}
+ }
+ ]
diff --git a/packages/helpermodules/broker.py b/packages/helpermodules/broker.py
index ab7feb3149..ef1bf9fc59 100644
--- a/packages/helpermodules/broker.py
+++ b/packages/helpermodules/broker.py
@@ -16,14 +16,19 @@ def get_name_suffix() -> str:
return f"{serial}-{datetime.datetime.today().timestamp()}"
-class InternalBrokerClient:
- def __init__(self, name: str, on_connect: Callable, on_message: Callable) -> None:
+class BrokerClient:
+ def __init__(self,
+ name: str,
+ on_connect: Callable,
+ on_message: Callable,
+ host: str = "localhost",
+ port: int = 1886) -> None:
try:
self.name = f"openWB-{name}-{get_name_suffix()}"
self.client = mqtt.Client(self.name)
self.client.on_connect = on_connect
self.client.on_message = on_message
- self.client.connect("localhost", 1886)
+ self.client.connect(host, port)
except Exception:
log.exception("Fehler beim Abonnieren des internen Brokers")
diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py
index edba8e6098..7a1ea3c7fd 100644
--- a/packages/helpermodules/command.py
+++ b/packages/helpermodules/command.py
@@ -23,7 +23,7 @@
from helpermodules.utils.run_command import run_command
from modules.backup_clouds.onedrive.api import generateMSALAuthCode, retrieveMSALTokens
-from helpermodules.broker import InternalBrokerClient
+from helpermodules.broker import BrokerClient
from helpermodules.data_migration.data_migration import MigrateData
from helpermodules.measurement_logging.process_log import get_daily_log, get_monthly_log, get_yearly_log
from helpermodules.messaging import MessageType, pub_user_message
@@ -57,7 +57,10 @@ class Command:
("chargepoint_template", "chargepoint/template", 0),
("device", "system/device", -1),
("ev_template", "vehicle/template/ev_template", 0),
- ("vehicle", "vehicle", 0)]
+ ("vehicle", "vehicle", 0),
+ ("io_action", "io/action", -1),
+ ("io_device", "system/io", -1),
+ ]
def __init__(self, event_command_completed: threading.Event):
try:
@@ -105,7 +108,7 @@ def sub_commands(self):
# kurze Pause, damit die ID vom Broker ermittelt werden können. Sonst werden noch vorher die retained
# Topics empfangen, was zu doppelten Meldungen im Protokoll führt.
time.sleep(1)
- self.internal_broker_client = InternalBrokerClient("command", self.on_connect, self.on_message)
+ self.internal_broker_client = BrokerClient("command", self.on_connect, self.on_message)
self.internal_broker_client.start_infinite_loop()
except Exception:
log.exception("Fehler im Command-Modul")
@@ -170,6 +173,63 @@ def removeDevice(self, connection_id: str, payload: dict) -> None:
f'Die ID \'{payload["data"]["id"]}\' ist größer als die maximal vergebene ID \'{self.max_id_device}\'.',
MessageType.ERROR)
+ def addIoAction(self, connection_id: str, payload: dict) -> None:
+ new_id = self.max_id_io_action + 1
+ dev = importlib.import_module(f".io_actions.{'.'.join(payload['data']['type'])}.api",
+ "modules")
+ descriptor = dev.device_descriptor.configuration_factory()
+ device_default = dataclass_utils.asdict(descriptor)
+ device_default["id"] = new_id
+ Pub().pub(f'openWB/set/io/action/{new_id}/config', device_default)
+ self.max_id_io_action = new_id
+ Pub().pub("openWB/set/command/max_id/io_action", self.max_id_io_action)
+ pub_user_message(
+ payload, connection_id,
+ f'Neue IO-Aktion vom Typ \'{" / ".join(payload["data"]["type"])}\' mit ID \'{new_id}\' hinzugefügt.',
+ MessageType.SUCCESS)
+
+ def removeIoAction(self, connection_id: str, payload: dict) -> None:
+ if self.max_id_io_action >= payload["data"]["id"]:
+ ProcessBrokerBranch(f'io/action/{payload["data"]["id"]}/').remove_topics()
+ pub_user_message(payload, connection_id, f'IO-Aktion mit ID \'{payload["data"]["id"]}\' gelöscht.',
+ MessageType.SUCCESS)
+ else:
+ pub_user_message(
+ payload, connection_id,
+ f'Die ID \'{payload["data"]["id"]}\' ist größer als die maximal vergebene '
+ f'ID \'{self.max_id_io_action}\'.',
+ MessageType.ERROR)
+
+ def addIoDevice(self, connection_id: str, payload: dict) -> None:
+ """ sendet das Topic, zu dem ein neues Io-Device erstellt werden soll.
+ """
+ new_id = self.max_id_io_device + 1
+ dev = importlib.import_module(".io_devices."+payload["data"]["type"]+".api", "modules")
+ descriptor = dev.device_descriptor.configuration_factory()
+ device_default = dataclass_utils.asdict(descriptor)
+ device_default["id"] = new_id
+ Pub().pub(f'openWB/set/system/io/{new_id}/config', device_default)
+ self.max_id_io_device = new_id
+ Pub().pub("openWB/set/command/max_id/io_device", self.max_id_io_device)
+ pub_user_message(
+ payload, connection_id,
+ f'Neues IO-Gerät vom Typ \'{payload["data"]["type"]}\' mit ID \'{new_id}\' hinzugefügt.',
+ MessageType.SUCCESS)
+
+ def removeIoDevice(self, connection_id: str, payload: dict) -> None:
+ """ löscht ein Io-Device.
+ """
+ if self.max_id_io_device >= payload["data"]["id"]:
+ ProcessBrokerBranch(f'system/io/{payload["data"]["id"]}/').remove_topics()
+ pub_user_message(payload, connection_id, f'IO-Gerät mit ID \'{payload["data"]["id"]}\' gelöscht.',
+ MessageType.SUCCESS)
+ else:
+ pub_user_message(
+ payload, connection_id,
+ f'Die ID \'{payload["data"]["id"]}\' ist größer als die maximal vergebene '
+ f'ID \'{self.max_id_io_device}\'.',
+ MessageType.ERROR)
+
def addChargepoint(self, connection_id: str, payload: dict) -> None:
""" sendet das Topic, zu dem ein neuer Chargepoint erstellt werden soll.
"""
@@ -255,7 +315,7 @@ def _check_max_num_of_internal_chargepoints(self, config: Dict) -> Optional[str]
return None
def removeChargepoint(self, connection_id: str, payload: dict) -> None:
- """ löscht ein Chargepoint.
+ """ löscht ein Ladepunkt.
"""
if self.max_id_hierarchy < payload["data"]["id"]:
pub_user_message(
@@ -711,26 +771,26 @@ def restoreBackup(self, connection_id: str, payload: dict) -> None:
def requestMSALAuthCode(self, connection_id: str, payload: dict) -> None:
''' fordert einen Authentifizierungscode für MSAL (Microsoft Authentication Library)
an um Onedrive Backup zu ermöglichen'''
- cloudbackupconfig = SubData.system_data["system"].backup_cloud
- if cloudbackupconfig is None:
+ cloud_backup_config = SubData.system_data["system"].backup_cloud
+ if cloud_backup_config is None:
pub_user_message(payload, connection_id,
"Es ist keine Backup-Cloud konfiguriert. Bitte Konfiguration speichern "
"und erneut versuchen.
", MessageType.WARNING)
return
- result = generateMSALAuthCode(cloudbackupconfig.config)
+ result = generateMSALAuthCode(cloud_backup_config.config)
pub_user_message(payload, connection_id, result["message"], result["MessageType"])
# ToDo: move to module commands if implemented
def retrieveMSALTokens(self, connection_id: str, payload: dict) -> None:
""" holt die Tokens für MSAL (Microsoft Authentication Library) um Onedrive Backup zu ermöglichen
"""
- cloudbackupconfig = SubData.system_data["system"].backup_cloud
- if cloudbackupconfig is None:
+ cloud_backup_config = SubData.system_data["system"].backup_cloud
+ if cloud_backup_config is None:
pub_user_message(payload, connection_id,
"Es ist keine Backup-Cloud konfiguriert. Bitte Konfiguration speichern "
"und erneut versuchen.
", MessageType.WARNING)
return
- result = retrieveMSALTokens(cloudbackupconfig.config)
+ result = retrieveMSALTokens(cloud_backup_config.config)
pub_user_message(payload, connection_id, result["message"], result["MessageType"])
def factoryReset(self, connection_id: str, payload: dict) -> None:
@@ -806,18 +866,18 @@ def __init__(self, topic_str: str) -> None:
def get_payload(self):
self.payload: str
- InternalBrokerClient("processBrokerBranch", self.on_connect, self.__get_payload).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.on_connect, self.__get_payload).start_finite_loop()
return json.loads(self.payload)
def remove_topics(self):
""" löscht einen Topic-Zweig auf dem Broker. Payload "" löscht nur ein einzelnes Topic.
"""
- InternalBrokerClient("processBrokerBranch", self.on_connect, self.__on_message_rm).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.on_connect, self.__on_message_rm).start_finite_loop()
def get_max_id(self) -> List[str]:
try:
self.received_topics = []
- InternalBrokerClient("processBrokerBranch", self.on_connect, self.__on_message_max_id).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.on_connect, self.__on_message_max_id).start_finite_loop()
return self.received_topics
except Exception:
log.exception("Fehler im Command-Modul")
@@ -827,8 +887,8 @@ def check_mqtt_bridge_exists(self, name: str) -> bool:
try:
self.name = name
self.mqtt_bridge_exists = False
- InternalBrokerClient("processBrokerBranch", self.on_connect,
- self.__on_message_mqtt_bridge_exists).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.on_connect,
+ self.__on_message_mqtt_bridge_exists).start_finite_loop()
return self.mqtt_bridge_exists
except Exception:
log.exception("Fehler im Command-Modul")
@@ -837,7 +897,7 @@ def check_mqtt_bridge_exists(self, name: str) -> bool:
def get_cloud_id(self):
try:
self.ids = []
- InternalBrokerClient("processBrokerBranch", self.on_connect, self.__on_message_cloud_id).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.on_connect, self.__on_message_cloud_id).start_finite_loop()
return self.ids
except Exception:
log.exception("Fehler im Command-Modul")
diff --git a/packages/helpermodules/command_test.py b/packages/helpermodules/command_test.py
index 22b04d2267..6664cc1fca 100644
--- a/packages/helpermodules/command_test.py
+++ b/packages/helpermodules/command_test.py
@@ -16,7 +16,7 @@
@pytest.fixture
def subdata_fixture() -> None:
- SubData(*([Mock()]*18))
+ SubData(*([Mock()]*19))
SubData.cp_data = {"cp0": Mock(spec=ChargepointStateUpdate, chargepoint=Mock(
spec=Chargepoint, chargepoint_module=Mock(spec=ChargepointModulePro)))}
diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py
index 461b46f802..b6cbb395b2 100644
--- a/packages/helpermodules/create_debug.py
+++ b/packages/helpermodules/create_debug.py
@@ -10,7 +10,7 @@
from control.chargepoint.chargepoint import Chargepoint
import dataclass_utils
from helpermodules import subdata
-from helpermodules.broker import InternalBrokerClient
+from helpermodules.broker import BrokerClient
from helpermodules.pub import Pub
from helpermodules.utils.run_command import run_command
from helpermodules.utils.topic_parser import decode_payload
@@ -218,7 +218,7 @@ def __init__(self) -> None:
self.content = ""
def get_broker(self):
- InternalBrokerClient("processBrokerBranch", self.__on_connect_broker, self.__get_content).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.__on_connect_broker, self.__get_content).start_finite_loop()
return self.content
def __on_connect_broker(self, client, userdata, flags, rc):
@@ -228,8 +228,8 @@ def __get_content(self, client, userdata, msg):
self.content += f"{msg.topic} {decode_payload(msg.payload)}\n"
def get_broker_essentials(self):
- InternalBrokerClient("processBrokerBranch", self.__on_connect_broker_essentials,
- self.__get_content).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.__on_connect_broker_essentials,
+ self.__get_content).start_finite_loop()
return self.content
def __on_connect_broker_essentials(self, client, userdata, flags, rc):
@@ -248,7 +248,7 @@ def __on_connect_broker_essentials(self, client, userdata, flags, rc):
client.subscribe("openWB/optional/et/provider", 2)
def get_bridges(self):
- InternalBrokerClient("processBrokerBranch", self.__on_connect_bridges, self.__get_bridges).start_finite_loop()
+ BrokerClient("processBrokerBranch", self.__on_connect_bridges, self.__get_bridges).start_finite_loop()
return self.content
def __on_connect_bridges(self, client, userdata, flags, rc):
diff --git a/packages/helpermodules/data_migration/data_migration.py b/packages/helpermodules/data_migration/data_migration.py
index 369641a846..ad7e2c4129 100644
--- a/packages/helpermodules/data_migration/data_migration.py
+++ b/packages/helpermodules/data_migration/data_migration.py
@@ -15,6 +15,7 @@
import pathlib
import shutil
import tarfile
+from paho.mqtt.client import Client as MqttClient, MQTTMessage
from threading import Thread
from typing import Callable, Dict, List, Optional, Union
@@ -22,17 +23,21 @@
from control.ev import ev
from dataclass_utils import dataclass_from_dict
import dataclass_utils
+from helpermodules.broker import BrokerClient
from helpermodules.data_migration.id_mapping import MapId
from helpermodules.hardware_configuration import update_hardware_configuration
from helpermodules.measurement_logging.process_log import get_totals, string_to_float, string_to_int
from helpermodules.measurement_logging.write_log import LegacySmartHomeLogData, get_names
from helpermodules.timecheck import convert_timedelta_to_time_string, get_difference
from helpermodules.utils import joined_thread_handler
+from helpermodules.utils.topic_parser import get_index
from helpermodules.pub import Pub
from helpermodules.utils.json_file_handler import write_and_check
-from modules.ripple_control_receivers.gpio.config import GpioRcr
+from modules.io_actions.controllable_consumers.ripple_control_receiver.config import RippleControlReceiverSetup
+from modules.io_devices.add_on.config import AddOn
import re
+
log = logging.getLogger("data_migration")
@@ -569,7 +574,19 @@ def _move_cloud_data(self) -> None:
def _move_rse(self) -> None:
if bool(self._get_openwb_conf_value("rseenabled", "0")):
- Pub().pub("openWB/set/general/ripple_control_receiver/module", dataclass_utils.asdict(GpioRcr()))
+ action = RippleControlReceiverSetup()
+ for cp_topic in self.all_received_topics.keys():
+ if re.search("openWB/chargepoint/[0-9]+/config", cp_topic) is not None:
+ action.configuration.cp_ids.append(get_index(cp_topic))
+ action.configuration.io_device = 0
+ # Wenn mindestens ein Kontakt geschlossen ist, wird die Ladung gesperrt. Wenn beide Kontakt
+ # offen sind, darf geladen werden.
+ action.configuration.input_pattern = [{"value": 1, "input_matrix": {"21": False, "24": False}},
+ {"value": 0, "input_matrix": {"21": False, "24": True}},
+ {"value": 0, "input_matrix": {"21": True, "24": False}},
+ {"value": 0, "input_matrix": {"21": True, "24": True}}]
+ Pub().pub('openWB/system/io/0/config', dataclass_utils.asdict(AddOn()))
+ Pub().pub('openWB/io/action/0/config', dataclass_utils.asdict(action))
def _move_max_c_socket(self):
try:
@@ -621,3 +638,21 @@ def merge_list_of_records(self, key):
reduce(self._merge_records_by(key), records)
for _, records in groupby(sorted(lst, key=key_prop), key_prop)
]
+
+
+class BrokerCphargepoints:
+ def get_configured_cp_ids(self) -> List:
+ self.all_received_topics = {}
+ BrokerClient("update-config", self.on_connect, self.on_message).start_finite_loop()
+ cp_ids = []
+ for topic, payload in self.all_received_topics.items():
+ cp_ids.append(get_index(topic))
+ return cp_ids
+
+ def on_connect(self, client: MqttClient, userdata, flags: dict, rc: int):
+ """ connect to broker and subscribe to set topics
+ """
+ client.subscribe("openWB/chargepoint/+/config", 2)
+
+ def on_message(self, client: MqttClient, userdata, msg: MQTTMessage):
+ self.all_received_topics.update({msg.topic: msg.payload})
diff --git a/packages/helpermodules/logger.py b/packages/helpermodules/logger.py
index 93fafcf7e5..1570a71532 100644
--- a/packages/helpermodules/logger.py
+++ b/packages/helpermodules/logger.py
@@ -138,6 +138,13 @@ def mb_to_bytes(megabytes: int) -> int:
mqtt_file_handler.addFilter(RedactingFilter())
mqtt_log.addHandler(mqtt_file_handler)
+ steuve_control_command_log = logging.getLogger("steuve_control_command")
+ steuve_control_command_log.propagate = False
+ steuve_control_command_file_handler = RotatingFileHandler(
+ PERSISTENT_LOG_PATH + 'steuve_control_command.log', maxBytes=mb_to_bytes(80), backupCount=1)
+ steuve_control_command_file_handler.setFormatter(logging.Formatter(FORMAT_STR_SHORT))
+ steuve_control_command_log.addHandler(steuve_control_command_file_handler)
+
smarthome_log_handler = RotatingFileHandler(RAMDISK_PATH + 'smarthome.log', maxBytes=mb_to_bytes(1), backupCount=1)
smarthome_log_handler.setFormatter(logging.Formatter(FORMAT_STR_SHORT))
smarthome_log_handler.addFilter(functools.partial(filter_pos, "smarthome"))
diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py
index 92707c9215..fc0b3455a8 100644
--- a/packages/helpermodules/measurement_logging/write_log.py
+++ b/packages/helpermodules/measurement_logging/write_log.py
@@ -10,7 +10,7 @@
from typing import Dict, Optional
from control import data
-from helpermodules.broker import InternalBrokerClient
+from helpermodules.broker import BrokerClient
from helpermodules import timecheck
from helpermodules.utils.json_file_handler import write_and_check
from helpermodules.utils.topic_parser import decode_payload, get_index
@@ -99,7 +99,7 @@ def __init__(self) -> None:
self.sh_dict: Dict = {}
self.sh_names: Dict = {}
try:
- InternalBrokerClient("smart-home-logging", self.on_connect, self.on_message).start_finite_loop()
+ BrokerClient("smart-home-logging", self.on_connect, self.on_message).start_finite_loop()
for topic, payload in self.all_received_topics.items():
if re.search("openWB/LegacySmartHome/config/get/Devices/[1-9]/device_configured", topic) is not None:
if decode_payload(payload) == 1:
diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py
index dec04bf379..6b31c19f9e 100644
--- a/packages/helpermodules/setdata.py
+++ b/packages/helpermodules/setdata.py
@@ -11,7 +11,7 @@
import logging
from helpermodules import hardware_configuration, subdata
-from helpermodules.broker import InternalBrokerClient
+from helpermodules.broker import BrokerClient
from helpermodules.pub import Pub, pub_single
from helpermodules.utils.topic_parser import (decode_payload, get_index, get_index_position, get_second_index,
get_second_index_position)
@@ -41,7 +41,7 @@ def __init__(self,
self.heartbeat = False
def set_data(self):
- self.internal_broker_client = InternalBrokerClient("mqttset", self.on_connect, self.on_message)
+ self.internal_broker_client = BrokerClient("mqttset", self.on_connect, self.on_message)
self.event_subdata_initialized.wait()
log.debug("Subdata initialization completed. Starting setdata loop to broker.")
self.internal_broker_client.start_infinite_loop()
@@ -84,6 +84,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage):
self.process_bat_topic(msg)
elif "openWB/set/general/" in msg.topic:
self.process_general_topic(msg)
+ elif ("openWB/set/io/" in msg.topic or "openWB/set/internal_io/" in msg.topic):
+ self.process_io_topic(msg)
elif "openWB/set/optional/" in msg.topic:
self.process_optional_topic(msg)
elif "openWB/set/counter/" in msg.topic:
@@ -822,19 +824,9 @@ def process_general_topic(self, msg: mqtt.MQTTMessage):
"openWB/set/general/prices/grid" in msg.topic or
"openWB/set/general/prices/pv" in msg.topic):
self._validate_value(msg, float, [(0, 99.99)])
- elif ("openWB/set/general/range_unit" in msg.topic or
- "openWB/set/general/ripple_control_receiver/override_reference" in msg.topic):
+ elif "openWB/set/general/range_unit" in msg.topic:
self._validate_value(msg, str)
- elif "openWB/set/general/ripple_control_receiver/configured" in msg.topic:
- self._validate_value(msg, bool)
- elif "openWB/set/general/ripple_control_receiver/get/override_value" in msg.topic:
- self._validate_value(msg, float)
- elif "openWB/set/general/ripple_control_receiver/get/fault_state" in msg.topic:
- self._validate_value(msg, int, [(0, 2)])
- elif "openWB/set/general/ripple_control_receiver/get/fault_str" in msg.topic:
- self._validate_value(msg, str)
- elif ("openWB/set/general/web_theme" in msg.topic or
- "openWB/set/general/ripple_control_receiver/module" in msg.topic):
+ elif "openWB/set/general/web_theme" in msg.topic:
self._validate_value(msg, "json")
elif ("openWB/set/general/charge_log_data_config" in msg.topic):
self._validate_value(msg, "json")
@@ -843,6 +835,36 @@ def process_general_topic(self, msg: mqtt.MQTTMessage):
except Exception:
log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}")
+ def process_io_topic(self, msg: mqtt.MQTTMessage):
+ """ Handler für die Allgemeinen-Topics
+
+ Parameters
+ ----------
+
+ msg:
+ enthält Topic und Payload
+ """
+ try:
+ if "config" in msg.topic:
+ self._validate_value(msg, "json")
+ elif ("get/digital_input" in msg.topic or
+ "get/analog_input" in msg.topic or
+ "get/digital_output" in msg.topic or
+ "get/analog_output" in msg.topic or
+ "set/digital_output" in msg.topic or
+ "set/analog_output" in msg.topic):
+ self._validate_value(msg, "json")
+ elif "get/fault_state" in msg.topic:
+ self._validate_value(msg, int, [(0, 2)])
+ elif "get/fault_str" in msg.topic:
+ self._validate_value(msg, str)
+ elif "/timestamp" in msg.topic:
+ self._validate_value(msg, float)
+ else:
+ self.__unknown_topic(msg)
+ except Exception:
+ log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}")
+
def process_optional_topic(self, msg: mqtt.MQTTMessage):
""" Handler für die Optionalen-Topics
@@ -1070,6 +1092,9 @@ def process_system_topic(self, msg: mqtt.MQTTMessage):
self._validate_value(msg, bool)
else:
self.__unknown_topic(msg)
+ elif "io" in msg.topic:
+ if "/config" in msg.topic:
+ self._validate_value(msg, "json")
else:
# hier kommen auch noch alte Topics ohne json-Format an.
# log.error("Unbekanntes set-Topic: "+str(msg.topic)+", "+
diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py
index f834771322..f843a7ceb2 100644
--- a/packages/helpermodules/subdata.py
+++ b/packages/helpermodules/subdata.py
@@ -9,7 +9,7 @@
import subprocess
import paho.mqtt.client as mqtt
-from control import bat_all, bat, counter, counter_all, general, optional, pv, pv_all
+from control import bat_all, bat, counter, counter_all, general, io_device, optional, pv, pv_all
from control.chargepoint import chargepoint
from control.chargepoint.chargepoint_all import AllChargepoints
from control.chargepoint.chargepoint_data import Log
@@ -21,7 +21,7 @@
from control.optional_data import Ocpp
from helpermodules import graph, system
from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan
-from helpermodules.broker import InternalBrokerClient
+from helpermodules.broker import BrokerClient
from helpermodules.messaging import MessageType, pub_system_message
from helpermodules.utils.run_command import run_command
from helpermodules.utils.topic_parser import decode_payload, get_index, get_second_index
@@ -29,7 +29,6 @@
from dataclass_utils import dataclass_from_dict
from modules.common.abstract_vehicle import CalculatedSocState, GeneralVehicleConfig
from modules.common.configurable_backup_cloud import ConfigurableBackupCloud
-from modules.common.configurable_ripple_control_receiver import ConfigurableRcr
from modules.common.configurable_tariff import ConfigurableElectricityTariff
from modules.common.simcount.simcounter_state import SimCounterState
from modules.internal_chargepoint_handler.internal_chargepoint_handler_config import (
@@ -64,6 +63,8 @@ class SubData:
"cp1": InternalChargepoint(),
"global_data": GlobalHandlerData(),
"rfid_data": RfidData()}
+ io_actions = io_device.IoActions()
+ io_states: Dict[str, io_device.IoStates] = {}
optional_data = optional.Optional()
system_data = {"system": system.System()}
graph_data = graph.Graph()
@@ -86,7 +87,8 @@ def __init__(self,
event_update_soc: threading.Event,
event_soc: threading.Event,
event_jobs_running: threading.Event,
- event_modbus_server: threading.Event,):
+ event_modbus_server: threading.Event,
+ event_restart_gpio: threading.Event,):
self.event_ev_template = event_ev_template
self.event_charge_template = event_charge_template
self.event_cp_config = event_cp_config
@@ -105,10 +107,11 @@ def __init__(self,
self.event_soc = event_soc
self.event_jobs_running = event_jobs_running
self.event_modbus_server = event_modbus_server
+ self.event_restart_gpio = event_restart_gpio
self.heartbeat = False
def sub_topics(self):
- self.internal_broker_client = InternalBrokerClient("mqttsub", self.on_connect, self.on_message)
+ self.internal_broker_client = BrokerClient("mqttsub", self.on_connect, self.on_message)
self.internal_broker_client.start_infinite_loop()
def disconnect(self) -> None:
@@ -129,6 +132,8 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int):
("openWB/bat/#", 2),
("openWB/general/#", 2),
("openWB/graph/#", 2),
+ ("openWB/internal_io/#", 2),
+ ("openWB/io/#", 2),
("openWB/optional/#", 2),
("openWB/counter/#", 2),
("openWB/command/command_completed", 2),
@@ -142,6 +147,7 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int):
("openWB/system/backup_cloud/#", 2),
("openWB/system/device/module_update_completed", 2),
("openWB/system/device/+/config", 2),
+ ("openWB/system/io/#", 2),
("openWB/LegacySmartHome/Status/wattnichtHaus", 2),
])
Pub().pub("openWB/system/subdata_initialized", True)
@@ -171,6 +177,10 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage):
self.process_general_topic(self.general_data, msg)
elif "openWB/graph/" in msg.topic:
self.process_graph_topic(self.graph_data, msg)
+ elif "openWB/io/action" in msg.topic:
+ self.process_io_topic(self.io_actions, msg)
+ elif "openWB/io/states" in msg.topic or "openWB/internal_io/states" in msg.topic:
+ self.process_io_topic(self.io_states, msg)
elif "openWB/internal_chargepoint/" in msg.topic:
self.process_internal_chargepoint_topic(client, self.internal_chargepoint_data, msg)
elif "openWB/optional/" in msg.topic:
@@ -240,6 +250,9 @@ def set_json_payload_class(self, class_obj: Dict, msg: mqtt.MQTTMessage):
log.error("Elemente können nur aus Dictionaries entfernt werden, nicht aus Klassen.")
else:
raise Exception(f"Key konnte nicht aus Topic {msg.topic} ermittelt werden.")
+ except AttributeError:
+ pass
+ # manche Topics werden nicht in einem Attribut gespeichert
except Exception:
log.exception("Fehler im subdata-Modul")
@@ -579,23 +592,7 @@ def process_general_topic(self, var: general.General, msg: mqtt.MQTTMessage):
"""
try:
if re.search("/general/", msg.topic) is not None:
- if re.search("/general/ripple_control_receiver/module", msg.topic) is not None:
- config_dict = decode_payload(msg.payload)
- if config_dict["type"] is None:
- var.data.ripple_control_receiver.module = None
- var.ripple_control_receiver = None
- else:
- mod = importlib.import_module(".ripple_control_receivers." +
- config_dict["type"]+".ripple_control_receiver", "modules")
- config = dataclass_from_dict(mod.device_descriptor.configuration_factory, config_dict)
- var.data.ripple_control_receiver.module = config_dict
- var.ripple_control_receiver = ConfigurableRcr(
- config=config, component_initializer=mod.create_ripple_control_receiver)
- elif re.search("/general/ripple_control_receiver/get/", msg.topic) is not None:
- self.set_json_payload_class(var.data.ripple_control_receiver.get, msg)
- elif re.search("/general/ripple_control_receiver/", msg.topic) is not None:
- return
- elif re.search("/general/prices/", msg.topic) is not None:
+ if re.search("/general/prices/", msg.topic) is not None:
self.set_json_payload_class(var.data.prices, msg)
elif re.search("/general/chargemode_config/", msg.topic) is not None:
if re.search("/general/chargemode_config/pv_charging/", msg.topic) is not None:
@@ -640,6 +637,63 @@ def process_general_topic(self, var: general.General, msg: mqtt.MQTTMessage):
except Exception:
log.exception("Fehler im subdata-Modul")
+ def process_io_topic(self, var: Dict[str, Union[io_device.IoActions, io_device.IoStates]], msg: mqtt.MQTTMessage):
+ """ Handler für die IO-Topics
+
+ Parameter
+ ----------
+ var : Dictionary
+ enthält aktuelle Daten
+ msg :
+ enthält Topic und Payload
+ """
+ try:
+ if (re.search("/io/states/", msg.topic) is not None or
+ re.search("/internal_io/states/", msg.topic) is not None):
+ if re.search("/io/states/[0-9]+/", msg.topic) is not None:
+ index = get_index(msg.topic)
+ key = "io_states"+index
+ else:
+ index = "internal"
+ key = "internal_io_states"
+
+ payload = decode_payload(msg.payload)
+ if payload == "":
+ if key in var:
+ var.pop(key)
+ else:
+ if key not in var:
+ var[key] = io_device.IoStates(index)
+ if (re.search("/io/states/[0-9]+/get", msg.topic) is not None or
+ re.search("/internal_io/states/get", msg.topic) is not None):
+ # Sonst werden Dicts als Payload verwendet, aber es wird alles in ein eigenes Attribut gespeichert
+ # Typ ist hier auch kein typing.Dict, sondern ein generisches Dict[str, bool]
+ setattr(var[key].data.get, msg.topic.split("/")[-1], payload)
+ elif (re.search("/io/states/[0-9]+/set", msg.topic) is not None or
+ re.search("/internal_io/states/set", msg.topic) is not None):
+ # Sonst werden Dicts als Payload verwendet, aber es wird alles in ein eigenes Attribut gespeichert
+ # Typ ist hier auch kein typing.Dict, sondern ein generisches Dict[str, bool]
+ setattr(var[key].data.set, msg.topic.split("/")[-1], payload)
+ else:
+ self.set_json_payload_class(var[key].data, msg)
+ elif "io/action" in msg.topic:
+ if re.search("/io/action/[0-9]+/config", msg.topic) is not None:
+ index = get_index(msg.topic)
+ payload = decode_payload(msg.payload)
+ if payload == "":
+ if f"io_action{index}" in var.actions:
+ var.actions.pop(f"io_action{index}")
+ else:
+ 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)
+ 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)
+ except Exception:
+ log.exception("Fehler im subdata-Modul")
+
def process_optional_topic(self, var: optional.Optional, msg: mqtt.MQTTMessage):
""" Handler für die Optionalen-Topics
@@ -847,6 +901,28 @@ def process_system_topic(self, client: mqtt.Client, var: dict, msg: mqtt.MQTTMes
Pub().pub("openWB/set/command/removeCloudBridge/todo", {
"command": "removeCloudBridge"
})
+ elif re.search("^.+/io/[0-9]+/config$", msg.topic) is not None:
+ index = get_index(msg.topic)
+ if decode_payload(msg.payload) == "":
+ if "io"+index in var:
+ if var[f"io{index}"].config.configuration.host == "localhost":
+ var.pop("iolocal")
+ var.pop("io"+index)
+ else:
+ log.error("Es konnte kein IO-Device mit der ID " +
+ str(index)+" gefunden werden.")
+ else:
+ io_config = decode_payload(msg.payload)
+ if io_config["type"] == "add_on" and io_config["configuration"]["host"] == "localhost":
+ self.event_restart_gpio.set()
+ dev = importlib.import_module(f".internal_chargepoint_handler.{io_config['type']}.api",
+ "modules")
+ config = dataclass_from_dict(dev.device_descriptor.configuration_factory, io_config)
+ var["iolocal"] = dev.create_io(config)
+ dev = importlib.import_module(f".io_devices.{io_config['type']}.api",
+ "modules")
+ config = dataclass_from_dict(dev.device_descriptor.configuration_factory, io_config)
+ var["io"+index] = dev.create_io(config)
else:
if "module_update_completed" in msg.topic:
self.event_module_update_completed.set()
diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py
index 1ef2073582..3ac434f3d6 100644
--- a/packages/helpermodules/update_config.py
+++ b/packages/helpermodules/update_config.py
@@ -1,6 +1,7 @@
from dataclasses import asdict
import datetime
import glob
+import importlib
import json
import logging
from pathlib import Path
@@ -14,7 +15,7 @@
from control.chargepoint.chargepoint_template import get_chargepoint_template_default
from helpermodules import timecheck
from helpermodules import hardware_configuration
-from helpermodules.broker import InternalBrokerClient
+from helpermodules.broker import BrokerClient
from helpermodules.constants import NO_ERROR
from helpermodules.hardware_configuration import (
get_hardware_configuration_setting,
@@ -41,7 +42,7 @@
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.ripple_control_receivers.gpio.config import GpioRcr
+from modules.io_actions.controllable_consumers.ripple_control_receiver.config import RippleControlReceiverSetup
from modules.web_themes.standard_legacy.config import StandardLegacyWebTheme
from modules.devices.good_we.good_we.version import GoodWeVersion
@@ -51,7 +52,7 @@
class UpdateConfig:
- DATASTORE_VERSION = 75
+ DATASTORE_VERSION = 76
valid_topic = [
"^openWB/bat/config/configured$",
"^openWB/bat/config/power_limit_mode$",
@@ -147,6 +148,8 @@ class UpdateConfig:
"^openWB/command/max_id/device$",
"^openWB/command/max_id/ev_template$",
"^openWB/command/max_id/hierarchy$",
+ "^openWB/command/max_id/io_action$",
+ "^openWB/command/max_id/io_device$",
"^openWB/command/max_id/mqtt_bridge$",
"^openWB/command/max_id/vehicle$",
"^openWB/command/[A-Za-z0-9_]+/error$",
@@ -200,12 +203,6 @@ class UpdateConfig:
"^openWB/general/notifications/stop_charging$",
"^openWB/general/notifications/plug$",
"^openWB/general/notifications/smart_home$",
- "^openWB/general/ripple_control_receiver/configured$",
- "^openWB/general/ripple_control_receiver/module$",
- "^openWB/general/ripple_control_receiver/get/fault_state$",
- "^openWB/general/ripple_control_receiver/get/fault_str$",
- "^openWB/general/ripple_control_receiver/get/override_value$",
- "^openWB/general/ripple_control_receiver/override_reference$",
"^openWB/general/chargemode_config/unbalanced_load_limit$",
"^openWB/general/chargemode_config/unbalanced_load$",
"^openWB/general/chargemode_config/pv_charging/bat_mode$",
@@ -245,6 +242,13 @@ class UpdateConfig:
"^openWB/internal_chargepoint/[0-1]/data/phases_to_use$",
"^openWB/internal_chargepoint/[0-1]/data/parent_cp$",
+ "^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$",
+
"^openWB/set/log/request",
"^openWB/set/log/data",
@@ -409,8 +413,9 @@ class UpdateConfig:
"^openWB/system/configurable/devices_components$",
"^openWB/system/configurable/electricity_tariffs$",
"^openWB/system/configurable/display_themes$",
+ "^openWB/system/configurable/io_actions$",
+ "^openWB/system/configurable/io_devices$",
"^openWB/system/configurable/monitoring$",
- "^openWB/system/configurable/ripple_control_receivers$",
"^openWB/system/configurable/soc_modules$",
"^openWB/system/configurable/web_themes$",
"^openWB/system/current_branch",
@@ -429,6 +434,7 @@ class UpdateConfig:
"^openWB/system/device/[0-9]+/component/[0-9]+/simulation/timestamp_present$",
"^openWB/system/device/[0-9]+/config$",
"^openWB/system/device/module_update_completed$",
+ "^openWB/system/io/[0-9]+/config$",
"^openWB/system/ip_address$",
"^openWB/system/lastlivevaluesJson$",
"^openWB/system/mqtt/bridge/[0-9]+$",
@@ -501,7 +507,6 @@ class UpdateConfig:
("openWB/general/prices/grid", Prices().grid),
("openWB/general/prices/pv", Prices().pv),
("openWB/general/range_unit", "km"),
- ("openWB/general/ripple_control_receiver/module", NO_MODULE),
("openWB/general/web_theme", dataclass_utils.asdict(StandardLegacyWebTheme())),
("openWB/graph/config/duration", 120),
("openWB/internal_chargepoint/0/data/parent_cp", None),
@@ -554,7 +559,7 @@ def __init__(self) -> None:
def update(self):
log.debug("Broker-Konfiguration aktualisieren")
- InternalBrokerClient("update-config", self.on_connect, self.on_message).start_finite_loop()
+ BrokerClient("update-config", self.on_connect, self.on_message).start_finite_loop()
try:
# erst breaking changes auflösen, sonst sind alte Topics schon gelöscht
self.__solve_breaking_changes()
@@ -1322,11 +1327,11 @@ def convert_file(file):
convert_file(file)
self.__update_topic("openWB/system/datastore_version", 36)
- 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)
+ # 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)
def upgrade_datastore_37(self) -> None:
def collect_names(topic: str, payload) -> None:
@@ -1957,3 +1962,46 @@ def upgrade(topic: str, payload) -> None:
Pub().pub(topic, payload)
self._loop_all_received_topics(upgrade)
self.__update_topic("openWB/system/datastore_version", 75)
+
+ def upgrade_datastore_75(self) -> None:
+ def upgrade(topic: str, payload) -> Optional[dict]:
+ if "openWB/general/ripple_control_receiver/module" == topic:
+ payload = decode_payload(payload)
+ if payload["type"] is not None:
+ if payload["type"] == "gpio":
+ dev = importlib.import_module(".io_devices.add_on.api", "modules")
+ else:
+ dev = importlib.import_module(".io_devices."+payload["type"]+".api", "modules")
+ io_device = dev.device_descriptor.configuration_factory()
+
+ action = RippleControlReceiverSetup()
+ for cp_topic in self.all_received_topics.keys():
+ if re.search("openWB/chargepoint/[0-9]+/config", cp_topic) is not None:
+ action.configuration.devices.append([f"cp{int(get_index(cp_topic))}"])
+ action.configuration.io_device = 0
+
+ if payload["type"] == "dimm_kit":
+ io_device.configuration.host = payload["configuration"]["ip_address"]
+ io_device.configuration.port = payload["configuration"]["port"]
+ io_device.configuration.modbus_id = payload["configuration"]["modbus_id"]
+ # Wenn mindestens ein Kontakt offen ist, wird die Ladung gesperrt. Wenn beide Kontakte
+ # geschlossen sind, darf geladen werden.
+ action.configuration.input_pattern = [
+ {"value": 0, "input_matrix": {"DI1": False, "DI2": False}},
+ {"value": 0, "input_matrix": {"DI1": False, "DI2": True}},
+ {"value": 0, "input_matrix": {"DI1": True, "DI2": False}},
+ {"value": 1, "input_matrix": {"DI1": True, "DI2": True}}]
+ elif payload["type"] == "gpio":
+ io_device.configuration.host = "localhost"
+ # Wenn mindestens ein Kontakt geschlossen ist, wird die Ladung gesperrt. Wenn beide Kontakt
+ # offen sind, darf geladen werden.
+ action.configuration.input_pattern = [
+ {"value": 1, "input_matrix": {"RSE1": False, "RSE2": False}},
+ {"value": 0, "input_matrix": {"RSE1": False, "RSE2": True}},
+ {"value": 0, "input_matrix": {"RSE1": True, "RSE2": False}},
+ {"value": 0, "input_matrix": {"RSE1": True, "RSE2": True}}]
+
+ 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)
diff --git a/packages/main.py b/packages/main.py
index afa82ce914..d0888e3d64 100755
--- a/packages/main.py
+++ b/packages/main.py
@@ -29,6 +29,7 @@
from helpermodules.utils import exit_after
from modules import configuration, loadvars, update_soc
from modules.internal_chargepoint_handler.internal_chargepoint_handler import GeneralInternalChargepointHandler
+from modules.internal_chargepoint_handler.gpio import InternalGpioHandler
from modules.internal_chargepoint_handler.rfid import RfidReader
from modules.utils import wait_for_module_update_completed
from smarthome.smarthome import readmq, smarthome_handler
@@ -213,6 +214,8 @@ def schedule_jobs():
event_jobs_running = threading.Event()
event_jobs_running.set()
event_update_soc = threading.Event()
+ event_restart_gpio = threading.Event()
+ gpio = InternalGpioHandler(event_restart_gpio)
prep = prepare.Prepare()
soc = update_soc.UpdateSoc(event_update_soc)
set = setdata.SetData(event_ev_template, event_charge_template,
@@ -228,7 +231,7 @@ def schedule_jobs():
event_update_config_completed,
event_update_soc,
event_soc,
- event_jobs_running, event_modbus_server)
+ event_jobs_running, event_modbus_server, event_restart_gpio)
comm = command.Command(event_command_completed)
t_sub = Thread(target=sub.sub_topics, args=(), name="Subdata")
t_set = Thread(target=set.set_data, args=(), name="Setdata")
@@ -237,9 +240,12 @@ def schedule_jobs():
t_internal_chargepoint = Thread(target=general_internal_chargepoint_handler.handler,
args=(), name="Internal Chargepoint")
if rfid.keyboards_detected:
- t_rfid = Thread(target=rfid.run, args=(), name="Internal Chargepoint")
+ t_rfid = Thread(target=rfid.run, args=(), name="Internal RFID")
t_rfid.start()
+ t_gpio = Thread(target=gpio.loop, args=(), name="Internal GPIO")
+ t_gpio.start()
+
t_sub.start()
t_set.start()
t_comm.start()
diff --git a/packages/modules/common/abstract_device.py b/packages/modules/common/abstract_device.py
index b0c235b9ed..88f33e90b7 100644
--- a/packages/modules/common/abstract_device.py
+++ b/packages/modules/common/abstract_device.py
@@ -50,6 +50,11 @@ def __init__(self, *kwargs) -> None:
def update(self, *kwargs) -> None:
pass
+ @abstractmethod
+ def set_power_limit(self, power_limit: float) -> None:
+ # power_limit in Werten zwischen 0 und 1
+ pass
+
class DeviceDescriptor:
def __init__(self, configuration_factory: Type):
diff --git a/packages/modules/common/abstract_io.py b/packages/modules/common/abstract_io.py
new file mode 100644
index 0000000000..0af498b96b
--- /dev/null
+++ b/packages/modules/common/abstract_io.py
@@ -0,0 +1,25 @@
+from abc import abstractmethod
+from typing import Dict, Optional
+
+
+class AbstractIoDevice:
+ @abstractmethod
+ def __init__(self, io_config: dict) -> None:
+ pass
+
+ @abstractmethod
+ def read(self) -> None:
+ pass
+
+ @abstractmethod
+ def write(self, analog_output: Optional[Dict[str, int]], digital_output: Optional[Dict[str, bool]]) -> None:
+ pass
+
+
+class AbstractIoAction:
+ def __init__(self):
+ self.timestamp = None
+
+ @abstractmethod
+ def setup(self) -> None:
+ pass
diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py
index 3a56202ea0..0298d7892c 100644
--- a/packages/modules/common/component_state.py
+++ b/packages/modules/common/component_state.py
@@ -207,6 +207,14 @@ def __init__(self,
@auto_str
-class RcrState:
- def __init__(self, override_value: float) -> None:
- self.override_value = override_value
+class IoState:
+ """JSON erlaubt nur Zeichenketten als Schlüssel für Objekte"""
+
+ def __init__(self, analog_input: Dict[str, float] = None,
+ digital_input: Dict[str, bool] = None,
+ analog_output: Dict[str, float] = None,
+ digital_output: Dict[str, bool] = None) -> None:
+ self.analog_input = analog_input
+ self.digital_input = digital_input
+ self.analog_output = analog_output
+ self.digital_output = digital_output
diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py
index 896f1f5737..d7a3b4b4bc 100644
--- a/packages/modules/common/component_type.py
+++ b/packages/modules/common/component_type.py
@@ -9,7 +9,7 @@ class ComponentType(Enum):
COUNTER = "counter"
ELECTRICITY_TARIFF = "electricity_tariff"
INVERTER = "inverter"
- RIPPLE_CONTROL_RECEIVER = "ripple_control_receiver"
+ IO = "io"
def special_to_general_type_mapping(component_type: str) -> ComponentType:
@@ -34,6 +34,8 @@ def type_to_topic_mapping(component_type: str) -> str:
return "pv"
elif ComponentType.ELECTRICITY_TARIFF.value in component_type:
return "optional/et"
+ elif ComponentType.IO.value in component_type:
+ return "io/states"
else:
return component_type
diff --git a/packages/modules/common/configurable_io.py b/packages/modules/common/configurable_io.py
new file mode 100644
index 0000000000..6e450bd9f0
--- /dev/null
+++ b/packages/modules/common/configurable_io.py
@@ -0,0 +1,41 @@
+from typing import Dict, Optional, TypeVar, Generic, Callable, Union
+
+from modules.common import store
+from modules.common.abstract_io import AbstractIoDevice
+from modules.common.component_context import SingleComponentUpdateContext
+from modules.common.component_state import IoState
+from modules.common.component_type import ComponentType
+from modules.common.fault_state import ComponentInfo, FaultState
+
+
+T_IO_CONFIG = TypeVar("T_IO_CONFIG")
+
+
+class ConfigurableIo(Generic[T_IO_CONFIG], AbstractIoDevice):
+ def __init__(self,
+ config: T_IO_CONFIG,
+ component_reader: Callable[[], IoState],
+ component_writer: Callable[[Dict[int, Union[float, int]]], Optional[IoState]]) -> None:
+ self.config = config
+ self.fault_state = FaultState(ComponentInfo(self.config.id, self.config.name,
+ ComponentType.IO.value))
+ self.store = store.get_io_value_store(self.config.id)
+ with SingleComponentUpdateContext(self.fault_state):
+ self.component_reader = component_reader
+ self.component_writer = component_writer
+
+ def read(self):
+ if hasattr(self, "component_reader"):
+ # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten
+ with SingleComponentUpdateContext(self.fault_state):
+ self.store.set(self.component_reader())
+
+ 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):
+ 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)
diff --git a/packages/modules/common/configurable_ripple_control_receiver.py b/packages/modules/common/configurable_ripple_control_receiver.py
deleted file mode 100644
index e15475604b..0000000000
--- a/packages/modules/common/configurable_ripple_control_receiver.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from typing import TypeVar, Generic, Callable
-
-from modules.common import store
-from modules.common.component_context import SingleComponentUpdateContext
-from modules.common.component_type import ComponentType
-from modules.common.fault_state import ComponentInfo, FaultState
-
-
-T_RCR_CONFIG = TypeVar("T_RCR_CONFIG")
-
-
-class ConfigurableRcr(Generic[T_RCR_CONFIG]):
- def __init__(self,
- config: T_RCR_CONFIG,
- component_initializer: Callable[[], float]) -> None:
- self.config = config
- self.fault_state = FaultState(ComponentInfo(None, self.config.name,
- ComponentType.RIPPLE_CONTROL_RECEIVER.value))
- with SingleComponentUpdateContext(self.fault_state):
- self._component_updater = component_initializer(config)
- self.store = store.get_ripple_control_receiver_value_store()
-
- def update(self):
- if hasattr(self, "_component_updater"):
- # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten
- with SingleComponentUpdateContext(self.fault_state):
- self.store.set(self._component_updater())
diff --git a/packages/modules/common/fault_state.py b/packages/modules/common/fault_state.py
index 7c1dd19254..c91bdffe45 100644
--- a/packages/modules/common/fault_state.py
+++ b/packages/modules/common/fault_state.py
@@ -53,8 +53,6 @@ def store_error(self) -> None:
topic = component_type.type_to_topic_mapping(self.component_info.type)
if self.component_info.type == component_type.ComponentType.ELECTRICITY_TARIFF.value:
topic_prefix = f"openWB/set/{topic}"
- elif self.component_info.type == component_type.ComponentType.RIPPLE_CONTROL_RECEIVER.value:
- topic_prefix = f"openWB/set/general/{topic}"
else:
topic_prefix = f"openWB/set/{topic}/{self.component_info.id}"
pub.Pub().pub(f"{topic_prefix}/get/fault_str", self.fault_str)
diff --git a/packages/modules/common/io_setup.py b/packages/modules/common/io_setup.py
new file mode 100644
index 0000000000..5ae5792e12
--- /dev/null
+++ b/packages/modules/common/io_setup.py
@@ -0,0 +1,23 @@
+
+from typing import Dict, Generic, Optional, TypeVar
+
+from dataclass_utils.factories import empty_dict_factory
+
+
+T = TypeVar("T")
+
+
+class IoDeviceSetup(Generic[T]):
+ def __init__(self,
+ name: str,
+ type: str,
+ id: int,
+ configuration: T,
+ input: Optional[Dict[str, Dict[int, float]]] = None,
+ output: Optional[Dict[str, Dict[int, float]]] = None) -> None:
+ self.name = name
+ self.type = type
+ self.id = id
+ self.configuration = configuration
+ self.input = input if input is not None else empty_dict_factory()
+ self.output = output if output is not None else empty_dict_factory()
diff --git a/packages/modules/common/modbus.py b/packages/modules/common/modbus.py
index f81f28b41e..c492a5356e 100644
--- a/packages/modules/common/modbus.py
+++ b/packages/modules/common/modbus.py
@@ -188,6 +188,9 @@ def read_coils(self, address: int, count: int, **kwargs):
def write_registers(self, address: int, value: Any, **kwargs):
self._delegate.write_registers(address, value, **kwargs)
+ def write_single_coil(self, address: int, value: Any, **kwargs):
+ self._delegate.write_coil(address, value, **kwargs)
+
class ModbusTcpClient_(ModbusClient):
def __init__(self,
diff --git a/packages/modules/common/store/__init__.py b/packages/modules/common/store/__init__.py
index 79530f7414..451f2ce2c1 100644
--- a/packages/modules/common/store/__init__.py
+++ b/packages/modules/common/store/__init__.py
@@ -5,7 +5,7 @@
from modules.common.store._chargepoint_internal import get_internal_chargepoint_value_store
from modules.common.store._counter import get_counter_value_store
from modules.common.store._inverter import get_inverter_value_store
-from modules.common.store._ripple_control_receiver import get_ripple_control_receiver_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.ramdisk.io import ramdisk_write, ramdisk_read, ramdisk_read_float, ramdisk_read_int, \
RAMDISK_PATH
diff --git a/packages/modules/common/store/_io.py b/packages/modules/common/store/_io.py
new file mode 100644
index 0000000000..7872e64275
--- /dev/null
+++ b/packages/modules/common/store/_io.py
@@ -0,0 +1,32 @@
+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
+
+
+class IoValueStoreBroker(ValueStore[IoState]):
+ def __init__(self, num: int) -> None:
+ self.num = num
+
+ 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", self.state.digital_input)
+ if self.state.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", self.state.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", self.state.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)
+
+
+def get_io_value_store(num: int) -> ValueStore[IoState]:
+ return LoggingValueStore(IoValueStoreBroker(num))
diff --git a/packages/modules/common/store/_io_internal.py b/packages/modules/common/store/_io_internal.py
new file mode 100644
index 0000000000..b3f191f76b
--- /dev/null
+++ b/packages/modules/common/store/_io_internal.py
@@ -0,0 +1,30 @@
+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
+
+
+class InternalIoValueStoreBroker(ValueStore[IoState]):
+ def __init__(self) -> None:
+ pass
+
+ 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)
+
+
+def get_internal_io_value_store() -> ValueStore[IoState]:
+ return LoggingValueStore(InternalIoValueStoreBroker())
diff --git a/packages/modules/common/store/_ripple_control_receiver.py b/packages/modules/common/store/_ripple_control_receiver.py
deleted file mode 100644
index c92ddec83f..0000000000
--- a/packages/modules/common/store/_ripple_control_receiver.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from modules.common.component_state import RcrState
-from modules.common.store import ValueStore
-from modules.common.store._api import LoggingValueStore
-from modules.common.store._broker import pub_to_broker
-
-
-class RippleControlReceiverValueStore(ValueStore[RcrState]):
- def __init__(self):
- pass
-
- def set(self, state: RcrState) -> None:
- self.state = state
-
- def update(self):
- pub_to_broker("openWB/set/general/ripple_control_receiver/get/override_value", self.state.override_value)
-
-
-def get_ripple_control_receiver_value_store() -> ValueStore[RcrState]:
- return LoggingValueStore(RippleControlReceiverValueStore())
diff --git a/packages/modules/common/utils/component_parser.py b/packages/modules/common/utils/component_parser.py
index b235042261..408eeda3a0 100644
--- a/packages/modules/common/utils/component_parser.py
+++ b/packages/modules/common/utils/component_parser.py
@@ -3,6 +3,7 @@
from control import data
from modules.common.abstract_device import AbstractDevice
+from modules.common.abstract_io import AbstractIoDevice
from modules.common.component_type import type_to_topic_mapping
log = logging.getLogger(__name__)
@@ -17,6 +18,15 @@ def get_component_name_by_id(id: int):
raise ValueError(f"Element {id} konnte keinem Gerät zugeordnet werden.")
+def get_io_name_by_id(id: int):
+ for item in data.data.system_data.values():
+ if isinstance(item, AbstractIoDevice):
+ if item.config.id == id:
+ return item.config.name
+ else:
+ raise ValueError(f"Element {id} konnte keinem Gerät zugeordnet werden.")
+
+
def get_component_obj_by_id(id: int, not_finished_threads: List[str]) -> Optional[Any]:
for item in data.data.system_data.values():
if isinstance(item, AbstractDevice):
diff --git a/packages/modules/configuration.py b/packages/modules/configuration.py
index 625bfe3488..59628e4494 100644
--- a/packages/modules/configuration.py
+++ b/packages/modules/configuration.py
@@ -5,6 +5,7 @@
import dataclass_utils
from helpermodules.pub import Pub
+from modules.io_actions.groups import READABLE_GROUP_NAME, ActionGroup
log = logging.getLogger(__name__)
@@ -18,7 +19,8 @@ def pub_configurable():
_pub_configurable_soc_modules()
_pub_configurable_devices_components()
_pub_configurable_chargepoints()
- _pub_configurable_ripple_control_receivers()
+ _pub_configurable_io_devices()
+ _pub_configurable_io_actions()
_pub_configurable_monitoring()
@@ -307,37 +309,53 @@ def create_chargepoints_list(path_list):
log.exception("Fehler im configuration-Modul")
-def _pub_configurable_ripple_control_receivers() -> None:
+def _pub_configurable_io_devices() -> None:
try:
- ripple_control_receivers = []
- path_list = Path(_get_packages_path()/"modules"/"ripple_control_receivers").glob('**/config.py')
+ io_devices = []
+ path_list = Path(_get_packages_path()/"modules"/"io_devices").glob('**/config.py')
for path in path_list:
try:
if path.name.endswith("_test.py"):
# Tests überspringen
continue
dev_defaults = importlib.import_module(
- f".ripple_control_receivers.{path.parts[-2]}.ripple_control_receiver",
- "modules").device_descriptor.configuration_factory()
- ripple_control_receivers.append({
+ f".io_devices.{path.parts[-2]}.api", "modules").device_descriptor.configuration_factory()
+ io_devices.append({
"value": dev_defaults.type,
"text": dev_defaults.name,
"defaults": dataclass_utils.asdict(dev_defaults)
})
except Exception:
log.exception("Fehler im configuration-Modul")
- ripple_control_receivers = sorted(ripple_control_receivers, key=lambda d: d['text'].upper())
- # "leeren" Eintrag an erster Stelle einfügen
- ripple_control_receivers.insert(0,
- {
- "value": None,
- "text": "- kein RSE Modul -",
- "defaults": {
- "type": None,
- "configuration": {}
- }
- })
- Pub().pub("openWB/set/system/configurable/ripple_control_receivers", ripple_control_receivers)
+ io_devices = sorted(io_devices, key=lambda d: d['text'].upper())
+ Pub().pub("openWB/set/system/configurable/io_devices", io_devices)
+ except Exception:
+ log.exception("Fehler im configuration-Modul")
+
+
+def _pub_configurable_io_actions() -> None:
+ try:
+ action_groups = {}
+ for group in ActionGroup:
+ action_groups[group.value] = {"group_name": READABLE_GROUP_NAME[group], "actions": []}
+ path_list = Path(_get_packages_path()/"modules"/"io_actions"/group.value).glob('**/api.py')
+ for path in path_list:
+ try:
+ if path.name.endswith("_test.py"):
+ # Tests überspringen
+ continue
+ action_defaults = importlib.import_module(f".io_actions.{group.value}.{path.parts[-2]}.api",
+ "modules").device_descriptor.configuration_factory()
+ action_groups[group.value]["actions"].append({
+ "value": action_defaults.type,
+ "text": action_defaults.name,
+ "defaults": dataclass_utils.asdict(action_defaults)
+ })
+ except Exception:
+ log.exception(f"Fehler im configuration-Modul: groups: {path}")
+ action_groups[group.value]["actions"] = sorted(
+ action_groups[group.value]["actions"], key=lambda d: d['text'].upper())
+ Pub().pub("openWB/set/system/configurable/io_actions", action_groups)
except Exception:
log.exception("Fehler im configuration-Modul")
diff --git a/packages/modules/ripple_control_receivers/dimm_kit/__init__.py b/packages/modules/internal_chargepoint_handler/add_on/__init__.py
similarity index 100%
rename from packages/modules/ripple_control_receivers/dimm_kit/__init__.py
rename to packages/modules/internal_chargepoint_handler/add_on/__init__.py
diff --git a/packages/modules/internal_chargepoint_handler/add_on/api.py b/packages/modules/internal_chargepoint_handler/add_on/api.py
new file mode 100644
index 0000000000..1f84777242
--- /dev/null
+++ b/packages/modules/internal_chargepoint_handler/add_on/api.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+import logging
+from typing import Dict, Optional, Tuple
+
+from modules.common.abstract_device import DeviceDescriptor
+from modules.common.component_state import IoState
+from modules.common.configurable_io import ConfigurableIo
+from modules.common.store._io_internal import get_internal_io_value_store
+from modules.io_devices.add_on.config import AddOn, DigitalInputMapping, DigitalOutputMapping
+
+log = logging.getLogger(__name__)
+has_gpio = True
+
+try:
+ import RPi.GPIO as GPIO
+except ImportError:
+ has_gpio = False
+ log.info("failed to import RPi.GPIO! maybe we are not running on a pi")
+ log.warning("RSE disabled!")
+
+
+def create_io(config: AddOn):
+ def read() -> Tuple[bool, bool]:
+ if has_gpio:
+ return IoState(
+ digital_input={input.name: GPIO.input(input.value) == GPIO.LOW for input in DigitalInputMapping},
+ digital_output={output.name: GPIO.input(output.value) == GPIO.LOW for output in DigitalOutputMapping})
+ else:
+ return IoState()
+
+ def write(analog_output: Optional[Dict[str, int]], digital_output: Optional[Dict[str, bool]]):
+ if has_gpio:
+ for pin, value in digital_output.items():
+ GPIO.output(DigitalOutputMapping[pin].value, GPIO.HIGH if value else GPIO.LOW)
+
+ if has_gpio:
+ GPIO.setmode(GPIO.BOARD)
+ for pin in config.input["digital"].keys():
+ GPIO.setup(DigitalInputMapping[pin].value, GPIO.IN)
+ GPIO.setup([7, 16, 18], GPIO.OUT)
+
+ io = ConfigurableIo(config=config, component_reader=read, component_writer=write)
+ io.store = get_internal_io_value_store()
+ return io
+
+
+device_descriptor = DeviceDescriptor(configuration_factory=AddOn)
diff --git a/packages/modules/internal_chargepoint_handler/gpio.py b/packages/modules/internal_chargepoint_handler/gpio.py
new file mode 100644
index 0000000000..8498778bf3
--- /dev/null
+++ b/packages/modules/internal_chargepoint_handler/gpio.py
@@ -0,0 +1,32 @@
+import copy
+import logging
+from threading import Event
+import time
+
+from helpermodules.subdata import SubData
+
+
+log = logging.getLogger(__name__)
+has_gpio = True
+
+
+class InternalGpioHandler:
+ def __init__(self, event_restart_gpio: Event):
+ self.event_restart_gpio = event_restart_gpio
+
+ def loop(self):
+ if has_gpio:
+ while True:
+ if SubData.system_data.get("iolocal") is not None:
+ if self.event_restart_gpio.is_set():
+ io = SubData.system_data["iolocal"]
+ self.event_restart_gpio.clear()
+ data = copy.deepcopy(SubData.io_states)
+ log.debug(data)
+ log.setLevel(SubData.system_data["system"].data["debug_level"])
+ io.read()
+ io.store.update()
+ if "internal_io_states" in data:
+ io.write(data["internal_io_states"].data.set.analog_output,
+ data["internal_io_states"].data.set.digital_output)
+ time.sleep(3)
diff --git a/packages/modules/ripple_control_receivers/gpio/__init__.py b/packages/modules/io_actions/__init__.py
similarity index 100%
rename from packages/modules/ripple_control_receivers/gpio/__init__.py
rename to packages/modules/io_actions/__init__.py
diff --git a/packages/modules/io_actions/controllable_consumers/dimming/__init__.py b/packages/modules/io_actions/controllable_consumers/dimming/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/io_actions/controllable_consumers/dimming/api.py b/packages/modules/io_actions/controllable_consumers/dimming/api.py
new file mode 100644
index 0000000000..14d7e9008b
--- /dev/null
+++ b/packages/modules/io_actions/controllable_consumers/dimming/api.py
@@ -0,0 +1,97 @@
+import logging
+from control import data
+from helpermodules.logger import ModifyLoglevelContext
+from helpermodules.pub import Pub
+from helpermodules.timecheck import create_timestamp
+from dataclass_utils import asdict
+from modules.common.abstract_device import DeviceDescriptor
+from modules.common.abstract_io import AbstractIoAction
+from modules.io_actions.controllable_consumers.dimming.config import DimmingSetup
+
+log = logging.getLogger(__name__)
+control_command_log = logging.getLogger("steuve_control_command")
+
+
+class Dimming(AbstractIoAction):
+ def __init__(self, config: DimmingSetup):
+ self.config = config
+ self.import_power_left = None
+ for pattern in self.config.configuration.input_pattern:
+ input_matrix_list = list(pattern["input_matrix"].items())
+ if len(input_matrix_list):
+ if pattern["value"]:
+ self.dimming_input, self.dimming_value = input_matrix_list[0]
+ control_command_log.info(f"Dimmen per HEMS: Eingang {self.dimming_input} wird überwacht.")
+ if pattern["value"] is False:
+ self.no_dimming_input, self.no_dimming_value = input_matrix_list[0]
+ else:
+ control_command_log.warning("Dimmen per HEMS: Kein Eingang zum Überwachen konfiguriert.")
+
+ fixed_import_power = 0
+ for device in self.config.configuration.devices:
+ if device["type"] != "cp":
+ fixed_import_power += 4200
+ log.debug(f"Dimmen per HEMS: Fest vergebene Mindestleistung: {fixed_import_power}W")
+ if fixed_import_power != self.config.configuration.fixed_import_power:
+ self.config.configuration.fixed_import_power = fixed_import_power
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/config", asdict(self.config))
+
+ super().__init__()
+
+ def setup(self) -> None:
+ surplus = data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].calc_raw_surplus()
+ if surplus > 0:
+ self.import_power_left = self.config.configuration.max_import_power + surplus
+ else:
+ self.import_power_left = self.config.configuration.max_import_power
+ self.import_power_left -= self.config.configuration.fixed_import_power
+
+ log.debug(f"Dimmen: {self.import_power_left}W inkl. Überschuss")
+
+ with ModifyLoglevelContext(control_command_log, logging.DEBUG):
+ if data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ self.dimming_input] == self.dimming_value:
+ if self.timestamp is None:
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp())
+ control_command_log.info("Dimmen aktiviert. Leistungswerte vor Ausführung des Steuerbefehls:")
+
+ msg = (f"EVU-Zähler: "
+ f"{data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].data.get.powers}W")
+ for device in self.config.configuration.devices:
+ if device["type"] == "cp":
+ cp = f"cp{device['id']}"
+ msg += (f", Ladepunkt {data.data.cp_data[cp].data.config.name}: "
+ f"{data.data.cp_data[cp].data.get.powers}W")
+ if device["type"] == "io":
+ io = f"io{device['id']}"
+ msg += (f", {data.data.system_data[io].config.name}: "
+ "Leistung unbekannt")
+ control_command_log.info(msg)
+ elif self.timestamp:
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None)
+ control_command_log.info("Dimmen deaktiviert.")
+
+ def dimming_get_import_power_left(self) -> None:
+ if self.dimming_active():
+ return self.import_power_left
+ elif data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ self.no_dimming_input] == self.no_dimming_value:
+ return None
+ else:
+ raise Exception("Pattern passt nicht zur Dimmung.")
+
+ def dimming_set_import_power_left(self, used_power: float) -> None:
+ self.import_power_left -= used_power
+ log.debug(f"verbleibende Dimm-Leistung: {self.import_power_left}W inkl. Überschuss")
+ return self.import_power_left
+
+ def dimming_active(self) -> bool:
+ return data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ self.dimming_input] == self.dimming_value
+
+
+def create_action(config: DimmingSetup):
+ return Dimming(config=config)
+
+
+device_descriptor = DeviceDescriptor(configuration_factory=DimmingSetup)
diff --git a/packages/modules/io_actions/controllable_consumers/dimming/config.py b/packages/modules/io_actions/controllable_consumers/dimming/config.py
new file mode 100644
index 0000000000..9fc8227194
--- /dev/null
+++ b/packages/modules/io_actions/controllable_consumers/dimming/config.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+from dataclass_utils.factories import empty_io_pattern_factory, empty_list_factory
+from modules.io_actions.groups import ActionGroup
+
+
+@dataclass
+class DimmingConfig:
+ io_device: Optional[int] = None
+ input_pattern: List[Dict] = field(default_factory=empty_io_pattern_factory)
+ devices: List[Dict] = field(default_factory=empty_list_factory)
+ # [{"type": "cp", "id": 0},
+ # {"type": "io", "id": 1, "digital_output": "SofortLa"}]
+ max_import_power: int = 0
+ fixed_import_power: float = 0 # don't show in UI
+
+
+class DimmingSetup:
+ def __init__(self,
+ name: str = "Dimmen per HEMS",
+ type: str = "dimming",
+ id: int = 0,
+ configuration: DimmingConfig = None):
+ self.name = name
+ self.id = id
+ self.configuration = configuration or DimmingConfig()
+ self.type = type
+ self.group = ActionGroup.CONTROLLABLE_CONSUMERS.value
diff --git a/packages/modules/io_actions/controllable_consumers/dimming_direct_control/__init__.py b/packages/modules/io_actions/controllable_consumers/dimming_direct_control/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/io_actions/controllable_consumers/dimming_direct_control/api.py b/packages/modules/io_actions/controllable_consumers/dimming_direct_control/api.py
new file mode 100644
index 0000000000..cbeaea3fde
--- /dev/null
+++ b/packages/modules/io_actions/controllable_consumers/dimming_direct_control/api.py
@@ -0,0 +1,73 @@
+import logging
+from control import data
+from helpermodules.logger import ModifyLoglevelContext
+from helpermodules.pub import Pub
+from helpermodules.timecheck import create_timestamp
+from modules.common.abstract_device import DeviceDescriptor
+from modules.common.abstract_io import AbstractIoAction
+from modules.io_actions.controllable_consumers.dimming_direct_control.config import DimmingDirectControlSetup
+
+control_command_log = logging.getLogger("steuve_control_command")
+
+
+class DimmingDirectControl(AbstractIoAction):
+ def __init__(self, config: DimmingDirectControlSetup):
+ self.config = config
+ for pattern in self.config.configuration.input_pattern:
+ input_matrix_list = list(pattern["input_matrix"].items())
+ if len(input_matrix_list):
+ if pattern["value"]:
+ self.dimming_input, self.dimming_value = input_matrix_list[0]
+ control_command_log.info(
+ f"Dimmen per Direktsteuerung: Eingang {self.dimming_input} wird überwacht.")
+ if pattern["value"] is False:
+ self.no_dimming_input, self.no_dimming_value = input_matrix_list[0]
+ else:
+ control_command_log.warning("Dimmen per Direktsteuerung: Kein Eingang zum Überwachen konfiguriert.")
+ super().__init__()
+
+ def setup(self) -> None:
+ with ModifyLoglevelContext(control_command_log, logging.DEBUG):
+ if data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ self.dimming_input] == self.dimming_value:
+ device = self.config.configuration.devices[0]
+ if device["type"] == "cp":
+ cp = f"cp{device['id']}"
+ if self.timestamp is None:
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp())
+ if device["type"] == "cp":
+ control_command_log.info(
+ f"Direktsteuerung an Ladepunkt "
+ f"{data.data.cp_data[cp].data.config.name} aktiviert. "
+ "Leistungswerte vor Ausführung des Steuerbefehls:")
+
+ msg = (f"EVU-Zähler: "
+ f"{data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].data.get.powers}W")
+ if device["type"] == "cp":
+ msg += (f", Ladepunkt {data.data.cp_data[cp].data.config.name}: "
+ f"{data.data.cp_data[cp].data.get.powers}W")
+ if device["type"] == "io":
+ io = f"io{device['id']}"
+ msg += (f", IO-Gerät {data.data.system_data[io].config.name}: "
+ "Leistung unbekannt")
+ control_command_log.info(msg)
+ elif self.timestamp:
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None)
+ control_command_log.info("Direktsteuerung deaktiviert.")
+
+ def dimming_via_direct_control(self) -> None:
+ if data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ self.dimming_input] == self.dimming_value:
+ return 4200
+ elif data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ self.no_dimming_input] == self.no_dimming_value:
+ return None
+ else:
+ raise Exception("Pattern passt nicht zur Dimmung per Direktsteuerung.")
+
+
+def create_action(config: DimmingDirectControlSetup):
+ return DimmingDirectControl(config=config)
+
+
+device_descriptor = DeviceDescriptor(configuration_factory=DimmingDirectControlSetup)
diff --git a/packages/modules/io_actions/controllable_consumers/dimming_direct_control/config.py b/packages/modules/io_actions/controllable_consumers/dimming_direct_control/config.py
new file mode 100644
index 0000000000..7ed110d50a
--- /dev/null
+++ b/packages/modules/io_actions/controllable_consumers/dimming_direct_control/config.py
@@ -0,0 +1,26 @@
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+from dataclass_utils.factories import empty_io_pattern_factory, empty_list_factory
+from modules.io_actions.groups import ActionGroup
+
+
+@dataclass
+class DimmingDirectControlConfig:
+ io_device: Optional[int] = None
+ input_pattern: List[Dict] = field(default_factory=empty_io_pattern_factory)
+ devices: List[Dict] = field(default_factory=empty_list_factory)
+ # [{"type": "cp", "id": 0},
+ # {"type": "io", "id": 1, "digital_output": "SofortLa"}]
+
+
+class DimmingDirectControlSetup:
+ def __init__(self,
+ name: str = "Dimmen per Direktsteuerung",
+ type: str = "dimming_direct_control",
+ id: int = 0,
+ configuration: DimmingDirectControlConfig = None):
+ self.name = name
+ self.type = type
+ self.id = id
+ self.configuration = configuration or DimmingDirectControlConfig()
+ self.group = ActionGroup.CONTROLLABLE_CONSUMERS.value
diff --git a/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/__init__.py b/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/api.py b/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/api.py
new file mode 100644
index 0000000000..b17191f9a0
--- /dev/null
+++ b/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/api.py
@@ -0,0 +1,70 @@
+import logging
+from control import data
+from helpermodules.logger import ModifyLoglevelContext
+from helpermodules.pub import Pub
+from helpermodules.timecheck import create_timestamp
+from modules.common.abstract_device import DeviceDescriptor
+from modules.common.abstract_io import AbstractIoAction
+from modules.io_actions.controllable_consumers.ripple_control_receiver.config import RippleControlReceiverSetup
+
+control_command_log = logging.getLogger("steuve_control_command")
+
+
+class RippleControlReceiver(AbstractIoAction):
+ def __init__(self, config: RippleControlReceiverSetup):
+ self.config = config
+ super().__init__()
+
+ def setup(self) -> None:
+ with ModifyLoglevelContext(control_command_log, logging.DEBUG):
+ for pattern in self.config.configuration.input_pattern:
+ for digital_input, value in pattern["input_matrix"].items():
+ if data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[
+ digital_input] != value:
+ break
+ else:
+ # Alle digitalen Eingänge entsprechen dem Pattern
+ if pattern["value"] != 1:
+ if self.timestamp is None:
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp())
+ control_command_log.info(
+ f"RSE-Sperre mit Wert {pattern['value']*100}"
+ "% aktiviert. Leistungswerte vor Ausführung des Steuerbefehls:")
+
+ evu_counter = data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()]
+ msg = f"EVU-Zähler: {evu_counter.data.get.powers}W"
+ for device in self.config.configuration.devices:
+ if device["type"] == "cp":
+ cp = f"cp{device['id']}"
+ msg += (f", Ladepunkt {data.data.cp_data[cp].data.config.name}: "
+ f"{data.data.cp_data[cp].data.get.powers}W")
+ if device["type"] == "io":
+ io = f"io{device['id']}"
+ msg += (f", IO-Gerät {data.data.io_data[io].data.config.name}: "
+ "Leistung unbekannt")
+ control_command_log.info(msg)
+ break
+ else:
+ if self.timestamp:
+ Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None)
+ control_command_log.info("RSE-Sperre deaktiviert.")
+
+ def ripple_control_receiver(self) -> float:
+ for pattern in self.config.configuration.input_pattern:
+ for digital_input, value in pattern["input_matrix"].items():
+ if data.data.io_states[f"io_states{self.config.configuration.io_device}"
+ ].data.get.digital_input[digital_input] != value:
+ break
+ else:
+ # Alle digitalen Eingänge entsprechen dem Pattern
+ return pattern["value"]
+ else:
+ # Zustand entspricht keinem Pattern
+ return 0
+
+
+def create_action(config: RippleControlReceiverSetup):
+ return RippleControlReceiver(config=config)
+
+
+device_descriptor = DeviceDescriptor(configuration_factory=RippleControlReceiverSetup)
diff --git a/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/config.py b/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/config.py
new file mode 100644
index 0000000000..9a866908b5
--- /dev/null
+++ b/packages/modules/io_actions/controllable_consumers/ripple_control_receiver/config.py
@@ -0,0 +1,27 @@
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+from dataclass_utils.factories import empty_list_factory
+from modules.io_actions.groups import ActionGroup
+
+
+@dataclass
+class RippleControlReceiverConfig:
+ io_device: Optional[int] = None
+ # [{"value": 0.5, "input_matrix": {"SofortLa": False, "PV": True}}]
+ input_pattern: List[Dict] = field(default_factory=empty_list_factory)
+ devices: List[Dict] = field(default_factory=empty_list_factory)
+ # [{"type": "cp", "id": 0},
+ # {"type": "io", "id": 1, "digital_output": "SofortLa"},
+
+
+class RippleControlReceiverSetup:
+ def __init__(self,
+ name: str = "RSE-Kontakt",
+ type: str = "ripple_control_receiver",
+ id: int = 0,
+ configuration: RippleControlReceiverConfig = None):
+ self.name = name
+ self.id = id
+ self.configuration = configuration or RippleControlReceiverConfig()
+ self.type = type
+ self.group = ActionGroup.CONTROLLABLE_CONSUMERS.value
diff --git a/packages/modules/io_actions/groups.py b/packages/modules/io_actions/groups.py
new file mode 100644
index 0000000000..538025f563
--- /dev/null
+++ b/packages/modules/io_actions/groups.py
@@ -0,0 +1,10 @@
+from enum import Enum
+
+
+class ActionGroup(Enum):
+ CONTROLLABLE_CONSUMERS = "controllable_consumers"
+
+
+READABLE_GROUP_NAME = {
+ ActionGroup.CONTROLLABLE_CONSUMERS: "Steuerbare Verbrauchseinrichtungen (§14a)",
+}
diff --git a/packages/modules/io_devices/__init__.py b/packages/modules/io_devices/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/io_devices/add_on/__init__.py b/packages/modules/io_devices/add_on/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/io_devices/add_on/api.py b/packages/modules/io_devices/add_on/api.py
new file mode 100644
index 0000000000..80552614d1
--- /dev/null
+++ b/packages/modules/io_devices/add_on/api.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+import logging
+from typing import Dict, Optional, Tuple
+
+from helpermodules import pub
+from helpermodules.broker import BrokerClient
+from helpermodules.utils.topic_parser import decode_payload
+from modules.common.abstract_device import DeviceDescriptor
+from modules.common.component_state import IoState
+from modules.common.configurable_io import ConfigurableIo
+from modules.io_devices.add_on.config import AddOn
+
+log = logging.getLogger(__name__)
+
+
+def create_io(config: AddOn):
+ def read() -> Tuple[bool, bool]:
+ if config.configuration.host is None:
+ raise ValueError("No host configured")
+ return IoStateManager().get(config.configuration.host)
+
+ def write(analog_output: Optional[Dict[str, int]], digital_output: Optional[Dict[str, bool]]):
+ if config.configuration.host is None:
+ raise ValueError("No host configured")
+ pub.pub_single("openWB/set/internal_io/states/set/digital_output", digital_output,
+ hostname=config.configuration.host)
+ pub.pub_single("openWB/set/internal_io/states/set/digital_output", digital_output)
+
+ return ConfigurableIo(config=config, component_reader=read, component_writer=write)
+
+
+device_descriptor = DeviceDescriptor(configuration_factory=AddOn)
+
+
+class IoStateManager:
+ def __init__(self) -> None:
+ self.io_state = IoState()
+
+ def get(self, host: str) -> IoState:
+ BrokerClient("processBrokerBranch", self.on_connect, self.on_message, host,
+ 1886 if host == "localhost" else 1883).start_finite_loop()
+ return self.io_state
+
+ def on_connect(self, client, userdata, flags, rc):
+ """ connect to broker and subscribe to set topics
+ """
+ client.subscribe('openWB/internal_io/states/#', 2)
+
+ def on_message(self, client, userdata, msg):
+ setattr(self.io_state, msg.topic.split("/")[-1], decode_payload(msg.payload))
diff --git a/packages/modules/io_devices/add_on/config.py b/packages/modules/io_devices/add_on/config.py
new file mode 100644
index 0000000000..b7fe7cca51
--- /dev/null
+++ b/packages/modules/io_devices/add_on/config.py
@@ -0,0 +1,53 @@
+from enum import Enum
+from typing import Dict, Optional, Union
+from modules.common.io_setup import IoDeviceSetup
+
+
+class DigitalInputMapping(Enum):
+ RSE1 = 24
+ RSE2 = 21
+ nurPV = 31
+ SofortLa = 32
+ Stop = 33
+ MinPV = 36
+ Standby = 40
+
+
+class DigitalOutputMapping(Enum):
+ LED1 = 18
+ LED2 = 16
+ LED3 = 7
+
+
+class AddOnConfiguration:
+ def __init__(self, host: Optional[str] = None) -> None:
+ self.host = host
+
+
+def init_input():
+ return {"analog": {},
+ "digital": {pin.name: False for pin in DigitalInputMapping}}
+
+
+def init_output():
+ return {"analog": {},
+ "digital": {pin.name: False for pin in DigitalOutputMapping}}
+
+
+class AddOn(IoDeviceSetup[AddOnConfiguration]):
+ def __init__(self,
+ name: str = "Kontakte der AddOn-Platine",
+ type: str = "add_on",
+ id: Union[int, str] = 0,
+ configuration: AddOnConfiguration = None,
+ input: Dict[str, Dict[int, float]] = None,
+ output: Dict[str, Dict[int, float]] = None) -> None:
+ self.name = name
+ self.type = type
+ self.id = id
+ self.configuration = configuration or AddOnConfiguration()
+ if input is None:
+ input = init_input()
+ if output is None:
+ output = init_output()
+ super().__init__(name, type, id, configuration or AddOnConfiguration(), input=input, output=output)
diff --git a/packages/modules/io_devices/dimm_kit/__init__.py b/packages/modules/io_devices/dimm_kit/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/modules/io_devices/dimm_kit/api.py b/packages/modules/io_devices/dimm_kit/api.py
new file mode 100644
index 0000000000..8f87420749
--- /dev/null
+++ b/packages/modules/io_devices/dimm_kit/api.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+from typing import Dict, Optional
+from modules.common.component_state import IoState
+from modules.common.configurable_io import ConfigurableIo
+from modules.io_devices.dimm_kit.config import IoLan, AnalogInputMapping, DigitalInputMapping, DigitalOutputMapping
+from modules.common.version_by_telnet import get_version_by_telnet
+from modules.common.modbus import ModbusDataType, ModbusTcpClient_
+import logging
+import socket
+
+from modules.common.abstract_device import DeviceDescriptor
+
+log = logging.getLogger(__name__)
+
+
+VALID_VERSIONS = ["openWB DimmModul"]
+
+
+def create_io(config: IoLan):
+ def read():
+ nonlocal version
+ if version is False:
+ try:
+ parsed_answer = get_version_by_telnet(VALID_VERSIONS[0], config.configuration.host)
+ for version in VALID_VERSIONS:
+ if version in parsed_answer:
+ version = True
+ log.debug("Firmware des openWB Dimm-& Control-Kit ist mit openWB software2 kompatibel.")
+ else:
+ version = False
+ raise ValueError
+ except (ConnectionRefusedError, ValueError):
+ log.exception("Dimm-Kit")
+ raise Exception("Firmware des openWB Dimm-& Control-Kit ist nicht mit openWB software2 kompatibel. "
+ "Bitte den Support kontaktieren.")
+ except socket.timeout:
+ log.exception("Dimm-Kit")
+ raise Exception("Die IP-Adresse ist nicht erreichbar. Bitte überprüfe die Einstellungen.")
+ return IoState(
+ # analog inputs are configured as 0-5V (AI1-AI4) and 0-25mA (AI5-AI8) as default
+ # the values are reported as integers in range of 0-1024
+ analog_input={
+ pin.name: client.read_input_registers(
+ pin.value, ModbusDataType.UINT_16, unit=config.configuration.modbus_id
+ ) * 5 for pin in AnalogInputMapping},
+ digital_input={
+ pin.name: client.read_coils(
+ pin.value, 1, unit=config.configuration.modbus_id
+ ) for pin in DigitalInputMapping},
+ digital_output={
+ pin.name: client.read_coils(
+ pin.value, 1, unit=config.configuration.modbus_id
+ ) for pin in DigitalOutputMapping})
+
+ def write(analog_output: Optional[Dict[str, int]], digital_output: Optional[Dict[str, int]]) -> None:
+ for i, value in digital_output.items():
+ client.write_single_coil(DigitalOutputMapping[i].value, value, unit=config.configuration.modbus_id)
+
+ version = False
+ client = ModbusTcpClient_(config.configuration.host, config.configuration.port)
+ for output, value in config.output["digital"].items():
+ client.write_single_coil(DigitalOutputMapping[output].value, value, unit=config.configuration.modbus_id)
+ return ConfigurableIo(config=config, component_reader=read, component_writer=write)
+
+
+device_descriptor = DeviceDescriptor(configuration_factory=IoLan)
diff --git a/packages/modules/io_devices/dimm_kit/config.py b/packages/modules/io_devices/dimm_kit/config.py
new file mode 100644
index 0000000000..d2e888932a
--- /dev/null
+++ b/packages/modules/io_devices/dimm_kit/config.py
@@ -0,0 +1,71 @@
+from enum import Enum
+from typing import Dict, Optional
+
+from helpermodules.auto_str import auto_str
+from modules.common.io_setup import IoDeviceSetup
+
+
+class AnalogInputMapping(Enum):
+ AI1 = 0x00
+ AI2 = 0x01
+ AI3 = 0x02
+ AI4 = 0x03
+ AI5 = 0x04
+ AI6 = 0x05
+ AI7 = 0x06
+ AI8 = 0x07
+
+
+class DigitalInputMapping(Enum):
+ DI1 = 0x00
+ DI2 = 0x01
+ DI3 = 0x02
+ DI4 = 0x03
+ DI5 = 0x04
+ DI6 = 0x05
+ DI7 = 0x06
+ DI8 = 0x07
+
+
+class DigitalOutputMapping(Enum):
+ DO1 = 0x10
+ DO2 = 0x11
+ DO3 = 0x12
+ DO4 = 0x13
+ DO5 = 0x14
+ DO6 = 0x15
+ DO7 = 0x16
+ DO8 = 0x17
+
+
+class IoLanConfiguration:
+ def __init__(self, host: Optional[str] = None, port: int = 8899, modbus_id: int = 1):
+ self.host = host
+ self.port = port
+ self.modbus_id = modbus_id
+
+
+def init_input():
+ return {"analog": {pin.name: None for pin in AnalogInputMapping},
+ "digital": {pin.name: False for pin in DigitalInputMapping}}
+
+
+def init_output():
+ return {"analog": {},
+ "digital": {pin.name: False for pin in DigitalOutputMapping}}
+
+
+@auto_str
+class IoLan(IoDeviceSetup[IoLanConfiguration]):
+ def __init__(self,
+ name: str = "openWB Dimm- & Control-Kit",
+ type: str = "dimm_kit",
+ id: int = 0,
+ configuration: IoLanConfiguration = None,
+ input: Dict[str, Dict[int, float]] = None,
+ output: Dict[str, Dict[int, float]] = None) -> None:
+ if input is None:
+ input = init_input()
+ if output is None:
+ output = init_output()
+ super().__init__(name, type, id, configuration or IoLanConfiguration(), input=input, output=output)
diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py
index 4036c4e934..507f6a28a5 100644
--- a/packages/modules/loadvars.py
+++ b/packages/modules/loadvars.py
@@ -3,6 +3,7 @@
from typing import List
from control import data
+from modules.common.abstract_io import AbstractIoDevice
from modules.utils import wait_for_module_update_completed
from modules.common.abstract_device import AbstractDevice
from modules.common.component_type import ComponentType, type_to_topic_mapping
@@ -28,8 +29,8 @@ def get_values(self) -> None:
wait_for_module_update_completed(self.event_module_update_completed, topic)
data.data.copy_module_data()
wait_for_module_update_completed(self.event_module_update_completed, topic)
- joined_thread_handler(self._get_general(), data.data.general_data.data.control_interval/3)
- joined_thread_handler(self._set_general(), data.data.general_data.data.control_interval/3)
+ joined_thread_handler(self._get_io(), data.data.general_data.data.control_interval/3)
+ joined_thread_handler(self._set_io(), data.data.general_data.data.control_interval/3)
wait_for_module_update_completed(self.event_module_update_completed, topic)
except Exception:
log.exception("Fehler im loadvars-Modul")
@@ -84,27 +85,33 @@ def thread_without_set_value(self,
return True
return False
- def _get_general(self) -> List[threading.Thread]:
+ def _get_io(self) -> List[threading.Thread]:
threads = [] # type: List[threading.Thread]
try:
- # Beim ersten Durchlauf wird in jedem Fall eine Exception geworfen,
- # da die Daten erstmalig ins data-Modul kopiert werden müssen.
- if data.data.general_data.data.ripple_control_receiver.module:
- threads.append(
- threading.Thread(target=data.data.general_data.ripple_control_receiver.update,
- args=(), name="get ripple control receiver"))
+ for io_device in data.data.system_data.values():
+ try:
+ if isinstance(io_device, AbstractIoDevice):
+ threads.append(
+ threading.Thread(target=io_device.read,
+ args=(), name="get io state"))
+ except Exception:
+ log.exception("Fehler im loadvars-Modul")
except Exception:
log.exception("Fehler im loadvars-Modul")
finally:
return threads
- def _set_general(self) -> List[threading.Thread]:
+ def _set_io(self) -> List[threading.Thread]:
threads = [] # type: List[threading.Thread]
try:
- if data.data.general_data.data.ripple_control_receiver.module:
- threads.append(threading.Thread(target=update_values,
- args=(data.data.general_data.ripple_control_receiver,),
- name="set ripple control receiver"))
+ for io_device in data.data.system_data.values():
+ try:
+ if isinstance(io_device, AbstractIoDevice):
+ threads.append(threading.Thread(target=update_values,
+ args=(io_device,),
+ name="publish io state"))
+ except Exception:
+ log.exception("Fehler im loadvars-Modul")
except Exception:
log.exception("Fehler im loadvars-Modul")
finally:
diff --git a/packages/modules/ripple_control_receivers/dimm_kit/config.py b/packages/modules/ripple_control_receivers/dimm_kit/config.py
deleted file mode 100644
index 12d10967fb..0000000000
--- a/packages/modules/ripple_control_receivers/dimm_kit/config.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from typing import Optional
-
-
-class IoLanRcrConfiguration:
- def __init__(self, ip_address: Optional[str] = None, port: int = 8899, modbus_id: int = 1):
- self.ip_address = ip_address
- self.port = port
- self.modbus_id = modbus_id
-
-
-class IoLanRcr:
- def __init__(self,
- name: str = "openWB Dimm- & Control-Kit",
- type: str = "dimm_kit",
- configuration: IoLanRcrConfiguration = None) -> None:
- self.name = name
- self.type = type
- self.configuration = configuration or IoLanRcrConfiguration()
diff --git a/packages/modules/ripple_control_receivers/dimm_kit/ripple_control_receiver.py b/packages/modules/ripple_control_receivers/dimm_kit/ripple_control_receiver.py
deleted file mode 100644
index cd7e6f13c8..0000000000
--- a/packages/modules/ripple_control_receivers/dimm_kit/ripple_control_receiver.py
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/usr/bin/env python3
-from enum import Enum
-import logging
-import socket
-
-from modules.common.abstract_device import DeviceDescriptor
-from modules.common.component_state import RcrState
-from modules.common.modbus import ModbusTcpClient_
-from modules.common.version_by_telnet import get_version_by_telnet
-from modules.ripple_control_receivers.dimm_kit.config import IoLanRcr
-
-log = logging.getLogger(__name__)
-
-
-class State(Enum):
- OPENED = False
- CLOSED = True
-
-
-VALID_VERSIONS = ["openWB DimmModul"]
-
-
-def create_ripple_control_receiver(config: IoLanRcr):
- def updater():
- nonlocal version
- if version is False:
- try:
- parsed_answer = get_version_by_telnet(VALID_VERSIONS[0], config.configuration.ip_address)
- for version in VALID_VERSIONS:
- if version in parsed_answer:
- version = True
- log.debug("Firmware des openWB Dimm-& Control-Kit ist mit openWB software2 kompatibel.")
- else:
- version = False
- raise ValueError
- except (ConnectionRefusedError, ValueError):
- log.exception("Dimm-Kit")
- raise Exception("Firmware des openWB Dimm-& Control-Kit ist nicht mit openWB software2 kompatibel. "
- "Bitte den Support kontaktieren.")
- except socket.timeout:
- log.exception("Dimm-Kit")
- raise Exception("Die IP-Adresse ist nicht erreichbar. Bitte den Support kontaktieren.")
- r1 = State(client.read_coils(0x0000, 1, unit=config.configuration.modbus_id))
- r2 = State(client.read_coils(0x0001, 1, unit=config.configuration.modbus_id))
- log.debug(f"RSE-Kontakt 1: {r1}, RSE-Kontakt 2: {r2}")
- if r1 == State.OPENED or r2 == State.OPENED:
- override_value = 0
- else:
- override_value = 100
- return RcrState(override_value=override_value)
-
- version = False
- client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port)
- return updater
-
-
-device_descriptor = DeviceDescriptor(configuration_factory=IoLanRcr)
diff --git a/packages/modules/ripple_control_receivers/gpio/config.py b/packages/modules/ripple_control_receivers/gpio/config.py
deleted file mode 100644
index aa2aac6aca..0000000000
--- a/packages/modules/ripple_control_receivers/gpio/config.py
+++ /dev/null
@@ -1,13 +0,0 @@
-class GpioRcrConfiguration:
- def __init__(self):
- pass
-
-
-class GpioRcr:
- def __init__(self,
- name: str = "GPIOs auf der AddOn-Platine",
- type: str = "gpio",
- configuration: GpioRcrConfiguration = None) -> None:
- self.name = name
- self.type = type
- self.configuration = configuration or GpioRcrConfiguration()
diff --git a/packages/modules/ripple_control_receivers/gpio/ripple_control_receiver.py b/packages/modules/ripple_control_receivers/gpio/ripple_control_receiver.py
deleted file mode 100644
index a65ab24705..0000000000
--- a/packages/modules/ripple_control_receivers/gpio/ripple_control_receiver.py
+++ /dev/null
@@ -1,49 +0,0 @@
-#!/usr/bin/env python3
-import logging
-from typing import Tuple
-
-from modules.common.abstract_device import DeviceDescriptor
-from modules.common.component_state import RcrState
-from modules.ripple_control_receivers.gpio.config import GpioRcr
-
-log = logging.getLogger(__name__)
-has_gpio = True
-
-try:
- import RPi.GPIO as GPIO
-except ImportError:
- has_gpio = False
- log.info("failed to import RPi.GPIO! maybe we are not running on a pi")
- log.warning("RSE disabled!")
-
-
-def read() -> Tuple[bool, bool]:
- rse1: bool = False
- rse2: bool = False
-
- if has_gpio:
- GPIO.setmode(GPIO.BOARD)
- GPIO.setup(24, GPIO.IN, pull_up_down=GPIO.PUD_UP)
- GPIO.setup(21, GPIO.IN, pull_up_down=GPIO.PUD_UP)
-
- try:
- rse1 = GPIO.input(24) == GPIO.LOW
- rse2 = GPIO.input(21) == GPIO.LOW
- except Exception as e:
- GPIO.cleanup()
- raise e
- log.debug(f"RSE-Kontakt 1: {rse1}, RSE-Kontakt 2: {rse2}")
- if rse1 or rse2:
- override_value = 0
- else:
- override_value = 100
- return RcrState(override_value=override_value)
-
-
-def create_ripple_control_receiver(config: GpioRcr):
- def updater():
- return read()
- return updater
-
-
-device_descriptor = DeviceDescriptor(configuration_factory=GpioRcr)
diff --git a/packages/modules/update_soc_test.py b/packages/modules/update_soc_test.py
index 0a3930d620..cbaee98aa1 100644
--- a/packages/modules/update_soc_test.py
+++ b/packages/modules/update_soc_test.py
@@ -22,7 +22,7 @@
def mock_data() -> None:
data.data_init(Mock())
- SubData(*([Mock()]*18))
+ SubData(*([Mock()]*19))
SubData.cp_data = {"cp0": Mock(spec=ChargepointStateUpdate, chargepoint=Mock(
spec=Chargepoint,
id=id,