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.
Files changed (61) hide show
  1. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/PKG-INFO +12 -3
  2. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/README.md +9 -1
  3. homeconnect_watcher-0.0.14.dev1/src/homeconnect_watcher/_version.py +24 -0
  4. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/client/appliance.py +4 -0
  5. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/client/client.py +103 -45
  6. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/metrics.py +1 -0
  7. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/PKG-INFO +12 -3
  8. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/SOURCES.txt +3 -0
  9. homeconnect_watcher-0.0.14.dev1/src/homeconnect_watcher.egg-info/scm_file_list.json +53 -0
  10. homeconnect_watcher-0.0.14.dev1/src/homeconnect_watcher.egg-info/scm_version.json +8 -0
  11. homeconnect_watcher-0.0.14.dev1/tests/client/test_watch.py +105 -0
  12. homeconnect_watcher-0.0.12.dev3/src/homeconnect_watcher/_version.py +0 -16
  13. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.github/workflows/ci.yaml +0 -0
  14. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.github/workflows/publish.yaml +0 -0
  15. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.gitignore +0 -0
  16. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/.pre-commit-config.yaml +0 -0
  17. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/LICENSE +0 -0
  18. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/pyproject.toml +0 -0
  19. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/setup.cfg +0 -0
  20. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/__init__.py +0 -0
  21. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/api.py +0 -0
  22. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/cli.py +0 -0
  23. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/client/__init__.py +0 -0
  24. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/__init__.py +0 -0
  25. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/client.py +0 -0
  26. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/utils.py +0 -0
  27. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/db/view.py +0 -0
  28. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/event.py +0 -0
  29. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exceptions.py +0 -0
  30. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/__init__.py +0 -0
  31. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/base.py +0 -0
  32. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/file.py +0 -0
  33. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/exporter/postgres.py +0 -0
  34. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/read.py +0 -0
  35. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/0_appliances.sql +0 -0
  36. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/1_events.sql +0 -0
  37. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/2_raw_events_active.sql +0 -0
  38. {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
  39. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/sql/4_sessions.sql +0 -0
  40. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/trigger.py +0 -0
  41. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/__init__.py +0 -0
  42. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/logging.py +0 -0
  43. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/retry.py +0 -0
  44. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher/utils/timeout.py +0 -0
  45. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/dependency_links.txt +0 -0
  46. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/entry_points.txt +0 -0
  47. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/requires.txt +0 -0
  48. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/src/homeconnect_watcher.egg-info/top_level.txt +0 -0
  49. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/client/test_client.py +0 -0
  50. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/conftest.py +0 -0
  51. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/test_db_client.py +0 -0
  52. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/test_views.py +0 -0
  53. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/conftest.py +0 -0
  54. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/test_view_appliances.py +0 -0
  55. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/test_view_events.py +0 -0
  56. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/db/views/test_view_session.py +0 -0
  57. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/exporter/test_file.py +0 -0
  58. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/exporter/test_postgres.py +0 -0
  59. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/test_event.py +0 -0
  60. {homeconnect_watcher-0.0.12.dev3 → homeconnect_watcher-0.0.14.dev1}/tests/utils/test_retry.py +0 -0
  61. {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
1
+ Metadata-Version: 2.4
2
2
  Name: homeconnect-watcher
3
- Version: 0.0.12.dev3
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
  ![PyPI - License](https://img.shields.io/pypi/l/homeconnect-watcher)
36
37
  ![PyPI - Downloads](https://img.shields.io/pypi/dm/homeconnect-watcher)
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
  ![PyPI - License](https://img.shields.io/pypi/l/homeconnect-watcher)
8
8
  ![PyPI - Downloads](https://img.shields.io/pypi/dm/homeconnect-watcher)
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 yield all events. In addition, processes triggers of these events
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
- Automatically reconnects when disconnected or on timeout.
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
- async for event in self._initial_triggers(appliance_id=appliance_id):
100
- yield event
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
- async for resulting_event in self._handle_event_and_triggers(event=event):
105
- yield resulting_event
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 _handle_event_and_triggers(self, event: HomeConnectEvent) -> AsyncIterable[HomeConnectEvent]:
188
+ async def _initial_triggers(self, queue: "Queue[HomeConnectEvent]", appliance_id: str | None) -> None:
155
189
  """
156
- Handle triggers for an event and increase all corresponding counters.
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. Otherwise do so
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
- async for triggered_event in self._handle_trigger(
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
- yield triggered_event
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) -> AsyncIterable[HomeConnectEvent]:
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
- if trigger.status:
196
- if not trigger.interval or appliance.time_since_update("status") >= 300:
197
- yield await appliance.get_status()
198
- if trigger.settings:
199
- if not trigger.interval or appliance.time_since_update("settings") >= 300:
200
- yield await appliance.get_settings()
201
- if await appliance.get_available_programs(): # Only if the appliance supports programs.
202
- if trigger.active_program:
203
- if not trigger.interval or appliance.time_since_update("active_program") >= 300:
204
- yield await appliance.get_active_program()
205
- if trigger.selected_program:
206
- if not trigger.interval or appliance.time_since_update("selected_program") >= 300:
207
- yield await appliance.get_selected_program()
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
1
+ Metadata-Version: 2.4
2
2
  Name: homeconnect-watcher
3
- Version: 0.0.12.dev3
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
  ![PyPI - License](https://img.shields.io/pypi/l/homeconnect-watcher)
36
37
  ![PyPI - Downloads](https://img.shields.io/pypi/dm/homeconnect-watcher)
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,8 @@
1
+ {
2
+ "tag": "0.0.14.dev1",
3
+ "distance": 0,
4
+ "node": "gaa9ced8929b726f3b6cd854dacc7fa4323c5e909",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-03"
8
+ }
@@ -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')