230 lines
8.6 KiB
Python
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
|