python-daikin-itm 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.
- daikin_itm/__init__.py +37 -0
- daikin_itm/cli.py +306 -0
- daikin_itm/client.py +964 -0
- daikin_itm/exceptions.py +61 -0
- daikin_itm/models.py +242 -0
- daikin_itm/protocol.py +742 -0
- daikin_itm/py.typed +0 -0
- daikin_itm/state.py +73 -0
- python_daikin_itm-0.1.0.dist-info/METADATA +410 -0
- python_daikin_itm-0.1.0.dist-info/RECORD +13 -0
- python_daikin_itm-0.1.0.dist-info/WHEEL +4 -0
- python_daikin_itm-0.1.0.dist-info/entry_points.txt +3 -0
- python_daikin_itm-0.1.0.dist-info/licenses/LICENSE +21 -0
daikin_itm/client.py
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
"""Async client for the Daikin iTM web interface.
|
|
2
|
+
|
|
3
|
+
The iTM allows a single web session, so this client never keeps one open: every
|
|
4
|
+
public method logs in, does its work and logs out (also on errors and cancellation).
|
|
5
|
+
The page token is handed to a SessionStore after every reply, so a session left
|
|
6
|
+
behind by a crash can be released on the next login instead of blocking everyone
|
|
7
|
+
until the iTM times it out.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar
|
|
23
|
+
|
|
24
|
+
import aiohttp
|
|
25
|
+
|
|
26
|
+
from .exceptions import (
|
|
27
|
+
ItmCommandError,
|
|
28
|
+
ItmError,
|
|
29
|
+
LoginRejected,
|
|
30
|
+
ProtocolError,
|
|
31
|
+
SessionEnded,
|
|
32
|
+
VersionMismatch,
|
|
33
|
+
ZoneNotFound,
|
|
34
|
+
)
|
|
35
|
+
from .models import (
|
|
36
|
+
D_CANCEL,
|
|
37
|
+
D_FAN_CHECK,
|
|
38
|
+
D_FAN_ICON,
|
|
39
|
+
D_FAN_UP,
|
|
40
|
+
D_MODE,
|
|
41
|
+
D_MODE_CHECK,
|
|
42
|
+
D_OK,
|
|
43
|
+
D_POWER_CHECK,
|
|
44
|
+
D_POWER_START,
|
|
45
|
+
D_POWER_STOP,
|
|
46
|
+
DT_CLOSE,
|
|
47
|
+
FAN_ICONS,
|
|
48
|
+
FAN_SPEEDS,
|
|
49
|
+
P_DETAILS_BUTTON,
|
|
50
|
+
P_NAME,
|
|
51
|
+
P_SETPOINT,
|
|
52
|
+
P_SETTINGS_BUTTON,
|
|
53
|
+
Capabilities,
|
|
54
|
+
Unit,
|
|
55
|
+
capabilities_from_dialog,
|
|
56
|
+
details_from_screen,
|
|
57
|
+
unit_from_panel,
|
|
58
|
+
)
|
|
59
|
+
from .protocol import (
|
|
60
|
+
MAX_REPLY_CHARS,
|
|
61
|
+
NO_TOKEN,
|
|
62
|
+
WEB_VERSION,
|
|
63
|
+
DataType,
|
|
64
|
+
EventType,
|
|
65
|
+
ScreenID,
|
|
66
|
+
encode_event,
|
|
67
|
+
is_token,
|
|
68
|
+
parse_reply,
|
|
69
|
+
)
|
|
70
|
+
from .state import UIState
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
|
|
74
|
+
|
|
75
|
+
from .protocol import Reply
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"DEFAULT_ZONE_PATH",
|
|
79
|
+
"FileSessionStore",
|
|
80
|
+
"ItmClient",
|
|
81
|
+
"ItmCommandError",
|
|
82
|
+
"ItmError",
|
|
83
|
+
"LoginRejected",
|
|
84
|
+
"MemorySessionStore",
|
|
85
|
+
"SessionEnded",
|
|
86
|
+
"SessionStore",
|
|
87
|
+
"VersionMismatch",
|
|
88
|
+
"ZoneNotFound",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
_LOGGER = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
# Icon view (screen 1) part IDs (ScMainStdIcon.initScreen in script.js).
|
|
94
|
+
ICON_BTN_TOP, ICON_BTN_DOWN, ICON_BTN_UP, ICON_PANEL = 1, 2, 3, 6
|
|
95
|
+
ICON_ID_ZONE = 1000
|
|
96
|
+
LOGIN_BUTTON = 4
|
|
97
|
+
|
|
98
|
+
DEFAULT_ZONE_PATH = ("Alles", "Binnendeel")
|
|
99
|
+
|
|
100
|
+
_T = TypeVar("_T")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SessionStore(Protocol):
|
|
104
|
+
"""Keeps the page token of the logged-in session between replies and runs."""
|
|
105
|
+
|
|
106
|
+
async def load(self) -> str | None:
|
|
107
|
+
"""Return the saved token, or None."""
|
|
108
|
+
|
|
109
|
+
async def save(self, token: str | None) -> None:
|
|
110
|
+
"""Save the token, or forget it when None."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class MemorySessionStore:
|
|
114
|
+
"""Keeps the token in memory."""
|
|
115
|
+
|
|
116
|
+
def __init__(self) -> None:
|
|
117
|
+
"""Start without a token."""
|
|
118
|
+
self.token: str | None = None
|
|
119
|
+
|
|
120
|
+
async def load(self) -> str | None:
|
|
121
|
+
"""Return the saved token, or None."""
|
|
122
|
+
return self.token
|
|
123
|
+
|
|
124
|
+
async def save(self, token: str | None) -> None:
|
|
125
|
+
"""Save the token, or forget it when None."""
|
|
126
|
+
self.token = token
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class FileSessionStore:
|
|
130
|
+
"""Keeps the token on disk so a killed process can release its session later.
|
|
131
|
+
|
|
132
|
+
The token is a bearer credential for the logged-in iTM session, so the file is
|
|
133
|
+
owner-only (0600, in a directory created as 0700) and remembers which host it
|
|
134
|
+
belongs to: a token is never sent to another host than the one that issued it.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, path: str, host: str | None = None) -> None:
|
|
138
|
+
"""Initialize the store.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
----
|
|
142
|
+
path: The file to keep the token in.
|
|
143
|
+
host: The iTM the token belongs to.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
self.path = path
|
|
147
|
+
self.host = host
|
|
148
|
+
|
|
149
|
+
async def load(self) -> str | None:
|
|
150
|
+
"""Return the saved token, or None."""
|
|
151
|
+
return await asyncio.to_thread(self._load)
|
|
152
|
+
|
|
153
|
+
async def save(self, token: str | None) -> None:
|
|
154
|
+
"""Save the token, or remove the file when None."""
|
|
155
|
+
await asyncio.to_thread(self._save, token)
|
|
156
|
+
|
|
157
|
+
def _load(self) -> str | None:
|
|
158
|
+
try:
|
|
159
|
+
data = json.loads(Path(self.path).read_text(encoding="utf-8"))
|
|
160
|
+
except (FileNotFoundError, ValueError):
|
|
161
|
+
return None
|
|
162
|
+
if self.host is not None and data.get("host") != self.host:
|
|
163
|
+
return None
|
|
164
|
+
token: str | None = data.get("token")
|
|
165
|
+
return token
|
|
166
|
+
|
|
167
|
+
def _save(self, token: str | None) -> None:
|
|
168
|
+
path = Path(self.path)
|
|
169
|
+
if token is None:
|
|
170
|
+
path.unlink(missing_ok=True)
|
|
171
|
+
return
|
|
172
|
+
directory = path.absolute().parent
|
|
173
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
174
|
+
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".session-") # created 0600
|
|
175
|
+
try:
|
|
176
|
+
with os.fdopen(fd, "w") as f:
|
|
177
|
+
json.dump(
|
|
178
|
+
{"host": self.host, "token": token, "saved_at": time.time()}, f
|
|
179
|
+
)
|
|
180
|
+
Path(tmp).replace(path)
|
|
181
|
+
except BaseException:
|
|
182
|
+
Path(tmp).unlink(missing_ok=True)
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _run_to_completion(coro: Coroutine[Any, Any, _T]) -> _T:
|
|
187
|
+
"""Run cleanup to the end even if the caller is cancelled meanwhile.
|
|
188
|
+
|
|
189
|
+
Each request is bounded by the HTTP timeout, so this ends. The cancellation is
|
|
190
|
+
passed on once the cleanup is done. A plain asyncio.shield would return at once
|
|
191
|
+
on cancellation and leave the cleanup running unsupervised.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
----
|
|
195
|
+
coro: The cleanup to run.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
-------
|
|
199
|
+
What the cleanup returned.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
------
|
|
203
|
+
asyncio.CancelledError: The caller was cancelled while the cleanup ran.
|
|
204
|
+
|
|
205
|
+
"""
|
|
206
|
+
task = asyncio.ensure_future(coro)
|
|
207
|
+
cancelled = False
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
result = await asyncio.shield(task)
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
if task.cancelled(): # the cleanup itself was cancelled, not just us
|
|
213
|
+
raise
|
|
214
|
+
cancelled = True
|
|
215
|
+
continue
|
|
216
|
+
except BaseException as e:
|
|
217
|
+
if cancelled:
|
|
218
|
+
raise asyncio.CancelledError from e
|
|
219
|
+
raise
|
|
220
|
+
if cancelled:
|
|
221
|
+
raise asyncio.CancelledError
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _Session:
|
|
226
|
+
"""One logged-in conversation with the iTM. Only created by ItmClient."""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
client: ItmClient,
|
|
231
|
+
post: Callable[[str], Awaitable[str]],
|
|
232
|
+
store: SessionStore,
|
|
233
|
+
) -> None:
|
|
234
|
+
self._client = client
|
|
235
|
+
self._post = post
|
|
236
|
+
self._store = store
|
|
237
|
+
self.token = NO_TOKEN
|
|
238
|
+
self.state = UIState()
|
|
239
|
+
self.logged_in = False
|
|
240
|
+
|
|
241
|
+
async def send( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments
|
|
242
|
+
# The arguments mirror the fields of the wire format.
|
|
243
|
+
self,
|
|
244
|
+
screen: int = -1,
|
|
245
|
+
part: int = -1,
|
|
246
|
+
event: int = EventType.CLICK,
|
|
247
|
+
data_type: int = DataType.NONE,
|
|
248
|
+
number: int = -1,
|
|
249
|
+
string: str = "",
|
|
250
|
+
) -> Reply:
|
|
251
|
+
"""Send a GUI event and merge the reply into the UI state."""
|
|
252
|
+
body = encode_event(
|
|
253
|
+
self.token, screen, part, event, data_type, number, string=string
|
|
254
|
+
)
|
|
255
|
+
request = asyncio.ensure_future(self._post(body))
|
|
256
|
+
try:
|
|
257
|
+
text = await asyncio.shield(request)
|
|
258
|
+
except asyncio.CancelledError:
|
|
259
|
+
# The iTM may already have processed this request and moved on to a new
|
|
260
|
+
# token. Let it finish so the token stays known and the caller's cleanup
|
|
261
|
+
# can still log out, then re-raise.
|
|
262
|
+
with contextlib.suppress(Exception):
|
|
263
|
+
await self._take_token(await request)
|
|
264
|
+
raise
|
|
265
|
+
await self._take_token(text)
|
|
266
|
+
reply = parse_reply(text)
|
|
267
|
+
if event == EventType.CLICK and data_type == DataType.INT:
|
|
268
|
+
self.state.select_locally(screen, part, number)
|
|
269
|
+
self.state.apply(reply.screens)
|
|
270
|
+
for s in reply.screens:
|
|
271
|
+
if s.update == "LOGOUT":
|
|
272
|
+
self.logged_in = False
|
|
273
|
+
await self._store.save(None)
|
|
274
|
+
if s.data and s.data[0] == 2 and len(s.data) > 1:
|
|
275
|
+
raise VersionMismatch(s.data[1])
|
|
276
|
+
msg = f"iTM ended the session: {s.data}"
|
|
277
|
+
raise SessionEnded(msg)
|
|
278
|
+
return reply
|
|
279
|
+
|
|
280
|
+
async def _take_token(self, text: str) -> None:
|
|
281
|
+
# Taken before parsing, so a parser problem can never lose the session.
|
|
282
|
+
# Something that is not a token (an HTML error page, a truncated reply) must
|
|
283
|
+
# not replace the last good one.
|
|
284
|
+
if not is_token(text[:32]):
|
|
285
|
+
msg = f"not an iTM reply: {text[:80]!r}"
|
|
286
|
+
raise ProtocolError(msg)
|
|
287
|
+
self.token = text[:32]
|
|
288
|
+
if self.logged_in:
|
|
289
|
+
await self._store.save(self.token)
|
|
290
|
+
|
|
291
|
+
async def login(self, username: str, password: str) -> None:
|
|
292
|
+
"""Log in; raises LoginRejected when the iTM refuses."""
|
|
293
|
+
version = self._client.web_version
|
|
294
|
+
try:
|
|
295
|
+
await self.send(
|
|
296
|
+
event=EventType.INIT, data_type=DataType.STRING, string=version
|
|
297
|
+
)
|
|
298
|
+
except VersionMismatch as e:
|
|
299
|
+
# After a firmware update the iTM wants its new version string; it tells
|
|
300
|
+
# us which. The protocol may have changed too, so say so loudly, but
|
|
301
|
+
# carry on.
|
|
302
|
+
_LOGGER.warning(
|
|
303
|
+
"iTM web UI version changed from %s to %s; "
|
|
304
|
+
"this client was built against %s",
|
|
305
|
+
version,
|
|
306
|
+
e.version,
|
|
307
|
+
WEB_VERSION,
|
|
308
|
+
)
|
|
309
|
+
self._client.web_version = e.version
|
|
310
|
+
self.token = NO_TOKEN
|
|
311
|
+
await self.send(
|
|
312
|
+
event=EventType.INIT, data_type=DataType.STRING, string=e.version
|
|
313
|
+
)
|
|
314
|
+
self.token = NO_TOKEN
|
|
315
|
+
digest = hashlib.sha256(password.encode()).hexdigest() if password else ""
|
|
316
|
+
self.logged_in = True # from here on every reply token is persisted
|
|
317
|
+
reply = await self.send(
|
|
318
|
+
ScreenID.LOGIN,
|
|
319
|
+
LOGIN_BUTTON,
|
|
320
|
+
EventType.CLICK,
|
|
321
|
+
DataType.STRING,
|
|
322
|
+
string=f"{len(username):04d}{username}{digest}",
|
|
323
|
+
)
|
|
324
|
+
if any(
|
|
325
|
+
s.screen == ScreenID.LOGIN and s.update == "DLG_OPEN" for s in reply.screens
|
|
326
|
+
):
|
|
327
|
+
self.logged_in = False
|
|
328
|
+
await self._store.save(None)
|
|
329
|
+
msg = "login rejected (wrong credentials, or another web session is active)"
|
|
330
|
+
raise LoginRejected(msg)
|
|
331
|
+
|
|
332
|
+
async def logout(self) -> None:
|
|
333
|
+
"""Log out, keeping the token unless the iTM confirms the logout."""
|
|
334
|
+
if not self.logged_in:
|
|
335
|
+
return
|
|
336
|
+
self.logged_in = False
|
|
337
|
+
try:
|
|
338
|
+
await self.send(event=EventType.LOGOUT)
|
|
339
|
+
except SessionEnded as err:
|
|
340
|
+
# The iTM says the session is gone (e.g. it had already timed out).
|
|
341
|
+
_LOGGER.debug("session already ended: %s", err)
|
|
342
|
+
except (ItmError, aiohttp.ClientError, TimeoutError):
|
|
343
|
+
# Not confirmed (no answer, an HTTP error, or a reply that can't be
|
|
344
|
+
# read): the session may still be active, so keep the latest token for
|
|
345
|
+
# the next login to release.
|
|
346
|
+
await self._store.save(self.token)
|
|
347
|
+
raise
|
|
348
|
+
await self._store.save(None)
|
|
349
|
+
|
|
350
|
+
# -- icon view navigation -----------------------------------------------------
|
|
351
|
+
def icons(self) -> list[tuple[str, int, int]]:
|
|
352
|
+
"""Return (name, icon ID, state) of every icon in the current zone."""
|
|
353
|
+
panel = self.state.get(ScreenID.STDICON, ICON_PANEL, "ICONPANEL")
|
|
354
|
+
return [(i[0], i[1], i[2]) for i in panel["icons"]] if panel else []
|
|
355
|
+
|
|
356
|
+
async def select_icon(self, index: int) -> None:
|
|
357
|
+
"""Select an icon of the current zone."""
|
|
358
|
+
await self.send(
|
|
359
|
+
ScreenID.STDICON, ICON_PANEL, EventType.CLICK, DataType.INT, index
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
async def select_by_name(self, name: str) -> None:
|
|
363
|
+
"""Select an icon by name and check the operation panel shows it."""
|
|
364
|
+
names = [i[0] for i in self.icons()]
|
|
365
|
+
if name not in names:
|
|
366
|
+
msg = f"{name!r} not found here; available: {names}"
|
|
367
|
+
raise ItmCommandError(msg)
|
|
368
|
+
await self.select_icon(names.index(name))
|
|
369
|
+
shown = self.state.get(ScreenID.OPERATION, P_NAME, "LABEL")
|
|
370
|
+
if shown != name:
|
|
371
|
+
msg = f"operation panel shows {shown!r} instead of {name!r}"
|
|
372
|
+
raise ItmCommandError(msg)
|
|
373
|
+
|
|
374
|
+
async def open_zone(self, path: tuple[str, ...]) -> None:
|
|
375
|
+
"""Go down into a zone path, starting at the top level."""
|
|
376
|
+
# Every session starts at the top level of the icon view, so the path is
|
|
377
|
+
# absolute.
|
|
378
|
+
for zone in path:
|
|
379
|
+
names = [i[0] for i in self.icons()]
|
|
380
|
+
if zone not in names:
|
|
381
|
+
raise ZoneNotFound(zone, names)
|
|
382
|
+
await self.select_by_name(zone)
|
|
383
|
+
await self.send(ScreenID.STDICON, ICON_BTN_DOWN)
|
|
384
|
+
|
|
385
|
+
async def read_units(self) -> dict[str, Unit]:
|
|
386
|
+
"""Read every unit in the current zone."""
|
|
387
|
+
units = {}
|
|
388
|
+
for i, (name, icon_id, _) in enumerate(self.icons()):
|
|
389
|
+
if icon_id == ICON_ID_ZONE:
|
|
390
|
+
continue
|
|
391
|
+
await self.select_icon(i)
|
|
392
|
+
unit = unit_from_panel(self.state)
|
|
393
|
+
if unit.name != name:
|
|
394
|
+
msg = f"operation panel shows {unit.name!r} instead of {name!r}"
|
|
395
|
+
raise ItmCommandError(msg)
|
|
396
|
+
units[name] = unit
|
|
397
|
+
return units
|
|
398
|
+
|
|
399
|
+
# -- operation panel ----------------------------------------------------------
|
|
400
|
+
async def choose(self, part: int, index: int) -> None:
|
|
401
|
+
"""Pick an option of an operation panel control."""
|
|
402
|
+
reply = await self.send(
|
|
403
|
+
ScreenID.OPERATION, part, EventType.CLICK, DataType.INT, index
|
|
404
|
+
)
|
|
405
|
+
if reply.dialogs():
|
|
406
|
+
msg = f"iTM opened a dialog: {reply.dialogs()}"
|
|
407
|
+
raise ItmCommandError(msg)
|
|
408
|
+
|
|
409
|
+
async def commit(self, delay: float) -> None:
|
|
410
|
+
"""Commit operation panel changes like the browser does."""
|
|
411
|
+
# The browser waits SEND_TIMER_INTERVAL (3s) after the last change, then
|
|
412
|
+
# sends a TIMER event.
|
|
413
|
+
await asyncio.sleep(delay)
|
|
414
|
+
await self.send(ScreenID.OPERATION, -1, EventType.TIMER)
|
|
415
|
+
|
|
416
|
+
# -- settings dialog (screen 7): tick what to change, set it, press OK ---------
|
|
417
|
+
async def open_settings(self) -> None:
|
|
418
|
+
"""Open the selected unit's settings dialog."""
|
|
419
|
+
reply = await self.send(ScreenID.OPERATION, P_SETTINGS_BUTTON)
|
|
420
|
+
if not any(
|
|
421
|
+
s.screen == ScreenID.SETTINGS and s.update == "OPEN" for s in reply.screens
|
|
422
|
+
):
|
|
423
|
+
msg = f"settings dialog did not open: {reply.dialogs()}"
|
|
424
|
+
raise ItmCommandError(msg)
|
|
425
|
+
|
|
426
|
+
async def dialog_click(self, part: int, number: int | None = None) -> None:
|
|
427
|
+
"""Click a settings dialog control, optionally picking an option."""
|
|
428
|
+
if number is None:
|
|
429
|
+
reply = await self.send(ScreenID.SETTINGS, part)
|
|
430
|
+
else:
|
|
431
|
+
reply = await self.send(
|
|
432
|
+
ScreenID.SETTINGS, part, EventType.CLICK, DataType.INT, number
|
|
433
|
+
)
|
|
434
|
+
if reply.dialogs():
|
|
435
|
+
msg = f"iTM opened a dialog: {reply.dialogs()}"
|
|
436
|
+
raise ItmCommandError(msg)
|
|
437
|
+
|
|
438
|
+
async def close_settings(self, *, ok: bool) -> None:
|
|
439
|
+
"""Close the settings dialog with OK (and confirm) or Cancel."""
|
|
440
|
+
reply = await self.send(ScreenID.SETTINGS, D_OK if ok else D_CANCEL)
|
|
441
|
+
# OK asks "Instelling uitvoeren?" (apply settings?). The browser answers "Ja"
|
|
442
|
+
# with a CLICK on the dialog's ID (onClickDialog1) and "Nee" with CLICK2
|
|
443
|
+
# (onClickDialog2).
|
|
444
|
+
opened = [s for s in reply.screens if s.update == "DLG_OPEN"]
|
|
445
|
+
confirms = [(s.screen, s.data[0]) for s in opened if s.data[1] == "CONFIRM"]
|
|
446
|
+
others = [s.data for s in opened if s.data[1] != "CONFIRM"]
|
|
447
|
+
if others:
|
|
448
|
+
msg = f"iTM refused the settings: {others}"
|
|
449
|
+
raise ItmCommandError(msg)
|
|
450
|
+
if ok and confirms:
|
|
451
|
+
screen, dialog_id = confirms[0]
|
|
452
|
+
reply = await self.send(screen, dialog_id, EventType.CLICK)
|
|
453
|
+
if reply.dialogs():
|
|
454
|
+
msg = f"iTM refused the settings: {reply.dialogs()}"
|
|
455
|
+
raise ItmCommandError(msg)
|
|
456
|
+
if not self.settings_open or any(
|
|
457
|
+
s.screen == ScreenID.SETTINGS and s.update == "CLOSE" for s in reply.screens
|
|
458
|
+
):
|
|
459
|
+
return
|
|
460
|
+
msg = "settings dialog did not close"
|
|
461
|
+
raise ItmCommandError(msg)
|
|
462
|
+
|
|
463
|
+
async def set_dialog_power(self, *, power: bool) -> None:
|
|
464
|
+
"""Tick power in the settings dialog and pick start or stop."""
|
|
465
|
+
await self.dialog_click(D_POWER_CHECK, 1)
|
|
466
|
+
await self.dialog_click(D_POWER_START if power else D_POWER_STOP)
|
|
467
|
+
|
|
468
|
+
async def set_dialog_mode(self, caps: Capabilities, mode: str) -> None:
|
|
469
|
+
"""Tick the operation mode in the settings dialog and pick one."""
|
|
470
|
+
if mode not in caps.mode_labels:
|
|
471
|
+
msg = f"{caps.name}: mode {mode!r} not offered; available: {caps.modes}"
|
|
472
|
+
raise ItmCommandError(msg)
|
|
473
|
+
labels = self.state.get(ScreenID.SETTINGS, D_MODE, "COMBO_STRS")
|
|
474
|
+
await self.dialog_click(D_MODE_CHECK, 1)
|
|
475
|
+
await self.dialog_click(D_MODE, labels.index(caps.mode_labels[mode]))
|
|
476
|
+
|
|
477
|
+
async def set_dialog_fan_speed(self, caps: Capabilities, fan_speed: str) -> None:
|
|
478
|
+
"""Tick the fan speed in the settings dialog and cycle to the wanted one."""
|
|
479
|
+
if not caps.fan_speed:
|
|
480
|
+
msg = f"{caps.name}: fan speed cannot be changed on this unit"
|
|
481
|
+
raise ItmCommandError(msg)
|
|
482
|
+
await self.dialog_click(D_FAN_CHECK, 1)
|
|
483
|
+
# The up button cycles low -> medium -> high -> auto -> low; the iTM reports
|
|
484
|
+
# the new icon.
|
|
485
|
+
for _ in range(len(FAN_ICONS) + 1):
|
|
486
|
+
icon = self.state.get(ScreenID.SETTINGS, D_FAN_ICON, "ICONID")
|
|
487
|
+
if FAN_ICONS.get(icon) == fan_speed:
|
|
488
|
+
return
|
|
489
|
+
await self.dialog_click(D_FAN_UP)
|
|
490
|
+
msg = f"{caps.name}: could not select fan speed {fan_speed!r}"
|
|
491
|
+
raise ItmCommandError(msg)
|
|
492
|
+
|
|
493
|
+
async def change_settings(
|
|
494
|
+
self,
|
|
495
|
+
unit: str,
|
|
496
|
+
*,
|
|
497
|
+
power: bool | None,
|
|
498
|
+
mode: str | None,
|
|
499
|
+
fan_speed: str | None,
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Change the selected unit through its settings dialog.
|
|
502
|
+
|
|
503
|
+
The dialog is cancelled when anything goes wrong.
|
|
504
|
+
"""
|
|
505
|
+
await self.open_settings()
|
|
506
|
+
try:
|
|
507
|
+
caps = capabilities_from_dialog(unit, self.state)
|
|
508
|
+
if power is not None:
|
|
509
|
+
await self.set_dialog_power(power=power)
|
|
510
|
+
if mode is not None:
|
|
511
|
+
await self.set_dialog_mode(caps, mode)
|
|
512
|
+
if fan_speed is not None:
|
|
513
|
+
await self.set_dialog_fan_speed(caps, fan_speed)
|
|
514
|
+
await self.close_settings(ok=True)
|
|
515
|
+
except BaseException:
|
|
516
|
+
if self.settings_open and self.logged_in:
|
|
517
|
+
with contextlib.suppress(Exception):
|
|
518
|
+
await _run_to_completion(self.close_settings(ok=False))
|
|
519
|
+
raise
|
|
520
|
+
|
|
521
|
+
async def read_details(self) -> dict[str, Any]:
|
|
522
|
+
"""Read the selected unit's identity from its Details screen."""
|
|
523
|
+
reply = await self.send(ScreenID.OPERATION, P_DETAILS_BUTTON)
|
|
524
|
+
if not any(
|
|
525
|
+
s.screen == ScreenID.DETAILS and s.update == "OPEN" for s in reply.screens
|
|
526
|
+
):
|
|
527
|
+
msg = f"details screen did not open: {reply.dialogs()}"
|
|
528
|
+
raise ItmCommandError(msg)
|
|
529
|
+
details = details_from_screen(self.state)
|
|
530
|
+
await self.send(ScreenID.DETAILS, DT_CLOSE)
|
|
531
|
+
return details
|
|
532
|
+
|
|
533
|
+
@property
|
|
534
|
+
def settings_open(self) -> bool:
|
|
535
|
+
"""Whether the settings dialog is open."""
|
|
536
|
+
return ScreenID.SETTINGS in self.state.screens
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class ItmClient:
|
|
540
|
+
"""Client for the web interface of a Daikin intelligent Touch Manager."""
|
|
541
|
+
|
|
542
|
+
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments
|
|
543
|
+
# Keyword-only tuning arguments, part of the public API.
|
|
544
|
+
self,
|
|
545
|
+
host: str,
|
|
546
|
+
username: str,
|
|
547
|
+
password: str,
|
|
548
|
+
*,
|
|
549
|
+
session: aiohttp.ClientSession | None = None,
|
|
550
|
+
store: SessionStore | None = None,
|
|
551
|
+
zone_path: tuple[str, ...] = DEFAULT_ZONE_PATH,
|
|
552
|
+
commit_delay: float = 3.0,
|
|
553
|
+
min_write_interval: float = 5.0,
|
|
554
|
+
confirm_timeout: float = 30.0,
|
|
555
|
+
confirm_interval: float = 2.0,
|
|
556
|
+
timeout: float = 30.0,
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Initialize the client.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
----
|
|
562
|
+
host: "host", "host:port", or a full "http(s)://host" URL.
|
|
563
|
+
username: The iTM user.
|
|
564
|
+
password: The iTM user's password.
|
|
565
|
+
session: The aiohttp session to use; one is created when omitted.
|
|
566
|
+
store: Where to keep the session token; in memory when omitted.
|
|
567
|
+
zone_path: The zones to go down into to find the indoor units.
|
|
568
|
+
commit_delay: Seconds to wait before committing a setpoint change.
|
|
569
|
+
min_write_interval: Minimum seconds between two writes to a unit.
|
|
570
|
+
confirm_timeout: Seconds to wait for a change to show on the unit.
|
|
571
|
+
confirm_interval: Seconds between the reads that confirm a change.
|
|
572
|
+
timeout: Timeout of a single request, in seconds.
|
|
573
|
+
|
|
574
|
+
"""
|
|
575
|
+
# The iTM's web UI is plain HTTP by default, which exposes the (replayable)
|
|
576
|
+
# login hash on the network; use https:// where the iTM offers it.
|
|
577
|
+
base = host.rstrip("/") if "://" in host else "http://" + host
|
|
578
|
+
self._url = base + "/cmd/"
|
|
579
|
+
self._username, self._password = username, password
|
|
580
|
+
self._http = session
|
|
581
|
+
self._owns_http = session is None
|
|
582
|
+
self._store: SessionStore = store or MemorySessionStore()
|
|
583
|
+
self.zone_path = tuple(zone_path)
|
|
584
|
+
self.commit_delay = commit_delay
|
|
585
|
+
self.min_write_interval = min_write_interval
|
|
586
|
+
# Changes take a few seconds to reach the unit over the Daikin bus; a write is
|
|
587
|
+
# only done once a fresh read shows it. Sending the next change before that
|
|
588
|
+
# can make the iTM drop one of them.
|
|
589
|
+
self.confirm_timeout = confirm_timeout
|
|
590
|
+
self.confirm_interval = confirm_interval
|
|
591
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
592
|
+
# Updated automatically if the iTM reports another one.
|
|
593
|
+
self.web_version = WEB_VERSION
|
|
594
|
+
self._lock = asyncio.Lock() # one session at a time, also within this process
|
|
595
|
+
self._last_write: dict[str, float] = {}
|
|
596
|
+
self._unit_locks: dict[str, asyncio.Lock] = {}
|
|
597
|
+
# Unconfirmed changes (written with confirm=False) by unit: what was changed
|
|
598
|
+
# and how to recognize it.
|
|
599
|
+
self._unconfirmed: dict[str, tuple[str, Callable[[Unit], bool]]] = {}
|
|
600
|
+
|
|
601
|
+
async def close(self) -> None:
|
|
602
|
+
"""Close the aiohttp session if the client created it."""
|
|
603
|
+
if self._owns_http and self._http is not None:
|
|
604
|
+
await self._http.close()
|
|
605
|
+
self._http = None
|
|
606
|
+
|
|
607
|
+
async def __aenter__(self) -> Self:
|
|
608
|
+
"""Async enter.
|
|
609
|
+
|
|
610
|
+
Returns
|
|
611
|
+
-------
|
|
612
|
+
The ItmClient object.
|
|
613
|
+
|
|
614
|
+
"""
|
|
615
|
+
return self
|
|
616
|
+
|
|
617
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
618
|
+
"""Async exit: close the client."""
|
|
619
|
+
await self.close()
|
|
620
|
+
|
|
621
|
+
async def _post(self, body: str) -> str:
|
|
622
|
+
if self._http is None:
|
|
623
|
+
self._http = aiohttp.ClientSession()
|
|
624
|
+
async with self._http.post(
|
|
625
|
+
self._url,
|
|
626
|
+
data=body.encode(),
|
|
627
|
+
timeout=self._timeout,
|
|
628
|
+
headers={"Content-Type": "text/plain"},
|
|
629
|
+
) as resp:
|
|
630
|
+
resp.raise_for_status()
|
|
631
|
+
# Replies are a few KB; refuse anything huge rather than buffering
|
|
632
|
+
# whatever the other end sends.
|
|
633
|
+
data = bytearray()
|
|
634
|
+
async for chunk in resp.content.iter_chunked(65536):
|
|
635
|
+
data += chunk
|
|
636
|
+
if len(data) > MAX_REPLY_CHARS:
|
|
637
|
+
msg = f"reply larger than {MAX_REPLY_CHARS} bytes"
|
|
638
|
+
raise ProtocolError(msg)
|
|
639
|
+
return data.decode("utf-8", errors="replace")
|
|
640
|
+
|
|
641
|
+
async def _request(self, body: str) -> str:
|
|
642
|
+
# Looks _post up on every request, so a replaced _post (tests) is used.
|
|
643
|
+
return await self._post(body)
|
|
644
|
+
|
|
645
|
+
def _new_session(self) -> _Session:
|
|
646
|
+
return _Session(self, self._request, self._store)
|
|
647
|
+
|
|
648
|
+
async def release_saved_session(self) -> bool:
|
|
649
|
+
"""Log out a session a previous run left behind.
|
|
650
|
+
|
|
651
|
+
Returns
|
|
652
|
+
-------
|
|
653
|
+
True if there was one.
|
|
654
|
+
|
|
655
|
+
"""
|
|
656
|
+
token = await self._store.load()
|
|
657
|
+
if not token or token == NO_TOKEN:
|
|
658
|
+
return False
|
|
659
|
+
s = self._new_session()
|
|
660
|
+
s.token, s.logged_in = token, True
|
|
661
|
+
_LOGGER.info("releasing session left behind by a previous run")
|
|
662
|
+
try:
|
|
663
|
+
await s.logout()
|
|
664
|
+
except (aiohttp.ClientResponseError, ProtocolError) as e:
|
|
665
|
+
# The iTM answered but didn't confirm. Retrying a token it keeps refusing
|
|
666
|
+
# would fail every later session, so give up on it; the iTM ends the
|
|
667
|
+
# session itself once it times out.
|
|
668
|
+
_LOGGER.warning(
|
|
669
|
+
"could not release the previous session (%s); "
|
|
670
|
+
"waiting for the iTM to time it out",
|
|
671
|
+
e,
|
|
672
|
+
)
|
|
673
|
+
await self._store.save(None)
|
|
674
|
+
return True
|
|
675
|
+
|
|
676
|
+
@asynccontextmanager
|
|
677
|
+
async def _session(self) -> AsyncIterator[_Session]:
|
|
678
|
+
async with self._lock:
|
|
679
|
+
await self.release_saved_session()
|
|
680
|
+
s = self._new_session()
|
|
681
|
+
try:
|
|
682
|
+
await s.login(self._username, self._password)
|
|
683
|
+
yield s
|
|
684
|
+
finally:
|
|
685
|
+
# A cancelled caller (e.g. HA shutting down) still frees the single
|
|
686
|
+
# session, and the lock is held until it has, so the next session
|
|
687
|
+
# can't start while this logout is still on its way.
|
|
688
|
+
await _run_to_completion(s.logout())
|
|
689
|
+
|
|
690
|
+
# -- public API ---------------------------------------------------------------
|
|
691
|
+
async def fetch_units(self) -> dict[str, Unit]:
|
|
692
|
+
"""Read every unit in the configured zone, in one short session.
|
|
693
|
+
|
|
694
|
+
Returns
|
|
695
|
+
-------
|
|
696
|
+
The units by name.
|
|
697
|
+
|
|
698
|
+
"""
|
|
699
|
+
async with self._session() as s:
|
|
700
|
+
await s.open_zone(self.zone_path)
|
|
701
|
+
return await s.read_units()
|
|
702
|
+
|
|
703
|
+
async def fetch_unit(self, name: str) -> Unit:
|
|
704
|
+
"""Read a single unit (cheaper than fetch_units).
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
----
|
|
708
|
+
name: The unit's name.
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
-------
|
|
712
|
+
The unit.
|
|
713
|
+
|
|
714
|
+
"""
|
|
715
|
+
async with self._session() as s:
|
|
716
|
+
await s.open_zone(self.zone_path)
|
|
717
|
+
await s.select_by_name(name)
|
|
718
|
+
return unit_from_panel(s.state)
|
|
719
|
+
|
|
720
|
+
async def _confirm(
|
|
721
|
+
self, unit: str, what: str, check: Callable[[Unit], bool]
|
|
722
|
+
) -> Unit:
|
|
723
|
+
deadline = time.monotonic() + self.confirm_timeout
|
|
724
|
+
while True:
|
|
725
|
+
await asyncio.sleep(self.confirm_interval)
|
|
726
|
+
u = await self.fetch_unit(unit)
|
|
727
|
+
if check(u):
|
|
728
|
+
return u
|
|
729
|
+
if time.monotonic() >= deadline:
|
|
730
|
+
msg = (
|
|
731
|
+
f"{unit}: {what} not confirmed within {self.confirm_timeout}s "
|
|
732
|
+
f"(last seen: power={u.power} mode={u.mode} fan={u.fan_speed} "
|
|
733
|
+
f"setpoint={u.setpoint})"
|
|
734
|
+
)
|
|
735
|
+
raise ItmCommandError(msg)
|
|
736
|
+
|
|
737
|
+
async def _settle(self, unit: str) -> None:
|
|
738
|
+
"""Wait until an earlier unconfirmed change to this unit shows.
|
|
739
|
+
|
|
740
|
+
So the next change can't overtake it. Callers hold _unit_lock(unit). A change
|
|
741
|
+
that never shows is logged, not raised: it belongs to an earlier call that
|
|
742
|
+
has already returned.
|
|
743
|
+
"""
|
|
744
|
+
pending = self._unconfirmed.pop(unit, None)
|
|
745
|
+
if pending is None:
|
|
746
|
+
return
|
|
747
|
+
what, check = pending
|
|
748
|
+
try:
|
|
749
|
+
await self._confirm(unit, what, check)
|
|
750
|
+
except ItmCommandError as err:
|
|
751
|
+
_LOGGER.warning("%s", err)
|
|
752
|
+
|
|
753
|
+
async def _finish(
|
|
754
|
+
self, unit: str, what: str, check: Callable[[Unit], bool], *, confirm: bool
|
|
755
|
+
) -> Unit | None:
|
|
756
|
+
if confirm:
|
|
757
|
+
return await self._confirm(unit, what, check)
|
|
758
|
+
self._unconfirmed[unit] = (what, check)
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
async def list_zones(self, path: tuple[str, ...] = ()) -> list[str]:
|
|
762
|
+
"""List the zones and units at a path (for discovery and configuration).
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
----
|
|
766
|
+
path: The zone path, empty for the top level.
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
-------
|
|
770
|
+
The names of the zones and units there.
|
|
771
|
+
|
|
772
|
+
"""
|
|
773
|
+
async with self._session() as s:
|
|
774
|
+
await s.open_zone(path)
|
|
775
|
+
return [i[0] for i in s.icons()]
|
|
776
|
+
|
|
777
|
+
async def set_setpoint(
|
|
778
|
+
self, unit: str, value: int, *, confirm: bool = True
|
|
779
|
+
) -> Unit | None:
|
|
780
|
+
"""Change a unit's setpoint.
|
|
781
|
+
|
|
782
|
+
The iTM ignores this while the unit is stopped, so that is refused.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
----
|
|
786
|
+
unit: The unit's name.
|
|
787
|
+
value: The new setpoint, one of the unit's setpoint_range.
|
|
788
|
+
confirm: Wait until the change shows on the unit. Without, the next
|
|
789
|
+
write to the same unit first waits until this change shows.
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
-------
|
|
793
|
+
The unit as read back once the change shows, or None when not confirmed.
|
|
794
|
+
|
|
795
|
+
Raises:
|
|
796
|
+
------
|
|
797
|
+
ItmCommandError: The unit is stopped, the value is out of range, or the
|
|
798
|
+
change did not show in time.
|
|
799
|
+
|
|
800
|
+
"""
|
|
801
|
+
async with self._unit_lock(unit):
|
|
802
|
+
await self._settle(unit)
|
|
803
|
+
async with self._write(unit) as s:
|
|
804
|
+
current = unit_from_panel(s.state)
|
|
805
|
+
if not current.running:
|
|
806
|
+
msg = f"{unit} is stopped; the iTM ignores setpoint changes then"
|
|
807
|
+
raise ItmCommandError(msg)
|
|
808
|
+
nums = list(current.setpoint_range)
|
|
809
|
+
if value not in nums:
|
|
810
|
+
msg = f"{unit}: setpoint {value} outside allowed range {nums}"
|
|
811
|
+
raise ItmCommandError(msg)
|
|
812
|
+
if current.setpoint == value:
|
|
813
|
+
return current
|
|
814
|
+
await s.choose(P_SETPOINT, nums.index(value))
|
|
815
|
+
await s.commit(self.commit_delay)
|
|
816
|
+
return await self._finish(
|
|
817
|
+
unit,
|
|
818
|
+
f"setpoint {value}",
|
|
819
|
+
lambda u: u.setpoint == value,
|
|
820
|
+
confirm=confirm,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
async def fetch_capabilities(self) -> dict[str, Capabilities]:
|
|
824
|
+
"""Read every unit's identity and which modes and fan control it offers.
|
|
825
|
+
|
|
826
|
+
The identity comes from the Details screen, the rest from the settings dialog
|
|
827
|
+
(cancelled). This costs four requests per unit, so do it once rather than on
|
|
828
|
+
every poll.
|
|
829
|
+
|
|
830
|
+
Returns
|
|
831
|
+
-------
|
|
832
|
+
The capabilities by unit name.
|
|
833
|
+
|
|
834
|
+
"""
|
|
835
|
+
async with self._session() as s:
|
|
836
|
+
await s.open_zone(self.zone_path)
|
|
837
|
+
caps = {}
|
|
838
|
+
for i, (name, icon_id, _) in enumerate(s.icons()):
|
|
839
|
+
if icon_id == ICON_ID_ZONE:
|
|
840
|
+
continue
|
|
841
|
+
await s.select_icon(i)
|
|
842
|
+
details = await s.read_details()
|
|
843
|
+
await s.open_settings()
|
|
844
|
+
caps[name] = capabilities_from_dialog(name, s.state, details)
|
|
845
|
+
await s.close_settings(ok=False)
|
|
846
|
+
return caps
|
|
847
|
+
|
|
848
|
+
async def apply(
|
|
849
|
+
self,
|
|
850
|
+
unit: str,
|
|
851
|
+
*,
|
|
852
|
+
power: bool | None = None,
|
|
853
|
+
mode: str | None = None,
|
|
854
|
+
fan_speed: str | None = None,
|
|
855
|
+
confirm: bool = True,
|
|
856
|
+
) -> Unit | None:
|
|
857
|
+
"""Change power, mode and/or fan speed of a unit in one go.
|
|
858
|
+
|
|
859
|
+
This goes through the unit's settings dialog.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
----
|
|
863
|
+
unit: The unit's name.
|
|
864
|
+
power: Start (True) or stop (False) the unit.
|
|
865
|
+
mode: heat / cool / fan / dry / auto, as offered by the unit (see
|
|
866
|
+
fetch_capabilities).
|
|
867
|
+
fan_speed: low / medium / high / auto.
|
|
868
|
+
confirm: Wait until the change shows on the unit. Without, the next
|
|
869
|
+
write to the same unit first waits until this change shows.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
-------
|
|
873
|
+
The unit as read back once the change shows, or None when not confirmed
|
|
874
|
+
or nothing was asked to change.
|
|
875
|
+
|
|
876
|
+
Raises:
|
|
877
|
+
------
|
|
878
|
+
ItmCommandError: The unit does not offer the change, or the change did
|
|
879
|
+
not show in time.
|
|
880
|
+
|
|
881
|
+
"""
|
|
882
|
+
if power is None and mode is None and fan_speed is None:
|
|
883
|
+
return None
|
|
884
|
+
if fan_speed is not None and fan_speed not in FAN_SPEEDS:
|
|
885
|
+
msg = (
|
|
886
|
+
f"{unit}: unknown fan speed {fan_speed!r}, expected one of {FAN_SPEEDS}"
|
|
887
|
+
)
|
|
888
|
+
raise ItmCommandError(msg)
|
|
889
|
+
async with self._unit_lock(unit):
|
|
890
|
+
await self._settle(unit)
|
|
891
|
+
async with self._write(unit) as s:
|
|
892
|
+
# Compare with the unit as read in this session, not with what the
|
|
893
|
+
# caller last saw: only send what differs, and nothing at all (no
|
|
894
|
+
# write to the unit) if it already is in the requested state.
|
|
895
|
+
current = unit_from_panel(s.state)
|
|
896
|
+
if power is not None and current.running == power:
|
|
897
|
+
power = None
|
|
898
|
+
if mode is not None and current.mode == mode:
|
|
899
|
+
mode = None
|
|
900
|
+
if fan_speed is not None and current.fan_speed == fan_speed:
|
|
901
|
+
fan_speed = None
|
|
902
|
+
if power is None and mode is None and fan_speed is None:
|
|
903
|
+
return current
|
|
904
|
+
await s.change_settings(
|
|
905
|
+
unit, power=power, mode=mode, fan_speed=fan_speed
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
return await self._finish(
|
|
909
|
+
unit,
|
|
910
|
+
"change",
|
|
911
|
+
lambda u: (
|
|
912
|
+
(power is None or u.running == power)
|
|
913
|
+
and (mode is None or u.mode == mode)
|
|
914
|
+
and (fan_speed is None or u.fan_speed == fan_speed)
|
|
915
|
+
),
|
|
916
|
+
confirm=confirm,
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
async def set_power(
|
|
920
|
+
self,
|
|
921
|
+
unit: str,
|
|
922
|
+
on: bool, # noqa: FBT001 - public API, positional like the iTM's own switch
|
|
923
|
+
*,
|
|
924
|
+
confirm: bool = True,
|
|
925
|
+
) -> Unit | None:
|
|
926
|
+
"""Start or stop a unit, see apply."""
|
|
927
|
+
return await self.apply(unit, power=on, confirm=confirm)
|
|
928
|
+
|
|
929
|
+
async def set_mode(
|
|
930
|
+
self, unit: str, mode: str, *, confirm: bool = True
|
|
931
|
+
) -> Unit | None:
|
|
932
|
+
"""Change a unit's operation mode, see apply."""
|
|
933
|
+
return await self.apply(unit, mode=mode, confirm=confirm)
|
|
934
|
+
|
|
935
|
+
async def set_fan_speed(
|
|
936
|
+
self, unit: str, fan_speed: str, *, confirm: bool = True
|
|
937
|
+
) -> Unit | None:
|
|
938
|
+
"""Change a unit's fan speed, see apply."""
|
|
939
|
+
return await self.apply(unit, fan_speed=fan_speed, confirm=confirm)
|
|
940
|
+
|
|
941
|
+
def _unit_lock(self, unit: str) -> asyncio.Lock:
|
|
942
|
+
"""Return the lock held for a whole write to a unit, incl. confirmation.
|
|
943
|
+
|
|
944
|
+
So a second write to the same unit can't be sent while the first change is
|
|
945
|
+
still on its way to the unit (the iTM may drop one of them).
|
|
946
|
+
"""
|
|
947
|
+
return self._unit_locks.setdefault(unit, asyncio.Lock())
|
|
948
|
+
|
|
949
|
+
@asynccontextmanager
|
|
950
|
+
async def _write(self, unit: str) -> AsyncIterator[_Session]:
|
|
951
|
+
# Callers hold _unit_lock(unit). Space out writes per unit: the indoor units
|
|
952
|
+
# store settings in memory with a limited number of writes.
|
|
953
|
+
wait = (
|
|
954
|
+
self._last_write.get(unit, 0) + self.min_write_interval - time.monotonic()
|
|
955
|
+
)
|
|
956
|
+
if wait > 0:
|
|
957
|
+
await asyncio.sleep(wait)
|
|
958
|
+
try:
|
|
959
|
+
async with self._session() as s:
|
|
960
|
+
await s.open_zone(self.zone_path)
|
|
961
|
+
await s.select_by_name(unit)
|
|
962
|
+
yield s
|
|
963
|
+
finally:
|
|
964
|
+
self._last_write[unit] = time.monotonic()
|