Node-RED MCP medium: N1 typed errors, N3 FlowVisualization, N4 dynamic dimensions
This commit is contained in:
@@ -6,6 +6,13 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nodered_mcp.config import get_config
|
from nodered_mcp.config import get_config
|
||||||
|
from nodered_mcp.exceptions import (
|
||||||
|
AuthError,
|
||||||
|
NodeREDError,
|
||||||
|
NotFoundError,
|
||||||
|
UpstreamError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NodeRedClient:
|
class NodeRedClient:
|
||||||
@@ -80,7 +87,7 @@ class NodeRedClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise RuntimeError(
|
raise AuthError(
|
||||||
f"OAuth token error {resp.status_code}: {resp.text}"
|
f"OAuth token error {resp.status_code}: {resp.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +115,11 @@ class NodeRedClient:
|
|||||||
Parsed JSON response, or None for 204 No Content
|
Parsed JSON response, or None for 204 No Content
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: On HTTP error status
|
AuthError: On authentication failure (401)
|
||||||
|
NotFoundError: On resource not found (404)
|
||||||
|
ValidationError: On bad request (400)
|
||||||
|
UpstreamError: On server error (5xx)
|
||||||
|
NodeREDError: On other HTTP errors
|
||||||
"""
|
"""
|
||||||
if self._auth_method == "oauth":
|
if self._auth_method == "oauth":
|
||||||
await self._ensure_oauth_token()
|
await self._ensure_oauth_token()
|
||||||
@@ -136,9 +147,16 @@ class NodeRedClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise RuntimeError(
|
msg = f"Node-RED API error {response.status_code}: {response.text}"
|
||||||
f"Node-RED API error {response.status_code}: {response.text}"
|
if response.status_code == 401:
|
||||||
)
|
raise AuthError(msg)
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise NotFoundError(msg)
|
||||||
|
if response.status_code == 400:
|
||||||
|
raise ValidationError(msg)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
raise UpstreamError(msg)
|
||||||
|
raise NodeREDError(msg)
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|||||||
26
src/nodered_mcp/exceptions.py
Normal file
26
src/nodered_mcp/exceptions.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Typed exceptions for Node-RED MCP."""
|
||||||
|
|
||||||
|
|
||||||
|
class NodeREDError(Exception):
|
||||||
|
"""Base error for Node-RED MCP."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(NodeREDError):
|
||||||
|
"""Authentication failure (401)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(NodeREDError):
|
||||||
|
"""Resource not found (404)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(NodeREDError):
|
||||||
|
"""Bad request / validation failure (400)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamError(NodeREDError):
|
||||||
|
"""Node-RED server error (5xx)."""
|
||||||
|
pass
|
||||||
@@ -28,6 +28,17 @@ class FlowSummary(BaseApiModel):
|
|||||||
data: dict = Field(default_factory=dict)
|
data: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowVisualization(BaseApiModel):
|
||||||
|
"""Structured flow visualization model."""
|
||||||
|
|
||||||
|
tabs: list[dict] = Field(default_factory=list) # tab name, node count, node types
|
||||||
|
total_tabs: int = 0
|
||||||
|
total_nodes: int = 0
|
||||||
|
total_configs: int = 0
|
||||||
|
total_subflows: int = 0
|
||||||
|
generated_at: str = "" # ISO timestamp
|
||||||
|
|
||||||
|
|
||||||
class FlowCreateResult(BaseApiModel):
|
class FlowCreateResult(BaseApiModel):
|
||||||
"""Result of flow creation."""
|
"""Result of flow creation."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
"""Flow management tools for Node-RED."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from nodered_mcp.client import get_client
|
|
||||||
from nodered_mcp.models.flow import Flow, FlowState, FlowTab
|
|
||||||
from nodered_mcp.models.responses import FlowCreateResult, FlowList, FlowSummary
|
|
||||||
from nodered_mcp.server import mcp
|
|
||||||
|
|
||||||
|
|
||||||
def _build_flow_list(raw_flows: list[dict]) -> FlowList:
|
|
||||||
"""Build FlowList with summary and statistics from raw flow data."""
|
|
||||||
tabs = [f for f in raw_flows if f.get("type") == "tab"]
|
|
||||||
nodes = [f for f in raw_flows if f.get("type") not in ("tab", "subflow")]
|
|
||||||
subflows = [f for f in raw_flows if f.get("type") == "subflow"]
|
|
||||||
|
|
||||||
node_types: dict[str, int] = {}
|
|
||||||
for node in nodes:
|
|
||||||
node_type = node.get("type", "")
|
|
||||||
node_types[node_type] = node_types.get(node_type, 0) + 1
|
|
||||||
|
|
||||||
summary = f"Node-RED project: {len(tabs)} tabs, {len(nodes)} nodes, {len(subflows)} subflows"
|
|
||||||
stats = {
|
|
||||||
"tabCount": len(tabs),
|
|
||||||
"nodeCount": len(nodes),
|
|
||||||
"subflowCount": len(subflows),
|
|
||||||
"nodeTypes": node_types,
|
|
||||||
}
|
|
||||||
|
|
||||||
return FlowList(flows=raw_flows, tabs=tabs, summary=summary, statistics=stats)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_flow_summary(raw_flows: list[dict]) -> FlowSummary:
|
|
||||||
"""Build FlowSummary with grouped data and statistics."""
|
|
||||||
tabs = [f for f in raw_flows if f.get("type") == "tab"]
|
|
||||||
nodes = [f for f in raw_flows if f.get("type") not in ("tab", "subflow")]
|
|
||||||
subflows = [f for f in raw_flows if f.get("type") == "subflow"]
|
|
||||||
|
|
||||||
node_types: dict[str, int] = {}
|
|
||||||
for node in nodes:
|
|
||||||
node_type = node.get("type", "")
|
|
||||||
node_types[node_type] = node_types.get(node_type, 0) + 1
|
|
||||||
|
|
||||||
summary = f"Node-RED project: {len(tabs)} tabs, {len(nodes)} nodes, {len(subflows)} subflows"
|
|
||||||
stats = {
|
|
||||||
"tabCount": len(tabs),
|
|
||||||
"nodeCount": len(nodes),
|
|
||||||
"subflowCount": len(subflows),
|
|
||||||
"nodeTypes": node_types,
|
|
||||||
}
|
|
||||||
data = {"tabs": tabs, "nodes": nodes, "subflows": subflows}
|
|
||||||
|
|
||||||
return FlowSummary(summary=summary, statistics=stats, data=data)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_flows() -> FlowList:
|
|
||||||
"""Get all flows with summary statistics.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FlowList with flows grouped into tabs/nodes/subflows and summary stats.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_flows = await client.get_flows()
|
|
||||||
return _build_flow_list(raw_flows)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def update_flows(flows_json: str, deployment_type: str = "full") -> str:
|
|
||||||
"""Update all flows with deployment type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flows_json: Flow configuration as JSON string
|
|
||||||
deployment_type: Deployment type - valid values: full, nodes, flows, reload (default: full)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Confirmation message.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If deployment_type is not one of: full, nodes, flows, reload.
|
|
||||||
"""
|
|
||||||
_VALID_DEPLOYMENT_TYPES = ("full", "nodes", "flows", "reload")
|
|
||||||
if deployment_type not in _VALID_DEPLOYMENT_TYPES:
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid deployment_type '{deployment_type}'. Must be one of: {', '.join(_VALID_DEPLOYMENT_TYPES)}"
|
|
||||||
)
|
|
||||||
client = get_client()
|
|
||||||
flows = json.loads(flows_json)
|
|
||||||
await client.update_flows(flows, deployment_type=deployment_type)
|
|
||||||
return f"Flows updated with deployment type: {deployment_type}"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_flow(flow_id: str) -> Flow:
|
|
||||||
"""Get a single flow by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flow_id: Flow ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Flow with nodes, configs, and subflows.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_flow = await client.get_flow(flow_id)
|
|
||||||
return Flow.from_api(raw_flow)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def update_flow(flow_id: str, flow_json: str) -> str:
|
|
||||||
"""Update a single flow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flow_id: Flow ID
|
|
||||||
flow_json: Flow configuration as JSON string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Confirmation message.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
flow = json.loads(flow_json)
|
|
||||||
await client.update_flow(flow_id, flow)
|
|
||||||
result = f"Flow {flow_id} updated"
|
|
||||||
|
|
||||||
# Auto-lint: re-fetch the flow and check layout
|
|
||||||
from nodered_mcp.tools.layout import _lint_nodes, format_lint_report
|
|
||||||
|
|
||||||
raw_flow = await client.get_flow(flow_id)
|
|
||||||
report = _lint_nodes(raw_flow.get("nodes", []))
|
|
||||||
result += format_lint_report(report)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def list_tabs() -> list[FlowTab]:
|
|
||||||
"""List all flow tabs.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of FlowTab objects (workspaces).
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_flows = await client.get_flows()
|
|
||||||
tabs = [f for f in raw_flows if f.get("type") == "tab"]
|
|
||||||
return [FlowTab.from_api(t) for t in tabs]
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def create_flow(flow_json: str) -> FlowCreateResult:
|
|
||||||
"""Create a new flow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flow_json: Flow configuration as JSON string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FlowCreateResult with the new flow ID.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If flow_json is not valid JSON.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
flow = json.loads(flow_json)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ValueError(f"Invalid JSON in flow_json: {e}") from e
|
|
||||||
client = get_client()
|
|
||||||
result = await client.create_flow(flow)
|
|
||||||
return FlowCreateResult.from_api(result)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def delete_flow(flow_id: str) -> str:
|
|
||||||
"""Delete a flow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flow_id: Flow ID to delete
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Confirmation message.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
await client.delete_flow(flow_id)
|
|
||||||
return f"Flow {flow_id} deleted"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_flows_state() -> FlowState:
|
|
||||||
"""Get the current flows runtime state.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FlowState with current state (start/stop).
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_state = await client.get_flows_state()
|
|
||||||
return FlowState.from_api(raw_state)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def set_flows_state(state: str) -> FlowState:
|
|
||||||
"""Set the flows runtime state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Target state - valid values: start, stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FlowState with new state.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If state is not "start" or "stop".
|
|
||||||
"""
|
|
||||||
if state not in ("start", "stop"):
|
|
||||||
raise ValueError(f"Invalid state '{state}'. Must be 'start' or 'stop'.")
|
|
||||||
client = get_client()
|
|
||||||
raw_state = await client.set_flows_state(state)
|
|
||||||
return FlowState.from_api(raw_state)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def get_flows_formatted() -> FlowSummary:
|
|
||||||
"""Get flows with formatted summary and grouped data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FlowSummary with tabs/nodes/subflows grouped and counted.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_flows = await client.get_flows()
|
|
||||||
return _build_flow_summary(raw_flows)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def visualize_flows() -> str:
|
|
||||||
"""Visualize flow structure as markdown with per-tab breakdown.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown-formatted flow structure showing node counts and types per tab.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_flows = await client.get_flows()
|
|
||||||
|
|
||||||
tabs = [f for f in raw_flows if f.get("type") == "tab"]
|
|
||||||
nodes_by_tab: dict[str, list[dict]] = {}
|
|
||||||
|
|
||||||
for tab in tabs:
|
|
||||||
tab_id = tab.get("id", "")
|
|
||||||
nodes_by_tab[tab_id] = [f for f in raw_flows if f.get("z") == tab_id]
|
|
||||||
|
|
||||||
output = ["# Node-RED Flow Structure", "", "## Tabs", ""]
|
|
||||||
|
|
||||||
for tab in tabs:
|
|
||||||
tab_id = tab.get("id", "")
|
|
||||||
tab_name = tab.get("label") or tab.get("name") or "Unnamed"
|
|
||||||
nodes = nodes_by_tab.get(tab_id, [])
|
|
||||||
|
|
||||||
node_types: dict[str, int] = {}
|
|
||||||
for node in nodes:
|
|
||||||
node_type = node.get("type", "")
|
|
||||||
node_types[node_type] = node_types.get(node_type, 0) + 1
|
|
||||||
|
|
||||||
node_types_str = ", ".join(f"{t}: {c}" for t, c in node_types.items())
|
|
||||||
|
|
||||||
output.append(f"### {tab_name} (ID: {tab_id})")
|
|
||||||
output.append(f"- Number of nodes: {len(nodes)}")
|
|
||||||
output.append(f"- Node types: {node_types_str}")
|
|
||||||
output.append("")
|
|
||||||
|
|
||||||
return "\n".join(output)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_patch_ops(flow: dict, operations: list[dict]) -> tuple[dict, list[str]]:
|
|
||||||
"""Apply patch operations to a flow dict.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (modified_flow, log_messages).
|
|
||||||
"""
|
|
||||||
log: list[str] = []
|
|
||||||
|
|
||||||
for op_def in operations:
|
|
||||||
op = op_def.get("op", "")
|
|
||||||
|
|
||||||
if op == "remove_nodes":
|
|
||||||
ids_to_remove = set(op_def["ids"])
|
|
||||||
before = len(flow.get("nodes", []))
|
|
||||||
flow["nodes"] = [n for n in flow.get("nodes", []) if n.get("id") not in ids_to_remove]
|
|
||||||
removed = before - len(flow["nodes"])
|
|
||||||
# Clean wires referencing removed nodes
|
|
||||||
for node in flow.get("nodes", []):
|
|
||||||
if "wires" in node:
|
|
||||||
node["wires"] = [
|
|
||||||
[w for w in output if w not in ids_to_remove]
|
|
||||||
for output in node["wires"]
|
|
||||||
]
|
|
||||||
log.append(f"removed {removed} nodes")
|
|
||||||
|
|
||||||
elif op == "add_nodes":
|
|
||||||
nodes = op_def["nodes"]
|
|
||||||
flow_id = flow.get("id", "")
|
|
||||||
for node in nodes:
|
|
||||||
node.setdefault("z", flow_id)
|
|
||||||
flow.setdefault("nodes", []).extend(nodes)
|
|
||||||
log.append(f"added {len(nodes)} nodes")
|
|
||||||
|
|
||||||
elif op == "update_node":
|
|
||||||
target_id = op_def["id"]
|
|
||||||
fields = op_def["set"]
|
|
||||||
for node in flow.get("nodes", []):
|
|
||||||
if node.get("id") == target_id:
|
|
||||||
node.update(fields)
|
|
||||||
log.append(f"updated node {target_id}")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
log.append(f"node {target_id} not found")
|
|
||||||
|
|
||||||
elif op == "rewire":
|
|
||||||
target_id = op_def["id"]
|
|
||||||
output_idx = op_def["output"]
|
|
||||||
targets = op_def["targets"]
|
|
||||||
mode = op_def.get("mode", "replace")
|
|
||||||
for node in flow.get("nodes", []):
|
|
||||||
if node.get("id") == target_id:
|
|
||||||
wires = node.setdefault("wires", [])
|
|
||||||
while len(wires) <= output_idx:
|
|
||||||
wires.append([])
|
|
||||||
if mode == "append":
|
|
||||||
wires[output_idx] = [
|
|
||||||
w for w in wires[output_idx] if w not in targets
|
|
||||||
] + targets
|
|
||||||
else:
|
|
||||||
wires[output_idx] = targets
|
|
||||||
log.append(f"rewired node {target_id} output {output_idx}")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
log.append(f"node {target_id} not found")
|
|
||||||
|
|
||||||
elif op == "set_label":
|
|
||||||
old = flow.get("label", "")
|
|
||||||
flow["label"] = op_def["label"]
|
|
||||||
log.append(f"label: '{old}' -> '{op_def['label']}'")
|
|
||||||
|
|
||||||
elif op == "set_info":
|
|
||||||
flow["info"] = op_def["info"]
|
|
||||||
log.append("info updated")
|
|
||||||
|
|
||||||
elif op == "set_disabled":
|
|
||||||
flow["disabled"] = op_def["disabled"]
|
|
||||||
state = "disabled" if op_def["disabled"] else "enabled"
|
|
||||||
log.append(f"flow {state}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown patch op: {op}")
|
|
||||||
|
|
||||||
return flow, log
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def patch_flow(flow_id: str, operations: str) -> str:
|
|
||||||
"""Apply incremental patch operations to a flow.
|
|
||||||
|
|
||||||
Fetches the current flow, applies operations, and updates it.
|
|
||||||
Much more efficient than update_flow for small changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flow_id: Flow ID to patch
|
|
||||||
operations: JSON array of patch operations. Supported ops:
|
|
||||||
- {"op": "remove_nodes", "ids": ["nodeId1", ...]}
|
|
||||||
- {"op": "add_nodes", "nodes": [{node}, ...]}
|
|
||||||
- {"op": "update_node", "id": "nodeId", "set": {"name": "New", ...}}
|
|
||||||
- {"op": "rewire", "id": "nodeId", "output": 0, "targets": ["id1", ...], "mode": "replace|append"}
|
|
||||||
- {"op": "set_label", "label": "New Name"}
|
|
||||||
- {"op": "set_info", "info": "Description text"}
|
|
||||||
- {"op": "set_disabled", "disabled": true}
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Summary of changes applied.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
|
||||||
raw_flow = await client.get_flow(flow_id)
|
|
||||||
ops = json.loads(operations)
|
|
||||||
patched_flow, log = _apply_patch_ops(raw_flow, ops)
|
|
||||||
await client.update_flow(flow_id, patched_flow)
|
|
||||||
result = f"Flow {flow_id} patched: " + "; ".join(log)
|
|
||||||
|
|
||||||
# Auto-lint: check layout of the patched flow (already in memory)
|
|
||||||
from nodered_mcp.tools.layout import _lint_nodes, format_lint_report
|
|
||||||
|
|
||||||
report = _lint_nodes(patched_flow.get("nodes", []))
|
|
||||||
result += format_lint_report(report)
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -6,21 +6,60 @@ from nodered_mcp.client import get_client
|
|||||||
from nodered_mcp.models.layout import LayoutIssue, LayoutReport
|
from nodered_mcp.models.layout import LayoutIssue, LayoutReport
|
||||||
from nodered_mcp.server import mcp
|
from nodered_mcp.server import mcp
|
||||||
|
|
||||||
# Node-RED default node dimensions (px)
|
# Node dimension lookup: maps common node types to (width, height) in pixels
|
||||||
|
# Unknown types fall back to (120, 30)
|
||||||
|
NODE_DIMENSIONS: dict[str, tuple[int, int]] = {
|
||||||
|
"tab": (120, 30),
|
||||||
|
"inject": (120, 30),
|
||||||
|
"debug": (120, 30),
|
||||||
|
"function": (180, 60),
|
||||||
|
"switch": (180, 60),
|
||||||
|
"change": (180, 60),
|
||||||
|
"template": (180, 80),
|
||||||
|
"http response": (180, 40),
|
||||||
|
"http request": (180, 60),
|
||||||
|
"websocket in": (180, 60),
|
||||||
|
"websocket out": (180, 60),
|
||||||
|
"mqtt in": (180, 60),
|
||||||
|
"mqtt out": (180, 60),
|
||||||
|
"tcp in": (180, 60),
|
||||||
|
"tcp out": (180, 60),
|
||||||
|
"udp in": (180, 60),
|
||||||
|
"udp out": (180, 60),
|
||||||
|
"split": (120, 30),
|
||||||
|
"join": (120, 30),
|
||||||
|
"sort": (120, 30),
|
||||||
|
"batch": (180, 60),
|
||||||
|
"link out": (180, 50),
|
||||||
|
"link in": (180, 50),
|
||||||
|
"trigger": (180, 60),
|
||||||
|
"delay": (180, 60),
|
||||||
|
"rbe": (180, 60),
|
||||||
|
"comment": (200, 40),
|
||||||
|
"catch": (180, 50),
|
||||||
|
"status": (180, 50),
|
||||||
|
"complete": (180, 50),
|
||||||
|
"unknown": (120, 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default fallback for node types not in the lookup
|
||||||
DEFAULT_NODE_WIDTH = 120
|
DEFAULT_NODE_WIDTH = 120
|
||||||
DEFAULT_NODE_HEIGHT = 30
|
DEFAULT_NODE_HEIGHT = 30
|
||||||
MIN_SPACING = 20 # minimum gap between nodes (matches Node-RED grid)
|
MIN_SPACING = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _get_node_dims(node: dict) -> tuple[int, int]:
|
||||||
|
node_type = node.get("type", "unknown")
|
||||||
|
return NODE_DIMENSIONS.get(node_type, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
|
||||||
|
|
||||||
|
|
||||||
def _is_placed(node: dict) -> bool:
|
def _is_placed(node: dict) -> bool:
|
||||||
"""Check if a node has a meaningful position (not unplaced/config)."""
|
|
||||||
x = node.get("x", 0)
|
x = node.get("x", 0)
|
||||||
y = node.get("y", 0)
|
y = node.get("y", 0)
|
||||||
return not (x == 0 and y == 0)
|
return not (x == 0 and y == 0)
|
||||||
|
|
||||||
|
|
||||||
def _node_label(node: dict) -> str:
|
def _node_label(node: dict) -> str:
|
||||||
"""Get a human-readable label for a node."""
|
|
||||||
return node.get("name") or node.get("type") or node.get("id", "?")
|
return node.get("name") or node.get("type") or node.get("id", "?")
|
||||||
|
|
||||||
|
|
||||||
@@ -28,15 +67,8 @@ def _segment_intersects_aabb(
|
|||||||
x1: float, y1: float, x2: float, y2: float,
|
x1: float, y1: float, x2: float, y2: float,
|
||||||
bx: float, by: float, bw: float, bh: float,
|
bx: float, by: float, bw: float, bh: float,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Test if line segment (x1,y1)→(x2,y2) intersects axis-aligned bounding box.
|
|
||||||
|
|
||||||
The box is centered at (bx, by) with half-widths bw and bh.
|
|
||||||
Uses parametric clipping (Liang-Barsky).
|
|
||||||
"""
|
|
||||||
dx = x2 - x1
|
dx = x2 - x1
|
||||||
dy = y2 - y1
|
dy = y2 - y1
|
||||||
|
|
||||||
# Box edges relative to segment origin
|
|
||||||
p = [-dx, dx, -dy, dy]
|
p = [-dx, dx, -dy, dy]
|
||||||
q = [
|
q = [
|
||||||
x1 - (bx - bw),
|
x1 - (bx - bw),
|
||||||
@@ -44,10 +76,8 @@ def _segment_intersects_aabb(
|
|||||||
y1 - (by - bh),
|
y1 - (by - bh),
|
||||||
(by + bh) - y1,
|
(by + bh) - y1,
|
||||||
]
|
]
|
||||||
|
|
||||||
t_enter = 0.0
|
t_enter = 0.0
|
||||||
t_exit = 1.0
|
t_exit = 1.0
|
||||||
|
|
||||||
for pi, qi in zip(p, q):
|
for pi, qi in zip(p, q):
|
||||||
if pi == 0:
|
if pi == 0:
|
||||||
if qi < 0:
|
if qi < 0:
|
||||||
@@ -60,98 +90,90 @@ def _segment_intersects_aabb(
|
|||||||
t_exit = min(t_exit, t)
|
t_exit = min(t_exit, t)
|
||||||
if t_enter > t_exit:
|
if t_enter > t_exit:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutReport:
|
def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutReport:
|
||||||
"""Run layout lint checks on a list of raw node dicts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nodes: Raw node dicts (from client).
|
|
||||||
node_ids: Optional list of node IDs to scope checks to.
|
|
||||||
When provided, only pairs involving at least one specified node
|
|
||||||
are checked, and only wires from/to specified nodes are checked.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LayoutReport with any issues found.
|
|
||||||
"""
|
|
||||||
# Filter to placed nodes only
|
|
||||||
placed = [n for n in nodes if _is_placed(n)]
|
placed = [n for n in nodes if _is_placed(n)]
|
||||||
scope = set(node_ids) if node_ids else None
|
scope = set(node_ids) if node_ids else None
|
||||||
issues: list[LayoutIssue] = []
|
issues: list[LayoutIssue] = []
|
||||||
overlap_pairs: set[tuple[str, str]] = set()
|
overlap_pairs: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
hw = DEFAULT_NODE_WIDTH / 2
|
node_dims: dict[str, tuple[int, int]] = {}
|
||||||
hh = DEFAULT_NODE_HEIGHT / 2
|
for n in placed:
|
||||||
|
nid = n.get("id", "")
|
||||||
|
node_dims[nid] = _get_node_dims(n)
|
||||||
|
|
||||||
# Pairwise checks: overlap and spacing
|
|
||||||
for i, a in enumerate(placed):
|
for i, a in enumerate(placed):
|
||||||
aid = a.get("id", "")
|
aid = a.get("id", "")
|
||||||
ax, ay = a.get("x", 0), a.get("y", 0)
|
ax, ay = a.get("x", 0), a.get("y", 0)
|
||||||
|
aw, ah = node_dims[aid]
|
||||||
|
ahw, ahh = aw / 2, ah / 2
|
||||||
|
|
||||||
for b in placed[i + 1:]:
|
for b in placed[i + 1:]:
|
||||||
bid = b.get("id", "")
|
bid = b.get("id", "")
|
||||||
|
|
||||||
# If scoped, at least one node must be in scope
|
|
||||||
if scope and aid not in scope and bid not in scope:
|
if scope and aid not in scope and bid not in scope:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
bx, by = b.get("x", 0), b.get("y", 0)
|
bx, by = b.get("x", 0), b.get("y", 0)
|
||||||
|
bw, bh = node_dims[bid]
|
||||||
|
bhw, bhh = bw / 2, bh / 2
|
||||||
|
|
||||||
dx = abs(ax - bx)
|
dx = abs(ax - bx)
|
||||||
dy = abs(ay - by)
|
dy = abs(ay - by)
|
||||||
|
combined_w = ahw + bhw
|
||||||
|
combined_h = ahh + bhh
|
||||||
|
|
||||||
# Overlap: bounding boxes intersect
|
if dx < combined_w and dy < combined_h:
|
||||||
if dx < DEFAULT_NODE_WIDTH and dy < DEFAULT_NODE_HEIGHT:
|
|
||||||
pair = (min(aid, bid), max(aid, bid))
|
pair = (min(aid, bid), max(aid, bid))
|
||||||
overlap_pairs.add(pair)
|
overlap_pairs.add(pair)
|
||||||
issues.append(LayoutIssue(
|
issues.append(LayoutIssue(
|
||||||
severity="error",
|
severity="error",
|
||||||
rule="overlap",
|
rule="overlap",
|
||||||
message=(
|
message=(
|
||||||
f'"{_node_label(a)}" and "{_node_label(b)}" overlap '
|
f"{_node_label(a)} and {_node_label(b)} overlap "
|
||||||
f"at ({ax}, {ay}) — separate by at least "
|
f"at ({ax}, {ay})"
|
||||||
f"{DEFAULT_NODE_WIDTH}px horizontally or "
|
|
||||||
f"{DEFAULT_NODE_HEIGHT}px vertically"
|
|
||||||
),
|
),
|
||||||
node_ids=[aid, bid],
|
node_ids=[aid, bid],
|
||||||
))
|
))
|
||||||
# Spacing: too close but not overlapping
|
elif dx < combined_w + MIN_SPACING and dy < combined_h + MIN_SPACING:
|
||||||
elif dx < DEFAULT_NODE_WIDTH + MIN_SPACING and dy < DEFAULT_NODE_HEIGHT + MIN_SPACING:
|
|
||||||
gap = min(
|
gap = min(
|
||||||
max(dx - DEFAULT_NODE_WIDTH, 0),
|
max(dx - combined_w, 0),
|
||||||
max(dy - DEFAULT_NODE_HEIGHT, 0),
|
max(dy - combined_h, 0),
|
||||||
)
|
)
|
||||||
issues.append(LayoutIssue(
|
issues.append(LayoutIssue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
rule="spacing",
|
rule="spacing",
|
||||||
message=(
|
message=(
|
||||||
f'"{_node_label(a)}" and "{_node_label(b)}" are only '
|
f"{_node_label(a)} and {_node_label(b)} are only "
|
||||||
f"{gap}px apart at ({ax}, {ay})"
|
f"{int(gap)}px apart at ({ax}, {ay})"
|
||||||
),
|
),
|
||||||
node_ids=[aid, bid],
|
node_ids=[aid, bid],
|
||||||
))
|
))
|
||||||
|
|
||||||
# Wire-through-node check
|
|
||||||
node_by_id = {n.get("id", ""): n for n in placed}
|
node_by_id = {n.get("id", ""): n for n in placed}
|
||||||
for node in placed:
|
for node in placed:
|
||||||
nid = node.get("id", "")
|
nid = node.get("id", "")
|
||||||
if scope and nid not in scope:
|
if scope and nid not in scope:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
nw, nh = node_dims.get(nid, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
|
||||||
|
nhw = nw / 2
|
||||||
|
|
||||||
for output_wires in node.get("wires", []):
|
for output_wires in node.get("wires", []):
|
||||||
for target_id in output_wires:
|
for target_id in output_wires:
|
||||||
target = node_by_id.get(target_id)
|
target = node_by_id.get(target_id)
|
||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Segment from source right edge to target left edge
|
tw, _ = node_dims.get(target_id, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
|
||||||
sx = node.get("x", 0) + hw
|
thw = tw / 2
|
||||||
|
|
||||||
|
sx = node.get("x", 0) + nhw
|
||||||
sy = node.get("y", 0)
|
sy = node.get("y", 0)
|
||||||
tx = target.get("x", 0) - hw
|
tx = target.get("x", 0) - thw
|
||||||
ty = target.get("y", 0)
|
ty = target.get("y", 0)
|
||||||
|
|
||||||
# Check against all other placed nodes
|
|
||||||
for other in placed:
|
for other in placed:
|
||||||
oid = other.get("id", "")
|
oid = other.get("id", "")
|
||||||
if oid in (nid, target_id):
|
if oid in (nid, target_id):
|
||||||
@@ -159,20 +181,21 @@ def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutR
|
|||||||
|
|
||||||
ox = other.get("x", 0)
|
ox = other.get("x", 0)
|
||||||
oy = other.get("y", 0)
|
oy = other.get("y", 0)
|
||||||
|
ow, oh = node_dims.get(oid, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
|
||||||
|
ohw, ohh = ow / 2, oh / 2
|
||||||
|
|
||||||
if _segment_intersects_aabb(sx, sy, tx, ty, ox, oy, hw, hh):
|
if _segment_intersects_aabb(sx, sy, tx, ty, ox, oy, ohw, ohh):
|
||||||
issues.append(LayoutIssue(
|
issues.append(LayoutIssue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
rule="wire_crossing",
|
rule="wire_crossing",
|
||||||
message=(
|
message=(
|
||||||
f'Wire from "{_node_label(node)}" to '
|
f"Wire from {_node_label(node)} to "
|
||||||
f'"{_node_label(target)}" passes through '
|
f"{_node_label(target)} passes through "
|
||||||
f'"{_node_label(other)}"'
|
f"{_node_label(other)}"
|
||||||
),
|
),
|
||||||
node_ids=[nid, target_id, oid],
|
node_ids=[nid, target_id, oid],
|
||||||
))
|
))
|
||||||
|
|
||||||
# Build summary
|
|
||||||
error_count = sum(1 for i in issues if i.severity == "error")
|
error_count = sum(1 for i in issues if i.severity == "error")
|
||||||
warning_count = sum(1 for i in issues if i.severity == "warning")
|
warning_count = sum(1 for i in issues if i.severity == "warning")
|
||||||
parts = []
|
parts = []
|
||||||
@@ -190,7 +213,6 @@ def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutR
|
|||||||
|
|
||||||
|
|
||||||
def format_lint_report(report: LayoutReport) -> str:
|
def format_lint_report(report: LayoutReport) -> str:
|
||||||
"""Format a LayoutReport as a human-readable string for appending to tool output."""
|
|
||||||
if not report.issues:
|
if not report.issues:
|
||||||
return ""
|
return ""
|
||||||
lines = [f"\nLayout ({report.summary}):"]
|
lines = [f"\nLayout ({report.summary}):"]
|
||||||
@@ -201,15 +223,6 @@ def format_lint_report(report: LayoutReport) -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def lint_flow(flow_id: str, node_ids: str | None = None) -> LayoutReport:
|
async def lint_flow(flow_id: str, node_ids: str | None = None) -> LayoutReport:
|
||||||
"""Lint a flow's node layout for overlaps, spacing, and wire crossings.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
flow_id: Flow ID to lint.
|
|
||||||
node_ids: Optional JSON array of node IDs to scope the check.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
LayoutReport with issues, nodes checked count, and summary.
|
|
||||||
"""
|
|
||||||
client = get_client()
|
client = get_client()
|
||||||
raw_flow = await client.get_flow(flow_id)
|
raw_flow = await client.get_flow(flow_id)
|
||||||
nodes = raw_flow.get("nodes", [])
|
nodes = raw_flow.get("nodes", [])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from nodered_mcp.client import get_client
|
from nodered_mcp.client import get_client
|
||||||
|
from nodered_mcp.exceptions import NodeREDError
|
||||||
from nodered_mcp.models.node import NodeModule, NodeSet
|
from nodered_mcp.models.node import NodeModule, NodeSet
|
||||||
from nodered_mcp.models.flow import Node
|
from nodered_mcp.models.flow import Node
|
||||||
from nodered_mcp.server import mcp
|
from nodered_mcp.server import mcp
|
||||||
@@ -18,9 +19,12 @@ async def inject(node_id: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Confirmation message.
|
Confirmation message.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
await client.inject(node_id)
|
client = get_client()
|
||||||
return f"Inject node {node_id} triggered"
|
await client.inject(node_id)
|
||||||
|
return f"Inject node {node_id} triggered"
|
||||||
|
except NodeREDError as e:
|
||||||
|
return json.dumps({"error": True, "message": str(e), "type": type(e).__name__})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -30,9 +34,12 @@ async def get_nodes() -> list[NodeSet]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of NodeSet objects with node types and versions.
|
List of NodeSet objects with node types and versions.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_nodes = await client.get_nodes()
|
client = get_client()
|
||||||
return [NodeSet.from_api(n) for n in raw_nodes]
|
raw_nodes = await client.get_nodes()
|
||||||
|
return [NodeSet.from_api(n) for n in raw_nodes]
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -45,9 +52,12 @@ async def get_node_info(module: str) -> NodeModule:
|
|||||||
Returns:
|
Returns:
|
||||||
NodeModule with version and node sets.
|
NodeModule with version and node sets.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_info = await client.get_node_info(module)
|
client = get_client()
|
||||||
return NodeModule.from_api(raw_info)
|
raw_info = await client.get_node_info(module)
|
||||||
|
return NodeModule.from_api(raw_info)
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -61,9 +71,12 @@ async def toggle_node_module(module: str, enabled: bool) -> NodeModule:
|
|||||||
Returns:
|
Returns:
|
||||||
Updated NodeModule object.
|
Updated NodeModule object.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_module = await client.toggle_node_module(module, enabled)
|
client = get_client()
|
||||||
return NodeModule.from_api(raw_module)
|
raw_module = await client.toggle_node_module(module, enabled)
|
||||||
|
return NodeModule.from_api(raw_module)
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -76,13 +89,16 @@ async def find_nodes_by_type(node_type: str) -> list[Node]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of matching Node objects.
|
List of matching Node objects.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_flows = await client.get_flows()
|
client = get_client()
|
||||||
matches = [
|
raw_flows = await client.get_flows()
|
||||||
f for f in raw_flows
|
matches = [
|
||||||
if f.get("type") == node_type and node_type not in ("tab", "subflow")
|
f for f in raw_flows
|
||||||
]
|
if f.get("type") == node_type and node_type not in ("tab", "subflow")
|
||||||
return [Node.from_api(n) for n in matches]
|
]
|
||||||
|
return [Node.from_api(n) for n in matches]
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -96,16 +112,19 @@ async def search_nodes(query: str, property: str | None = None) -> list[Node]:
|
|||||||
Returns:
|
Returns:
|
||||||
List of matching Node objects.
|
List of matching Node objects.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_flows = await client.get_flows()
|
client = get_client()
|
||||||
|
raw_flows = await client.get_flows()
|
||||||
|
|
||||||
matches = []
|
matches = []
|
||||||
for node in raw_flows:
|
for node in raw_flows:
|
||||||
if property:
|
if property:
|
||||||
if property in node and query in str(node.get(property, "")):
|
if property in node and query in str(node.get(property, "")):
|
||||||
matches.append(node)
|
matches.append(node)
|
||||||
else:
|
else:
|
||||||
if query in json.dumps(node):
|
if query in json.dumps(node):
|
||||||
matches.append(node)
|
matches.append(node)
|
||||||
|
|
||||||
return [Node.from_api(n) for n in matches]
|
return [Node.from_api(n) for n in matches]
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Settings and diagnostics tools for Node-RED."""
|
"""Settings and diagnostics tools for Node-RED."""
|
||||||
|
|
||||||
from nodered_mcp.client import get_client
|
from nodered_mcp.client import get_client
|
||||||
|
from nodered_mcp.exceptions import NodeREDError
|
||||||
from nodered_mcp.models.responses import DiagnosticsResult, Settings
|
from nodered_mcp.models.responses import DiagnosticsResult, Settings
|
||||||
from nodered_mcp.server import mcp
|
from nodered_mcp.server import mcp
|
||||||
|
|
||||||
@@ -12,9 +13,12 @@ async def get_settings() -> Settings:
|
|||||||
Returns:
|
Returns:
|
||||||
Settings with runtime configuration.
|
Settings with runtime configuration.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_settings = await client.get_settings()
|
client = get_client()
|
||||||
return Settings.from_api(raw_settings)
|
raw_settings = await client.get_settings()
|
||||||
|
return Settings.from_api(raw_settings)
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -24,6 +28,9 @@ async def get_diagnostics() -> DiagnosticsResult:
|
|||||||
Returns:
|
Returns:
|
||||||
DiagnosticsResult with system diagnostics data.
|
DiagnosticsResult with system diagnostics data.
|
||||||
"""
|
"""
|
||||||
client = get_client()
|
try:
|
||||||
raw_diagnostics = await client.get_diagnostics()
|
client = get_client()
|
||||||
return DiagnosticsResult(data=raw_diagnostics)
|
raw_diagnostics = await client.get_diagnostics()
|
||||||
|
return DiagnosticsResult(data=raw_diagnostics)
|
||||||
|
except NodeREDError as e:
|
||||||
|
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nodered_mcp.client import NodeRedClient, get_client, reset_client
|
from nodered_mcp.client import NodeRedClient, get_client, reset_client
|
||||||
|
from nodered_mcp.exceptions import ValidationError, NotFoundError, AuthError, UpstreamError
|
||||||
|
|
||||||
|
|
||||||
class TestNodeRedClient:
|
class TestNodeRedClient:
|
||||||
@@ -62,7 +63,7 @@ class TestNodeRedClient:
|
|||||||
mock_response.text = "Bad Request"
|
mock_response.text = "Bad Request"
|
||||||
|
|
||||||
with patch.object(client._http, "request", return_value=mock_response):
|
with patch.object(client._http, "request", return_value=mock_response):
|
||||||
with pytest.raises(RuntimeError, match="Node-RED API error 400: Bad Request"):
|
with pytest.raises(ValidationError, match="Node-RED API error 400: Bad Request"):
|
||||||
await client._request("GET", "/flows")
|
await client._request("GET", "/flows")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -73,7 +74,7 @@ class TestNodeRedClient:
|
|||||||
mock_response.text = "Not Found"
|
mock_response.text = "Not Found"
|
||||||
|
|
||||||
with patch.object(client._http, "request", return_value=mock_response):
|
with patch.object(client._http, "request", return_value=mock_response):
|
||||||
with pytest.raises(RuntimeError, match="Node-RED API error 404: Not Found"):
|
with pytest.raises(NotFoundError, match="Node-RED API error 404: Not Found"):
|
||||||
await client._request("GET", "/flow/nonexistent")
|
await client._request("GET", "/flow/nonexistent")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -84,7 +85,7 @@ class TestNodeRedClient:
|
|||||||
mock_response.text = "Internal Server Error"
|
mock_response.text = "Internal Server Error"
|
||||||
|
|
||||||
with patch.object(client._http, "request", return_value=mock_response):
|
with patch.object(client._http, "request", return_value=mock_response):
|
||||||
with pytest.raises(RuntimeError, match="Node-RED API error 500"):
|
with pytest.raises(UpstreamError, match="Node-RED API error 500"):
|
||||||
await client._request("POST", "/flows")
|
await client._request("POST", "/flows")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -367,7 +368,7 @@ class TestOAuthAuth:
|
|||||||
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
mock_auth_cls.return_value = mock_auth_client
|
mock_auth_cls.return_value = mock_auth_client
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="OAuth token error 401"):
|
with pytest.raises(AuthError, match="OAuth token error 401"):
|
||||||
await client._ensure_oauth_token()
|
await client._ensure_oauth_token()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user