diff --git a/CODEOWNERS b/CODEOWNERS index c372cb70371..7da06479b92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -422,6 +422,8 @@ build.json @home-assistant/supervisor /homeassistant/components/emby/ @mezz64 /homeassistant/components/emoncms/ @borpin @alexandrecuer /tests/components/emoncms/ @borpin @alexandrecuer +/homeassistant/components/emoncms_history/ @alexandrecuer +/tests/components/emoncms_history/ @alexandrecuer /homeassistant/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco /homeassistant/components/emulated_hue/ @bdraco @Tho85 diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 2ab00d6ca42..5394a797272 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -1,10 +1,11 @@ """Support for sending data to Emoncms.""" -from datetime import timedelta -from http import HTTPStatus +from datetime import datetime, timedelta +from functools import partial import logging -import requests +import aiohttp +from pyemoncms import EmoncmsClient import voluptuous as vol from homeassistant.const import ( @@ -17,9 +18,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.event import track_point_in_time +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -42,61 +43,51 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_send_to_emoncms( + hass: HomeAssistant, + emoncms_client: EmoncmsClient, + whitelist: list[str], + node: str | int, + _: datetime, +) -> None: + """Send data to Emoncms.""" + payload_dict = {} + + for entity_id in whitelist: + state = hass.states.get(entity_id) + if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): + continue + try: + payload_dict[entity_id] = state_helper.state_as_number(state) + except ValueError: + continue + + if payload_dict: + try: + await emoncms_client.async_input_post(data=payload_dict, node=node) + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.warning("Network error when sending data to Emoncms: %s", err) + except ValueError as err: + _LOGGER.warning("Value error when preparing data for Emoncms: %s", err) + else: + _LOGGER.debug("Sent data to Emoncms: %s", payload_dict) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Emoncms history component.""" conf = config[DOMAIN] whitelist = conf.get(CONF_WHITELIST) + input_node = str(conf.get(CONF_INPUTNODE)) - def send_data(url, apikey, node, payload): - """Send payload data to Emoncms.""" - try: - fullurl = f"{url}/input/post.json" - data = {"apikey": apikey, "data": payload} - parameters = {"node": node} - req = requests.post( - fullurl, params=parameters, data=data, allow_redirects=True, timeout=5 - ) + emoncms_client = EmoncmsClient( + url=conf.get(CONF_URL), + api_key=conf.get(CONF_API_KEY), + session=async_get_clientsession(hass), + ) + async_track_time_interval( + hass, + partial(async_send_to_emoncms, hass, emoncms_client, whitelist, input_node), + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)), + ) - except requests.exceptions.RequestException: - _LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl) - - else: - if req.status_code != HTTPStatus.OK: - _LOGGER.error( - "Error saving data %s to %s (http status code = %d)", - payload, - fullurl, - req.status_code, - ) - - def update_emoncms(time): - """Send whitelisted entities states regularly to Emoncms.""" - payload_dict = {} - - for entity_id in whitelist: - state = hass.states.get(entity_id) - - if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): - continue - - try: - payload_dict[entity_id] = state_helper.state_as_number(state) - except ValueError: - continue - - if payload_dict: - payload = ",".join(f"{key}:{val}" for key, val in payload_dict.items()) - - send_data( - conf.get(CONF_URL), - conf.get(CONF_API_KEY), - str(conf.get(CONF_INPUTNODE)), - f"{{{payload}}}", - ) - - track_point_in_time( - hass, update_emoncms, time + timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)) - ) - - update_emoncms(dt_util.utcnow()) return True diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json index e73f76f7528..3c8c445b766 100644 --- a/homeassistant/components/emoncms_history/manifest.json +++ b/homeassistant/components/emoncms_history/manifest.json @@ -1,8 +1,9 @@ { "domain": "emoncms_history", "name": "Emoncms History", - "codeowners": [], + "codeowners": ["@alexandrecuer"], "documentation": "https://www.home-assistant.io/integrations/emoncms_history", "iot_class": "local_polling", - "quality_scale": "legacy" + "quality_scale": "legacy", + "requirements": ["pyemoncms==0.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 17052c94bf1..d3e86ebd342 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1957,6 +1957,7 @@ pyefergy==22.5.0 pyegps==0.2.5 # homeassistant.components.emoncms +# homeassistant.components.emoncms_history pyemoncms==0.1.2 # homeassistant.components.enphase_envoy diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 669695558d9..624b9b5f6ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1632,6 +1632,7 @@ pyefergy==22.5.0 pyegps==0.2.5 # homeassistant.components.emoncms +# homeassistant.components.emoncms_history pyemoncms==0.1.2 # homeassistant.components.enphase_envoy diff --git a/tests/components/emoncms_history/__init__.py b/tests/components/emoncms_history/__init__.py new file mode 100644 index 00000000000..0c60d27655b --- /dev/null +++ b/tests/components/emoncms_history/__init__.py @@ -0,0 +1 @@ +"""Tests for emoncms_history component.""" diff --git a/tests/components/emoncms_history/test_init.py b/tests/components/emoncms_history/test_init.py new file mode 100644 index 00000000000..c62252750b5 --- /dev/null +++ b/tests/components/emoncms_history/test_init.py @@ -0,0 +1,125 @@ +"""The tests for the emoncms_history init.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +import aiohttp +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import CONF_API_KEY, CONF_URL, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_setup_valid_config(hass: HomeAssistant) -> None: + """Test setting up the emoncms_history component with valid configuration.""" + config = { + "emoncms_history": { + CONF_API_KEY: "dummy", + CONF_URL: "https://emoncms.example", + "inputnode": 42, + "whitelist": ["sensor.temp"], + } + } + # Simulate a sensor + hass.states.async_set("sensor.temp", "23.4", {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "emoncms_history", config) + await hass.async_block_till_done() + + +async def test_setup_missing_config(hass: HomeAssistant) -> None: + """Test setting up the emoncms_history component with missing configuration.""" + config = {"emoncms_history": {"api_key": "dummy"}} + success = await async_setup_component(hass, "emoncms_history", config) + assert not success + + +@pytest.fixture +async def emoncms_client() -> AsyncGenerator[AsyncMock]: + """Mock pyemoncms client with successful responses.""" + with patch( + "homeassistant.components.emoncms_history.EmoncmsClient", autospec=True + ) as mock_client: + client = mock_client.return_value + client.async_input_post.return_value = '{"success": true}' + yield client + + +async def test_emoncms_send_data( + hass: HomeAssistant, + emoncms_client: AsyncMock, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sending data to Emoncms with and without success.""" + + config = { + "emoncms_history": { + "api_key": "dummy", + "url": "http://fake-url", + "inputnode": 42, + "whitelist": ["sensor.temp"], + } + } + + assert await async_setup_component(hass, "emoncms_history", config) + await hass.async_block_till_done() + + for state in None, "", STATE_UNAVAILABLE, STATE_UNKNOWN: + hass.states.async_set("sensor.temp", state, {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + assert emoncms_client.async_input_post.call_args is None + + hass.states.async_set("sensor.temp", "not_a_number", {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + emoncms_client.async_input_post.assert_not_called() + + hass.states.async_set("sensor.temp", "23.4", {"unit_of_measurement": "°C"}) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + emoncms_client.async_input_post.assert_called_once() + assert emoncms_client.async_input_post.return_value == '{"success": true}' + + _, kwargs = emoncms_client.async_input_post.call_args + assert kwargs["data"] == {"sensor.temp": 23.4} + assert kwargs["node"] == "42" + + emoncms_client.async_input_post.side_effect = aiohttp.ClientError( + "Connection refused" + ) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + assert any( + "Network error when sending data to Emoncms" in message + for message in caplog.text.splitlines() + ) + + emoncms_client.async_input_post.side_effect = ValueError("Invalid value format") + + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + assert any( + "Value error when preparing data for Emoncms" in message + for message in caplog.text.splitlines() + )