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.
Files changed (32) hide show
  1. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.github/workflows/validate.yaml +1 -0
  2. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/PKG-INFO +2 -2
  3. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/README.md +1 -1
  4. pywmswebcontrolpro-0.1.2/mypy.ini +6 -0
  5. pywmswebcontrolpro-0.1.2/pywmswebcontrolpro/__init__.py +1 -0
  6. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/action.py +42 -10
  7. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/const.py +14 -0
  8. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/destination.py +30 -6
  9. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/room.py +26 -3
  10. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/scene.py +18 -5
  11. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/travel.py +17 -4
  12. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/webcontrol.py +61 -14
  13. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/requirements_lint.txt +1 -0
  14. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/ruff.toml +9 -1
  15. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/conftest.py +2 -1
  16. pywmswebcontrolpro-0.1.2/tests/test_configuration.py +59 -0
  17. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.github/workflows/release.yaml +0 -0
  18. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.gitignore +0 -0
  19. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/.python-version +0 -0
  20. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/LICENSE +0 -0
  21. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pyproject.toml +0 -0
  22. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pytest.ini +0 -0
  23. /pywmswebcontrolpro-0.1.0/pywmswebcontrolpro/__init__.py → /pywmswebcontrolpro-0.1.2/pywmswebcontrolpro/py.typed +0 -0
  24. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/refresh.py +0 -0
  25. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/pywmswebcontrolpro/store.py +0 -0
  26. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/requirements_test.txt +0 -0
  27. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/simulator/simulator.py +0 -0
  28. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/__init__.py +0 -0
  29. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_import.py +0 -0
  30. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_refresh.py +0 -0
  31. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_settings.py +0 -0
  32. {pywmswebcontrolpro-0.1.0 → pywmswebcontrolpro-0.1.2}/tests/test_travel.py +0 -0
@@ -16,6 +16,7 @@ jobs:
16
16
  - run: python -m pip install -r requirements_lint.txt
17
17
  - run: ruff check .
18
18
  - run: ruff format --check .
19
+ - run: mypy
19
20
  test:
20
21
  runs-on: ubuntu-24.04
21
22
  steps:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pywmswebcontrolpro
3
- Version: 0.1.0
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,6 @@
1
+ [mypy]
2
+ python_version = 3.14
3
+ files = pywmswebcontrolpro
4
+
5
+ [mypy-aiohttp.*,aiofiles.*]
6
+ ignore_missing_imports = True
@@ -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
- def __init__(self, control) -> None:
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, responseType=WMS_WebControl_pro_API_responseType.Instant
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, dest, id: int, actionType: int, actionDescription: int, **kwargs
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, responseType=WMS_WebControl_pro_API_responseType.Instant, **kwargs
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, control, id: int, names: list, actions: list, animationType: int
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
- def __init__(self, control, id: int, name: str, destinations: list, scenes: list):
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
- from typing import Any
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
- def __init__(self, control, id: int, names: list):
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 self.is_travelling and self._target > self._start
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 self.is_travelling and self._target < self._start
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 not self.is_travelling:
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.is_travelling else None
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; stale_warning_after sets how long a destination may
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(url=self._control, json=data, timeout=5) as response,
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, actions: list, responseType=WMS_WebControl_pro_API_responseType.Instant
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=WMS_WebControl_pro_API_sceneActionType.Execute,
114
- responseType=WMS_WebControl_pro_API_responseType.Instant,
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) -> None:
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) for dest in config["destinations"]
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())
@@ -1 +1,2 @@
1
1
  ruff==0.17.3
2
+ mypy==2.4.1
@@ -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
- "simulator/**" = ["T20"]
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(CONFIG)
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