Compare commits

...
2 changed files with 343 additions and 1 deletions

No files matched your search

+126 -1
View File
@@ -1,6 +1,6 @@
import logging
import math
from typing import Any
from typing import Any, NamedTuple
from esphome import automation
import esphome.codegen as cg
@@ -112,6 +112,36 @@ from esphome.const import (
DEVICE_CLASS_WIND_DIRECTION,
DEVICE_CLASS_WIND_SPEED,
ENTITY_CATEGORY_CONFIG,
ICON_CHEMICAL_WEAPON,
ICON_TIMER,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_DECIBEL_MILLIWATT,
UNIT_DEGREES,
UNIT_HECTOPASCAL,
UNIT_HERTZ,
UNIT_HOUR,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_LUX,
UNIT_METER,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_MILLIMETER,
UNIT_MINUTE,
UNIT_OHM,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.core import (
CORE,
@@ -392,9 +422,90 @@ _SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor"))
_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_sensor"))
class SensorKind(NamedTuple):
"""Values `sensor_schema(kind=...)` sets; unset fields are left to the caller.
`state_class` is always set (measurement by default). `accuracy_decimals` is only a
default, as sensors differ; the caller's value wins. Passing any other field the kind
sets is an error.
"""
unit_of_measurement: str = cv.UNDEFINED
device_class: str = cv.UNDEFINED
state_class: str = STATE_CLASS_MEASUREMENT
icon: str = cv.UNDEFINED
accuracy_decimals: int = cv.UNDEFINED
TEMPERATURE = SensorKind(UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE, accuracy_decimals=1)
HUMIDITY = SensorKind(UNIT_PERCENT, DEVICE_CLASS_HUMIDITY, accuracy_decimals=1)
BATTERY = SensorKind(UNIT_PERCENT, DEVICE_CLASS_BATTERY, accuracy_decimals=0)
PRESSURE = SensorKind(UNIT_HECTOPASCAL, DEVICE_CLASS_PRESSURE, accuracy_decimals=1)
ILLUMINANCE = SensorKind(UNIT_LUX, DEVICE_CLASS_ILLUMINANCE, accuracy_decimals=0)
CARBON_DIOXIDE = SensorKind(
UNIT_PARTS_PER_MILLION, DEVICE_CLASS_CARBON_DIOXIDE, accuracy_decimals=0
)
MASS_CONCENTRATION = SensorKind(UNIT_MICROGRAMS_PER_CUBIC_METER, accuracy_decimals=0)
def _particulate(device_class: str) -> SensorKind:
return MASS_CONCENTRATION._replace(
device_class=device_class, icon=ICON_CHEMICAL_WEAPON
)
PM1 = _particulate(DEVICE_CLASS_PM1)
PM25 = _particulate(DEVICE_CLASS_PM25)
PM10 = _particulate(DEVICE_CLASS_PM10)
SIGNAL_STRENGTH = SensorKind(
UNIT_DECIBEL_MILLIWATT, DEVICE_CLASS_SIGNAL_STRENGTH, accuracy_decimals=0
)
VOLTAGE = SensorKind(UNIT_VOLT, DEVICE_CLASS_VOLTAGE)
CURRENT = SensorKind(UNIT_AMPERE, DEVICE_CLASS_CURRENT)
FREQUENCY = SensorKind(UNIT_HERTZ, DEVICE_CLASS_FREQUENCY)
POWER = SensorKind(UNIT_WATT, DEVICE_CLASS_POWER)
POWER_KW = SensorKind(UNIT_KILOWATT, DEVICE_CLASS_POWER, accuracy_decimals=3)
APPARENT_POWER = SensorKind(UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER)
APPARENT_POWER_KVA = SensorKind(
UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, accuracy_decimals=3
)
REACTIVE_POWER = SensorKind(UNIT_VOLT_AMPS_REACTIVE, DEVICE_CLASS_REACTIVE_POWER)
REACTIVE_POWER_KVAR = SensorKind(
UNIT_KILOVOLT_AMPS_REACTIVE, DEVICE_CLASS_REACTIVE_POWER, accuracy_decimals=3
)
POWER_FACTOR = SensorKind(device_class=DEVICE_CLASS_POWER_FACTOR)
ENERGY_WH = SensorKind(
UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING
)
ENERGY_KWH = SensorKind(
UNIT_KILOWATT_HOURS,
DEVICE_CLASS_ENERGY,
STATE_CLASS_TOTAL_INCREASING,
accuracy_decimals=3,
)
# No device class, matching the meters that report it today.
REACTIVE_ENERGY_KVARH = SensorKind(
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
state_class=STATE_CLASS_TOTAL_INCREASING,
accuracy_decimals=3,
)
DISTANCE_MM = SensorKind(UNIT_MILLIMETER, DEVICE_CLASS_DISTANCE, accuracy_decimals=0)
DURATION_H = SensorKind(
UNIT_HOUR, DEVICE_CLASS_DURATION, icon=ICON_TIMER, accuracy_decimals=0
)
DURATION_MIN = SensorKind(UNIT_MINUTE, DEVICE_CLASS_DURATION, accuracy_decimals=0)
DURATION_S = SensorKind(UNIT_SECOND, DEVICE_CLASS_DURATION, accuracy_decimals=0)
# Generic quantities with no device class.
PERCENT = SensorKind(UNIT_PERCENT, accuracy_decimals=0)
ANGLE = SensorKind(UNIT_DEGREES)
LENGTH_M = SensorKind(UNIT_METER, accuracy_decimals=2)
RESISTANCE = SensorKind(UNIT_OHM, accuracy_decimals=0)
def sensor_schema(
class_: MockObjClass = cv.UNDEFINED,
*,
kind: SensorKind = cv.UNDEFINED,
unit_of_measurement: str = cv.UNDEFINED,
icon: str = cv.UNDEFINED,
accuracy_decimals: int = cv.UNDEFINED,
@@ -403,6 +514,20 @@ def sensor_schema(
entity_category: str = cv.UNDEFINED,
filters: list = cv.UNDEFINED,
) -> cv.Schema:
"""Sensor schema; `kind` presets the unit, device class, state class, icon and decimals."""
if kind is not cv.UNDEFINED:
if accuracy_decimals is not cv.UNDEFINED:
kind = kind._replace(accuracy_decimals=accuracy_decimals)
given = (unit_of_measurement, device_class, state_class, icon, cv.UNDEFINED)
merged = []
for name, preset, value in zip(kind._fields, kind, given, strict=True):
if preset is not cv.UNDEFINED and value is not cv.UNDEFINED:
raise ValueError(f"kind already sets {name}")
merged.append(value if preset is cv.UNDEFINED else preset)
unit_of_measurement, device_class, state_class, icon, accuracy_decimals = (
SensorKind(*merged)
)
schema = {}
if class_ is not cv.UNDEFINED:
@@ -0,0 +1,217 @@
"""Tests for the sensor_schema kind presets."""
from typing import Any
import pytest
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DISTANCE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
DEVICE_CLASS_PM25,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_REACTIVE_POWER,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_CHEMICAL_WEAPON,
ICON_TIMER,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_DECIBEL_MILLIWATT,
UNIT_DEGREES,
UNIT_HECTOPASCAL,
UNIT_HERTZ,
UNIT_HOUR,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_LUX,
UNIT_METER,
UNIT_MICROGRAMS_PER_CUBIC_METER,
UNIT_MILLIMETER,
UNIT_MINUTE,
UNIT_OHM,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
UNIT_WATT_HOURS,
)
_M = STATE_CLASS_MEASUREMENT
_T = STATE_CLASS_TOTAL_INCREASING
_PM = UNIT_MICROGRAMS_PER_CUBIC_METER
_UNSET = cv.UNDEFINED
# (unit, device class, state class, icon, accuracy decimals) each preset must expand to
EXPECTED = {
"TEMPERATURE": (UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE, _M, _UNSET, 1),
"HUMIDITY": (UNIT_PERCENT, DEVICE_CLASS_HUMIDITY, _M, _UNSET, 1),
"BATTERY": (UNIT_PERCENT, DEVICE_CLASS_BATTERY, _M, _UNSET, 0),
"PRESSURE": (UNIT_HECTOPASCAL, DEVICE_CLASS_PRESSURE, _M, _UNSET, 1),
"ILLUMINANCE": (UNIT_LUX, DEVICE_CLASS_ILLUMINANCE, _M, _UNSET, 0),
"CARBON_DIOXIDE": (
UNIT_PARTS_PER_MILLION,
DEVICE_CLASS_CARBON_DIOXIDE,
_M,
_UNSET,
0,
),
"PM1": (_PM, DEVICE_CLASS_PM1, _M, ICON_CHEMICAL_WEAPON, 0),
"PM25": (_PM, DEVICE_CLASS_PM25, _M, ICON_CHEMICAL_WEAPON, 0),
"PM10": (_PM, DEVICE_CLASS_PM10, _M, ICON_CHEMICAL_WEAPON, 0),
"SIGNAL_STRENGTH": (
UNIT_DECIBEL_MILLIWATT,
DEVICE_CLASS_SIGNAL_STRENGTH,
_M,
_UNSET,
0,
),
"VOLTAGE": (UNIT_VOLT, DEVICE_CLASS_VOLTAGE, _M, _UNSET, _UNSET),
"CURRENT": (UNIT_AMPERE, DEVICE_CLASS_CURRENT, _M, _UNSET, _UNSET),
"POWER": (UNIT_WATT, DEVICE_CLASS_POWER, _M, _UNSET, _UNSET),
"APPARENT_POWER": (
UNIT_VOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
_M,
_UNSET,
_UNSET,
),
"REACTIVE_POWER": (
UNIT_VOLT_AMPS_REACTIVE,
DEVICE_CLASS_REACTIVE_POWER,
_M,
_UNSET,
_UNSET,
),
"FREQUENCY": (UNIT_HERTZ, DEVICE_CLASS_FREQUENCY, _M, _UNSET, _UNSET),
"ENERGY_KWH": (UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY, _T, _UNSET, 3),
"ENERGY_WH": (UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, _T, _UNSET, _UNSET),
"POWER_KW": (UNIT_KILOWATT, DEVICE_CLASS_POWER, _M, _UNSET, 3),
"APPARENT_POWER_KVA": (
UNIT_KILOVOLT_AMPS,
DEVICE_CLASS_APPARENT_POWER,
_M,
_UNSET,
3,
),
"REACTIVE_POWER_KVAR": (
UNIT_KILOVOLT_AMPS_REACTIVE,
DEVICE_CLASS_REACTIVE_POWER,
_M,
_UNSET,
3,
),
"REACTIVE_ENERGY_KVARH": (UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, _UNSET, _T, _UNSET, 3),
"POWER_FACTOR": (_UNSET, DEVICE_CLASS_POWER_FACTOR, _M, _UNSET, _UNSET),
"PERCENT": (UNIT_PERCENT, _UNSET, _M, _UNSET, 0),
"ANGLE": (UNIT_DEGREES, _UNSET, _M, _UNSET, _UNSET),
"MASS_CONCENTRATION": (_PM, _UNSET, _M, _UNSET, 0),
"LENGTH_M": (UNIT_METER, _UNSET, _M, _UNSET, 2),
"DISTANCE_MM": (UNIT_MILLIMETER, DEVICE_CLASS_DISTANCE, _M, _UNSET, 0),
"RESISTANCE": (UNIT_OHM, _UNSET, _M, _UNSET, 0),
"DURATION_H": (UNIT_HOUR, DEVICE_CLASS_DURATION, _M, ICON_TIMER, 0),
"DURATION_MIN": (UNIT_MINUTE, DEVICE_CLASS_DURATION, _M, _UNSET, 0),
"DURATION_S": (UNIT_SECOND, DEVICE_CLASS_DURATION, _M, _UNSET, 0),
}
def _keys(schema: cv.Schema) -> list[tuple[Any, ...]]:
"""Key type, name and validator of every schema entry, in order."""
return [
(type(key), str(key), validator) for key, validator in schema.schema.items()
]
def _defaults(schema: cv.Schema) -> dict[str, Any]:
return {
str(key): key.default()
for key in schema.schema
if isinstance(key, cv.Optional) and key.default is not cv.UNDEFINED
}
def test_every_preset_is_pinned() -> None:
kinds = {
name
for name, value in vars(sensor).items()
if isinstance(value, sensor.SensorKind)
}
assert kinds == EXPECTED.keys()
@pytest.mark.parametrize("name", sorted(EXPECTED))
def test_kind_matches_hand_written(name: str) -> None:
unit, device_class, state_class, icon, decimals = EXPECTED[name]
by_kind = sensor.sensor_schema(kind=getattr(sensor, name))
by_hand = sensor.sensor_schema(
unit_of_measurement=unit,
icon=icon,
accuracy_decimals=decimals,
device_class=device_class,
state_class=state_class,
)
assert _keys(by_kind) == _keys(by_hand)
assert _defaults(by_kind) == _defaults(by_hand)
@pytest.mark.parametrize("kind", [sensor.TEMPERATURE, sensor.VOLTAGE])
def test_accuracy_decimals_can_be_overridden(kind: sensor.SensorKind) -> None:
schema = sensor.sensor_schema(kind=kind, accuracy_decimals=3)
assert _defaults(schema)["accuracy_decimals"] == 3
def test_extra_arguments_pass_through() -> None:
extra = {
"accuracy_decimals": 2,
"icon": "mdi:thermometer",
"entity_category": ENTITY_CATEGORY_DIAGNOSTIC,
"filters": [{"offset": 1.0}],
}
by_kind = sensor.sensor_schema(kind=sensor.TEMPERATURE, **extra)
by_hand = sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
**extra,
)
assert _keys(by_kind) == _keys(by_hand)
assert _defaults(by_kind) == _defaults(by_hand)
@pytest.mark.parametrize(
("kind", "key"),
[
(sensor.TEMPERATURE, "unit_of_measurement"),
(sensor.TEMPERATURE, "device_class"),
(sensor.TEMPERATURE, "state_class"),
(sensor.PM10, "icon"),
],
)
def test_kind_with_explicit_value_is_rejected(
kind: sensor.SensorKind, key: str
) -> None:
with pytest.raises(ValueError, match=f"kind already sets {key}"):
sensor.sensor_schema(kind=kind, **{key: "x"})