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/exceptions.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Exceptions raised by the Daikin iTM client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ItmError(Exception):
|
|
7
|
+
"""Base error for everything this library raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtocolError(ItmError):
|
|
11
|
+
"""A reply could not be decoded (not an iTM reply, or an unknown layout)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionEnded(ItmError): # noqa: N818 - public API name
|
|
15
|
+
"""The iTM ended the session (it answered with a LOGOUT update).
|
|
16
|
+
|
|
17
|
+
For example because the token was stale.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VersionMismatch(SessionEnded):
|
|
22
|
+
"""The iTM expects another web UI version string; carries the one it reported."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, version: str) -> None:
|
|
25
|
+
"""Initialize the error.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
----
|
|
29
|
+
version: The web UI version string the iTM reported.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(f"iTM web UI version is {version}")
|
|
33
|
+
self.version = version
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LoginRejected(ItmError): # noqa: N818 - public API name
|
|
37
|
+
"""The iTM refused the login.
|
|
38
|
+
|
|
39
|
+
Wrong credentials, or another web session is active (the iTM allows one).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ItmCommandError(ItmError):
|
|
44
|
+
"""The iTM answered a command with a dialog, or the UI was not as expected."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ZoneNotFound(ItmCommandError): # noqa: N818 - public API name
|
|
48
|
+
"""A zone of the zone path is not there; carries the names that are."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, zone: str, available: list[str]) -> None:
|
|
51
|
+
"""Initialize the error.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
----
|
|
55
|
+
zone: The zone that was not found.
|
|
56
|
+
available: The names of the zones and units that are there.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(f"{zone!r} not found here; available: {available}")
|
|
60
|
+
self.zone = zone
|
|
61
|
+
self.available = available
|
daikin_itm/models.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Typed view of an indoor unit as shown on the iTM operation panel (screen 4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from .protocol import ScreenID
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .state import UIState
|
|
14
|
+
|
|
15
|
+
# Operation panel part IDs (ScMainOperation.initScreen in script.js).
|
|
16
|
+
P_NAME, P_STATE_ICON, P_FILTER_ICON, P_TEMP, P_MODE = 0, 1, 2, 4, 5
|
|
17
|
+
P_DETAILS_BUTTON, P_POWER, P_SETPOINT, P_FAN_ICON = 7, 9, 11, 14
|
|
18
|
+
P_SETTINGS_BUTTON, P_ERROR_CODE = 17, 19
|
|
19
|
+
|
|
20
|
+
# Second value of the state icon (ICONID_MODE) as seen on the device.
|
|
21
|
+
ICON_STATE_ON, ICON_STATE_OFF, ICON_STATE_SETBACK = 0, 2, 11
|
|
22
|
+
|
|
23
|
+
# Operation mode labels are localised; map the ones we know, keep the raw label too.
|
|
24
|
+
MODES = {
|
|
25
|
+
"verwarmen": "heat",
|
|
26
|
+
"heat": "heat",
|
|
27
|
+
"heating": "heat",
|
|
28
|
+
"koelen": "cool",
|
|
29
|
+
"cool": "cool",
|
|
30
|
+
"cooling": "cool",
|
|
31
|
+
"ventilator": "fan",
|
|
32
|
+
"ventilatie": "fan",
|
|
33
|
+
"fan": "fan",
|
|
34
|
+
"drogen": "dry",
|
|
35
|
+
"dry": "dry",
|
|
36
|
+
"auto": "auto",
|
|
37
|
+
"automatisch": "auto",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Fan speed icons (bars / "A" in the web UI's own images); the settings dialog
|
|
41
|
+
# cycles through them in this order.
|
|
42
|
+
FAN_ICONS = {3009: "low", 3011: "medium", 3013: "high", 3015: "auto"}
|
|
43
|
+
FAN_SPEEDS = tuple(FAN_ICONS.values())
|
|
44
|
+
|
|
45
|
+
# Settings dialog (screen 7, ScMainSetting in script.js).
|
|
46
|
+
D_OK, D_CANCEL = 5, 6
|
|
47
|
+
D_POWER_CHECK, D_POWER_START, D_POWER_STOP = 2, 3, 4
|
|
48
|
+
D_MODE_CHECK, D_MODE = 17, 18
|
|
49
|
+
D_FAN_CHECK, D_FAN_UP, D_FAN_DOWN, D_FAN_ICON = 28, 29, 30, 31
|
|
50
|
+
|
|
51
|
+
# Details screen (screen 9, ScMainDetail): read-only text fields, closed with part 15.
|
|
52
|
+
DT_NAME, DT_ID, DT_TYPE, DT_PORT, DT_ADDRESS = 2, 4, 6, 8, 10
|
|
53
|
+
DT_INFO, DT_CHARS, DT_CLOSE = 12, 14, 15
|
|
54
|
+
|
|
55
|
+
_TEMP = re.compile(r"-?\d+(?:[.,]\d+)?")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_mode(label: str | None) -> str | None:
|
|
59
|
+
"""Map a localised operation mode label to heat/cool/fan/dry/auto.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
----
|
|
63
|
+
label: The label as the iTM shows it.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
-------
|
|
67
|
+
The normalized mode, or None when the label is unknown.
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
return MODES.get(label.strip().lower()) if label else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Capabilities:
|
|
75
|
+
"""Static facts about a unit.
|
|
76
|
+
|
|
77
|
+
Its identity (Details screen) and what its settings dialog offers. Read once;
|
|
78
|
+
none of this changes while running.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
# Normalized mode -> label as the iTM shows it, in dialog order.
|
|
83
|
+
mode_labels: dict[str, str]
|
|
84
|
+
fan_speed: bool
|
|
85
|
+
point_id: str | None = None # iTM management point ID, e.g. "105"
|
|
86
|
+
# Bus port:address, e.g. "1:1-04"; stable, survives renames.
|
|
87
|
+
address: str | None = None
|
|
88
|
+
unit_type: str | None = None # e.g. "Binnendeel"
|
|
89
|
+
description: str | None = None # free text set in the iTM
|
|
90
|
+
zones: tuple[str, ...] = field(default_factory=tuple) # zone paths of the unit
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def modes(self) -> tuple[str, ...]:
|
|
94
|
+
"""The normalized modes the unit offers, in dialog order."""
|
|
95
|
+
return tuple(self.mode_labels)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def key(self) -> str:
|
|
99
|
+
"""Stable identifier: the bus address when known, else the name."""
|
|
100
|
+
return self.address or self.name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def details_from_screen(state: UIState) -> dict[str, Any]:
|
|
104
|
+
"""Read a unit's identity from the open Details screen.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
----
|
|
108
|
+
state: The merged UI state with the Details screen open.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
-------
|
|
112
|
+
Keyword arguments for Capabilities: point_id, address, unit_type,
|
|
113
|
+
description and zones.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
s = ScreenID.DETAILS
|
|
117
|
+
|
|
118
|
+
def text(part: int) -> str | None:
|
|
119
|
+
value = state.get(s, part, "TEXT")
|
|
120
|
+
return html.unescape(value).strip() or None if value else None
|
|
121
|
+
|
|
122
|
+
port, address = text(DT_PORT), text(DT_ADDRESS)
|
|
123
|
+
chars = text(DT_CHARS) or ""
|
|
124
|
+
zones = []
|
|
125
|
+
if chars.startswith("Zone:"):
|
|
126
|
+
for line in chars.splitlines()[1:]:
|
|
127
|
+
if not line.strip():
|
|
128
|
+
break
|
|
129
|
+
zones.append(line.strip())
|
|
130
|
+
return {
|
|
131
|
+
"point_id": text(DT_ID),
|
|
132
|
+
"address": f"{port}:{address}" if port and address else None,
|
|
133
|
+
"unit_type": text(DT_TYPE),
|
|
134
|
+
"description": text(DT_INFO),
|
|
135
|
+
"zones": tuple(zones),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def capabilities_from_dialog(
|
|
140
|
+
name: str, state: UIState, details: dict[str, Any] | None = None
|
|
141
|
+
) -> Capabilities:
|
|
142
|
+
"""Read what a unit offers from its open settings dialog.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
----
|
|
146
|
+
name: The unit's name.
|
|
147
|
+
state: The merged UI state with the settings dialog open.
|
|
148
|
+
details: The unit's identity, see details_from_screen.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
-------
|
|
152
|
+
The unit's capabilities.
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
s = ScreenID.SETTINGS
|
|
156
|
+
labels = state.get(s, D_MODE, "COMBO_STRS", []) or []
|
|
157
|
+
modes = {}
|
|
158
|
+
for label in labels:
|
|
159
|
+
if (mode := normalize_mode(label)) and mode not in modes:
|
|
160
|
+
modes[mode] = label
|
|
161
|
+
return Capabilities(
|
|
162
|
+
name=name,
|
|
163
|
+
mode_labels=modes,
|
|
164
|
+
fan_speed=state.visible(s, D_FAN_CHECK)
|
|
165
|
+
and state.get(s, D_FAN_ICON, "ICONID") in FAN_ICONS,
|
|
166
|
+
**(details or {}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class Unit:
|
|
172
|
+
"""The state of an indoor unit as shown on the operation panel."""
|
|
173
|
+
|
|
174
|
+
name: str
|
|
175
|
+
power: str | None # raw on/off option, e.g. "Start", "Stop", "SB Laag" (setback)
|
|
176
|
+
running: bool | None # True when "Start" (first option); setback is not running
|
|
177
|
+
state_icon: int | None
|
|
178
|
+
setpoint: int | None
|
|
179
|
+
setpoint_range: tuple[int, ...]
|
|
180
|
+
room_temperature: float | None
|
|
181
|
+
mode: str | None # heat / cool / fan / dry / auto, None when unknown
|
|
182
|
+
mode_label: str | None
|
|
183
|
+
fan_icon: int | None
|
|
184
|
+
fan_speed: str | None # low / medium / high / auto
|
|
185
|
+
error_code: str | None
|
|
186
|
+
filter_sign: bool
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def setback(self) -> bool:
|
|
190
|
+
"""Whether the unit is in setback (the state icon shows SB)."""
|
|
191
|
+
return self.state_icon == ICON_STATE_SETBACK
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _selected(options: Any, index: Any) -> Any:
|
|
195
|
+
"""Return options[index], or None when the index is missing or out of range."""
|
|
196
|
+
return options[index] if index is not None and 0 <= index < len(options) else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def unit_from_panel(state: UIState) -> Unit:
|
|
200
|
+
"""Build a Unit from the merged operation panel.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
----
|
|
204
|
+
state: The merged UI state with the unit selected.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
-------
|
|
208
|
+
The unit.
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
s = ScreenID.OPERATION
|
|
212
|
+
get = state.get
|
|
213
|
+
|
|
214
|
+
options = get(s, P_POWER, "COMBO_STRS", []) or []
|
|
215
|
+
power_idx = get(s, P_POWER, "SINGLE_SELIDX")
|
|
216
|
+
nums = tuple(get(s, P_SETPOINT, "COMBO_NUMS", []) or [])
|
|
217
|
+
|
|
218
|
+
temp = None
|
|
219
|
+
if state.visible(s, P_TEMP):
|
|
220
|
+
m = _TEMP.search(get(s, P_TEMP, "LABEL") or "")
|
|
221
|
+
temp = float(m.group().replace(",", ".")) if m else None
|
|
222
|
+
|
|
223
|
+
mode_label = get(s, P_MODE, "LABEL") if state.visible(s, P_MODE) else None
|
|
224
|
+
icon = get(s, P_STATE_ICON, "ICONID_MODE")
|
|
225
|
+
error = get(s, P_ERROR_CODE, "LABEL") if state.visible(s, P_ERROR_CODE) else None
|
|
226
|
+
fan_icon = get(s, P_FAN_ICON, "ICONID")
|
|
227
|
+
|
|
228
|
+
return Unit(
|
|
229
|
+
name=get(s, P_NAME, "LABEL"),
|
|
230
|
+
power=_selected(options, power_idx),
|
|
231
|
+
running=None if power_idx is None else power_idx == 0,
|
|
232
|
+
state_icon=icon[1] if icon else None,
|
|
233
|
+
setpoint=_selected(nums, get(s, P_SETPOINT, "SINGLE_SELIDX")),
|
|
234
|
+
setpoint_range=nums,
|
|
235
|
+
room_temperature=temp,
|
|
236
|
+
mode=normalize_mode(mode_label),
|
|
237
|
+
mode_label=mode_label,
|
|
238
|
+
fan_icon=fan_icon,
|
|
239
|
+
fan_speed=FAN_ICONS.get(fan_icon),
|
|
240
|
+
error_code=error or None,
|
|
241
|
+
filter_sign=state.visible(s, P_FILTER_ICON),
|
|
242
|
+
)
|