diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 7191ee50d0..e8c8037991 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1196,10 +1196,7 @@ def date_time(date: bool, time: bool): } ) - @schema_extractor("string") def validator(value): - if value is SCHEMA_EXTRACT: - return None if isinstance(value, dict): return schema(value) value = string(value) @@ -1367,13 +1364,15 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False): pattern = re.compile( f"^([-+]?[0-9]*\\.?[0-9]*)\\s*(\\w*?){regex_suffix}$", re.UNICODE ) + # First alternative of the accepted-units regex is the canonical unit + # (e.g. "(Hz|HZ|hz)?" -> "Hz"); reported to the schema dump so editors can + # label the numeric field with its unit. None for an empty suffix. + unit = regex_suffix.lstrip("(").split(")", 1)[0].split("|", 1)[0] or None @schema_extractor("float") def validator(value): if value is SCHEMA_EXTRACT: - # Report the specific quantity (e.g. "frequency", "voltage") so the - # schema dump can use it as the field type instead of a bare float. - return quantity + return unit if optional_unit: try: return float_(value) @@ -1397,8 +1396,8 @@ def float_with_unit(quantity, regex_suffix, optional_unit=False): bps = float_with_unit("bits per second", "(bps|bits/s|bit/s)?") frequency = float_with_unit("frequency", "(Hz|HZ|hz)?") resistance = float_with_unit("resistance", "(Ω|Ω|ohm|Ohm|OHM)?") -current = float_with_unit("current", "(a|A|amp|Amp|amps|Amps|ampere|Ampere)?") -voltage = float_with_unit("voltage", "(v|V|volt|Volts)?") +current = float_with_unit("current", "(A|a|amp|Amp|amps|Amps|ampere|Ampere)?") +voltage = float_with_unit("voltage", "(V|v|volt|Volts)?") distance = float_with_unit("distance", "(m)") framerate = float_with_unit("framerate", "(FPS|fps|Fps|FpS|Hz)") angle = float_with_unit("angle", "(°|deg)", optional_unit=True) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index fe01d4e3c2..1fddffda86 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -155,12 +155,7 @@ _CV_STRING_VALIDATORS = ( cv.dimensions, cv.none, ) -_CV_INTEGER_VALIDATORS = ( - cv.hex_int, - cv.percentage_int, - cv.mqtt_qos, - cv.validate_bytes, -) +_CV_INTEGER_VALIDATORS = (cv.hex_int, cv.percentage_int, cv.mqtt_qos) _CV_FLOAT_VALIDATORS = ( cv.percentage, cv.possibly_negative_percentage, @@ -1129,10 +1124,12 @@ def convert(schema, config_var, path): config_var["registry"] = "light.effects" config_var["filter"] = data[0] elif schema_type == "float": - # cv.float_with_unit returns its quantity name (e.g. "frequency") - # for SCHEMA_EXTRACT, so the field type is the specific quantity - # rather than a bare "float". Other float sources return None. - config_var[S_TYPE] = data if isinstance(data, str) else "float" + config_var[S_TYPE] = "float" + # cv.float_with_unit reports its canonical unit (e.g. "Hz", "V") for + # SCHEMA_EXTRACT; surface it next to the type so editors can label + # the field. Other float sources return None (no unit). + if isinstance(data, str): + config_var["unit"] = data elif schema_type in ("string", "integer", "time", "lambda"): # Scalar validators (e.g. cv.date_time) that declare their result # type via schema_extractor. ``data`` is unused (the decorated diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 83d084bc89..f7dc472bed 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -333,7 +333,6 @@ def _convert(validator: object) -> dict: (cv.directory, "string"), (cv.mqtt_qos, "integer"), (cv.hex_int, "integer"), - (cv.validate_bytes, "integer"), (cv.percentage, "float"), (cv.temperature, "float"), (cv.color_temperature, "float"), @@ -378,28 +377,32 @@ def test_convert_scalar_schema_extractor(scalar: str) -> None: del ejs.hidden_schemas[repr(decorated)] -def test_convert_float_with_unit_uses_quantity_as_type() -> None: - """A float schema_extractor whose probe returns a quantity types by quantity.""" +def test_convert_float_with_unit_reports_unit() -> None: + """A float schema_extractor whose probe returns a unit types float + unit.""" import voluptuous as vol from esphome import schema_extractors as ejs def frequency_validator(value: object) -> object: - return "frequency" if value is ejs.SCHEMA_EXTRACT else value + return "Hz" if value is ejs.SCHEMA_EXTRACT else value ejs.hidden_schemas[repr(frequency_validator)] = "float" try: - assert _convert(frequency_validator).get("type") == "frequency" + entry = _convert(frequency_validator) + assert entry.get("type") == "float" + assert entry.get("unit") == "Hz" finally: del ejs.hidden_schemas[repr(frequency_validator)] - # A float source that does not name a quantity falls back to "float". + # A float source with no unit is a bare float (no unit key). def plain_float(value: object) -> object: return None if value is ejs.SCHEMA_EXTRACT else value ejs.hidden_schemas[repr(plain_float)] = "float" try: - assert _convert(plain_float).get("type") == "float" + entry = _convert(plain_float) + assert entry.get("type") == "float" + assert "unit" not in entry finally: del ejs.hidden_schemas[repr(plain_float)] @@ -461,13 +464,14 @@ def test_cv_types_end_to_end(full_schema_dir: Path) -> None: ]["config_vars"]["abbwelcome"]["schema"]["config_vars"] assert abbwelcome["message_type"]["type"] == "integer" - # cv.All(cv.frequency, cv.float_range(45, 66)) -> quantity type + min/max + # cv.All(cv.frequency, cv.float_range(45, 66)) -> float + unit + min/max # inline, next to type. ade = json.loads((full_schema_dir / "ade7880.json").read_text()) freq = ade["ade7880.sensor"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"][ "frequency" ] - assert freq["type"] == "frequency" + assert freq["type"] == "float" + assert freq["unit"] == "Hz" assert freq["min"] == 45.0 assert freq["max"] == 66.0 diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index c60f9a040a..6a43d0230b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1642,31 +1642,19 @@ def test_templatable_schema_extract() -> None: @pytest.mark.parametrize( - ("validator", "quantity"), + ("validator", "unit"), [ - (cv.float_with_unit("frequency", "(Hz)?"), "frequency"), - (cv.frequency, "frequency"), - (cv.voltage, "voltage"), - (cv.decibel, "decibel"), + (cv.float_with_unit("frequency", "(Hz|HZ|hz)?"), "Hz"), + (cv.frequency, "Hz"), + (cv.voltage, "V"), + (cv.current, "A"), + (cv.decibel, "dB"), ], ) -def test_float_with_unit_schema_extract(validator: object, quantity: str) -> None: - # For the SCHEMA_EXTRACT sentinel the validator returns its quantity name - # (not the parsed value) so build_language_schema can type the field by - # quantity (e.g. "frequency") instead of a bare float. - assert validator(SCHEMA_EXTRACT) == quantity - - -@pytest.mark.parametrize( - "validator", - [ - cv.date_time(date=True, time=False), - cv.date_time(date=False, time=True), - cv.date_time(date=True, time=True), - ], -) -def test_date_time_schema_extract(validator: object) -> None: - assert validator(SCHEMA_EXTRACT) is None +def test_float_with_unit_schema_extract(validator: object, unit: str) -> None: + # For the SCHEMA_EXTRACT sentinel the validator returns its canonical unit + # (not the parsed value) so build_language_schema can label the float field. + assert validator(SCHEMA_EXTRACT) == unit def test_templatable_lambda() -> None: