Skip to content

Commit ec86ac9

Browse files
authored
Merge pull request #202 from timohencken/fix/history_dates
get_entity_histories() - fixed date formatting
2 parents 69a2783 + d52908b commit ec86ac9

8 files changed

Lines changed: 103 additions & 35 deletions

File tree

homeassistant_api/errors.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,29 @@ class HomeassistantAPIError(Exception):
1010
class RequestError(HomeassistantAPIError):
1111
"""Error raised when an issue occurs when requesting to Homeassistant."""
1212

13+
def __init__(
14+
self, data: Optional[str], /, url: str, message: Optional[str] = None
15+
) -> None:
16+
if message is not None:
17+
super().__init__(
18+
message
19+
+ f" {url!r}"
20+
+ (f" with data: {data!r}" if data is not None else "")
21+
)
22+
elif data is None:
23+
super().__init__(f"An error occurred while making the request to {url!r}")
24+
else:
25+
super().__init__(
26+
f"An error occurred while making the request to {url!r} with data: {data!r}"
27+
)
28+
1329

1430
class RequestTimeoutError(RequestError):
1531
"""Error raised when a request times out."""
1632

33+
def __init__(self, message: str, url: str) -> None:
34+
super().__init__(None, url, message)
35+
1736

1837
class ResponseError(HomeassistantAPIError):
1938
"""Error raised when an issue occurs in a response from Homeassistant."""

homeassistant_api/processing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def process(self) -> Any:
8888
if status_code in (200, 201):
8989
return self.process_content(async_=async_)
9090
if status_code == 400:
91-
raise RequestError(content)
91+
raise RequestError(content, url=self._response.url) # type: ignore
9292
if status_code == 401:
9393
raise UnauthorizedError()
9494
if status_code == 404:

homeassistant_api/rawasyncclient.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ async def __aexit__(self, _, __, ___):
9292
async def async_request(
9393
self,
9494
path: str,
95+
*,
96+
params: str = "", # should be a string of query parameters from construct_params()
9597
method: str = "GET",
9698
headers: Optional[Dict[str, str]] = None,
9799
**kwargs,
@@ -103,14 +105,15 @@ async def async_request(
103105
return await self.async_response_logic(
104106
await self.async_cache_session.request(
105107
method,
106-
self.endpoint(path),
108+
self.endpoint(path) + f"?{params}" * bool(params),
107109
headers=self.prepare_headers(headers),
108110
**kwargs,
109111
)
110112
)
111113
except asyncio.exceptions.TimeoutError as err:
112114
raise RequestTimeoutError(
113-
f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)'
115+
f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)',
116+
self.endpoint(path) + f"?{params}" * bool(params),
114117
) from err
115118

116119
@staticmethod
@@ -143,7 +146,9 @@ async def async_get_logbook_entries(
143146
:code:`GET /api/logbook/<timestamp>`
144147
"""
145148
params, url = self.prepare_get_logbook_entry_params(*args, **kwargs)
146-
data = await self.async_request(url, params=params)
149+
data = await self.async_request(
150+
url, params=self.construct_params(cast(Dict[str, Optional[str]], params))
151+
)
147152
for entry in data:
148153
yield LogbookEntry.model_validate(entry)
149154

homeassistant_api/rawbaseclient.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Module for parent RawWrapper class"""
22

3-
from datetime import datetime
3+
from datetime import datetime, timedelta
44
from posixpath import join
55
from typing import Any, Dict, Iterable, Optional, Tuple, Union
6+
from urllib.parse import quote_plus
67

78
from .models import Entity
89

@@ -62,8 +63,16 @@ def prepare_headers(
6263

6364
@staticmethod
6465
def construct_params(params: Dict[str, Optional[str]]) -> str:
65-
"""Custom method for constructing non-standard query strings"""
66-
return "&".join([k if v is None else f"{k}={v}" for k, v in params.items()])
66+
"""
67+
Custom method for constructing non-standard query strings.
68+
69+
For keys with corresponding None values, the query string will be key only (i.e. :code:`?key1&key2`).
70+
For keys with corresponding non-None values, the query string will be key-value pairs (i.e. :code:`?key1=value1&key2=value2`).
71+
To have an empty value use an empty string :code:`""` (i.e. :code:`?key1=&key2=value2`).
72+
"""
73+
return "&".join(
74+
[k if v is None else f"{k}={quote_plus(v)}" for k, v in params.items()]
75+
)
6776

6877
@staticmethod
6978
def prepare_get_entity_histories_params(
@@ -73,20 +82,32 @@ def prepare_get_entity_histories_params(
7382
end_timestamp: Optional[datetime] = None,
7483
significant_changes_only: bool = False,
7584
) -> Tuple[Dict[str, Optional[str]], str]:
76-
"""Pre-logic for `Client.get_entity_histories` and `Client.async_get_entity_histories`."""
85+
"""
86+
Pre-logic for :py:meth:`Client.get_entity_histories` and :py:meth:`Client.async_get_entity_histories`.
87+
88+
Ensure timestamps
89+
90+
* use second resolution (microseconds are truncated)
91+
* are timezone-aware
92+
* are URL-encoded (as :py:meth:`construct_params` is used instead of request's default parameter encoding)
93+
"""
7794
params: Dict[str, Optional[str]] = {}
7895
if entities is not None:
7996
params["filter_entity_id"] = ",".join([ent.entity_id for ent in entities])
80-
if end_timestamp is not None:
81-
params["end_time"] = (
82-
end_timestamp.isoformat()
83-
) # Params are automatically URL encoded
84-
if significant_changes_only:
85-
params["significant_changes_only"] = None
8697
if start_timestamp is not None:
98+
start_timestamp = start_timestamp.replace(microsecond=0)
99+
if start_timestamp.tzinfo is None:
100+
start_timestamp = start_timestamp.astimezone()
87101
url = join("history/period/", start_timestamp.isoformat())
88102
else:
89103
url = "history/period"
104+
if end_timestamp is not None:
105+
end_timestamp = end_timestamp.replace(microsecond=0) + timedelta(seconds=1)
106+
if end_timestamp.tzinfo is None:
107+
end_timestamp = end_timestamp.astimezone()
108+
params["end_time"] = end_timestamp.isoformat()
109+
if significant_changes_only:
110+
params["significant_changes_only"] = None
90111
return params, url
91112

92113
@staticmethod

homeassistant_api/rawclient.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ def __exit__(self, _, __, ___) -> None:
8686
def request(
8787
self,
8888
path: str,
89+
*,
90+
params: str = "", # should be a string of query parameters from construct_params()
8991
method="GET",
9092
headers: Optional[Dict[str, str]] = None,
9193
decode_bytes: bool = True,
@@ -99,13 +101,14 @@ def request(
99101
if self.cache_session:
100102
resp = self.cache_session.request(
101103
method,
102-
self.endpoint(path),
104+
self.endpoint(path) + f"?{params}" * bool(params),
103105
headers=self.prepare_headers(headers),
104106
**kwargs,
105107
)
106108
except requests.exceptions.Timeout as err:
107109
raise RequestTimeoutError(
108-
f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)'
110+
f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)',
111+
url=self.endpoint(path) + f"?{params}" * bool(params),
109112
) from err
110113
return self.response_logic(response=resp, decode_bytes=decode_bytes)
111114

@@ -139,7 +142,9 @@ def get_logbook_entries(
139142
:code:`GET /api/logbook/<timestamp>`
140143
"""
141144
params, url = self.prepare_get_logbook_entry_params(*args, **kwargs)
142-
data = self.request(url, params=params)
145+
data = self.request(
146+
url, params=self.construct_params(cast(Dict[str, Optional[str]], params))
147+
)
143148
for entry in data:
144149
yield LogbookEntry.model_validate(entry)
145150

homeassistant_api/websocket.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121

2222
class WebsocketClient(RawWebsocketClient):
2323
"""
24-
24+
2525
The main class for interactign with the Home Assistant WebSocket API client.
2626
2727
Here's a quick example of how to use the :py:class:`WebsocketClient` class:
28-
28+
2929
.. code-block:: python
3030
3131
from homeassistant_api import WebsocketClient
@@ -66,7 +66,7 @@ def get_rendered_template(self, template: str) -> str:
6666
def get_config(self) -> dict[str, Any]:
6767
"""
6868
Get the Home Assistant configuration.
69-
69+
7070
Sends command :code:`{"type": "get_config", ...}`.
7171
"""
7272
return cast(
@@ -80,7 +80,7 @@ def get_config(self) -> dict[str, Any]:
8080
def get_states(self) -> Tuple[State, ...]:
8181
"""
8282
Get a list of states.
83-
83+
8484
Sends command :code:`{"type": "get_states", ...}`.
8585
"""
8686
return tuple(
@@ -170,7 +170,7 @@ def get_domains(self) -> dict[str, Domain]:
170170
Get a list of services that Home Assistant offers (organized into a dictionary of service domains).
171171
172172
For example, the service :code:`light.turn_on` would be in the domain :code:`light`.
173-
173+
174174
Sends command :code:`{"type": "get_services", ...}`.
175175
"""
176176
resp = self.recv(self.send("get_services"))
@@ -203,7 +203,7 @@ def trigger_service(
203203
) -> None:
204204
"""
205205
Trigger a service (that doesn't return a response).
206-
206+
207207
Sends command :code:`{"type": "call_service", ...}`.
208208
"""
209209
params = {
@@ -236,7 +236,7 @@ def trigger_service_with_response(
236236
) -> dict[str, Any]:
237237
"""
238238
Trigger a service (that returns a response) and return the response.
239-
239+
240240
Sends command :code:`{"type": "call_service", ...}`.
241241
"""
242242
params = {
@@ -261,7 +261,7 @@ def listen_events(
261261
Listen for all events of a certain type.
262262
263263
For example, to listen for all events of type `test_event`:
264-
264+
265265
.. code-block:: python
266266
267267
with ws_client.listen_events("test_event") as events:
@@ -275,7 +275,7 @@ def listen_events(
275275
def _subscribe_events(self, event_type: Optional[str]) -> int:
276276
"""
277277
Subscribe to all events of a certain type.
278-
278+
279279
280280
Sends command :code:`{"type": "subscribe_events", ...}`.
281281
"""
@@ -292,15 +292,15 @@ def listen_trigger(
292292
293293
For example, in Home Assistant Automations we can subscribe to a state trigger for a light entity with YAML:
294294
295-
.. code-block:: yaml
296-
295+
.. code-block:: yaml
296+
297297
triggers:
298298
# ...
299299
- trigger: state
300300
entity_id: light.kitchen
301301
302302
To subscribe to that same state trigger with :py:class:`WebsocketClient` instead
303-
303+
304304
.. code-block:: python
305305
306306
with ws_client.listen_trigger("state", entity_id="light.kitchen") as trigger:
@@ -309,7 +309,7 @@ def listen_trigger(
309309
if <some_condition>:
310310
break
311311
# exiting the context manager unsubscribes from the trigger
312-
312+
313313
Woohoo! We can now listen to triggers in Python code!
314314
"""
315315
subscription = self._subscribe_trigger(trigger, **trigger_fields)
@@ -325,7 +325,7 @@ def listen_trigger(
325325
def _subscribe_trigger(self, trigger: str, **trigger_fields) -> int:
326326
"""
327327
Return the subscription id of the trigger we subscribe to.
328-
328+
329329
Sends command :code:`{"type": "subscribe_trigger", ...}`.
330330
"""
331331
return self.recv(
@@ -351,7 +351,7 @@ def _wait_for(
351351
def _unsubscribe(self, subcription_id: int) -> None:
352352
"""
353353
Unsubscribe from all events of a certain type.
354-
354+
355355
Sends command :code:`{"type": "unsubscribe_events", ...}`.
356356
"""
357357
resp = self.recv(self.send("unsubscribe_events", subscription=subcription_id))
@@ -361,7 +361,7 @@ def _unsubscribe(self, subcription_id: int) -> None:
361361
def fire_event(self, event_type: str, **event_data) -> Context:
362362
"""
363363
Fire an event.
364-
364+
365365
Sends command :code:`{"type": "fire_event", ...}`.
366366
"""
367367
params: dict[str, Any] = {"event_type": event_type}

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from homeassistant_api import Client, WebsocketClient
1010

11+
logging.basicConfig(level=logging.INFO)
12+
1113
TIMEOUT = 300
1214

1315

tests/test_endpoints.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ def test_get_logbook_entries(cached_client: Client) -> None:
4444

4545
async def test_async_get_logbook_entries(async_cached_client: Client) -> None:
4646
"""Tests the `GET /api/logbook/<timestamp>` endpoint."""
47-
async for entry in async_cached_client.async_get_logbook_entries():
47+
async for entry in async_cached_client.async_get_logbook_entries(
48+
filter_entities="sun.sun",
49+
start_timestamp=datetime(2020, 1, 1),
50+
end_timestamp=datetime.now(),
51+
):
4852
assert entry
4953

5054

@@ -64,12 +68,18 @@ def test_get_entity_histories(cached_client: Client) -> None:
6468
assert sun is not None
6569
for history in cached_client.get_entity_histories(
6670
(sun,),
67-
end_timestamp=datetime(2023, 1, 1),
71+
end_timestamp=datetime.now(), # test for microsecond truncation
6872
start_timestamp=datetime(2020, 1, 1),
6973
significant_changes_only=True,
7074
):
7175
for state in history.states:
7276
assert isinstance(state, State)
77+
break
78+
else:
79+
raise AssertionError("No states in entity history found.")
80+
break
81+
else:
82+
raise AssertionError("No history found.")
7383

7484

7585
async def test_async_get_entity_histories(async_cached_client: Client) -> None:
@@ -79,6 +89,12 @@ async def test_async_get_entity_histories(async_cached_client: Client) -> None:
7989
async for history in async_cached_client.async_get_entity_histories((sun,)):
8090
for state in history.states:
8191
assert isinstance(state, State)
92+
break
93+
else:
94+
raise AssertionError("No states in entity history found.")
95+
break
96+
else:
97+
raise AssertionError("No history found.")
8298

8399

84100
def test_get_rendered_template(cached_client: Client) -> None:

0 commit comments

Comments
 (0)