mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 09:36:04 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb67f2b358 | ||
|
|
d22696f6ca | ||
|
|
bec2cafdba | ||
|
|
45a4a4429f | ||
|
|
448a9acb8e | ||
|
|
ef19c2cc45 | ||
|
|
84de79dddc | ||
|
|
3377b39790 | ||
|
|
28b30480a8 | ||
|
|
171799c188 | ||
|
|
30ed0b88f9 | ||
|
|
bd21a3d808 | ||
|
|
b261faf041 |
No files matched your search
@@ -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"})
|
||||
Reference in new issue
Block a user