homeconnect-watcher 0.0.12.dev3__tar.gz → 0.0.14.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/PKG-INFO +12 -3
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/README.md +9 -1
- homeconnect_watcher-0.0.14.dev1/src/homeconnect_watcher/_version.py +24 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/client/appliance.py +4 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/client/client.py +103 -45
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/metrics.py +1 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/PKG-INFO +12 -3
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/SOURCES.txt +3 -0
- homeconnect_watcher-0.0.14.dev1/src/homeconnect_watcher.egg-info/scm_file_list.json +53 -0
- homeconnect_watcher-0.0.14.dev1/src/homeconnect_watcher.egg-info/scm_version.json +8 -0
- homeconnect_watcher-0.0.14.dev1/tests/client/test_watch.py +105 -0
- homeconnect_watcher-0.0.12.dev3/src/homeconnect_watcher/_version.py +0 -16
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.github/workflows/ci.yaml +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.github/workflows/publish.yaml +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.gitignore +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.pre-commit-config.yaml +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/LICENSE +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/pyproject.toml +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/setup.cfg +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/__init__.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/api.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/cli.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/client/__init__.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/__init__.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/client.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/utils.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/view.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/event.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exceptions.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/__init__.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/base.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/file.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/postgres.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/read.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/0_appliances.sql +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/1_events.sql +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/2_raw_events_active.sql +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/3_raw_events_with_session_id.sql +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/4_sessions.sql +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/trigger.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/__init__.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/logging.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/retry.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/timeout.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/dependency_links.txt +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/entry_points.txt +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/requires.txt +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/top_level.txt +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/client/test_client.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/conftest.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/test_db_client.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/test_views.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/conftest.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/test_view_appliances.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/test_view_events.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/test_view_session.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/exporter/test_file.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/exporter/test_postgres.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/test_event.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/utils/test_retry.py +0 -0
- {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/utils/test_timeout.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: homeconnect-watcher
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.14.dev1
|
|
4
4
|
Summary: Python service that listens to HomeConnect event and logs them.
|
|
5
5
|
Author-email: Rogier van der Geer <rogier@vander-geer.nl>
|
|
6
6
|
License: MIT
|
|
@@ -25,6 +25,7 @@ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
|
25
25
|
Requires-Dist: pytest-postgresql>=5.0.0; extra == "dev"
|
|
26
26
|
Provides-Extra: prometheus
|
|
27
27
|
Requires-Dist: prometheus-client>=0.16.0; extra == "prometheus"
|
|
28
|
+
Dynamic: license-file
|
|
28
29
|
|
|
29
30
|
# homeconnect-watcher
|
|
30
31
|
|
|
@@ -35,6 +36,11 @@ Python service that listens to HomeConnect event and logs them.
|
|
|
35
36
|

|
|
36
37
|

|
|
37
38
|
|
|
39
|
+
## Project Summary
|
|
40
|
+
|
|
41
|
+
This project attempts to do two things:
|
|
42
|
+
- Listen to the HomeConnect API for events, make requests to gather additional information, and save everything to file and/or a database.
|
|
43
|
+
- Group the events in the database into sessions, summarizing the collected data into programs that ran on the appliance.
|
|
38
44
|
|
|
39
45
|
## Usage
|
|
40
46
|
|
|
@@ -62,8 +68,11 @@ If the watcher is used regularly, this only needs to be done once.
|
|
|
62
68
|
Next up, run
|
|
63
69
|
|
|
64
70
|
```
|
|
65
|
-
homeconnect-watcher watch
|
|
71
|
+
homeconnect-watcher watch --log-path ./logs
|
|
66
72
|
```
|
|
73
|
+
to start watching your appliances and write the logs to "./logs".
|
|
74
|
+
|
|
75
|
+
To store the logs to a database, provide `--db-uri <uri>` with a uri to a postgres database. This will store all events to the `events` table.
|
|
67
76
|
|
|
68
77
|
## Exposing Metrics to Prometheus
|
|
69
78
|
|
|
@@ -7,6 +7,11 @@ Python service that listens to HomeConnect event and logs them.
|
|
|
7
7
|

|
|
8
8
|

|
|
9
9
|
|
|
10
|
+
## Project Summary
|
|
11
|
+
|
|
12
|
+
This project attempts to do two things:
|
|
13
|
+
- Listen to the HomeConnect API for events, make requests to gather additional information, and save everything to file and/or a database.
|
|
14
|
+
- Group the events in the database into sessions, summarizing the collected data into programs that ran on the appliance.
|
|
10
15
|
|
|
11
16
|
## Usage
|
|
12
17
|
|
|
@@ -34,8 +39,11 @@ If the watcher is used regularly, this only needs to be done once.
|
|
|
34
39
|
Next up, run
|
|
35
40
|
|
|
36
41
|
```
|
|
37
|
-
homeconnect-watcher watch
|
|
42
|
+
homeconnect-watcher watch --log-path ./logs
|
|
38
43
|
```
|
|
44
|
+
to start watching your appliances and write the logs to "./logs".
|
|
45
|
+
|
|
46
|
+
To store the logs to a database, provide `--db-uri <uri>` with a uri to a postgres database. This will store all events to the `events` table.
|
|
39
47
|
|
|
40
48
|
## Exposing Metrics to Prometheus
|
|
41
49
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.14.dev1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 14, 'dev1')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'gaa9ced892'
|
|
@@ -15,6 +15,10 @@ class HomeConnectAppliance:
|
|
|
15
15
|
self.appliance_type = appliance_type
|
|
16
16
|
self._available_programs: list[str] | None = None
|
|
17
17
|
self._last_update: dict[str, float] = dict()
|
|
18
|
+
# Request types with a deferred fetch already scheduled. Used to
|
|
19
|
+
# collapse repeated triggers (e.g. a burst of NOTIFYs all wanting
|
|
20
|
+
# `status`) into a single fetch fired when the throttle expires.
|
|
21
|
+
self._pending: set[str] = set()
|
|
18
22
|
|
|
19
23
|
def __repr__(self) -> str:
|
|
20
24
|
return f"HomeConnect{self.appliance_type}(ha_id={repr(self.appliance_id)})"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from asyncio import sleep
|
|
1
|
+
from asyncio import Queue, Task, create_task, sleep
|
|
2
|
+
from collections.abc import Callable, Coroutine
|
|
2
3
|
from json import load, dump
|
|
3
4
|
from logging import getLogger
|
|
4
5
|
from os import environ
|
|
@@ -41,6 +42,8 @@ class HomeConnectClient:
|
|
|
41
42
|
self.client.auth = self.client.token_auth # Ensure we use token auth for all requests.
|
|
42
43
|
self._appliances: list["HomeConnectAppliance"] | None = None
|
|
43
44
|
self._last_event: int | None = None
|
|
45
|
+
# Outstanding deferred-fetch tasks, tracked so they are cancelled when watching stops.
|
|
46
|
+
self._deferred_tasks: set[Task] = set()
|
|
44
47
|
self.metrics = metrics
|
|
45
48
|
if self.metrics:
|
|
46
49
|
self.metrics.set_last_event(lambda: monotonic() - self._last_event if self._last_event else -1)
|
|
@@ -91,18 +94,41 @@ class HomeConnectClient:
|
|
|
91
94
|
"""
|
|
92
95
|
Watch the status of one or all appliances.
|
|
93
96
|
|
|
94
|
-
Listens to the event stream and
|
|
95
|
-
to make requests for further details.
|
|
97
|
+
Listens to the event stream and yields all events. In addition, processes
|
|
98
|
+
triggers of these events to make requests for further details. Throttled
|
|
99
|
+
requests that can't fire immediately are deferred — a single fetch is
|
|
100
|
+
scheduled for when the throttle expires, and any further triggers within
|
|
101
|
+
that window collapse onto the same pending fetch.
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
Both stream events and deferred-fetch results land on a shared queue and
|
|
104
|
+
are yielded in arrival order. Automatically reconnects when disconnected
|
|
105
|
+
or on timeout.
|
|
98
106
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
queue: Queue[HomeConnectEvent] = Queue()
|
|
108
|
+
producer = create_task(self._producer(queue=queue, appliance_id=appliance_id, reconnect_delay=reconnect_delay))
|
|
109
|
+
try:
|
|
110
|
+
while True:
|
|
111
|
+
event = await queue.get()
|
|
112
|
+
if self.metrics:
|
|
113
|
+
self.metrics.increment_event_counter(event=event)
|
|
114
|
+
yield event
|
|
115
|
+
finally:
|
|
116
|
+
producer.cancel()
|
|
117
|
+
for task in list(self._deferred_tasks):
|
|
118
|
+
task.cancel()
|
|
119
|
+
|
|
120
|
+
async def _producer(self, queue: "Queue[HomeConnectEvent]", appliance_id: str | None, reconnect_delay: int) -> None:
|
|
121
|
+
"""Drive the SSE stream and dispatch triggers onto the shared queue."""
|
|
122
|
+
try:
|
|
123
|
+
await self._initial_triggers(queue=queue, appliance_id=appliance_id)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
# Best-effort enrichment: a failure here must not kill the producer.
|
|
126
|
+
self.logger.warning("Error while handling initial triggers.", exc_info=e)
|
|
101
127
|
while True:
|
|
102
128
|
try:
|
|
103
129
|
async for event in self._event_stream(appliance_id=appliance_id):
|
|
104
|
-
|
|
105
|
-
|
|
130
|
+
await queue.put(event)
|
|
131
|
+
await self._handle_trigger(event.trigger, queue=queue)
|
|
106
132
|
except HomeConnectConnectionClosed:
|
|
107
133
|
self.logger.warning(f"Connection closed. Reconnecting in {reconnect_delay} seconds.")
|
|
108
134
|
if self.metrics:
|
|
@@ -115,6 +141,14 @@ class HomeConnectClient:
|
|
|
115
141
|
self.metrics.increment_disconnects(reason="timeout")
|
|
116
142
|
await sleep(reconnect_delay)
|
|
117
143
|
continue
|
|
144
|
+
except Exception as e:
|
|
145
|
+
# Catch Exception (not BaseException) so CancelledError from watch()'s
|
|
146
|
+
# shutdown still stops the producer instead of being swallowed here.
|
|
147
|
+
self.logger.warning(f"Unexpected error. Reconnecting in {reconnect_delay} seconds.", exc_info=e)
|
|
148
|
+
if self.metrics:
|
|
149
|
+
self.metrics.increment_disconnects(reason="error")
|
|
150
|
+
await sleep(reconnect_delay)
|
|
151
|
+
continue
|
|
118
152
|
|
|
119
153
|
async def _event_stream(self, appliance_id: str | None = None) -> AsyncIterable[HomeConnectEvent]:
|
|
120
154
|
"""
|
|
@@ -151,60 +185,84 @@ class HomeConnectClient:
|
|
|
151
185
|
self.logger.info("Reached end of events stream.")
|
|
152
186
|
return
|
|
153
187
|
|
|
154
|
-
async def
|
|
188
|
+
async def _initial_triggers(self, queue: "Queue[HomeConnectEvent]", appliance_id: str | None) -> None:
|
|
155
189
|
"""
|
|
156
|
-
|
|
157
|
-
"""
|
|
158
|
-
if self.metrics:
|
|
159
|
-
self.metrics.increment_event_counter(event=event)
|
|
160
|
-
yield event
|
|
161
|
-
async for triggered_event in self._handle_trigger(event.trigger):
|
|
162
|
-
if self.metrics:
|
|
163
|
-
self.metrics.increment_event_counter(triggered_event)
|
|
164
|
-
yield triggered_event
|
|
165
|
-
|
|
166
|
-
async def _initial_triggers(self, appliance_id: str | None) -> AsyncIterable[HomeConnectEvent]:
|
|
167
|
-
"""
|
|
168
|
-
Create and handle initial triggers
|
|
190
|
+
Create and handle initial triggers.
|
|
169
191
|
|
|
170
|
-
If appliance_id is not None, only create requests for the single appliance.
|
|
171
|
-
for all known appliances.
|
|
192
|
+
If appliance_id is not None, only create requests for the single appliance.
|
|
193
|
+
Otherwise do so for all known appliances. Results are pushed onto `queue`.
|
|
172
194
|
"""
|
|
173
195
|
for appliance in await self.appliances:
|
|
174
196
|
if appliance_id == appliance.appliance_id or appliance_id is None:
|
|
175
|
-
|
|
197
|
+
await self._handle_trigger(
|
|
176
198
|
Trigger(
|
|
177
199
|
appliance_id=appliance.appliance_id,
|
|
178
200
|
status=True,
|
|
179
201
|
settings=True,
|
|
180
202
|
active_program=True,
|
|
181
203
|
selected_program=True,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if self.metrics:
|
|
186
|
-
self.metrics.increment_event_counter(triggered_event)
|
|
204
|
+
),
|
|
205
|
+
queue=queue,
|
|
206
|
+
)
|
|
187
207
|
|
|
188
|
-
async def _handle_trigger(self, trigger: Trigger | None
|
|
208
|
+
async def _handle_trigger(self, trigger: Trigger | None, queue: "Queue[HomeConnectEvent]") -> None:
|
|
189
209
|
"""
|
|
190
|
-
Handle a trigger.
|
|
210
|
+
Handle a trigger by firing or deferring the requested fetches.
|
|
211
|
+
|
|
212
|
+
Each fetch either runs immediately (when the throttle has elapsed or
|
|
213
|
+
`trigger.interval` is False) or is scheduled to run when the throttle
|
|
214
|
+
next expires. Repeated triggers landing during the deferral window
|
|
215
|
+
collapse onto the single pending task. Results land on `queue`.
|
|
191
216
|
"""
|
|
192
217
|
if trigger is None:
|
|
193
218
|
return
|
|
194
219
|
appliance = await self.get_appliance(trigger.appliance_id)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
220
|
+
fetches: list[tuple[str, bool, Callable[[], Coroutine]]] = [
|
|
221
|
+
("status", trigger.status, appliance.get_status),
|
|
222
|
+
("settings", trigger.settings, appliance.get_settings),
|
|
223
|
+
]
|
|
224
|
+
# active/selected program endpoints only exist for appliances that support programs.
|
|
225
|
+
if (trigger.active_program or trigger.selected_program) and await appliance.get_available_programs():
|
|
226
|
+
fetches += [
|
|
227
|
+
("active_program", trigger.active_program, appliance.get_active_program),
|
|
228
|
+
("selected_program", trigger.selected_program, appliance.get_selected_program),
|
|
229
|
+
]
|
|
230
|
+
for kind, wanted, fetch in fetches:
|
|
231
|
+
if wanted:
|
|
232
|
+
await self._fetch_or_defer(appliance, kind, fetch, throttled=trigger.interval, queue=queue)
|
|
233
|
+
|
|
234
|
+
async def _fetch_or_defer(
|
|
235
|
+
self,
|
|
236
|
+
appliance: "HomeConnectAppliance",
|
|
237
|
+
kind: str,
|
|
238
|
+
fetch: Callable[[], Coroutine],
|
|
239
|
+
throttled: bool,
|
|
240
|
+
queue: "Queue[HomeConnectEvent]",
|
|
241
|
+
) -> None:
|
|
242
|
+
"""
|
|
243
|
+
Fetch immediately if the throttle has elapsed; otherwise schedule a
|
|
244
|
+
deferred fetch for when it expires. The pending-task set on the
|
|
245
|
+
appliance deduplicates concurrent triggers for the same request type.
|
|
246
|
+
"""
|
|
247
|
+
if not throttled or appliance.time_since_update(kind) >= 300:
|
|
248
|
+
await queue.put(await fetch())
|
|
249
|
+
return
|
|
250
|
+
if kind in appliance._pending:
|
|
251
|
+
return
|
|
252
|
+
appliance._pending.add(kind)
|
|
253
|
+
|
|
254
|
+
async def deferred() -> None:
|
|
255
|
+
try:
|
|
256
|
+
wait = 300 - appliance.time_since_update(kind)
|
|
257
|
+
if wait > 0:
|
|
258
|
+
await sleep(wait)
|
|
259
|
+
await queue.put(await fetch())
|
|
260
|
+
finally:
|
|
261
|
+
appliance._pending.discard(kind)
|
|
262
|
+
|
|
263
|
+
task = create_task(deferred())
|
|
264
|
+
self._deferred_tasks.add(task)
|
|
265
|
+
task.add_done_callback(self._deferred_tasks.discard)
|
|
208
266
|
|
|
209
267
|
def _load_token(self) -> OAuth2Token | None:
|
|
210
268
|
"""Load the OAuth token from file."""
|
|
@@ -36,6 +36,7 @@ class Metrics:
|
|
|
36
36
|
self._disconnects = Counter("disconnects", "The number of time the connection failed.", ["reason"])
|
|
37
37
|
self._disconnects.labels(reason="timeout")
|
|
38
38
|
self._disconnects.labels(reason="closed")
|
|
39
|
+
self._disconnects.labels(reason="error")
|
|
39
40
|
self._events = Counter("events", "Number of events.", ["appliance_id", "event"])
|
|
40
41
|
self._events.labels(appliance_id=None, event="KEEP-ALIVE")
|
|
41
42
|
self._info = Info("version", "Version info.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: homeconnect-watcher
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.14.dev1
|
|
4
4
|
Summary: Python service that listens to HomeConnect event and logs them.
|
|
5
5
|
Author-email: Rogier van der Geer <rogier@vander-geer.nl>
|
|
6
6
|
License: MIT
|
|
@@ -25,6 +25,7 @@ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
|
|
|
25
25
|
Requires-Dist: pytest-postgresql>=5.0.0; extra == "dev"
|
|
26
26
|
Provides-Extra: prometheus
|
|
27
27
|
Requires-Dist: prometheus-client>=0.16.0; extra == "prometheus"
|
|
28
|
+
Dynamic: license-file
|
|
28
29
|
|
|
29
30
|
# homeconnect-watcher
|
|
30
31
|
|
|
@@ -35,6 +36,11 @@ Python service that listens to HomeConnect event and logs them.
|
|
|
35
36
|

|
|
36
37
|

|
|
37
38
|
|
|
39
|
+
## Project Summary
|
|
40
|
+
|
|
41
|
+
This project attempts to do two things:
|
|
42
|
+
- Listen to the HomeConnect API for events, make requests to gather additional information, and save everything to file and/or a database.
|
|
43
|
+
- Group the events in the database into sessions, summarizing the collected data into programs that ran on the appliance.
|
|
38
44
|
|
|
39
45
|
## Usage
|
|
40
46
|
|
|
@@ -62,8 +68,11 @@ If the watcher is used regularly, this only needs to be done once.
|
|
|
62
68
|
Next up, run
|
|
63
69
|
|
|
64
70
|
```
|
|
65
|
-
homeconnect-watcher watch
|
|
71
|
+
homeconnect-watcher watch --log-path ./logs
|
|
66
72
|
```
|
|
73
|
+
to start watching your appliances and write the logs to "./logs".
|
|
74
|
+
|
|
75
|
+
To store the logs to a database, provide `--db-uri <uri>` with a uri to a postgres database. This will store all events to the `events` table.
|
|
67
76
|
|
|
68
77
|
## Exposing Metrics to Prometheus
|
|
69
78
|
|
|
@@ -18,6 +18,8 @@ src/homeconnect_watcher.egg-info/SOURCES.txt
|
|
|
18
18
|
src/homeconnect_watcher.egg-info/dependency_links.txt
|
|
19
19
|
src/homeconnect_watcher.egg-info/entry_points.txt
|
|
20
20
|
src/homeconnect_watcher.egg-info/requires.txt
|
|
21
|
+
src/homeconnect_watcher.egg-info/scm_file_list.json
|
|
22
|
+
src/homeconnect_watcher.egg-info/scm_version.json
|
|
21
23
|
src/homeconnect_watcher.egg-info/top_level.txt
|
|
22
24
|
src/homeconnect_watcher/client/__init__.py
|
|
23
25
|
src/homeconnect_watcher/client/appliance.py
|
|
@@ -43,6 +45,7 @@ src/homeconnect_watcher/utils/timeout.py
|
|
|
43
45
|
tests/conftest.py
|
|
44
46
|
tests/test_event.py
|
|
45
47
|
tests/client/test_client.py
|
|
48
|
+
tests/client/test_watch.py
|
|
46
49
|
tests/db/test_db_client.py
|
|
47
50
|
tests/db/test_views.py
|
|
48
51
|
tests/db/views/conftest.py
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
".pre-commit-config.yaml",
|
|
4
|
+
"README.md",
|
|
5
|
+
"LICENSE",
|
|
6
|
+
"pyproject.toml",
|
|
7
|
+
".gitignore",
|
|
8
|
+
"src/homeconnect_watcher/__init__.py",
|
|
9
|
+
"src/homeconnect_watcher/exceptions.py",
|
|
10
|
+
"src/homeconnect_watcher/trigger.py",
|
|
11
|
+
"src/homeconnect_watcher/event.py",
|
|
12
|
+
"src/homeconnect_watcher/api.py",
|
|
13
|
+
"src/homeconnect_watcher/read.py",
|
|
14
|
+
"src/homeconnect_watcher/cli.py",
|
|
15
|
+
"src/homeconnect_watcher/sql/1_events.sql",
|
|
16
|
+
"src/homeconnect_watcher/sql/3_raw_events_with_session_id.sql",
|
|
17
|
+
"src/homeconnect_watcher/sql/0_appliances.sql",
|
|
18
|
+
"src/homeconnect_watcher/sql/4_sessions.sql",
|
|
19
|
+
"src/homeconnect_watcher/sql/2_raw_events_active.sql",
|
|
20
|
+
"src/homeconnect_watcher/utils/__init__.py",
|
|
21
|
+
"src/homeconnect_watcher/utils/retry.py",
|
|
22
|
+
"src/homeconnect_watcher/utils/timeout.py",
|
|
23
|
+
"src/homeconnect_watcher/utils/logging.py",
|
|
24
|
+
"src/homeconnect_watcher/utils/metrics.py",
|
|
25
|
+
"src/homeconnect_watcher/db/__init__.py",
|
|
26
|
+
"src/homeconnect_watcher/db/utils.py",
|
|
27
|
+
"src/homeconnect_watcher/db/view.py",
|
|
28
|
+
"src/homeconnect_watcher/db/client.py",
|
|
29
|
+
"src/homeconnect_watcher/client/__init__.py",
|
|
30
|
+
"src/homeconnect_watcher/client/client.py",
|
|
31
|
+
"src/homeconnect_watcher/client/appliance.py",
|
|
32
|
+
"src/homeconnect_watcher/exporter/__init__.py",
|
|
33
|
+
"src/homeconnect_watcher/exporter/postgres.py",
|
|
34
|
+
"src/homeconnect_watcher/exporter/file.py",
|
|
35
|
+
"src/homeconnect_watcher/exporter/base.py",
|
|
36
|
+
"tests/conftest.py",
|
|
37
|
+
"tests/test_event.py",
|
|
38
|
+
"tests/utils/test_retry.py",
|
|
39
|
+
"tests/utils/test_timeout.py",
|
|
40
|
+
"tests/db/test_db_client.py",
|
|
41
|
+
"tests/db/test_views.py",
|
|
42
|
+
"tests/db/views/test_view_session.py",
|
|
43
|
+
"tests/db/views/test_view_appliances.py",
|
|
44
|
+
"tests/db/views/conftest.py",
|
|
45
|
+
"tests/db/views/test_view_events.py",
|
|
46
|
+
"tests/client/test_watch.py",
|
|
47
|
+
"tests/client/test_client.py",
|
|
48
|
+
"tests/exporter/test_file.py",
|
|
49
|
+
"tests/exporter/test_postgres.py",
|
|
50
|
+
".github/workflows/publish.yaml",
|
|
51
|
+
".github/workflows/ci.yaml"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from asyncio import CancelledError, Event, sleep
|
|
2
|
+
from time import time
|
|
3
|
+
from unittest.mock import Mock
|
|
4
|
+
|
|
5
|
+
from pytest import fixture, mark, raises
|
|
6
|
+
|
|
7
|
+
from homeconnect_watcher.client import HomeConnectClient
|
|
8
|
+
from homeconnect_watcher.client.appliance import HomeConnectAppliance
|
|
9
|
+
from homeconnect_watcher.event import HomeConnectEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@fixture
|
|
13
|
+
def client(tmp_path, monkeypatch) -> HomeConnectClient:
|
|
14
|
+
"""A bare client for exercising watch(); every network path is patched in the tests.
|
|
15
|
+
|
|
16
|
+
Overrides the session-scoped `client` fixture from conftest so these tests need no
|
|
17
|
+
credentials or simulator connection.
|
|
18
|
+
"""
|
|
19
|
+
monkeypatch.setenv("HOMECONNECT_CLIENT_ID", "id")
|
|
20
|
+
monkeypatch.setenv("HOMECONNECT_CLIENT_SECRET", "secret")
|
|
21
|
+
monkeypatch.setenv("HOMECONNECT_REDIRECT_URI", "http://localhost:8000/code/")
|
|
22
|
+
return HomeConnectClient(token_cache=tmp_path / "token")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestWatchResilience:
|
|
26
|
+
@mark.asyncio
|
|
27
|
+
async def test_unexpected_error_reconnects(self, client: HomeConnectClient, monkeypatch):
|
|
28
|
+
"""An unexpected error in the producer is logged, counted, and recovered from."""
|
|
29
|
+
keep_alive = HomeConnectEvent(event="KEEP-ALIVE", timestamp=0.0)
|
|
30
|
+
state = {"raised": False}
|
|
31
|
+
|
|
32
|
+
async def fake_event_stream(appliance_id=None):
|
|
33
|
+
if not state["raised"]:
|
|
34
|
+
state["raised"] = True
|
|
35
|
+
raise ValueError("boom")
|
|
36
|
+
yield keep_alive
|
|
37
|
+
await Event().wait() # suspend so the loop can run the consumer; keep the stream "open"
|
|
38
|
+
|
|
39
|
+
async def noop_initial(queue, appliance_id):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
async def noop_handle(trigger, queue):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
metrics = Mock()
|
|
46
|
+
monkeypatch.setattr(client, "metrics", metrics)
|
|
47
|
+
monkeypatch.setattr(client, "_initial_triggers", noop_initial)
|
|
48
|
+
monkeypatch.setattr(client, "_event_stream", fake_event_stream)
|
|
49
|
+
monkeypatch.setattr(client, "_handle_trigger", noop_handle)
|
|
50
|
+
|
|
51
|
+
gen = client.watch(reconnect_delay=0)
|
|
52
|
+
try:
|
|
53
|
+
received = await gen.__anext__()
|
|
54
|
+
finally:
|
|
55
|
+
await gen.aclose()
|
|
56
|
+
|
|
57
|
+
# The producer survived the ValueError, reconnected, and delivered the next event.
|
|
58
|
+
assert received == keep_alive
|
|
59
|
+
assert state["raised"] is True
|
|
60
|
+
metrics.increment_disconnects.assert_any_call(reason="error")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestWatchTaskCleanup:
|
|
64
|
+
@mark.asyncio
|
|
65
|
+
async def test_deferred_tasks_cancelled_on_close(self, client: HomeConnectClient, monkeypatch):
|
|
66
|
+
"""Pending deferred fetches are cancelled (and untracked) when watching stops."""
|
|
67
|
+
appliance = HomeConnectAppliance(client, "SIEMENS-TEST-AB1234567890", "Washer")
|
|
68
|
+
appliance._last_update["status"] = time() # throttle active -> the fetch is deferred, not run
|
|
69
|
+
scheduled = Event()
|
|
70
|
+
|
|
71
|
+
async def never_fetch(): # pragma: no cover - deferred task is cancelled before this runs
|
|
72
|
+
return HomeConnectEvent(event="STATUS-REQUEST", timestamp=0.0)
|
|
73
|
+
|
|
74
|
+
async def fake_handle_trigger(trigger, queue):
|
|
75
|
+
await client._fetch_or_defer(appliance, "status", never_fetch, throttled=True, queue=queue)
|
|
76
|
+
scheduled.set()
|
|
77
|
+
|
|
78
|
+
async def fake_event_stream(appliance_id=None):
|
|
79
|
+
yield HomeConnectEvent(event="KEEP-ALIVE", timestamp=0.0)
|
|
80
|
+
await Event().wait() # keep the stream open so the deferred task stays pending
|
|
81
|
+
|
|
82
|
+
async def noop_initial(queue, appliance_id):
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(client, "_initial_triggers", noop_initial)
|
|
86
|
+
monkeypatch.setattr(client, "_event_stream", fake_event_stream)
|
|
87
|
+
monkeypatch.setattr(client, "_handle_trigger", fake_handle_trigger)
|
|
88
|
+
|
|
89
|
+
before = set(client._deferred_tasks)
|
|
90
|
+
gen = client.watch(reconnect_delay=0)
|
|
91
|
+
await gen.__anext__() # receive the KEEP-ALIVE event
|
|
92
|
+
await scheduled.wait() # ensure the deferred fetch has been scheduled
|
|
93
|
+
|
|
94
|
+
new_tasks = client._deferred_tasks - before
|
|
95
|
+
assert len(new_tasks) == 1
|
|
96
|
+
task = new_tasks.pop()
|
|
97
|
+
assert not task.done()
|
|
98
|
+
|
|
99
|
+
await gen.aclose() # runs watch()'s finally: cancels the producer and deferred tasks
|
|
100
|
+
|
|
101
|
+
with raises(CancelledError):
|
|
102
|
+
await task
|
|
103
|
+
await sleep(0) # let the done-callback drain the task from the set
|
|
104
|
+
assert task.cancelled()
|
|
105
|
+
assert task not in client._deferred_tasks
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# file generated by setuptools_scm
|
|
2
|
-
# don't change, don't track in version control
|
|
3
|
-
TYPE_CHECKING = False
|
|
4
|
-
if TYPE_CHECKING:
|
|
5
|
-
from typing import Tuple, Union
|
|
6
|
-
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
-
else:
|
|
8
|
-
VERSION_TUPLE = object
|
|
9
|
-
|
|
10
|
-
version: str
|
|
11
|
-
__version__: str
|
|
12
|
-
__version_tuple__: VERSION_TUPLE
|
|
13
|
-
version_tuple: VERSION_TUPLE
|
|
14
|
-
|
|
15
|
-
__version__ = version = '0.0.12.dev3'
|
|
16
|
-
__version_tuple__ = version_tuple = (0, 0, 12, 'dev3')
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.github/workflows/ci.yaml
RENAMED
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.github/workflows/publish.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/api.py
RENAMED
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/cli.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/event.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/read.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/client/test_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/test_db_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/conftest.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/exporter/test_file.py
RENAMED
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/exporter/test_postgres.py
RENAMED
|
File without changes
|
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/utils/test_retry.py
RENAMED
|
File without changes
|
{homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/utils/test_timeout.py
RENAMED
|
File without changes
|