refactor(gateway): migrate Home Assistant adapter to bundled plugin

Move gateway/platforms/homeassistant.py into plugins/platforms/homeassistant/
following the same shape as the Mattermost and Discord migrations.

  - Adapter file is renamed via git mv (history is preserved).
  - register() exposes the platform via the plugin system instead of the
    hardcoded Platform.HOMEASSISTANT elif in gateway/run.py::build_adapter().
  - _standalone_send() replaces the legacy _send_homeassistant() helper in
    tools/send_message_tool.py.  Out-of-process cron delivery
    (deliver=homeassistant from a cron process not co-located with the
    gateway) now flows through the registry's standalone_sender_fn path
    instead of the hardcoded elif.
  - _is_connected() probes HASS_TOKEN via hermes_cli.gateway.get_env_value
    so existing connected-platform checks behave identically.

The HASS_TOKEN / HASS_URL env-to-PlatformConfig seeding in
gateway/config.py stays in core — same pattern bluebubbles, mattermost,
and discord migrations followed.  No setup_fn or apply_yaml_config_fn is
registered because Home Assistant has no _setup_homeassistant wizard in
hermes_cli/setup.py and no homeassistant: YAML block in config.yaml today;
setup runs through the existing hermes_cli/tools_config.py toolset wizard.

Test imports were rewritten across tests/gateway/test_homeassistant.py,
tests/integration/test_ha_integration.py, and
tests/tools/test_send_message_missing_platforms.py; the legacy
(token, extra, chat_id, message)-shaped _send_homeassistant call site is
preserved via a small SimpleNamespace shim in
test_send_message_missing_platforms.py (same approach used when
mattermost moved).

  - Focused HA suites (64 tests across the three rewritten files) pass.
  - Broader gateway/cron sweep produces 10 failures identical to main
    baseline (telegram approval/model-picker xdist isolation flakes,
    wecom_callback defusedxml issue, cron script_timeout fixture issue).
    Zero net new failures.
This commit is contained in:
kshitijk4poor 2026-05-26 14:06:33 +05:30 committed by Teknium
parent ebed881d46
commit c37c6eaf29
8 changed files with 176 additions and 40 deletions

View file

@ -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():

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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.