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.
- weishaupt_webif_api/__init__.py +24 -0
- weishaupt_webif_api/__main__.py +158 -0
- weishaupt_webif_api/api.py +545 -0
- weishaupt_webif_api/const.py +50 -0
- weishaupt_webif_api/exceptions.py +17 -0
- weishaupt_webif_api-0.1.0.dist-info/METADATA +111 -0
- weishaupt_webif_api-0.1.0.dist-info/RECORD +11 -0
- weishaupt_webif_api-0.1.0.dist-info/WHEEL +5 -0
- weishaupt_webif_api-0.1.0.dist-info/entry_points.txt +2 -0
- weishaupt_webif_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- weishaupt_webif_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|