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