weishaupt-webif-api 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,24 @@
1
+ """Asynchronous API for Weishaupt WebIF (Heat Pumps)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .api import WebifConnection
6
+ from .const import EXPECTED_COUNTS, ColoredFormatter, Info
7
+ from .exceptions import (
8
+ ConnectionTimeoutError,
9
+ McuResourceError,
10
+ SessionExpiredError,
11
+ WeishauptWebifError,
12
+ )
13
+
14
+ __all__ = [
15
+ "EXPECTED_COUNTS",
16
+ "ColoredFormatter",
17
+ "ConnectionTimeoutError",
18
+ "Info",
19
+ "McuResourceError",
20
+ "SessionExpiredError",
21
+ "WebifConnection",
22
+ "WeishauptWebifError",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,158 @@
1
+ """Command line interface for the Weishaupt WebIF API."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from .api import WebifConnection
11
+ from .const import ColoredFormatter
12
+
13
+
14
+ async def _main(args: argparse.Namespace) -> None:
15
+ storage_path = Path(args.path) if args.path else Path.cwd()
16
+
17
+ # Configure logging for the console
18
+ logger = logging.getLogger("weishaupt_webif_api")
19
+ log_level = logging.DEBUG if args.debug else logging.INFO
20
+ logger.setLevel(log_level)
21
+
22
+ # Console Handler (Colored)
23
+ sh = logging.StreamHandler()
24
+ sh.setFormatter(ColoredFormatter())
25
+ logger.addHandler(sh)
26
+
27
+ # File Handler (Persistent log)
28
+ log_file = storage_path / "weishaupt_webif_api.log"
29
+ fh = logging.FileHandler(str(log_file))
30
+ fh.setFormatter(
31
+ logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"),
32
+ )
33
+ logger.addHandler(fh)
34
+
35
+ logger.info("Starting Weishaupt WebIF API monitor for %s...", args.ip)
36
+ async with WebifConnection(
37
+ args.ip,
38
+ args.user,
39
+ args.password,
40
+ request_delay=args.request_delay,
41
+ cooldown_delay=args.cooldown_delay,
42
+ storage_path=storage_path,
43
+ ) as con:
44
+ try:
45
+ while True:
46
+ try:
47
+ # Fetch all data
48
+ if args.mock:
49
+ data = await con.update_all_mock()
50
+ else:
51
+ data = await con.update_all()
52
+ summary = ", ".join(
53
+ [f"[{cat}]: {len(vals)}" for cat, vals in data.items()],
54
+ )
55
+ logger.info(
56
+ "Update Success: %s. Next check in %ds.",
57
+ summary,
58
+ args.interval,
59
+ )
60
+ await asyncio.sleep(args.interval)
61
+ except (asyncio.CancelledError, KeyboardInterrupt):
62
+ raise
63
+ except Exception as err: # noqa: BLE001
64
+ logger.error( # noqa: TRY400
65
+ "Update failed: %s. Retrying in %ds...",
66
+ err,
67
+ args.retry_interval,
68
+ )
69
+ await asyncio.sleep(args.retry_interval)
70
+ except (asyncio.CancelledError, KeyboardInterrupt):
71
+ # Graceful exit on Ctrl+C
72
+ pass
73
+ except Exception as err: # noqa: BLE001
74
+ logger.critical("Monitor encountered an error: %s", err)
75
+ finally:
76
+ logger.info("--- Final Communication Statistics ---")
77
+ for key, value in con.stats.items():
78
+ logger.info("%-20s: %s", key, value)
79
+
80
+
81
+ def main() -> None:
82
+ """Sync wrapper for the entry point."""
83
+ parser = argparse.ArgumentParser(
84
+ description="Weishaupt WebIF API CLI",
85
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
86
+ )
87
+ parser.add_argument(
88
+ "ip",
89
+ nargs="?" if os.environ.get("WEISHAUPT_IP") else None,
90
+ default=os.environ.get("WEISHAUPT_IP"),
91
+ help="IP address of the Weishaupt module (env: WEISHAUPT_IP)",
92
+ )
93
+ parser.add_argument(
94
+ "user",
95
+ nargs="?" if os.environ.get("WEISHAUPT_USER") else None,
96
+ default=os.environ.get("WEISHAUPT_USER"),
97
+ help="Login username (env: WEISHAUPT_USER)",
98
+ )
99
+ parser.add_argument(
100
+ "password",
101
+ nargs="?" if os.environ.get("WEISHAUPT_PASSWORD") else None,
102
+ default=os.environ.get("WEISHAUPT_PASSWORD"),
103
+ help="Login password (env: WEISHAUPT_PASSWORD)",
104
+ )
105
+ parser.add_argument(
106
+ "--path",
107
+ help="Directory to store logs and state files (defaults to execution folder)",
108
+ default=None,
109
+ )
110
+ parser.add_argument(
111
+ "--interval",
112
+ type=int,
113
+ help="Polling interval in seconds (default: 300)",
114
+ default=300,
115
+ )
116
+ parser.add_argument(
117
+ "--retry-interval",
118
+ type=int,
119
+ help="Retry interval after failure in seconds",
120
+ default=60,
121
+ )
122
+ parser.add_argument(
123
+ "--request-delay",
124
+ type=float,
125
+ help="Internal breather delay between requests in seconds",
126
+ default=60.0,
127
+ )
128
+ parser.add_argument(
129
+ "--cooldown-delay",
130
+ type=float,
131
+ help="Internal cooldown penalty after failure in seconds",
132
+ default=300.0,
133
+ )
134
+ parser.add_argument(
135
+ "--debug",
136
+ action="store_true",
137
+ help="Enable debug logging",
138
+ )
139
+ parser.add_argument(
140
+ "--mock",
141
+ action="store_true",
142
+ help="Use mock data for development without polling the device",
143
+ )
144
+
145
+ args = parser.parse_args()
146
+
147
+ if not all([args.ip, args.user, args.password]):
148
+ parser.error(
149
+ "IP, user, and password must be provided via arguments or "
150
+ "environment variables.",
151
+ )
152
+
153
+ with contextlib.suppress(KeyboardInterrupt):
154
+ asyncio.run(_main(args))
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1,545 @@
1
+ """Main API module for Weishaupt WebIF communication."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ import types
8
+ from http.cookiejar import LWPCookieJar
9
+ from pathlib import Path
10
+ from typing import Any, Self
11
+
12
+ import httpx
13
+ from bs4 import BeautifulSoup
14
+ from bs4.element import Tag
15
+
16
+ from .const import EXPECTED_COUNTS, UNITS, Info
17
+ from .exceptions import (
18
+ ConnectionTimeoutError,
19
+ McuResourceError,
20
+ SessionExpiredError,
21
+ WeishauptWebifError,
22
+ )
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+ MAX_STATS_REQS = 100
27
+ HTTP_OK = 200
28
+
29
+
30
+ class WebifConnection:
31
+ """A connection handler for the Weishaupt WebIF module.
32
+
33
+ This class manages an asynchronous HTTP session, handles authentication
34
+ via cookies, and provides methods to fetch and parse heat pump data.
35
+ It includes built-in rate-limiting and error recovery logic to avoid
36
+ overwhelming the heat pump microcontroller.
37
+ """
38
+
39
+ def __init__( # noqa: PLR0913
40
+ self,
41
+ ip: str,
42
+ user: str,
43
+ password: str,
44
+ *,
45
+ request_delay: float = 60.0,
46
+ cooldown_delay: float = 300.0,
47
+ storage_path: str | Path | None = None,
48
+ ) -> None:
49
+ """Initialize the connection.
50
+
51
+ :param ip: The IP address of the heat pump module.
52
+ :param user: The username for login.
53
+ :param password: The password for login.
54
+ :param storage_path: Optional path for storing cookies and state files.
55
+ :param request_delay: Breather delay between requests in seconds.
56
+ :param cooldown_delay: Cooldown penalty after failure in seconds.
57
+ """
58
+ self._ip = ip
59
+ self._username = user
60
+ self._password = password
61
+ self._client: httpx.AsyncClient | None = None
62
+ self._storage_path = Path(storage_path) if storage_path else Path.cwd()
63
+ self._storage_path.mkdir(parents=True, exist_ok=True)
64
+
65
+ self._state_file = self._storage_path / "lwp_state.json"
66
+ self._request_lock = asyncio.Lock()
67
+ self._request_delay = request_delay
68
+ self._cooldown_delay = cooldown_delay
69
+ self._last_request_time = 0.0 # Monotonic
70
+ self._cooldown_until = 0.0 # Monotonic
71
+ self._base_url = f"http://{self._ip}"
72
+ self._values: dict[str, dict[str, dict[str, Any]]] = {}
73
+ self._stats = {
74
+ "requests": 0,
75
+ "successes": 0,
76
+ "integrity_failures": 0,
77
+ "timeouts": 0,
78
+ "cooldowns": 0,
79
+ "max_duration": 0.0,
80
+ }
81
+
82
+ self._load_state()
83
+
84
+ def _load_state(self) -> None:
85
+ """Load persisted timers from the state file."""
86
+ try:
87
+ with self._state_file.open(encoding="utf-8") as f:
88
+ state = json.load(f)
89
+ # Recover cooldown relative to current wall clock
90
+ expiry = state.get("cooldown_expiry", 0.0)
91
+ remaining = expiry - time.time()
92
+ if remaining > 0:
93
+ self._cooldown_until = time.monotonic() + remaining
94
+ else:
95
+ self._cooldown_until = 0.0
96
+ except (FileNotFoundError, json.JSONDecodeError):
97
+ self._cooldown_until = 0.0
98
+
99
+ def _save_state(self) -> None:
100
+ """Persist current timers to the state file."""
101
+ # Calculate wall-clock expiry for persistence
102
+ remaining = max(0, self._cooldown_until - time.monotonic())
103
+ state = {"cooldown_expiry": time.time() + remaining if remaining > 0 else 0.0}
104
+ with self._state_file.open("w", encoding="utf-8") as f:
105
+ json.dump(state, f)
106
+
107
+ def _get_client(self) -> httpx.AsyncClient:
108
+ """Initialize or return the existing httpx AsyncClient.
109
+
110
+ Sets up default headers and loads cookies from the local lwp_cookies.txt file
111
+ to maintain session state between script restarts.
112
+
113
+ :return: An active httpx.AsyncClient instance.
114
+ """
115
+ if self._client is None:
116
+ headers = {
117
+ "User-Agent": "Mozilla/5.0 Weishaupt-Webif-API/0.1.0",
118
+ "Accept": (
119
+ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
120
+ ),
121
+ "Content-Type": "application/x-www-form-urlencoded",
122
+ "Origin": self._base_url,
123
+ "Referer": f"{self._base_url}/login.html",
124
+ "Connection": "close",
125
+ }
126
+ cookie_file = self._storage_path / "lwp_cookies.txt"
127
+ cookies = LWPCookieJar(filename=str(cookie_file))
128
+ try:
129
+ cookies.load(ignore_discard=True)
130
+ except FileNotFoundError:
131
+ _LOGGER.debug("Starting with empty cookie jar.")
132
+
133
+ self._client = httpx.AsyncClient(
134
+ base_url=self._base_url,
135
+ headers=headers,
136
+ follow_redirects=True,
137
+ timeout=httpx.Timeout(60.0, connect=20.0),
138
+ limits=httpx.Limits(max_connections=1, max_keepalive_connections=0),
139
+ cookies=cookies,
140
+ )
141
+ return self._client
142
+
143
+ async def __aenter__(self) -> Self:
144
+ """Support async context manager."""
145
+ return self
146
+
147
+ async def __aexit__(
148
+ self,
149
+ exc_type: type[BaseException] | None,
150
+ exc_val: BaseException | None,
151
+ exc_tb: types.TracebackType | None,
152
+ ) -> None:
153
+ """Close client on exit."""
154
+ await self.close()
155
+
156
+ def _check_and_reset_stats(self, *, force: bool = False) -> None:
157
+ """Check if request count reached threshold, log and reset stats."""
158
+ req_count = self._stats["requests"]
159
+ if req_count >= MAX_STATS_REQS or (force and req_count > 0):
160
+ period = "final" if force else f"last {MAX_STATS_REQS} requests"
161
+ _LOGGER.info("Communication Statistics (%s): %s", period, self._stats)
162
+ # Update in place to maintain reference integrity
163
+ self._stats.update(
164
+ {
165
+ "requests": 0,
166
+ "successes": 0,
167
+ "integrity_failures": 0,
168
+ "timeouts": 0,
169
+ "cooldowns": 0,
170
+ "max_duration": 0.0,
171
+ },
172
+ )
173
+
174
+ @property
175
+ def stats(self) -> dict[str, int | float]:
176
+ """Return communication statistics."""
177
+ return self._stats
178
+
179
+ async def close(self) -> None:
180
+ """Gracefully close the underlying HTTP client."""
181
+ self._check_and_reset_stats(force=True)
182
+ if self._client and not self._client.is_closed:
183
+ await self._client.aclose()
184
+ self._client = None
185
+
186
+ async def _request(
187
+ self,
188
+ method: str,
189
+ url: str,
190
+ **kwargs: Any, # noqa: ANN401
191
+ ) -> httpx.Response:
192
+ """Perform a serialized HTTP request.
193
+
194
+ Ensures that only one request is active at a time and enforces cooldown
195
+ or breather delays before execution.
196
+
197
+ :param method: HTTP method (e.g., 'GET', 'POST').
198
+ :param url: The relative or absolute URL to request.
199
+ :return: The httpx.Response object.
200
+ """
201
+ async with self._request_lock:
202
+ now = time.monotonic()
203
+ if now < self._cooldown_until:
204
+ self._stats["cooldowns"] += 1
205
+ wait_time = self._cooldown_until - now
206
+ _LOGGER.warning("In COOLDOWN period. Waiting %.1fs", wait_time)
207
+ await asyncio.sleep(wait_time)
208
+ now = time.monotonic()
209
+
210
+ elapsed = now - self._last_request_time
211
+ if elapsed < self._request_delay:
212
+ time_to_wait = self._request_delay - elapsed
213
+ _LOGGER.debug("Breather delay: waiting %.1fs", time_to_wait)
214
+ await asyncio.sleep(time_to_wait)
215
+ now = time.monotonic()
216
+
217
+ return await self._do_request(method, url, **kwargs)
218
+
219
+ async def _do_request(
220
+ self,
221
+ method: str,
222
+ url: str,
223
+ **kwargs: Any, # noqa: ANN401
224
+ ) -> httpx.Response:
225
+ """Execute an HTTP request and handle session expiration."""
226
+ self._check_and_reset_stats()
227
+ client = self._get_client()
228
+ self._stats["requests"] += 1
229
+ start_time = time.monotonic()
230
+
231
+ try:
232
+ response = await client.request(method, url, **kwargs)
233
+ except httpx.ConnectTimeout as err:
234
+ self._stats["timeouts"] += 1
235
+ self._last_request_time = time.monotonic()
236
+ self._cooldown_until = self._last_request_time + self._cooldown_delay
237
+ self._save_state()
238
+ msg = f"Connect timeout to {url}"
239
+ raise ConnectionTimeoutError(msg) from err
240
+ except httpx.ConnectError as err:
241
+ self._last_request_time = time.monotonic()
242
+ self._cooldown_until = self._last_request_time + self._cooldown_delay
243
+ self._save_state()
244
+ msg = f"Connection refused at {url}"
245
+ raise WeishauptWebifError(msg) from err
246
+ except httpx.ReadTimeout as err:
247
+ self._stats["timeouts"] += 1
248
+ self._last_request_time = time.monotonic()
249
+ self._cooldown_until = self._last_request_time + self._cooldown_delay
250
+ self._save_state()
251
+ msg = f"Read timeout from {url}"
252
+ raise ConnectionTimeoutError(msg) from err
253
+ except Exception as err:
254
+ if isinstance(err, WeishauptWebifError):
255
+ raise
256
+ msg = f"Unexpected error: {err}"
257
+ raise WeishauptWebifError(msg) from err
258
+ else:
259
+ duration = time.monotonic() - start_time
260
+ self._stats["max_duration"] = max(self._stats["max_duration"], duration)
261
+ self._last_request_time = time.monotonic()
262
+ self._save_state()
263
+
264
+ if "login.html" in str(response.url) or 'name="pass"' in response.text:
265
+ return await self._handle_expired_session(method, url, **kwargs)
266
+
267
+ _LOGGER.debug(
268
+ "WEBIF %s %s -> %s",
269
+ method,
270
+ response.url,
271
+ response.status_code,
272
+ )
273
+ return response
274
+
275
+ async def _handle_expired_session(
276
+ self,
277
+ method: str,
278
+ url: str,
279
+ **kwargs: Any, # noqa: ANN401
280
+ ) -> httpx.Response:
281
+ """Handle re-authentication when session expires."""
282
+ if url != "/login.html":
283
+ _LOGGER.info("Session expired. Re-authenticating...")
284
+ await self._login()
285
+ # Retry original request after re-login
286
+ self._stats["requests"] += 1
287
+ start_retry = time.monotonic()
288
+ response = await self._get_client().request(method, url, **kwargs)
289
+ duration_retry = time.monotonic() - start_retry
290
+
291
+ self._stats["max_duration"] = max(
292
+ self._stats["max_duration"],
293
+ duration_retry,
294
+ )
295
+ self._last_request_time = time.monotonic()
296
+ self._save_state()
297
+ _LOGGER.debug(
298
+ "WEBIF %s %s -> %s (retry)",
299
+ method,
300
+ response.url,
301
+ response.status_code,
302
+ )
303
+ return response
304
+ msg = "Authentication failed: Redirected to login."
305
+ raise SessionExpiredError(msg)
306
+
307
+ async def _login(self) -> None:
308
+ """Perform the authentication process.
309
+
310
+ Sends a POST request with credentials and verifies the redirection URL.
311
+ Saves valid session cookies to the disk upon success.
312
+ """
313
+ client = self._get_client()
314
+ _LOGGER.info("Attempting login to Weishaupt WebIF...")
315
+
316
+ # Explicit direct request to avoid recursion through _do_request
317
+ self._stats["requests"] += 1
318
+ start_time = time.monotonic()
319
+
320
+ try:
321
+ response = await client.post(
322
+ "/login.html",
323
+ data={"user": self._username, "pass": self._password},
324
+ )
325
+ end_time = time.monotonic()
326
+ self._last_request_time = end_time
327
+ self._stats["max_duration"] = max(
328
+ self._stats["max_duration"],
329
+ end_time - start_time,
330
+ )
331
+ self._save_state()
332
+ except httpx.HTTPError as err:
333
+ # Errors during login are caught by the caller (_do_request or update_all)
334
+ msg = f"HTTP error during login: {err}"
335
+ raise WeishauptWebifError(msg) from err
336
+
337
+ final_url = str(response.url)
338
+ if "wrongpassword" in final_url:
339
+ msg = "Authentication failed: Invalid credentials."
340
+ raise WeishauptWebifError(msg)
341
+ if "nocon" in final_url:
342
+ msg = "MCU database connection failure."
343
+ raise McuResourceError(msg)
344
+ if "home.html" in final_url:
345
+ _LOGGER.info("✅ Successful Login!")
346
+ if self._client and isinstance(self._client.cookies.jar, LWPCookieJar):
347
+ self._client.cookies.jar.save(ignore_discard=True, ignore_expires=True)
348
+ else:
349
+ msg = "Login failed: Unexpected response."
350
+ raise WeishauptWebifError(msg)
351
+
352
+ async def update_all(
353
+ self,
354
+ categories: list[str] | None = None,
355
+ ) -> dict[str, dict[str, Any]]:
356
+ """Fetch and update data categories from the heat pump.
357
+
358
+ :param categories: List of categories to fetch (e.g., ['Statistik']).
359
+ :return: A dictionary containing the updated data grouped by category.
360
+ :raises ValueError: If an invalid category name is provided.
361
+ :raises McuResourceError: If data integrity check fails.
362
+ """
363
+ requested = self._validate_categories(categories)
364
+
365
+ info_header = "0C00000100000000008000F9AF010002000301"
366
+ self._values.setdefault("Info", {})
367
+
368
+ # Respect original safe batching structure but filter for requested items
369
+ safe_batches = [["Heizkreis", "2WEZ", "Statistik"], ["Waermepumpe"]]
370
+ active_batches = []
371
+ for batch in safe_batches:
372
+ filtered = [c for c in batch if c in requested]
373
+ if filtered:
374
+ active_batches.append(filtered)
375
+
376
+ for batch_cats in active_batches:
377
+ stack_string = f"{info_header}," + ",".join([Info[c] for c in batch_cats])
378
+ url = f"/settings_export.html?stack={stack_string}"
379
+ response = await self._request("GET", url)
380
+ if response.status_code != HTTP_OK:
381
+ msg = f"HTTP {response.status_code}"
382
+ raise WeishauptWebifError(msg)
383
+
384
+ soup = BeautifulSoup(markup=response.text, features="html.parser")
385
+ cols = soup.find_all("div", class_="col-3")
386
+ if len(cols) < (2 + len(batch_cats)):
387
+ self._cooldown_until = time.monotonic() + self._cooldown_delay
388
+ self._stats["integrity_failures"] += 1
389
+ self._save_state()
390
+ msg = "Incomplete HTML: missing columns"
391
+ raise McuResourceError(msg)
392
+
393
+ for i, category in enumerate(batch_cats):
394
+ section_data = self._get_values(cols[i + 2])
395
+ found, expected = (
396
+ len(section_data),
397
+ EXPECTED_COUNTS.get(category, 0),
398
+ )
399
+ _LOGGER.debug(
400
+ "Section [%s]: %d/%d entries",
401
+ category,
402
+ found,
403
+ expected,
404
+ )
405
+ if found != expected or found == 0:
406
+ self._stats["integrity_failures"] += 1
407
+ self._cooldown_until = time.monotonic() + self._cooldown_delay
408
+ self._save_state()
409
+ msg = f"Data integrity error in {category}"
410
+ raise McuResourceError(msg)
411
+ self._values["Info"][category] = section_data
412
+
413
+ self._stats["successes"] += 1
414
+
415
+ _LOGGER.debug("Update cycle successful.")
416
+ return self._values["Info"]
417
+
418
+ async def update_all_mock(
419
+ self,
420
+ categories: list[str] | None = None,
421
+ ) -> dict[str, dict[str, Any]]:
422
+ """Return realistic data from a provided sample without polling.
423
+
424
+ :param categories: List of categories to fetch (e.g., ['Statistik']).
425
+ :return: A dictionary containing the mock data grouped by category.
426
+ :raises ValueError: If an invalid category name is provided.
427
+ """
428
+ requested = self._validate_categories(categories)
429
+
430
+ _LOGGER.debug("Returning static mock data for development.")
431
+ mock_data = self._get_mock_data()
432
+ return {cat: mock_data.get(cat, {}) for cat in requested}
433
+
434
+ def _validate_categories(self, categories: list[str] | None) -> list[str]:
435
+ """Validate requested categories against available ones."""
436
+ valid_categories = list(Info.keys())
437
+ requested = categories if categories is not None else valid_categories
438
+
439
+ for cat in requested:
440
+ if cat not in valid_categories:
441
+ msg = f"Invalid category '{cat}'. Valid options: {valid_categories}"
442
+ raise ValueError(msg)
443
+ return requested
444
+
445
+ def _get_mock_data(self) -> dict[str, dict[str, Any]]:
446
+ """Return the real-world data sample provided for development purposes."""
447
+ return {
448
+ "Heizkreis": {
449
+ "Außentemperatur": "24.5",
450
+ "AT Mittelwert": "25.0",
451
+ "AT Langzeitwert": "25.5",
452
+ "Raumsolltemperatur": "16.0",
453
+ "Vorlaufsolltemperatur": "--",
454
+ "Vorlauftemperatur": "62.0",
455
+ },
456
+ "Waermepumpe": {
457
+ "Betrieb": "PV Optimierung",
458
+ "Störmeldung": "--",
459
+ "Warmwassertemperatur": "53.0",
460
+ "Leistungsanforderung": "100",
461
+ "Solltemperatur": "59.5",
462
+ "Anforderung": "4.3",
463
+ "Schaltdifferenz dynamisch": "5.0",
464
+ "Vorlauftemperatur": "63.0",
465
+ "Rücklauftemperatur": "57.5",
466
+ "Drehzahl Pumpe M1": "80",
467
+ "Volumenstrom": "1.7",
468
+ "Stellung Umschaltventil": "Warmwasser",
469
+ "Version WWP-SG": "V3.0",
470
+ "Version WWP-EC WBB": "V5.3",
471
+ "Soll Leistung": "9.9",
472
+ "Ist Leistung": "9.6",
473
+ "Expansionsventil AG Eintr": "46.0",
474
+ "Luftansaugtemperatur": "24.5",
475
+ "Wärmetauscher AG Austrit": "15.0",
476
+ "Verdichtersauggastemp.": "23.0",
477
+ "EVI Sauggastemperatur": "57.5",
478
+ "Kältemittel IG Austritt": "54.0",
479
+ "Ölsumpftemperatur": "41.5",
480
+ "Druckgastemperatur": "95.0",
481
+ "Niederdruck": "10.3",
482
+ "Verdampfungstemperatur": "11.5",
483
+ "Hochdruck": "39.1",
484
+ "Kondensationstemperatur": "62.0",
485
+ "Mitteldruck": "20.8",
486
+ "Sättigungstemperatur EVI": "36.0",
487
+ "Überhitzung Heizen": "3.0",
488
+ "Öffnungsgrad EXV Heizen": "22",
489
+ "Überhitzung Verdichter": "11.0",
490
+ "Öffnungsgrad EXV Kühlen": "0",
491
+ "Überhitzung EVI": "21.5",
492
+ "Öffnungsgrad EVI": "48",
493
+ "Betriebsstd. Verdichter": "7704",
494
+ "Schaltspiele Verdichter": "3712",
495
+ "Schaltspiele Abtauen": "1490",
496
+ "Verdichter": "5013",
497
+ "Außengerät Variante": "RMHA-10",
498
+ },
499
+ "2WEZ": {
500
+ "Status": "0",
501
+ "Status E-Heizung 1": "Aus",
502
+ "Status E-Heizung 2": "Aus",
503
+ "Betriebsstunden E1": "106",
504
+ "Betriebsstunden E2": "71",
505
+ "Schaltspiele E1": "58",
506
+ "Schaltspiele E2": "20",
507
+ },
508
+ "Statistik": {
509
+ "th. Energie Heizen Tag": "1.630h",
510
+ "th. Energie WW Tag": "5.498h",
511
+ "th. Energie gesamt Tag": "7.129h",
512
+ "elektrische Energie Tag": "1.882h",
513
+ "th. Energie Heizen Monat": "3.832h",
514
+ "th. Energie WW Monat": "42.393h",
515
+ "th. Energie gesamt Monat": "46.226h",
516
+ "elektrische Energie Monat": "14.420h",
517
+ "th. Energie Heizen Jahr": "8474.162h",
518
+ "th. Energie WW Jahr": "1594.194h",
519
+ "th. Energie gesamt Jahr": "10068.356h",
520
+ "elektrische Energie Jahr": "3157.207h",
521
+ },
522
+ }
523
+
524
+ def _get_values(self, soup: Tag) -> dict[str, Any]:
525
+ """Parse parameter names and values from a specific HTML column.
526
+
527
+ This method strips known units (like °C or KWh) from the values
528
+ to return clean data.
529
+
530
+ :param soup: A BeautifulSoup Tag representing an HTML 'col-3' div.
531
+ :return: A dictionary of parameter names and their cleaned values.
532
+ """
533
+ soup_links = soup.find_all(name="div", class_="nav-link browseobj")
534
+ values: dict[str, Any] = {}
535
+ for item in soup_links:
536
+ if not isinstance(item, Tag):
537
+ continue
538
+ h5_tag = item.find("h5")
539
+ if h5_tag and h5_tag.text:
540
+ name = h5_tag.text.strip()
541
+ value = "".join(item.find_all(string=True, recursive=False))
542
+ for unit in UNITS:
543
+ value = value.replace(unit, "").strip()
544
+ values[name] = value
545
+ return values
@@ -0,0 +1,50 @@
1
+ """Constants and formatters for the Weishaupt WebIF API."""
2
+
3
+ import logging
4
+ from typing import ClassVar
5
+
6
+
7
+ class ColoredFormatter(logging.Formatter):
8
+ """Custom logging formatter that applies ANSI color codes to output.
9
+
10
+ Designed to improve visibility of log levels in terminal environments.
11
+ """
12
+
13
+ grey = "\x1b[38;21m"
14
+ blue = "\x1b[34m"
15
+ yellow = "\x1b[33m"
16
+ red = "\x1b[31m"
17
+ bold_red = "\x1b[31;1m"
18
+ reset = "\x1b[0m"
19
+ format_str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
20
+
21
+ FORMATS: ClassVar[dict[int, str]] = {
22
+ logging.DEBUG: grey + format_str + reset,
23
+ logging.INFO: blue + format_str + reset,
24
+ logging.WARNING: yellow + format_str + reset,
25
+ logging.ERROR: red + format_str + reset,
26
+ logging.CRITICAL: bold_red + format_str + reset,
27
+ }
28
+
29
+ def format(self, record: logging.LogRecord) -> str:
30
+ """Format the log record with ANSI color codes."""
31
+ log_fmt = self.FORMATS.get(record.levelno)
32
+ formatter = logging.Formatter(log_fmt)
33
+ return formatter.format(record)
34
+
35
+
36
+ UNITS = ["m3/h", " KWh", " KW", " °C", " BAR", " rpm", " %", " K", " h"]
37
+
38
+ Info = {
39
+ "Heizkreis": "0C000C1900000000000000F9AF020003000401",
40
+ "Waermepumpe": "0C000C2200000000000000F9AF020003000401",
41
+ "2WEZ": "0C000C2300000000000000F9AF020003000401",
42
+ "Statistik": "0C000C2700000000000000F9AF020003000401",
43
+ }
44
+
45
+ EXPECTED_COUNTS = {
46
+ "Heizkreis": 6,
47
+ "Waermepumpe": 41,
48
+ "2WEZ": 7,
49
+ "Statistik": 12,
50
+ }
@@ -0,0 +1,17 @@
1
+ """Exception definitions for the Weishaupt WebIF API."""
2
+
3
+
4
+ class WeishauptWebifError(Exception):
5
+ """Base class for all library errors."""
6
+
7
+
8
+ class McuResourceError(WeishauptWebifError):
9
+ """Raised when the MCU is under memory pressure or rendering fails."""
10
+
11
+
12
+ class SessionExpiredError(WeishauptWebifError):
13
+ """Raised when the session cookie is no longer valid."""
14
+
15
+
16
+ class ConnectionTimeoutError(WeishauptWebifError):
17
+ """Raised when a request to the web server times out."""
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: weishaupt-webif-api
3
+ Version: 0.1.0
4
+ Summary: Async Python library for the Weishaupt WebIF heat pump interface.
5
+ Author-email: Mad_One <madone85@googlemail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/MadOne/weishaupt_webif_api
8
+ Project-URL: Repository, https://github.com/MadOne/weishaupt_webif_api
9
+ Project-URL: Issues, https://github.com/MadOne/weishaupt_webif_api/issues
10
+ Keywords: weishaupt,heatpump,heat-pump,home-assistant,hvac,webif
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Home Automation
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: httpx>=0.24
23
+ Requires-Dist: beautifulsoup4>=4.12
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7.0; extra == "test"
26
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "test"
27
+ Requires-Dist: respx>=0.20.0; extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # Weishaupt WebIF API
31
+
32
+ A Python library for interacting asynchronously with the Weishaupt heating system web interface (WebIF). This library allows you to programmatically retrieve telemetry data and system statistics.
33
+
34
+ ## Features
35
+
36
+ - **Asynchronous Design**: Built on `httpx` and `asyncio` for non-blocking I/O.
37
+ - **Selective Updates**: Fetch only the categories you need (e.g., "Statistik", "Heizkreis") to minimize load on the device's MCU.
38
+ - **Persistence**: Automatically manages session cookies and internal state (cooldowns, request timing) across restarts using local storage.
39
+ - **Smart Batching**: Includes logic to batch requests safely, preventing resource exhaustion on the hardware interface.
40
+ - **Robust Error Handling**: Detects session expiration, login redirections, and MCU resource errors.
41
+ - **Mock Mode**: Access real-world sample data without polling the device—perfect for developing Home Assistant integrations.
42
+ - **CLI Monitor**: A built-in command-line tool to monitor your heat pump telemetry in real-time.
43
+
44
+ ## Installation
45
+
46
+ Currently, this library can be installed locally:
47
+
48
+ ```bash
49
+ pip install .
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ```python
55
+ import asyncio
56
+ from weishaupt_webif_api import WebifConnection
57
+
58
+ async def main():
59
+ # Initialize the connection
60
+ async with WebifConnection(
61
+ ip="10.10.1.225",
62
+ username="user",
63
+ password="pass",
64
+ storage_path="./data"
65
+ ) as api:
66
+ # Fetch specific categories
67
+ data = await api.update_all(["Statistik", "Heizkreis"])
68
+
69
+ # Fetch mockup data for developement puposes. This is no real data and wont change!
70
+ mcok_data = await api.update_all_mock(["Statistik", "Heizkreis"])
71
+
72
+ for category, values in data.items():
73
+ print(f"--- {category} ---")
74
+ for key, value in values.items():
75
+ print(f"{key}: {value}")
76
+
77
+ if __name__ == "__main__":
78
+ asyncio.run(main())
79
+ ```
80
+
81
+
82
+
83
+ ## Configuration & State
84
+
85
+ The library creates several files in the `storage_path` to maintain state:
86
+
87
+ - `lwp_cookies.txt`: Stores session cookies to avoid redundant logins.
88
+ - `lwp_state.json`: Stores internal metadata, such as request cooldown timers.
89
+ - `weishaupt_webif_api.log`: Contains diagnostic logs for troubleshooting.
90
+
91
+ These files are excluded from version control by default via `.gitignore`.
92
+
93
+ ## Development
94
+
95
+ ### Running Tests
96
+
97
+ The project uses `pytest` with `pytest-asyncio` and `respx` for mocking API responses.
98
+
99
+ ```bash
100
+ pytest
101
+ ```
102
+
103
+ Tests utilize HTML fixtures located in `tests/fixtures/` to simulate real device responses without requiring access to physical hardware.
104
+
105
+ ## License
106
+
107
+ This project is licensed under the MIT License - see the LICENSE file for details.
108
+
109
+ ## Disclaimer
110
+
111
+ *This project is not affiliated with or endorsed by Weishaupt. Use it at your own risk. Frequent polling can put significant stress on the device's web interface.*
@@ -0,0 +1,11 @@
1
+ weishaupt_webif_api/__init__.py,sha256=7Mx1sZKivVrXuEx6X2PWsr_t_oGA6w5CkZUi5A5RzEk,525
2
+ weishaupt_webif_api/__main__.py,sha256=uqWyGwLg25KGbgJJn_VhhntxkyrnMHHBIOU9xNvhhqk,5049
3
+ weishaupt_webif_api/api.py,sha256=94SLSUeASAMqpJMm2g3HBwK0oaQJbH7Dv9D4wC4Qo5Y,21555
4
+ weishaupt_webif_api/const.py,sha256=K5KBJKwNI0tn42-VDSOoarQreeStbrdDVFtJFFF9Uo0,1503
5
+ weishaupt_webif_api/exceptions.py,sha256=O4RqJDB56nffgIIvWi7o7mPHp9YEtUVrUdiGV0LNKrg,489
6
+ weishaupt_webif_api-0.1.0.dist-info/licenses/LICENSE,sha256=KNAADYhXKAIGySYjfCVq6P4ZDhIUFfHxeZFYa3-32ec,1055
7
+ weishaupt_webif_api-0.1.0.dist-info/METADATA,sha256=hj4_uFJXwJ0k20xBScoGCpcbMVnTEj4lx6s6onwREyU,4103
8
+ weishaupt_webif_api-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ weishaupt_webif_api-0.1.0.dist-info/entry_points.txt,sha256=muqMrVTiRAZShxx1hI-kbZY5aIqX2yc3r5dvSCwtu_o,68
10
+ weishaupt_webif_api-0.1.0.dist-info/top_level.txt,sha256=Z571xeG36iafUuE_yPT_-skRoUDy0gxa9kXLYrY6yz4,20
11
+ weishaupt_webif_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ weishaupt-api = weishaupt_webif_api.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ weishaupt_webif_api