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/protocol.py ADDED
@@ -0,0 +1,742 @@
1
+ """Wire format of the iTM web UI (POST /cmd/ with ``snD=<hex>``).
2
+
3
+ Reverse-engineered from the web UI's script.js (Ver1.32.00). Requests and replies are
4
+ hex strings: numbers are big-endian hex (short = 4 chars, int = 8 chars) and strings
5
+ are a short length followed by the raw characters. Every reply starts with a new
6
+ 32-character page token.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from enum import IntEnum
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from .exceptions import ItmError, ProtocolError
17
+
18
+ if TYPE_CHECKING:
19
+ import builtins
20
+ from collections.abc import Callable
21
+
22
+ __all__ = [
23
+ "DIALOG",
24
+ "MAX_REPLY_CHARS",
25
+ "NO_TOKEN",
26
+ "PART_UPDATE",
27
+ "PART_UPDATE_CODE",
28
+ "SCREEN_UPDATE",
29
+ "SCREEN_UPDATE_CODE",
30
+ "TOKEN_LEN",
31
+ "WEB_VERSION",
32
+ "DataType",
33
+ "EventType",
34
+ "ItmError",
35
+ "Part",
36
+ "ProtocolError",
37
+ "Reader",
38
+ "Reply",
39
+ "ScreenID",
40
+ "ScreenUpdate",
41
+ "Writer",
42
+ "encode_event",
43
+ "is_token",
44
+ "parse_reply",
45
+ ]
46
+
47
+ WEB_VERSION = "Ver1.32.00"
48
+ TOKEN_LEN = 32
49
+ NO_TOKEN = "0" * TOKEN_LEN
50
+ # Real replies are a few KB (the settings dialog, the largest, ~30 KB).
51
+ MAX_REPLY_CHARS = 1_000_000
52
+
53
+
54
+ class EventType(IntEnum):
55
+ """GUI event types the web UI sends."""
56
+
57
+ INIT = 0
58
+ CLICK = 1
59
+ CLICK2 = 2
60
+ TIMER = 3
61
+ POLLING = 4
62
+ UPD_PROG = 5
63
+ CANCEL_PROG = 6
64
+ LIST_COLUMN = 7
65
+ TAB_SELECT = 8
66
+ FINISH_DOWNLOAD = 9
67
+ LOGOUT = 10
68
+
69
+
70
+ class DataType(IntEnum):
71
+ """Type of the value sent along with an event."""
72
+
73
+ NONE = 0
74
+ INT = 1
75
+ STRING = 2
76
+
77
+
78
+ class ScreenID(IntEnum):
79
+ """Screens of the web UI this client uses."""
80
+
81
+ MAINMENU = 0
82
+ STDICON = 1
83
+ STDLIST = 2
84
+ STDLAYOUT = 3
85
+ OPERATION = 4
86
+ SETTINGS = 7 # "Gedetailleerde instellingen" dialog, from the operation panel
87
+ DETAILS = 9 # "Gedetailleerde info": name, ID, type, port, address, description
88
+ LOGIN = 1203
89
+
90
+
91
+ # Bits for the POLLING event, one per open screen (systemPolingPrivate in script.js).
92
+ POLL_MAINMENU, POLL_STDICON, POLL_STDLIST, POLL_STDLAYOUT, POLL_OPERATION = (
93
+ 1,
94
+ 2,
95
+ 4,
96
+ 8,
97
+ 16,
98
+ )
99
+
100
+ SCREEN_UPDATE = {
101
+ 0: "INIT",
102
+ 1: "OPEN",
103
+ 2: "CLOSE",
104
+ 3: "UPDATE",
105
+ 4: "SHOW",
106
+ 5: "HIDE",
107
+ 6: "DLG_OPEN",
108
+ 7: "DLG_CLOSE",
109
+ 8: "DLG_UPDATE",
110
+ 9: "RELOAD",
111
+ 10: "BALLOON_OPEN",
112
+ 11: "BALLOON_CLOSE",
113
+ 12: "TAB_SWITCH",
114
+ 13: "DLG_DOWNLOAD",
115
+ 14: "BUZZER_ON",
116
+ 15: "BUZZER_OFF",
117
+ 1000: "LOGIN",
118
+ 1001: "LOGOUT",
119
+ 1002: "SCREENSAVER_ON",
120
+ 1003: "SYSSET_UPDATE",
121
+ }
122
+ PART_UPDATE = {
123
+ 100: "VISIBLED",
124
+ 101: "ENABLED",
125
+ 200: "LABEL",
126
+ 201: "TEXT",
127
+ 300: "CHECK_STATE",
128
+ 400: "SINGLE_SELIDX",
129
+ 401: "MULTI_SELIDX",
130
+ 500: "COMBO_STRS",
131
+ 501: "COMBO_NUMS",
132
+ 600: "LIST_COLUMN",
133
+ 601: "LIST_ROW",
134
+ 602: "LIST_CELL",
135
+ 700: "TAB_NAME",
136
+ 701: "TAB_VISIBLE",
137
+ 1000: "ICONID",
138
+ 1001: "ICONID_MODE",
139
+ 1100: "CALENDAR",
140
+ 1200: "SCHEDULE_PANEL",
141
+ 1300: "TIMELINE_BAR",
142
+ 1400: "ICONPANEL",
143
+ 1401: "ICONPANEL_STATE",
144
+ 1402: "PNTLIST",
145
+ 1403: "PNTLIST_STATE",
146
+ 1404: "PNTLAYOUT",
147
+ 1405: "PNTLAYOUT_STATE",
148
+ 1500: "BARLINE_GRAPH",
149
+ 1501: "POINT_GRAPH",
150
+ 1502: "POINT_GRAPH_ID",
151
+ }
152
+ PART_UPDATE_CODE = {name: code for code, name in PART_UPDATE.items()}
153
+ SCREEN_UPDATE_CODE = {name: code for code, name in SCREEN_UPDATE.items()}
154
+ DIALOG = {
155
+ 1: "ERROR",
156
+ 2: "CONFIRM",
157
+ 3: "INFO",
158
+ 4: "CHARINPUT",
159
+ 5: "PASSWORD",
160
+ 6: "NUMBER",
161
+ 7: "IPADDR",
162
+ 8: "TIME",
163
+ 9: "DATE",
164
+ 10: "HOURMIN",
165
+ 11: "PROGRESS",
166
+ 12: "CALENDAR",
167
+ 13: "KEYBOARDSUPPORT",
168
+ 14: "NUMBER_EXTEND",
169
+ }
170
+
171
+
172
+ _HEX = re.compile(r"[0-9a-fA-F]+")
173
+ _TRUNCATED = re.compile(r"[0-9a-fA-F]*\0*") # a cut-off number at the end of a reply
174
+
175
+
176
+ def is_token(text: str) -> bool:
177
+ """Tell whether text is a page token.
178
+
179
+ Args:
180
+ ----
181
+ text: The text to check.
182
+
183
+ Returns:
184
+ -------
185
+ True when text is exactly 32 hex characters.
186
+
187
+ """
188
+ return len(text) == TOKEN_LEN and all(c in "0123456789abcdefABCDEF" for c in text)
189
+
190
+
191
+ @dataclass
192
+ class Part:
193
+ """An update of one part (control) of a screen."""
194
+
195
+ part: int
196
+ type: str
197
+ value: Any
198
+
199
+
200
+ @dataclass
201
+ class ScreenUpdate:
202
+ """An update of one screen, with the parts that changed."""
203
+
204
+ screen: int
205
+ update: str
206
+ data: list[Any] = field(default_factory=list)
207
+ parts: list[Part] = field(default_factory=list)
208
+
209
+
210
+ @dataclass
211
+ class Reply:
212
+ """A decoded reply: the new page token and the screen updates."""
213
+
214
+ token: str
215
+ screens: list[ScreenUpdate]
216
+
217
+ def dialogs(self) -> list[list[Any]]:
218
+ """Return the data of every dialog the reply opens."""
219
+ return [s.data for s in self.screens if s.update == "DLG_OPEN"]
220
+
221
+
222
+ class Writer:
223
+ """Builds a hex-encoded request body."""
224
+
225
+ def __init__(self, token: str = "") -> None:
226
+ """Start a request body.
227
+
228
+ Args:
229
+ ----
230
+ token: The page token the body starts with.
231
+
232
+ """
233
+ self.buf = token
234
+
235
+ def short(self, v: int) -> None:
236
+ """Append a 16-bit number."""
237
+ self.buf += f"{v & 0xFFFF:04x}"
238
+
239
+ def int(self, v: int) -> None:
240
+ """Append a 32-bit number."""
241
+ self.buf += f"{v & 0xFFFFFFFF:08x}"
242
+
243
+ def string(self, s: str) -> None:
244
+ """Append a string, preceded by its length."""
245
+ self.short(len(s))
246
+ self.buf += s
247
+
248
+
249
+ class Reader:
250
+ """Reads the hex-encoded reply.
251
+
252
+ Every read must stay inside the reply: counts in a reply come from whatever
253
+ answers at the iTM's address, so a read past the end is an error rather than a
254
+ zero (which would let a tiny reply claiming a huge count spin a loop billions of
255
+ times).
256
+ """
257
+
258
+ def __init__(self, buf: str) -> None:
259
+ """Start reading a reply.
260
+
261
+ Args:
262
+ ----
263
+ buf: The reply text.
264
+
265
+ """
266
+ self.buf, self.pos = buf, 0
267
+
268
+ def _num(self, width: int) -> int:
269
+ """Read a signed number of width hex characters."""
270
+ chunk = self.buf[self.pos : self.pos + width]
271
+ if len(chunk) == width and _HEX.fullmatch(chunk):
272
+ self.pos += width
273
+ v = int(chunk, 16)
274
+ return v - (1 << (width * 4)) if v >= 1 << (width * 4 - 1) else v
275
+ if (
276
+ chunk
277
+ and self.pos + len(chunk) == len(self.buf)
278
+ and _TRUNCATED.fullmatch(chunk)
279
+ and (len(chunk) < width or "\0" in chunk)
280
+ ):
281
+ # The iTM cuts off the final part count of some replies (e.g. "0000\0"
282
+ # where 8 hex digits are due); the JS client reads that as NaN, which its
283
+ # loops treat as 0. Tolerate exactly that: a short or malformed number
284
+ # that is the very end of the reply. Nothing can be read after it.
285
+ self.pos = len(self.buf)
286
+ return 0
287
+ if not chunk:
288
+ msg = f"reply ends at offset {self.pos} but more data was expected"
289
+ raise ProtocolError(msg)
290
+ msg = f"invalid number {chunk!r} at offset {self.pos}"
291
+ raise ProtocolError(msg)
292
+
293
+ def short(self) -> int:
294
+ """Read a 16-bit number."""
295
+ return self._num(4)
296
+
297
+ def int(self) -> int:
298
+ """Read a 32-bit number."""
299
+ return self._num(8)
300
+
301
+ def count(self, *, wide: bool = True) -> builtins.int:
302
+ """Read the number of elements that follow.
303
+
304
+ Each element takes at least 4 characters, so a count the rest of the reply
305
+ can't hold is rejected up front instead of failing after a long loop.
306
+
307
+ Args:
308
+ ----
309
+ wide: Whether the count is a 32-bit (True) or 16-bit number.
310
+
311
+ Returns:
312
+ -------
313
+ The number of elements.
314
+
315
+ Raises:
316
+ ------
317
+ ProtocolError: The count does not fit the rest of the reply.
318
+
319
+ """
320
+ n = self._num(8 if wide else 4)
321
+ if n < 0 or n * 4 > len(self.buf) - self.pos:
322
+ msg = f"count {n} at offset {self.pos} does not fit the reply"
323
+ raise ProtocolError(msg)
324
+ return n
325
+
326
+ def string(self) -> str:
327
+ """Read a string, preceded by its length.
328
+
329
+ Returns
330
+ -------
331
+ The string.
332
+
333
+ Raises
334
+ ------
335
+ ProtocolError: The string does not fit the rest of the reply.
336
+
337
+ """
338
+ n = self.short()
339
+ if n < 0 or self.pos + n > len(self.buf):
340
+ msg = f"string of length {n} at offset {self.pos} does not fit the reply"
341
+ raise ProtocolError(msg)
342
+ s = self.buf[self.pos : self.pos + n]
343
+ self.pos += n
344
+ return s
345
+
346
+ def raw(self, n: builtins.int) -> str:
347
+ """Read n characters as they are."""
348
+ s = self.buf[self.pos : self.pos + n]
349
+ self.pos += n
350
+ return s
351
+
352
+
353
+ def encode_event( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments
354
+ # The arguments mirror the fields of the wire format.
355
+ token: str,
356
+ screen: int = -1,
357
+ part: int = -1,
358
+ event: int = EventType.CLICK,
359
+ data_type: int = DataType.NONE,
360
+ number: int = -1,
361
+ array: tuple[int, ...] = (),
362
+ string: str = "",
363
+ ) -> str:
364
+ """Encode a GUI event into the ``snD=`` request body.
365
+
366
+ Args:
367
+ ----
368
+ token: The current page token.
369
+ screen: The screen the event is for.
370
+ part: The part (control) of the screen the event is for.
371
+ event: The event type.
372
+ data_type: The type of the value sent along.
373
+ number: The number sent along.
374
+ array: Numbers sent along.
375
+ string: The string sent along.
376
+
377
+ Returns:
378
+ -------
379
+ The request body.
380
+
381
+ """
382
+ w = Writer(token)
383
+ w.short(screen)
384
+ w.short(part)
385
+ w.short(event)
386
+ w.short(data_type)
387
+ w.int(number)
388
+ w.short(len(array))
389
+ for v in array:
390
+ w.int(v)
391
+ w.string(string)
392
+ return "snD=" + w.buf
393
+
394
+
395
+ # -- decoding -------------------------------------------------------------------------
396
+ # Fixed layouts are written as one letter per field: N = int, H = short, S = string.
397
+ # Lowercase letters are read and dropped (fields the web UI skips as well).
398
+ _FIELD_READERS: dict[str, Callable[[Reader], Any]] = {
399
+ "N": Reader.int,
400
+ "H": Reader.short,
401
+ "S": Reader.string,
402
+ }
403
+
404
+
405
+ def _fields(r: Reader, layout: str) -> list[Any]:
406
+ """Read the fields of a fixed layout."""
407
+ out = []
408
+ for code in layout:
409
+ value = _FIELD_READERS[code.upper()](r)
410
+ if code.isupper():
411
+ out.append(value)
412
+ return out
413
+
414
+
415
+ def _repeat(r: Reader, layout: str) -> list[list[Any]]:
416
+ """Read a counted list of fixed layouts."""
417
+ return [_fields(r, layout) for _ in range(r.count())]
418
+
419
+
420
+ _DIALOG_LAYOUTS = {
421
+ 1: "SSS", # ERROR
422
+ 2: "SSSS", # CONFIRM
423
+ 3: "SSS", # INFO
424
+ 4: "SSHHHHH" + "h" + "S" * 14 + "sss" + "S", # CHARINPUT
425
+ 5: "SHHN" + "S" * 6 + "N", # PASSWORD
426
+ 6: "NSNNNNNNHH" + "S" * 11, # NUMBER
427
+ 7: "SS" + "S" * 4, # IPADDR
428
+ 8: "NS" + "H" * 18 + "S" * 13, # TIME
429
+ 9: "NS" + "H" * 9 + "h" + "S" * 13, # DATE
430
+ 10: "NS" + "H" * 6 + "S" * 13, # HOURMIN
431
+ 11: "SNNSS", # PROGRESS
432
+ 14: "NSNNNNNNNHH" + "S" * 12, # NUMBER_EXTEND
433
+ }
434
+
435
+
436
+ def _parse_calendar_dialog(r: Reader) -> list[Any]:
437
+ """Read the CALENDAR dialog."""
438
+ c, kind = r.count(wide=False), r.short()
439
+ o = [c, kind, r.string(), *_fields(r, "HHHHN" + "S" * 9 + "H" * 4 * c)]
440
+ if kind:
441
+ o += _fields(r, "S" * 5)
442
+ if kind == 2:
443
+ o += _fields(r, "H" * 4)
444
+ return o
445
+
446
+
447
+ def _parse_dialog(r: Reader, dtype: int) -> list[Any]:
448
+ """Read the data of a dialog of type dtype."""
449
+ if dtype == 12:
450
+ return _parse_calendar_dialog(r)
451
+ if dtype == 13: # KEYBOARDSUPPORT: no payload
452
+ return []
453
+ layout = _DIALOG_LAYOUTS.get(dtype)
454
+ if layout is None:
455
+ msg = f"unknown dialog type {dtype} at offset {r.pos}"
456
+ raise ProtocolError(msg)
457
+ return _fields(r, layout)
458
+
459
+
460
+ def _read_vaxis(r: Reader, out: list[Any]) -> None:
461
+ """Read a vertical graph axis."""
462
+ e = r.int()
463
+ out.append(e)
464
+ if e == 1:
465
+ n = r.count()
466
+ out.append(n)
467
+ for _ in range(n):
468
+ out += [r.string(), r.int()]
469
+ s = r.int()
470
+ out.append(s)
471
+ if s == 1:
472
+ out.append(r.string())
473
+ out += [r.int(), r.int()]
474
+
475
+
476
+ def _read_haxis(r: Reader, pt: int, out: list[Any]) -> None:
477
+ """Read a horizontal graph axis."""
478
+ e = r.int()
479
+ out.append(e)
480
+ if e == 1:
481
+ out.append(pt)
482
+ n = r.count()
483
+ out.append(n)
484
+ for _ in range(n):
485
+ out += [r.string(), r.int()]
486
+ s = r.int()
487
+ out.append(s)
488
+ if s == 1:
489
+ out.append(r.string())
490
+ n = r.count()
491
+ out.append(n)
492
+ out += [r.string() for _ in range(n)]
493
+
494
+
495
+ def _read_axes(r: Reader) -> list[Any]:
496
+ """Read the axes every graph starts with."""
497
+ o: list[Any] = []
498
+ _, pt = r.short(), r.short()
499
+ _read_vaxis(r, o)
500
+ _read_vaxis(r, o)
501
+ _read_haxis(r, pt, o)
502
+ return o
503
+
504
+
505
+ def _parse_barline_graph(r: Reader) -> list[Any]:
506
+ """Read a BARLINE_GRAPH part."""
507
+ o = _read_axes(r)
508
+ for _ in range(r.count()):
509
+ o.append(r.int())
510
+ n = r.count()
511
+ o.append(n)
512
+ for _ in range(n):
513
+ a = r.int()
514
+ o.append(a)
515
+ if a == 1:
516
+ o.append(r.int() / 10)
517
+ o += [r.string(), r.string()]
518
+ ct = r.int()
519
+ o.append(ct)
520
+ if ct == 1:
521
+ o.append(r.int() / 10)
522
+ return o
523
+
524
+
525
+ def _parse_point_graph(r: Reader) -> list[Any]:
526
+ """Read a POINT_GRAPH part."""
527
+ o = _read_axes(r)
528
+ for _ in range(r.count()):
529
+ o.append(r.int())
530
+ n = r.count()
531
+ o.append(n)
532
+ for _ in range(n):
533
+ o += [r.int(), r.int() / 100, r.int() / 100]
534
+ o.append(r.int())
535
+ return o
536
+
537
+
538
+ def _parse_tab_visible(r: Reader) -> list[Any]:
539
+ """Read a TAB_VISIBLE part."""
540
+ u, g = r.short(), r.count(wide=False)
541
+ return [u, [r.int() for _ in range(g)]]
542
+
543
+
544
+ def _parse_list_row(r: Reader) -> dict[str, Any]:
545
+ """Read a LIST_ROW part."""
546
+ nrows, ncols = r.count(wide=False), r.count(wide=False)
547
+ rows = []
548
+ for _ in range(nrows):
549
+ texts = _fields(r, "S" * ncols)
550
+ a = _fields(r, "H" * ncols)
551
+ b = _fields(r, "H" * ncols)
552
+ rows.append({"text": texts, "a": a, "b": b})
553
+ return {"rows": rows, "selected": [r.int() for _ in range(r.count())]}
554
+
555
+
556
+ def _parse_calendar(r: Reader) -> list[Any]:
557
+ """Read a CALENDAR part."""
558
+ it, ht = r.count(wide=False), r.short()
559
+ o = _fields(r, "HHHHN" + "S" * 7 + "H" * 4 * it)
560
+ if ht == 1:
561
+ o += _fields(r, "HHHH")
562
+ return [it, ht, o]
563
+
564
+
565
+ def _parse_schedule_panel(r: Reader) -> list[Any]:
566
+ """Read a SCHEDULE_PANEL part."""
567
+ o = _repeat(r, "NSSS")
568
+ return [o, r.int()]
569
+
570
+
571
+ def _parse_iconpanel(r: Reader) -> dict[str, Any]:
572
+ """Read an ICONPANEL part: the icons of the icon view."""
573
+ icons = _repeat(r, "SHH")
574
+ return {"icons": icons, "selected": r.int()}
575
+
576
+
577
+ def _parse_pntlist(r: Reader) -> dict[str, Any]:
578
+ """Read a PNTLIST part."""
579
+ ncols = r.count()
580
+ cols = [_fields(r, "SN") for _ in range(ncols)]
581
+ sort = _fields(r, "HH")
582
+ rows = [[_fields(r, "SHH") for _ in range(ncols)] for _ in range(r.count())]
583
+ return {"cols": cols, "sort": sort, "rows": rows, "selected": r.int()}
584
+
585
+
586
+ def _parse_pntlayout(r: Reader) -> dict[str, Any]:
587
+ """Read a PNTLAYOUT part."""
588
+ img = _fields(r, "SSHH")
589
+ items = []
590
+ for _ in range(r.count()):
591
+ a, b, kind = r.short(), r.short(), r.int()
592
+ rest = _fields(r, "HHN" if kind == 0 else "HHNNNNS")
593
+ items.append([a, b, kind, *rest])
594
+ return {"img": img, "items": items, "selected": r.int()}
595
+
596
+
597
+ def _parse_pntlayout_state(r: Reader) -> list[Any]:
598
+ """Read a PNTLAYOUT_STATE part."""
599
+ o = []
600
+ for _ in range(r.count()):
601
+ a, kind = r.short(), r.short()
602
+ o.append([a, kind, r.int() if kind == 0 else r.string()])
603
+ return o
604
+
605
+
606
+ _PART_READERS: dict[int, Callable[[Reader], Any]] = {
607
+ 100: Reader.int, # VISIBLED
608
+ 101: Reader.int, # ENABLED
609
+ 200: Reader.string, # LABEL
610
+ 201: Reader.string, # TEXT
611
+ 300: Reader.int, # CHECK_STATE
612
+ 400: Reader.int, # SINGLE_SELIDX
613
+ 401: lambda r: [r.int() for _ in range(r.count())], # MULTI_SELIDX
614
+ 500: lambda r: [r.string() for _ in range(r.count())], # COMBO_STRS
615
+ 501: lambda r: [r.int() for _ in range(r.count())], # COMBO_NUMS
616
+ 600: lambda r: _repeat(r, "SHHHH"), # LIST_COLUMN
617
+ 601: _parse_list_row, # LIST_ROW
618
+ 602: lambda r: _repeat(r, "HHSHH"), # LIST_CELL
619
+ 700: lambda r: [r.string() for _ in range(r.count())], # TAB_NAME
620
+ 701: _parse_tab_visible, # TAB_VISIBLE
621
+ 1000: Reader.int, # ICONID
622
+ 1001: lambda r: _fields(r, "HH"), # ICONID_MODE
623
+ 1100: _parse_calendar, # CALENDAR
624
+ 1200: _parse_schedule_panel, # SCHEDULE_PANEL
625
+ 1300: lambda r: _repeat(r, "HHHH"), # TIMELINE_BAR
626
+ 1400: _parse_iconpanel, # ICONPANEL
627
+ 1401: lambda r: _repeat(r, "HH"), # ICONPANEL_STATE
628
+ 1402: _parse_pntlist, # PNTLIST
629
+ 1403: lambda r: _repeat(r, "HHSHH"), # PNTLIST_STATE
630
+ 1404: _parse_pntlayout, # PNTLAYOUT
631
+ 1405: _parse_pntlayout_state, # PNTLAYOUT_STATE
632
+ 1500: _parse_barline_graph, # BARLINE_GRAPH
633
+ 1501: _parse_point_graph, # POINT_GRAPH
634
+ 1502: Reader.int, # POINT_GRAPH_ID
635
+ }
636
+
637
+
638
+ def _parse_part(r: Reader, ptype: int) -> Any:
639
+ """Read the value of a part update of type ptype."""
640
+ reader = _PART_READERS.get(ptype)
641
+ if reader is None:
642
+ msg = f"unknown part update type {ptype} at offset {r.pos}"
643
+ raise ProtocolError(msg)
644
+ return reader(r)
645
+
646
+
647
+ def _parse_dialog_open(r: Reader) -> list[Any]:
648
+ """Read the data of DLG_OPEN."""
649
+ dlg_id, dtype = r.short(), r.short()
650
+ return [dlg_id, DIALOG.get(dtype, dtype), *_parse_dialog(r, dtype)]
651
+
652
+
653
+ def _parse_dialog_update(r: Reader) -> list[Any]:
654
+ """Read the data of DLG_UPDATE."""
655
+ data = _fields(r, "HH")
656
+ if data[1] == 11: # PROGRESS
657
+ data.append(r.int())
658
+ return data
659
+
660
+
661
+ def _parse_logout(r: Reader) -> list[Any]:
662
+ """Read the data of LOGOUT: the reason, and its details."""
663
+ reason = r.int()
664
+ if reason == 1:
665
+ return [reason, *_fields(r, "SS")]
666
+ if reason == 2: # another web UI version is expected; carries that version
667
+ return [reason, r.string()]
668
+ return [reason]
669
+
670
+
671
+ _SCREEN_DATA_READERS: dict[int, Callable[[Reader], list[Any]]] = {
672
+ 0: lambda r: [r.int()], # INIT
673
+ 1: lambda r: [r.int()], # OPEN
674
+ 6: _parse_dialog_open, # DLG_OPEN
675
+ 7: lambda r: _fields(r, "HH"), # DLG_CLOSE
676
+ 8: _parse_dialog_update, # DLG_UPDATE
677
+ 10: lambda r: _fields(r, "NS"), # BALLOON_OPEN
678
+ 13: lambda r: _fields(r, "HH" + "S" * 5), # DLG_DOWNLOAD
679
+ 1000: lambda r: _fields(r, "NSS"), # LOGIN
680
+ 1001: _parse_logout, # LOGOUT
681
+ 1003: lambda r: [r.int()], # SYSSET_UPDATE
682
+ }
683
+ # OPEN, CLOSE, UPDATE, SHOW, HIDE and RELOAD are followed by part updates.
684
+ _WITH_PARTS = frozenset({1, 2, 3, 4, 5, 9})
685
+ # Screen update types that carry nothing besides the screen ID (script.js reads
686
+ # nothing more for them).
687
+ _NO_PAYLOAD = frozenset(
688
+ SCREEN_UPDATE_CODE[name]
689
+ for name in (
690
+ "BALLOON_CLOSE",
691
+ "TAB_SWITCH",
692
+ "BUZZER_ON",
693
+ "BUZZER_OFF",
694
+ "SCREENSAVER_ON",
695
+ )
696
+ )
697
+
698
+
699
+ def parse_reply(text: str) -> Reply:
700
+ """Decode a /cmd/ reply. Mirrors HttpHandler.onReply in script.js.
701
+
702
+ Args:
703
+ ----
704
+ text: The reply as received.
705
+
706
+ Returns:
707
+ -------
708
+ The decoded reply.
709
+
710
+ Raises:
711
+ ------
712
+ ProtocolError: The reply could not be decoded.
713
+
714
+ """
715
+ r = Reader(text)
716
+ token = r.raw(TOKEN_LEN)
717
+ if not is_token(token):
718
+ msg = f"not an iTM reply: {text[:80]!r}"
719
+ raise ProtocolError(msg)
720
+ screens = []
721
+ for _ in range(r.count()):
722
+ utype, screen_id = r.short(), r.short()
723
+ data_reader = _SCREEN_DATA_READERS.get(utype)
724
+ if data_reader is not None:
725
+ data = data_reader(r)
726
+ elif utype in _WITH_PARTS or utype in _NO_PAYLOAD:
727
+ data = []
728
+ else:
729
+ # Its layout is unknown, so nothing after it can be read reliably.
730
+ msg = f"unknown screen update type {utype} at offset {r.pos}"
731
+ raise ProtocolError(msg)
732
+ parts = []
733
+ if utype in _WITH_PARTS:
734
+ for _ in range(r.count()):
735
+ part_id, ptype = r.short(), r.short()
736
+ ptype_name = PART_UPDATE.get(ptype, str(ptype))
737
+ parts.append(Part(part_id, ptype_name, _parse_part(r, ptype)))
738
+ screens.append(ScreenUpdate(screen_id, SCREEN_UPDATE[utype], data, parts))
739
+ if r.buf[r.pos :] not in {"", "\0"}: # replies end with a NUL
740
+ msg = f"unexpected data after the reply at offset {r.pos}"
741
+ raise ProtocolError(msg)
742
+ return Reply(token, screens)