Files
homeassistant/config/custom_components/nodered/sensor.py
T
2026-08-24 21:55:33 +08:00

230 lines
8.6 KiB
Python

"""Sensor platform for nodered."""
from datetime import date, datetime, timezone
import logging
from typing import Any
from dateutil import parser
from propcache.api import cached_property
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_STATE, CONF_UNIT_OF_MEASUREMENT, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONF_CONFIG,
CONF_LAST_RESET,
CONF_SENSOR,
CONF_STATE_CLASS,
NODERED_DISCOVERY_NEW,
)
from .entity import NodeRedEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensor platform."""
async def async_discover(
config: dict[str, Any], _connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_SENSOR),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Node-RED sensor."""
async_add_entities([NodeRedSensor(hass, config)])
class NodeRedSensor(NodeRedEntity, SensorEntity):
"""Node-RED Sensor class."""
component = CONF_SENSOR
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Initialize the sensor."""
super().__init__(hass, config)
self._attr_unit_of_measurement = None
# Only attempt conversion if a state was provided in discovery/config
if CONF_STATE in config:
self._attr_native_value = self.convert_state(config.get(CONF_STATE))
else:
self._attr_native_value = None
self._attr_native_unit_of_measurement = self._config.get(
CONF_UNIT_OF_MEASUREMENT
)
self._attr_state_class = self._config.get(CONF_STATE_CLASS)
@cached_property
def last_reset(self) -> datetime | None:
"""Return the cached parsed last reset time for this sensor."""
return self.__dict__.get("last_reset")
def convert_state(
self, state: str | float | None
) -> datetime | date | float | int | str | bool | None:
"""Convert state if needed."""
if state is None:
return None
if self.device_class in [
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.DATE,
]:
# Accept numeric timestamps (seconds or milliseconds) as well as
# ISO date strings. Numeric timestamps are often sent as integers
# (ms since epoch) by Node-RED/JS environments.
ts: float | None = None
if isinstance(state, (int, float)):
ts = float(state)
elif isinstance(state, str):
s = state.strip()
# Accept numeric strings (ints or floats)
if s.lstrip("-+").replace(".", "", 1).isdigit():
try:
ts = float(s)
except ValueError:
ts = None
if ts is not None:
# Heuristic: treat large numbers as milliseconds
if ts > 1e11:
seconds = ts / 1000.0
else:
seconds = ts
try:
parsed = datetime.fromtimestamp(seconds, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
_LOGGER.exception(
"Invalid timestamp (%s): %s has a timestamp device class",
state,
self.entity_id,
)
return None
else:
if self.device_class is SensorDeviceClass.DATE:
return parsed.date()
return parsed
# Fallback to ISO parsing for string states
if not isinstance(state, str):
_LOGGER.exception(
"Invalid ISO date string (%s): %s has a timestamp device class",
state,
self.entity_id,
)
return None
try:
parsed = parser.parse(state)
except (ValueError, TypeError):
_LOGGER.exception(
"Invalid ISO date string (%s): %s has a timestamp device class",
state,
self.entity_id,
)
return None
else:
if self.device_class is SensorDeviceClass.DATE:
return parsed.date()
return parsed
return state
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update entity state attributes."""
super().update_entity_state_attributes(msg)
# Only update native value when a state key is present; this avoids
# overwriting existing values when messages only include attributes.
if CONF_STATE in msg:
self._attr_native_value = self.convert_state(msg.get(CONF_STATE))
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Update entity config."""
super().update_discovery_config(msg)
config = msg[CONF_CONFIG]
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
self._attr_unit_of_measurement = None
self._attr_state_class = config.get(CONF_STATE_CLASS)
# Validate and cache last_reset from discovery config to surface invalid
# values immediately and to ensure the cached property reflects updates.
last = config.get(CONF_LAST_RESET)
if last is not None:
# Only cache last_reset when it makes sense for the sensor. Home
# Assistant will raise if last_reset is present for sensors that do
# not have a supported state_class (for example, None). Accept
# last_reset when the sensor has a timestamp/date device class or
# when the state_class indicates a total counter ("total" or
# "total_increasing"). Otherwise ignore and warn.
allowed_state_classes = {"total", "total_increasing"}
if self.device_class in [
SensorDeviceClass.TIMESTAMP,
SensorDeviceClass.DATE,
] or (self.state_class in allowed_state_classes):
self._validate_and_cache_last_reset(last)
else:
_LOGGER.warning(
"Sensor %s (node ID: %s) provided last_reset but state_class is %s; "
"ignoring last_reset",
self.name,
self._node_id,
self.state_class,
)
# Ensure no stale cached value remains
if "last_reset" in self.__dict__:
del self.__dict__["last_reset"]
def _validate_and_cache_last_reset(self, last: str) -> None:
"""Validate a last_reset ISO string and cache the parsed value.
Keeps the same logging and cache-invalidation behaviour as before.
"""
try:
parsed = parser.parse(last)
except (ValueError, TypeError):
_LOGGER.exception(
"Invalid ISO date string (%s): %s requires last_reset to be "
"an iso date formatted string",
last,
self.entity_id,
)
# Remove any cached value so it will be recomputed on next access
if "last_reset" in self.__dict__:
del self.__dict__["last_reset"]
else:
if self.device_class is SensorDeviceClass.DATE:
self.__dict__["last_reset"] = parsed.date()
else:
self.__dict__["last_reset"] = parsed
def entity_category_mapper(self, category: str) -> None | EntityCategory:
"""Map Node-RED category to Home Assistant entity category."""
if category == "config":
_LOGGER.warning(
"Sensor %s has category 'config' which is not supported",
self.name,
)
if category == "diagnostic":
return EntityCategory.DIAGNOSTIC
return None