Skip to content

Commit c7df56d

Browse files
authored
feat: add ha_call_event tool for publishing events on the HA event bus (#996) (#1239)
* feat: add ha_fire_event tool for firing events on the HA event bus (#996) * refactor(internal): add fire verb to approved list, add Caveats to docstring (#996) - Add `fire` verb to AGENTS.md approved-verb list as Gemini suggested - Add Caveats section to ha_fire_event docstring: events are fire-and-forget * fix: set destructiveHint=True on ha_fire_event and remove unused import - ha_fire_event fires events with side effects (triggers automations, subscribers) so destructiveHint=True is correct; False was bypassing the annotation compliance test - Remove unused safe_call_tool import from test_ha_fire_event.py (ruff F401) * fix: align ha_fire_event data parsing with repo error-handling pattern - Wrap parse_json_param in try/except ValueError → raise_tool_error with invalid_json=True, matching _parse_service_data and ha_bulk_control - Declare parsed_data before the inner try block (repo variable-init rule) - Replace cast(dict, raw) with direct isinstance check + explicit assignment; include details=f"Received type: {type(raw).__name__}" in the validation error - Add unit test: test_raises_tool_error_on_invalid_json_string covers the new ValueError → ToolError path (was previously untested) Addresses Gemini code-review comment on PR #1239. * refactor(internal): rename ha_fire_event -> ha_call_event, revert fire verb Per maintainer feedback: use the existing `call` verb for consistency with ha_call_service rather than introducing a new `fire` verb. No behavior change; only the tool name, method name, test files, and AGENTS.md entry are updated. * test(internal): address all CHANGES_REQUESTED items on ha_call_event PR - Rename ha_fire_event → ha_call_event in tools_service.py; update docstring verb to 'Execute'; revert 'fire' from approved verbs list - Add event_type validation: reject empty/whitespace and path-separator values before the wire call (fixes malformed URL at POST /api/events/) - Mirror HomeAssistantConnectionError + httpx.TimeoutException handling from ha_call_service: return partial-success instead of raising - Unit tests: fix broken ha_fire_event call, remove duplicate method, strengthen assertions (event_type + success fields), add 4 new tests (empty type, whitespace type, slash in type, ToolError re-raise) - E2E tests: drop test_call_builtin_event_type (fires homeassistant_start mid-suite, pollutes later tests); add test_call_event_delivery_verified using input_boolean + event-triggered automation to prove end-to-end delivery through the HA event bus
1 parent f363b3b commit c7df56d

4 files changed

Lines changed: 364 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ These feed the picker tiles in the markup section AND the wizard `<script>` bloc
507507
- `set` — create/update (`ha_config_set_helper`)
508508
- `delete` — delete dashboards, config entries, or files (`ha_config_delete_dashboard`, `ha_delete_file`)
509509
- `remove` — remove registry items (`ha_remove_entity`, `ha_remove_area_or_floor`)
510-
- `call` — execute (`ha_call_service`)
510+
- `call` — execute (`ha_call_service`, `ha_call_event`)
511511
- `manage` — multi-modal tools combining several operations behind one interface (`ha_manage_addon`)
512512

513513
**Namespace prefixes**: An optional `<namespace>_` prefix between `ha_` and the verb is allowed for grouped tool families that share a domain. The full shape becomes `ha_<namespace>_<verb>_<noun>`:

src/ha_mcp/tools/tools_service.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,115 @@ async def ha_bulk_control(
431431
)
432432
return cast(dict[str, Any], result)
433433

434+
@tool(
435+
name="ha_call_event",
436+
tags={"Service & Device Control"},
437+
annotations={
438+
"destructiveHint": True,
439+
"idempotentHint": False,
440+
"title": "Call Event",
441+
},
442+
)
443+
@log_tool_usage
444+
async def ha_call_event(
445+
self,
446+
event_type: str,
447+
data: str | dict[str, Any] | None = None,
448+
) -> dict[str, Any]:
449+
"""Execute a custom event on the Home Assistant event bus.
450+
451+
When NOT to use: for controlling entities (lights, switches, climate) — use
452+
ha_call_service instead. For triggering automations by name, use
453+
ha_call_service("automation", "trigger").
454+
455+
Use this to publish custom event types consumed by event-triggered automations,
456+
Node-RED flows, or custom integrations that subscribe to specific event types.
457+
458+
Caveats: Events are fire-and-forget; this tool confirms the event was accepted
459+
by the bus but does not verify whether any automation or subscriber acted on it.
460+
"""
461+
# Validate event_type before hitting the wire — empty strings or path separators
462+
# produce malformed URLs at POST /api/events/{event_type}.
463+
if not event_type or not event_type.strip():
464+
raise_tool_error(
465+
create_validation_error(
466+
"event_type cannot be empty or whitespace",
467+
parameter="event_type",
468+
)
469+
)
470+
if "/" in event_type or "\\" in event_type:
471+
raise_tool_error(
472+
create_validation_error(
473+
"event_type cannot contain path separators",
474+
parameter="event_type",
475+
details=f"Received: {event_type!r}",
476+
)
477+
)
478+
479+
parsed_data: dict[str, Any] | None = None
480+
if data is not None:
481+
raw: Any = None
482+
try:
483+
raw = parse_json_param(data, "data")
484+
except ValueError as e:
485+
raise_tool_error(
486+
create_validation_error(
487+
f"Invalid data parameter: {e}",
488+
parameter="data",
489+
invalid_json=True,
490+
)
491+
)
492+
if raw is not None:
493+
if not isinstance(raw, dict):
494+
raise_tool_error(
495+
create_validation_error(
496+
"Event data must be a JSON object (dict)",
497+
parameter="data",
498+
details=f"Received type: {type(raw).__name__}",
499+
)
500+
)
501+
parsed_data = raw
502+
503+
try:
504+
response = await self._client.fire_event(event_type, parsed_data)
505+
except HomeAssistantConnectionError as error:
506+
if isinstance(error.__cause__, httpx.TimeoutException):
507+
return {
508+
"success": True,
509+
"partial": True,
510+
"event_type": event_type,
511+
"message": (
512+
f"Event {event_type} was dispatched but Home Assistant "
513+
"did not respond within the timeout period."
514+
),
515+
"warning": (
516+
"Response timed out. The event was dispatched and may still "
517+
"have been delivered to subscribers."
518+
),
519+
}
520+
exception_to_structured_error(
521+
error,
522+
context={"event_type": event_type},
523+
suggestions=["Check Home Assistant connection"],
524+
)
525+
except ToolError:
526+
raise
527+
except Exception as e:
528+
exception_to_structured_error(
529+
e,
530+
context={"event_type": event_type},
531+
suggestions=[
532+
"Check Home Assistant connection",
533+
"Verify event_type is a valid identifier",
534+
],
535+
)
536+
537+
return {
538+
"success": True,
539+
"event_type": event_type,
540+
"message": response.get("message", f"Event {event_type} fired."),
541+
}
542+
434543

435544
def register_service_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
436545
"""Register service call and operation monitoring tools with the MCP server."""
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
E2E tests for ha_call_event tool - publish events onto the HA event bus.
3+
"""
4+
5+
import logging
6+
import uuid
7+
8+
import pytest
9+
from fastmcp.exceptions import ToolError
10+
11+
from ..utilities.assertions import assert_mcp_success, wait_for_automation
12+
from ..utilities.wait_helpers import wait_for_entity_state
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_call_event_no_data(mcp_client):
19+
"""Publish a custom event without event data."""
20+
result = await mcp_client.call_tool(
21+
"ha_call_event",
22+
{"event_type": "test_mcp_event_no_data"},
23+
)
24+
data = assert_mcp_success(result, "call event without data")
25+
assert data["success"] is True
26+
assert data["event_type"] == "test_mcp_event_no_data"
27+
assert "message" in data
28+
logger.info("Called event without data: %s", data)
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_call_event_with_dict_data(mcp_client):
33+
"""Publish a custom event with dict event data."""
34+
result = await mcp_client.call_tool(
35+
"ha_call_event",
36+
{"event_type": "test_mcp_event_with_data", "data": {"key": "value", "count": 1}},
37+
)
38+
data = assert_mcp_success(result, "call event with dict data")
39+
assert data["success"] is True
40+
assert data["event_type"] == "test_mcp_event_with_data"
41+
logger.info("Called event with dict data: %s", data)
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_call_event_with_json_string_data(mcp_client):
46+
"""Publish a custom event with JSON-string event data (auto-parsed)."""
47+
result = await mcp_client.call_tool(
48+
"ha_call_event",
49+
{"event_type": "test_mcp_event_json_str", "data": '{"source": "e2e_test"}'},
50+
)
51+
data = assert_mcp_success(result, "call event with JSON string data")
52+
assert data["success"] is True
53+
assert data["event_type"] == "test_mcp_event_json_str"
54+
logger.info("Called event with JSON string data: %s", data)
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_call_event_list_data_rejected(mcp_client):
59+
"""Event data must be a dict — a JSON array is rejected with ToolError."""
60+
with pytest.raises(ToolError):
61+
await mcp_client.call_tool(
62+
"ha_call_event",
63+
{"event_type": "test_mcp_event_bad_data", "data": "[1, 2, 3]"},
64+
)
65+
66+
67+
@pytest.mark.asyncio
68+
async def test_call_event_delivery_verified(mcp_client):
69+
"""Prove that ha_call_event delivers events to HA subscribers end-to-end.
70+
71+
Creates an input_boolean flag and an automation that turns it on when the
72+
custom event fires. After calling ha_call_event, polls until the flag
73+
reaches 'on', confirming event delivery through the HA event bus.
74+
"""
75+
suffix = uuid.uuid4().hex[:8]
76+
event_type = f"test_mcp_event_delivery_{suffix}"
77+
boolean_id = None
78+
automation_id = None
79+
80+
# 1. Create input_boolean flag (starts 'off')
81+
boolean_result = await mcp_client.call_tool(
82+
"ha_config_set_helper",
83+
{"helper_type": "input_boolean", "name": f"e2e event delivery {suffix}"},
84+
)
85+
boolean_data = assert_mcp_success(boolean_result, "Create input_boolean flag")
86+
boolean_id = (
87+
boolean_data.get("entity_id")
88+
or f"input_boolean.{boolean_data['helper_data']['id']}"
89+
)
90+
logger.info("Created flag entity: %s", boolean_id)
91+
92+
try:
93+
# 2. Create automation: event trigger → turn on the flag
94+
automation_config = {
95+
"alias": f"e2e event delivery {suffix}",
96+
"trigger": [{"platform": "event", "event_type": event_type}],
97+
"action": [
98+
{
99+
"service": "input_boolean.turn_on",
100+
"target": {"entity_id": boolean_id},
101+
}
102+
],
103+
}
104+
create_result = await mcp_client.call_tool(
105+
"ha_config_set_automation",
106+
{"config": automation_config},
107+
)
108+
create_data = assert_mcp_success(create_result, "Create delivery-probe automation")
109+
automation_id = create_data.get("entity_id") or create_data.get("automation_id")
110+
logger.info("Created automation: %s", automation_id)
111+
112+
# 3. Wait for automation to be fully registered before firing
113+
config = await wait_for_automation(mcp_client, automation_id, timeout=15)
114+
assert config is not None, f"Automation {automation_id} not registered within 15s"
115+
116+
# 4. Fire the event via ha_call_event
117+
event_result = await mcp_client.call_tool(
118+
"ha_call_event",
119+
{"event_type": event_type},
120+
)
121+
data = assert_mcp_success(event_result, "fire custom event")
122+
assert data["success"] is True
123+
assert data["event_type"] == event_type
124+
logger.info("Fired event %r: %s", event_type, data)
125+
126+
# 5. Wait for flag to flip 'on' — proves event reached the bus
127+
state_reached = await wait_for_entity_state(
128+
mcp_client, boolean_id, "on", timeout=15
129+
)
130+
assert state_reached, (
131+
f"Event {event_type!r} was not delivered: "
132+
f"{boolean_id} did not reach 'on' within 15s"
133+
)
134+
logger.info("Event delivery verified: %s reached 'on'", boolean_id)
135+
136+
finally:
137+
if automation_id:
138+
try:
139+
await mcp_client.call_tool(
140+
"ha_config_remove_automation",
141+
{"identifier": automation_id},
142+
)
143+
except Exception as exc:
144+
logger.warning("Cleanup failed for automation %s: %s", automation_id, exc)
145+
if boolean_id:
146+
try:
147+
await mcp_client.call_tool(
148+
"ha_delete_helpers_integrations",
149+
{
150+
"target": boolean_id,
151+
"helper_type": "input_boolean",
152+
"confirm": True,
153+
},
154+
)
155+
except Exception as exc:
156+
logger.warning("Cleanup failed for helper %s: %s", boolean_id, exc)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Unit tests for ha_call_event tool."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
import pytest
6+
from fastmcp.exceptions import ToolError
7+
8+
from ha_mcp.tools.tools_service import ServiceTools
9+
10+
11+
def _make_tools(fire_event_return: dict | Exception) -> ServiceTools:
12+
client = MagicMock()
13+
if isinstance(fire_event_return, Exception):
14+
client.fire_event = AsyncMock(side_effect=fire_event_return)
15+
else:
16+
client.fire_event = AsyncMock(return_value=fire_event_return)
17+
tools = ServiceTools.__new__(ServiceTools)
18+
tools._client = client
19+
tools._device_tools = MagicMock()
20+
return tools
21+
22+
23+
class TestHaCallEvent:
24+
async def test_fires_event_with_no_data(self):
25+
tools = _make_tools({"message": "Event my_event fired."})
26+
result = await tools.ha_call_event("my_event")
27+
assert result["success"] is True
28+
assert result["event_type"] == "my_event"
29+
assert "fired" in result["message"]
30+
tools._client.fire_event.assert_called_once_with("my_event", None)
31+
32+
async def test_fires_event_with_dict_data(self):
33+
tools = _make_tools({"message": "Event custom_event fired."})
34+
result = await tools.ha_call_event("custom_event", {"key": "value"})
35+
assert result["success"] is True
36+
assert result["event_type"] == "custom_event"
37+
tools._client.fire_event.assert_called_once_with("custom_event", {"key": "value"})
38+
39+
async def test_fires_event_with_json_string_data(self):
40+
tools = _make_tools({"message": "Event my_event fired."})
41+
result = await tools.ha_call_event("my_event", '{"temperature": 22}')
42+
assert result["success"] is True
43+
assert result["event_type"] == "my_event"
44+
tools._client.fire_event.assert_called_once_with("my_event", {"temperature": 22})
45+
46+
async def test_raises_tool_error_on_list_data(self):
47+
tools = _make_tools({"message": "ok"})
48+
with pytest.raises(ToolError):
49+
await tools.ha_call_event("my_event", "[1, 2, 3]")
50+
51+
async def test_raises_tool_error_on_invalid_json_string(self):
52+
"""Invalid JSON raises ToolError via the ValueError → invalid_json path."""
53+
tools = _make_tools({"message": "ok"})
54+
with pytest.raises(ToolError):
55+
await tools.ha_call_event("my_event", "{not valid json")
56+
57+
async def test_returns_fallback_message_when_response_empty(self):
58+
tools = _make_tools({})
59+
result = await tools.ha_call_event("my_event")
60+
assert result["success"] is True
61+
assert result["event_type"] == "my_event"
62+
assert "my_event" in result["message"]
63+
64+
async def test_propagates_connection_error_as_tool_error(self):
65+
tools = _make_tools(ConnectionError("HA unreachable"))
66+
with pytest.raises(ToolError):
67+
await tools.ha_call_event("my_event")
68+
69+
async def test_event_type_passed_to_client(self):
70+
tools = _make_tools({"message": "Event zone_entered fired."})
71+
await tools.ha_call_event("zone_entered", {"zone": "home"})
72+
call_args = tools._client.fire_event.call_args
73+
assert call_args[0][0] == "zone_entered"
74+
assert call_args[0][1] == {"zone": "home"}
75+
76+
async def test_raises_tool_error_on_empty_event_type(self):
77+
"""Empty event_type is rejected before hitting the wire."""
78+
tools = _make_tools({"message": "ok"})
79+
with pytest.raises(ToolError):
80+
await tools.ha_call_event("")
81+
82+
async def test_raises_tool_error_on_whitespace_event_type(self):
83+
"""Whitespace-only event_type is rejected before hitting the wire."""
84+
tools = _make_tools({"message": "ok"})
85+
with pytest.raises(ToolError):
86+
await tools.ha_call_event(" ")
87+
88+
async def test_raises_tool_error_on_event_type_with_slash(self):
89+
"""event_type containing a slash produces a malformed URL — rejected early."""
90+
tools = _make_tools({"message": "ok"})
91+
with pytest.raises(ToolError):
92+
await tools.ha_call_event("bad/event")
93+
94+
async def test_reraises_tool_error_from_client(self):
95+
"""ToolError from fire_event propagates unchanged (not swallowed by except Exception)."""
96+
tools = _make_tools(ToolError("already structured"))
97+
with pytest.raises(ToolError, match="already structured"):
98+
await tools.ha_call_event("my_event")

0 commit comments

Comments
 (0)