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/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()