pywmswebcontrolpro 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.github/workflows/validate.yaml +1 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/PKG-INFO +2 -2
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/README.md +1 -1
- pywmswebcontrolpro-0.1.2/mypy.ini +6 -0
- pywmswebcontrolpro-0.1.2/pywmswebcontrolpro/__init__.py +1 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/action.py +42 -10
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/const.py +14 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/destination.py +30 -6
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/room.py +26 -3
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/scene.py +18 -5
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/travel.py +17 -4
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/webcontrol.py +61 -14
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/requirements_lint.txt +1 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/ruff.toml +9 -1
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/conftest.py +2 -1
- pywmswebcontrolpro-0.1.2/tests/test_configuration.py +59 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.github/workflows/release.yaml +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.gitignore +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.python-version +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/LICENSE +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pyproject.toml +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pytest.ini +0 -0
- /pywmswebcontrolpro-0.1.0/pywmswebcontrolpro/__init__.py → /pywmswebcontrolpro-0.1.2/pywmswebcontrolpro/py.typed +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/refresh.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/store.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/requirements_test.txt +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/simulator/simulator.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/__init__.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_import.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_refresh.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_settings.py +0 -0
- {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_travel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pywmswebcontrolpro
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Python library for the Warema WMS WebControl pro API
|
|
5
5
|
Author-email: Marc Hoersken <info@marc-hoersken.de>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -14,7 +14,7 @@ Description-Content-Type: text/markdown
|
|
|
14
14
|
# pywmswebcontrolpro
|
|
15
15
|
|
|
16
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
|
|
17
|
+
Home Assistant integration [`wms_webcontrol_pro`](https://github.com/zomtec/ha_wms_webcontrol_pro).
|
|
18
18
|
|
|
19
19
|
Reference: https://media.warema.com/dokumente/anleitungen-handbuecher/966664/warema_2064534_alhb_de_v0.pdf
|
|
20
20
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# pywmswebcontrolpro
|
|
2
2
|
|
|
3
3
|
Python library for the Warema WMS WebControl pro API. It is the companion library of the
|
|
4
|
-
Home Assistant integration `ha_wms_webcontrol_pro
|
|
4
|
+
Home Assistant integration [`wms_webcontrol_pro`](https://github.com/zomtec/ha_wms_webcontrol_pro).
|
|
5
5
|
|
|
6
6
|
Reference: https://media.warema.com/dokumente/anleitungen-handbuecher/966664/warema_2064534_alhb_de_v0.pdf
|
|
7
7
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python client for the Warema WMS WebControl pro gateway."""
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
"""An action of a destination with its stored settings and movement model."""
|
|
2
|
+
|
|
1
3
|
import math
|
|
2
4
|
from dataclasses import asdict, replace
|
|
3
|
-
from typing import Any
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
4
6
|
|
|
5
7
|
from .const import (
|
|
6
8
|
WMS_WebControl_pro_API_actionDescription,
|
|
@@ -10,17 +12,28 @@ from .const import (
|
|
|
10
12
|
from .store import STORE_VERSION, ActionSettings, JsonStore, TravelDirection
|
|
11
13
|
from .travel import CoverModel, Measurement
|
|
12
14
|
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .destination import Destination
|
|
17
|
+
from .webcontrol import WebControlPro
|
|
18
|
+
|
|
13
19
|
_STORED_KEYS = frozenset({"version", "overwrites", "settings", "state"})
|
|
14
20
|
|
|
15
21
|
|
|
16
22
|
class ActionList(list):
|
|
17
|
-
|
|
23
|
+
"""Actions that are sent to the gateway with one request."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, control: WebControlPro) -> None:
|
|
26
|
+
"""Create an empty list for the given client."""
|
|
18
27
|
super().__init__()
|
|
19
28
|
self._control = control
|
|
20
29
|
|
|
21
30
|
async def __call__(
|
|
22
|
-
self,
|
|
31
|
+
self,
|
|
32
|
+
responseType: WMS_WebControl_pro_API_responseType = (
|
|
33
|
+
WMS_WebControl_pro_API_responseType.Instant
|
|
34
|
+
),
|
|
23
35
|
) -> Any:
|
|
36
|
+
"""Send all actions and return the answer of the gateway."""
|
|
24
37
|
if len(self) == 0:
|
|
25
38
|
raise ValueError("ActionList is empty")
|
|
26
39
|
return await self._control._action(
|
|
@@ -30,9 +43,17 @@ class ActionList(list):
|
|
|
30
43
|
|
|
31
44
|
|
|
32
45
|
class Action:
|
|
46
|
+
"""One function of a destination, for example moving an awning."""
|
|
47
|
+
|
|
33
48
|
def __init__(
|
|
34
|
-
self,
|
|
49
|
+
self,
|
|
50
|
+
dest: Destination,
|
|
51
|
+
id: int,
|
|
52
|
+
actionType: int,
|
|
53
|
+
actionDescription: int,
|
|
54
|
+
**kwargs: Any,
|
|
35
55
|
) -> None:
|
|
56
|
+
"""Create the action from the configuration of the gateway."""
|
|
36
57
|
self._dest = dest
|
|
37
58
|
self._id = id
|
|
38
59
|
self._persist = (dest._persist / f"{id}.json") if dest._persist else None
|
|
@@ -42,8 +63,8 @@ class Action:
|
|
|
42
63
|
actionDescription
|
|
43
64
|
)
|
|
44
65
|
self._attrs = kwargs
|
|
45
|
-
self._params = {}
|
|
46
|
-
self._overwrites = {}
|
|
66
|
+
self._params: dict[str, Any] = {}
|
|
67
|
+
self._overwrites: dict[str, Any] = {}
|
|
47
68
|
self._needs_load = True
|
|
48
69
|
self._needs_save = False
|
|
49
70
|
self._extra: dict[str, Any] = {}
|
|
@@ -57,8 +78,8 @@ class Action:
|
|
|
57
78
|
def __repr__(self) -> str:
|
|
58
79
|
return f"<Action {self.id}: {self} ({self.actionType.name})>"
|
|
59
80
|
|
|
60
|
-
def __eq__(self, other) -> bool:
|
|
61
|
-
return self.id == other.id
|
|
81
|
+
def __eq__(self, other: object) -> bool:
|
|
82
|
+
return isinstance(other, Action) and self.id == other.id
|
|
62
83
|
|
|
63
84
|
def __hash__(self) -> int:
|
|
64
85
|
return hash(self.id)
|
|
@@ -67,18 +88,22 @@ class Action:
|
|
|
67
88
|
|
|
68
89
|
@property
|
|
69
90
|
def host(self) -> str:
|
|
91
|
+
"""Return the host of the gateway."""
|
|
70
92
|
return self._dest.host
|
|
71
93
|
|
|
72
94
|
@property
|
|
73
95
|
def id(self) -> int:
|
|
96
|
+
"""Return the id of the action."""
|
|
74
97
|
return self._id
|
|
75
98
|
|
|
76
99
|
@property
|
|
77
100
|
def actionType(self) -> WMS_WebControl_pro_API_actionType:
|
|
101
|
+
"""Return the kind of value or command of the action."""
|
|
78
102
|
return self._actionType
|
|
79
103
|
|
|
80
104
|
@property
|
|
81
105
|
def actionDescription(self) -> WMS_WebControl_pro_API_actionDescription:
|
|
106
|
+
"""Return the function of the action."""
|
|
82
107
|
return self._actionDescription
|
|
83
108
|
|
|
84
109
|
# --- Private methods ---
|
|
@@ -191,7 +216,8 @@ class Action:
|
|
|
191
216
|
del self._overwrites[name]
|
|
192
217
|
self._needs_save = True
|
|
193
218
|
|
|
194
|
-
def prep(self, **kwargs) -> ActionList:
|
|
219
|
+
def prep(self, **kwargs: Any) -> ActionList:
|
|
220
|
+
"""Return an action list with this action and the given parameters."""
|
|
195
221
|
actionList = ActionList(self._dest._control)
|
|
196
222
|
actionList.append(
|
|
197
223
|
{
|
|
@@ -203,14 +229,20 @@ class Action:
|
|
|
203
229
|
return actionList
|
|
204
230
|
|
|
205
231
|
async def __call__(
|
|
206
|
-
self,
|
|
232
|
+
self,
|
|
233
|
+
responseType: WMS_WebControl_pro_API_responseType = (
|
|
234
|
+
WMS_WebControl_pro_API_responseType.Instant
|
|
235
|
+
),
|
|
236
|
+
**kwargs: Any,
|
|
207
237
|
) -> Any:
|
|
238
|
+
"""Send the action with the given parameters to the gateway."""
|
|
208
239
|
return await self._dest._control._action(
|
|
209
240
|
actions=self.prep(**kwargs),
|
|
210
241
|
responseType=responseType,
|
|
211
242
|
)
|
|
212
243
|
|
|
213
244
|
def diag(self) -> dict:
|
|
245
|
+
"""Return the action for the diagnostics."""
|
|
214
246
|
return {
|
|
215
247
|
"id": self.id,
|
|
216
248
|
"actionType": self.actionType.name,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Constants and enumerations of the WMS WebControl pro API."""
|
|
2
|
+
|
|
1
3
|
from enum import IntEnum
|
|
2
4
|
|
|
3
5
|
WMS_WebControl_pro_API_protocolVersion = "1.0"
|
|
@@ -11,6 +13,8 @@ WMS_WebControl_pro_API_command_sceneActions = "sceneActions"
|
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class WMS_WebControl_pro_API_animationType(IntEnum):
|
|
16
|
+
"""Kind of device that a destination controls."""
|
|
17
|
+
|
|
14
18
|
VenetianBlind = 0
|
|
15
19
|
Awning = 1
|
|
16
20
|
RollerShutterBlind = 2
|
|
@@ -22,6 +26,8 @@ class WMS_WebControl_pro_API_animationType(IntEnum):
|
|
|
22
26
|
|
|
23
27
|
|
|
24
28
|
class WMS_WebControl_pro_API_actionType(IntEnum):
|
|
29
|
+
"""Kind of value or command of an action."""
|
|
30
|
+
|
|
25
31
|
Percentage = 0
|
|
26
32
|
PercentageDelta = 1
|
|
27
33
|
Rotation = 2
|
|
@@ -36,6 +42,8 @@ class WMS_WebControl_pro_API_actionType(IntEnum):
|
|
|
36
42
|
|
|
37
43
|
|
|
38
44
|
class WMS_WebControl_pro_API_actionDescription(IntEnum):
|
|
45
|
+
"""Function of an action, for example awning drive or light switch."""
|
|
46
|
+
|
|
39
47
|
AwningDrive = 0
|
|
40
48
|
ValanceDrive = 1
|
|
41
49
|
SlatDrive = 2
|
|
@@ -54,16 +62,22 @@ class WMS_WebControl_pro_API_actionDescription(IntEnum):
|
|
|
54
62
|
|
|
55
63
|
|
|
56
64
|
class WMS_WebControl_pro_API_responseType(IntEnum):
|
|
65
|
+
"""How much the gateway answers to a command."""
|
|
66
|
+
|
|
57
67
|
Instant = 0
|
|
58
68
|
Detailed = 1
|
|
59
69
|
|
|
60
70
|
|
|
61
71
|
class WMS_WebControl_pro_API_sceneActionType(IntEnum):
|
|
72
|
+
"""Command for a scene."""
|
|
73
|
+
|
|
62
74
|
Relearn = 0
|
|
63
75
|
Execute = 1
|
|
64
76
|
|
|
65
77
|
|
|
66
78
|
class WMS_WebControl_pro_API_drivingCause(IntEnum):
|
|
79
|
+
"""Reason why a drive moved last."""
|
|
80
|
+
|
|
67
81
|
_None = 0 # No driving cause
|
|
68
82
|
Sun = 1
|
|
69
83
|
DuskDawn = 2
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
"""A destination of the gateway, for example a drive, a light or a load."""
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import logging
|
|
3
5
|
import time
|
|
4
6
|
from collections.abc import Mapping
|
|
5
7
|
from types import MappingProxyType
|
|
6
|
-
from typing import Any
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
7
9
|
|
|
8
10
|
import aiohttp
|
|
9
11
|
|
|
@@ -23,14 +25,24 @@ from .refresh import (
|
|
|
23
25
|
)
|
|
24
26
|
from .room import Room
|
|
25
27
|
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .webcontrol import WebControlPro
|
|
30
|
+
|
|
26
31
|
DEFAULT_REFRESH_RETRIES = 2
|
|
27
32
|
|
|
28
33
|
_LOGGER = logging.getLogger(__name__)
|
|
29
34
|
|
|
30
35
|
|
|
31
36
|
class Destination:
|
|
37
|
+
"""A component of the gateway with its actions and the last known status."""
|
|
38
|
+
|
|
32
39
|
def __init__(
|
|
33
|
-
self,
|
|
40
|
+
self,
|
|
41
|
+
control: WebControlPro,
|
|
42
|
+
id: int,
|
|
43
|
+
names: list[str],
|
|
44
|
+
actions: list[dict[str, Any]],
|
|
45
|
+
animationType: int,
|
|
34
46
|
) -> None:
|
|
35
47
|
self._control = control
|
|
36
48
|
self._id = id
|
|
@@ -39,10 +51,10 @@ class Destination:
|
|
|
39
51
|
self._actions = {action["id"]: Action(self, **action) for action in actions}
|
|
40
52
|
self._animationType = WMS_WebControl_pro_API_animationType(animationType)
|
|
41
53
|
self._drivingCause = WMS_WebControl_pro_API_drivingCause.Unknown
|
|
42
|
-
self._unknownProducts = {}
|
|
54
|
+
self._unknownProducts: dict[int, Any] = {}
|
|
43
55
|
self._heartbeatError = None
|
|
44
56
|
self._blocking = None
|
|
45
|
-
self._status = {}
|
|
57
|
+
self._status: dict[str, Any] = {}
|
|
46
58
|
self._stats = RefreshStats()
|
|
47
59
|
|
|
48
60
|
def __str__(self) -> str:
|
|
@@ -51,8 +63,8 @@ class Destination:
|
|
|
51
63
|
def __repr__(self) -> str:
|
|
52
64
|
return f"<Destination {self.id}: {self}>"
|
|
53
65
|
|
|
54
|
-
def __eq__(self, other) -> bool:
|
|
55
|
-
return self.id == other.id
|
|
66
|
+
def __eq__(self, other: object) -> bool:
|
|
67
|
+
return isinstance(other, Destination) and self.id == other.id
|
|
56
68
|
|
|
57
69
|
def __hash__(self) -> int:
|
|
58
70
|
return hash(self.id)
|
|
@@ -61,30 +73,37 @@ class Destination:
|
|
|
61
73
|
|
|
62
74
|
@property
|
|
63
75
|
def host(self) -> str:
|
|
76
|
+
"""Return the host of the gateway."""
|
|
64
77
|
return self._control.host
|
|
65
78
|
|
|
66
79
|
@property
|
|
67
80
|
def id(self) -> int:
|
|
81
|
+
"""Return the id of the destination."""
|
|
68
82
|
return self._id
|
|
69
83
|
|
|
70
84
|
@property
|
|
71
85
|
def name(self) -> str:
|
|
86
|
+
"""Return the name of the destination."""
|
|
72
87
|
return self._names[0]
|
|
73
88
|
|
|
74
89
|
@property
|
|
75
90
|
def actions(self) -> dict:
|
|
91
|
+
"""Return the actions of the destination by id."""
|
|
76
92
|
return self._actions
|
|
77
93
|
|
|
78
94
|
@property
|
|
79
95
|
def animationType(self) -> WMS_WebControl_pro_API_animationType:
|
|
96
|
+
"""Return the kind of device that the destination controls."""
|
|
80
97
|
return self._animationType
|
|
81
98
|
|
|
82
99
|
@property
|
|
83
100
|
def drivingCause(self) -> WMS_WebControl_pro_API_drivingCause:
|
|
101
|
+
"""Return why the drive moved last."""
|
|
84
102
|
return self._drivingCause
|
|
85
103
|
|
|
86
104
|
@property
|
|
87
105
|
def room(self) -> Room | None:
|
|
106
|
+
"""Return the room of the destination, if it belongs to one."""
|
|
88
107
|
for room in self._control.rooms.values():
|
|
89
108
|
if self._id in room._destination_ids:
|
|
90
109
|
return room
|
|
@@ -92,10 +111,12 @@ class Destination:
|
|
|
92
111
|
|
|
93
112
|
@property
|
|
94
113
|
def available(self) -> bool:
|
|
114
|
+
"""Return False while the gateway reports an error or a blocked drive."""
|
|
95
115
|
return not (self._heartbeatError or self._blocking)
|
|
96
116
|
|
|
97
117
|
@property
|
|
98
118
|
def status(self) -> Mapping:
|
|
119
|
+
"""Return the last status that the gateway answered."""
|
|
99
120
|
return MappingProxyType(self._status)
|
|
100
121
|
|
|
101
122
|
@property
|
|
@@ -209,6 +230,7 @@ class Destination:
|
|
|
209
230
|
actionDescription: WMS_WebControl_pro_API_actionDescription,
|
|
210
231
|
actionType: WMS_WebControl_pro_API_actionType | None = None,
|
|
211
232
|
) -> bool:
|
|
233
|
+
"""Return True if the destination has a matching action."""
|
|
212
234
|
for action in self._actions.values():
|
|
213
235
|
if action.actionDescription == actionDescription and (
|
|
214
236
|
actionType is None or actionType == action.actionType
|
|
@@ -221,6 +243,7 @@ class Destination:
|
|
|
221
243
|
actionDescription: WMS_WebControl_pro_API_actionDescription,
|
|
222
244
|
actionType: WMS_WebControl_pro_API_actionType | None = None,
|
|
223
245
|
) -> Action:
|
|
246
|
+
"""Return the matching action or raise NotImplementedError."""
|
|
224
247
|
for action in self._actions.values():
|
|
225
248
|
if action.actionDescription == actionDescription and (
|
|
226
249
|
actionType is None or actionType == action.actionType
|
|
@@ -234,6 +257,7 @@ class Destination:
|
|
|
234
257
|
)
|
|
235
258
|
|
|
236
259
|
def diag(self) -> dict:
|
|
260
|
+
"""Return the destination for the diagnostics."""
|
|
237
261
|
return {
|
|
238
262
|
"id": self.id,
|
|
239
263
|
"name": self.name,
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
"""A room of the gateway with its destinations and scenes."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .webcontrol import WebControlPro
|
|
7
|
+
|
|
8
|
+
|
|
1
9
|
class Room:
|
|
2
|
-
|
|
10
|
+
"""A room that groups destinations and scenes."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
control: WebControlPro,
|
|
15
|
+
id: int,
|
|
16
|
+
name: str,
|
|
17
|
+
destinations: list[int],
|
|
18
|
+
scenes: list[int],
|
|
19
|
+
) -> None:
|
|
3
20
|
self._control = control
|
|
4
21
|
self._id = id
|
|
5
22
|
self._name = name
|
|
@@ -12,8 +29,8 @@ class Room:
|
|
|
12
29
|
def __repr__(self) -> str:
|
|
13
30
|
return f"<Room {self.id}: {self}>"
|
|
14
31
|
|
|
15
|
-
def __eq__(self, other) -> bool:
|
|
16
|
-
return self.id == other.id
|
|
32
|
+
def __eq__(self, other: object) -> bool:
|
|
33
|
+
return isinstance(other, Room) and self.id == other.id
|
|
17
34
|
|
|
18
35
|
def __hash__(self) -> int:
|
|
19
36
|
return hash(self.id)
|
|
@@ -22,24 +39,29 @@ class Room:
|
|
|
22
39
|
|
|
23
40
|
@property
|
|
24
41
|
def host(self) -> str:
|
|
42
|
+
"""Return the host of the gateway."""
|
|
25
43
|
return self._control.host
|
|
26
44
|
|
|
27
45
|
@property
|
|
28
46
|
def id(self) -> int:
|
|
47
|
+
"""Return the id of the room."""
|
|
29
48
|
return self._id
|
|
30
49
|
|
|
31
50
|
@property
|
|
32
51
|
def name(self) -> str:
|
|
52
|
+
"""Return the name of the room."""
|
|
33
53
|
return self._name
|
|
34
54
|
|
|
35
55
|
@property
|
|
36
56
|
def destinations(self) -> dict:
|
|
57
|
+
"""Return the destinations of the room by id."""
|
|
37
58
|
return {
|
|
38
59
|
dest_id: self._control.dests[dest_id] for dest_id in self._destination_ids
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
@property
|
|
42
63
|
def scenes(self) -> dict:
|
|
64
|
+
"""Return the scenes of the room by id."""
|
|
43
65
|
return {
|
|
44
66
|
scene_id: self._control.scenes[scene_id] for scene_id in self._scene_ids
|
|
45
67
|
}
|
|
@@ -47,6 +69,7 @@ class Room:
|
|
|
47
69
|
# --- Public methods ---
|
|
48
70
|
|
|
49
71
|
def diag(self) -> dict:
|
|
72
|
+
"""Return the room for the diagnostics."""
|
|
50
73
|
return {
|
|
51
74
|
"id": self.id,
|
|
52
75
|
"name": self.name,
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
"""A scene of the gateway."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
2
4
|
|
|
3
5
|
from .room import Room
|
|
4
6
|
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .webcontrol import WebControlPro
|
|
9
|
+
|
|
5
10
|
|
|
6
11
|
class Scene:
|
|
7
|
-
|
|
12
|
+
"""A scene that the gateway can execute."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, control: WebControlPro, id: int, names: list[str]) -> None:
|
|
8
15
|
self._control = control
|
|
9
16
|
self._id = id
|
|
10
17
|
self._names = names
|
|
@@ -15,8 +22,8 @@ class Scene:
|
|
|
15
22
|
def __repr__(self) -> str:
|
|
16
23
|
return f"<Scene {self.id}: {self}>"
|
|
17
24
|
|
|
18
|
-
def __eq__(self, other) -> bool:
|
|
19
|
-
return self.id == other.id
|
|
25
|
+
def __eq__(self, other: object) -> bool:
|
|
26
|
+
return isinstance(other, Scene) and self.id == other.id
|
|
20
27
|
|
|
21
28
|
def __hash__(self) -> int:
|
|
22
29
|
return hash(self.id)
|
|
@@ -25,18 +32,22 @@ class Scene:
|
|
|
25
32
|
|
|
26
33
|
@property
|
|
27
34
|
def host(self) -> str:
|
|
35
|
+
"""Return the host of the gateway."""
|
|
28
36
|
return self._control.host
|
|
29
37
|
|
|
30
38
|
@property
|
|
31
39
|
def id(self) -> int:
|
|
40
|
+
"""Return the id of the scene."""
|
|
32
41
|
return self._id
|
|
33
42
|
|
|
34
43
|
@property
|
|
35
44
|
def name(self) -> str:
|
|
45
|
+
"""Return the name of the scene."""
|
|
36
46
|
return self._names[0]
|
|
37
47
|
|
|
38
48
|
@property
|
|
39
49
|
def room(self) -> Room | None:
|
|
50
|
+
"""Return the room of the scene, if it belongs to one."""
|
|
40
51
|
for room in self._control.rooms.values():
|
|
41
52
|
if self._id in room._scene_ids:
|
|
42
53
|
return room
|
|
@@ -44,10 +55,12 @@ class Scene:
|
|
|
44
55
|
|
|
45
56
|
# --- Public methods ---
|
|
46
57
|
|
|
47
|
-
async def __call__(self, **kwargs) -> Any:
|
|
58
|
+
async def __call__(self, **kwargs: Any) -> Any:
|
|
59
|
+
"""Execute the scene and return the answer of the gateway."""
|
|
48
60
|
return await self._control._sceneActions(self._id, **kwargs)
|
|
49
61
|
|
|
50
62
|
def diag(self) -> dict:
|
|
63
|
+
"""Return the scene for the diagnostics."""
|
|
51
64
|
return {
|
|
52
65
|
"id": self.id,
|
|
53
66
|
"name": self.name,
|
|
@@ -81,12 +81,20 @@ class CoverModel:
|
|
|
81
81
|
@property
|
|
82
82
|
def is_opening(self) -> bool:
|
|
83
83
|
"""Return True while an interpolated drive moves towards open."""
|
|
84
|
-
return
|
|
84
|
+
return (
|
|
85
|
+
self._target is not None
|
|
86
|
+
and self._start is not None
|
|
87
|
+
and self._target > self._start
|
|
88
|
+
)
|
|
85
89
|
|
|
86
90
|
@property
|
|
87
91
|
def is_closing(self) -> bool:
|
|
88
92
|
"""Return True while an interpolated drive moves towards closed."""
|
|
89
|
-
return
|
|
93
|
+
return (
|
|
94
|
+
self._target is not None
|
|
95
|
+
and self._start is not None
|
|
96
|
+
and self._target < self._start
|
|
97
|
+
)
|
|
90
98
|
|
|
91
99
|
@property
|
|
92
100
|
def position_int(self) -> int | None:
|
|
@@ -109,7 +117,12 @@ class CoverModel:
|
|
|
109
117
|
|
|
110
118
|
Returns True if a drive reached its target with this call.
|
|
111
119
|
"""
|
|
112
|
-
if
|
|
120
|
+
if (
|
|
121
|
+
self._target is None
|
|
122
|
+
or self._start is None
|
|
123
|
+
or self._started_at is None
|
|
124
|
+
or self._duration is None
|
|
125
|
+
):
|
|
113
126
|
return False
|
|
114
127
|
elapsed = max(0.0, now - self._started_at)
|
|
115
128
|
if elapsed >= self._duration:
|
|
@@ -235,7 +248,7 @@ class CoverModel:
|
|
|
235
248
|
"position": self.position_int,
|
|
236
249
|
"tilt": self.tilt,
|
|
237
250
|
"target_position": (
|
|
238
|
-
int(self._target + 0.5) if self.
|
|
251
|
+
int(self._target + 0.5) if self._target is not None else None
|
|
239
252
|
),
|
|
240
253
|
"target_tilt": self._pending_tilt if self.is_travelling else None,
|
|
241
254
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
"""Client of the local API of the WMS WebControl pro gateway."""
|
|
2
|
+
|
|
1
3
|
import asyncio
|
|
2
4
|
import pprint
|
|
3
5
|
import time
|
|
4
6
|
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
5
8
|
from datetime import timedelta
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
from types import MappingProxyType
|
|
8
11
|
from typing import Any
|
|
9
12
|
|
|
10
|
-
from aiohttp import ClientSession
|
|
13
|
+
from aiohttp import ClientSession, ClientTimeout
|
|
11
14
|
|
|
12
15
|
from .const import (
|
|
13
16
|
WMS_WebControl_pro_API_actionDescription,
|
|
@@ -31,7 +34,17 @@ DEFAULT_RETRY_DELAY = 1.0
|
|
|
31
34
|
DEFAULT_REQUEST_SPACING = 0.5
|
|
32
35
|
|
|
33
36
|
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ConfigChanges:
|
|
39
|
+
"""Destinations that appeared or disappeared with a configuration refresh."""
|
|
40
|
+
|
|
41
|
+
added: frozenset[int] = frozenset()
|
|
42
|
+
removed: frozenset[int] = frozenset()
|
|
43
|
+
|
|
44
|
+
|
|
34
45
|
class WebControlPro:
|
|
46
|
+
"""Client of one WMS WebControl pro gateway."""
|
|
47
|
+
|
|
35
48
|
def __init__(
|
|
36
49
|
self,
|
|
37
50
|
host: str,
|
|
@@ -42,7 +55,9 @@ class WebControlPro:
|
|
|
42
55
|
retry_delay: float = DEFAULT_RETRY_DELAY,
|
|
43
56
|
request_spacing: float = DEFAULT_REQUEST_SPACING,
|
|
44
57
|
) -> None:
|
|
45
|
-
"""Create a client
|
|
58
|
+
"""Create a client.
|
|
59
|
+
|
|
60
|
+
stale_warning_after sets how long a destination may
|
|
46
61
|
stay silent before a warning is logged, retry_delay the pause in
|
|
47
62
|
seconds between retries after a radio busy answer and request_spacing
|
|
48
63
|
the minimum pause in seconds between two status requests.
|
|
@@ -57,14 +72,14 @@ class WebControlPro:
|
|
|
57
72
|
self._control = f"http://{host}/commonCommand"
|
|
58
73
|
self._session = session
|
|
59
74
|
self._persist = Path(persist) if persist else None
|
|
60
|
-
self._config = {}
|
|
61
|
-
self._dests = {}
|
|
62
|
-
self._rooms = {}
|
|
63
|
-
self._scenes = {}
|
|
75
|
+
self._config: dict[str, Any] = {}
|
|
76
|
+
self._dests: dict[int, Destination] = {}
|
|
77
|
+
self._rooms: dict[int, Room] = {}
|
|
78
|
+
self._scenes: dict[int, Scene] = {}
|
|
64
79
|
|
|
65
80
|
# --- Private methods ---
|
|
66
81
|
|
|
67
|
-
async def _commonCommand(self, command: str, **kwargs) -> Any:
|
|
82
|
+
async def _commonCommand(self, command: str, **kwargs: Any) -> Any:
|
|
68
83
|
data = {
|
|
69
84
|
"protocolVersion": WMS_WebControl_pro_API_protocolVersion,
|
|
70
85
|
"command": command,
|
|
@@ -73,7 +88,9 @@ class WebControlPro:
|
|
|
73
88
|
data.update(kwargs)
|
|
74
89
|
async with (
|
|
75
90
|
self._common_lock,
|
|
76
|
-
self._session.post(
|
|
91
|
+
self._session.post(
|
|
92
|
+
url=self._control, json=data, timeout=ClientTimeout(total=5)
|
|
93
|
+
) as response,
|
|
77
94
|
):
|
|
78
95
|
return await response.json()
|
|
79
96
|
|
|
@@ -99,7 +116,11 @@ class WebControlPro:
|
|
|
99
116
|
self._status_last = time.monotonic()
|
|
100
117
|
|
|
101
118
|
async def _action(
|
|
102
|
-
self,
|
|
119
|
+
self,
|
|
120
|
+
actions: list,
|
|
121
|
+
responseType: WMS_WebControl_pro_API_responseType = (
|
|
122
|
+
WMS_WebControl_pro_API_responseType.Instant
|
|
123
|
+
),
|
|
103
124
|
) -> Any:
|
|
104
125
|
return await self._commonCommand(
|
|
105
126
|
WMS_WebControl_pro_API_command_action,
|
|
@@ -110,8 +131,12 @@ class WebControlPro:
|
|
|
110
131
|
async def _sceneActions(
|
|
111
132
|
self,
|
|
112
133
|
sceneId: int,
|
|
113
|
-
sceneActionType=
|
|
114
|
-
|
|
134
|
+
sceneActionType: WMS_WebControl_pro_API_sceneActionType = (
|
|
135
|
+
WMS_WebControl_pro_API_sceneActionType.Execute
|
|
136
|
+
),
|
|
137
|
+
responseType: WMS_WebControl_pro_API_responseType = (
|
|
138
|
+
WMS_WebControl_pro_API_responseType.Instant
|
|
139
|
+
),
|
|
115
140
|
) -> Any:
|
|
116
141
|
return await self._commonCommand(
|
|
117
142
|
WMS_WebControl_pro_API_command_sceneActions,
|
|
@@ -123,25 +148,41 @@ class WebControlPro:
|
|
|
123
148
|
# --- Public methods ---
|
|
124
149
|
|
|
125
150
|
async def ping(self) -> bool:
|
|
151
|
+
"""Return True if the gateway answers the ping."""
|
|
126
152
|
ping = await self._ping()
|
|
127
153
|
return ping["status"] == 0
|
|
128
154
|
|
|
129
|
-
async def refresh(self) ->
|
|
155
|
+
async def refresh(self) -> ConfigChanges:
|
|
156
|
+
"""Read the configuration and create destinations, rooms and scenes.
|
|
157
|
+
|
|
158
|
+
A destination that is already known stays the same object, so its
|
|
159
|
+
actions keep their movement model and stored state. Only new
|
|
160
|
+
destinations are created, and the result names the destinations that
|
|
161
|
+
appeared and disappeared. Rooms and scenes are created again.
|
|
162
|
+
"""
|
|
130
163
|
config = await self._getConfiguration()
|
|
131
164
|
self._config = config
|
|
165
|
+
known = self._dests
|
|
132
166
|
self._dests = {
|
|
133
|
-
dest["id"]: Destination(self, **dest)
|
|
167
|
+
dest["id"]: known.get(dest["id"]) or Destination(self, **dest)
|
|
168
|
+
for dest in config["destinations"]
|
|
134
169
|
}
|
|
135
170
|
self._rooms = {room["id"]: Room(self, **room) for room in config["rooms"]}
|
|
136
171
|
self._scenes = {scene["id"]: Scene(self, **scene) for scene in config["scenes"]}
|
|
172
|
+
return ConfigChanges(
|
|
173
|
+
added=frozenset(self._dests) - frozenset(known),
|
|
174
|
+
removed=frozenset(known) - frozenset(self._dests),
|
|
175
|
+
)
|
|
137
176
|
|
|
138
177
|
def dest(self, name: str) -> Destination:
|
|
178
|
+
"""Return the destination with the given name or raise KeyError."""
|
|
139
179
|
for dest in self._dests.values():
|
|
140
180
|
if dest.name == name:
|
|
141
181
|
return dest
|
|
142
182
|
raise KeyError(f"Destination with name '{name}' not found")
|
|
143
183
|
|
|
144
184
|
def diag(self) -> dict:
|
|
185
|
+
"""Return the client for the diagnostics."""
|
|
145
186
|
return {
|
|
146
187
|
"host": self._host,
|
|
147
188
|
"config": self._config,
|
|
@@ -154,26 +195,32 @@ class WebControlPro:
|
|
|
154
195
|
|
|
155
196
|
@property
|
|
156
197
|
def host(self) -> str:
|
|
198
|
+
"""Return the host of the gateway."""
|
|
157
199
|
return self._host
|
|
158
200
|
|
|
159
201
|
@property
|
|
160
202
|
def config(self) -> Mapping:
|
|
203
|
+
"""Return the configuration of the gateway."""
|
|
161
204
|
return MappingProxyType(self._config)
|
|
162
205
|
|
|
163
206
|
@property
|
|
164
207
|
def dests(self) -> Mapping:
|
|
208
|
+
"""Return the destinations by id."""
|
|
165
209
|
return MappingProxyType(self._dests)
|
|
166
210
|
|
|
167
211
|
@property
|
|
168
212
|
def rooms(self) -> Mapping:
|
|
213
|
+
"""Return the rooms by id."""
|
|
169
214
|
return MappingProxyType(self._rooms)
|
|
170
215
|
|
|
171
216
|
@property
|
|
172
217
|
def scenes(self) -> Mapping:
|
|
218
|
+
"""Return the scenes by id."""
|
|
173
219
|
return MappingProxyType(self._scenes)
|
|
174
220
|
|
|
175
221
|
|
|
176
|
-
async def async_main_test():
|
|
222
|
+
async def async_main_test() -> None:
|
|
223
|
+
"""Run the example against a gateway named webcontrol."""
|
|
177
224
|
async with ClientSession() as session:
|
|
178
225
|
control = WebControlPro("webcontrol", session)
|
|
179
226
|
pprint.pprint(await control.ping())
|
|
@@ -3,6 +3,8 @@ line-length = 88
|
|
|
3
3
|
|
|
4
4
|
[lint]
|
|
5
5
|
select = [
|
|
6
|
+
"ANN", # type annotations
|
|
7
|
+
"D", # docstrings
|
|
6
8
|
"ASYNC", # blocking calls in async code
|
|
7
9
|
"B", # bugbear
|
|
8
10
|
"C4", # comprehensions
|
|
@@ -21,11 +23,17 @@ select = [
|
|
|
21
23
|
"W", # pycodestyle warnings
|
|
22
24
|
]
|
|
23
25
|
ignore = [
|
|
26
|
+
"ANN401", # the answers of the gateway are untyped JSON
|
|
27
|
+
"D105", # magic methods are self-explanatory
|
|
28
|
+
"D107", # the class docstring describes the constructor
|
|
29
|
+
"D203", # incompatible with D211
|
|
30
|
+
"D213", # incompatible with D212
|
|
24
31
|
"E501", # line length is enforced by the formatter
|
|
25
32
|
]
|
|
26
33
|
|
|
27
34
|
[lint.per-file-ignores]
|
|
28
|
-
"
|
|
35
|
+
"tests/**" = ["D", "ANN"]
|
|
36
|
+
"simulator/**" = ["D", "ANN", "T20"]
|
|
29
37
|
"pywmswebcontrolpro/webcontrol.py" = ["T20"] # example main prints
|
|
30
38
|
|
|
31
39
|
[lint.isort]
|
|
@@ -106,12 +106,13 @@ class FakeGateway:
|
|
|
106
106
|
def __init__(self) -> None:
|
|
107
107
|
self.status_answers: list[dict[str, Any] | Exception] = []
|
|
108
108
|
self.requests: list[dict[str, Any]] = []
|
|
109
|
+
self.config = CONFIG
|
|
109
110
|
|
|
110
111
|
def post(self, url: str, json: dict[str, Any], timeout: int) -> FakeRequest:
|
|
111
112
|
"""Answer one request of the library."""
|
|
112
113
|
self.requests.append(json)
|
|
113
114
|
if json["command"] == "getConfiguration":
|
|
114
|
-
return FakeRequest(
|
|
115
|
+
return FakeRequest(self.config)
|
|
115
116
|
return FakeRequest(self.status_answers.pop(0))
|
|
116
117
|
|
|
117
118
|
@property
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for reading the configuration of the gateway again."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pywmswebcontrolpro.webcontrol import ConfigChanges, WebControlPro
|
|
8
|
+
|
|
9
|
+
from .conftest import CONFIG, DEST_ID, FakeGateway
|
|
10
|
+
|
|
11
|
+
NEW_DEST_ID = 8
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _config_with(*dest_ids: int) -> dict[str, Any]:
|
|
15
|
+
"""Return a configuration with copies of the test destination."""
|
|
16
|
+
config = deepcopy(CONFIG)
|
|
17
|
+
template = config["destinations"][0]
|
|
18
|
+
config["destinations"] = [{**template, "id": dest_id} for dest_id in dest_ids]
|
|
19
|
+
return config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def test_first_refresh_reports_all_destinations_as_added(
|
|
23
|
+
make_control: Callable[..., WebControlPro],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""The first read of the configuration finds the destinations."""
|
|
26
|
+
changes = await make_control().refresh()
|
|
27
|
+
|
|
28
|
+
assert changes == ConfigChanges(added=frozenset({DEST_ID}))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def test_refresh_reports_added_and_removed_destinations(
|
|
32
|
+
make_control: Callable[..., WebControlPro], gateway: FakeGateway
|
|
33
|
+
) -> None:
|
|
34
|
+
"""A destination that appears or disappears is named in the result."""
|
|
35
|
+
control = make_control()
|
|
36
|
+
await control.refresh()
|
|
37
|
+
gateway.config = _config_with(NEW_DEST_ID)
|
|
38
|
+
|
|
39
|
+
changes = await control.refresh()
|
|
40
|
+
|
|
41
|
+
assert changes == ConfigChanges(
|
|
42
|
+
added=frozenset({NEW_DEST_ID}), removed=frozenset({DEST_ID})
|
|
43
|
+
)
|
|
44
|
+
assert set(control.dests) == {NEW_DEST_ID}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_refresh_keeps_the_known_destinations(
|
|
48
|
+
make_control: Callable[..., WebControlPro], gateway: FakeGateway
|
|
49
|
+
) -> None:
|
|
50
|
+
"""A known destination stays the same object with its state."""
|
|
51
|
+
control = make_control()
|
|
52
|
+
await control.refresh()
|
|
53
|
+
known = control.dests[DEST_ID]
|
|
54
|
+
gateway.config = _config_with(DEST_ID, NEW_DEST_ID)
|
|
55
|
+
|
|
56
|
+
changes = await control.refresh()
|
|
57
|
+
|
|
58
|
+
assert changes == ConfigChanges(added=frozenset({NEW_DEST_ID}))
|
|
59
|
+
assert control.dests[DEST_ID] is known
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|