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.
File without changes
@@ -0,0 +1,226 @@
1
+ import math
2
+ from dataclasses import asdict, replace
3
+ from typing import Any
4
+
5
+ from .const import (
6
+ WMS_WebControl_pro_API_actionDescription,
7
+ WMS_WebControl_pro_API_actionType,
8
+ WMS_WebControl_pro_API_responseType,
9
+ )
10
+ from .store import STORE_VERSION, ActionSettings, JsonStore, TravelDirection
11
+ from .travel import CoverModel, Measurement
12
+
13
+ _STORED_KEYS = frozenset({"version", "overwrites", "settings", "state"})
14
+
15
+
16
+ class ActionList(list):
17
+ def __init__(self, control) -> None:
18
+ super().__init__()
19
+ self._control = control
20
+
21
+ async def __call__(
22
+ self, responseType=WMS_WebControl_pro_API_responseType.Instant
23
+ ) -> Any:
24
+ if len(self) == 0:
25
+ raise ValueError("ActionList is empty")
26
+ return await self._control._action(
27
+ actions=self,
28
+ responseType=responseType,
29
+ )
30
+
31
+
32
+ class Action:
33
+ def __init__(
34
+ self, dest, id: int, actionType: int, actionDescription: int, **kwargs
35
+ ) -> None:
36
+ self._dest = dest
37
+ self._id = id
38
+ self._persist = (dest._persist / f"{id}.json") if dest._persist else None
39
+ self._store = JsonStore(self._persist)
40
+ self._actionType = WMS_WebControl_pro_API_actionType(actionType)
41
+ self._actionDescription = WMS_WebControl_pro_API_actionDescription(
42
+ actionDescription
43
+ )
44
+ self._attrs = kwargs
45
+ self._params = {}
46
+ self._overwrites = {}
47
+ self._needs_load = True
48
+ self._needs_save = False
49
+ self._extra: dict[str, Any] = {}
50
+ self._settings = ActionSettings()
51
+ self._saved_state: dict[str, Any] | None = None
52
+ self.model = CoverModel()
53
+
54
+ def __str__(self) -> str:
55
+ return self.actionDescription.name
56
+
57
+ def __repr__(self) -> str:
58
+ return f"<Action {self.id}: {self} ({self.actionType.name})>"
59
+
60
+ def __eq__(self, other) -> bool:
61
+ return self.id == other.id
62
+
63
+ def __hash__(self) -> int:
64
+ return hash(self.id)
65
+
66
+ # --- Properties ---
67
+
68
+ @property
69
+ def host(self) -> str:
70
+ return self._dest.host
71
+
72
+ @property
73
+ def id(self) -> int:
74
+ return self._id
75
+
76
+ @property
77
+ def actionType(self) -> WMS_WebControl_pro_API_actionType:
78
+ return self._actionType
79
+
80
+ @property
81
+ def actionDescription(self) -> WMS_WebControl_pro_API_actionDescription:
82
+ return self._actionDescription
83
+
84
+ # --- Private methods ---
85
+
86
+ def _update_params(self, value: dict) -> None:
87
+ self._params.update(value)
88
+
89
+ async def _load_persists(self) -> None:
90
+ data = await self._store.load()
91
+ self._extra = {k: v for k, v in data.items() if k not in _STORED_KEYS}
92
+ overwrites = data.get("overwrites")
93
+ if isinstance(overwrites, dict):
94
+ self._overwrites = overwrites
95
+ self._settings = ActionSettings.from_dict(data.get("settings"))
96
+ state = data.get("state")
97
+ if isinstance(state, dict):
98
+ self.model.restore(
99
+ state.get("position"),
100
+ state.get("tilt"),
101
+ state.get("target_position"),
102
+ state.get("target_tilt"),
103
+ )
104
+ self._saved_state = self.model.snapshot()
105
+
106
+ async def _save_persists(self) -> None:
107
+ state = self.model.snapshot()
108
+ await self._store.save(
109
+ {
110
+ **self._extra,
111
+ "version": STORE_VERSION,
112
+ "overwrites": self._overwrites,
113
+ "settings": asdict(self._settings),
114
+ "state": state,
115
+ }
116
+ )
117
+ self._saved_state = state
118
+ self._needs_save = False
119
+
120
+ async def _ensure_loaded(self) -> None:
121
+ if self._needs_load:
122
+ self._needs_load = False
123
+ await self._load_persists()
124
+
125
+ # --- Public methods ---
126
+
127
+ @property
128
+ def settings(self) -> ActionSettings:
129
+ """Return the settings of this action."""
130
+ return self._settings
131
+
132
+ async def set_travel_time(self, direction: TravelDirection, seconds: float) -> None:
133
+ """Store the travel time of a full drive in seconds, 0 means unknown."""
134
+ if not (math.isfinite(seconds) and seconds >= 0):
135
+ raise ValueError(f"Travel time must be a number >= 0: {seconds}")
136
+ await self._ensure_loaded()
137
+ self._settings = replace(
138
+ self._settings, **{f"travel_time_{direction.value}": float(seconds)}
139
+ )
140
+ await self._save_persists()
141
+
142
+ def start_move(self, now: float, target: float, tilt: int | None = None) -> float:
143
+ """Handle an accepted drive command with the stored travel times.
144
+
145
+ Returns the delay in seconds until the settle poll.
146
+ """
147
+ return self.model.start_move(
148
+ now,
149
+ target,
150
+ self._settings.travel_time(TravelDirection.OPEN),
151
+ self._settings.travel_time(TravelDirection.CLOSE),
152
+ tilt,
153
+ )
154
+
155
+ def apply_measurement(
156
+ self, now: float, position: int | None, tilt: int | None
157
+ ) -> Measurement:
158
+ """Take over a measured position unless a drive is running."""
159
+ return self.model.apply_measurement(now, position, tilt)
160
+
161
+ async def sync(self) -> None:
162
+ """Load stored data once and store changes of overwrites, settings and state.
163
+
164
+ Call this after commands and measurements. Destination.refresh does it too.
165
+ """
166
+ await self._ensure_loaded()
167
+ if self._needs_save or self.model.snapshot() != self._saved_state:
168
+ await self._save_persists()
169
+
170
+ def __getattr__(self, name: str) -> Any:
171
+ if name in self._overwrites:
172
+ return self._overwrites[name]
173
+ if name.startswith("wms__"):
174
+ name = name[5:]
175
+ return self._attrs.get(name)
176
+
177
+ def __getitem__(self, name: str) -> Any:
178
+ if name in self._overwrites:
179
+ return self._overwrites[name]
180
+ return self._params.get(name)
181
+
182
+ def __setitem__(self, name: str, value: Any) -> None:
183
+ if name in self._attrs:
184
+ self._overwrites[name] = value
185
+ self._needs_save = True
186
+ elif name in self._params:
187
+ self._params[name] = value
188
+
189
+ def __delitem__(self, name: str) -> None:
190
+ if name in self._overwrites:
191
+ del self._overwrites[name]
192
+ self._needs_save = True
193
+
194
+ def prep(self, **kwargs) -> ActionList:
195
+ actionList = ActionList(self._dest._control)
196
+ actionList.append(
197
+ {
198
+ "destinationId": self._dest.id,
199
+ "actionId": self.id,
200
+ "parameters": kwargs,
201
+ }
202
+ )
203
+ return actionList
204
+
205
+ async def __call__(
206
+ self, responseType=WMS_WebControl_pro_API_responseType.Instant, **kwargs
207
+ ) -> Any:
208
+ return await self._dest._control._action(
209
+ actions=self.prep(**kwargs),
210
+ responseType=responseType,
211
+ )
212
+
213
+ def diag(self) -> dict:
214
+ return {
215
+ "id": self.id,
216
+ "actionType": self.actionType.name,
217
+ "actionDescription": self.actionDescription.name,
218
+ "attrs": self._attrs,
219
+ "params": self._params,
220
+ "overwrites": self._overwrites,
221
+ "settings": asdict(self._settings),
222
+ "model": {
223
+ **self.model.snapshot(),
224
+ "source": self.model.source.value if self.model.source else None,
225
+ },
226
+ }
@@ -0,0 +1,81 @@
1
+ from enum import IntEnum
2
+
3
+ WMS_WebControl_pro_API_protocolVersion = "1.0"
4
+ WMS_WebControl_pro_API_source = 2
5
+
6
+ WMS_WebControl_pro_API_command_ping = "ping"
7
+ WMS_WebControl_pro_API_command_getConfiguration = "getConfiguration"
8
+ WMS_WebControl_pro_API_command_getStatus = "getStatus"
9
+ WMS_WebControl_pro_API_command_action = "action"
10
+ WMS_WebControl_pro_API_command_sceneActions = "sceneActions"
11
+
12
+
13
+ class WMS_WebControl_pro_API_animationType(IntEnum):
14
+ VenetianBlind = 0
15
+ Awning = 1
16
+ RollerShutterBlind = 2
17
+ SlatRoof = 3
18
+ Window = 4
19
+ Switch = 5
20
+ Dimmer = 6
21
+ Unknown = 999
22
+
23
+
24
+ class WMS_WebControl_pro_API_actionType(IntEnum):
25
+ Percentage = 0
26
+ PercentageDelta = 1
27
+ Rotation = 2
28
+ RotationDelta = 3
29
+ Switch = 4
30
+ Toggle = 5
31
+ Stop = 6
32
+ Impulse = 7
33
+ Identify = 8
34
+ Enumeration = 9
35
+ Unknown = 999
36
+
37
+
38
+ class WMS_WebControl_pro_API_actionDescription(IntEnum):
39
+ AwningDrive = 0
40
+ ValanceDrive = 1
41
+ SlatDrive = 2
42
+ SlatRotate = 3
43
+ RollerShutterBlindDrive = 4
44
+ WindowDrive = 5
45
+ LightSwitch = 6
46
+ LoadSwitch = 7
47
+ LightDimming = 8
48
+ LoadDimming = 9
49
+ LightToggle = 10
50
+ LastToggle = 11
51
+ ManualCommand = 12
52
+ Identify = 13
53
+ Unknown = 999
54
+
55
+
56
+ class WMS_WebControl_pro_API_responseType(IntEnum):
57
+ Instant = 0
58
+ Detailed = 1
59
+
60
+
61
+ class WMS_WebControl_pro_API_sceneActionType(IntEnum):
62
+ Relearn = 0
63
+ Execute = 1
64
+
65
+
66
+ class WMS_WebControl_pro_API_drivingCause(IntEnum):
67
+ _None = 0 # No driving cause
68
+ Sun = 1
69
+ DuskDawn = 2
70
+ Wind = 3
71
+ Rain = 4
72
+ Ice = 5
73
+ Temperature = 6
74
+ SwitchingTime = 7
75
+ Scene = 8
76
+ ControlMode = 9
77
+ Manual = 10
78
+ Safety = 11
79
+ Contact = 12
80
+ CentralCommand = 13
81
+ Unknown = 999
@@ -0,0 +1,250 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections.abc import Mapping
5
+ from types import MappingProxyType
6
+ from typing import Any
7
+
8
+ import aiohttp
9
+
10
+ from .action import Action
11
+ from .const import (
12
+ WMS_WebControl_pro_API_actionDescription,
13
+ WMS_WebControl_pro_API_actionType,
14
+ WMS_WebControl_pro_API_animationType,
15
+ WMS_WebControl_pro_API_drivingCause,
16
+ )
17
+ from .refresh import (
18
+ ERROR_RADIO_BUSY,
19
+ RefreshOutcome,
20
+ RefreshResult,
21
+ RefreshStats,
22
+ StaleChange,
23
+ )
24
+ from .room import Room
25
+
26
+ DEFAULT_REFRESH_RETRIES = 2
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+
31
+ class Destination:
32
+ def __init__(
33
+ self, control, id: int, names: list, actions: list, animationType: int
34
+ ) -> None:
35
+ self._control = control
36
+ self._id = id
37
+ self._names = names
38
+ self._persist = (control._persist / str(id)) if control._persist else None
39
+ self._actions = {action["id"]: Action(self, **action) for action in actions}
40
+ self._animationType = WMS_WebControl_pro_API_animationType(animationType)
41
+ self._drivingCause = WMS_WebControl_pro_API_drivingCause.Unknown
42
+ self._unknownProducts = {}
43
+ self._heartbeatError = None
44
+ self._blocking = None
45
+ self._status = {}
46
+ self._stats = RefreshStats()
47
+
48
+ def __str__(self) -> str:
49
+ return self.name
50
+
51
+ def __repr__(self) -> str:
52
+ return f"<Destination {self.id}: {self}>"
53
+
54
+ def __eq__(self, other) -> bool:
55
+ return self.id == other.id
56
+
57
+ def __hash__(self) -> int:
58
+ return hash(self.id)
59
+
60
+ # --- Properties ---
61
+
62
+ @property
63
+ def host(self) -> str:
64
+ return self._control.host
65
+
66
+ @property
67
+ def id(self) -> int:
68
+ return self._id
69
+
70
+ @property
71
+ def name(self) -> str:
72
+ return self._names[0]
73
+
74
+ @property
75
+ def actions(self) -> dict:
76
+ return self._actions
77
+
78
+ @property
79
+ def animationType(self) -> WMS_WebControl_pro_API_animationType:
80
+ return self._animationType
81
+
82
+ @property
83
+ def drivingCause(self) -> WMS_WebControl_pro_API_drivingCause:
84
+ return self._drivingCause
85
+
86
+ @property
87
+ def room(self) -> Room | None:
88
+ for room in self._control.rooms.values():
89
+ if self._id in room._destination_ids:
90
+ return room
91
+ return None
92
+
93
+ @property
94
+ def available(self) -> bool:
95
+ return not (self._heartbeatError or self._blocking)
96
+
97
+ @property
98
+ def status(self) -> Mapping:
99
+ return MappingProxyType(self._status)
100
+
101
+ @property
102
+ def stats(self) -> RefreshStats:
103
+ """Return a snapshot of the refresh counters of this destination."""
104
+ return self._stats.snapshot()
105
+
106
+ # --- Public methods ---
107
+
108
+ async def sync(self) -> None:
109
+ """Load stored data once and store changes of all actions."""
110
+ for action in self._actions.values():
111
+ await action.sync()
112
+
113
+ async def refresh(
114
+ self, retries: int = DEFAULT_REFRESH_RETRIES, *, raise_on_error: bool = False
115
+ ) -> RefreshOutcome:
116
+ """Request the status once and retry up to retries times if radio busy.
117
+
118
+ With retries=0 exactly one request is sent. On any failure the
119
+ previously known values stay unchanged. The outcome is counted in
120
+ stats instead of being logged for every failure.
121
+ """
122
+ await self.sync()
123
+
124
+ outcome = await self._request_status(retries, raise_on_error)
125
+ self._record(outcome.result)
126
+ return outcome
127
+
128
+ async def _request_status(
129
+ self, retries: int, raise_on_error: bool
130
+ ) -> RefreshOutcome:
131
+ status: dict = {}
132
+ for attempt in range(retries + 1):
133
+ try:
134
+ status = await self._control._getStatus(self._id)
135
+ except aiohttp.ClientError, TimeoutError:
136
+ if raise_on_error:
137
+ raise
138
+ return RefreshOutcome(RefreshResult.ERROR)
139
+ if ERROR_RADIO_BUSY not in ((status or {}).get("errors") or ()):
140
+ break
141
+ if attempt < retries:
142
+ await asyncio.sleep(self._control.retry_delay)
143
+ else:
144
+ return RefreshOutcome(RefreshResult.BUSY)
145
+
146
+ if not status:
147
+ return RefreshOutcome(RefreshResult.EMPTY)
148
+
149
+ self._status = status
150
+ values = self._apply_status(status)
151
+ if values is None:
152
+ return RefreshOutcome(RefreshResult.EMPTY)
153
+ return RefreshOutcome(RefreshResult.OK, values)
154
+
155
+ def _apply_status(self, status: dict) -> dict[int, dict[str, Any]] | None:
156
+ values: dict[int, dict[str, Any]] = {}
157
+ refreshed = False
158
+ for detail in status.get("details") or ():
159
+ if detail.get("destinationId") != self._id or "data" not in detail:
160
+ continue
161
+
162
+ refreshed = True
163
+ data = detail["data"]
164
+ if "drivingCause" in data:
165
+ self._drivingCause = WMS_WebControl_pro_API_drivingCause(
166
+ data["drivingCause"]
167
+ )
168
+ if "heartbeatError" in data:
169
+ self._heartbeatError = data["heartbeatError"]
170
+ if "blocking" in data:
171
+ self._blocking = data["blocking"]
172
+
173
+ for product in data.get("productData", []):
174
+ action_id = product["actionId"]
175
+ if action_id in self._actions and "value" in product:
176
+ self._actions[action_id]._update_params(product["value"])
177
+ values[action_id] = dict(product["value"])
178
+ continue
179
+
180
+ _LOGGER.debug(
181
+ "Unknown actionId %s in productData for %s (%s)",
182
+ action_id,
183
+ self,
184
+ self._id,
185
+ )
186
+ self._unknownProducts[action_id] = product
187
+
188
+ return values if refreshed else None
189
+
190
+ def _record(self, result: RefreshResult) -> None:
191
+ change = self._stats.record(
192
+ result,
193
+ now=time.monotonic(),
194
+ stale_after=self._control.stale_warning_after.total_seconds(),
195
+ )
196
+ if change is StaleChange.STALE:
197
+ _LOGGER.warning(
198
+ "No status for %s (%s) for %s, last result: %s",
199
+ self,
200
+ self._id,
201
+ self._control.stale_warning_after,
202
+ result.value,
203
+ )
204
+ elif change is StaleChange.RECOVERED:
205
+ _LOGGER.warning("Status for %s (%s) is available again", self, self._id)
206
+
207
+ def hasAction(
208
+ self,
209
+ actionDescription: WMS_WebControl_pro_API_actionDescription,
210
+ actionType: WMS_WebControl_pro_API_actionType | None = None,
211
+ ) -> bool:
212
+ for action in self._actions.values():
213
+ if action.actionDescription == actionDescription and (
214
+ actionType is None or actionType == action.actionType
215
+ ):
216
+ return True
217
+ return False
218
+
219
+ def action(
220
+ self,
221
+ actionDescription: WMS_WebControl_pro_API_actionDescription,
222
+ actionType: WMS_WebControl_pro_API_actionType | None = None,
223
+ ) -> Action:
224
+ for action in self._actions.values():
225
+ if action.actionDescription == actionDescription and (
226
+ actionType is None or actionType == action.actionType
227
+ ):
228
+ return action
229
+ description = actionDescription.name if actionDescription else "*"
230
+ type_name = actionType.name if actionType else "*"
231
+ raise NotImplementedError(
232
+ f"Failed to find action with description {description} "
233
+ f"and type {type_name} in {self} ({self._id})"
234
+ )
235
+
236
+ def diag(self) -> dict:
237
+ return {
238
+ "id": self.id,
239
+ "name": self.name,
240
+ "room": {self.room.id: self.room.name} if self.room else None,
241
+ "actions": {k: v.diag() for k, v in self._actions.items()},
242
+ "animationType": self.animationType.name,
243
+ "drivingCause": self.drivingCause.name,
244
+ "available": self.available,
245
+ "unknownProducts": self._unknownProducts,
246
+ "heartbeatError": self._heartbeatError,
247
+ "blocking": self._blocking,
248
+ "status": self._status,
249
+ "stats": self._stats.as_dict(),
250
+ }
@@ -0,0 +1,95 @@
1
+ """Result and statistics of status requests to a destination."""
2
+
3
+ from dataclasses import dataclass, field, replace
4
+ from datetime import UTC, datetime
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ # Error code of the hub: the radio channel is occupied and nothing was sent.
9
+ ERROR_RADIO_BUSY = 0x50005
10
+
11
+
12
+ class RefreshResult(StrEnum):
13
+ """Outcome of one status refresh of a destination."""
14
+
15
+ OK = "ok"
16
+ BUSY = "busy"
17
+ EMPTY = "empty"
18
+ ERROR = "error"
19
+
20
+
21
+ class StaleChange(StrEnum):
22
+ """Change of the silent state of a destination."""
23
+
24
+ STALE = "stale"
25
+ RECOVERED = "recovered"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class RefreshOutcome:
30
+ """Result of a refresh and the values the hub delivered for each action.
31
+
32
+ The outcome is truthy only if the hub delivered data for the destination.
33
+ """
34
+
35
+ result: RefreshResult
36
+ values: dict[int, dict[str, Any]] = field(default_factory=dict)
37
+
38
+ def __bool__(self) -> bool:
39
+ """Return True if the hub delivered data for the destination."""
40
+ return self.result is RefreshResult.OK
41
+
42
+
43
+ @dataclass
44
+ class RefreshStats:
45
+ """Counters of the refresh outcomes of one destination."""
46
+
47
+ ok: int = 0
48
+ busy: int = 0
49
+ empty: int = 0
50
+ error: int = 0
51
+ last_ok: datetime | None = None
52
+ last_result: RefreshResult | None = None
53
+ failing_since: float | None = field(default=None, repr=False)
54
+ stale_reported: bool = field(default=False, repr=False)
55
+
56
+ def record(
57
+ self, result: RefreshResult, *, now: float, stale_after: float
58
+ ) -> StaleChange | None:
59
+ """Count a result and report when the silent state changes.
60
+
61
+ now is a monotonic time in seconds. A destination is stale when its
62
+ refreshes have failed continuously for stale_after seconds.
63
+ """
64
+ setattr(self, result.value, getattr(self, result.value) + 1)
65
+ self.last_result = result
66
+
67
+ if result is RefreshResult.OK:
68
+ self.last_ok = datetime.now(UTC)
69
+ was_stale = self.stale_reported
70
+ self.failing_since = None
71
+ self.stale_reported = False
72
+ return StaleChange.RECOVERED if was_stale else None
73
+
74
+ if self.failing_since is None:
75
+ self.failing_since = now
76
+ return None
77
+ if not self.stale_reported and now - self.failing_since >= stale_after:
78
+ self.stale_reported = True
79
+ return StaleChange.STALE
80
+ return None
81
+
82
+ def snapshot(self) -> RefreshStats:
83
+ """Return a copy that does not change with later refreshes."""
84
+ return replace(self)
85
+
86
+ def as_dict(self) -> dict[str, Any]:
87
+ """Return the counters for diagnostics."""
88
+ return {
89
+ "ok": self.ok,
90
+ "busy": self.busy,
91
+ "empty": self.empty,
92
+ "error": self.error,
93
+ "last_ok": self.last_ok.isoformat() if self.last_ok else None,
94
+ "last_result": self.last_result.value if self.last_result else None,
95
+ }
@@ -0,0 +1,55 @@
1
+ class Room:
2
+ def __init__(self, control, id: int, name: str, destinations: list, scenes: list):
3
+ self._control = control
4
+ self._id = id
5
+ self._name = name
6
+ self._destination_ids = destinations
7
+ self._scene_ids = scenes
8
+
9
+ def __str__(self) -> str:
10
+ return self.name
11
+
12
+ def __repr__(self) -> str:
13
+ return f"<Room {self.id}: {self}>"
14
+
15
+ def __eq__(self, other) -> bool:
16
+ return self.id == other.id
17
+
18
+ def __hash__(self) -> int:
19
+ return hash(self.id)
20
+
21
+ # --- Properties ---
22
+
23
+ @property
24
+ def host(self) -> str:
25
+ return self._control.host
26
+
27
+ @property
28
+ def id(self) -> int:
29
+ return self._id
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return self._name
34
+
35
+ @property
36
+ def destinations(self) -> dict:
37
+ return {
38
+ dest_id: self._control.dests[dest_id] for dest_id in self._destination_ids
39
+ }
40
+
41
+ @property
42
+ def scenes(self) -> dict:
43
+ return {
44
+ scene_id: self._control.scenes[scene_id] for scene_id in self._scene_ids
45
+ }
46
+
47
+ # --- Public methods ---
48
+
49
+ def diag(self) -> dict:
50
+ return {
51
+ "id": self.id,
52
+ "name": self.name,
53
+ "destinations": {dest.id: dest.name for dest in self.destinations.values()},
54
+ "scenes": {scene.id: scene.name for scene in self.scenes.values()},
55
+ }