From 0b78800e3d2e03e5e4ea398ed2f63b6ebc3de174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:07:18 +0200 Subject: [PATCH] [emontx] Add apparent power (AP) and frequency (F) sensor support (#18586) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/emontx/sensor/__init__.py | 98 +++++++++++++--- .../emontx/test_sensor_defaults.py | 109 ++++++++++++++++++ .../components/emontx/validate.esp32-idf.yaml | 15 +++ 3 files changed, 207 insertions(+), 15 deletions(-) diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 967bc4e699..56a7fb8b55 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -7,8 +7,10 @@ from esphome.const import ( CONF_ID, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_TEMPERATURE, @@ -18,8 +20,10 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_EMPTY, + UNIT_HERTZ, UNIT_PULSES, UNIT_VOLT, + UNIT_VOLT_AMPS, UNIT_WATT, UNIT_WATT_HOURS, ) @@ -29,6 +33,32 @@ from .. import CONF_EMONTX_ID, CONF_TAG_NAME, EmonTx, emontx_ns EmonTxSensor = emontx_ns.class_("EmonTxSensor", sensor.Sensor, cg.Component) +# Known emonTx/avrdb JSON tag conventions, gathered from real firmware +# (see https://github.com/openenergymonitor/avrdb_firmware), used to decide +# whether each tag below requires a numeric index or may also appear bare: +# +# Tag family Bare (no index) Numeric-indexed +# ----------- ----------------------- ---------------------------------- +# P (power) no P1, P2, ... (multi-channel boards) +# E (energy) no E1, E2, ... +# V (voltage) Vrms (NOT matched here, V1, V2, V3 (per-phase boards) +# doesn't fit "V"+digits) +# I (current) no I1, I2, ... +# T (temp.) no T1, T2, ... +# F (frequency) F (single mains freq.) not seen indexed +# PULSE pulse (single-CT boards) PULSE1, PULSE2, ... (other variants) +# PF (power not seen bare PF1, PF2, ... (currently unused/ +# factor) commented out in avrdb firmware) +# AP (apparent not seen bare AP1, AP2, ... (not an avrdb tag at +# power) all; avrdb uses "VA"+index instead, +# itself currently unused/commented +# out; "AP" is kept here for other +# firmware/integrations using it) +# +# This is why a bare "PULSE" resolves to proper defaults below, but bare +# "PF"/"AP" fall back to generic defaults instead: only PULSE has a +# confirmed bare-tag use in real, currently-shipping firmware. + # Define sensor type configurations by prefix SENSOR_CONFIGS = { "P": { @@ -63,7 +93,25 @@ SENSOR_CONFIGS = { }, } -# Pattern-based configurations +# Tags reported once, without a numeric index (e.g. "F"), matched exactly +# rather than by prefix. +EXACT_TAG_CONFIGS = { + "F": { + CONF_UNIT_OF_MEASUREMENT: UNIT_HERTZ, + CONF_DEVICE_CLASS: DEVICE_CLASS_FREQUENCY, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, +} + +# Pattern-based configurations. The remainder after the prefix must be a +# non-empty numeric index (like V1/I1/E1), so e.g. "APPLE" doesn't collide +# with the "AP" prefix and a bare "PF"/"AP" (no index) doesn't match. +# "PULSE" is the exception: some emonTx firmware (e.g. avrdb-based single-CT +# variants) reports a single pulse counter as a bare "pulse" tag with no +# numeric index at all, so that pattern also accepts an empty suffix. +PATTERNS_ALLOWING_BARE_TAG = {"PULSE"} + PATTERN_CONFIGS = { "PULSE": { CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, @@ -77,14 +125,21 @@ PATTERN_CONFIGS = { CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, CONF_ACCURACY_DECIMALS: 2, }, + "AP": { + CONF_UNIT_OF_MEASUREMENT: UNIT_VOLT_AMPS, + CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, } # 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. +# They are injected by apply_tag_defaults below, after running through the +# same validators sensor_schema() would use (see _DEFAULT_VALIDATORS) so the +# values are code-generation-ready. BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend( { cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), @@ -93,30 +148,43 @@ BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend( ) +_DEFAULT_VALIDATORS = { + CONF_STATE_CLASS: sensor.validate_state_class, + CONF_DEVICE_CLASS: sensor.validate_device_class, + CONF_UNIT_OF_MEASUREMENT: sensor.validate_unit_of_measurement, +} + + 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.""" + Values are run through the same validators sensor_schema() would use, so + they are code-generation-ready and a typo'd constant fails validation + instead of shipping silently.""" for key, value in defaults.items(): if key not in config: - if key == CONF_STATE_CLASS: - value = sensor.validate_state_class(value) + if key in _DEFAULT_VALIDATORS: + value = _DEFAULT_VALIDATORS[key](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] + tag_upper = tag.upper() + if (exact_config := EXACT_TAG_CONFIGS.get(tag_upper)) is not None: + _apply_defaults(config, exact_config) + return config + + for pattern, pattern_config in PATTERN_CONFIGS.items(): + suffix = tag_upper[len(pattern) :] + bare_ok = not suffix and pattern in PATTERNS_ALLOWING_BARE_TAG + if tag_upper.startswith(pattern) and (suffix.isdigit() or bare_ok): + _apply_defaults(config, pattern_config) + return config + + # Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3) if len(tag) >= 2: - tag_upper = tag.upper() - - for pattern, pattern_config in PATTERN_CONFIGS.items(): - if tag_upper.startswith(pattern): - _apply_defaults(config, pattern_config) - return config - - # Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3) prefix = tag_upper[0] if prefix in SENSOR_CONFIGS and tag[1:].isdigit(): _apply_defaults(config, SENSOR_CONFIGS[prefix]) diff --git a/tests/component_tests/emontx/test_sensor_defaults.py b/tests/component_tests/emontx/test_sensor_defaults.py index 00d24d282e..5c6a8e4108 100644 --- a/tests/component_tests/emontx/test_sensor_defaults.py +++ b/tests/component_tests/emontx/test_sensor_defaults.py @@ -6,9 +6,28 @@ from esphome.components import sensor from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults from esphome.const import ( CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_EMPTY, + UNIT_HERTZ, + UNIT_PULSES, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, + UNIT_WATT_HOURS, ) @@ -61,9 +80,25 @@ def _make_config(tag: str) -> dict: ("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0), ("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0), ("PF1", STATE_CLASS_MEASUREMENT, 2), + ("AP1", STATE_CLASS_MEASUREMENT, 2), + ("AP12", STATE_CLASS_MEASUREMENT, 2), + # Frequency: reported as a single, un-numbered tag + ("F", STATE_CLASS_MEASUREMENT, 2), # Unknown / free-form tags fall back to generic defaults ("CUSTOM1", STATE_CLASS_MEASUREMENT, 0), ("X", STATE_CLASS_MEASUREMENT, 0), + # "F1" is not the exact "F" tag, so it falls back to generic defaults + ("F1", STATE_CLASS_MEASUREMENT, 0), + # "PULSE" (no index) is how some real emonTx firmware reports a + # single pulse counter, so it still resolves to the PULSE defaults + ("PULSE", STATE_CLASS_TOTAL_INCREASING, 0), + # Real firmware sends this lowercase; tag_upper's case-folding must + # still match it against the PULSE pattern + ("pulse", STATE_CLASS_TOTAL_INCREASING, 0), + # PF/AP require a numeric index; the bare prefix alone (no index) + # falls back to generic defaults + ("PF", STATE_CLASS_MEASUREMENT, 0), + ("AP", STATE_CLASS_MEASUREMENT, 0), ], ) def test_apply_tag_defaults(tag, expected_state_class, expected_decimals): @@ -76,6 +111,80 @@ def test_apply_tag_defaults(tag, expected_state_class, expected_decimals): assert result[CONF_ACCURACY_DECIMALS] == expected_decimals +@pytest.mark.parametrize( + ("tag", "expected_unit", "expected_device_class"), + [ + # Known numeric-index prefixes + ("E1", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY), + ("E12", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY), + ("P1", UNIT_WATT, DEVICE_CLASS_POWER), + ("V1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE), + ("I1", UNIT_AMPERE, DEVICE_CLASS_CURRENT), + ("T1", UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE), + # Known patterns + ("PULSE1", UNIT_PULSES, DEVICE_CLASS_ENERGY), + ("PULSE12", UNIT_PULSES, DEVICE_CLASS_ENERGY), + # Bare "PULSE" (no index), as reported by some real emonTx firmware + ("PULSE", UNIT_PULSES, DEVICE_CLASS_ENERGY), + # Real firmware sends this lowercase; tag_upper's case-folding must + # still match it against the PULSE pattern + ("pulse", UNIT_PULSES, DEVICE_CLASS_ENERGY), + ("PF1", UNIT_EMPTY, DEVICE_CLASS_POWER_FACTOR), + ("AP1", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + ("AP12", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + # Frequency: reported as a single, un-numbered tag + ("F", UNIT_HERTZ, DEVICE_CLASS_FREQUENCY), + ], +) +def test_apply_tag_defaults_unit_and_device_class( + tag, expected_unit, expected_device_class +): + """apply_tag_defaults must inject the correct, validated unit_of_measurement + and device_class for each tag type when no user overrides are present.""" + config = _make_config(tag) + result = apply_tag_defaults(config) + + assert result[CONF_UNIT_OF_MEASUREMENT] == sensor.validate_unit_of_measurement( + expected_unit + ) + assert result[CONF_DEVICE_CLASS] == sensor.validate_device_class( + expected_device_class + ) + + +@pytest.mark.parametrize( + "tag", + [ + "CUSTOM1", + "X", + # Non-numeric suffixes must not collide with a PATTERN_CONFIGS prefix + # (e.g. "APPLE" starting with "AP", "PFX" starting with "PF"). + "APPLE", + "PFX", + "PULSE_A", + # "F1" is not the exact "F" tag + "F1", + # Bare "PF"/"AP" (no numeric index) don't match; unlike "PULSE", + # real firmware never reports these without an index + "PF", + "AP", + ], +) +def test_apply_tag_defaults_unknown_tag_has_no_unit_or_device_class(tag): + """Unknown / free-form tags only get generic state_class and + accuracy_decimals defaults; unit_of_measurement and device_class are left + for the user to set explicitly.""" + config = _make_config(tag) + result = apply_tag_defaults(config) + + assert CONF_UNIT_OF_MEASUREMENT not in result + assert CONF_DEVICE_CLASS not in result + assert result[CONF_STATE_CLASS] == sensor.validate_state_class( + STATE_CLASS_MEASUREMENT + ) + assert result[CONF_ACCURACY_DECIMALS] == 0 + + @pytest.mark.parametrize( ("tag", "user_state_class", "user_decimals"), [ diff --git a/tests/components/emontx/validate.esp32-idf.yaml b/tests/components/emontx/validate.esp32-idf.yaml index 7caee78a07..882ee26fcb 100644 --- a/tests/components/emontx/validate.esp32-idf.yaml +++ b/tests/components/emontx/validate.esp32-idf.yaml @@ -57,6 +57,21 @@ sensor: name: Power Factor 1 emontx_id: test_emontx + # Apparent power sensor (AP pattern): expects state_class=measurement, + # unit=VA, device_class=apparent_power, accuracy_decimals=2 + - platform: emontx + tag_name: AP1 + name: Apparent Power 1 + emontx_id: test_emontx + + # Frequency sensor (F, matched exactly, not as a prefix): expects + # state_class=measurement, unit=Hz, device_class=frequency, + # accuracy_decimals=2 + - platform: emontx + tag_name: F + name: Frequency + emontx_id: test_emontx + # Unknown tag: no prefix match, falls back to state_class=measurement, # accuracy_decimals=0 - platform: emontx