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/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Asynchronous Python client for the Daikin intelligent Touch Manager (iTM)."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_ZONE_PATH,
|
|
5
|
+
FileSessionStore,
|
|
6
|
+
ItmClient,
|
|
7
|
+
MemorySessionStore,
|
|
8
|
+
SessionStore,
|
|
9
|
+
)
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
ItmCommandError,
|
|
12
|
+
ItmError,
|
|
13
|
+
LoginRejected,
|
|
14
|
+
ProtocolError,
|
|
15
|
+
SessionEnded,
|
|
16
|
+
VersionMismatch,
|
|
17
|
+
ZoneNotFound,
|
|
18
|
+
)
|
|
19
|
+
from .models import FAN_SPEEDS, Capabilities, Unit
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_ZONE_PATH",
|
|
23
|
+
"FAN_SPEEDS",
|
|
24
|
+
"Capabilities",
|
|
25
|
+
"FileSessionStore",
|
|
26
|
+
"ItmClient",
|
|
27
|
+
"ItmCommandError",
|
|
28
|
+
"ItmError",
|
|
29
|
+
"LoginRejected",
|
|
30
|
+
"MemorySessionStore",
|
|
31
|
+
"ProtocolError",
|
|
32
|
+
"SessionEnded",
|
|
33
|
+
"SessionStore",
|
|
34
|
+
"Unit",
|
|
35
|
+
"VersionMismatch",
|
|
36
|
+
"ZoneNotFound",
|
|
37
|
+
]
|
daikin_itm/cli.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Small CLI for debugging: ``itm status``, ``itm zones``, ``itm setpoint 1:1-04 21``.
|
|
2
|
+
|
|
3
|
+
Credentials come from ITM_USERNAME / ITM_PASSWORD, or --creds pointing at the JSON
|
|
4
|
+
written by ``op item get <id> --fields label=username,label=password --reveal
|
|
5
|
+
--format json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
17
|
+
import unicodedata
|
|
18
|
+
from dataclasses import asdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
import aiohttp
|
|
23
|
+
|
|
24
|
+
from .client import DEFAULT_ZONE_PATH, FileSessionStore, ItmClient
|
|
25
|
+
from .exceptions import ItmError
|
|
26
|
+
from .models import FAN_SPEEDS, MODES, Capabilities
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
30
|
+
|
|
31
|
+
from .models import Unit
|
|
32
|
+
|
|
33
|
+
CACHE_DIR = Path.home() / ".cache" / "daikin-itm"
|
|
34
|
+
# Descriptions etc., refreshed when unit names change.
|
|
35
|
+
UNITS_CACHE = CACHE_DIR / "units.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _safe(text: str) -> str:
|
|
39
|
+
"""Make text from the iTM safe to print.
|
|
40
|
+
|
|
41
|
+
Names, descriptions and errors come from whatever answers at the iTM's address;
|
|
42
|
+
replace control and format characters so they can't drive the terminal (escape
|
|
43
|
+
sequences, hidden or rewritten lines).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
----
|
|
47
|
+
text: The text to sanitize.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
-------
|
|
51
|
+
The text with every control and format character replaced by ``?``.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
return "".join(
|
|
55
|
+
"?" if unicodedata.category(ch).startswith("C") else ch for ch in text
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _out(text: str = "") -> None:
|
|
60
|
+
"""Print sanitized text."""
|
|
61
|
+
print(_safe(text))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _session_file(host: str) -> Path:
|
|
65
|
+
"""Return the default session file for a host."""
|
|
66
|
+
safe_host = re.sub(r"[^A-Za-z0-9._-]", "_", host)
|
|
67
|
+
return CACHE_DIR / f"session-{safe_host}.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _credentials(path: str | None) -> tuple[str, str]:
|
|
71
|
+
"""Load the iTM credentials.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
----
|
|
75
|
+
path: A JSON file with username/password fields, or None to use the
|
|
76
|
+
ITM_USERNAME and ITM_PASSWORD environment variables.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
-------
|
|
80
|
+
The username and password.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
if path:
|
|
84
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
85
|
+
fields = data if isinstance(data, list) else data.get("fields", [data])
|
|
86
|
+
values = {f.get("label"): f.get("value") for f in fields}
|
|
87
|
+
if not values.get("username") or not values.get("password"):
|
|
88
|
+
sys.exit(f"{path} has no username and password fields")
|
|
89
|
+
return values["username"], values["password"]
|
|
90
|
+
try:
|
|
91
|
+
return os.environ["ITM_USERNAME"], os.environ["ITM_PASSWORD"]
|
|
92
|
+
except KeyError:
|
|
93
|
+
sys.exit("set ITM_USERNAME and ITM_PASSWORD, or pass --creds")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_caps(host: str) -> dict[str, Capabilities]:
|
|
97
|
+
"""Load the cached capabilities of a host's units."""
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(UNITS_CACHE.read_text(encoding="utf-8"))
|
|
100
|
+
data = data.get(host, {}) if isinstance(data, dict) else {}
|
|
101
|
+
return {
|
|
102
|
+
name: Capabilities(**{**c, "zones": tuple(c.get("zones", ()))})
|
|
103
|
+
for name, c in data.items()
|
|
104
|
+
}
|
|
105
|
+
except (FileNotFoundError, ValueError, TypeError):
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _save_caps(host: str, caps: dict[str, Capabilities]) -> None:
|
|
110
|
+
"""Cache the capabilities of a host's units."""
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(UNITS_CACHE.read_text(encoding="utf-8"))
|
|
113
|
+
except (FileNotFoundError, ValueError):
|
|
114
|
+
data = {}
|
|
115
|
+
if not isinstance(data, dict): # not ours: start over
|
|
116
|
+
data = {}
|
|
117
|
+
data[host] = {name: asdict(c) for name, c in caps.items()}
|
|
118
|
+
UNITS_CACHE.write_text(
|
|
119
|
+
json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_units(units: dict[str, Unit], caps: dict[str, Capabilities]) -> None:
|
|
124
|
+
"""Print a table of units."""
|
|
125
|
+
_out(
|
|
126
|
+
f"{'unit':14} {'description':22} {'power':8} {'mode':10} {'fan':7} "
|
|
127
|
+
f"{'setpoint':>8} {'range':>7} {'room':>6} error"
|
|
128
|
+
)
|
|
129
|
+
for u in units.values():
|
|
130
|
+
rng = (
|
|
131
|
+
f"{u.setpoint_range[0]}-{u.setpoint_range[-1]}" if u.setpoint_range else "-"
|
|
132
|
+
)
|
|
133
|
+
temp = f"{u.room_temperature:.1f}" if u.room_temperature is not None else "-"
|
|
134
|
+
desc = caps[u.name].description if u.name in caps else None
|
|
135
|
+
_out(
|
|
136
|
+
f"{u.name:14} {desc or '-':22} {u.power or '-':8} "
|
|
137
|
+
f"{u.mode_label or '-':10} {u.fan_speed or '-':7} "
|
|
138
|
+
f"{u.setpoint or '-':>8} {rng:>7} {temp:>6} {u.error_code or ''}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _print_change(unit: str, u: Unit | None) -> None:
|
|
143
|
+
"""Print a unit's state after a change."""
|
|
144
|
+
if u is not None:
|
|
145
|
+
_out(
|
|
146
|
+
f"{unit}: power={u.power} mode={u.mode_label} fan={u.fan_speed} "
|
|
147
|
+
f"setpoint={u.setpoint}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _status(client: ItmClient, args: argparse.Namespace) -> None:
|
|
152
|
+
"""Show all units."""
|
|
153
|
+
units = await client.fetch_units()
|
|
154
|
+
caps = {} if args.refresh else _load_caps(args.host)
|
|
155
|
+
if set(units) - set(caps): # first run, or a unit was added/renamed in the iTM
|
|
156
|
+
caps = await client.fetch_capabilities()
|
|
157
|
+
_save_caps(args.host, caps)
|
|
158
|
+
_print_units(units, caps)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _zones(client: ItmClient, args: argparse.Namespace) -> None:
|
|
162
|
+
"""List the zones and units at a path."""
|
|
163
|
+
path = tuple(args.path.split("/")) if args.path else ()
|
|
164
|
+
for name in await client.list_zones(path):
|
|
165
|
+
_out(name)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _setpoint(client: ItmClient, args: argparse.Namespace) -> None:
|
|
169
|
+
"""Change a unit's setpoint."""
|
|
170
|
+
# Returns the unit as read back once the change shows; refuses stopped units.
|
|
171
|
+
u = await client.set_setpoint(args.unit, args.value)
|
|
172
|
+
_out(f"{args.unit}: setpoint now {u.setpoint if u else None}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _caps(client: ItmClient, args: argparse.Namespace) -> None:
|
|
176
|
+
"""Show the modes and fan control each unit offers."""
|
|
177
|
+
all_caps = await client.fetch_capabilities()
|
|
178
|
+
_save_caps(args.host, all_caps)
|
|
179
|
+
for name, caps in all_caps.items():
|
|
180
|
+
_out(
|
|
181
|
+
f"{name:14} {caps.address or '-':8} id={caps.point_id or '-':5} "
|
|
182
|
+
f"modes={','.join(caps.modes) or '-':20} "
|
|
183
|
+
f"fan={'yes' if caps.fan_speed else 'no':4} "
|
|
184
|
+
f"{caps.description or ''}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _power(client: ItmClient, args: argparse.Namespace) -> None:
|
|
189
|
+
"""Start or stop a unit."""
|
|
190
|
+
_print_change(args.unit, await client.set_power(args.unit, args.state == "on"))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _mode(client: ItmClient, args: argparse.Namespace) -> None:
|
|
194
|
+
"""Change a unit's operation mode."""
|
|
195
|
+
_print_change(args.unit, await client.set_mode(args.unit, args.mode))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _fan(client: ItmClient, args: argparse.Namespace) -> None:
|
|
199
|
+
"""Change a unit's fan speed."""
|
|
200
|
+
_print_change(args.unit, await client.set_fan_speed(args.unit, args.speed))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
COMMANDS: dict[str, Callable[[ItmClient, argparse.Namespace], Awaitable[None]]] = {
|
|
204
|
+
"status": _status,
|
|
205
|
+
"zones": _zones,
|
|
206
|
+
"setpoint": _setpoint,
|
|
207
|
+
"caps": _caps,
|
|
208
|
+
"power": _power,
|
|
209
|
+
"mode": _mode,
|
|
210
|
+
"fan": _fan,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _run(args: argparse.Namespace) -> None:
|
|
215
|
+
"""Run the command given on the command line."""
|
|
216
|
+
username, password = _credentials(args.creds)
|
|
217
|
+
# Owner-only: it holds the session token while a command runs (and after a
|
|
218
|
+
# failed logout).
|
|
219
|
+
CACHE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
220
|
+
CACHE_DIR.chmod(0o700)
|
|
221
|
+
zone_path = tuple(args.zone.split("/")) if args.zone else DEFAULT_ZONE_PATH
|
|
222
|
+
async with ItmClient(
|
|
223
|
+
args.host,
|
|
224
|
+
username,
|
|
225
|
+
password,
|
|
226
|
+
store=FileSessionStore(
|
|
227
|
+
str(args.session_file or _session_file(args.host)), args.host
|
|
228
|
+
),
|
|
229
|
+
zone_path=zone_path,
|
|
230
|
+
) as client:
|
|
231
|
+
await COMMANDS[args.command](client, args)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parser() -> argparse.ArgumentParser:
|
|
235
|
+
"""Build the argument parser."""
|
|
236
|
+
p = argparse.ArgumentParser(prog="itm", description=(__doc__ or "").splitlines()[0])
|
|
237
|
+
p.add_argument("--host", default=os.environ.get("ITM_HOST", "10.50.3.46"))
|
|
238
|
+
p.add_argument(
|
|
239
|
+
"--creds", help="JSON file with username/password fields (1Password export)"
|
|
240
|
+
)
|
|
241
|
+
p.add_argument("--zone", help="zone path to the units, e.g. 'Alles/Binnendeel'")
|
|
242
|
+
p.add_argument(
|
|
243
|
+
"--session-file",
|
|
244
|
+
help=f"where to keep the session token (default: per host in {CACHE_DIR})",
|
|
245
|
+
)
|
|
246
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
247
|
+
st = sub.add_parser("status", help="show all units")
|
|
248
|
+
st.add_argument(
|
|
249
|
+
"--refresh",
|
|
250
|
+
action="store_true",
|
|
251
|
+
help="re-read descriptions instead of using the cache",
|
|
252
|
+
)
|
|
253
|
+
z = sub.add_parser("zones", help="list zones/units at a path")
|
|
254
|
+
z.add_argument("path", nargs="?", help="e.g. 'Alles'")
|
|
255
|
+
s = sub.add_parser("setpoint", help="change a unit's setpoint")
|
|
256
|
+
s.add_argument("unit")
|
|
257
|
+
s.add_argument("value", type=int)
|
|
258
|
+
sub.add_parser(
|
|
259
|
+
"caps",
|
|
260
|
+
help="modes and fan control each unit offers (opens each settings dialog)",
|
|
261
|
+
)
|
|
262
|
+
w = sub.add_parser("power", help="start or stop a unit")
|
|
263
|
+
w.add_argument("unit")
|
|
264
|
+
w.add_argument("state", choices=["on", "off"])
|
|
265
|
+
m = sub.add_parser("mode", help="change a unit's operation mode")
|
|
266
|
+
m.add_argument("unit")
|
|
267
|
+
m.add_argument("mode", choices=sorted(set(MODES.values())))
|
|
268
|
+
f = sub.add_parser("fan", help="change a unit's fan speed")
|
|
269
|
+
f.add_argument("unit")
|
|
270
|
+
f.add_argument("speed", choices=FAN_SPEEDS)
|
|
271
|
+
return p
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
275
|
+
"""Run the ``itm`` command line tool.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
----
|
|
279
|
+
argv: The arguments, defaults to the ones the process was started with.
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
args = _parser().parse_args(argv)
|
|
283
|
+
|
|
284
|
+
async def runner() -> None:
|
|
285
|
+
task = asyncio.current_task()
|
|
286
|
+
if task is not None:
|
|
287
|
+
# SIGTERM/SIGHUP cancel the task, so the client's finally-blocks still
|
|
288
|
+
# log out.
|
|
289
|
+
loop = asyncio.get_running_loop()
|
|
290
|
+
for sig in (signal.SIGTERM, signal.SIGHUP):
|
|
291
|
+
loop.add_signal_handler(sig, task.cancel)
|
|
292
|
+
await _run(args)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
asyncio.run(runner())
|
|
296
|
+
except ItmError as e:
|
|
297
|
+
sys.exit(_safe(f"error: {e}"))
|
|
298
|
+
except (aiohttp.ClientError, TimeoutError) as e:
|
|
299
|
+
reason = str(e) or type(e).__name__
|
|
300
|
+
sys.exit(_safe(f"error: could not reach the iTM at {args.host}: {reason}"))
|
|
301
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
302
|
+
sys.exit(130)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|