From 10d3ced897c50595e1f63d77b0116a64d2383d15 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:11:56 +0200 Subject: [PATCH] [core] Parse unit values as one decimal so listed floats match, fixing at581x power consumption (#20889) Co-authored-by: J. Nick Koston --- esphome/components/at581x/__init__.py | 2 +- esphome/config_validation.py | 11 +- tests/component_tests/at581x/__init__.py | 0 .../component_tests/at581x/test_validation.py | 105 ++++++++++++++++++ tests/components/at581x/common.yaml | 4 + tests/unit_tests/test_config_validation.py | 12 ++ 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/at581x/__init__.py create mode 100644 tests/component_tests/at581x/test_validation.py diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 50c9080c2dd..fd1a8002921 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -93,7 +93,7 @@ def _megahertz(value: float) -> int: def _microamps(value: float) -> int: - return int(value * 1000000) + return round(value * 1000000) RADAR_SETTINGS_SCHEMA = cv.Schema( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index e7d2501e5df..32da6e41855 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -16,6 +16,7 @@ from ipaddress import ( ip_network, ) import logging +import math import os from pathlib import Path import re @@ -1416,12 +1417,16 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False): if match is None: raise Invalid(f"Expected {quantity} with unit, got {value}") - mantissa = float(match.group(1)) if match.group(2) not in METRIC_SUFFIXES: raise Invalid(f"Invalid {quantity} suffix {match.group(2)}") - multiplier = METRIC_SUFFIXES[match.group(2)] - return mantissa * multiplier + # Parse the scaled decimal in one go: "91uA" is then the same float as the + # literal 91e-6, which a product of two floats is not always. + exponent = round(math.log10(METRIC_SUFFIXES[match.group(2)])) + try: + return float(f"{match.group(1)}e{exponent}") + except ValueError as err: + raise Invalid(f"Expected {quantity} with unit, got {value}") from err return validator diff --git a/tests/component_tests/at581x/__init__.py b/tests/component_tests/at581x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/at581x/test_validation.py b/tests/component_tests/at581x/test_validation.py new file mode 100644 index 00000000000..7ef209a4b6f --- /dev/null +++ b/tests/component_tests/at581x/test_validation.py @@ -0,0 +1,105 @@ +"""Tests for at581x power_consumption validation.""" + +from collections.abc import Callable +from decimal import Decimal + +import pytest + +from esphome import config_validation as cv +from esphome.components.at581x import CONF_POWER_CONSUMPTION, RADAR_SETTINGS_SCHEMA +from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import Lambda +from esphome.types import ConfigType + +ALLOWED_UA = [48, 56, 63, 70, 77, 91, 105, 115, 40, 44, 47, 51, 54, 61, 68, 78] + + +def _settings(**extra: object) -> ConfigType: + return RADAR_SETTINGS_SCHEMA( + {CONF_ID: "waveradar", CONF_FREQUENCY: "5800MHz", **extra} + ) + + +def _power(value: object) -> object: + return _settings(**{CONF_POWER_CONSUMPTION: value})[CONF_POWER_CONSUMPTION] + + +UNIT_SPELLINGS = [ + lambda ua: f"{ua}uA", + lambda ua: f"{ua}µA", + lambda ua: f"{ua} uA", + lambda ua: f"{Decimal(ua) / 1000}mA", + lambda ua: f"{Decimal(ua) / 1000000}A", + lambda ua: f"{ua * 1000}nA", +] + + +@pytest.mark.parametrize("spell", UNIT_SPELLINGS) +@pytest.mark.parametrize("microamps", ALLOWED_UA) +def test_every_listed_value_in_every_unit( + spell: Callable[[int], str], microamps: int +) -> None: + result = _power(spell(microamps)) + assert result == microamps + assert type(result) is int + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("91uA", 91), + ("105uA", 105), + ("115uA", 115), + ("40uA", 40), + ("0.07mA", 70), + ("0.091mA", 91), + ("0.000091A", 91), + ("0.000000091kA", 91), + ("+91uA", 91), + ], +) +def test_previously_broken_and_equivalent_spellings(value: str, expected: int) -> None: + assert _power(value) == expected + + +def test_default_is_70() -> None: + assert _settings()[CONF_POWER_CONSUMPTION] == 70 + + +def test_lambda_is_passed_through() -> None: + assert isinstance(_power(Lambda("return 70;")), Lambda) + + +@pytest.mark.parametrize( + "value", + [ + # Near-miss values must not be rounded onto a supported setting. + "91.00000000001uA", + "90.99999999999uA", + "91.00000000009uA", + "91.0000000001uA", + "90.9999999999uA", + "91.000000001uA", + "91.0001uA", + "91.4uA", + "0.09100000000001mA", + ], +) +def test_fractional_microamps_are_rejected(value: str) -> None: + with pytest.raises(cv.Invalid): + _power(value) + + +def test_sub_ulp_digits_are_indistinguishable_parse_noise() -> None: + # 1e-15 is below half an ulp of 91.0, so the mantissa parses to exactly 91.0. + assert float("91.000000000000001") == 91.0 + assert _power("91.000000000000001uA") == 91 + + +@pytest.mark.parametrize( + "value", + ["92uA", "0uA", "-91uA", "-0.091mA", "9" * 400 + "uA", "infuA", "nanuA", "nan"], +) +def test_unsupported_and_nonfinite_are_rejected(value: str) -> None: + with pytest.raises(cv.Invalid): + _power(value) diff --git a/tests/components/at581x/common.yaml b/tests/components/at581x/common.yaml index dfb3a9a05eb..fbe5bef16ea 100644 --- a/tests/components/at581x/common.yaml +++ b/tests/components/at581x/common.yaml @@ -17,6 +17,10 @@ esphome: frequency: !lambda "return 5800;" poweron_selfcheck_time: !lambda "return 2000;" power_consumption: !lambda "return 70;" + - at581x.settings: + id: waveradar + frequency: 5800MHz + power_consumption: 91uA - at581x.reset: id: waveradar diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 0f68ccb34ab..66732dd04db 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2101,6 +2101,18 @@ def test_float_with_unit_with_suffix() -> None: assert cv.frequency("10kHz") == 10000.0 +def test_float_with_unit_matches_the_literal() -> None: + """The scaled decimal is parsed once, so it equals the same value written as a literal.""" + assert cv.current("91uA") == 91e-6 + assert cv.current("0.091mA") == 91e-6 + assert cv.frequency("5.8GHz") == 5.8e9 + + +def test_float_with_unit_empty_mantissa() -> None: + with pytest.raises(cv.Invalid): + cv.current("uA") + + def test_float_with_unit_no_match() -> None: with pytest.raises(Invalid, match="Expected frequency with unit"): cv.frequency("!!")