diff --git a/.gitignore b/.gitignore index 77d323b7..6ad19a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ venv/ .replit .breakpoints +# Informal testing +main.py + # Cache files *.sqlite diff --git a/README.md b/README.md index e2e1dd68..cd5bcafa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/GrandMoff100/HomeassistantAPI?style=for-the-badge)](https://github.com/GrandMoff100/HomeassistantAPI/releases) - + ## Python wrapper for Homeassistant's [Websocket API](https://developers.home-assistant.io/docs/api/websocket/) and [REST API](https://developers.home-assistant.io/docs/api/rest/) diff --git a/docs/advanced.rst b/docs/advanced.rst index dc5fd3c3..978045ed 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -12,7 +12,7 @@ Rather than the default behavior, which is saving the cache to memory or not at If you want to persist your requests cache you can pass your own custom cached session to :py:class:`Client`'s init method. You can pass a variety of options to your cached session like how fast to expire the cache, where to cache it (the cache backend), and what to do when the cache is expired. -Depending on whether you are using this in an async of sync project you will want to use either :py:class:`aiohttp_client_cache.backends.CachedSession` or :py:class:`requests_cache.CachedSession` respectively. +Depending on whether you are using this in an async or sync project you will want to use either :py:class:`aiohttp_client_cache.backends.CachedSession` or :py:class:`requests_cache.CachedSession` respectively. See the docs for `requests_cache `__ and `aiohttp_client_cache `__ for how to implement these backends, options, and much more. You can simply pass them to your client like so. diff --git a/docs/api.rst b/docs/api.rst index 47ab8172..0007e0ed 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,4 +4,4 @@ Code Reference .. automodule:: homeassistant_api :platform: Linux, Windows, MacOS :inherited-members: - :exclude-members: model_json_schema, model_copy, model_rebuild, model_dump, construct, copy, dict, from_orm, json, parse_file, model_validate, parse_raw, parse_str, parse_url, schema, schema_json, schema_yaml, schema_yml, to_orm, update_forward_refs, validate, validate_file, validate_obj, validate_raw, validate_str, validate_url, model_validate_strings, model_validate_json, model_validate, model_post_init, model_parametrized_name, model_extra, model_fields_set, model_dump_json, model_construct, model_computed_fields \ No newline at end of file + :exclude-members: model_json_schema, model_copy, model_rebuild, model_dump, construct, copy, dict, from_orm, json, parse_file, model_validate, parse_raw, parse_obj, parse_str, parse_url, schema, schema_json, schema_yaml, schema_yml, to_orm, update_forward_refs, validate, validate_file, validate_obj, validate_raw, validate_str, validate_url, model_validate_strings, model_validate_json, model_validate, model_post_init, model_parametrized_name, model_extra, model_fields_set, model_dump_json, model_construct, model_computed_fields \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 8f8e4f98..45daf466 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,8 +21,8 @@ # -- Project information ----------------------------------------------------- project = "Homeassistant API" -copyright = "2024, Nathan Larsen" # pylint: disable=redefined-builtin -author = "Nate Larsen" +copyright = "2023-2025, Nathan Larsen" # pylint: disable=redefined-builtin +author = "Nathan Larsen" # The full version, including alpha/beta/rc tags with open("../pyproject.toml") as f: diff --git a/docs/index.rst b/docs/index.rst index fc44539a..742970e8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,8 +9,8 @@ Welcome to Homeassistant API! ============================= -Homeassistant API is a pythonic module that interacts with `Homeassistant's REST API integration `_. -You can use it to remotely control your Home Assistant like getting entity states, triggering services, etc. +Homeassistant API is a pythonic module that interacts with `Homeassistant's REST API integration `_ and Homeassistant's `Websocket API `_. +You can use it to remotely control your Home Assistant to do things like turn on lights, change the temperature, or listen for when the garage door opens. Index ---------- @@ -30,6 +30,7 @@ Features ---------- - Full consumption of the Home Assistant REST API endpoints. +- Full consumption of the Home Assistant Websocket API (all of the documented commands and some undocumented ones) - Convenient Pydantic Models for data validation. - Syncrononous and Asynchronous support for integrating with all applications and/or libraries. - Modular design for intuitive readability. diff --git a/docs/usage.rst b/docs/usage.rst index f786600a..d879f36d 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -6,20 +6,19 @@ Usage The Basics... ################# -This library is centered around the :py:class:`Client` class. +This library is centered around the :py:class:`Client` and :py:class:`WebsocketClient` classes. Once you have have your api base url and Long Lived Access Token from Home Assistant we can start to do stuff. -The rest of this guide assumes you have the :py:class:`Client` saved to a :code:`client` variable. +The rest of this guide assumes you have the :py:class:`Client` saved to a :code:`client` variable or a :py:class:`WebsocketClient` saved to a :code:`ws_client` variable. Most of these examples require some integrations to be setup inside Home Assistant for the examples to actually work. The most commonly used features of this library include triggering services and getting and modifying entity states. - .. code-block:: python :linenos: import os from homeassistant_api import Client - URL = '' + URL = '' # Example: 'https://foobarhomeassistant.duckdns.org:8123/api' TOKEN = '' # Assigns the Client object to a variable and checks if it's running. @@ -31,6 +30,18 @@ The most commonly used features of this library include triggering services and # Triggers the light.turn_on service on the entity `light.my_living_room_light` +.. code-block:: python + :linenos: + + from homeassistant_api import WebsocketClient + + WS_URL = '' # Example: 'https://foobarhomeassistant.duckdns.org:8123/api/websocket' + TOKEN = '' + + with WebsocketClient(WS_URL, TOKEN) as ws_client: # opens a websocket connection to Home Assistant + print(ws_client.render_template("{{ states('sensor.my_sensor') }}")) + + .. code-block:: python :linenos: @@ -66,6 +77,15 @@ Services changed_states = light.toggle(entity_id="light.light_bulb_1") +.. code-block:: python + + climate = ws_client.get_domain("climate") + + print(climate.services) + # {'set_temperature': Service(service_id='set_temperature', name='Set temperature', description='Set the target temperature for a climate entity.\n', ... + + changed_states = climate.set_temperature(entity_id="climate.my_thermostat", temperature=72) + Entities ************* @@ -95,12 +115,15 @@ Entities door.set_state(State(state="My new state", attributes={"open_height": "5ft"})) # + ## All of these methods can be used with the WebsocketClient as well [except for set_state because the WS API doesn't support it :((( ]. Using Client with :code:`async`/:code:`await` ************************************************* Are you wondering if you can use :code:`homeassistant_api` using Python's :code:`async`/:code:`await` syntax? Good news! You can! +(You can't use the WebsocketClient with :code:`async`/:code:`await` yet because we haven't implemented it yet.) + Async Services ******************** .. code-block:: python @@ -159,8 +182,67 @@ Async Entities # +Using Events (Listening and Firing) +***************************************** + +.. code-block:: python + + from homeassistant_api import WebsocketClient + + WS_URL = '' # Example: 'https://foobarhomeassistant.duckdns.org:8123/api/websocket' + TOKEN = '' + with WebsocketClient(WS_URL, TOKEN) as ws_client: + with ws_client.listen_events() as events: + for event in events: + print(event) + + # Or if you want to listen for a specific event type until dinner time. + with ws_client.listen_events('state_changed') as events: + for event in events: + print(event) + if event.data.entity_id == 'myalarmclock.dinner_time' and event.data.new_state.state == 'now': + break + + # Or if you want to listen for just 10 events. + with ws_client.listen_events("my_event") as events: + for _, event in zip(range(10), events): + print(event) + + # Alternatively for just one event. + with ws_client.listen_events("my_event") as events: + event = next(events) + print(event) + + # Now to fire an event. + ws_client.fire_event("my_event", my_arg="my_value") + + +Listening for Triggers +************************** +.. code-block:: python + + from homeassistant_api import WebsocketClient + + with WebsocketClient(WS_URL, TOKEN) as ws_client: + with ws_client.listen_triggers() as triggers: # see WebsocketClient.listen_triggers for more info. + for trigger in triggers: + print(trigger) + + # Another more specific example, listening for event triggers. + with ws_client.listen_triggers("event", event_type="my_event") as triggers: + ws_client.fire_event("my_event", my_arg="my_value") + + for trigger in triggers: + print(trigger.variables.my_arg) # This is the value of my_arg from the event fired above. + + # Another one, listening for time triggers. + future = ws_client.get_rendered_template( + "{{ (now() + timedelta(seconds=1)).strftime('%H:%M:%S') }}" + ) + with ws_client.listen_trigger("time", at=future) as triggers: # `at` can be HH:MM or HH:MM:SS + print(next(triggers)) What's Next? ############# diff --git a/homeassistant_api/__init__.py b/homeassistant_api/__init__.py index da79af0a..cc637912 100644 --- a/homeassistant_api/__init__.py +++ b/homeassistant_api/__init__.py @@ -3,40 +3,40 @@ __all__ = ( "Client", "State", + "Context", + "Domain", "Service", - "History", "Group", - "Event", "Entity", - "Domain", - "Processing", + "History", + "Event", "LogbookEntry", - "APIConfigurationError", - "EndpointNotFoundError", - "HomeassistantAPIError", - "MalformedDataError", - "MalformedInputError", - "MethodNotAllowedError", - "ParameterMissingError", - "RequestError", - "UnauthorizedError", "WebsocketClient", + "AuthInvalid", + "AuthOk", + "AuthRequired", + "ResultResponse", + "ErrorResponse", + "PingResponse", + "EventResponse", ) from .client import Client -from .errors import ( - APIConfigurationError, - EndpointNotFoundError, - HomeassistantAPIError, - MalformedDataError, - MalformedInputError, - MethodNotAllowedError, - ParameterMissingError, - RequestError, - UnauthorizedError, +from .models.domains import Domain, Service +from .models.entity import Entity, Group +from .models.events import Event +from .models.history import History +from .models.logbook import LogbookEntry +from .models.states import Context, State +from .models.websocket import ( + AuthInvalid, + AuthOk, + AuthRequired, + ErrorResponse, + EventResponse, + PingResponse, + ResultResponse, ) -from .models import Domain, Entity, Event, Group, History, LogbookEntry, Service, State -from .processing import Processing from .websocket import WebsocketClient Domain.model_rebuild() diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 053bb65d..4b366e21 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -40,7 +40,7 @@ class RawClient(RawBaseClient): """ - The base object for interacting with Homeassistant. + The base object for interacting with Homeassistant via the REST API. :param api_url: The location of the api endpoint. e.g. :code:`http://localhost:8123/api` Required. :param token: The refresh or long lived access token to authenticate your requests. Required. diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py index 4f6e26ff..6ec0fdbf 100644 --- a/homeassistant_api/rawwebsocket.py +++ b/homeassistant_api/rawwebsocket.py @@ -22,7 +22,6 @@ ResultResponse, ) - logger = logging.getLogger(__name__) diff --git a/homeassistant_api/websocket.py b/homeassistant_api/websocket.py index 8068ade2..384155c5 100644 --- a/homeassistant_api/websocket.py +++ b/homeassistant_api/websocket.py @@ -20,6 +20,23 @@ class WebsocketClient(RawWebsocketClient): + """ + + The main class for interactign with the Home Assistant WebSocket API client. + + Here's a quick example of how to use the :py:class:`WebsocketClient` class: + + .. code-block:: python + + from homeassistant_api import WebsocketClient + + with WebsocketClient( + '', # i.e. 'ws://homeassistant.local:8123/api/websocket' + '' + ) as ws_client: + light = ws_client.trigger_service('light', 'turn_on', entity_id="light.living_room") + """ + def __init__( self, api_url: str, @@ -36,7 +53,8 @@ def get_rendered_template(self, template: str) -> str: """ Renders a Jinja2 template with Home Assistant context data. See https://www.home-assistant.io/docs/configuration/templating. - :code:`"type": "render_template"` + + Sends command :code:`{"type": "render_template", ...}`. """ id = self.send("render_template", template=template, report_errors=True) first = self.recv(id) @@ -46,7 +64,11 @@ def get_rendered_template(self, template: str) -> str: return cast(TemplateEvent, cast(EventResponse, second).event).result def get_config(self) -> dict[str, Any]: - """Get the Home Assistant configuration.""" + """ + Get the Home Assistant configuration. + + Sends command :code:`{"type": "get_config", ...}`. + """ return cast( dict[str, Any], cast( @@ -56,7 +78,11 @@ def get_config(self) -> dict[str, Any]: ) def get_states(self) -> Tuple[State, ...]: - """Get a list of states.""" + """ + Get a list of states. + + Sends command :code:`{"type": "get_states", ...}`. + """ return tuple( State.from_json(state) for state in cast( @@ -73,9 +99,10 @@ def get_state( # pylint: disable=duplicate-code slug: Optional[str] = None, ) -> State: """ - Just calls the `get_states` method and filters the result. + Just calls the :py:meth:`get_states` method and filters the result. - Please tell home-assistant/core to add a `get_state` command to the WS API! + Please tell home-assistant/core to add a :code:`{"type": "get_state", ...}` command to the WS API! + There is a lot of disappointment and frustration in the community because this is not available. """ entity_id = prepare_entity_id( group_id=group_id, @@ -91,6 +118,7 @@ def get_state( # pylint: disable=duplicate-code def get_entities(self) -> Dict[str, Group]: """ Fetches all entities from the Websocket API and returns them as a dictionary of :py:class:`Group`'s. + For example :code:`light.living_room` would be in the group :code:`light` (i.e. :code:`get_entities()["light"].living_room`). """ entities: Dict[str, Group] = {} for state in self.get_states(): @@ -112,9 +140,10 @@ def get_entity( """ Returns an :py:class:`Entity` model for an :code:`entity_id`. - Calls :py:meth:`get_state` in the process. + Calls :py:meth:`get_states` under the hood. - Please tell home-assistant/core to add a `get_state` command to the WS API! + Please tell home-assistant/core to add a :code:`{"type": "get_state", ...}` command to the WS API! + There is a lot of disappointment and frustration in the community because this is not available. """ if group_id is not None and slug is not None: state = self.get_state(group_id=group_id, slug=slug) @@ -137,7 +166,13 @@ def get_entity( return group.get_entity(split_slug) def get_domains(self) -> dict[str, Domain]: - """Get a list of (service) domains.""" + """ + Get a list of services that Home Assistant offers (organized into a dictionary of service domains). + + For example, the service :code:`light.turn_on` would be in the domain :code:`light`. + + Sends command :code:`{"type": "get_services", ...}`. + """ resp = self.recv(self.send("get_services")) domains = map( lambda item: Domain.from_json( @@ -155,7 +190,7 @@ def get_domain(self, domain: str) -> Domain: Please tell home-assistant/core to add a `get_domain` command to the WS API! - For now, just call the `get_services` method and parsing the result. + For now, just call the :py:meth":`get_domains` method and parsing the result. """ return self.get_domains()[domain] @@ -166,7 +201,11 @@ def trigger_service( entity_id: Optional[str] = None, **service_data, ) -> None: - """Trigger a service.""" + """ + Trigger a service (that doesn't return a response). + + Sends command :code:`{"type": "call_service", ...}`. + """ params = { "domain": domain, "service": service, @@ -195,6 +234,11 @@ def trigger_service_with_response( entity_id: Optional[str] = None, **service_data, ) -> dict[str, Any]: + """ + Trigger a service (that returns a response) and return the response. + + Sends command :code:`{"type": "call_service", ...}`. + """ params = { "domain": domain, "service": service, @@ -209,35 +253,64 @@ def trigger_service_with_response( return cast(dict[str, Any], cast(ResultResponse, data).result)["response"] @contextlib.contextmanager - def subscribe_events( + def listen_events( self, event_type: Optional[str] = None, ) -> Generator[Generator[FiredEvent, None, None], None, None]: """ - Subscribe to all events of a certain type and calls `unsubscribe_events` when done. + Listen for all events of a certain type. + + For example, to listen for all events of type `test_event`: + + .. code-block:: python + + with ws_client.listen_events("test_event") as events: + for i, event in zip(range(2), events): # to only wait for two events to be received + print(event) """ subscription = self._subscribe_events(event_type) yield cast(Generator[FiredEvent, None, None], self._wait_for(subscription)) self._unsubscribe(subscription) def _subscribe_events(self, event_type: Optional[str]) -> int: - """Subscribe to all events of a certain type.""" + """ + Subscribe to all events of a certain type. + + + Sends command :code:`{"type": "subscribe_events", ...}`. + """ params = {"event_type": event_type} if event_type else {} return self.recv(self.send("subscribe_events", include_id=True, **params)).id @contextlib.contextmanager - def subscribe_trigger( + def listen_trigger( self, trigger: str, **trigger_fields ) -> Generator[Generator[dict[str, Any], None, None], None, None]: """ - Subscribe to a Home Assistant trigger. - Allows additional trigger keyword parameters with **kwargs (i.e. passing `tag_id=...` for NFC tag triggers). - - Ex. - ``` - - trigger: state - entity_id: light.kitchen - ``` -> `subscribe_trigger("state", entity_id="light.kitchen")` + Listen to a Home Assistant trigger. + Allows additional trigger keyword parameters with :code:`**kwargs` (i.e. passing :code:`tag_id=...` for NFC tag triggers). + + For example, in Home Assistant Automations we can subscribe to a state trigger for a light entity with YAML: + + .. code-block:: yaml + + triggers: + # ... + - trigger: state + entity_id: light.kitchen + + To subscribe to that same state trigger with :py:class:`WebsocketClient` instead + + .. code-block:: python + + with ws_client.listen_trigger("state", entity_id="light.kitchen") as trigger: + for event in trigger: # will iterate until we manually break out of the loop + print(event) + if : + break + # exiting the context manager unsubscribes from the trigger + + Woohoo! We can now listen to triggers in Python code! """ subscription = self._subscribe_trigger(trigger, **trigger_fields) yield ( @@ -250,7 +323,11 @@ def subscribe_trigger( self._unsubscribe(subscription) def _subscribe_trigger(self, trigger: str, **trigger_fields) -> int: - """Return the subscription id of the trigger we subscribe to.""" + """ + Return the subscription id of the trigger we subscribe to. + + Sends command :code:`{"type": "subscribe_trigger", ...}`. + """ return self.recv( self.send( "subscribe_trigger", trigger={"platform": trigger, **trigger_fields} @@ -272,13 +349,21 @@ def _wait_for( ) def _unsubscribe(self, subcription_id: int) -> None: - """Unsubscribe from all events of a certain type.""" + """ + Unsubscribe from all events of a certain type. + + Sends command :code:`{"type": "unsubscribe_events", ...}`. + """ resp = self.recv(self.send("unsubscribe_events", subscription=subcription_id)) assert cast(ResultResponse, resp).result is None self._event_responses.pop(subcription_id) def fire_event(self, event_type: str, **event_data) -> Context: - """Fire an event.""" + """ + Fire an event. + + Sends command :code:`{"type": "fire_event", ...}`. + """ params: dict[str, Any] = {"event_type": event_type} if event_data: params["event_data"] = event_data diff --git a/scripts/run_docs_dev.sh b/scripts/run_docs_dev.sh index d4f1ad00..793eac6d 100755 --- a/scripts/run_docs_dev.sh +++ b/scripts/run_docs_dev.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash rm -rf build +mkdir build sphinx-build docs build cd build python -m http.server diff --git a/tests/test_errors.py b/tests/test_errors.py index 11630c4f..4549b1a6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -10,7 +10,7 @@ import requests from multidict import CIMultiDict, CIMultiDictProxy -from homeassistant_api import Client, Domain, UnauthorizedError +from homeassistant_api import Client, Domain from homeassistant_api.errors import ( APIConfigurationError, BadTemplateError, @@ -20,6 +20,7 @@ MethodNotAllowedError, ProcessorNotFoundError, ResponseError, + UnauthorizedError, UnexpectedStatusCodeError, ) from homeassistant_api.processing import Processing diff --git a/tests/test_events.py b/tests/test_events.py index 150065b4..71aa24c3 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -5,8 +5,8 @@ from homeassistant_api.websocket import WebsocketClient -def test_subscribe_events(websocket_client: WebsocketClient) -> None: - with websocket_client.subscribe_events("test_event") as events: +def test_listen_events(websocket_client: WebsocketClient) -> None: + with websocket_client.listen_events("test_event") as events: websocket_client.fire_event( "test_event", message="Triggered by websocket client" ) @@ -16,11 +16,11 @@ def test_subscribe_events(websocket_client: WebsocketClient) -> None: assert event.data["message"] == "Triggered by websocket client" -def test_subscribe_trigger(websocket_client: WebsocketClient) -> None: +def test_listen_trigger(websocket_client: WebsocketClient) -> None: future = datetime.fromisoformat( websocket_client.get_rendered_template("{{ (now() + timedelta(seconds=1)) }}") ) - with websocket_client.subscribe_trigger( + with websocket_client.listen_trigger( "time", at=future.strftime("%H:%M:%S") ) as triggers: for _, trigger in zip(range(1), triggers):