mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
ebed881d46
commit
c37c6eaf29
8 changed files with 176 additions and 40 deletions
|
|
@ -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():
|
||||
|
|
|
|||
3
plugins/platforms/homeassistant/__init__.py
Normal file
3
plugins/platforms/homeassistant/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
|
|
@ -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,
|
||||
)
|
||||
22
plugins/platforms/homeassistant/plugin.yaml
Normal file
22
plugins/platforms/homeassistant/plugin.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue