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
@ -6900,13 +6900,6 @@ class GatewayRunner:
|
|||||||
return None
|
return None
|
||||||
return SignalAdapter(config)
|
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:
|
elif platform == Platform.EMAIL:
|
||||||
from gateway.platforms.email import EmailAdapter, check_email_requirements
|
from gateway.platforms.email import EmailAdapter, check_email_requirements
|
||||||
if not 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",
|
"type": "channel",
|
||||||
"url": self._hass_url,
|
"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,
|
Platform,
|
||||||
PlatformConfig,
|
PlatformConfig,
|
||||||
)
|
)
|
||||||
from gateway.platforms.homeassistant import (
|
from plugins.platforms.homeassistant.adapter import (
|
||||||
HomeAssistantAdapter,
|
HomeAssistantAdapter,
|
||||||
check_ha_requirements,
|
check_ha_requirements,
|
||||||
)
|
)
|
||||||
@ -34,7 +34,7 @@ class TestCheckRequirements:
|
|||||||
monkeypatch.setenv("HASS_TOKEN", "test-token")
|
monkeypatch.setenv("HASS_TOKEN", "test-token")
|
||||||
assert check_ha_requirements() is True
|
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):
|
def test_returns_false_without_aiohttp(self, monkeypatch):
|
||||||
monkeypatch.setenv("HASS_TOKEN", "test-token")
|
monkeypatch.setenv("HASS_TOKEN", "test-token")
|
||||||
assert check_ha_requirements() is False
|
assert check_ha_requirements() is False
|
||||||
@ -504,7 +504,7 @@ class TestSendViaRestApi:
|
|||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
mock_session = self._mock_aiohttp_session(200)
|
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.ClientSession = MagicMock(return_value=mock_session)
|
||||||
mock_aiohttp.ClientTimeout = lambda total: total
|
mock_aiohttp.ClientTimeout = lambda total: total
|
||||||
|
|
||||||
@ -523,7 +523,7 @@ class TestSendViaRestApi:
|
|||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
mock_session = self._mock_aiohttp_session(401, "Unauthorized")
|
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.ClientSession = MagicMock(return_value=mock_session)
|
||||||
mock_aiohttp.ClientTimeout = lambda total: total
|
mock_aiohttp.ClientTimeout = lambda total: total
|
||||||
|
|
||||||
@ -538,7 +538,7 @@ class TestSendViaRestApi:
|
|||||||
mock_session = self._mock_aiohttp_session(200)
|
mock_session = self._mock_aiohttp_session(200)
|
||||||
long_message = "x" * 10000
|
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.ClientSession = MagicMock(return_value=mock_session)
|
||||||
mock_aiohttp.ClientTimeout = lambda total: total
|
mock_aiohttp.ClientTimeout = lambda total: total
|
||||||
|
|
||||||
@ -554,7 +554,7 @@ class TestSendViaRestApi:
|
|||||||
adapter._ws = AsyncMock() # Simulate an active WS
|
adapter._ws = AsyncMock() # Simulate an active WS
|
||||||
mock_session = self._mock_aiohttp_session(200)
|
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.ClientSession = MagicMock(return_value=mock_session)
|
||||||
mock_aiohttp.ClientTimeout = lambda total: total
|
mock_aiohttp.ClientTimeout = lambda total: total
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ pytestmark = pytest.mark.integration
|
|||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from gateway.config import Platform, PlatformConfig
|
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 tests.fakes.fake_ha_server import FakeHAServer, ENTITY_STATES
|
||||||
from tools.homeassistant_tool import (
|
from tools.homeassistant_tool import (
|
||||||
_async_call_service,
|
_async_call_service,
|
||||||
|
|||||||
@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
from tools.send_message_tool import (
|
from tools.send_message_tool import (
|
||||||
_send_dingtalk,
|
_send_dingtalk,
|
||||||
_send_homeassistant,
|
|
||||||
_send_matrix,
|
_send_matrix,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -28,6 +27,22 @@ async def _send_mattermost(token, extra, chat_id, message):
|
|||||||
return await _mattermost_standalone_send(pconfig, 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
|
# 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)
|
result = await _send_sms(pconfig.api_key, chat_id, chunk)
|
||||||
elif platform == Platform.MATRIX:
|
elif platform == Platform.MATRIX:
|
||||||
result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk)
|
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:
|
elif platform == Platform.DINGTALK:
|
||||||
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
|
result = await _send_dingtalk(pconfig.extra, chat_id, chunk)
|
||||||
elif platform == Platform.FEISHU:
|
elif platform == Platform.FEISHU:
|
||||||
@ -1486,29 +1484,6 @@ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None,
|
|||||||
pass
|
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):
|
async def _send_dingtalk(extra, chat_id, message):
|
||||||
"""Send via DingTalk robot webhook.
|
"""Send via DingTalk robot webhook.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user