Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6900,13 +6900,6 @@ def _create_adapter(
return None
return SignalAdapter(config)

elif platform == Platform.HOMEASSISTANT:
from gateway.platforms.homeassistant import HomeAssistantAdapter, check_ha_requirements
if not check_ha_requirements():
logger.warning("HomeAssistant: aiohttp not installed or HASS_TOKEN not set")
return None
return HomeAssistantAdapter(config)

elif platform == Platform.EMAIL:
from gateway.platforms.email import EmailAdapter, check_email_requirements
if not check_email_requirements():
Expand Down
3 changes: 3 additions & 0 deletions plugins/platforms/homeassistant/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .adapter import register

__all__ = ["register"]
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,131 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"type": "channel",
"url": self._hass_url,
}


# ---------------------------------------------------------------------------
# Standalone (out-of-process) sender — used by cron deliver=homeassistant
# ---------------------------------------------------------------------------


async def _standalone_send(
pconfig,
chat_id: str,
message: str,
*,
thread_id: Optional[str] = None,
media_files: Optional[list] = None,
force_document: bool = False,
) -> Dict[str, Any]:
"""Send a notification via the HA ``notify.notify`` service without a
live gateway adapter.

Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
runner is not in this process (typical for cron jobs running
out-of-process). The HTTP path is the same one the legacy
``_send_homeassistant`` helper used in ``tools/send_message_tool.py``
before this migration.

Reads ``HASS_TOKEN`` from ``pconfig.token`` (set by the gateway config
loader from env) and falls back to the ``HASS_TOKEN`` env var. Server
URL comes from ``pconfig.extra["url"]`` (seeded by the env loader in
``gateway/config.py``) or the ``HASS_URL`` env var.

``thread_id``, ``media_files`` and ``force_document`` are accepted for
signature parity with other standalone senders. HA notifications have
no native threading or attachment model — these arguments are ignored.
"""
if not AIOHTTP_AVAILABLE:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}

extra = getattr(pconfig, "extra", {}) or {}
hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/")
token = (getattr(pconfig, "token", None) or os.getenv("HASS_TOKEN", "")).strip()
if not hass_url or not token:
return {
"error": (
"Home Assistant standalone send: HASS_URL and HASS_TOKEN "
"must both be set"
)
}

url = f"{hass_url}/api/services/notify/notify"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {"message": message, "target": chat_id}

try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
) as session:
async with session.post(url, headers=headers, json=payload) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
return {
"error": (
f"Home Assistant API error ({resp.status}): {body}"
)
}
return {
"success": True,
"platform": "homeassistant",
"chat_id": chat_id,
}
except asyncio.TimeoutError:
return {"error": "Timeout sending notification to Home Assistant"}
except Exception as e:
return {"error": f"Home Assistant send failed: {e}"}


# ---------------------------------------------------------------------------
# is_connected probe
# ---------------------------------------------------------------------------


def _is_connected(config) -> bool:
"""Home Assistant is considered connected when ``HASS_TOKEN`` is set.

Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via
the plugin's own bound import) so tests that patch
``gateway_mod.get_env_value`` can suppress ambient ``HASS_TOKEN`` env
vars. Matches what the legacy connected-platforms check did before
this migration.
"""
import hermes_cli.gateway as gateway_mod
return bool((gateway_mod.get_env_value("HASS_TOKEN") or "").strip())


# ---------------------------------------------------------------------------
# Plugin registration entry point
# ---------------------------------------------------------------------------


def _build_adapter(config):
"""Factory wrapper that constructs HomeAssistantAdapter from a PlatformConfig."""
return HomeAssistantAdapter(config)


def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="homeassistant",
label="Home Assistant",
adapter_factory=_build_adapter,
check_fn=check_ha_requirements,
is_connected=_is_connected,
required_env=["HASS_TOKEN"],
install_hint="pip install aiohttp",
# Out-of-process cron delivery via the HA ``notify.notify`` service.
# Without this hook, ``deliver=homeassistant`` cron jobs would fail
# with "No live adapter" when cron runs separately from the gateway.
# Mirrors the Discord / Teams / Mattermost pattern.
standalone_sender_fn=_standalone_send,
# HA notification message cap — matches MAX_MESSAGE_LENGTH on the
# adapter class above.
max_message_length=HomeAssistantAdapter.MAX_MESSAGE_LENGTH,
# Display
emoji="🏠",
allow_update_command=True,
)
22 changes: 22 additions & 0 deletions plugins/platforms/homeassistant/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: homeassistant-platform
label: Home Assistant
kind: platform
version: 1.0.0
description: >
Home Assistant gateway adapter for Hermes Agent.
Subscribes to HA's WebSocket event bus and forwards state-change events
(with per-entity cooldowns and domain/entity filtering) to the agent.
Outbound messages are delivered as HA persistent notifications via the
REST API. Out-of-process cron delivery via the ``notify.notify``
service is also supported.
author: NousResearch
requires_env:
- name: HASS_TOKEN
description: "Home Assistant Long-Lived Access Token"
prompt: "Home Assistant Long-Lived Access Token"
password: true
optional_env:
- name: HASS_URL
description: "Home Assistant base URL (default: http://homeassistant.local:8123)"
prompt: "Home Assistant URL"
password: false
12 changes: 6 additions & 6 deletions tests/gateway/test_homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
Platform,
PlatformConfig,
)
from gateway.platforms.homeassistant import (
from plugins.platforms.homeassistant.adapter import (
HomeAssistantAdapter,
check_ha_requirements,
)
Expand All @@ -34,7 +34,7 @@ def test_returns_true_with_token(self, monkeypatch):
monkeypatch.setenv("HASS_TOKEN", "test-token")
assert check_ha_requirements() is True

@patch("gateway.platforms.homeassistant.AIOHTTP_AVAILABLE", False)
@patch("plugins.platforms.homeassistant.adapter.AIOHTTP_AVAILABLE", False)
def test_returns_false_without_aiohttp(self, monkeypatch):
monkeypatch.setenv("HASS_TOKEN", "test-token")
assert check_ha_requirements() is False
Expand Down Expand Up @@ -504,7 +504,7 @@ async def test_send_success(self):
adapter = _make_adapter()
mock_session = self._mock_aiohttp_session(200)

with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp:
with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp:
mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
mock_aiohttp.ClientTimeout = lambda total: total

Expand All @@ -523,7 +523,7 @@ async def test_send_http_error(self):
adapter = _make_adapter()
mock_session = self._mock_aiohttp_session(401, "Unauthorized")

with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp:
with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp:
mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
mock_aiohttp.ClientTimeout = lambda total: total

Expand All @@ -538,7 +538,7 @@ async def test_send_truncates_long_message(self):
mock_session = self._mock_aiohttp_session(200)
long_message = "x" * 10000

with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp:
with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp:
mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
mock_aiohttp.ClientTimeout = lambda total: total

Expand All @@ -554,7 +554,7 @@ async def test_send_does_not_use_websocket(self):
adapter._ws = AsyncMock() # Simulate an active WS
mock_session = self._mock_aiohttp_session(200)

with patch("gateway.platforms.homeassistant.aiohttp") as mock_aiohttp:
with patch("plugins.platforms.homeassistant.adapter.aiohttp") as mock_aiohttp:
mock_aiohttp.ClientSession = MagicMock(return_value=mock_session)
mock_aiohttp.ClientTimeout = lambda total: total

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_ha_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from unittest.mock import AsyncMock

from gateway.config import Platform, PlatformConfig
from gateway.platforms.homeassistant import HomeAssistantAdapter
from plugins.platforms.homeassistant.adapter import HomeAssistantAdapter
from tests.fakes.fake_ha_server import FakeHAServer, ENTITY_STATES
from tools.homeassistant_tool import (
_async_call_service,
Expand Down
17 changes: 16 additions & 1 deletion tests/tools/test_send_message_missing_platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from tools.send_message_tool import (
_send_dingtalk,
_send_homeassistant,
_send_matrix,
)

Expand All @@ -28,6 +27,22 @@ async def _send_mattermost(token, extra, chat_id, message):
return await _mattermost_standalone_send(pconfig, chat_id, message)


# ``_send_homeassistant`` moved into the homeassistant plugin
# (``plugins/platforms/homeassistant/adapter.py::_standalone_send``). Same
# shim pattern as ``_send_mattermost`` above.
from plugins.platforms.homeassistant.adapter import (
_standalone_send as _homeassistant_standalone_send,
)


async def _send_homeassistant(token, extra, chat_id, message):
"""Pre-migration ``(token, extra, chat_id, message)`` shim around the
plugin's ``_standalone_send(pconfig, chat_id, message)``.
"""
pconfig = SimpleNamespace(token=token, extra=extra or {})
return await _homeassistant_standalone_send(pconfig, chat_id, message)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
Expand Down
25 changes: 0 additions & 25 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -788,8 +788,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
result = await _send_sms(pconfig.api_key, chat_id, chunk)
elif platform == Platform.MATRIX:
result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk)
elif platform == Platform.HOMEASSISTANT:
result = await _send_homeassistant(pconfig.token, pconfig.extra, chat_id, chunk)
elif platform == Platform.DINGTALK:
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
elif platform == Platform.FEISHU:
Expand Down Expand Up @@ -1486,29 +1484,6 @@ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None,
pass


async def _send_homeassistant(token, extra, chat_id, message):
"""Send via Home Assistant notify service."""
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/")
token = token or os.getenv("HASS_TOKEN", "")
if not hass_url or not token:
return {"error": "Home Assistant not configured (HASS_URL, HASS_TOKEN required)"}
url = f"{hass_url}/api/services/notify/notify"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
return _error(f"Home Assistant API error ({resp.status}): {body}")
return {"success": True, "platform": "homeassistant", "chat_id": chat_id}
except Exception as e:
return _error(f"Home Assistant send failed: {e}")


async def _send_dingtalk(extra, chat_id, message):
"""Send via DingTalk robot webhook.

Expand Down
Loading