Skip to content

Commit 14b32db

Browse files
authored
Merge pull request #2563 from rsr5/dev
2 parents b5b309c + cb6b64d commit 14b32db

3 files changed

Lines changed: 363 additions & 0 deletions

File tree

appdaemon/plugins/hass/hassapi.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,61 @@ async def ping(self) -> float | None:
6868
case _:
6969
return None
7070

71+
@sync_decorator
72+
async def call_ws(self, namespace: str | None = None, **message) -> dict:
73+
"""Sends an arbitrary WebSocket message to Home Assistant and returns the full response.
74+
75+
This is a low-level escape hatch for accessing any Home Assistant WebSocket API command,
76+
including those that do not have a dedicated helper method in AppDaemon.
77+
78+
The ``type`` key is required in the message.
79+
The ``id`` key must **not** be included — it is assigned automatically by the plugin.
80+
81+
Args:
82+
namespace (str, optional): Namespace to use for the call. See the section on
83+
`namespaces <APPGUIDE.html#namespaces>`__ for a detailed description.
84+
In most cases it is safe to ignore this parameter.
85+
**message: Keyword arguments that form the JSON body of the WebSocket message.
86+
87+
Returns:
88+
dict: The full response dictionary from Home Assistant, containing keys such as
89+
``success``, ``result``, and ``error``.
90+
91+
Examples:
92+
Get a list of all registered panels:
93+
94+
>>> result = self.call_ws(type="get_panels")
95+
96+
Subscribe to an event type:
97+
98+
>>> result = self.call_ws(type="subscribe_events", event_type="state_changed")
99+
100+
Call a service via raw WebSocket:
101+
102+
>>> result = self.call_ws(
103+
... type="call_service",
104+
... domain="light",
105+
... service="turn_on",
106+
... target={"entity_id": "light.living_room"},
107+
... )
108+
"""
109+
if 'type' not in message:
110+
self.logger.warning("call_ws: 'type' key is required in the message")
111+
return {"error": "'type' key is required"}
112+
113+
if 'id' in message:
114+
self.logger.warning("call_ws: 'id' key must not be included — it is assigned automatically")
115+
return {"error": "'id' key must not be included"}
116+
117+
namespace = namespace if namespace is not None else self.namespace
118+
119+
match self.AD.plugins.get_plugin_object(namespace):
120+
case HassPlugin() as plugin:
121+
return await plugin.websocket_send_json(**message)
122+
case _:
123+
self.logger.warning("call_ws: namespace '%s' is not a Home Assistant namespace", namespace)
124+
return {"error": f"namespace '{namespace}' is not a Home Assistant namespace"}
125+
71126
@sync_decorator
72127
async def check_for_entity(self, entity_id: str, namespace: str | None = None) -> bool:
73128
"""Uses the REST API to check if an entity exists instead of checking AppDaemon's internal state.

docs/HASS_API_REFERENCE.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,53 @@ Home Assistant has a powerful `templating <https://www.home-assistant.io/docs/co
609609
can be used to render templates in your apps. The `Hass` API provides access to this with the
610610
:py:meth:`render_template <appdaemon.plugins.hass.hassapi.Hass.render_template>` method.
611611

612+
Arbitrary WebSocket Messages
613+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
614+
615+
Home Assistant exposes a large number of
616+
`WebSocket API <https://developers.home-assistant.io/docs/api/websocket>`__ message types beyond service calls. These
617+
include querying history, accessing long-term statistics, reading logbook events, and interacting with per-user
618+
server-side storage. The :py:meth:`call_ws <appdaemon.plugins.hass.hassapi.Hass.call_ws>` method provides a general
619+
purpose escape hatch to send any arbitrary WebSocket message to Home Assistant.
620+
621+
Unlike :py:meth:`call_service <appdaemon.plugins.hass.hassapi.Hass.call_service>`, which is specific to HA service
622+
actions, ``call_ws`` accepts a raw message dict with a ``type`` key and forwards it directly over the WebSocket
623+
connection. The ``id`` field is managed automatically by AppDaemon.
624+
625+
.. code-block:: python
626+
627+
from appdaemon.plugins.hass import Hass
628+
629+
630+
class MyApp(Hass):
631+
async def initialize(self):
632+
# Read per-user data from HA's server-side storage
633+
result = self.call_ws({
634+
"type": "frontend/get_user_data",
635+
"key": "my_app",
636+
})
637+
match result:
638+
case {"success": True, "result": {"value": value}}:
639+
self.log(f"Loaded stored data: {value}")
640+
case {"success": True}:
641+
self.log("No data stored yet, initializing...")
642+
self.call_ws({
643+
"type": "frontend/set_user_data",
644+
"key": "my_app",
645+
"value": {"version": 1, "entries": []},
646+
})
647+
648+
The response format is consistent with
649+
:py:meth:`call_service <appdaemon.plugins.hass.hassapi.Hass.call_service>` — the returned dict includes ``success``,
650+
``result``, ``ad_status``, and ``ad_duration`` fields. Error handling follows the same patterns described in the
651+
`Error Handling`_ section above.
652+
653+
.. note::
654+
655+
The ``call_ws`` method is a general purpose tool — it does not validate the message contents beyond requiring a
656+
``type`` key. Refer to the `Home Assistant WebSocket API documentation
657+
<https://developers.home-assistant.io/docs/api/websocket>`__ for the expected message format for each message type.
658+
612659
API Reference
613660
-------------
614661

tests/unit/test_call_ws.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""Tests for the Hass.call_ws() method."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
import pytest
6+
7+
pytestmark = [
8+
pytest.mark.ci,
9+
pytest.mark.unit,
10+
]
11+
12+
13+
@pytest.fixture
14+
def mock_plugin():
15+
"""Creates a mock HassPlugin with a mock websocket_send_json method."""
16+
plugin = MagicMock()
17+
plugin.websocket_send_json = AsyncMock()
18+
return plugin
19+
20+
21+
@pytest.fixture
22+
def hass_instance(mock_plugin):
23+
"""Creates a minimal Hass-like object with mocked internals for testing call_ws.
24+
25+
Rather than instantiating the full Hass class (which requires a full AppDaemon instance),
26+
we import the unbound async method and call it with a mock self that has the necessary
27+
attributes wired up.
28+
"""
29+
from appdaemon.plugins.hass.hassplugin import HassPlugin
30+
31+
mock_self = MagicMock()
32+
mock_self.namespace = "default"
33+
34+
# Wire up the plugin resolution: self.AD.plugins.get_plugin_object(namespace) -> mock_plugin
35+
# The mock_plugin must pass the `case HassPlugin() as plugin:` match, so we use spec
36+
real_plugin = MagicMock(spec=HassPlugin)
37+
real_plugin.websocket_send_json = mock_plugin.websocket_send_json
38+
mock_self.AD.plugins.get_plugin_object.return_value = real_plugin
39+
40+
return mock_self, real_plugin
41+
42+
43+
class TestCallWsValidation:
44+
"""Tests for input validation in call_ws."""
45+
46+
@pytest.mark.asyncio
47+
async def test_missing_type_key_returns_error(self, hass_instance):
48+
"""call_ws should return an error dict when 'type' key is missing."""
49+
from appdaemon.plugins.hass.hassapi import Hass
50+
51+
mock_self, _ = hass_instance
52+
53+
result = await Hass.call_ws.__wrapped__(mock_self, key="my_app")
54+
55+
assert "error" in result
56+
mock_self.logger.warning.assert_called_once()
57+
58+
@pytest.mark.asyncio
59+
async def test_id_key_present_returns_error(self, hass_instance):
60+
"""call_ws should return an error dict when 'id' key is present in the message."""
61+
from appdaemon.plugins.hass.hassapi import Hass
62+
63+
mock_self, _ = hass_instance
64+
65+
result = await Hass.call_ws.__wrapped__(mock_self, type="frontend/get_user_data", id=42)
66+
67+
assert "error" in result
68+
mock_self.logger.warning.assert_called_once()
69+
70+
@pytest.mark.asyncio
71+
async def test_empty_message_returns_error(self, hass_instance):
72+
"""call_ws should return an error dict for an empty call (no kwargs)."""
73+
from appdaemon.plugins.hass.hassapi import Hass
74+
75+
mock_self, _ = hass_instance
76+
77+
result = await Hass.call_ws.__wrapped__(mock_self)
78+
79+
assert "error" in result
80+
mock_self.logger.warning.assert_called_once()
81+
82+
83+
class TestCallWsSuccess:
84+
"""Tests for successful call_ws invocations."""
85+
86+
@pytest.mark.asyncio
87+
async def test_success_response(self, hass_instance):
88+
"""call_ws should return the full response dict from websocket_send_json."""
89+
from appdaemon.plugins.hass.hassapi import Hass
90+
91+
mock_self, mock_plugin = hass_instance
92+
expected_response = {
93+
"id": 5,
94+
"type": "result",
95+
"success": True,
96+
"result": {"value": {"version": 1, "entries": []}},
97+
"ad_status": "OK",
98+
"ad_duration": 0.015,
99+
}
100+
mock_plugin.websocket_send_json.return_value = expected_response
101+
102+
result = await Hass.call_ws.__wrapped__(
103+
mock_self,
104+
type="frontend/get_user_data",
105+
key="my_app",
106+
)
107+
108+
assert result == expected_response
109+
mock_plugin.websocket_send_json.assert_awaited_once_with(type="frontend/get_user_data", key="my_app")
110+
111+
@pytest.mark.asyncio
112+
async def test_message_keys_passed_through(self, hass_instance):
113+
"""All kwargs should be passed through to websocket_send_json."""
114+
from appdaemon.plugins.hass.hassapi import Hass
115+
116+
mock_self, mock_plugin = hass_instance
117+
mock_plugin.websocket_send_json.return_value = {"success": True, "result": None}
118+
119+
await Hass.call_ws.__wrapped__(
120+
mock_self,
121+
type="recorder/statistics_during_period",
122+
start_time="2026-02-01T00:00:00Z",
123+
statistic_ids=["sensor.energy"],
124+
period="hour",
125+
)
126+
127+
mock_plugin.websocket_send_json.assert_awaited_once_with(
128+
type="recorder/statistics_during_period",
129+
start_time="2026-02-01T00:00:00Z",
130+
statistic_ids=["sensor.energy"],
131+
period="hour",
132+
)
133+
134+
@pytest.mark.asyncio
135+
async def test_write_user_data(self, hass_instance):
136+
"""call_ws should handle write-style messages that return minimal results."""
137+
from appdaemon.plugins.hass.hassapi import Hass
138+
139+
mock_self, mock_plugin = hass_instance
140+
mock_plugin.websocket_send_json.return_value = {
141+
"success": True,
142+
"result": None,
143+
"ad_status": "OK",
144+
"ad_duration": 0.008,
145+
}
146+
147+
result = await Hass.call_ws.__wrapped__(
148+
mock_self,
149+
type="frontend/set_user_data",
150+
key="my_app",
151+
value={"version": 1, "entries": []},
152+
)
153+
154+
assert result["success"] is True
155+
156+
157+
class TestCallWsErrorHandling:
158+
"""Tests for error responses from call_ws."""
159+
160+
@pytest.mark.asyncio
161+
async def test_error_response_returned(self, hass_instance):
162+
"""call_ws should return the full error response without raising."""
163+
from appdaemon.plugins.hass.hassapi import Hass
164+
165+
mock_self, mock_plugin = hass_instance
166+
error_response = {
167+
"id": 10,
168+
"type": "result",
169+
"success": False,
170+
"error": {"code": "unknown_command", "message": "Unknown command."},
171+
"ad_status": "OK",
172+
"ad_duration": 0.005,
173+
}
174+
mock_plugin.websocket_send_json.return_value = error_response
175+
176+
result = await Hass.call_ws.__wrapped__(
177+
mock_self,
178+
type="bogus/not_real",
179+
)
180+
181+
assert result["success"] is False
182+
assert result["error"]["code"] == "unknown_command"
183+
184+
@pytest.mark.asyncio
185+
async def test_timeout_response(self, hass_instance):
186+
"""call_ws should return a timeout response when websocket_send_json times out."""
187+
from appdaemon.plugins.hass.hassapi import Hass
188+
189+
mock_self, mock_plugin = hass_instance
190+
timeout_response = {
191+
"success": False,
192+
"ad_status": "TIMEOUT",
193+
"ad_duration": 10.0,
194+
}
195+
mock_plugin.websocket_send_json.return_value = timeout_response
196+
197+
result = await Hass.call_ws.__wrapped__(
198+
mock_self,
199+
type="frontend/get_user_data",
200+
key="my_app",
201+
)
202+
203+
assert result["success"] is False
204+
assert result["ad_status"] == "TIMEOUT"
205+
206+
207+
class TestCallWsNamespace:
208+
"""Tests for namespace resolution in call_ws."""
209+
210+
@pytest.mark.asyncio
211+
async def test_default_namespace(self, hass_instance):
212+
"""call_ws should use the app's default namespace when none is specified."""
213+
from appdaemon.plugins.hass.hassapi import Hass
214+
215+
mock_self, mock_plugin = hass_instance
216+
mock_self.namespace = "default"
217+
mock_plugin.websocket_send_json.return_value = {"success": True, "result": {}}
218+
219+
await Hass.call_ws.__wrapped__(
220+
mock_self,
221+
type="frontend/get_user_data",
222+
key="test",
223+
)
224+
225+
mock_self.AD.plugins.get_plugin_object.assert_called_once_with("default")
226+
227+
@pytest.mark.asyncio
228+
async def test_explicit_namespace(self, hass_instance):
229+
"""call_ws should use the specified namespace when one is provided."""
230+
from appdaemon.plugins.hass.hassapi import Hass
231+
232+
mock_self, mock_plugin = hass_instance
233+
mock_plugin.websocket_send_json.return_value = {"success": True, "result": {}}
234+
235+
await Hass.call_ws.__wrapped__(
236+
mock_self,
237+
type="frontend/get_user_data",
238+
key="test",
239+
namespace="hass2",
240+
)
241+
242+
mock_self.AD.plugins.get_plugin_object.assert_called_once_with("hass2")
243+
244+
@pytest.mark.asyncio
245+
async def test_non_hass_namespace_returns_error(self):
246+
"""call_ws should return an error dict and log a warning for a non-HASS namespace."""
247+
from appdaemon.plugins.hass.hassapi import Hass
248+
249+
mock_self = MagicMock()
250+
mock_self.namespace = "default"
251+
# Return something that doesn't match HassPlugin()
252+
mock_self.AD.plugins.get_plugin_object.return_value = MagicMock(spec=[])
253+
254+
result = await Hass.call_ws.__wrapped__(
255+
mock_self,
256+
type="frontend/get_user_data",
257+
key="test",
258+
)
259+
260+
assert "error" in result
261+
mock_self.logger.warning.assert_called_once()

0 commit comments

Comments
 (0)