Files
2026-08-24 21:55:33 +08:00

415 lines
13 KiB
Python

"""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]))