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
|
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
|
+
}
|