初始化config文件
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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={})
|
||||
@@ -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]()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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": "已经配置好了。 只能进行单一配置。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
Reference in New Issue
Block a user