mirror of
https://github.com/home-assistant/core.git
synced 2025-07-20 19:57:07 +00:00
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:
parent
aa4382e091
commit
f59588b413
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user