Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Patch return_response flag handling when calling services #195

Merged
merged 20 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading