[emontx] Fix sensor state_class defaults not being applied correctly (#17610)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Frédéric Metrich
2026-08-21 09:57:58 -05:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Claude Sonnet 4.6
parent 52bfc0efb1
commit 7957808f00
7 changed files with 213 additions and 32 deletions
+33 -22
View File
@@ -68,6 +68,7 @@ PATTERN_CONFIGS = {
"PULSE": {
CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES,
CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY,
CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
CONF_ACCURACY_DECIMALS: 0,
},
"PF": {
@@ -78,12 +79,13 @@ PATTERN_CONFIGS = {
},
}
# Create a base schema that's flexible for any tag
BASE_SCHEMA = sensor.sensor_schema(
EmonTxSensor,
state_class=STATE_CLASS_MEASUREMENT,
accuracy_decimals=0,
).extend(
# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults.
# Passing them to sensor_schema() would register them via cv.Optional(key, default=...),
# making them always present in the validated config dict and preventing
# apply_tag_defaults from overriding them with the correct per-prefix values.
# They are injected by apply_tag_defaults below, after running through
# sensor.validate_state_class() so the value is code-generation-ready.
BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend(
{
cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx),
cv.Required(CONF_TAG_NAME): cv.string,
@@ -91,34 +93,43 @@ BASE_SCHEMA = sensor.sensor_schema(
)
def _apply_defaults(config: ConfigType, defaults: dict) -> None:
"""Inject defaults into config, skipping keys already set by the user.
state_class values are run through validate_state_class so they are
code-generation-ready, matching what sensor_schema() would normally do."""
for key, value in defaults.items():
if key not in config:
if key == CONF_STATE_CLASS:
value = sensor.validate_state_class(value)
config[key] = value
def apply_tag_defaults(config: ConfigType) -> ConfigType:
"""Apply defaults based on tag prefix if applicable, but don't restrict any tags."""
tag = config[CONF_TAG_NAME]
# Skip if tag is too short
if len(tag) < 2:
return config
# Check if this tag starts with a known prefix
if len(tag) >= 2:
tag_upper = tag.upper()
for pattern, pattern_config in PATTERN_CONFIGS.items():
if tag_upper.startswith(pattern):
# Apply pattern defaults if not overridden by user
for key, value in pattern_config.items():
if key not in config:
config[key] = value
_apply_defaults(config, pattern_config)
return config
# Only apply defaults for known prefixes with numeric indices
# Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3)
prefix = tag_upper[0]
if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit():
# Apply defaults for known tag types, but only if not overridden by user
defaults = SENSOR_CONFIGS[prefix]
for key, value in defaults.items():
if key not in config:
config[key] = value
if prefix in SENSOR_CONFIGS and tag[1:].isdigit():
_apply_defaults(config, SENSOR_CONFIGS[prefix])
return config
# Fall back to generic defaults for tags with no known prefix
_apply_defaults(
config,
{
CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
CONF_ACCURACY_DECIMALS: 0,
},
)
return config
@@ -0,0 +1,100 @@
"""Tests for emontx sensor tag defaults."""
import pytest
from esphome.components import sensor
from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_STATE_CLASS,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
)
def _resolve_via_config_schema(tag: str) -> dict:
"""Run a minimal config through the real CONFIG_SCHEMA pipeline, the
same path a user's YAML goes through."""
return CONFIG_SCHEMA(
{"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"}
)
def test_config_schema_applies_tag_default_state_class():
"""If sensor_schema(state_class=...) is reintroduced, the schema-level
default wins over apply_tag_defaults' per-prefix value, and E1 would
resolve to measurement instead of total_increasing. Driving the real
CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since
sensor_schema() runs before apply_tag_defaults in the cv.All() chain.
"""
result = _resolve_via_config_schema("E1")
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_TOTAL_INCREASING
)
def test_config_schema_applies_tag_default_accuracy_decimals():
"""Same root cause as the state_class regression: reintroducing
sensor_schema(accuracy_decimals=...) would make V1 resolve to the
schema-level default instead of the prefix-specific value of 2.
"""
result = _resolve_via_config_schema("V1")
assert result[CONF_ACCURACY_DECIMALS] == 2
def _make_config(tag: str) -> dict:
"""Minimal config dict with only tag_name set — no overrides."""
return {"tag_name": tag}
@pytest.mark.parametrize(
("tag", "expected_state_class", "expected_decimals"),
[
# Known numeric-index prefixes
("E1", STATE_CLASS_TOTAL_INCREASING, 0),
("E12", STATE_CLASS_TOTAL_INCREASING, 0),
("P1", STATE_CLASS_MEASUREMENT, 0),
("V1", STATE_CLASS_MEASUREMENT, 2),
("I1", STATE_CLASS_MEASUREMENT, 2),
("T1", STATE_CLASS_MEASUREMENT, 2),
# Known patterns
("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0),
("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0),
("PF1", STATE_CLASS_MEASUREMENT, 2),
# Unknown / free-form tags fall back to generic defaults
("CUSTOM1", STATE_CLASS_MEASUREMENT, 0),
("X", STATE_CLASS_MEASUREMENT, 0),
],
)
def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
"""apply_tag_defaults must inject the correct state_class and accuracy_decimals
for each tag type when no user overrides are present."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class)
assert result[CONF_ACCURACY_DECIMALS] == expected_decimals
@pytest.mark.parametrize(
("tag", "user_state_class", "user_decimals"),
[
# User overrides must not be clobbered by defaults
("E1", STATE_CLASS_MEASUREMENT, 3),
("PULSE1", STATE_CLASS_MEASUREMENT, 1),
("V1", STATE_CLASS_TOTAL_INCREASING, 0),
("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4),
],
)
def test_apply_tag_defaults_respects_user_overrides(
tag, user_state_class, user_decimals
):
"""apply_tag_defaults must not overwrite values already set by the user."""
config = _make_config(tag)
config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class)
config[CONF_ACCURACY_DECIMALS] = user_decimals
result = apply_tag_defaults(config)
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class)
assert result[CONF_ACCURACY_DECIMALS] == user_decimals
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -0,0 +1,73 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
emontx: !include common.yaml
# Validate that each sensor type gets the correct default state_class,
# unit_of_measurement, device_class, and accuracy_decimals when NO overrides
# are provided. The values are intentionally omitted so apply_tag_defaults is
# exercised, not the user-override path.
sensor:
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
# device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: E1
name: Energy 1
emontx_id: test_emontx
# Power sensor (P prefix): expects state_class=measurement, unit=W,
# device_class=power, accuracy_decimals=0
- platform: emontx
tag_name: P1
name: Power 1
emontx_id: test_emontx
# Voltage sensor (V prefix): expects state_class=measurement, unit=V,
# device_class=voltage, accuracy_decimals=2
- platform: emontx
tag_name: V1
name: Voltage 1
emontx_id: test_emontx
# Current sensor (I prefix): expects state_class=measurement, unit=A,
# device_class=current, accuracy_decimals=2
- platform: emontx
tag_name: I1
name: Current 1
emontx_id: test_emontx
# Temperature sensor (T prefix): expects state_class=measurement, unit=°C,
# device_class=temperature, accuracy_decimals=2
- platform: emontx
tag_name: T1
name: Temperature 1
emontx_id: test_emontx
# Pulse sensor (PULSE pattern): expects state_class=total_increasing,
# unit=pulses, device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: PULSE1
name: Pulse 1
emontx_id: test_emontx
# Power factor sensor (PF pattern): expects state_class=measurement,
# device_class=power_factor, accuracy_decimals=2
- platform: emontx
tag_name: PF1
name: Power Factor 1
emontx_id: test_emontx
# Unknown tag: no prefix match, falls back to state_class=measurement,
# accuracy_decimals=0
- platform: emontx
tag_name: CUSTOM1
name: Custom sensor
emontx_id: test_emontx
# User override: verify that explicit values are respected and not clobbered
- platform: emontx
tag_name: E2
name: Energy 2 (user override)
emontx_id: test_emontx
state_class: measurement
accuracy_decimals: 3