diff --git a/packages/modules/consumers/ems_esp/ems_esp/consumer.py b/packages/modules/consumers/ems_esp/ems_esp/consumer.py index b19460b451..17e1bbbe8a 100644 --- a/packages/modules/consumers/ems_esp/ems_esp/consumer.py +++ b/packages/modules/consumers/ems_esp/ems_esp/consumer.py @@ -2,6 +2,8 @@ import logging from typing import Optional +import requests + from modules.common import req from modules.common.abstract_device import DeviceDescriptor from modules.common.component_state import ConsumerState @@ -10,23 +12,46 @@ log = logging.getLogger(__name__) +# EMS-ESP führt eine reine Wärmepumpe nicht zwingend unter "heatpump" (ursprüngliche Annahme +# dieses Moduls) - zB Bosch Compress 6800i liegt unter "boiler", eigentlich für Gasthermen o.ä. +# gedacht (Issue #4282). +DEVICE_NAMES = ("heatpump", "boiler") + def create_consumer(config: EmsEsp): session: Optional[req.CustomSession] = None - base_url: str = "" + base_url: Optional[str] = None headers: dict = {} def initializer(): - nonlocal session, base_url, headers + nonlocal session, headers session = req.get_http_session() - # "heatpump" ist der EMS-ESP-Gerätename für eine reine Wärmepumpe; Hybridanlagen mit - # Gas-/Ölkessel laufen zB unter "boiler" (https://emsesp.org/All-Entities). - base_url = f"http://{config.configuration.ip_address}/api/heatpump" headers = {"Content-Type": "application/json", "Authorization": f"Bearer {config.configuration.token}"} + def _detect_base_url() -> str: + for device_name in DEVICE_NAMES: + url = f"http://{config.configuration.ip_address}/api/{device_name}" + try: + values = session.get(url, headers=headers).json() + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 404: + continue + raise + if "hpcurrpower" in values: + return url + raise ValueError( + f"Keine Wärmepumpen-Entity 'hpcurrpower' unter {DEVICE_NAMES} auf " + f"{config.configuration.ip_address} gefunden.") + + def _get_base_url() -> str: + nonlocal base_url + if base_url is None: + base_url = _detect_base_url() + return base_url + def _write_entity(entity: str, value) -> None: - session.post(f"{base_url}/{entity}", json={"value": value}, headers=headers) + session.post(f"{_get_base_url()}/{entity}", json={"value": value}, headers=headers) def switch_on() -> None: _write_entity("hpin4opt", "1xxxxxxxxxxx") @@ -36,12 +61,13 @@ def switch_off() -> None: _write_entity("hpin1opt", "0xxxxxxxxxxxxxx") def update() -> ConsumerState: + url = _get_base_url() # fehlender Schlüssel (zB falsches Modell) kommt als 200 OK zurück, nicht als HTTP-Fehler - # ohne diese Prüfung würde das unbemerkt als 0W durchgehen. - values = session.get(base_url, headers=headers).json() + values = session.get(url, headers=headers).json() if "hpcurrpower" not in values: raise ValueError( - f"Entity 'hpcurrpower' nicht in der Antwort von {base_url} enthalten - " + f"Entity 'hpcurrpower' nicht in der Antwort von {url} enthalten - " f"vorhandene Werte: {list(values.keys())}") power = float(values["hpcurrpower"]) return ConsumerState(power=power) diff --git a/packages/modules/consumers/ems_esp/ems_esp/consumer_test.py b/packages/modules/consumers/ems_esp/ems_esp/consumer_test.py new file mode 100644 index 0000000000..07b00b1208 --- /dev/null +++ b/packages/modules/consumers/ems_esp/ems_esp/consumer_test.py @@ -0,0 +1,86 @@ +from unittest.mock import Mock + +import pytest +import requests +import requests_mock + +from modules.common import configurable_consumer +from modules.consumers.ems_esp.ems_esp.config import EmsEsp, EmsEspConfiguration +from modules.consumers.ems_esp.ems_esp.consumer import create_consumer + +SAMPLE_IP = "1.1.1.1" + + +def _create_consumer(monkeypatch): + # ConfigurableConsumer.__init__ needs a store, uninteresting for these tests + monkeypatch.setattr(configurable_consumer, "get_component_value_store", Mock()) + config = EmsEsp(configuration=EmsEspConfiguration(ip_address=SAMPLE_IP, token="testtoken")) + return create_consumer(config) + + +def test_update_uses_heatpump_entity_when_available(monkeypatch, requests_mock: requests_mock.Mocker): + requests_mock.get(f"http://{SAMPLE_IP}/api/heatpump", json={"hpcurrpower": 42}) + consumer = _create_consumer(monkeypatch) + + state = consumer.module_updater() + + assert state.power == 42 + # 2 calls on this first cycle: the detection probe, then update()'s own fetch for the real + # reading - no /api/boiler request at all, since detection never needed to fall back to it + assert requests_mock.call_count == 2 + assert all(c.url.endswith("/api/heatpump") for c in requests_mock.request_history) + + +def test_update_falls_back_to_boiler_when_heatpump_device_unknown(monkeypatch, requests_mock: requests_mock.Mocker): + # matches issue #4282: Bosch Compress 6800i has no "heatpump" device, its hp* entities + # (incl. hpcurrpower) live under "boiler" instead + requests_mock.get(f"http://{SAMPLE_IP}/api/heatpump", status_code=404, + json={"message": "unknown device heatpump"}) + requests_mock.get(f"http://{SAMPLE_IP}/api/boiler", json={"hpcurrpower": 28, "curflowtemp": 46.5}) + consumer = _create_consumer(monkeypatch) + + state = consumer.module_updater() + + assert state.power == 28 + + +def test_detected_base_url_is_cached_across_calls(monkeypatch, requests_mock: requests_mock.Mocker): + requests_mock.get(f"http://{SAMPLE_IP}/api/heatpump", status_code=404) + requests_mock.get(f"http://{SAMPLE_IP}/api/boiler", json={"hpcurrpower": 10}) + consumer = _create_consumer(monkeypatch) + + consumer.module_updater() + consumer.module_updater() + + heatpump_calls = [c for c in requests_mock.request_history if c.url.endswith("/api/heatpump")] + assert len(heatpump_calls) == 1 + + +def test_switch_on_uses_detected_base_url(monkeypatch, requests_mock: requests_mock.Mocker): + requests_mock.get(f"http://{SAMPLE_IP}/api/heatpump", status_code=404) + requests_mock.get(f"http://{SAMPLE_IP}/api/boiler", json={"hpcurrpower": 10}) + post_mock = requests_mock.post(f"http://{SAMPLE_IP}/api/boiler/hpin4opt", json={}) + consumer = _create_consumer(monkeypatch) + + consumer.module_switch_on() + + assert post_mock.called + + +def test_raises_when_neither_device_has_hpcurrpower(monkeypatch, requests_mock: requests_mock.Mocker): + requests_mock.get(f"http://{SAMPLE_IP}/api/heatpump", json={"other": 1}) + requests_mock.get(f"http://{SAMPLE_IP}/api/boiler", json={"other": 2}) + consumer = _create_consumer(monkeypatch) + + with pytest.raises(ValueError): + consumer.module_updater() + + +def test_non_404_http_error_is_not_mistaken_for_unknown_device(monkeypatch, requests_mock: requests_mock.Mocker): + # eg. a wrong/expired token (401) would otherwise fail on every device name and surface as + # a misleading "hpcurrpower not found" instead of the real problem + requests_mock.get(f"http://{SAMPLE_IP}/api/heatpump", status_code=401) + consumer = _create_consumer(monkeypatch) + + with pytest.raises(requests.exceptions.HTTPError): + consumer.module_updater()