pywmswebcontrolpro 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.
- pywmswebcontrolpro/__init__.py +0 -0
- pywmswebcontrolpro/action.py +226 -0
- pywmswebcontrolpro/const.py +81 -0
- pywmswebcontrolpro/destination.py +250 -0
- pywmswebcontrolpro/refresh.py +95 -0
- pywmswebcontrolpro/room.py +55 -0
- pywmswebcontrolpro/scene.py +55 -0
- pywmswebcontrolpro/store.py +104 -0
- pywmswebcontrolpro/travel.py +241 -0
- pywmswebcontrolpro/webcontrol.py +218 -0
- pywmswebcontrolpro-0.1.0.dist-info/METADATA +79 -0
- pywmswebcontrolpro-0.1.0.dist-info/RECORD +14 -0
- pywmswebcontrolpro-0.1.0.dist-info/WHEEL +4 -0
- pywmswebcontrolpro-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .room import Room
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Scene:
|
|
7
|
+
def __init__(self, control, id: int, names: list):
|
|
8
|
+
self._control = control
|
|
9
|
+
self._id = id
|
|
10
|
+
self._names = names
|
|
11
|
+
|
|
12
|
+
def __str__(self) -> str:
|
|
13
|
+
return self.name
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return f"<Scene {self.id}: {self}>"
|
|
17
|
+
|
|
18
|
+
def __eq__(self, other) -> bool:
|
|
19
|
+
return self.id == other.id
|
|
20
|
+
|
|
21
|
+
def __hash__(self) -> int:
|
|
22
|
+
return hash(self.id)
|
|
23
|
+
|
|
24
|
+
# --- Properties ---
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def host(self) -> str:
|
|
28
|
+
return self._control.host
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def id(self) -> int:
|
|
32
|
+
return self._id
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
return self._names[0]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def room(self) -> Room | None:
|
|
40
|
+
for room in self._control.rooms.values():
|
|
41
|
+
if self._id in room._scene_ids:
|
|
42
|
+
return room
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# --- Public methods ---
|
|
46
|
+
|
|
47
|
+
async def __call__(self, **kwargs) -> Any:
|
|
48
|
+
return await self._control._sceneActions(self._id, **kwargs)
|
|
49
|
+
|
|
50
|
+
def diag(self) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"id": self.id,
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"room": {self.room.id: self.room.name} if self.room else None,
|
|
55
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Persistent settings and last known state of one destination."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, fields
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiofiles
|
|
12
|
+
from aiofiles.os import makedirs, path as aio_path, replace
|
|
13
|
+
|
|
14
|
+
_LOGGER = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
STORE_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TravelDirection(StrEnum):
|
|
20
|
+
"""Direction of a full drive of a cover."""
|
|
21
|
+
|
|
22
|
+
OPEN = "open"
|
|
23
|
+
CLOSE = "close"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ActionSettings:
|
|
28
|
+
"""Settings of a drive action that the user can change.
|
|
29
|
+
|
|
30
|
+
A travel time of 0 means unknown, the target of a drive is then taken over
|
|
31
|
+
at once.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
travel_time_open: float = 0.0
|
|
35
|
+
travel_time_close: float = 0.0
|
|
36
|
+
|
|
37
|
+
def travel_time(self, direction: TravelDirection) -> float:
|
|
38
|
+
"""Return the travel time of a full drive in seconds."""
|
|
39
|
+
if direction is TravelDirection.OPEN:
|
|
40
|
+
return self.travel_time_open
|
|
41
|
+
return self.travel_time_close
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: Any) -> ActionSettings:
|
|
45
|
+
"""Build settings from stored data, invalid values become 0."""
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
return cls()
|
|
48
|
+
return cls(**{f.name: _valid_seconds(data.get(f.name)) for f in fields(cls)})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _valid_seconds(value: Any) -> float:
|
|
52
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
53
|
+
return 0.0
|
|
54
|
+
return float(value) if value >= 0 else 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class JsonStore:
|
|
58
|
+
"""JSON file with the persistent data of one action.
|
|
59
|
+
|
|
60
|
+
Reads and writes are serialized and a write replaces the file atomically.
|
|
61
|
+
Without a path nothing is stored.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: Path | None) -> None:
|
|
65
|
+
"""Create a store for the given file."""
|
|
66
|
+
self._path = path
|
|
67
|
+
self._lock = asyncio.Lock()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def path(self) -> Path | None:
|
|
71
|
+
"""Return the file of this store."""
|
|
72
|
+
return self._path
|
|
73
|
+
|
|
74
|
+
async def load(self) -> dict[str, Any]:
|
|
75
|
+
"""Return the stored data, empty if there is none or it is unreadable."""
|
|
76
|
+
if self._path is None:
|
|
77
|
+
return {}
|
|
78
|
+
async with self._lock:
|
|
79
|
+
return await self._read(self._path)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
async def _read(path: Path) -> dict[str, Any]:
|
|
83
|
+
if not await aio_path.exists(path):
|
|
84
|
+
return {}
|
|
85
|
+
async with aiofiles.open(path) as file:
|
|
86
|
+
content = await file.read()
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(content)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
_LOGGER.warning("Ignoring unreadable file %s", path)
|
|
91
|
+
return {}
|
|
92
|
+
return data if isinstance(data, dict) else {}
|
|
93
|
+
|
|
94
|
+
async def save(self, data: dict[str, Any]) -> None:
|
|
95
|
+
"""Replace the file with the given data."""
|
|
96
|
+
if self._path is None:
|
|
97
|
+
return
|
|
98
|
+
content = json.dumps(data)
|
|
99
|
+
temp = self._path.with_name(self._path.name + ".tmp")
|
|
100
|
+
async with self._lock:
|
|
101
|
+
await makedirs(self._path.parent, exist_ok=True)
|
|
102
|
+
async with aiofiles.open(temp, mode="w") as file:
|
|
103
|
+
await file.write(content)
|
|
104
|
+
await replace(temp, self._path)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Movement model of a WMS cover, free of Home Assistant imports.
|
|
2
|
+
|
|
3
|
+
The model keeps the displayed position and tilt of one cover. Commands set
|
|
4
|
+
the state, a running drive is interpolated linearly, and a measured value
|
|
5
|
+
from a status poll replaces the calculated one while the cover stands still.
|
|
6
|
+
|
|
7
|
+
Scale: position and tilt use the Home Assistant scale, 0 is closed and 100
|
|
8
|
+
is open. All times are seconds, "now" is a monotonic clock value.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PositionSource(StrEnum):
|
|
16
|
+
"""Origin of the displayed position."""
|
|
17
|
+
|
|
18
|
+
MEASURED = "measured"
|
|
19
|
+
CALCULATED = "calculated"
|
|
20
|
+
RESTORED = "restored"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TravelSettings:
|
|
25
|
+
"""Delays and tolerances of the model."""
|
|
26
|
+
|
|
27
|
+
settle_after_travel: float = 2.0
|
|
28
|
+
settle_after_stop: float = 2.0
|
|
29
|
+
settle_tilt: float = 5.0
|
|
30
|
+
settle_unknown_travel: float = 120.0
|
|
31
|
+
position_tolerance: int = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Measurement:
|
|
36
|
+
"""Result of handing a measured value to the model."""
|
|
37
|
+
|
|
38
|
+
applied: bool
|
|
39
|
+
deviation: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clamp_percent(value: float) -> float:
|
|
43
|
+
"""Limit a value to the range 0..100."""
|
|
44
|
+
return max(0.0, min(100.0, float(value)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def wms_percentage_to_position(percentage: object) -> int | None:
|
|
48
|
+
"""Convert a WMS percentage (0 open, 100 closed) to a position.
|
|
49
|
+
|
|
50
|
+
Returns None if the value is missing or outside 0..100.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(percentage, bool) or not isinstance(percentage, (int, float)):
|
|
53
|
+
return None
|
|
54
|
+
if percentage < 0 or percentage > 100:
|
|
55
|
+
return None
|
|
56
|
+
return int(100 - percentage + 0.5)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CoverModel:
|
|
60
|
+
"""Displayed state of one cover."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, settings: TravelSettings | None = None) -> None:
|
|
63
|
+
"""Initialize an unknown cover state."""
|
|
64
|
+
self.settings = settings or TravelSettings()
|
|
65
|
+
self.position: float | None = None
|
|
66
|
+
self.tilt: int | None = None
|
|
67
|
+
self.source: PositionSource | None = None
|
|
68
|
+
self._start: float | None = None
|
|
69
|
+
self._target: float | None = None
|
|
70
|
+
self._started_at: float | None = None
|
|
71
|
+
self._duration: float | None = None
|
|
72
|
+
self._pending_tilt: int | None = None
|
|
73
|
+
|
|
74
|
+
# --- State ---
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_travelling(self) -> bool:
|
|
78
|
+
"""Return True while a drive is interpolated."""
|
|
79
|
+
return self._target is not None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_opening(self) -> bool:
|
|
83
|
+
"""Return True while an interpolated drive moves towards open."""
|
|
84
|
+
return self.is_travelling and self._target > self._start
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_closing(self) -> bool:
|
|
88
|
+
"""Return True while an interpolated drive moves towards closed."""
|
|
89
|
+
return self.is_travelling and self._target < self._start
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def position_int(self) -> int | None:
|
|
93
|
+
"""Return the displayed position as integer."""
|
|
94
|
+
if self.position is None:
|
|
95
|
+
return None
|
|
96
|
+
return int(self.position + 0.5)
|
|
97
|
+
|
|
98
|
+
def _clear_travel(self) -> None:
|
|
99
|
+
self._start = None
|
|
100
|
+
self._target = None
|
|
101
|
+
self._started_at = None
|
|
102
|
+
self._duration = None
|
|
103
|
+
self._pending_tilt = None
|
|
104
|
+
|
|
105
|
+
# --- Time ---
|
|
106
|
+
|
|
107
|
+
def advance(self, now: float) -> bool:
|
|
108
|
+
"""Move the displayed position to the given time.
|
|
109
|
+
|
|
110
|
+
Returns True if a drive reached its target with this call.
|
|
111
|
+
"""
|
|
112
|
+
if not self.is_travelling:
|
|
113
|
+
return False
|
|
114
|
+
elapsed = max(0.0, now - self._started_at)
|
|
115
|
+
if elapsed >= self._duration:
|
|
116
|
+
self.position = self._target
|
|
117
|
+
if self._pending_tilt is not None:
|
|
118
|
+
self.tilt = self._pending_tilt
|
|
119
|
+
self._clear_travel()
|
|
120
|
+
return True
|
|
121
|
+
self.position = self._start + (self._target - self._start) * (
|
|
122
|
+
elapsed / self._duration
|
|
123
|
+
)
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
# --- Commands ---
|
|
127
|
+
|
|
128
|
+
def start_move(
|
|
129
|
+
self,
|
|
130
|
+
now: float,
|
|
131
|
+
target: float,
|
|
132
|
+
time_open: float,
|
|
133
|
+
time_close: float,
|
|
134
|
+
tilt: int | None = None,
|
|
135
|
+
) -> float:
|
|
136
|
+
"""Handle an accepted drive command.
|
|
137
|
+
|
|
138
|
+
Returns the delay in seconds until the settle poll.
|
|
139
|
+
"""
|
|
140
|
+
self.advance(now)
|
|
141
|
+
target = clamp_percent(target)
|
|
142
|
+
start = self.position
|
|
143
|
+
self._clear_travel()
|
|
144
|
+
self.source = PositionSource.CALCULATED
|
|
145
|
+
|
|
146
|
+
if start is None:
|
|
147
|
+
self.position = target
|
|
148
|
+
if tilt is not None:
|
|
149
|
+
self.tilt = tilt
|
|
150
|
+
return self.settings.settle_unknown_travel
|
|
151
|
+
|
|
152
|
+
distance = abs(target - start)
|
|
153
|
+
if distance < 0.5:
|
|
154
|
+
self.position = target
|
|
155
|
+
if tilt is not None:
|
|
156
|
+
self.tilt = tilt
|
|
157
|
+
return self.settings.settle_tilt
|
|
158
|
+
|
|
159
|
+
full_time = time_open if target > start else time_close
|
|
160
|
+
if full_time <= 0:
|
|
161
|
+
self.position = target
|
|
162
|
+
if tilt is not None:
|
|
163
|
+
self.tilt = tilt
|
|
164
|
+
return self.settings.settle_unknown_travel
|
|
165
|
+
|
|
166
|
+
self._start = start
|
|
167
|
+
self._target = target
|
|
168
|
+
self._started_at = now
|
|
169
|
+
self._duration = full_time * distance / 100
|
|
170
|
+
self._pending_tilt = tilt
|
|
171
|
+
return self._duration + self.settings.settle_after_travel
|
|
172
|
+
|
|
173
|
+
def set_tilt(self, now: float, tilt: int) -> float:
|
|
174
|
+
"""Handle an accepted tilt-only command, it ends a running drive."""
|
|
175
|
+
self.advance(now)
|
|
176
|
+
self._clear_travel()
|
|
177
|
+
self.tilt = int(clamp_percent(tilt) + 0.5)
|
|
178
|
+
self.source = PositionSource.CALCULATED
|
|
179
|
+
return self.settings.settle_tilt
|
|
180
|
+
|
|
181
|
+
def stop(self, now: float) -> float:
|
|
182
|
+
"""Handle an accepted stop command, the displayed value freezes."""
|
|
183
|
+
self.advance(now)
|
|
184
|
+
self._clear_travel()
|
|
185
|
+
if self.position is not None:
|
|
186
|
+
self.source = PositionSource.CALCULATED
|
|
187
|
+
return self.settings.settle_after_stop
|
|
188
|
+
|
|
189
|
+
# --- Measurement and restore ---
|
|
190
|
+
|
|
191
|
+
def apply_measurement(
|
|
192
|
+
self, now: float, position: int | None, tilt: int | None
|
|
193
|
+
) -> Measurement:
|
|
194
|
+
"""Take over a measured value unless a drive is running."""
|
|
195
|
+
self.advance(now)
|
|
196
|
+
if self.is_travelling or position is None:
|
|
197
|
+
return Measurement(applied=False, deviation=False)
|
|
198
|
+
old = self.position
|
|
199
|
+
self.position = clamp_percent(position)
|
|
200
|
+
if tilt is not None:
|
|
201
|
+
self.tilt = int(clamp_percent(tilt) + 0.5)
|
|
202
|
+
self.source = PositionSource.MEASURED
|
|
203
|
+
deviation = (
|
|
204
|
+
old is not None
|
|
205
|
+
and abs(self.position - old) > self.settings.position_tolerance
|
|
206
|
+
)
|
|
207
|
+
return Measurement(applied=True, deviation=deviation)
|
|
208
|
+
|
|
209
|
+
def restore(
|
|
210
|
+
self,
|
|
211
|
+
position: float | None,
|
|
212
|
+
tilt: int | None,
|
|
213
|
+
target_position: float | None,
|
|
214
|
+
target_tilt: int | None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Take over the state stored before a restart.
|
|
217
|
+
|
|
218
|
+
A value that is already known (measured at setup) wins.
|
|
219
|
+
"""
|
|
220
|
+
if self.position is not None:
|
|
221
|
+
return
|
|
222
|
+
if target_position is not None:
|
|
223
|
+
self.position = clamp_percent(target_position)
|
|
224
|
+
self.tilt = target_tilt if target_tilt is not None else tilt
|
|
225
|
+
elif position is not None:
|
|
226
|
+
self.position = clamp_percent(position)
|
|
227
|
+
self.tilt = tilt
|
|
228
|
+
else:
|
|
229
|
+
return
|
|
230
|
+
self.source = PositionSource.RESTORED
|
|
231
|
+
|
|
232
|
+
def snapshot(self) -> dict[str, int | None]:
|
|
233
|
+
"""Return the state to store for a restart."""
|
|
234
|
+
return {
|
|
235
|
+
"position": self.position_int,
|
|
236
|
+
"tilt": self.tilt,
|
|
237
|
+
"target_position": (
|
|
238
|
+
int(self._target + 0.5) if self.is_travelling else None
|
|
239
|
+
),
|
|
240
|
+
"target_tilt": self._pending_tilt if self.is_travelling else None,
|
|
241
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pprint
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from aiohttp import ClientSession
|
|
11
|
+
|
|
12
|
+
from .const import (
|
|
13
|
+
WMS_WebControl_pro_API_actionDescription,
|
|
14
|
+
WMS_WebControl_pro_API_actionType,
|
|
15
|
+
WMS_WebControl_pro_API_command_action,
|
|
16
|
+
WMS_WebControl_pro_API_command_getConfiguration,
|
|
17
|
+
WMS_WebControl_pro_API_command_getStatus,
|
|
18
|
+
WMS_WebControl_pro_API_command_ping,
|
|
19
|
+
WMS_WebControl_pro_API_command_sceneActions,
|
|
20
|
+
WMS_WebControl_pro_API_protocolVersion,
|
|
21
|
+
WMS_WebControl_pro_API_responseType,
|
|
22
|
+
WMS_WebControl_pro_API_sceneActionType,
|
|
23
|
+
WMS_WebControl_pro_API_source,
|
|
24
|
+
)
|
|
25
|
+
from .destination import Destination
|
|
26
|
+
from .room import Room
|
|
27
|
+
from .scene import Scene
|
|
28
|
+
|
|
29
|
+
DEFAULT_STALE_WARNING_AFTER = timedelta(hours=24)
|
|
30
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
31
|
+
DEFAULT_REQUEST_SPACING = 0.5
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WebControlPro:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
host: str,
|
|
38
|
+
session: ClientSession,
|
|
39
|
+
persist: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
stale_warning_after: timedelta = DEFAULT_STALE_WARNING_AFTER,
|
|
42
|
+
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
43
|
+
request_spacing: float = DEFAULT_REQUEST_SPACING,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Create a client; stale_warning_after sets how long a destination may
|
|
46
|
+
stay silent before a warning is logged, retry_delay the pause in
|
|
47
|
+
seconds between retries after a radio busy answer and request_spacing
|
|
48
|
+
the minimum pause in seconds between two status requests.
|
|
49
|
+
"""
|
|
50
|
+
self.stale_warning_after = stale_warning_after
|
|
51
|
+
self.retry_delay = retry_delay
|
|
52
|
+
self._common_lock = asyncio.Lock()
|
|
53
|
+
self._status_lock = asyncio.Lock()
|
|
54
|
+
self.request_spacing = request_spacing
|
|
55
|
+
self._status_last = time.monotonic() - self.request_spacing
|
|
56
|
+
self._host = host
|
|
57
|
+
self._control = f"http://{host}/commonCommand"
|
|
58
|
+
self._session = session
|
|
59
|
+
self._persist = Path(persist) if persist else None
|
|
60
|
+
self._config = {}
|
|
61
|
+
self._dests = {}
|
|
62
|
+
self._rooms = {}
|
|
63
|
+
self._scenes = {}
|
|
64
|
+
|
|
65
|
+
# --- Private methods ---
|
|
66
|
+
|
|
67
|
+
async def _commonCommand(self, command: str, **kwargs) -> Any:
|
|
68
|
+
data = {
|
|
69
|
+
"protocolVersion": WMS_WebControl_pro_API_protocolVersion,
|
|
70
|
+
"command": command,
|
|
71
|
+
"source": WMS_WebControl_pro_API_source,
|
|
72
|
+
}
|
|
73
|
+
data.update(kwargs)
|
|
74
|
+
async with (
|
|
75
|
+
self._common_lock,
|
|
76
|
+
self._session.post(url=self._control, json=data, timeout=5) as response,
|
|
77
|
+
):
|
|
78
|
+
return await response.json()
|
|
79
|
+
|
|
80
|
+
async def _ping(self) -> Any:
|
|
81
|
+
return await self._commonCommand(WMS_WebControl_pro_API_command_ping)
|
|
82
|
+
|
|
83
|
+
async def _getConfiguration(self) -> Any:
|
|
84
|
+
return await self._commonCommand(
|
|
85
|
+
WMS_WebControl_pro_API_command_getConfiguration
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _getStatus(self, destinationId: int) -> Any:
|
|
89
|
+
async with self._status_lock:
|
|
90
|
+
elapsed_time = time.monotonic() - self._status_last
|
|
91
|
+
if elapsed_time < self.request_spacing:
|
|
92
|
+
await asyncio.sleep(self.request_spacing - elapsed_time)
|
|
93
|
+
try:
|
|
94
|
+
return await self._commonCommand(
|
|
95
|
+
WMS_WebControl_pro_API_command_getStatus,
|
|
96
|
+
destinations=[destinationId],
|
|
97
|
+
)
|
|
98
|
+
finally:
|
|
99
|
+
self._status_last = time.monotonic()
|
|
100
|
+
|
|
101
|
+
async def _action(
|
|
102
|
+
self, actions: list, responseType=WMS_WebControl_pro_API_responseType.Instant
|
|
103
|
+
) -> Any:
|
|
104
|
+
return await self._commonCommand(
|
|
105
|
+
WMS_WebControl_pro_API_command_action,
|
|
106
|
+
responseType=responseType,
|
|
107
|
+
actions=actions,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def _sceneActions(
|
|
111
|
+
self,
|
|
112
|
+
sceneId: int,
|
|
113
|
+
sceneActionType=WMS_WebControl_pro_API_sceneActionType.Execute,
|
|
114
|
+
responseType=WMS_WebControl_pro_API_responseType.Instant,
|
|
115
|
+
) -> Any:
|
|
116
|
+
return await self._commonCommand(
|
|
117
|
+
WMS_WebControl_pro_API_command_sceneActions,
|
|
118
|
+
responseType=responseType,
|
|
119
|
+
sceneId=sceneId,
|
|
120
|
+
sceneActionType=sceneActionType,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# --- Public methods ---
|
|
124
|
+
|
|
125
|
+
async def ping(self) -> bool:
|
|
126
|
+
ping = await self._ping()
|
|
127
|
+
return ping["status"] == 0
|
|
128
|
+
|
|
129
|
+
async def refresh(self) -> None:
|
|
130
|
+
config = await self._getConfiguration()
|
|
131
|
+
self._config = config
|
|
132
|
+
self._dests = {
|
|
133
|
+
dest["id"]: Destination(self, **dest) for dest in config["destinations"]
|
|
134
|
+
}
|
|
135
|
+
self._rooms = {room["id"]: Room(self, **room) for room in config["rooms"]}
|
|
136
|
+
self._scenes = {scene["id"]: Scene(self, **scene) for scene in config["scenes"]}
|
|
137
|
+
|
|
138
|
+
def dest(self, name: str) -> Destination:
|
|
139
|
+
for dest in self._dests.values():
|
|
140
|
+
if dest.name == name:
|
|
141
|
+
return dest
|
|
142
|
+
raise KeyError(f"Destination with name '{name}' not found")
|
|
143
|
+
|
|
144
|
+
def diag(self) -> dict:
|
|
145
|
+
return {
|
|
146
|
+
"host": self._host,
|
|
147
|
+
"config": self._config,
|
|
148
|
+
"dests": {k: v.diag() for k, v in self._dests.items()},
|
|
149
|
+
"rooms": {k: v.diag() for k, v in self._rooms.items()},
|
|
150
|
+
"scenes": {k: v.diag() for k, v in self._scenes.items()},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# --- Properties ---
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def host(self) -> str:
|
|
157
|
+
return self._host
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def config(self) -> Mapping:
|
|
161
|
+
return MappingProxyType(self._config)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def dests(self) -> Mapping:
|
|
165
|
+
return MappingProxyType(self._dests)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def rooms(self) -> Mapping:
|
|
169
|
+
return MappingProxyType(self._rooms)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def scenes(self) -> Mapping:
|
|
173
|
+
return MappingProxyType(self._scenes)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def async_main_test():
|
|
177
|
+
async with ClientSession() as session:
|
|
178
|
+
control = WebControlPro("webcontrol", session)
|
|
179
|
+
pprint.pprint(await control.ping())
|
|
180
|
+
await control.refresh()
|
|
181
|
+
|
|
182
|
+
for dest in control.dests.values():
|
|
183
|
+
print((dest.room, dest, dest.animationType))
|
|
184
|
+
await dest.refresh()
|
|
185
|
+
for action in dest.actions.values():
|
|
186
|
+
print((action, action._attrs, action._params))
|
|
187
|
+
|
|
188
|
+
for scene in control.scenes.values():
|
|
189
|
+
print(scene)
|
|
190
|
+
if scene.name == "Licht an":
|
|
191
|
+
response = await scene()
|
|
192
|
+
print(response)
|
|
193
|
+
|
|
194
|
+
action = control.dest("Licht").action(
|
|
195
|
+
WMS_WebControl_pro_API_actionDescription.LightDimming,
|
|
196
|
+
WMS_WebControl_pro_API_actionType.Percentage,
|
|
197
|
+
)
|
|
198
|
+
print(action)
|
|
199
|
+
response = await action(
|
|
200
|
+
percentage=0, responseType=WMS_WebControl_pro_API_responseType.Detailed
|
|
201
|
+
)
|
|
202
|
+
print(response)
|
|
203
|
+
|
|
204
|
+
action_list = action.prep(percentage=100)
|
|
205
|
+
action_list += action.prep(percentage=0)
|
|
206
|
+
print(action_list)
|
|
207
|
+
response = await action_list(
|
|
208
|
+
responseType=WMS_WebControl_pro_API_responseType.Detailed
|
|
209
|
+
)
|
|
210
|
+
print(response)
|
|
211
|
+
|
|
212
|
+
pprint.pprint(control.diag())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
loop = asyncio.new_event_loop()
|
|
217
|
+
asyncio.set_event_loop(loop)
|
|
218
|
+
loop.run_until_complete(async_main_test())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pywmswebcontrolpro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for the Warema WMS WebControl pro API
|
|
5
|
+
Author-email: Marc Hoersken <info@marc-hoersken.de>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: warema,webcontrol,wms
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Requires-Dist: aiofiles>=24.1.0
|
|
11
|
+
Requires-Dist: aiohttp>=3.14.1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# pywmswebcontrolpro
|
|
15
|
+
|
|
16
|
+
Python library for the Warema WMS WebControl pro API. It is the companion library of the
|
|
17
|
+
Home Assistant integration `ha_wms_webcontrol_pro`.
|
|
18
|
+
|
|
19
|
+
Reference: https://media.warema.com/dokumente/anleitungen-handbuecher/966664/warema_2064534_alhb_de_v0.pdf
|
|
20
|
+
|
|
21
|
+
## Origin and license
|
|
22
|
+
|
|
23
|
+
This project is a fork of [`pywmspro`](https://github.com/mback2k/pywmspro) by
|
|
24
|
+
Marc Hoersken, licensed under the Apache License 2.0. The full history of the original
|
|
25
|
+
is kept in this repository. All files changed since the fork are changed by this
|
|
26
|
+
project's contributors; see the git history for details. The license text is in
|
|
27
|
+
`LICENSE` and applies to this repository unchanged.
|
|
28
|
+
|
|
29
|
+
## Example usage
|
|
30
|
+
|
|
31
|
+
See the main section in `pywmswebcontrolpro/webcontrol.py`.
|
|
32
|
+
|
|
33
|
+
## Status refresh
|
|
34
|
+
|
|
35
|
+
`Destination.refresh(retries=2)` requests the status of a destination and returns a
|
|
36
|
+
`RefreshOutcome` with the result (`ok`, `busy`, `empty` or `error`) and the values
|
|
37
|
+
of the actions. Only a "radio busy" answer is retried; with `retries=0` exactly one
|
|
38
|
+
request is sent. On any failure the previous values stay unchanged. The outcome is
|
|
39
|
+
truthy only for `ok`.
|
|
40
|
+
|
|
41
|
+
`WebControlPro(request_spacing=...)` sets the minimum pause between two status requests
|
|
42
|
+
to the hub (default 0.5 seconds).
|
|
43
|
+
|
|
44
|
+
Failed refreshes are counted per destination (`Destination.stats`, also part of
|
|
45
|
+
`diag()`) instead of being logged. A warning is logged once when a destination has
|
|
46
|
+
failed continuously for `stale_warning_after` (default 24 hours) and once when it
|
|
47
|
+
delivers data again.
|
|
48
|
+
|
|
49
|
+
## Settings, movement model and stored state
|
|
50
|
+
|
|
51
|
+
Each action keeps its data in `<persist>/<destination id>/<action id>.json`, the file
|
|
52
|
+
that already holds the `overwrites` (for example the rotation limits). A drive action
|
|
53
|
+
(for example `AwningDrive`, `ValanceDrive` or `RollerShutterBlindDrive`) stores in it:
|
|
54
|
+
|
|
55
|
+
- `settings`: the travel times for a full drive, open and close, in seconds
|
|
56
|
+
(`Action.set_travel_time`, 0 means unknown).
|
|
57
|
+
- `state`: the last known position and tilt and the target of a running drive.
|
|
58
|
+
|
|
59
|
+
The file is written atomically and serialized against concurrent access, unknown keys
|
|
60
|
+
and the `overwrites` of older versions are kept. Without a persist path nothing is
|
|
61
|
+
stored.
|
|
62
|
+
|
|
63
|
+
`Action.model` is the movement model of the cover driven by this action: `start_move`,
|
|
64
|
+
`stop`, `set_tilt` and `apply_measurement` calculate the displayed position, which is
|
|
65
|
+
replaced by a measured value when the cover stands still. Because the model belongs to
|
|
66
|
+
the drive action, an awning and its valance, which share a destination, move
|
|
67
|
+
independently. After a restart the position is restored from the file and marked as
|
|
68
|
+
`restored` until a measurement arrives. Call `Action.sync()` after commands and
|
|
69
|
+
measurements to store changes; `Destination.refresh` does it as well.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m pip install -r requirements_lint.txt -r requirements_test.txt
|
|
75
|
+
ruff check . && ruff format --check .
|
|
76
|
+
python -m pytest
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The version is derived from git tags (`hatch-vcs`).
|