[core] Parse unit values as one decimal so listed floats match, fixing at581x power consumption (#20889)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Igor Stadnyk
2026-10-16 10:11:56 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 6fa7b87e6a
commit 10d3ced897
6 changed files with 130 additions and 4 deletions
+1 -1
View File
@@ -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(
+8 -3
View File
@@ -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
@@ -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)
+4
View File
@@ -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
@@ -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("!!")