Compare commits

...

19 Commits

Author SHA1 Message Date
G Johansson 8044f9c3ff true 2026-05-12 17:57:54 +00:00
G Johansson 209943bf60 Mod 2026-05-12 17:56:17 +00:00
G Johansson b4f7a315d1 Mod 2026-05-12 16:42:51 +00:00
G Johansson 833a2c3e7e string 2026-05-03 19:48:39 +00:00
G Johansson e4afd71d16 Add constants 2026-05-03 19:46:58 +00:00
G Johansson 6308a2e691 Add initial test 2026-05-03 19:44:35 +00:00
G Johansson 4d71e8d594 swap order 2026-05-03 17:40:21 +00:00
G Johansson e049a85785 More mods 2026-05-03 17:36:45 +00:00
G Johansson 79703601eb string mods 2026-05-03 17:35:29 +00:00
G Johansson 137801f811 Degree more than 0 2026-05-03 17:24:58 +00:00
G Johansson d89697fcef Flow mods 2026-05-03 17:24:30 +00:00
G Johansson 45ed55be98 Fix selector issue 2026-05-03 17:24:05 +00:00
G Johansson 017a584822 Feedback 2026-05-03 13:15:17 +00:00
G Johansson 299c506ab0 strings feedback 2026-05-03 13:15:16 +00:00
G Johansson cf5c149a4e Auto reload options 2026-05-03 13:15:16 +00:00
G Johansson 00f12ed8dc Workable 2026-05-03 13:15:15 +00:00
G Johansson 21f374154f Fix refactor 2026-05-03 13:15:15 +00:00
G Johansson d8a2949b7f Fix strings 2026-05-03 13:15:12 +00:00
G Johansson 01d2eded29 Add config flow to compensation helper 2026-05-03 13:15:12 +00:00
11 changed files with 517 additions and 57 deletions
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
@@ -23,22 +24,27 @@ from homeassistant.const import (
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_COMPENSATED_VALUE,
CONF_COMPENSATION,
CONF_DATAPOINTS,
CONF_DEGREE,
CONF_LOWER_LIMIT,
CONF_POLYNOMIAL,
CONF_POLYNOMIAL_CONFIG,
CONF_PRECISION,
CONF_UNCOMPENSATED_VALUE,
CONF_UPPER_LIMIT,
DATA_COMPENSATION,
DEFAULT_DEGREE,
DEFAULT_PRECISION,
DOMAIN,
PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
@@ -87,59 +93,103 @@ CONFIG_SCHEMA = vol.Schema(
)
async def create_compensation_data(
hass: HomeAssistant, compensation: str, conf: ConfigType, should_raise: bool = False
) -> None:
"""Create compensation data."""
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
degree = conf[CONF_DEGREE]
initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS]
sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0))
# get x values and y values from the x,y point pairs
x_values, y_values = zip(*initial_coefficients, strict=False)
# try to get valid coefficients for a polynomial
coefficients = None
with np.errstate(all="raise"):
try:
coefficients = np.polyfit(x_values, y_values, degree)
except FloatingPointError as error:
_LOGGER.error(
"Setup of %s encountered an error, %s",
compensation,
error,
)
if should_raise:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="setup_error",
translation_placeholders={
"title": conf[CONF_NAME],
"error": str(error),
},
) from error
if coefficients is not None:
data = {
k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS]
}
data[CONF_POLYNOMIAL] = np.poly1d(coefficients)
if data[CONF_LOWER_LIMIT]:
data[CONF_MINIMUM] = sorted_coefficients[0]
else:
data[CONF_MINIMUM] = None
if data[CONF_UPPER_LIMIT]:
data[CONF_MAXIMUM] = sorted_coefficients[-1]
else:
data[CONF_MAXIMUM] = None
hass.data[DATA_COMPENSATION][compensation] = data
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Compensation sensor."""
hass.data[DATA_COMPENSATION] = {}
if DOMAIN not in config:
return True
for compensation, conf in config[DOMAIN].items():
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
degree = conf[CONF_DEGREE]
initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS]
sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0))
# get x values and y values from the x,y point pairs
x_values, y_values = zip(*initial_coefficients, strict=False)
# try to get valid coefficients for a polynomial
coefficients = None
with np.errstate(all="raise"):
try:
coefficients = np.polyfit(x_values, y_values, degree)
except FloatingPointError as error:
_LOGGER.error(
"Setup of %s encountered an error, %s",
compensation,
error,
)
if coefficients is not None:
data = {
k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS]
}
data[CONF_POLYNOMIAL] = np.poly1d(coefficients)
if data[CONF_LOWER_LIMIT]:
data[CONF_MINIMUM] = sorted_coefficients[0]
else:
data[CONF_MINIMUM] = None
if data[CONF_UPPER_LIMIT]:
data[CONF_MAXIMUM] = sorted_coefficients[-1]
else:
data[CONF_MAXIMUM] = None
hass.data[DATA_COMPENSATION][compensation] = data
hass.async_create_task(
async_load_platform(
hass,
SENSOR_DOMAIN,
DOMAIN,
{CONF_COMPENSATION: compensation},
config,
)
await create_compensation_data(hass, compensation, conf)
hass.async_create_task(
async_load_platform(
hass,
SENSOR_DOMAIN,
DOMAIN,
{CONF_COMPENSATION: compensation},
config,
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Compensation from a config entry."""
config = dict(entry.options)
data_points: list[dict[str, float]] = config[CONF_POLYNOMIAL_CONFIG][
CONF_DATAPOINTS
]
new_data_points = [
[data_point[CONF_UNCOMPENSATED_VALUE], data_point[CONF_COMPENSATED_VALUE]]
for data_point in data_points
]
config[CONF_DEGREE] = config[CONF_POLYNOMIAL_CONFIG][CONF_DEGREE]
config[CONF_DATAPOINTS] = new_data_points
await create_compensation_data(hass, entry.entry_id, config, True)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Compensation config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DATA_COMPENSATION].pop(entry.entry_id, None)
return unload_ok
@@ -0,0 +1,171 @@
"""Config flow for Compensation integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
import voluptuous as vol
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_ENTITY_ID,
CONF_NAME,
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
from homeassistant.helpers.selector import (
AttributeSelector,
AttributeSelectorConfig,
BooleanSelector,
EntitySelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
ObjectSelector,
ObjectSelectorConfig,
ObjectSelectorField,
TextSelector,
)
from .const import (
CONF_COMPENSATED_VALUE,
CONF_DATAPOINTS,
CONF_DEGREE,
CONF_LOWER_LIMIT,
CONF_POLYNOMIAL_CONFIG,
CONF_PRECISION,
CONF_UNCOMPENSATED_VALUE,
CONF_UPPER_LIMIT,
DEFAULT_DEGREE,
DEFAULT_NAME,
DEFAULT_PRECISION,
DOMAIN,
)
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Get options schema."""
entity_id = handler.options[CONF_ENTITY_ID]
return vol.Schema(
{
vol.Required(CONF_POLYNOMIAL_CONFIG): section(
vol.Schema(
{
vol.Optional(CONF_DATAPOINTS): ObjectSelector(
ObjectSelectorConfig(
label_field="uncompensated_value",
description_field="compensated_value",
multiple=True,
translation_key=CONF_DATAPOINTS,
overview_labels=True,
fields={
CONF_UNCOMPENSATED_VALUE: ObjectSelectorField(
required=True,
selector=NumberSelector(
NumberSelectorConfig(
mode=NumberSelectorMode.BOX
)
),
),
CONF_COMPENSATED_VALUE: ObjectSelectorField(
required=True,
selector=NumberSelector(
NumberSelectorConfig(
mode=NumberSelectorMode.BOX
)
),
),
},
)
),
vol.Optional(
CONF_DEGREE, default=DEFAULT_DEGREE
): NumberSelector(
NumberSelectorConfig(
min=1, max=7, step=1, mode=NumberSelectorMode.BOX
)
),
},
),
SectionConfig(collapsed=False),
),
vol.Optional(CONF_ATTRIBUTE): AttributeSelector(
AttributeSelectorConfig(entity_id=entity_id)
),
vol.Optional(CONF_UPPER_LIMIT, default=False): BooleanSelector(),
vol.Optional(CONF_LOWER_LIMIT, default=False): BooleanSelector(),
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(),
}
)
async def validate_options(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate options selected."""
if not user_input[CONF_POLYNOMIAL_CONFIG].get(CONF_DATAPOINTS):
raise SchemaFlowError("not_enough_datapoints")
user_input[CONF_PRECISION] = int(user_input[CONF_PRECISION])
user_input[CONF_POLYNOMIAL_CONFIG][CONF_DEGREE] = int(
user_input[CONF_POLYNOMIAL_CONFIG][CONF_DEGREE]
)
if (
len(user_input[CONF_POLYNOMIAL_CONFIG][CONF_DATAPOINTS])
<= user_input[CONF_POLYNOMIAL_CONFIG][CONF_DEGREE]
):
raise SchemaFlowError("not_enough_datapoints")
handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001
return user_input
DATA_SCHEMA_SETUP = vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_ENTITY_ID): EntitySelector(),
}
)
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=DATA_SCHEMA_SETUP,
next_step="options",
),
"options": SchemaFlowFormStep(
schema=get_options_schema,
validate_user_input=validate_options,
),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
get_options_schema,
validate_user_input=validate_options,
),
}
class CompensationConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Compensation."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options[CONF_NAME])
+11 -1
View File
@@ -1,6 +1,12 @@
"""Compensation constants."""
from typing import Any
from homeassistant.const import Platform
from homeassistant.util.hass_dict import HassKey
DOMAIN = "compensation"
PLATFORMS = [Platform.SENSOR]
SENSOR = "compensation"
@@ -11,8 +17,12 @@ CONF_DATAPOINTS = "data_points"
CONF_DEGREE = "degree"
CONF_PRECISION = "precision"
CONF_POLYNOMIAL = "polynomial"
CONF_POLYNOMIAL_CONFIG = "polynomial_config"
CONF_COMPENSATED_VALUE = "compensated_value"
CONF_UNCOMPENSATED_VALUE = "uncompensated_value"
DATA_COMPENSATION = "compensation_data"
DATA_COMPENSATION: HassKey[dict[str, Any]] = HassKey("compensation_data")
DEFAULT_DEGREE = 1
DEFAULT_NAME = "Compensation"
@@ -2,7 +2,9 @@
"domain": "compensation",
"name": "Compensation",
"codeowners": ["@Petro31"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/compensation",
"integration_type": "helper",
"iot_class": "calculated",
"quality_scale": "legacy",
"requirements": ["numpy==2.3.2"]
@@ -10,11 +10,13 @@ from homeassistant.components.sensor import (
CONF_STATE_CLASS,
SensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ATTRIBUTE,
CONF_DEVICE_CLASS,
CONF_ENTITY_ID,
CONF_MAXIMUM,
CONF_MINIMUM,
CONF_NAME,
@@ -31,7 +33,10 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -75,6 +80,24 @@ async def async_setup_platform(
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Compensation sensor entry."""
compensation = entry.entry_id
conf: dict[str, Any] = hass.data[DATA_COMPENSATION][compensation]
source: str = conf[CONF_ENTITY_ID]
attribute: str | None = conf.get(CONF_ATTRIBUTE)
name = entry.title
async_add_entities(
[CompensationSensor(entry.entry_id, name, source, attribute, conf)]
)
class CompensationSensor(SensorEntity):
"""Representation of a Compensation sensor."""
@@ -0,0 +1,108 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"not_enough_datapoints": "The number of datapoints needs to be more than the configured degree."
},
"step": {
"options": {
"data": {
"attribute": "Attribute",
"lower_limit": "Lower limit",
"precision": "Precision",
"unit_of_measurement": "Unit of measurement",
"upper_limit": "Upper limit"
},
"data_description": {
"attribute": "Attribute from the source to monitor/compensate.",
"lower_limit": "Enables a lower limit for the sensor. The lower limit is defined by the data point's lowest uncompensated value.",
"precision": "Defines the precision of the calculated values, through the argument of round().",
"unit_of_measurement": "The unit of measurement of the compensation sensor, if any.",
"upper_limit": "Enables an upper limit for the sensor. The upper limit is defined by the data point's highest uncompensated value."
},
"description": "Refer to the documentation for further details on how to configure the compensation sensor using these options.",
"sections": {
"polynomial_config": {
"data": {
"data_points": "Data points",
"degree": "Degree"
},
"data_description": {
"degree": "The degree of the polynomial."
},
"description": "Configure the polynomial used for compensation.",
"name": "Polynomial configuration"
}
}
},
"user": {
"data": {
"entity_id": "Source entity",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"entity_id": "Entity to use as source.",
"name": "Name of the compensation sensor."
},
"description": "Create a compensation sensor that enhances the accuracy of a source sensor's value. This is done by calculating a new state using an nth degree polynomial with the source sensor's value."
}
}
},
"exceptions": {
"setup_error": {
"message": "Setup of {title} could not be completed due to {error}"
}
},
"options": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"not_enough_datapoints": "[%key:component::compensation::config::error::not_enough_datapoints%]"
},
"step": {
"init": {
"data": {
"attribute": "[%key:component::compensation::config::step::options::data::attribute%]",
"lower_limit": "[%key:component::compensation::config::step::options::data::lower_limit%]",
"precision": "[%key:component::compensation::config::step::options::data::precision%]",
"unit_of_measurement": "[%key:component::compensation::config::step::options::data::unit_of_measurement%]",
"upper_limit": "[%key:component::compensation::config::step::options::data::upper_limit%]"
},
"data_description": {
"attribute": "[%key:component::compensation::config::step::options::data_description::attribute%]",
"lower_limit": "[%key:component::compensation::config::step::options::data_description::lower_limit%]",
"precision": "[%key:component::compensation::config::step::options::data_description::precision%]",
"unit_of_measurement": "[%key:component::compensation::config::step::options::data_description::unit_of_measurement%]",
"upper_limit": "[%key:component::compensation::config::step::options::data_description::upper_limit%]"
},
"description": "[%key:component::compensation::config::step::options::description%]",
"sections": {
"polynomial_config": {
"data": {
"data_points": "[%key:component::compensation::config::step::options::sections::polynomial_config::data::data_points%]",
"degree": "[%key:component::compensation::config::step::options::sections::polynomial_config::data::degree%]"
},
"data_description": {
"degree": "[%key:component::compensation::config::step::options::sections::polynomial_config::data_description::degree%]"
},
"description": "[%key:component::compensation::config::step::options::sections::polynomial_config::description%]",
"name": "[%key:component::compensation::config::step::options::sections::polynomial_config::name%]"
}
}
}
}
},
"selector": {
"data_points": {
"fields": {
"compensated_value": "Compensated value",
"uncompensated_value": "Uncompensated value"
}
}
}
}
+1
View File
@@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest
FLOWS = {
"helper": [
"compensation",
"derivative",
"filter",
"generic_hygrostat",
+6 -6
View File
@@ -1147,12 +1147,6 @@
"config_flow": false,
"iot_class": "local_polling"
},
"compensation": {
"name": "Compensation",
"integration_type": "hub",
"config_flow": false,
"iot_class": "calculated"
},
"compit": {
"name": "Compit",
"integration_type": "hub",
@@ -8244,6 +8238,12 @@
}
},
"helper": {
"compensation": {
"name": "Compensation",
"integration_type": "helper",
"config_flow": true,
"iot_class": "calculated"
},
"counter": {
"integration_type": "helper",
"config_flow": false
+6 -1
View File
@@ -1641,6 +1641,7 @@ class ObjectSelectorConfig(BaseSelectorConfig, total=False):
label_field: str
description_field: str
translation_key: str
overview_labels: bool
@SELECTORS.register("object")
@@ -1662,6 +1663,7 @@ class ObjectSelector(Selector[ObjectSelectorConfig]):
vol.Optional("label_field"): str,
vol.Optional("description_field"): str,
vol.Optional("translation_key"): str,
vol.Optional("overview_labels", default=False): bool,
}
)
@@ -1698,7 +1700,10 @@ class ObjectSelector(Selector[ObjectSelectorConfig]):
if field_data.get("required") and field not in _config:
raise vol.Invalid(f"Field {field} is required")
if field in _config:
selector(field_data["selector"])(_config[field]) # type: ignore[operator]
if isinstance(field_data["selector"], Selector):
field_data["selector"](_config[field]) # type: ignore[operator]
else:
selector(field_data["selector"])(_config[field]) # type: ignore[operator]
for key in _config:
if key not in self.config["fields"]:
+16
View File
@@ -0,0 +1,16 @@
"""Compensation fixtures."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.compensation.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@@ -0,0 +1,74 @@
"""Test for compensation config flow."""
from unittest.mock import AsyncMock
from homeassistant.components.compensation.const import (
CONF_COMPENSATED_VALUE,
CONF_DATAPOINTS,
CONF_DEGREE,
CONF_LOWER_LIMIT,
CONF_POLYNOMIAL_CONFIG,
CONF_PRECISION,
CONF_UNCOMPENSATED_VALUE,
CONF_UPPER_LIMIT,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
async def test_user_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: "sensor.test_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "options"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOWER_LIMIT: False,
CONF_POLYNOMIAL_CONFIG: {
CONF_DATAPOINTS: [
{CONF_COMPENSATED_VALUE: 6, CONF_UNCOMPENSATED_VALUE: 5},
{CONF_COMPENSATED_VALUE: 8, CONF_UNCOMPENSATED_VALUE: 7},
],
CONF_DEGREE: 1,
},
CONF_PRECISION: 2,
CONF_UPPER_LIMIT: False,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
assert result["data"] == {}
assert result["options"] == {
CONF_ENTITY_ID: "sensor.test_sensor",
CONF_LOWER_LIMIT: False,
CONF_NAME: "Compensation",
CONF_POLYNOMIAL_CONFIG: {
CONF_DATAPOINTS: [
{CONF_COMPENSATED_VALUE: 6, CONF_UNCOMPENSATED_VALUE: 5},
{CONF_COMPENSATED_VALUE: 8, CONF_UNCOMPENSATED_VALUE: 7},
],
CONF_DEGREE: 1,
},
CONF_PRECISION: 2,
CONF_UPPER_LIMIT: False,
}