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