初始化config文件

This commit is contained in:
2026-08-24 21:55:33 +08:00
commit 80a05d172e
2983 changed files with 1221334 additions and 0 deletions
@@ -0,0 +1,90 @@
"""Component to integrate with node-red.
For more details about this component, please refer to
https://github.com/zachowj/hass-node-red
"""
import asyncio
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import async_entries_for_device, async_get
from .const import CONF_VERSION, DOMAIN, DOMAIN_DATA, STARTUP_MESSAGE, WEBHOOKS
from .discovery import (
PLATFORMS_LOADED,
SUPPORTED_COMPONENTS,
start_discovery,
stop_discovery,
)
from .version import __version__
from .websocket import register_websocket_handlers, unregister_all_webhooks
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
domain_data = hass.data.setdefault(DOMAIN_DATA, {})
if not domain_data:
_LOGGER.info(STARTUP_MESSAGE)
# Initialize webhook tracking
domain_data.setdefault(WEBHOOKS, set())
await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_COMPONENTS)
domain_data[PLATFORMS_LOADED] = set(SUPPORTED_COMPONENTS)
register_websocket_handlers(hass)
await start_discovery(hass, domain_data)
hass.bus.async_fire(DOMAIN, {CONF_TYPE: "loaded", CONF_VERSION: __version__})
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
# Unregister all webhooks before unloading platforms
unregister_all_webhooks(hass)
unloaded = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in hass.data[DOMAIN_DATA].get(PLATFORMS_LOADED, set())
]
)
)
if unloaded:
stop_discovery(hass)
hass.data.pop(DOMAIN_DATA)
hass.bus.async_fire(DOMAIN, {CONF_TYPE: "unloaded"})
return unloaded
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant, device_entry: DeviceEntry
) -> bool:
"""Remove a entry from the device registry."""
entity_registry = async_get(hass)
entries = async_entries_for_device(entity_registry, device_entry.id)
# Remove entities from device before removing device
# so the entities are not removed from HA
if entries:
for entry in entries:
entity_registry.async_update_entity(entry.entity_id, device_id=None)
return True
@@ -0,0 +1,94 @@
"""Binary sensor platform for nodered."""
from numbers import Number
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_STATE, STATE_HOME, STATE_ON, STATE_OPEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_BINARY_SENSOR, NODERED_DISCOVERY_NEW
from .entity import NodeRedEntity
try:
from homeassistant.components.lock.const import LockState
STATE_UNLOCKED = LockState.UNLOCKED
except ImportError:
# Fallback for older Home Assistant versions
STATE_UNLOCKED = "unlocked"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_devices: 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_devices)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_BINARY_SENSOR),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_devices: AddEntitiesCallback,
) -> None:
"""Set up the Node-RED binary-sensor."""
async_add_devices([NodeRedBinarySensor(hass, config)])
class NodeRedBinarySensor(NodeRedEntity, BinarySensorEntity):
"""Node-RED binary-sensor class."""
on_states = (
"1",
"true",
"yes",
"enable",
STATE_ON,
STATE_OPEN,
STATE_HOME,
STATE_UNLOCKED,
)
component = CONF_BINARY_SENSOR
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Initialize the binary sensor."""
super().__init__(hass, config)
self._attr_is_on = self._evaluate_sensor_state(config.get(CONF_STATE))
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update entity state attributes."""
super().update_entity_state_attributes(msg)
self._attr_is_on = self._evaluate_sensor_state(msg.get(CONF_STATE))
def _evaluate_sensor_state(self, value: Any) -> Any:
"""Parse state."""
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
value = value.lower().strip()
if value in NodeRedBinarySensor.on_states:
return True
elif isinstance(value, Number):
return value != 0
return False
@@ -0,0 +1,79 @@
"""Button platform for nodered."""
import logging
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.messages import event_message
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import CONF_BUTTON, NODERED_DISCOVERY_NEW
from .entity import NodeRedEntity
_LOGGER = logging.getLogger(__name__)
EVENT_TRIGGER_NODE = "automation_triggered"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button platform."""
async def async_discover(
config: dict[str, Any], connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities, connection)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_BUTTON),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
connection: ActiveConnection,
) -> None:
"""Set up the Node-RED button."""
async_add_entities([NodeRedButton(hass, config, connection)])
class NodeRedButton(NodeRedEntity, ButtonEntity):
"""Node-RED button class."""
_bidirectional = True
component = CONF_BUTTON
def __init__(
self, hass: HomeAssistant, config: dict[str, Any], connection: ActiveConnection
) -> None:
"""Initialize the button."""
super().__init__(hass, config)
self._message_id = config[CONF_ID]
self._connection = connection
def press(self) -> None:
"""Handle the button press."""
self._connection.send_message(
event_message(
self._message_id,
{
CONF_TYPE: EVENT_TRIGGER_NODE,
"data": {
"entity": self.hass.states.get(self.entity_id),
},
},
)
)
@@ -0,0 +1,30 @@
"""Adds config flow for Node-RED."""
from homeassistant import config_entries
from .const import DOMAIN
@config_entries.HANDLERS.register(DOMAIN)
class NodeRedFlowHandler(config_entries.ConfigFlow):
"""Config flow for Node-RED."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
def __init__(self) -> None:
"""Initialize."""
self._errors = {}
async def async_step_user(
self, user_input: dict | None = None
) -> config_entries.ConfigFlowResult:
"""Handle a user initiated set up flow to create a webhook."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if self.hass.data.get(DOMAIN):
return self.async_abort(reason="single_instance_allowed")
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="Node-RED", data={})
+75
View File
@@ -0,0 +1,75 @@
"""Constants for Node-RED."""
from .version import __version__
VERSION = __version__
# Base component constants
DOMAIN = "nodered"
DOMAIN_DATA = f"{DOMAIN}_data"
WEBHOOKS = "webhooks"
ISSUE_URL = "https://github.com/zachowj/hass-node-red/issues"
# Home Assistant limits any state to 255 characters
HA_MAX_STATE_LENGTH = 255
# Configuration
CONF_ATTRIBUTES = "attributes"
CONF_BINARY_SENSOR = "binary_sensor"
CONF_BUTTON = "button"
CONF_COMPONENT = "component"
CONF_CONFIG = "config"
CONF_CONNECTION = "connection"
CONF_DATA = "data"
CONF_DEVICE_INFO = "device_info"
CONF_DEVICE_TRIGGER = "device_trigger"
CONF_ENABLED = "enabled"
CONF_ENTITY_PICTURE = "entity_picture"
CONF_LAST_RESET = "last_reset"
CONF_MESSAGE = "message"
CONF_NAME = "name"
CONF_NODE_ID = "node_id"
CONF_NUMBER = "number"
CONF_OPTIONS = "options"
CONF_OUTPUT_PATH = "output_path"
CONF_REMOVE = "remove"
CONF_SELECT = "select"
CONF_SENSOR = "sensor"
CONF_SERVER_ID = "server_id"
CONF_SKIP_CONDITION = "skip_condition"
CONF_STATE_CLASS = "state_class"
CONF_SUB_TYPE = "sub_type"
CONF_SWITCH = "switch"
CONF_TEXT = "text"
CONF_TIME = "time"
CONF_TRIGGER_ENTITY_ID = "trigger_entity_id"
CONF_VERSION = "version"
EVENT_VALUE_CHANGE = "value_change"
NODERED_DISCOVERY = "nodered_discovery"
NODERED_DISCOVERY_NEW = "nodered_discovery_new_{}"
NODERED_DISCOVERY_UPDATED = "nodered_discovery_updated_{}"
NODERED_ENTITY = "nodered_entity_{}_{}"
NODERED_CONFIG_UPDATE = "nodered_config_update_{}_{}"
SERVICE_TRIGGER = "trigger"
# Defaults
NAME = "Node-RED Companion"
NUMBER_ICON = "mdi:numeric"
SWITCH_ICON = "mdi:electric-switch-closed"
SELECT_ICON = "mdi:format-list-bulleted"
TEXT_ICON = "mdi:form-textbox"
TIME_ICON = "mdi:clock-time-three"
STARTUP_MESSAGE = f"""
-------------------------------------------------------------------
{NAME}
Version: {VERSION}
This is a custom integration!
If you have any issues with this you need to open an issue here:
{ISSUE_URL}
-------------------------------------------------------------------
"""
@@ -0,0 +1,105 @@
"""Support for Node-RED discovery."""
import logging
from typing import Any
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from .const import (
CONF_BINARY_SENSOR,
CONF_BUTTON,
CONF_COMPONENT,
CONF_NODE_ID,
CONF_NUMBER,
CONF_REMOVE,
CONF_SELECT,
CONF_SENSOR,
CONF_SERVER_ID,
CONF_SWITCH,
CONF_TEXT,
CONF_TIME,
DOMAIN,
DOMAIN_DATA,
NODERED_DISCOVERY,
NODERED_DISCOVERY_NEW,
NODERED_DISCOVERY_UPDATED,
)
SUPPORTED_COMPONENTS = [
CONF_BINARY_SENSOR,
CONF_BUTTON,
CONF_NUMBER,
CONF_SELECT,
CONF_SENSOR,
CONF_SWITCH,
CONF_TEXT,
CONF_TIME,
]
_LOGGER = logging.getLogger(__name__)
ALREADY_DISCOVERED = "already_discovered"
CHANGE_ENTITY_TYPE = "change_entity_type"
PLATFORMS_LOADED = "platforms_loaded"
DISCOVERY_DISPATCHED = "discovery_dispatched"
async def start_discovery(hass: HomeAssistant, hass_config: dict) -> None:
"""Initialize of Node-RED Discovery."""
async def async_device_message_received(
msg: dict[str, Any], connection: ActiveConnection
) -> None:
"""Process the received message."""
component = msg[CONF_COMPONENT]
server_id = msg[CONF_SERVER_ID]
node_id = msg[CONF_NODE_ID]
if component not in SUPPORTED_COMPONENTS:
_LOGGER.warning("Integration %s is not supported", component)
return
discovery_hash = f"{DOMAIN}-{server_id}-{node_id}"
data = hass_config
_LOGGER.debug("Discovery message: %s", msg)
if ALREADY_DISCOVERED not in data:
data[ALREADY_DISCOVERED] = set()
# Check if already discovered
already_discovered = discovery_hash in data[ALREADY_DISCOVERED]
if already_discovered:
log_text = "Removing" if CONF_REMOVE in msg else "Updating"
_LOGGER.info("%s %s %s %s", log_text, component, server_id, node_id)
async_dispatcher_send(
hass, NODERED_DISCOVERY_UPDATED.format(discovery_hash), msg, connection
)
else:
# Add component - ensure platform is set up first
_LOGGER.info("Creating %s %s %s", component, server_id, node_id)
data[ALREADY_DISCOVERED].add(discovery_hash)
async_dispatcher_send(
hass, NODERED_DISCOVERY_NEW.format(component), msg, connection
)
hass.data[DOMAIN_DATA][DISCOVERY_DISPATCHED] = async_dispatcher_connect(
hass,
NODERED_DISCOVERY,
async_device_message_received,
)
def stop_discovery(hass: HomeAssistant) -> None:
"""Remove discovery dispatcher."""
hass.data[DOMAIN_DATA][DISCOVERY_DISPATCHED]()
+268
View File
@@ -0,0 +1,268 @@
"""Node-RED base entity class."""
from __future__ import annotations
from contextlib import suppress
from typing import TYPE_CHECKING, Any, ClassVar
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_UNIT_OF_MEASUREMENT,
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_registry import async_get
if TYPE_CHECKING:
from homeassistant.components.websocket_api.connection import ActiveConnection
from .const import (
CONF_ATTRIBUTES,
CONF_COMPONENT,
CONF_CONFIG,
CONF_DEVICE_INFO,
CONF_ENTITY_PICTURE,
CONF_NAME,
CONF_NODE_ID,
CONF_OPTIONS,
CONF_REMOVE,
CONF_SERVER_ID,
DOMAIN,
DOMAIN_DATA,
NODERED_CONFIG_UPDATE,
NODERED_DISCOVERY,
NODERED_DISCOVERY_UPDATED,
NODERED_ENTITY,
)
from .discovery import ALREADY_DISCOVERED, CHANGE_ENTITY_TYPE
class MissingConfigError(TypeError):
"""Raised when config is missing required values."""
class NodeRedEntity(Entity):
"""Base entity for Node-RED integration."""
component: ClassVar[str] = ""
_bidirectional = False
def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
"""Initialize the entity."""
if not type(self).component:
msg = f"{type(self).__name__} must set class attribute `component`"
raise TypeError(msg)
self.hass = hass
self._server_id = config.get(CONF_SERVER_ID)
self._node_id = config.get(CONF_NODE_ID)
if self._server_id is None or self._node_id is None:
msg = f"{type(self).__name__} requires 'server_id' and 'node_id'"
raise MissingConfigError(msg)
self._attr_unique_id = f"{DOMAIN}-{self._server_id}-{self._node_id}"
self._attr_should_poll = False
device_info = config.get(CONF_DEVICE_INFO, {})
device_id = device_info.get("id")
if device_id:
self._attr_device_info = DeviceInfo(
identifiers=generate_device_identifiers(device_id),
hw_version=device_info.get("hw_version"),
manufacturer=device_info.get("manufacturer"),
model=device_info.get("model"),
name=device_info.get("name"),
sw_version=device_info.get("sw_version"),
)
else:
self._attr_device_info = None
self.update_discovery_config(config)
self.update_entity_state_attributes(config)
@callback
def handle_config_update(self, msg: dict[str, Any]) -> None:
"""Handle an incoming config update and write state."""
self.update_config(msg)
self.async_write_ha_state()
@callback
def handle_entity_update(self, msg: dict[str, Any]) -> None:
"""Update entity state attributes and write state."""
self.update_entity_state_attributes(msg)
self.async_write_ha_state()
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Set extra state attributes from incoming message."""
self._attr_extra_state_attributes = msg.get(CONF_ATTRIBUTES, {})
@callback
def handle_lost_connection(self) -> None:
"""Mark entity unavailable after losing connection."""
self._attr_available = False
self.async_write_ha_state()
@callback
def handle_discovery_update(
self, msg: dict[str, Any], connection: ActiveConnection
) -> None:
"""Update entity config/state based on discovery message."""
if CONF_REMOVE in msg:
if msg[CONF_REMOVE] == CHANGE_ENTITY_TYPE:
@callback
def recreate_entity() -> None:
del msg[CONF_REMOVE]
async_dispatcher_send(
self.hass,
NODERED_DISCOVERY.format(msg[CONF_COMPONENT]),
msg,
connection,
)
self.async_on_remove(recreate_entity)
# Schedule entity removal; recreation will happen when the on_remove
# callback runs and re-dispatches discovery for the new entity
self.hass.async_create_task(self.async_remove())
else:
@callback
def cleanup_discovery() -> None:
"""Remove discovery tracking for this entity, if present."""
with suppress(KeyError):
del self.hass.data[DOMAIN_DATA][ALREADY_DISCOVERED][
self.unique_id
]
self.async_on_remove(cleanup_discovery)
return
self.update_discovery_device_info(msg)
if CONF_CONFIG in msg:
self.update_discovery_config(msg)
# Bidirectional subscription handling
if self._bidirectional and "id" in msg:
self._attr_available = True
self._message_id = msg["id"]
self._connection = connection
self._connection.subscriptions[msg["id"]] = self.handle_lost_connection
self.async_write_ha_state()
def entity_category_mapper(self, category: str) -> None | EntityCategory:
"""Map Node-RED category strings to Home Assistant EntityCategory."""
if category == "config":
return EntityCategory.CONFIG
if category == "diagnostic":
return EntityCategory.DIAGNOSTIC
return None
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Apply discovery config fields to the entity attributes."""
self._config = msg[CONF_CONFIG]
self._attr_icon = self._config.get(CONF_ICON)
self._attr_name = self._config.get(CONF_NAME, f"{DOMAIN} {self._node_id}")
self._attr_device_class = self._config.get(CONF_DEVICE_CLASS)
self._attr_entity_category = self.entity_category_mapper(
self._config.get(CONF_ENTITY_CATEGORY)
)
self._attr_entity_picture = self._config.get(CONF_ENTITY_PICTURE)
self._attr_unit_of_measurement = self._config.get(CONF_UNIT_OF_MEASUREMENT)
def update_config(self, msg: dict[str, Any]) -> None:
"""Apply runtime config updates to the entity."""
config = msg.get(CONF_CONFIG, {})
if config.get(CONF_NAME):
self._attr_name = config.get(CONF_NAME)
if config.get(CONF_ICON):
self._attr_icon = config.get(CONF_ICON)
if config.get(CONF_ENTITY_PICTURE):
self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE)
if config.get(CONF_OPTIONS):
self._attr_options = config.get(CONF_OPTIONS)
def update_discovery_device_info(self, msg: dict[str, Any]) -> None:
"""Update or remove device info for this entity based on discovery msg."""
entity_registry = async_get(self.hass)
if self.unique_id is None:
raise RuntimeError("unique_id should be set during initialization")
entity_id = entity_registry.async_get_entity_id(
self.component,
DOMAIN,
self.unique_id,
)
device_info = msg.get(CONF_DEVICE_INFO)
# Exit early when there is no device info. If an entity existed, dissociate it from any device.
if device_info is None:
if entity_id is not None:
entity_registry.async_update_entity(entity_id, device_id=None)
return
device_registry = dr.async_get(self.hass)
identifiers = generate_device_identifiers(device_info["id"])
# Get or create the device
device = device_registry.async_get_or_create(
config_entry_id=self.hass.config_entries.async_entries(DOMAIN)[0].entry_id,
identifiers=identifiers,
name=device_info.get("name"),
manufacturer=device_info.get("manufacturer"),
model=device_info.get("model"),
)
# Update device properties
device_registry.async_update_device(
device_id=device.id,
hw_version=device_info.get("hw_version"),
name=device_info.get("name"),
manufacturer=device_info.get("manufacturer"),
model=device_info.get("model"),
sw_version=device_info.get("sw_version"),
)
# Associate entity with device
if entity_id is not None:
entity_registry.async_update_entity(entity_id, device_id=device.id)
async def async_added_to_hass(self) -> None:
"""Register dispatcher listeners when added to Home Assistant."""
self._remove_signal_entity_update = async_dispatcher_connect(
self.hass,
NODERED_ENTITY.format(self._server_id, self._node_id),
self.handle_entity_update,
)
self._remove_signal_discovery_update = async_dispatcher_connect(
self.hass,
NODERED_DISCOVERY_UPDATED.format(self.unique_id),
self.handle_discovery_update,
)
self._remove_signal_config_update = async_dispatcher_connect(
self.hass,
NODERED_CONFIG_UPDATE.format(self._server_id, self._node_id),
self.handle_config_update,
)
async def async_will_remove_from_hass(self) -> None:
"""Remove dispatcher listeners when the entity is removed from hass."""
if self._remove_signal_entity_update is not None:
self._remove_signal_entity_update()
if self._remove_signal_discovery_update is not None:
self._remove_signal_discovery_update()
if self._remove_signal_config_update is not None:
self._remove_signal_config_update()
def generate_device_identifiers(device_id: str) -> set[tuple[str, str]]:
"""Create identifiers set from device info."""
return {(DOMAIN, device_id)}
@@ -0,0 +1,17 @@
{
"domain": "nodered",
"name": "Node-RED Companion",
"after_dependencies": [
"conversation",
"mqtt"
],
"codeowners": [
"@zachowj"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://zachowj.github.io/node-red-contrib-home-assistant-websocket/guide/custom_integration/",
"iot_class": "local_push",
"issue_tracker": "https://github.com/zachowj/hass-node-red/issues",
"version": "4.2.2"
}
+167
View File
@@ -0,0 +1,167 @@
"""Sensor platform for nodered."""
import logging
from typing import Any
from homeassistant.components.number import RestoreNumber
from homeassistant.components.number.const import (
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
NumberMode,
)
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.messages import event_message
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ICON,
CONF_ID,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
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_NUMBER,
EVENT_VALUE_CHANGE,
NODERED_DISCOVERY_NEW,
NUMBER_ICON,
)
from .entity import NodeRedEntity
_LOGGER = logging.getLogger(__name__)
CONF_MIN_VALUE = "min_value"
CONF_MAX_VALUE = "max_value"
CONF_STEP_VALUE = "step_value"
CONF_MODE = "mode"
CONF_STATE = "state"
CONF_VALUE = "value"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the number platform."""
async def async_discover(
config: dict[str, Any], connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities, connection)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_NUMBER),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
connection: ActiveConnection,
) -> None:
"""Set up the Node-RED number."""
async_add_entities([NodeRedNumber(hass, config, connection)])
class NodeRedNumber(NodeRedEntity, RestoreNumber):
"""Node-RED number class."""
_attr_native_min_value = DEFAULT_MIN_VALUE
_attr_native_max_value = DEFAULT_MAX_VALUE
_attr_native_step = DEFAULT_STEP
_attr_native_value = 0
_attr_mode = NumberMode.AUTO
_bidirectional = True
component = CONF_NUMBER
def __init__(
self,
hass: HomeAssistant,
config: dict[str, Any],
connection: ActiveConnection,
) -> None:
"""Initialize the number."""
super().__init__(hass, config)
self._message_id = config[CONF_ID]
self._connection = connection
self._attr_icon = self._config.get(CONF_ICON, NUMBER_ICON)
self._attr_native_value = config.get(CONF_STATE)
self._attr_native_min_value = self._config.get(
CONF_MIN_VALUE, DEFAULT_MIN_VALUE
)
self._attr_native_max_value = self._config.get(
CONF_MAX_VALUE, DEFAULT_MAX_VALUE
)
self._attr_native_step = self._config.get(CONF_STEP_VALUE, DEFAULT_STEP)
self._attr_mode = self._config.get(CONF_MODE, NumberMode.AUTO)
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
self._connection.send_message(
event_message(
self._message_id, {CONF_TYPE: EVENT_VALUE_CHANGE, CONF_VALUE: value}
)
)
async def async_added_to_hass(self) -> None:
"""Load the last known state when added to hass."""
await super().async_added_to_hass()
if (
(last_state := await self.async_get_last_state())
and (last_number_data := await self.async_get_last_number_data())
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
):
self._attr_native_value = last_number_data.native_value
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update the entity state attributes."""
super().update_entity_state_attributes(msg)
self._attr_native_value = msg.get(CONF_STATE)
def update_config(self, msg: dict[str, Any]) -> None:
"""Update entity config."""
super().update_config(msg)
config = msg.get(CONF_CONFIG, {})
if (min_value := config.get(CONF_MIN_VALUE)) is not None:
self._attr_native_min_value = min_value
if (max_value := config.get(CONF_MAX_VALUE)) is not None:
self._attr_native_max_value = max_value
if (step_value := config.get(CONF_STEP_VALUE)) is not None:
self._attr_native_step = step_value
if (mode := config.get(CONF_MODE)) is not None:
self._attr_mode = mode
if (unit := config.get(CONF_UNIT_OF_MEASUREMENT)) is not None:
self._attr_native_unit_of_measurement = unit
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Update the entity config."""
super().update_discovery_config(msg)
self._attr_icon = self._config.get(CONF_ICON, NUMBER_ICON)
self._attr_native_min_value = self._config.get(
CONF_MIN_VALUE, DEFAULT_MIN_VALUE
)
self._attr_native_max_value = self._config.get(
CONF_MAX_VALUE, DEFAULT_MAX_VALUE
)
self._attr_native_step = self._config.get(CONF_STEP_VALUE, DEFAULT_STEP)
self._attr_mode = self._config.get(CONF_MODE, NumberMode.AUTO)
self._attr_native_unit_of_measurement = self._config.get(
CONF_UNIT_OF_MEASUREMENT
)
@@ -0,0 +1,95 @@
"""Select platform for nodered."""
from typing import Any
from custom_components.nodered.number import CONF_VALUE
from homeassistant.components.select import SelectEntity
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.messages import event_message
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ICON, CONF_ID, CONF_TYPE
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_OPTIONS,
CONF_SELECT,
EVENT_VALUE_CHANGE,
NODERED_DISCOVERY_NEW,
SELECT_ICON,
)
from .entity import NodeRedEntity
CONF_STATE = "state"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the text platform."""
async def async_discover(
config: dict[str, Any], connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities, connection)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_SELECT),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
connection: ActiveConnection,
) -> None:
"""Set up the Node-RED text."""
async_add_entities([NodeRedSelect(hass, config, connection)])
class NodeRedSelect(NodeRedEntity, SelectEntity):
"""Node-RED text class."""
_bidirectional = True
component = CONF_SELECT
def __init__(
self,
hass: HomeAssistant,
config: dict[str, Any],
connection: ActiveConnection,
) -> None:
"""Initialize the number."""
super().__init__(hass, config)
self._message_id = config[CONF_ID]
self._connection = connection
async def async_select_option(self, option: str) -> None:
"""Set new option."""
self._connection.send_message(
event_message(
self._message_id, {CONF_TYPE: EVENT_VALUE_CHANGE, CONF_VALUE: option}
)
)
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update the entity state attributes."""
super().update_entity_state_attributes(msg)
self._attr_current_option = msg.get(CONF_STATE)
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Update the entity config."""
super().update_discovery_config(msg)
self._attr_icon = msg[CONF_CONFIG].get(CONF_ICON, SELECT_ICON)
if msg[CONF_CONFIG].get(CONF_OPTIONS) is not None:
self._attr_options = msg[CONF_CONFIG].get(CONF_OPTIONS)
+229
View File
@@ -0,0 +1,229 @@
"""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
@@ -0,0 +1,247 @@
"""Sentence platform for nodered."""
import asyncio
from enum import Enum
import logging
from typing import Any
from hassil.expression import Sentence
from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.decorators import (
async_response,
require_admin,
websocket_command,
)
from homeassistant.components.websocket_api.messages import (
error_message,
event_message,
result_message,
)
from homeassistant.const import CONF_ID, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .const import CONF_SERVER_ID
_LOGGER = logging.getLogger(__name__)
response_futures: dict[str, asyncio.Future] = {}
response_lock = asyncio.Lock()
class ResponseType(Enum):
"""Enumeration for sentence response types used in Node-RED triggers."""
FIXED = "fixed"
DYNAMIC = "dynamic"
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/sentence",
vol.Required(CONF_SERVER_ID): cv.string,
vol.Required("sentences", default=[]): [cv.string],
vol.Optional("response", default="Done"): cv.string,
vol.Optional("response_type", default=ResponseType.FIXED): vol.Coerce(
ResponseType
),
vol.Optional("response_timeout", default=1): cv.positive_float,
}
)
@async_response
async def websocket_sentence(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Create sentence trigger."""
message_id = msg[CONF_ID]
sentences = msg["sentences"]
response = msg["response"]
response_timeout = msg["response_timeout"]
response_type = msg["response_type"]
@callback
async def handle_trigger(
sentence: str,
result: RecognizeResult | None = None,
device_id: str | None = None,
) -> Any:
"""Handle Sentence trigger.
RecognizeResult was added in 2023.8.0
device_id was added in 2024.4.0.
"""
# RecognizeResult in 2024.12 is not serializable,
# so we need to convert it to a serializable format
serialized = convert_recognize_result_to_dict(result)
_LOGGER.debug("Sentence trigger: %s", sentence)
connection.send_message(
event_message(
message_id,
{
"data": {
"sentence": sentence,
"result": serialized,
"deviceId": device_id,
"responseId": message_id,
}
},
)
)
if response_type == ResponseType.DYNAMIC:
async with response_lock:
if message_id not in response_futures:
response_futures[message_id] = hass.loop.create_future()
try:
result = await asyncio.wait_for(
response_futures[message_id], response_timeout
)
_LOGGER.debug(
"Sentence response %s received with response: %s",
message_id,
result,
)
except TimeoutError:
_LOGGER.debug(
"Timeout reached for sentence response %s. Continuing...",
message_id,
)
# Remove the event from the dictionary after timeout
del response_futures[message_id]
else:
return result
return response
def remove_trigger() -> None:
"""Remove sentence trigger."""
async def _remove_future() -> None:
async with response_lock:
response_futures.pop(message_id, None)
hass.async_create_task(_remove_future())
_remove_trigger()
_LOGGER.info("Sentence trigger removed: %s", sentences)
try:
from homeassistant.components.conversation.agent_manager import ( # noqa: PLC0415
get_agent_manager,
)
from homeassistant.components.conversation.trigger import ( # noqa: PLC0415
TriggerDetails,
)
manager = get_agent_manager(hass)
if manager is None:
raise ValueError("Conversation integration not loaded") # noqa: TRY301
_remove_trigger = manager.register_trigger(
TriggerDetails(sentences, handle_trigger) # type: ignore[arg-type]
)
except ImportError:
# Fallback for Home Assistant versions before 2025.10.0
from homeassistant.components.conversation.default_agent import ( # noqa: PLC0415
DATA_DEFAULT_ENTITY, # type: ignore[import]
DefaultAgent,
)
default_agent = hass.data[DATA_DEFAULT_ENTITY]
if not isinstance(default_agent, DefaultAgent):
raise TypeError("default_agent is not an instance of DefaultAgent") # noqa: B904
_remove_trigger = default_agent.register_trigger(sentences, handle_trigger) # type: ignore[arg-type]
except ValueError as err:
connection.send_message(error_message(message_id, "value_error", str(err)))
return
except TypeError as err:
connection.send_message(error_message(message_id, "type_error", str(err)))
return
except RuntimeError as err:
connection.send_message(error_message(message_id, "runtime_error", str(err)))
return
if response_type == ResponseType.FIXED:
_LOGGER.info("Sentence trigger created: %s", sentences)
else:
_LOGGER.info(
"Sentence trigger created: %s with dynamic response #%s and "
"timeout of %s seconds",
sentences,
message_id,
response_timeout,
)
connection.subscriptions[msg[CONF_ID]] = remove_trigger
connection.send_message(result_message(message_id))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/sentence_response",
vol.Required("response_id"): cv.positive_int,
vol.Required("response"): cv.string,
}
)
@async_response
async def websocket_sentence_response(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Send response to sentence trigger."""
message_id = msg[CONF_ID]
response_id = msg["response_id"]
response = msg["response"]
async with response_lock:
# Print the current response_futures keys (ids)
if response_id in response_futures:
# Set the message as the result
response_futures[response_id].set_result(response)
# Remove the event from the dictionary after dispatching
del response_futures[response_id]
_LOGGER.info("Sentence response received: %s", response)
connection.send_message(result_message(message_id))
else:
message = f"Sentence response not found for id: {response_id}"
_LOGGER.warning(message)
connection.send_message(
error_message(message_id, "sentence_response_not_found", message),
)
def convert_recognize_result_to_dict(result: Any) -> dict:
"""Serialize a RecognizeResult object into a JSON-serializable dictionary."""
def serialize(obj: Any) -> Any:
if isinstance(obj, Sentence):
# Custom serialization for Sentence
return {
"text": obj.text,
"pattern": (
obj.pattern.pattern
if hasattr(obj, "pattern") and obj.pattern is not None
else None
),
}
if hasattr(obj, "__dict__"):
# For objects with attributes, serialize attributes
return {key: serialize(value) for key, value in vars(obj).items()}
if isinstance(obj, list):
# Recursively handle lists
return [serialize(item) for item in obj]
if isinstance(obj, dict):
# Recursively handle dictionaries
return {key: serialize(value) for key, value in obj.items()}
if isinstance(obj, (int, float, str, type(None))):
# Primitive types are already serializable
return obj
# Fallback for non-serializable types
return str(obj)
return serialize(result)
@@ -0,0 +1,14 @@
trigger:
target:
entity:
integration: nodered
domain: switch
fields:
output_path:
default: "0"
example: "1,2"
selector:
text:
message:
selector:
object:
+156
View File
@@ -0,0 +1,156 @@
"""Switch platform for nodered."""
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.messages import event_message
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_ICON,
CONF_ID,
CONF_STATE,
CONF_TYPE,
EVENT_STATE_CHANGED,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONF_CONFIG,
CONF_DATA,
CONF_MESSAGE,
CONF_OUTPUT_PATH,
CONF_SWITCH,
NODERED_DISCOVERY_NEW,
SERVICE_TRIGGER,
SWITCH_ICON,
)
from .entity import NodeRedEntity
_LOGGER = logging.getLogger(__name__)
SERVICE_TRIGGER_SCHEMA = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_OUTPUT_PATH, default="0"): cv.string,
vol.Optional(CONF_MESSAGE, default={}): dict,
}
EVENT_TRIGGER_NODE = "automation_triggered"
EVENT_DEVICE_TRIGGER = "device_trigger"
TYPE_SWITCH = "switch"
TYPE_DEVICE_TRIGGER = "device_trigger"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Switch platform."""
async def async_discover(
config: dict[str, Any], connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities, connection)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_SWITCH),
async_discover,
)
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_TRIGGER, SERVICE_TRIGGER_SCHEMA, "async_trigger_node"
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
connection: ActiveConnection,
) -> None:
"""Set up the Node-RED Switch."""
async_add_entities([NodeRedSwitch(hass, config, connection)])
class NodeRedSwitch(NodeRedEntity, ToggleEntity):
"""Node-RED Switch class."""
component = CONF_SWITCH
_bidirectional = True
def __init__(
self,
hass: HomeAssistant,
config: dict[str, Any],
connection: ActiveConnection,
) -> None:
"""Initialize the switch."""
super().__init__(hass, config)
self._message_id = config[CONF_ID]
self._connection = connection
self._attr_state = config.get(CONF_STATE, True)
self._attr_icon = self._config.get(CONF_ICON)
async def async_turn_off(self, **_kwargs: Any) -> None:
"""Turn off the switch."""
self._notify_node_red_off()
async def async_turn_on(self, **_kwargs: Any) -> None:
"""Turn on the switch."""
self._notify_node_red_on()
async def async_trigger_node(self, **kwargs: object) -> None:
"""Trigger node in Node-RED."""
data = {}
data[CONF_OUTPUT_PATH] = kwargs.get(CONF_OUTPUT_PATH, True)
if kwargs.get(CONF_MESSAGE) is not None:
data[CONF_MESSAGE] = kwargs[CONF_MESSAGE]
self._connection.send_message(
event_message(
self._message_id,
{CONF_TYPE: EVENT_TRIGGER_NODE, CONF_DATA: data},
)
)
def _notify_node_red_on(self) -> None:
"""Notify Node-RED that the entity switched on."""
self._connection.send_message(
event_message(
self._message_id, {CONF_TYPE: EVENT_STATE_CHANGED, CONF_STATE: True}
)
)
def _notify_node_red_off(self) -> None:
"""Notify Node-RED that the entity switched off."""
self._connection.send_message(
event_message(
self._message_id, {CONF_TYPE: EVENT_STATE_CHANGED, CONF_STATE: False}
)
)
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update the entity state attributes."""
super().update_entity_state_attributes(msg)
self._attr_is_on = msg.get(CONF_STATE)
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Update the entity config."""
super().update_discovery_config(msg)
self._attr_icon = msg[CONF_CONFIG].get(CONF_ICON, SWITCH_ICON)
+147
View File
@@ -0,0 +1,147 @@
"""Sensor platform for nodered."""
import logging
from typing import Any
from homeassistant.components.text import RestoreText, TextMode
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.messages import event_message
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ICON, CONF_ID, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONF_TEXT,
EVENT_VALUE_CHANGE,
HA_MAX_STATE_LENGTH,
NODERED_DISCOVERY_NEW,
TEXT_ICON,
)
from .entity import NodeRedEntity
from .number import CONF_VALUE
CONF_MAX_LENGTH = "max_length"
CONF_MIN_LENGTH = "min_length"
CONF_MODE = "mode"
CONF_PATTERN = "pattern"
CONF_STATE = "state"
DEFAULT_MODE = "text"
DEFAULT_MAX_LENGTH = 100
DEFAULT_MIN_LENGTH = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the text platform."""
async def async_discover(
config: dict[str, Any], connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities, connection)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_TEXT),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
connection: ActiveConnection,
) -> None:
"""Set up the Node-RED text."""
async_add_entities([NodeRedText(hass, config, connection)])
class NodeRedText(NodeRedEntity, RestoreText):
"""Node-RED text class."""
_bidirectional = True
component = CONF_TEXT
def __init__(
self,
hass: HomeAssistant,
config: dict[str, Any],
connection: ActiveConnection,
) -> None:
"""Initialize the number."""
super().__init__(hass, config)
self._message_id = config[CONF_ID]
self._connection = connection
async def async_added_to_hass(self) -> None:
"""Restore native_*."""
await super().async_added_to_hass()
if (last_text_data := await self.async_get_last_text_data()) is None:
return
self._attr_native_max = last_text_data.native_max
self._attr_native_min = last_text_data.native_min
self._attr_native_value = last_text_data.native_value
async def async_set_value(self, value: str) -> None:
"""Set new value."""
self._connection.send_message(
event_message(
self._message_id, {CONF_TYPE: EVENT_VALUE_CHANGE, CONF_VALUE: value}
)
)
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update the entity state attributes."""
super().update_entity_state_attributes(msg)
# Ensure native value is a string for Text entity to avoid type
# errors in Home Assistant when measuring value length.
state = msg.get(CONF_STATE)
value = str(state) if state is not None else None
if value is not None:
# Determine max length to enforce. Prefer the attribute if set,
# otherwise fall back to the configured value or default.
max_len = getattr(self, "_attr_native_max", None)
if max_len is None:
max_len = self._config.get(CONF_MAX_LENGTH, DEFAULT_MAX_LENGTH)
# Enforce Home Assistant's maximum state length
if isinstance(max_len, int) and max_len >= 0:
effective_max = min(max_len, HA_MAX_STATE_LENGTH)
else:
effective_max = HA_MAX_STATE_LENGTH
if len(value) > effective_max:
_LOGGER.warning(
"Truncating text entity %s (node ID: %s) value to max length %s",
self.entity_id,
self._node_id,
effective_max,
)
value = value[:effective_max]
self._attr_native_value = value
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Update the entity config."""
super().update_discovery_config(msg)
self._attr_icon = self._config.get(CONF_ICON, TEXT_ICON)
self._attr_native_min = self._config.get(CONF_MIN_LENGTH, DEFAULT_MIN_LENGTH)
cfg_max = self._config.get(CONF_MAX_LENGTH, DEFAULT_MAX_LENGTH)
if isinstance(cfg_max, int) and cfg_max >= 0:
self._attr_native_max = min(cfg_max, HA_MAX_STATE_LENGTH)
else:
self._attr_native_max = DEFAULT_MAX_LENGTH
self._attr_pattern = self._config.get(CONF_PATTERN, None)
self._attr_mode = TextMode(self._config.get(CONF_MODE, DEFAULT_MODE))
+113
View File
@@ -0,0 +1,113 @@
"""Sensor platform for nodered."""
from datetime import time
import logging
from typing import Any
from dateutil import parser
from homeassistant.components.time import TimeEntity
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.messages import event_message
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ICON, CONF_ID, CONF_TYPE
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_TIME,
EVENT_VALUE_CHANGE,
NODERED_DISCOVERY_NEW,
TIME_ICON,
)
from .entity import NodeRedEntity
_LOGGER = logging.getLogger(__name__)
CONF_STATE = "state"
CONF_VALUE = "value"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the time platform."""
async def async_discover(
config: dict[str, Any], connection: ActiveConnection
) -> None:
await _async_setup_entity(hass, config, async_add_entities, connection)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
NODERED_DISCOVERY_NEW.format(CONF_TIME),
async_discover,
)
)
async def _async_setup_entity(
hass: HomeAssistant,
config: dict[str, Any],
async_add_entities: AddEntitiesCallback,
connection: ActiveConnection,
) -> None:
"""Set up the Node-RED time."""
async_add_entities([NodeRedTime(hass, config, connection)])
def _convert_string_to_time(value: str | None) -> None | time:
"""Convert string to time."""
if value is None:
return None
try:
return parser.parse(value).time()
except ValueError:
_LOGGER.exception("Unable to parse time: %s", value)
return None
class NodeRedTime(NodeRedEntity, TimeEntity):
"""Node-RED time class."""
_attr_native_value = None
_bidirectional = True
component = CONF_TIME
def __init__(
self,
hass: HomeAssistant,
config: dict[str, Any],
connection: ActiveConnection,
) -> None:
"""Initialize the time."""
super().__init__(hass, config)
self._message_id = config[CONF_ID]
self._connection = connection
async def async_set_value(self, value: time) -> None:
"""Set new value."""
# Convert the datetime.time to an ISO-formatted string before sending.
self._connection.send_message(
event_message(
self._message_id,
{CONF_TYPE: EVENT_VALUE_CHANGE, CONF_VALUE: value.isoformat()},
)
)
def update_entity_state_attributes(self, msg: dict[str, Any]) -> None:
"""Update the entity state attributes."""
super().update_entity_state_attributes(msg)
self._attr_native_value = _convert_string_to_time(msg.get(CONF_STATE))
def update_discovery_config(self, msg: dict[str, Any]) -> None:
"""Update the entity config."""
super().update_discovery_config(msg)
self._attr_icon = msg[CONF_CONFIG].get(CONF_ICON, TIME_ICON)
@@ -0,0 +1,13 @@
{
"config": {
"step": {
"user": {
"title": "Node-RED Begleiter",
"description": "Möchtest du den Node-RED Begleiter wirklich einrichten?"
}
},
"abort": {
"single_instance_allowed": "Es kann nur eine Konfiguration für den Node-RED Begleiter geben."
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Er du sikker på, at du vil konfigurere Node-RED Companion?"
}
},
"abort": {
"single_instance_allowed": "Kun en enkelt konfiguration af Node-RED Companion er tilladt."
}
}
}
@@ -0,0 +1,28 @@
{
"config": {
"abort": {
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"step": {
"user": {
"description": "Do you want to add Node-RED Companion to Home Assistant?"
}
}
},
"services": {
"trigger": {
"description": "Send a message to a Node-RED flow that has been exposed to Home Assistant.",
"fields": {
"message": {
"description": "The message object that will be sent to the next node.",
"name": "Message"
},
"output_path": {
"description": "Comma separated list of paths to send the message to. Zero is used to send the message to all paths.",
"name": "Output Path"
}
},
"name": "Trigger a Node-RED flow"
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Voulez-vous ajouter Node-RED Companion à Home Assistant ?"
}
},
"abort": {
"single_instance_allowed": "Déjà configuré. Une seule configuration possible à la fois."
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Er du sikker på at du vil konfigurere Node-RED?"
}
},
"abort": {
"single_instance_allowed": "Bare en enkelt konfigurasjon av Node-RED er tillatt."
}
}
}
@@ -0,0 +1,28 @@
{
"config": {
"abort": {
"single_instance_allowed": "Już skonfigurowano. Tylko jedna configuracja możliwa."
},
"step": {
"user": {
"description": "Czy chcesz dodać Asystenta Node-RED do Home Assistanta?"
}
}
},
"services": {
"trigger": {
"description": "Wyślij wiadomość do przepływu Node-RED, który został wystawiony w Home Assistancie.",
"fields": {
"message": {
"description": "Obiekt wiadomości, któ©y zostanie wysłany do następnego węzła.",
"name": "Wiadomość"
},
"output_path": {
"description": "Oddzielona przecinkami lista ścieżek do których wysłać wiadomość. Zero aby wysłać wiadomość do wszystkich ścieżek.",
"name": "Ścieżka wyjściowa"
}
},
"name": "Wyzwól przepływ Node-RED"
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Tem certeza de que deseja configurar o Node-RED Companion?"
}
},
"abort": {
"single_instance_allowed": "Apenas uma única configuração do Node-RED Companion é permitida."
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Quer configurar o Node-RED Companion?"
}
},
"abort": {
"single_instance_allowed": "Só é permitida uma configuração do Node-RED Companion."
}
}
}
@@ -0,0 +1,28 @@
{
"config": {
"abort": {
"single_instance_allowed": "Už je nakonfigurovaný. Možná len jedna konfigurácia."
},
"step": {
"user": {
"description": "Chcete pridať Node-RED Companion do aplikácie Home Assistant?"
}
}
},
"services": {
"trigger": {
"description": "Odoslanie správy do toku Node-RED, ktorý bol vystavený aplikácii Home Assistant.",
"fields": {
"message": {
"description": "Objekt správy, ktorý bude odoslaný ďalšiemu uzlu.",
"name": "Správa"
},
"output_path": {
"description": "Zoznam ciest oddelených čiarkou, na ktoré sa má správa odoslať. Nula sa používa na odoslanie správy na všetky cesty.",
"name": "Výstupná cesta"
}
},
"name": "Spustenie priebehu Node-RED"
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Vill du lägga till Node-RED Companion till Home Assistant?"
}
},
"abort": {
"single_instance_allowed": "Redan konfigurerad. Bara en konfiguration är möjlig."
}
}
}
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "您是否要将 Node-RED Companion 添加到 Home Assistant?"
}
},
"abort": {
"single_instance_allowed": "已经配置好了。 只能进行单一配置。"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
"""Helpers for node-red."""
from datetime import timedelta
from typing import Any
from homeassistant.helpers.json import JSONEncoder
class NodeRedJSONEncoder(JSONEncoder):
"""JSONEncoder that supports timedelta objects and falls back to the Home Assistant Encoder."""
def default(self, o: Any) -> Any:
"""Convert timedelta objects.
Hand other objects to the Home Assistant JSONEncoder.
"""
if isinstance(o, timedelta):
return o.total_seconds()
return JSONEncoder.default(self, o)
@@ -0,0 +1,3 @@
"""Version info for Node-RED integration."""
__version__ = "4.2.2"
@@ -0,0 +1,414 @@
"""Websocket API for Node-RED."""
import contextlib
import json
import logging
from typing import Any
from aiohttp.web import Request, Response
import voluptuous as vol
from homeassistant.components import device_automation
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.device_automation.exceptions import (
DeviceNotFound,
InvalidDeviceAutomationConfig,
)
from homeassistant.components.device_automation.trigger import TRIGGER_SCHEMA
from homeassistant.components.webhook import (
SUPPORTED_METHODS,
async_register as webhook_async_register,
async_unregister as webhook_async_unregister,
)
from homeassistant.components.websocket_api import async_register_command
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.decorators import (
async_response,
require_admin,
websocket_command,
)
from homeassistant.components.websocket_api.messages import (
error_message,
event_message,
result_message,
)
from homeassistant.const import (
CONF_DOMAIN,
CONF_ID,
CONF_NAME,
CONF_STATE,
CONF_TYPE,
CONF_WEBHOOK_ID,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
trigger,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_registry import async_entries_for_device, async_get
from .const import (
CONF_ATTRIBUTES,
CONF_COMPONENT,
CONF_CONFIG,
CONF_DEVICE_INFO,
CONF_DEVICE_TRIGGER,
CONF_NODE_ID,
CONF_REMOVE,
CONF_SERVER_ID,
CONF_SUB_TYPE,
DOMAIN,
DOMAIN_DATA,
NODERED_CONFIG_UPDATE,
NODERED_DISCOVERY,
NODERED_ENTITY,
VERSION,
WEBHOOKS,
)
from .sentence import websocket_sentence, websocket_sentence_response
from .utils import NodeRedJSONEncoder
CONF_ALLOWED_METHODS = "allowed_methods"
CONF_LOCAL_ONLY = "local_only"
_LOGGER = logging.getLogger(__name__)
def unregister_all_webhooks(hass: HomeAssistant) -> None:
"""Unregister all tracked webhooks."""
if DOMAIN_DATA not in hass.data:
return
domain_data = hass.data[DOMAIN_DATA]
webhook_ids = domain_data.get(WEBHOOKS, set()).copy()
for webhook_id in webhook_ids:
with contextlib.suppress(ValueError):
webhook_async_unregister(hass, webhook_id)
_LOGGER.info("Webhook unregistered during cleanup: %s..", webhook_id[:15])
# Clear the tracking set
domain_data[WEBHOOKS] = set()
def register_websocket_handlers(hass: HomeAssistant) -> None:
"""Register the websocket handlers."""
async_register_command(hass, websocket_device_action)
async_register_command(hass, websocket_device_remove)
async_register_command(hass, websocket_device_trigger)
async_register_command(hass, websocket_discovery)
async_register_command(hass, websocket_entity)
async_register_command(hass, websocket_config_update)
async_register_command(hass, websocket_version)
async_register_command(hass, websocket_webhook)
async_register_command(hass, websocket_sentence)
async_register_command(hass, websocket_sentence_response)
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/device/action",
vol.Required("action"): cv.DEVICE_ACTION_SCHEMA,
}
)
@async_response
async def websocket_device_action(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Execute a device action."""
context = connection.context(msg)
platform = await device_automation.async_get_device_automation_platform(
hass, msg["action"][CONF_DOMAIN], DeviceAutomationType.ACTION
)
try:
if "entity_id" in msg["action"]:
entity_registry = async_get(hass)
entity_entry = entity_registry.async_get(msg["action"]["entity_id"])
if entity_entry is None:
connection.send_message(
error_message(
msg[CONF_ID],
"entity_not_found",
f"Entity '{msg['action']['entity_id']}' not found",
)
)
return
msg["action"]["entity_id"] = entity_entry.entity_id
await platform.async_call_action_from_config(hass, msg["action"], {}, context)
connection.send_message(result_message(msg[CONF_ID]))
except InvalidDeviceAutomationConfig as err:
connection.send_message(error_message(msg[CONF_ID], "invalid_config", str(err)))
except DeviceNotFound as err:
connection.send_message(
error_message(msg[CONF_ID], "device_not_found", str(err))
)
except ValueError as err:
connection.send_message(error_message(msg[CONF_ID], "value_error", str(err)))
except RuntimeError as err:
connection.send_message(error_message(msg[CONF_ID], "runtime_error", str(err)))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/device/remove",
vol.Required(CONF_NODE_ID): cv.string,
vol.Optional(CONF_CONFIG, default={}): dict,
}
)
@async_response
async def websocket_device_remove(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Remove a device."""
device_registry = dr.async_get(hass)
device = device_registry.async_get_device({(DOMAIN, msg[CONF_NODE_ID])})
if device is not None:
entity_registry = async_get(hass)
entries = async_entries_for_device(entity_registry, device.id)
# Remove entities from device before removing device so the entities
# are not removed from HA
if entries:
for entry in entries:
entity_registry.async_update_entity(entry.entity_id, device_id=None)
device_registry.async_remove_device(device.id)
connection.send_message(result_message(msg[CONF_ID]))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/discovery",
vol.Required(CONF_COMPONENT): cv.string,
vol.Required(CONF_SERVER_ID): cv.string,
vol.Required(CONF_NODE_ID): cv.string,
vol.Optional(CONF_CONFIG, default={}): dict,
vol.Optional(CONF_STATE): vol.Any(bool, str, int, float, None),
vol.Optional(CONF_ATTRIBUTES): dict,
vol.Optional(CONF_REMOVE): bool,
vol.Optional(CONF_DEVICE_INFO): dict,
vol.Optional(CONF_DEVICE_TRIGGER): TRIGGER_SCHEMA,
vol.Optional(CONF_SUB_TYPE): str,
}
)
def websocket_discovery(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Sensor command."""
async_dispatcher_send(
hass, NODERED_DISCOVERY.format(msg[CONF_COMPONENT]), msg, connection
)
connection.send_message(result_message(msg[CONF_ID]))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/entity",
vol.Required(CONF_SERVER_ID): cv.string,
vol.Required(CONF_NODE_ID): cv.string,
vol.Required(CONF_STATE): vol.Any(bool, str, int, float, None),
vol.Optional(CONF_ATTRIBUTES, default={}): dict,
}
)
def websocket_entity(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Sensor command."""
async_dispatcher_send(
hass, NODERED_ENTITY.format(msg[CONF_SERVER_ID], msg[CONF_NODE_ID]), msg
)
connection.send_message(result_message(msg[CONF_ID]))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/entity/update_config",
vol.Required(CONF_SERVER_ID): cv.string,
vol.Required(CONF_NODE_ID): cv.string,
vol.Optional(CONF_CONFIG, default={}): dict,
}
)
def websocket_config_update(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Sensor command."""
async_dispatcher_send(
hass, NODERED_CONFIG_UPDATE.format(msg[CONF_SERVER_ID], msg[CONF_NODE_ID]), msg
)
connection.send_message(result_message(msg[CONF_ID]))
@require_admin
@websocket_command({vol.Required(CONF_TYPE): "nodered/version"})
def websocket_version(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Version command."""
connection.send_message(result_message(msg[CONF_ID], VERSION))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/webhook",
vol.Required(CONF_SERVER_ID): cv.string,
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_WEBHOOK_ID): cv.string,
vol.Optional(CONF_ALLOWED_METHODS): vol.All(
cv.ensure_list,
[vol.All(vol.Upper, vol.In(SUPPORTED_METHODS))],
vol.Unique(),
),
}
)
@async_response
async def websocket_webhook(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Create webhook command."""
webhook_id = msg[CONF_WEBHOOK_ID]
allowed_methods = msg.get(CONF_ALLOWED_METHODS)
@callback
async def handle_webhook(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
"""Handle webhook callback."""
body = await request.text()
try:
payload = json.loads(body) if body else {}
except ValueError:
payload = body
data = {
"payload": payload,
"headers": dict(request.headers),
"params": dict(request.query),
}
_LOGGER.debug("Webhook received %s..: %s", webhook_id[:15], data)
connection.send_message(event_message(msg[CONF_ID], {"data": data}))
def remove_webhook() -> None:
"""Remove webhook command."""
with contextlib.suppress(ValueError):
webhook_async_unregister(hass, webhook_id)
# Remove from tracking
if DOMAIN_DATA in hass.data:
hass.data[DOMAIN_DATA].get(WEBHOOKS, set()).discard(webhook_id)
_LOGGER.info("Webhook removed: %s..", webhook_id[:15])
connection.send_message(result_message(msg[CONF_ID]))
try:
webhook_async_register(
hass,
DOMAIN,
msg[CONF_NAME],
webhook_id,
handle_webhook,
allowed_methods=allowed_methods,
)
except ValueError as err:
connection.send_message(error_message(msg[CONF_ID], "value_error", str(err)))
return
# Track webhook for cleanup during unload
if DOMAIN_DATA in hass.data:
hass.data[DOMAIN_DATA].setdefault(WEBHOOKS, set()).add(webhook_id)
_LOGGER.info("Webhook created: %s..", webhook_id[:15])
connection.subscriptions[msg[CONF_ID]] = remove_webhook
connection.send_message(result_message(msg[CONF_ID]))
@require_admin
@websocket_command(
{
vol.Required(CONF_TYPE): "nodered/device/trigger",
vol.Required(CONF_NODE_ID): cv.string,
vol.Required(CONF_DEVICE_TRIGGER, default={}): dict,
}
)
@async_response
async def websocket_device_trigger(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Create device trigger."""
node_id = msg[CONF_NODE_ID]
trigger_data = msg[CONF_DEVICE_TRIGGER]
def forward_trigger(event: dict[str, Any], _context: Context | None = None) -> None:
"""Forward events to websocket."""
message = event_message(
msg[CONF_ID],
{"type": "device_trigger", "data": event["trigger"]},
)
hass.loop.call_soon_threadsafe(
connection.send_message,
json.dumps(message, cls=NodeRedJSONEncoder, allow_nan=False),
)
def unsubscribe() -> None:
"""Remove device trigger."""
if remove_trigger is not None:
remove_trigger()
_LOGGER.info("Device trigger removed: %s", node_id)
try:
trigger_config = await trigger.async_validate_trigger_config(
hass, [trigger_data]
)
remove_trigger = await trigger.async_initialize_triggers(
hass,
trigger_config,
forward_trigger,
DOMAIN,
DOMAIN,
_LOGGER.log,
)
except vol.MultipleInvalid as err:
_LOGGER.exception("Error initializing device trigger for node_id: %s", node_id)
connection.send_message(
error_message(
msg[CONF_ID],
"invalid_trigger",
str(err),
)
)
return
except vol.Invalid as err:
_LOGGER.exception("Error initializing device trigger for node_id: %s", node_id)
connection.send_message(
error_message(
msg[CONF_ID],
"invalid_trigger",
str(err),
)
)
return
except RuntimeError as err:
_LOGGER.exception("Error initializing device trigger for node_id: %s", node_id)
connection.send_message(
error_message(
msg[CONF_ID],
"runtime_error",
str(err),
)
)
return
_LOGGER.info("Device trigger created: %s", node_id)
_LOGGER.debug("Device trigger config for %s: %s", node_id, trigger_data)
connection.subscriptions[msg[CONF_ID]] = unsubscribe
connection.send_message(result_message(msg[CONF_ID]))