diff --git a/gateway/run.py b/gateway/run.py index 8ec8eefb549..8db1a52a5b0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6900,13 +6900,6 @@ class GatewayRunner: 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(): diff --git a/plugins/platforms/homeassistant/__init__.py b/plugins/platforms/homeassistant/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/homeassistant/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/homeassistant.py b/plugins/platforms/homeassistant/adapter.py similarity index 76% rename from gateway/platforms/homeassistant.py rename to plugins/platforms/homeassistant/adapter.py index e7ea762e2e7..1baa3da75ad 100644 --- a/gateway/platforms/homeassistant.py +++ b/plugins/platforms/homeassistant/adapter.py @@ -447,3 +447,131 @@ class HomeAssistantAdapter(BasePlatformAdapter): "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, + ) diff --git a/plugins/platforms/homeassistant/plugin.yaml b/plugins/platforms/homeassistant/plugin.yaml new file mode 100644 index 00000000000..b772d860040 --- /dev/null +++ b/plugins/platforms/homeassistant/plugin.yaml @@ -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 diff --git a/tests/gateway/test_homeassistant.py b/tests/gateway/test_homeassistant.py index b4ff5d8a351..8b6b83cf9cf 100644 --- a/tests/gateway/test_homeassistant.py +++ b/tests/gateway/test_homeassistant.py @@ -14,7 +14,7 @@ from gateway.config import ( Platform, PlatformConfig, ) -from gateway.platforms.homeassistant import ( +from plugins.platforms.homeassistant.adapter import ( HomeAssistantAdapter, check_ha_requirements, ) @@ -34,7 +34,7 @@ class TestCheckRequirements: 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 @@ -504,7 +504,7 @@ class TestSendViaRestApi: 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 @@ -523,7 +523,7 @@ class TestSendViaRestApi: 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 @@ -538,7 +538,7 @@ class TestSendViaRestApi: 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 @@ -554,7 +554,7 @@ class TestSendViaRestApi: 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 diff --git a/tests/integration/test_ha_integration.py b/tests/integration/test_ha_integration.py index 7f7329bad23..4b619816972 100644 --- a/tests/integration/test_ha_integration.py +++ b/tests/integration/test_ha_integration.py @@ -16,7 +16,7 @@ pytestmark = pytest.mark.integration 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, diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index cb201f8914b..05d1023bcfa 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch from tools.send_message_tool import ( _send_dingtalk, - _send_homeassistant, _send_matrix, ) @@ -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 # --------------------------------------------------------------------------- diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 3009aac3b9b..53a9fc60037 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -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: @@ -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.