Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions packages/modules/consumers/ems_esp/ems_esp/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions packages/modules/consumers/ems_esp/ems_esp/consumer_test.py
Original file line number Diff line number Diff line change
@@ -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()