core/homeassistant/components/number/__init__.py

855 lines
25 KiB
Python

"""Component to allow numeric input for platforms."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
import dataclasses
from datetime import timedelta
import inspect
import logging
from math import ceil, floor
from typing import Any, Final, final
import voluptuous as vol
from homeassistant.backports.enum import StrEnum
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter
from .const import (
ATTR_MAX,
ATTR_MIN,
ATTR_STEP,
ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
DOMAIN,
SERVICE_SET_VALUE,
)
SCAN_INTERVAL = timedelta(seconds=30)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
_LOGGER = logging.getLogger(__name__)
class NumberDeviceClass(StrEnum):
"""Device class for numbers."""
# NumberDeviceClass should be aligned with SensorDeviceClass
APPARENT_POWER = "apparent_power"
"""Apparent power.
Unit of measurement: `VA`
"""
AQI = "aqi"
"""Air Quality Index.
Unit of measurement: `None`
"""
ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
"""Atmospheric pressure.
Unit of measurement: `UnitOfPressure` units
"""
BATTERY = "battery"
"""Percentage of battery that is left.
Unit of measurement: `%`
"""
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
Unit of measurement: `ppm` (parts per million)
"""
CO2 = "carbon_dioxide"
"""Carbon Dioxide gas concentration.
Unit of measurement: `ppm` (parts per million)
"""
CURRENT = "current"
"""Current.
Unit of measurement: `A`
"""
DATA_RATE = "data_rate"
"""Data rate.
Unit of measurement: UnitOfDataRate
"""
DATA_SIZE = "data_size"
"""Data size.
Unit of measurement: UnitOfInformation
"""
DISTANCE = "distance"
"""Generic distance.
Unit of measurement: `LENGTH_*` units
- SI /metric: `mm`, `cm`, `m`, `km`
- USCS / imperial: `in`, `ft`, `yd`, `mi`
"""
ENERGY = "energy"
"""Energy.
Unit of measurement: `Wh`, `kWh`, `MWh`, `GJ`
"""
FREQUENCY = "frequency"
"""Frequency.
Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz`
"""
GAS = "gas"
"""Gas.
Unit of measurement:
- SI / metric: `m³`
- USCS / imperial: `ft³`, `CCF`
"""
HUMIDITY = "humidity"
"""Relative humidity.
Unit of measurement: `%`
"""
ILLUMINANCE = "illuminance"
"""Illuminance.
Unit of measurement: `lx`
"""
IRRADIANCE = "irradiance"
"""Irradiance.
Unit of measurement:
- SI / metric: `W/m²`
- USCS / imperial: `BTU/(h⋅ft²)`
"""
MOISTURE = "moisture"
"""Moisture.
Unit of measurement: `%`
"""
MONETARY = "monetary"
"""Amount of money.
Unit of measurement: ISO4217 currency code
See https://en.wikipedia.org/wiki/ISO_4217#Active_codes for active codes
"""
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `µg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
"""Amount of NO.
Unit of measurement: `µg/m³`
"""
NITROUS_OXIDE = "nitrous_oxide"
"""Amount of N2O.
Unit of measurement: `µg/m³`
"""
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `µg/m³`
"""
PM1 = "pm1"
"""Particulate matter <= 0.1 μm.
Unit of measurement: `µg/m³`
"""
PM10 = "pm10"
"""Particulate matter <= 10 μm.
Unit of measurement: `µg/m³`
"""
PM25 = "pm25"
"""Particulate matter <= 2.5 μm.
Unit of measurement: `µg/m³`
"""
POWER_FACTOR = "power_factor"
"""Power factor.
Unit of measurement: `%`
"""
POWER = "power"
"""Power.
Unit of measurement: `W`, `kW`
"""
PRECIPITATION = "precipitation"
"""Precipitation.
Unit of measurement: UnitOfPrecipitationDepth
- SI / metric: `cm`, `mm`
- USCS / imperial: `in`
"""
PRECIPITATION_INTENSITY = "precipitation_intensity"
"""Precipitation intensity.
Unit of measurement: UnitOfVolumetricFlux
- SI /metric: `mm/d`, `mm/h`
- USCS / imperial: `in/d`, `in/h`
"""
PRESSURE = "pressure"
"""Pressure.
Unit of measurement:
- `mbar`, `cbar`, `bar`
- `Pa`, `hPa`, `kPa`
- `inHg`
- `psi`
"""
REACTIVE_POWER = "reactive_power"
"""Reactive power.
Unit of measurement: `var`
"""
SIGNAL_STRENGTH = "signal_strength"
"""Signal strength.
Unit of measurement: `dB`, `dBm`
"""
SOUND_PRESSURE = "sound_pressure"
"""Sound pressure.
Unit of measurement: `dB`, `dBA`
"""
SPEED = "speed"
"""Generic speed.
Unit of measurement: `SPEED_*` units or `UnitOfVolumetricFlux`
- SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h`
- USCS / imperial: `in/d`, `in/h`, `ft/s`, `mph`
- Nautical: `kn`
"""
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `µg/m³`
"""
TEMPERATURE = "temperature"
"""Temperature.
Unit of measurement: `°C`, `°F`
"""
VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
"""Amount of VOC.
Unit of measurement: `µg/m³`
"""
VOLTAGE = "voltage"
"""Voltage.
Unit of measurement: `V`
"""
VOLUME = "volume"
"""Generic volume.
Unit of measurement: `VOLUME_*` units
- SI / metric: `mL`, `L`, `m³`
- USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
WATER = "water"
"""Water.
Unit of measurement:
- SI / metric: `m³`, `L`
- USCS / imperial: `ft³`, `CCF`, `gal` (warning: volumes expressed in
USCS/imperial units are currently assumed to be US volumes)
"""
WEIGHT = "weight"
"""Generic weight, represents a measurement of an object's mass.
Weight is used instead of mass to fit with every day language.
Unit of measurement: `MASS_*` units
- SI / metric: `µg`, `mg`, `g`, `kg`
- USCS / imperial: `oz`, `lb`
"""
WIND_SPEED = "wind_speed"
"""Wind speed.
Unit of measurement: `SPEED_*` units
- SI /metric: `m/s`, `km/h`
- USCS / imperial: `ft/s`, `mph`
- Nautical: `kn`
"""
DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass))
class NumberMode(StrEnum):
"""Modes for number entities."""
AUTO = "auto"
BOX = "box"
SLIDER = "slider"
UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = {
NumberDeviceClass.TEMPERATURE: TemperatureConverter,
}
# mypy: disallow-any-generics
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Number entities."""
component = hass.data[DOMAIN] = EntityComponent[NumberEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
component.async_register_entity_service(
SERVICE_SET_VALUE,
{vol.Required(ATTR_VALUE): vol.Coerce(float)},
async_set_value,
)
return True
async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> None:
"""Service call wrapper to set a new value."""
value = service_call.data["value"]
if value < entity.min_value or value > entity.max_value:
raise ValueError(
f"Value {value} for {entity.name} is outside valid range"
f" {entity.min_value} - {entity.max_value}"
)
try:
native_value = entity.convert_to_native_value(value)
# Clamp to the native range
native_value = min(
max(native_value, entity.native_min_value), entity.native_max_value
)
await entity.async_set_native_value(native_value)
except NotImplementedError:
await entity.async_set_value(value)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
component: EntityComponent[NumberEntity] = hass.data[DOMAIN]
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[NumberEntity] = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
@dataclasses.dataclass
class NumberEntityDescription(EntityDescription):
"""A class that describes number entities."""
device_class: NumberDeviceClass | None = None
max_value: None = None
min_value: None = None
native_max_value: float | None = None
native_min_value: float | None = None
native_unit_of_measurement: str | None = None
native_step: float | None = None
step: None = None
unit_of_measurement: None = None # Type override, use native_unit_of_measurement
def __post_init__(self) -> None:
"""Post initialisation processing."""
if (
self.max_value is not None
or self.min_value is not None
or self.step is not None
or self.unit_of_measurement is not None
):
if self.__class__.__name__ == "NumberEntityDescription": # type: ignore[unreachable]
caller = inspect.stack()[2]
module = inspect.getmodule(caller[0])
else:
module = inspect.getmodule(self)
if module and module.__file__ and "custom_components" in module.__file__:
report_issue = "report it to the custom integration author."
else:
report_issue = (
"create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue"
)
_LOGGER.warning(
(
"%s is setting deprecated attributes on an instance of"
" NumberEntityDescription, this is not valid and will be"
" unsupported from Home Assistant 2022.10. Please %s"
),
module.__name__ if module else self.__class__.__name__,
report_issue,
)
self.native_unit_of_measurement = self.unit_of_measurement
def ceil_decimal(value: float, precision: float = 0) -> float:
"""Return the ceiling of f with d decimals.
This is a simple implementation which ignores floating point inexactness.
"""
factor = 10**precision
return ceil(value * factor) / factor
def floor_decimal(value: float, precision: float = 0) -> float:
"""Return the floor of f with d decimals.
This is a simple implementation which ignores floating point inexactness.
"""
factor = 10**precision
return floor(value * factor) / factor
class NumberEntity(Entity):
"""Representation of a Number entity."""
entity_description: NumberEntityDescription
_attr_device_class: NumberDeviceClass | None
_attr_max_value: None
_attr_min_value: None
_attr_mode: NumberMode = NumberMode.AUTO
_attr_state: None = None
_attr_step: None
_attr_unit_of_measurement: None # Subclasses of NumberEntity should not set this
_attr_value: None
_attr_native_max_value: float
_attr_native_min_value: float
_attr_native_step: float
_attr_native_value: float | None = None
_attr_native_unit_of_measurement: str | None
_deprecated_number_entity_reported = False
_number_option_unit_of_measurement: str | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if any(
method in cls.__dict__
for method in (
"async_set_value",
"max_value",
"min_value",
"set_value",
"step",
"unit_of_measurement",
"value",
)
):
module = inspect.getmodule(cls)
if module and module.__file__ and "custom_components" in module.__file__:
report_issue = "report it to the custom integration author."
else:
report_issue = (
"create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue"
)
_LOGGER.warning(
(
"%s::%s is overriding deprecated methods on an instance of "
"NumberEntity, this is not valid and will be unsupported "
"from Home Assistant 2022.10. Please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
async def async_internal_added_to_hass(self) -> None:
"""Call when the number entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self.async_registry_entry_updated()
@property
def capability_attributes(self) -> dict[str, Any]:
"""Return capability attributes."""
return {
ATTR_MIN: self.min_value,
ATTR_MAX: self.max_value,
ATTR_STEP: self.step,
ATTR_MODE: self.mode,
}
@property
def device_class(self) -> NumberDeviceClass | None:
"""Return the class of this entity."""
if hasattr(self, "_attr_device_class"):
return self._attr_device_class
if hasattr(self, "entity_description"):
return self.entity_description.device_class
return None
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
if hasattr(self, "_attr_native_min_value"):
return self._attr_native_min_value
if (
hasattr(self, "entity_description")
and self.entity_description.native_min_value is not None
):
return self.entity_description.native_min_value
return DEFAULT_MIN_VALUE
@property
@final
def min_value(self) -> float:
"""Return the minimum value."""
if hasattr(self, "_attr_min_value"):
self._report_deprecated_number_entity()
return self._attr_min_value # type: ignore[return-value]
if (
hasattr(self, "entity_description")
and self.entity_description.min_value is not None
):
self._report_deprecated_number_entity() # type: ignore[unreachable]
return self.entity_description.min_value
return self._convert_to_state_value(self.native_min_value, floor_decimal)
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
if hasattr(self, "_attr_native_max_value"):
return self._attr_native_max_value
if (
hasattr(self, "entity_description")
and self.entity_description.native_max_value is not None
):
return self.entity_description.native_max_value
return DEFAULT_MAX_VALUE
@property
@final
def max_value(self) -> float:
"""Return the maximum value."""
if hasattr(self, "_attr_max_value"):
self._report_deprecated_number_entity()
return self._attr_max_value # type: ignore[return-value]
if (
hasattr(self, "entity_description")
and self.entity_description.max_value is not None
):
self._report_deprecated_number_entity() # type: ignore[unreachable]
return self.entity_description.max_value
return self._convert_to_state_value(self.native_max_value, ceil_decimal)
@property
def native_step(self) -> float | None:
"""Return the increment/decrement step."""
if (
hasattr(self, "entity_description")
and self.entity_description.native_step is not None
):
return self.entity_description.native_step
return None
@property
@final
def step(self) -> float:
"""Return the increment/decrement step."""
if hasattr(self, "_attr_step"):
self._report_deprecated_number_entity()
return self._attr_step # type: ignore[return-value]
if (
hasattr(self, "entity_description")
and self.entity_description.step is not None
):
self._report_deprecated_number_entity() # type: ignore[unreachable]
return self.entity_description.step
if hasattr(self, "_attr_native_step"):
return self._attr_native_step
if (native_step := self.native_step) is not None:
return native_step
step = DEFAULT_STEP
value_range = abs(self.max_value - self.min_value)
if value_range != 0:
while value_range <= step:
step /= 10.0
return step
@property
def mode(self) -> NumberMode:
"""Return the mode of the entity."""
return self._attr_mode
@property
@final
def state(self) -> float | None:
"""Return the entity state."""
return self.value
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of the entity, if any."""
if hasattr(self, "_attr_native_unit_of_measurement"):
return self._attr_native_unit_of_measurement
if hasattr(self, "entity_description"):
return self.entity_description.native_unit_of_measurement
return None
@property
@final
def unit_of_measurement(self) -> str | None:
"""Return the unit of measurement of the entity, after unit conversion."""
if self._number_option_unit_of_measurement:
return self._number_option_unit_of_measurement
if hasattr(self, "_attr_unit_of_measurement"):
self._report_deprecated_number_entity()
return self._attr_unit_of_measurement
if (
hasattr(self, "entity_description")
and self.entity_description.unit_of_measurement is not None
):
return self.entity_description.unit_of_measurement # type: ignore[unreachable]
native_unit_of_measurement = self.native_unit_of_measurement
if (
self.device_class == NumberDeviceClass.TEMPERATURE
and native_unit_of_measurement
in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT)
):
return self.hass.config.units.temperature_unit
return native_unit_of_measurement
@property
def native_value(self) -> float | None:
"""Return the value reported by the number."""
return self._attr_native_value
@property
@final
def value(self) -> float | None:
"""Return the entity value to represent the entity state."""
if hasattr(self, "_attr_value"):
self._report_deprecated_number_entity()
return self._attr_value
if (native_value := self.native_value) is None:
return native_value
return self._convert_to_state_value(native_value, round)
def set_native_value(self, value: float) -> None:
"""Set new value."""
raise NotImplementedError()
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.hass.async_add_executor_job(self.set_native_value, value)
@final
def set_value(self, value: float) -> None:
"""Set new value."""
raise NotImplementedError()
@final
async def async_set_value(self, value: float) -> None:
"""Set new value."""
await self.hass.async_add_executor_job(self.set_value, value)
def _convert_to_state_value(
self, value: float, method: Callable[[float, int], float]
) -> float:
"""Convert a value in the number's native unit to the configured unit."""
native_unit_of_measurement = self.native_unit_of_measurement
unit_of_measurement = self.unit_of_measurement
device_class = self.device_class
if (
native_unit_of_measurement != unit_of_measurement
and device_class in UNIT_CONVERTERS
):
assert native_unit_of_measurement
assert unit_of_measurement
value_s = str(value)
prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0
# Suppress ValueError (Could not convert value to float)
with suppress(ValueError):
value_new: float = UNIT_CONVERTERS[device_class].convert(
value,
native_unit_of_measurement,
unit_of_measurement,
)
# Round to the wanted precision
value = method(value_new, prec)
return value
def convert_to_native_value(self, value: float) -> float:
"""Convert a value to the number's native unit."""
native_unit_of_measurement = self.native_unit_of_measurement
unit_of_measurement = self.unit_of_measurement
device_class = self.device_class
if (
value is not None
and native_unit_of_measurement != unit_of_measurement
and device_class in UNIT_CONVERTERS
):
assert native_unit_of_measurement
assert unit_of_measurement
value = UNIT_CONVERTERS[device_class].convert(
value,
unit_of_measurement,
native_unit_of_measurement,
)
return value
def _report_deprecated_number_entity(self) -> None:
"""Report that the number entity has not been upgraded."""
if not self._deprecated_number_entity_reported:
self._deprecated_number_entity_reported = True
report_issue = self._suggest_report_issue()
_LOGGER.warning(
(
"Entity %s (%s) is using deprecated NumberEntity features which"
" will be unsupported from Home Assistant Core 2022.10, please %s"
),
self.entity_id,
type(self),
report_issue,
)
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
assert self.registry_entry
if (
(number_options := self.registry_entry.options.get(DOMAIN))
and (custom_unit := number_options.get(CONF_UNIT_OF_MEASUREMENT))
and (device_class := self.device_class) in UNIT_CONVERTERS
and self.native_unit_of_measurement
in UNIT_CONVERTERS[device_class].VALID_UNITS
and custom_unit in UNIT_CONVERTERS[device_class].VALID_UNITS
):
self._number_option_unit_of_measurement = custom_unit
return
self._number_option_unit_of_measurement = None
@dataclasses.dataclass
class NumberExtraStoredData(ExtraStoredData):
"""Object to hold extra stored data."""
native_max_value: float | None
native_min_value: float | None
native_step: float | None
native_unit_of_measurement: str | None
native_value: float | None
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the number data."""
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, restored: dict[str, Any]) -> NumberExtraStoredData | None:
"""Initialize a stored number state from a dict."""
try:
return cls(
restored["native_max_value"],
restored["native_min_value"],
restored["native_step"],
restored["native_unit_of_measurement"],
restored["native_value"],
)
except KeyError:
return None
class RestoreNumber(NumberEntity, RestoreEntity):
"""Mixin class for restoring previous number state."""
@property
def extra_restore_state_data(self) -> NumberExtraStoredData:
"""Return number specific state data to be restored."""
return NumberExtraStoredData(
self.native_max_value,
self.native_min_value,
self.native_step,
self.native_unit_of_measurement,
self.native_value,
)
async def async_get_last_number_data(self) -> NumberExtraStoredData | None:
"""Restore native_*."""
if (restored_last_extra_data := await self.async_get_last_extra_data()) is None:
return None
return NumberExtraStoredData.from_dict(restored_last_extra_data.as_dict())