415 lines
13 KiB
Python
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]))
|