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.
@@ -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
+ )