Skip to content

Commit

Permalink
Merge pull request #195 from GrandMoff100/rest-service-response-patch
Browse files Browse the repository at this point in the history
Patch return_response flag handling when calling services
  • Loading branch information
GrandMoff100 authored Dec 22, 2024
2 parents 0c7fdaa + 9203f63 commit a13895f
Show file tree
Hide file tree
Showing 29 changed files with 371 additions and 152 deletions.
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,27 @@
<img src="https://github.com/GrandMoff100/HomeAssistantAPI/blob/7edb4e6298d37bda19c08b807613c6d351788491/docs/images/homeassistant-logo.png?raw=true" width="60%">
</a>

## Python wrapper for Homeassistant's [REST API](https://developers.home-assistant.io/docs/api/rest/)
## 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/)

> Note: As of [this comment](https://github.com/home-assistant/architecture/discussions/1074#discussioncomment-9196867) the REST API is not getting any new features or endpoints.
> However, it is not going to be deprecated according to [this comment](https://github.com/home-assistant/developers.home-assistant/pull/2150#pullrequestreview-2017433583)
> But it is recommended to use the Websocket API for new integrations.
Here is a quick example.

```py
from homeassistant_api import Client

with Client(
'<API Server URL>',
'<API Server URL>', # i.e. 'http://homeassistant.local:8123/api/'
'<Your Long Lived Access-Token>'
) as client:

light = client.get_domain("light")

light.turn_on(entity_id="light.living_room_lamp")
light = client.trigger_service('light', 'turn_on', {'entity_id': 'light.living_room'})
```

All the methods also support async!
Just prefix the method with `async_`
All the methods also support async/await!
Just prefix the method with `async_` and pass the `use_async=True` argument to the `Client` constructor.
Then you can use the methods as coroutines
(i.e. `await light.async_turn_on(...)`).

## Documentation
Expand Down
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Configuration file for the Sphinx documentation builder."""

#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
Expand Down
1 change: 0 additions & 1 deletion homeassistant_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Interact with your Homeassistant Instance remotely."""


__all__ = (
"Client",
"State",
Expand Down
27 changes: 22 additions & 5 deletions homeassistant_api/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Module containing the primary Client class."""

import logging
from typing import Any
import urllib.parse as urlparse
import warnings

from .rawasyncclient import RawAsyncClient
from .rawclient import RawClient
Expand All @@ -21,12 +24,26 @@ class Client(RawClient, RawAsyncClient):

def __init__(
self,
*args: Any,
api_url: str,
token: str,
use_async: bool = False,
verify_ssl: bool = True,
**kwargs: Any
**kwargs: Any,
) -> None:
if use_async:
RawAsyncClient.__init__(self, *args, verify_ssl=verify_ssl, **kwargs)
parsed = urlparse.urlparse(api_url)

if parsed.scheme in {"http", "https"}:
if use_async:
RawAsyncClient.__init__(
self, api_url, token, verify_ssl=verify_ssl, **kwargs
)
else:
RawClient.__init__(
self, api_url, token, verify_ssl=verify_ssl, **kwargs
)
warnings.warn(
"The REST API is being phased out and will be removed in a far future release. Please use the WebSocket API instead.",
DeprecationWarning,
)
else:
RawClient.__init__(self, *args, verify_ssl=verify_ssl, **kwargs)
raise ValueError(f"Unknown scheme {parsed.scheme} in {api_url}")
10 changes: 9 additions & 1 deletion homeassistant_api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Union


class HomeassistantAPIError(BaseException):
class HomeassistantAPIError(Exception):
"""Base class for custom errors"""


Expand Down Expand Up @@ -84,3 +84,11 @@ class UnexpectedStatusCodeError(ResponseError):

def __init__(self, status_code: int) -> None:
super().__init__(f"Response has unexpected status code: {status_code!r}")


class WebsocketError(HomeassistantAPIError):
"""Error raised when an issue occurs with the websocket connection."""


class ReceivingError(WebsocketError):
"""Error raised when an issue occurs when receiving a message from the websocket server."""
1 change: 1 addition & 0 deletions homeassistant_api/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""The Model objects for the entire library."""

from .base import BaseModel
from .domains import Domain, Service, ServiceField
from .entity import Entity, Group
Expand Down
4 changes: 3 additions & 1 deletion homeassistant_api/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@


DatetimeIsoField = Annotated[
datetime, PlainSerializer(lambda x: x.isoformat(), return_type=str, when_used='json')
datetime,
PlainSerializer(lambda x: x.isoformat(), return_type=str, when_used="json"),
]


class BaseModel(PydanticBaseModel):
"""Base model that all Library Models inherit from."""

model_config = ConfigDict(
arbitrary_types_allowed=True,
validate_assignment=True,
Expand Down
70 changes: 50 additions & 20 deletions homeassistant_api/models/domains.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""File for Service and Domain data models"""

import gc
import inspect
from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast

from pydantic import Field

from homeassistant_api.errors import RequestError

from .base import BaseModel
from .states import State

Expand Down Expand Up @@ -36,9 +39,7 @@ def __init__(self, *args, _client: Optional["Client"] = None, **kwargs) -> None:
def from_json(cls, json: Dict[str, Any], client: "Client") -> "Domain":
"""Constructs Domain and Service models from json data."""
if "domain" not in json or "services" not in json:
raise ValueError(
"Missing services or attribute attribute in json argument."
)
raise ValueError("Missing services or domain attribute in json argument.")
domain = cls(domain_id=cast(str, json.get("domain")), _client=client)
services = json.get("services")
assert isinstance(services, dict)
Expand Down Expand Up @@ -67,7 +68,13 @@ def __getattr__(self, attr: str):
"""Allows services accessible as attributes"""
if attr in self.services:
return self.get_service(attr)
return super().__getattribute__(attr)
try:
return super().__getattribute__(attr)
except AttributeError as err:
try:
return object.__getattribute__(self, attr)
except AttributeError as e:
raise e from err


class ServiceField(BaseModel):
Expand All @@ -89,26 +96,49 @@ class Service(BaseModel):
description: Optional[str] = None
fields: Optional[Dict[str, ServiceField]] = None

def trigger(self, **service_data) -> Tuple[State, ...]:
"""Triggers the service associated with this object."""
return self.domain._client.trigger_service(
self.domain.domain_id,
self.service_id,
**service_data,
)

async def async_trigger(self, **service_data) -> Tuple[State, ...]:
def trigger(
self, **service_data
) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]:
"""Triggers the service associated with this object."""
return await self.domain._client.async_trigger_service(
self.domain.domain_id,
self.service_id,
**service_data,
)
try:
return self.domain._client.trigger_service_with_response(
self.domain.domain_id,
self.service_id,
**service_data,
)
except RequestError:
return self.domain._client.trigger_service(
self.domain.domain_id,
self.service_id,
**service_data,
)

def __call__(
async def async_trigger(
self, **service_data
) -> Union[Tuple[State, ...], Coroutine[Any, Any, Tuple[State, ...]]]:
) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]:
"""Triggers the service associated with this object."""
try:
return await self.domain._client.async_trigger_service_with_response(
self.domain.domain_id,
self.service_id,
**service_data,
)
except RequestError:
return await self.domain._client.async_trigger_service(
self.domain.domain_id,
self.service_id,
**service_data,
)

def __call__(self, **service_data) -> Union[
Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]],
Coroutine[
Any, Any, Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]
],
]:
"""
Triggers the service associated with this object.
"""
assert (frame := inspect.currentframe()) is not None
assert (parent_frame := frame.f_back) is not None
try:
Expand Down
1 change: 1 addition & 0 deletions homeassistant_api/models/history.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module for the History model."""

from typing import Tuple

from pydantic import Field
Expand Down
1 change: 1 addition & 0 deletions homeassistant_api/models/logbook.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module for the Logbook Entry model."""

from typing import Optional

from pydantic import Field
Expand Down
1 change: 1 addition & 0 deletions homeassistant_api/models/states.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module for the Entity State model."""

from datetime import datetime
from typing import Any, Dict, Optional

Expand Down
42 changes: 37 additions & 5 deletions homeassistant_api/rawasyncclient.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module for interacting with Home Assistant asyncronously."""

from __future__ import annotations

import asyncio
Expand Down Expand Up @@ -176,11 +177,14 @@ async def async_get_rendered_template(self, template: str) -> str:
:code:`POST /api/template`
"""
try:
return cast(str, await self.async_request(
"template",
json=dict(template=template),
method="POST",
))
return cast(
str,
await self.async_request(
"template",
json=dict(template=template),
method="POST",
),
)
except RequestError as err:
raise BadTemplateError(
"Your template is invalid. "
Expand Down Expand Up @@ -291,6 +295,34 @@ async def async_trigger_service(
)
return tuple(map(State.from_json, cast(List[Dict[Any, Any]], data)))

async def async_trigger_service_with_response(
self,
domain: str,
service: str,
**service_data: Union[Dict[str, Any], List[Any], str],
) -> tuple[tuple[State, ...], dict[str, Any]]:
"""
Tells Home Assistant to trigger a service, returns the response from the service call.
:code:`POST /api/services/<domain>/<service>`
Returns a list of the states changed and the response from the service call.
"""
data = cast(
dict[str, Any],
await self.async_request(
join("services", domain, service) + "?return_response",
method="POST",
json=service_data,
),
)
states = tuple(
map(
State.from_json,
cast(List[Dict[Any, Any]], data.get("changed_states", [])),
)
)
return states, data.get("service_response", {})

# EntityState methods
async def async_get_state( # pylint: disable=duplicate-code
self,
Expand Down
15 changes: 8 additions & 7 deletions homeassistant_api/rawbaseclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,14 @@ def prepare_get_entity_histories_params(
end_timestamp: Optional[datetime] = None,
significant_changes_only: bool = False,
) -> Tuple[Dict[str, Optional[str]], str]:

"""Pre-logic for `Client.get_entity_histories` and `Client.async_get_entity_histories`."""
params: Dict[str, Optional[str]] = {}
if entities is not None:
params["filter_entity_id"] = ",".join([ent.entity_id for ent in entities])
if end_timestamp is not None:
params[
"end_time"
] = end_timestamp.isoformat() # Params are automatically URL encoded
params["end_time"] = (
end_timestamp.isoformat()
) # Params are automatically URL encoded
if significant_changes_only:
params["significant_changes_only"] = None
if start_timestamp is not None:
Expand All @@ -134,9 +133,11 @@ def prepare_get_logbook_entry_params(
if filter_entities is not None:
params.update(
{
"entity": filter_entities
if isinstance(filter_entities, str)
else ",".join(filter_entities)
"entity": (
filter_entities
if isinstance(filter_entities, str)
else ",".join(filter_entities)
)
}
)
if end_timestamp is not None:
Expand Down
29 changes: 29 additions & 0 deletions homeassistant_api/rawclient.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module for all interaction with homeassistant."""

from __future__ import annotations

import json
Expand Down Expand Up @@ -290,6 +291,34 @@ def trigger_service(
)
return tuple(map(State.from_json, cast(List[Dict[str, Any]], data)))

def trigger_service_with_response(
self,
domain: str,
service: str,
**service_data,
) -> tuple[tuple[State, ...], dict[str, Any]]:
"""
Tells Home Assistant to trigger a service, returns the response from the service call.
:code:`POST /api/services/<domain>/<service>`
Returns a list of the states changed and the response from the service call.
"""
data = cast(
dict[str, Any],
self.request(
join("services", domain, service) + "?return_response",
method="POST",
json=service_data,
),
)
states = tuple(
map(
State.from_json,
cast(List[Dict[Any, Any]], data.get("changed_states", [])),
)
)
return states, data.get("service_response", {})

# EntityState methods
def get_state( # pylint: disable=duplicate-code
self,
Expand Down
Loading

0 comments on commit a13895f

Please sign in to comment.