From b2793e0a52136fc3f788bca35f89afdf68674f3b Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 22 Oct 2026 15:38:17 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Verkn=C3=BCpfte=20Z=C3=A4hler=20k=C3=B6nnen?= =?UTF-8?q?=20nicht=20mehr=20gel=C3=B6scht=20werden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/helpermodules/command.py | 19 ++++++++++ packages/helpermodules/command_test.py | 48 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 34486c9324..ea0cfe4fdd 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -215,6 +215,11 @@ def removeDevice(self, connection_id: str, payload: dict) -> None: """ löscht ein Device. """ if self.max_id_device >= payload["data"]["id"]: + device = SubData.system_data.get(f'device{payload["data"]["id"]}') + if device is not None: + for component in device.components.values(): + if self._prevent_linked_meter_removal(connection_id, payload, component.component_config.id): + return ProcessBrokerBranch(f'system/device/{payload["data"]["id"]}/').remove_topics() pub_user_message(payload, connection_id, f'Gerät mit ID \'{payload["data"]["id"]}\' gelöscht.', MessageType.SUCCESS) @@ -727,12 +732,26 @@ def set_default(topic: str, defaults: dict): f'Neue Komponente vom Typ \'{payload["data"]["type"]}\' mit ID \'{new_id}\' hinzugefügt.', MessageType.SUCCESS) + def _prevent_linked_meter_removal(self, connection_id: str, payload: dict, component_id: int) -> bool: + for consumer in SubData.consumer_data.values(): + if consumer.data.extra_meter is not None and consumer.data.extra_meter == component_id: + pub_user_message( + payload, connection_id, + f'Zähler mit ID \'{component_id}\' kann nicht gelöscht werden, da er beim Verbraucher ' + f'\'{consumer.data.module.name}\' als separate Leistungsmessung zugeordnet ist. ' + 'Bitte zuerst die Zuordnung beim Verbraucher ändern.', + MessageType.ERROR) + return True + return False + def removeComponent(self, connection_id: str, payload: dict) -> None: """ löscht eine Komponente. """ if self.max_id_hierarchy < payload["data"]["id"]: log.error(payload, connection_id, "Die ID ist größer als die maximal vergebene ID.", MessageType.ERROR) + if self._prevent_linked_meter_removal(connection_id, payload, payload["data"]["id"]): + return branch = f'system/device/{payload["data"]["deviceId"]}/component/{payload["data"]["id"]}/' ProcessBrokerBranch(branch).remove_topics() # remove ACL roles for component access, if user management is active diff --git a/packages/helpermodules/command_test.py b/packages/helpermodules/command_test.py index 95dd27d88c..8a20864c0d 100644 --- a/packages/helpermodules/command_test.py +++ b/packages/helpermodules/command_test.py @@ -21,6 +21,54 @@ pytest_plugins = ["helpermodules.measurement_logging.process_log_testdata", "helpermodules.command_test_data"] +@pytest.mark.parametrize("command_name", ["removeComponent", "removeDevice"]) +@pytest.mark.parametrize("meter_id", [0, 4]) +@pytest.mark.parametrize("linked_meter", [None, 0, 4, 9]) +def test_remove_linked_consumer_meter(command_name, meter_id, linked_meter, monkeypatch): + c = Command.__new__(Command) + c.max_id_device = 2 + c.max_id_hierarchy = 10 + monkeypatch.setattr(SubData, "consumer_data", { + "consumer8": SimpleNamespace(data=SimpleNamespace( + extra_meter=linked_meter, module=SimpleNamespace(name="Wärmepumpe"))), + }) + monkeypatch.setattr(SubData, "system_data", { + "device2": SimpleNamespace(components={ + "component7": SimpleNamespace(component_config=SimpleNamespace(id=7)), + f"component{meter_id}": SimpleNamespace(component_config=SimpleNamespace(id=meter_id)), + }), + "system": SimpleNamespace(data={"security": {"user_management_active": False}}), + }) + broker_branch = Mock() + message = Mock() + remove_role = Mock() + monkeypatch.setattr(command, "ProcessBrokerBranch", broker_branch) + monkeypatch.setattr(command, "pub_user_message", message) + monkeypatch.setattr(command, "remove_acl_role", remove_role) + data = {"id": 2} if command_name == "removeDevice" else { + "deviceId": 2, "id": meter_id, "type": "counter", + } + payload = {"data": data} + + getattr(c, command_name)("test", payload) + + if linked_meter == meter_id: + broker_branch.assert_not_called() + remove_role.assert_not_called() + message.assert_called_once() + args = message.call_args.args + assert args[:2] == (payload, "test") + assert args[3] == command.MessageType.ERROR + assert "Wärmepumpe" in args[2] + assert "separate Leistungsmessung" in args[2] + else: + branch = "system/device/2/" if command_name == "removeDevice" else ( + f"system/device/2/component/{meter_id}/") + broker_branch.assert_called_once_with(branch) + broker_branch.return_value.remove_topics.assert_called_once_with() + assert message.call_args.args[3] == command.MessageType.SUCCESS + + @pytest.fixture def subdata_fixture() -> None: SubData(*([Mock()]*16)) From ae7cfefe8c30cbc86caf3ecf61ade9145a8f565b Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 23 Oct 2026 09:01:55 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Existenzpr=C3=BCfung=20beim=20Wechsel=20ode?= =?UTF-8?q?r=20Entfernen=20der=20Z=C3=A4hlerzuordnung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/helpermodules/subdata.py | 3 +++ packages/helpermodules/subdata_test.py | 23 ++++++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 30c7dc0471..26f20853d3 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -1316,6 +1316,9 @@ def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], self.set_json_payload_class(var[f"consumer{index}"].data, msg) if self.event_subdata_initialized.is_set() and old_extra_meter != var[ f"consumer{index}"].data.extra_meter: + # Only restore the previous meter if its configuration still exists. + if f"counter{old_extra_meter}" not in self.counter_data: + old_extra_meter = None if self.counter_all_data.update_linked_counter_hierarchy( int(index), old_extra_meter, diff --git a/packages/helpermodules/subdata_test.py b/packages/helpermodules/subdata_test.py index 1720e89884..e4912de932 100644 --- a/packages/helpermodules/subdata_test.py +++ b/packages/helpermodules/subdata_test.py @@ -130,7 +130,9 @@ def test_process_consumer_extra_meter_removes_linked_counter_from_hierarchy(monk mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) -def test_process_consumer_extra_meter_adds_unlinked_counter_to_hierarchy(monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("counter_exists", [True, False]) +def test_process_consumer_extra_meter_adds_unlinked_counter_to_hierarchy(counter_exists: bool, + monkeypatch: pytest.MonkeyPatch, mock_pub: Mock): subdata = SubData.__new__(SubData) subdata.event_subdata_initialized = Mock() @@ -145,6 +147,7 @@ def test_process_consumer_extra_meter_adds_unlinked_counter_to_hierarchy(monkeyp consumers = {"consumer2": Consumer(2)} consumers["consumer2"].data.extra_meter = 5 msg = Mock(topic="openWB/consumer/2/extra_meter", payload=b"null") + monkeypatch.setattr(SubData, "counter_data", {"counter5": Mock()} if counter_exists else {}) subdata.process_consumer_topic(Mock(), consumers, msg) @@ -154,13 +157,17 @@ def test_process_consumer_extra_meter_adds_unlinked_counter_to_hierarchy(monkeyp "type": "counter", "children": [ {"id": 2, "type": "consumer", "children": []}, - {"id": 5, "type": "counter", "children": []}, - ], + ] + ([{"id": 5, "type": "counter", "children": []}] if counter_exists else []), }] - mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) + if counter_exists: + mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) + else: + mock_pub.pub.assert_not_called() -def test_process_consumer_extra_meter_switches_linked_counter_in_hierarchy(monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("counter_exists", [True, False]) +def test_process_consumer_extra_meter_switches_linked_counter_in_hierarchy(counter_exists: bool, + monkeypatch: pytest.MonkeyPatch, mock_pub: Mock): subdata = SubData.__new__(SubData) subdata.event_subdata_initialized = Mock() @@ -178,6 +185,9 @@ def test_process_consumer_extra_meter_switches_linked_counter_in_hierarchy(monke consumers = {"consumer2": Consumer(2)} consumers["consumer2"].data.extra_meter = 5 msg = Mock(topic="openWB/consumer/2/extra_meter", payload=b"6") + monkeypatch.setattr(SubData, "counter_data", { + "counter6": Mock(), **({"counter5": Mock()} if counter_exists else {}), + }) subdata.process_consumer_topic(Mock(), consumers, msg) @@ -187,8 +197,7 @@ def test_process_consumer_extra_meter_switches_linked_counter_in_hierarchy(monke "type": "counter", "children": [ {"id": 2, "type": "consumer", "children": []}, - {"id": 5, "type": "counter", "children": []}, - ], + ] + ([{"id": 5, "type": "counter", "children": []}] if counter_exists else []), }] mock_pub.pub.assert_called_once_with("openWB/set/counter/get/hierarchy", counter_all_data.data.get.hierarchy) From 972fd72da6170069a31821ee431ac95fee384388 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 23 Oct 2026 10:15:08 +0200 Subject: [PATCH 3/5] =?UTF-8?q?in=20Hierarchie=20enthaltene=20mit=20Verbra?= =?UTF-8?q?uchern=20verkn=C3=BCpfte=20Z=C3=A4hler=20entfernt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../control/counter_all/counter_all_data.py | 4 +-- packages/control/counter_all/hierarchy.py | 24 +++++++------ .../control/counter_all/hierarchy_test.py | 35 +++++++++++++++++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index 2290372a07..e97ce145df 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -77,8 +77,8 @@ def _add_item_aside(self, child: Dict, current_entry: Dict, id_to_find: int, def _add_item_below(self, child: Dict, current_entry: Dict, id_to_find: int, new_id: int, new_type: ComponentType) -> bool: ... - def _add_missing_entries(self): ... - def _delete_obsolete_entries(self): ... + def _add_missing_entries(self, linked_extra_meter_counter_ids: List[int]): ... + def _delete_obsolete_entries(self, linked_extra_meter_counter_ids: List[int]): ... def _edit_element_in_hierarchy(self, current_entry: Dict, id_to_find: int, func: Callable, *args) -> bool: ... def _get_all_counter_in_branch(self, child: Dict, id_to_find: int) -> bool: ... def _get_all_cp_connected_to_counter(self, child: Dict) -> None: ... diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py index ebf8dacffa..ea72d654bc 100644 --- a/packages/control/counter_all/hierarchy.py +++ b/packages/control/counter_all/hierarchy.py @@ -341,12 +341,17 @@ def get_non_hybrid_bat_ids(self: HierarchyProtocol) -> List[int]: def validate_hierarchy(self: HierarchyProtocol): try: - self._delete_obsolete_entries() - self._add_missing_entries() + linked_extra_meter_counter_ids = { + consumer.data.extra_meter + for consumer in data.data.consumer_data.values() + if consumer.data.extra_meter is not None + } + self._delete_obsolete_entries(linked_extra_meter_counter_ids) + self._add_missing_entries(linked_extra_meter_counter_ids) except Exception: log.exception("Fehler bei der Validierung der Hierarchie") - def _delete_obsolete_entries(self: HierarchyProtocol): + def _delete_obsolete_entries(self: HierarchyProtocol, linked_extra_meter_counter_ids: List[int]): def check_and_remove(name, type_name: ComponentType, data_structure): if element["type"] == type_name.value: if f"{name}{element['id']}" not in data_structure: @@ -354,6 +359,11 @@ def check_and_remove(name, type_name: ComponentType, data_structure): pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" " aus der Hierarchie entfernt, da keine gültige Konfiguration gefunden wurde.", MessageType.WARNING) + elif type_name == ComponentType.COUNTER and element["id"] in linked_extra_meter_counter_ids: + self.hierarchy_remove_item(element["id"]) + pub_system_message({}, f"Zähler mit ID {element['id']} wurde aus der Hierarchie entfernt, da er " + "einem Verbraucher als separate Leistungsmessung zugeordnet ist.", + MessageType.WARNING) for level in self.get_list_of_elements_per_level(): for element in level: @@ -363,13 +373,7 @@ def check_and_remove(name, type_name: ComponentType, data_structure): check_and_remove("pv", ComponentType.INVERTER, data.data.pv_data) check_and_remove("consumer", ComponentType.CONSUMER, data.data.consumer_data) - def _add_missing_entries(self: HierarchyProtocol): - linked_extra_meter_counter_ids = { - consumer.data.extra_meter - for consumer in data.data.consumer_data.values() - if consumer.data.extra_meter is not None - } - + def _add_missing_entries(self: HierarchyProtocol, linked_extra_meter_counter_ids: List[int]): def check_and_add(type_name: ComponentType, data_structure): for entry in data_structure: break_flag = False diff --git a/packages/control/counter_all/hierarchy_test.py b/packages/control/counter_all/hierarchy_test.py index 2f0d73d418..74bbd34915 100644 --- a/packages/control/counter_all/hierarchy_test.py +++ b/packages/control/counter_all/hierarchy_test.py @@ -326,7 +326,7 @@ def test_delete_obsolete_entries(hierarchy, data_): counter_all.data.get.hierarchy = hierarchy # execution - counter_all._delete_obsolete_entries() + counter_all._delete_obsolete_entries(set()) # evaluation assert counter_all.data.get.hierarchy == hierarchy_nested().data.get.hierarchy @@ -398,12 +398,41 @@ def test_add_missing_entries(hierarchy, expected_hierarchy, data_, monkeypatch): monkeypatch.setattr(CounterAll, "get_evu_counter", mock_get_evu_counter) # execution - counter_all._add_missing_entries() + counter_all._add_missing_entries(set()) # evaluation assert counter_all.data.get.hierarchy == expected_hierarchy +@pytest.mark.parametrize("linked_meter_in_hierarchy", [False, True]) +def test_validate_hierarchy_removes_linked_counter_and_keeps_children(linked_meter_in_hierarchy, monkeypatch): + data.data_init(Mock()) + counters = {f"counter{i}": Mock(spec=Counter, num=i) for i in (0, 6, 8)} + consumers = {"consumer7": Mock(spec=Consumer, data=Mock(spec=ConsumerData, extra_meter=6))} + data.data.counter_data = counters + data.data.consumer_data = consumers + data.data.bat_data = {} + data.data.cp_data = {} + data.data.pv_data = {} + children = [ + {"id": 7, "type": "consumer", "children": []}, + {"id": 8, "type": "counter", "children": []}, + ] + counter_all = CounterAll() + counter_all.data.get.hierarchy = [{ + "id": 0, "type": "counter", "children": ( + [{"id": 6, "type": "counter", "children": children}] if linked_meter_in_hierarchy else children), + }] + monkeypatch.setattr(CounterAll, "get_evu_counter", Mock(return_value=counters["counter0"])) + + counter_all.validate_hierarchy() + counter_all.validate_hierarchy() + + assert counter_all.data.get.hierarchy == [{"id": 0, "type": "counter", "children": children}] + assert data.data.counter_data == counters + assert data.data.consumer_data["consumer7"].data.extra_meter == 6 + + def test_add_missing_entries_re_adds_removed_linked_counter(monkeypatch): # setup data.data_init(Mock()) @@ -429,7 +458,7 @@ def test_add_missing_entries_re_adds_removed_linked_counter(monkeypatch): monkeypatch.setattr(CounterAll, "get_evu_counter", Mock(return_value=c)) # execution - counter_all._add_missing_entries() + counter_all._add_missing_entries({6}) # evaluation assert counter_all.data.get.hierarchy == [{ From 727f7b8fdce4f415e7908df262b76ce1220edb7a Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:41:20 +0200 Subject: [PATCH 4/5] Potential fix for pull request finding 'Avoid None module dereference when removing linked counters' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/helpermodules/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index ea0cfe4fdd..5567333bbc 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -738,7 +738,7 @@ def _prevent_linked_meter_removal(self, connection_id: str, payload: dict, compo pub_user_message( payload, connection_id, f'Zähler mit ID \'{component_id}\' kann nicht gelöscht werden, da er beim Verbraucher ' - f'\'{consumer.data.module.name}\' als separate Leistungsmessung zugeordnet ist. ' + f'\'{consumer.data.module.name if consumer.data.module is not None else "Verbraucher"}\' als separate Leistungsmessung zugeordnet ist. ' 'Bitte zuerst die Zuordnung beim Verbraucher ändern.', MessageType.ERROR) return True From 1564d864b751cc426b3f7d16143b3528ac9c06e9 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 23 Oct 2026 11:43:38 +0200 Subject: [PATCH 5/5] flake8 --- packages/helpermodules/command.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 5567333bbc..d6526dc066 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -738,8 +738,8 @@ def _prevent_linked_meter_removal(self, connection_id: str, payload: dict, compo pub_user_message( payload, connection_id, f'Zähler mit ID \'{component_id}\' kann nicht gelöscht werden, da er beim Verbraucher ' - f'\'{consumer.data.module.name if consumer.data.module is not None else "Verbraucher"}\' als separate Leistungsmessung zugeordnet ist. ' - 'Bitte zuerst die Zuordnung beim Verbraucher ändern.', + f'\'{consumer.data.module.name if consumer.data.module is not None else "Verbraucher"}\' als ' + 'separate Leistungsmessung zugeordnet ist. Bitte zuerst die Zuordnung beim Verbraucher ändern.', MessageType.ERROR) return True return False