Migrate Emoncms_history to external async library (#149824)

This commit is contained in:
Alexandre CUER
2025-08-19 14:32:13 +02:00
committed by GitHub
parent 4c1788e757
commit 319e37384f
7 changed files with 181 additions and 59 deletions

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}

1
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
"""Tests for emoncms_history component."""

View File

@@ -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()
)