Make the minimum number of samples used by the trend sensor configurable (#101102)

* Make the minimum of samples configurable & raise issue when min_samples > max_samples

* Wording

* Remove issue creation and use a custom schema validator

* Remove issue from strings.json

* Add test for validator and fix error message
This commit is contained in:
Jan-Philipp Benecke 2023-11-30 15:41:58 +01:00 committed by GitHub
parent aa4382e091
commit f59588b413
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 115 additions and 18 deletions

View File

@ -52,23 +52,39 @@ from .const import (
CONF_INVERT, CONF_INVERT,
CONF_MAX_SAMPLES, CONF_MAX_SAMPLES,
CONF_MIN_GRADIENT, CONF_MIN_GRADIENT,
CONF_MIN_SAMPLES,
CONF_SAMPLE_DURATION, CONF_SAMPLE_DURATION,
DOMAIN, DOMAIN,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SENSOR_SCHEMA = vol.Schema(
{ def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]:
vol.Required(CONF_ENTITY_ID): cv.entity_id, if (
vol.Optional(CONF_ATTRIBUTE): cv.string, CONF_MIN_SAMPLES in data
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, and CONF_MAX_SAMPLES in data
vol.Optional(CONF_FRIENDLY_NAME): cv.string, and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES]
vol.Optional(CONF_INVERT, default=False): cv.boolean, ):
vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, raise vol.Invalid("min_samples must be smaller than or equal to max_samples")
vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), return data
vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int,
}
SENSOR_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_ATTRIBUTE): cv.string,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_FRIENDLY_NAME): cv.string,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int,
vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float),
vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int,
vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int,
}
),
_validate_min_max,
) )
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@ -96,6 +112,7 @@ async def async_setup_platform(
max_samples = device_config[CONF_MAX_SAMPLES] max_samples = device_config[CONF_MAX_SAMPLES]
min_gradient = device_config[CONF_MIN_GRADIENT] min_gradient = device_config[CONF_MIN_GRADIENT]
sample_duration = device_config[CONF_SAMPLE_DURATION] sample_duration = device_config[CONF_SAMPLE_DURATION]
min_samples = device_config[CONF_MIN_SAMPLES]
sensors.append( sensors.append(
SensorTrend( SensorTrend(
@ -109,8 +126,10 @@ async def async_setup_platform(
max_samples, max_samples,
min_gradient, min_gradient,
sample_duration, sample_duration,
min_samples,
) )
) )
if not sensors: if not sensors:
_LOGGER.error("No sensors added") _LOGGER.error("No sensors added")
return return
@ -137,6 +156,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity):
max_samples: int, max_samples: int,
min_gradient: float, min_gradient: float,
sample_duration: int, sample_duration: int,
min_samples: int,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self._hass = hass self._hass = hass
@ -148,6 +168,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity):
self._invert = invert self._invert = invert
self._sample_duration = sample_duration self._sample_duration = sample_duration
self._min_gradient = min_gradient self._min_gradient = min_gradient
self._min_samples = min_samples
self.samples: deque = deque(maxlen=max_samples) self.samples: deque = deque(maxlen=max_samples)
@property @property
@ -210,7 +231,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity):
while self.samples and self.samples[0][0] < cutoff: while self.samples and self.samples[0][0] < cutoff:
self.samples.popleft() self.samples.popleft()
if len(self.samples) < 2: if len(self.samples) < self._min_samples:
return return
# Calculate gradient of linear trend # Calculate gradient of linear trend

View File

@ -12,3 +12,4 @@ CONF_INVERT = "invert"
CONF_MAX_SAMPLES = "max_samples" CONF_MAX_SAMPLES = "max_samples"
CONF_MIN_GRADIENT = "min_gradient" CONF_MIN_GRADIENT = "min_gradient"
CONF_SAMPLE_DURATION = "sample_duration" CONF_SAMPLE_DURATION = "sample_duration"
CONF_MIN_SAMPLES = "min_samples"

View File

@ -1,5 +1,6 @@
"""The test for the Trend sensor platform.""" """The test for the Trend sensor platform."""
from datetime import timedelta from datetime import timedelta
import logging
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -68,6 +69,7 @@ class TestTrendBinarySensor:
"sample_duration": 10000, "sample_duration": 10000,
"min_gradient": 1, "min_gradient": 1,
"max_samples": 25, "max_samples": 25,
"min_samples": 5,
} }
}, },
} }
@ -76,24 +78,35 @@ class TestTrendBinarySensor:
self.hass.block_till_done() self.hass.block_till_done()
now = dt_util.utcnow() now = dt_util.utcnow()
# add not enough states to trigger calculation
for val in [10, 0, 20, 30]: for val in [10, 0, 20, 30]:
with patch("homeassistant.util.dt.utcnow", return_value=now): with patch("homeassistant.util.dt.utcnow", return_value=now):
self.hass.states.set("sensor.test_state", val) self.hass.states.set("sensor.test_state", val)
self.hass.block_till_done() self.hass.block_till_done()
now += timedelta(seconds=2) now += timedelta(seconds=2)
state = self.hass.states.get("binary_sensor.test_trend_sensor") assert (
assert state.state == "on" self.hass.states.get("binary_sensor.test_trend_sensor").state == "unknown"
)
# have to change state value, otherwise sample will lost # add one more state to trigger gradient calculation
for val in [100]:
with patch("homeassistant.util.dt.utcnow", return_value=now):
self.hass.states.set("sensor.test_state", val)
self.hass.block_till_done()
now += timedelta(seconds=2)
assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "on"
# add more states to trigger a downtrend
for val in [0, 30, 1, 0]: for val in [0, 30, 1, 0]:
with patch("homeassistant.util.dt.utcnow", return_value=now): with patch("homeassistant.util.dt.utcnow", return_value=now):
self.hass.states.set("sensor.test_state", val) self.hass.states.set("sensor.test_state", val)
self.hass.block_till_done() self.hass.block_till_done()
now += timedelta(seconds=2) now += timedelta(seconds=2)
state = self.hass.states.get("binary_sensor.test_trend_sensor") assert self.hass.states.get("binary_sensor.test_trend_sensor").state == "off"
assert state.state == "off"
def test_down_using_trendline(self): def test_down_using_trendline(self):
"""Test down trend using multiple samples and trendline calculation.""" """Test down trend using multiple samples and trendline calculation."""
@ -434,10 +447,72 @@ async def test_restore_state(
{ {
"binary_sensor": { "binary_sensor": {
"platform": "trend", "platform": "trend",
"sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, "sensors": {
"test_trend_sensor": {
"entity_id": "sensor.test_state",
"sample_duration": 10000,
"min_gradient": 1,
"max_samples": 25,
"min_samples": 5,
}
},
} }
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
# restored sensor should match saved one
assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state
now = dt_util.utcnow()
# add not enough samples to trigger calculation
for val in [10, 20, 30, 40]:
with patch("homeassistant.util.dt.utcnow", return_value=now):
hass.states.async_set("sensor.test_state", val)
await hass.async_block_till_done()
now += timedelta(seconds=2)
# state should match restored state as no calculation happened
assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state
# add more samples to trigger calculation
for val in [50, 60, 70, 80]:
with patch("homeassistant.util.dt.utcnow", return_value=now):
hass.states.async_set("sensor.test_state", val)
await hass.async_block_till_done()
now += timedelta(seconds=2)
# sensor should detect an upwards trend and turn on
assert hass.states.get("binary_sensor.test_trend_sensor").state == "on"
async def test_invalid_min_sample(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test if error is logged when min_sample is larger than max_samples."""
with caplog.at_level(logging.ERROR):
assert await setup.async_setup_component(
hass,
"binary_sensor",
{
"binary_sensor": {
"platform": "trend",
"sensors": {
"test_trend_sensor": {
"entity_id": "sensor.test_state",
"max_samples": 25,
"min_samples": 30,
}
},
}
},
)
await hass.async_block_till_done()
record = caplog.records[0]
assert record.levelname == "ERROR"
assert (
"Invalid config for 'binary_sensor.trend': min_samples must be smaller than or equal to max_samples"
in record.message
)