kettle-x2p-cli 1.0.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.
kettle/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Local control of the Xiaomi Smart Kettle 2 Pro over the LAN."""
2
+
3
+ version = "1.0.0"
kettle/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
kettle/aes.py ADDED
@@ -0,0 +1,151 @@
1
+ """AES-128 in CBC mode with PKCS7 padding, pure Python (standard library only).
2
+
3
+ The miio protocol encrypts a few hundred bytes per request with AES-128-CBC, so speed is
4
+ irrelevant and a tiny implementation keeps the package free of compiled dependencies
5
+ (which makes it trivial to package for Homebrew). Verified against the FIPS-197 and
6
+ NIST SP 800-38A test vectors in tests/test_aes.py.
7
+ """
8
+ from __future__ import annotations
9
+
10
+
11
+ def _build_sbox() -> list:
12
+ sbox = [0] * 256
13
+ p = q = 1
14
+ while True:
15
+ p = p ^ ((p << 1) & 0xFF) ^ (0x1B if p & 0x80 else 0) # p *= 3 in GF(2^8)
16
+ q ^= q << 1
17
+ q ^= q << 2
18
+ q ^= q << 4
19
+ q &= 0xFF
20
+ if q & 0x80:
21
+ q ^= 0x09 # q /= 3
22
+ x = q
23
+ for shift in (1, 2, 3, 4):
24
+ x ^= ((q << shift) | (q >> (8 - shift))) & 0xFF
25
+ sbox[p] = (x ^ 0x63) & 0xFF
26
+ if p == 1:
27
+ break
28
+ sbox[0] = 0x63
29
+ return sbox
30
+
31
+
32
+ _SBOX = _build_sbox()
33
+ _INV_SBOX = [0] * 256
34
+ for _i, _v in enumerate(_SBOX):
35
+ _INV_SBOX[_v] = _i
36
+
37
+
38
+ def _xtime(a: int) -> int:
39
+ a <<= 1
40
+ return (a ^ 0x11B) & 0xFF if a & 0x100 else a
41
+
42
+
43
+ def _mul(a: int, b: int) -> int:
44
+ r = 0
45
+ while b:
46
+ if b & 1:
47
+ r ^= a
48
+ a = _xtime(a)
49
+ b >>= 1
50
+ return r
51
+
52
+
53
+ _MUL = {n: [_mul(i, n) for i in range(256)] for n in (2, 3, 9, 11, 13, 14)}
54
+
55
+
56
+ def _expand_key(key: bytes) -> list:
57
+ if len(key) != 16:
58
+ raise ValueError("AES-128 needs a 16-byte key")
59
+ w = [list(key[i:i + 4]) for i in range(0, 16, 4)]
60
+ rcon = 1
61
+ for i in range(4, 44):
62
+ t = list(w[i - 1])
63
+ if i % 4 == 0:
64
+ t = t[1:] + t[:1]
65
+ t = [_SBOX[b] for b in t]
66
+ t[0] ^= rcon
67
+ rcon = _xtime(rcon)
68
+ w.append([w[i - 4][j] ^ t[j] for j in range(4)])
69
+ return [sum(w[4 * r:4 * r + 4], []) for r in range(11)]
70
+
71
+
72
+ def _add_key(state: list, rk: list) -> list:
73
+ return [s ^ k for s, k in zip(state, rk)]
74
+
75
+
76
+ def _shift_rows(s: list) -> list:
77
+ return [s[r + 4 * ((c + r) % 4)] for c in range(4) for r in range(4)]
78
+
79
+
80
+ def _inv_shift_rows(s: list) -> list:
81
+ out = [0] * 16
82
+ for c in range(4):
83
+ for r in range(4):
84
+ out[r + 4 * ((c + r) % 4)] = s[r + 4 * c]
85
+ return out
86
+
87
+
88
+ def _mix_columns(s: list) -> list:
89
+ m2, m3 = _MUL[2], _MUL[3]
90
+ out = []
91
+ for c in range(0, 16, 4):
92
+ a0, a1, a2, a3 = s[c:c + 4]
93
+ out += [m2[a0] ^ m3[a1] ^ a2 ^ a3,
94
+ a0 ^ m2[a1] ^ m3[a2] ^ a3,
95
+ a0 ^ a1 ^ m2[a2] ^ m3[a3],
96
+ m3[a0] ^ a1 ^ a2 ^ m2[a3]]
97
+ return out
98
+
99
+
100
+ def _inv_mix_columns(s: list) -> list:
101
+ m9, m11, m13, m14 = _MUL[9], _MUL[11], _MUL[13], _MUL[14]
102
+ out = []
103
+ for c in range(0, 16, 4):
104
+ a0, a1, a2, a3 = s[c:c + 4]
105
+ out += [m14[a0] ^ m11[a1] ^ m13[a2] ^ m9[a3],
106
+ m9[a0] ^ m14[a1] ^ m11[a2] ^ m13[a3],
107
+ m13[a0] ^ m9[a1] ^ m14[a2] ^ m11[a3],
108
+ m11[a0] ^ m13[a1] ^ m9[a2] ^ m14[a3]]
109
+ return out
110
+
111
+
112
+ def encrypt_block(round_keys: list, block: bytes) -> bytes:
113
+ s = _add_key(list(block), round_keys[0])
114
+ for r in range(1, 10):
115
+ s = _add_key(_mix_columns(_shift_rows([_SBOX[b] for b in s])), round_keys[r])
116
+ return bytes(_add_key(_shift_rows([_SBOX[b] for b in s]), round_keys[10]))
117
+
118
+
119
+ def decrypt_block(round_keys: list, block: bytes) -> bytes:
120
+ s = _add_key(list(block), round_keys[10])
121
+ for r in range(9, 0, -1):
122
+ s = _inv_mix_columns(_add_key([_INV_SBOX[b] for b in _inv_shift_rows(s)], round_keys[r]))
123
+ return bytes(_add_key([_INV_SBOX[b] for b in _inv_shift_rows(s)], round_keys[0]))
124
+
125
+
126
+ def cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
127
+ """AES-128-CBC with PKCS7 padding."""
128
+ rk = _expand_key(key)
129
+ pad = 16 - len(data) % 16
130
+ data += bytes([pad]) * pad
131
+ out, prev = bytearray(), iv
132
+ for i in range(0, len(data), 16):
133
+ prev = encrypt_block(rk, bytes(a ^ b for a, b in zip(data[i:i + 16], prev)))
134
+ out += prev
135
+ return bytes(out)
136
+
137
+
138
+ def cbc_decrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
139
+ """Inverse of cbc_encrypt; raises ValueError on bad length or padding."""
140
+ if not data or len(data) % 16:
141
+ raise ValueError("ciphertext is not a multiple of 16 bytes")
142
+ rk = _expand_key(key)
143
+ out, prev = bytearray(), iv
144
+ for i in range(0, len(data), 16):
145
+ block = data[i:i + 16]
146
+ out += bytes(a ^ b for a, b in zip(decrypt_block(rk, block), prev))
147
+ prev = block
148
+ pad = out[-1]
149
+ if not 1 <= pad <= 16 or out[-pad:] != bytes([pad]) * pad:
150
+ raise ValueError("bad PKCS7 padding")
151
+ return bytes(out[:-pad])
kettle/cli.py ADDED
@@ -0,0 +1,437 @@
1
+ """Command line interface: argument parsing, the commands and the interactive shell."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import copy
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+
11
+ from . import config, version
12
+ from .miio import MiioClient, MiioError, MiioRpcError, discover, local_subnet
13
+
14
+ EXPECTED_MODEL = "xiaomi.kettle.v20"
15
+
16
+ # (siid, piid) from SPEC.md
17
+ STATUS, FAULT, TEMP, TARGET, AUTO_KEEP, KEEP_TEMP, ON = (2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (2, 6), (2, 7)
18
+ KEEP_MIN, LIFTED, WARMING, TARGET_MODE, HEAT_MODE, BOIL_MODE = (3, 1), (3, 7), (3, 10), (3, 11), (3, 12), (3, 13)
19
+ STOP_ACTION = (3, 1)
20
+ STATUS_NAMES = {0: "idle", 1: "heating", 2: "boiling", 3: "cooling down", 4: "keep warm"}
21
+
22
+
23
+ class KettleError(Exception):
24
+ def __init__(self, msg: str, code: int = 1):
25
+ super().__init__(msg)
26
+ self.code = code
27
+
28
+
29
+ def die(msg: str, code: int = 1):
30
+ raise KettleError(msg, code)
31
+
32
+
33
+ def connect(args, use_saved_token: bool = True, need_token: bool = True) -> tuple[MiioClient, dict]:
34
+ cfg = config.load_config()
35
+ shared = getattr(args, "_client", None)
36
+ if shared is not None and use_saved_token:
37
+ return shared, cfg
38
+ ip = args.ip or cfg.get("ip") or die("no kettle configured yet: run `kettle setup`")
39
+ token_hex = args.token or (cfg.get("token") if use_saved_token else None)
40
+ token = bytes.fromhex(token_hex) if token_hex else None
41
+ if token is not None and len(token) != 16:
42
+ die("token must be 32 hex characters")
43
+ if token is None and need_token:
44
+ die("no token saved: run `kettle setup`")
45
+ return MiioClient(ip, token, timeout=args.timeout, debug=args.debug), cfg
46
+
47
+
48
+ def read_props(c: MiioClient, props) -> dict:
49
+ """Read properties; any non-zero code or missing value aborts (never act on ambiguous data)."""
50
+ out = {}
51
+ for item in c.get_properties(props):
52
+ key = (item.get("siid"), item.get("piid"))
53
+ if item.get("code") != 0 or "value" not in item:
54
+ die(f"could not read property {key}: {item}")
55
+ out[key] = item["value"]
56
+ if len(out) != len(props):
57
+ die(f"expected {len(props)} properties, got {len(out)}")
58
+ return out
59
+
60
+
61
+ def get_info(c: MiioClient, cfg: dict, save: bool = True) -> dict:
62
+ h = c.handshake()
63
+ info = c.call("miIO.info")
64
+ if save and isinstance(info, dict):
65
+ cfg.update(ip=c.ip, token=c.token.hex(), did=h.did, model=info.get("model"))
66
+ config.save_config(cfg)
67
+ return info
68
+
69
+
70
+ def check_model(c: MiioClient, cfg: dict, args) -> None:
71
+ if getattr(args, "_model_ok", False):
72
+ return
73
+ info = get_info(c, cfg)
74
+ model = info.get("model")
75
+ if model != EXPECTED_MODEL and not args.force_model:
76
+ die(f"model is {model!r}, SPEC.md is for {EXPECTED_MODEL!r}; refusing to send commands "
77
+ f"(--force-model to override)")
78
+ if getattr(args, "_client", None) is not None:
79
+ args._model_ok = True
80
+
81
+
82
+ # --- commands ---------------------------------------------------------
83
+
84
+ def cmd_hello(args):
85
+ c, _ = connect(args, need_token=False)
86
+ with c:
87
+ h = c.handshake()
88
+ print(f"device id : {h.did} ({h.did:#010x})")
89
+ print(f"stamp : {h.stamp}")
90
+ print(f"checksum : {h.checksum.hex()}")
91
+ if h.token:
92
+ print(f"TOKEN LEAKED in hello reply: {h.token.hex()}")
93
+ print(" (keep it private; `kettle info` will save it to the config)")
94
+ else:
95
+ print("token not exposed in hello reply (zeros/ff): supply it with --token or run `kettle setup`")
96
+
97
+
98
+ def cmd_info(args):
99
+ c, cfg = connect(args, need_token=False)
100
+ with c:
101
+ if c.token is None:
102
+ c.handshake()
103
+ if c.token is None:
104
+ die("no token available; run `kettle setup`")
105
+ info = get_info(c, cfg)
106
+ shown = {k: ("<saved to config, hidden>" if k == "token" else v) for k, v in info.items()}
107
+ print(json.dumps(shown, indent=2))
108
+ print(f"\nmodel: {info.get('model')}"
109
+ + ("" if info.get("model") == EXPECTED_MODEL else f" <-- differs from {EXPECTED_MODEL}"))
110
+ print(f"saved to {config.CONFIG_PATH}")
111
+
112
+
113
+ def cmd_status(args):
114
+ c, cfg = connect(args)
115
+ with c:
116
+ v = read_props(c, [STATUS, FAULT, TEMP, TARGET, AUTO_KEEP, KEEP_TEMP, KEEP_MIN, LIFTED, WARMING, TARGET_MODE])
117
+ print(f"status : {STATUS_NAMES.get(v[STATUS], v[STATUS])} ({v[STATUS]})")
118
+ print(f"fault : {v[FAULT]}")
119
+ print(f"temperature : {v[TEMP]} C")
120
+ print(f"target : {v[TARGET]} C")
121
+ print(f"keep warm : {'on' if v[AUTO_KEEP] else 'off'} at {v[KEEP_TEMP]} C for {v[KEEP_MIN]} min")
122
+ print(f"warming time : {v[WARMING]} min")
123
+ print(f"target mode : {v[TARGET_MODE]}")
124
+ print(f"lifted : {v[LIFTED]}")
125
+ print("water level : not exposed by this model (a low-water fault shows up in `fault`)")
126
+
127
+
128
+ def show_items(items) -> None:
129
+ for s_, p_, val in items:
130
+ print(f" ({s_},{p_}) = {val!r}")
131
+
132
+
133
+ def confirm(args, items) -> None:
134
+ print("About to send set_properties:")
135
+ show_items(items)
136
+ if args.yes:
137
+ return
138
+ if not sys.stdin.isatty():
139
+ die("non-interactive: pass --yes to start heating")
140
+ if input("Start heating? [y/N] ").strip().lower() != "y":
141
+ die("aborted", 2)
142
+
143
+
144
+ TEMP_MIN, TEMP_MAX = 40, 99 # spec: (2,4) target-temperature
145
+ KEEP_MIN_LO, KEEP_MIN_HI = 60, 1440 # spec: (3,1) keep-warm-time, minutes
146
+
147
+
148
+ def boil_items(keep_temp, keep_min) -> list:
149
+ return [(*TARGET, 99), (*AUTO_KEEP, False), (*KEEP_TEMP, keep_temp), (*KEEP_MIN, keep_min),
150
+ (*TARGET_MODE, 1), (*BOIL_MODE, f"99,0,{keep_temp},{keep_min}")]
151
+
152
+
153
+ def warm_to_items(temp, keep_temp, keep_min) -> list:
154
+ return [(*TARGET, temp), (*AUTO_KEEP, False), (*KEEP_TEMP, keep_temp), (*KEEP_MIN, keep_min),
155
+ (*TARGET_MODE, 0), (*HEAT_MODE, f"{temp},0,{keep_temp},{keep_min}")]
156
+
157
+
158
+ def warm_keep_items(temp, minutes) -> list:
159
+ return [(*TARGET, temp), (*AUTO_KEEP, True), (*KEEP_TEMP, temp), (*KEEP_MIN, minutes),
160
+ (*TARGET_MODE, 0), (*HEAT_MODE, f"{temp},1,{temp},{minutes}")]
161
+
162
+
163
+ def check_temp(temp: int) -> int:
164
+ if not TEMP_MIN <= temp <= TEMP_MAX:
165
+ die(f"temperature must be {TEMP_MIN}-{TEMP_MAX} C (kettle limits), got {temp}")
166
+ return temp
167
+
168
+
169
+ def check_minutes(minutes: int) -> int:
170
+ if not KEEP_MIN_LO <= minutes <= KEEP_MIN_HI:
171
+ die(f"keep-warm time must be {KEEP_MIN_LO}-{KEEP_MIN_HI} minutes (1-24 h, kettle limits), got {minutes}")
172
+ return minutes
173
+
174
+
175
+ def start_heating(args, build, label: str) -> None:
176
+ """Shared flow for boil / warm-to / warm-keep: preflight reads, confirm, write, verify.
177
+
178
+ build(keep_temp, keep_min) -> items. On --dry-run nothing is sent (no network at all)."""
179
+ if args.dry_run:
180
+ items = build("<current keep temp>", "<current keep min>")
181
+ if args.set_on:
182
+ items.append((*ON, True))
183
+ print(f"dry-run, {label}: set_properties would be:")
184
+ show_items(items)
185
+ return
186
+ c, cfg = connect(args)
187
+ with c:
188
+ check_model(c, cfg, args)
189
+ v = read_props(c, [STATUS, FAULT, TEMP, LIFTED, KEEP_TEMP, KEEP_MIN])
190
+ if v[FAULT] != 0:
191
+ die(f"kettle reports fault {v[FAULT]} (possibly low water); not heating")
192
+ if v[LIFTED]:
193
+ die("kettle is lifted off its base; not heating")
194
+ if v[STATUS] != 0:
195
+ die(f"kettle is not idle ({STATUS_NAMES.get(v[STATUS], v[STATUS])}); run `cancel` first")
196
+ keep_min = min(max(v[KEEP_MIN], KEEP_MIN_LO), KEEP_MIN_HI)
197
+ if keep_min != v[KEEP_MIN]:
198
+ print(f"note: keep-warm minutes {v[KEEP_MIN]} out of spec range, using {keep_min}")
199
+ print(f"{label}: water at {v[TEMP]} C, idle, no fault.")
200
+ items = build(v[KEEP_TEMP], keep_min)
201
+ if args.set_on:
202
+ items.append((*ON, True))
203
+ confirm(args, items)
204
+ results = c.set_properties(items)
205
+ bad = [r for r in results if r.get("code") != 0]
206
+ if bad:
207
+ die(f"device rejected some writes: {bad}")
208
+ print("accepted. waiting 3 s then reading status...")
209
+ time.sleep(3)
210
+ v = read_props(c, [STATUS, FAULT, TEMP])
211
+ print(f"status: {STATUS_NAMES.get(v[STATUS], v[STATUS])}, {v[TEMP]} C, fault {v[FAULT]}")
212
+ if v[STATUS] == 0:
213
+ print("kettle is still idle: the batch alone did not start it. Re-run with --set-on "
214
+ "(also writes on=true) once you are watching the kettle.")
215
+
216
+
217
+ def cmd_boil(args):
218
+ start_heating(args, boil_items, "boil")
219
+
220
+
221
+ def cmd_warm_to(args):
222
+ t = check_temp(args.temp)
223
+ start_heating(args, lambda kt, km: warm_to_items(t, kt, km), f"warm to {t} C, then stop")
224
+
225
+
226
+ def cmd_warm_keep(args):
227
+ t, m = check_temp(args.temp), check_minutes(args.minutes)
228
+ start_heating(args, lambda kt, km: warm_keep_items(t, m), f"warm to {t} C and keep warm {m} min")
229
+
230
+
231
+ def cmd_cancel(args):
232
+ if args.dry_run:
233
+ print(f"dry-run, action would be: siid={STOP_ACTION[0]} aiid={STOP_ACTION[1]} in=[]")
234
+ return
235
+ c, cfg = connect(args)
236
+ with c:
237
+ check_model(c, cfg, args)
238
+ res = c.action(*STOP_ACTION)
239
+ print(f"action result: {res}")
240
+ if isinstance(res, dict) and res.get("code") not in (0, None):
241
+ die("device rejected stop-work")
242
+ time.sleep(1)
243
+ v = read_props(c, [STATUS, TEMP])
244
+ print(f"status: {STATUS_NAMES.get(v[STATUS], v[STATUS])}, {v[TEMP]} C")
245
+
246
+
247
+ def local_tz_name() -> str:
248
+ """Olson name of the Mac's timezone (e.g. Europe/Berlin), or '' if it cannot be determined."""
249
+ try:
250
+ target = os.path.realpath("/etc/localtime")
251
+ return target.split("zoneinfo/", 1)[1]
252
+ except (OSError, IndexError):
253
+ return ""
254
+
255
+
256
+ def router_params(ssid: str, password: str, uid: int, app_params: bool, cc: str) -> dict:
257
+ """Params for miIO.config_router. app_params mirrors what the Xiaomi Home app 11.7.704 sends
258
+ in AP mode, minus bind_key (cloud-issued, deliberately omitted)."""
259
+ params = {"ssid": ssid, "passwd": password, "uid": uid}
260
+ if app_params:
261
+ params.update(config_type="app",
262
+ gmt_offset=time.localtime().tm_gmtoff,
263
+ tz=local_tz_name(),
264
+ wifi_config={"cc": cc})
265
+ return params
266
+
267
+
268
+ def cmd_provision(args):
269
+ """Send home Wi-Fi credentials via miIO.config_router. Run while joined to the kettle's AP."""
270
+ import getpass
271
+ cfg = config.load_config()
272
+ args.ip = args.ip or "10.10.1.1"
273
+ if not args.ssid:
274
+ die("--ssid is required (must be a 2.4 GHz network)")
275
+ password = getpass.getpass(f"Password for {args.ssid!r} (hidden, not stored): ")
276
+ if getpass.getpass("Type it again to confirm: ") != password:
277
+ die("passwords differ; nothing was sent")
278
+ if args.dry_run:
279
+ shown = router_params(args.ssid, "<hidden>", args.uid, args.app_params, args.cc)
280
+ print(f"dry-run: miIO.config_router {json.dumps(shown)}")
281
+ return
282
+ # In setup mode the token is re-generated each time, so never trust the saved one here:
283
+ # take the fresh one the kettle leaks in its hello reply.
284
+ c, cfg = connect(args, use_saved_token=False, need_token=False)
285
+ with c:
286
+ c.handshake()
287
+ if c.token is None:
288
+ die("kettle did not leak its token in the hello reply; supply --token")
289
+ info = get_info(c, cfg) # also saves token/did/model
290
+ if info.get("model") != EXPECTED_MODEL and not args.force_model:
291
+ die(f"unexpected model {info.get('model')!r}")
292
+ params = router_params(args.ssid, password, args.uid, args.app_params, args.cc)
293
+ try:
294
+ res = c.call("miIO.config_router", params, retry=False)
295
+ except MiioError as e:
296
+ # The kettle drops its AP as soon as it accepts the config, so a lost reply is expected.
297
+ print(f"no clean reply ({e}); this is normal if the kettle dropped its AP.")
298
+ res = None
299
+ print(f"device replied: {res}")
300
+ print("Rejoin your normal Wi-Fi, give the kettle ~30 s, then run `kettle find`.")
301
+
302
+
303
+ def cmd_find(args):
304
+ """Locate the saved device on the LAN by sweeping a /24 with hello packets."""
305
+ cfg = config.load_config()
306
+ did = cfg.get("did") or die("no saved device id; run `kettle setup` first")
307
+ base = (args.subnet.rsplit(".", 1)[0] if args.subnet and args.subnet.count(".") == 3
308
+ else args.subnet) or local_subnet()
309
+ print(f"sweeping {base}.0/24 for device id {did} ...")
310
+ found = next((ip for ip, d in discover(base) if d == did), None)
311
+ if not found:
312
+ die("device not found; is it online and on the same Wi-Fi/LAN as this computer?")
313
+ cfg["ip"] = found
314
+ config.save_config(cfg)
315
+ print(f"found at {found}, saved to config. Consider a DHCP reservation for it.")
316
+
317
+
318
+ def cmd_setup(args):
319
+ from .wizard import run_setup
320
+ run_setup(args)
321
+
322
+
323
+ def cmd_shell(args):
324
+ """Interactive session: one open socket, model checked once, same commands as the CLI."""
325
+ import shlex
326
+ try:
327
+ import readline # noqa: F401 (line editing/history where available)
328
+ except ImportError:
329
+ pass
330
+ parser = build_parser()
331
+ c, _cfg = connect(args)
332
+ c.persistent = True
333
+ args._client = c
334
+ try:
335
+ c.handshake()
336
+ print(f"connected to {c.ip}. Type `help` for commands, `quit` to leave.")
337
+ except MiioError as e:
338
+ print(f"warning: handshake failed ({e}); commands will retry")
339
+ try:
340
+ while True:
341
+ try:
342
+ line = input("kettle> ").strip()
343
+ except (EOFError, KeyboardInterrupt):
344
+ print()
345
+ break
346
+ if not line:
347
+ continue
348
+ if line in ("quit", "exit", "q"):
349
+ break
350
+ if line in ("help", "?"):
351
+ print("boil | warm-to <40-99> | warm-keep <40-99> <60-1440 min> | cancel | status | info")
352
+ print("add --yes to skip the heating confirmation, --dry-run to only print the RPC")
353
+ continue
354
+ try:
355
+ tokens = shlex.split(line)
356
+ if tokens[0] in ("shell", "setup", "provision", "find", "hello"):
357
+ print(f"`{tokens[0]}` is not available inside the shell")
358
+ continue
359
+ sub_args = parser.parse_args(tokens, namespace=copy.copy(args))
360
+ run_command(sub_args)
361
+ except SystemExit:
362
+ pass # argparse already printed its message
363
+ except KettleError as e:
364
+ print(f"error: {e}")
365
+ except (MiioError, OSError, ValueError) as e:
366
+ print(f"error: {e}")
367
+ finally:
368
+ c.persistent = False
369
+ c.close()
370
+
371
+
372
+ COMMANDS = {"hello": cmd_hello, "info": cmd_info, "status": cmd_status, "boil": cmd_boil,
373
+ "warm-to": cmd_warm_to, "warm-keep": cmd_warm_keep, "cancel": cmd_cancel,
374
+ "provision": cmd_provision, "find": cmd_find, "shell": cmd_shell, "setup": cmd_setup}
375
+
376
+
377
+ def run_command(args) -> None:
378
+ COMMANDS[args.cmd](args)
379
+
380
+
381
+ def build_parser() -> argparse.ArgumentParser:
382
+ p = argparse.ArgumentParser(prog="kettle", description="Local control of the Xiaomi Smart Kettle 2 Pro.")
383
+ p.add_argument("--version", action="version", version=f"kettle v{version}")
384
+ p.add_argument("--ip")
385
+ p.add_argument("--token", help="32 hex chars")
386
+ p.add_argument("--timeout", type=float, default=3.0)
387
+ p.add_argument("--debug", action="store_true", help="print raw packets and RPC")
388
+ p.add_argument("--dry-run", action="store_true", help="print the RPC instead of sending")
389
+ p.add_argument("--yes", action="store_true", help="skip heating confirmation")
390
+ p.add_argument("--force-model", action="store_true")
391
+ sub = p.add_subparsers(dest="cmd", metavar="command")
392
+ st = sub.add_parser("setup", help="guided first-time setup (token, IP, check)")
393
+ st.add_argument("--subnet", help="e.g. 192.168.1 (default: this computer's /24)")
394
+ sub.add_parser("hello", help="raw handshake, shows whether the token is exposed")
395
+ sub.add_parser("info", help="miIO.info; saves ip/token/did/model to config")
396
+ sub.add_parser("status", help="temperature, mode, target, fault, keep-warm")
397
+ sub.add_parser("cancel", help="stop heating / keep-warm")
398
+ sub.add_parser("shell", help="interactive session")
399
+ heat = argparse.ArgumentParser(add_help=False)
400
+ heat.add_argument("--set-on", action="store_true", help="also write on=true (2,7)")
401
+ sub.add_parser("boil", parents=[heat], help="heat to boiling")
402
+ w = sub.add_parser("warm-to", parents=[heat], help="heat to TEMP C, then stop")
403
+ w.add_argument("temp", type=int, help=f"{TEMP_MIN}-{TEMP_MAX} C")
404
+ k = sub.add_parser("warm-keep", parents=[heat], help="heat to TEMP C and keep warm for MINUTES")
405
+ k.add_argument("temp", type=int, help=f"{TEMP_MIN}-{TEMP_MAX} C")
406
+ k.add_argument("minutes", type=int, help=f"{KEEP_MIN_LO}-{KEEP_MIN_HI}")
407
+ pr = sub.add_parser("provision", help="advanced: send Wi-Fi to a kettle in setup mode (see README)")
408
+ pr.add_argument("--ssid")
409
+ pr.add_argument("--uid", type=int, default=0)
410
+ pr.add_argument("--app-params", action="store_true",
411
+ help="also send config_type/gmt_offset/tz/wifi_config like the Xiaomi Home app")
412
+ pr.add_argument("--cc", default="GB", help="country code for --app-params (app sends the account's)")
413
+ f = sub.add_parser("find", help="find the kettle's LAN IP by device id and save it")
414
+ f.add_argument("--subnet", help="e.g. 192.168.1 (default: the Mac's current /24)")
415
+ return p
416
+
417
+
418
+ def main(argv=None):
419
+ parser = build_parser()
420
+ args = parser.parse_args(argv)
421
+ if args.cmd is None:
422
+ parser.print_help()
423
+ if not config.load_config().get("token"):
424
+ print("\nFirst time here? Run `kettle setup`.")
425
+ return
426
+ try:
427
+ run_command(args)
428
+ except KettleError as e:
429
+ print(f"error: {e}", file=sys.stderr)
430
+ sys.exit(e.code)
431
+ except (MiioError, OSError) as e:
432
+ print(f"error: {e}", file=sys.stderr)
433
+ sys.exit(1)
434
+
435
+
436
+ if __name__ == "__main__":
437
+ main()
kettle/config.py ADDED
@@ -0,0 +1,21 @@
1
+ """The saved kettle (ip, token, did, model) in ~/.config/kettle/config.json, mode 600."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ CONFIG_PATH = Path(os.path.expanduser("~/.config/kettle/config.json"))
9
+
10
+
11
+ def load_config() -> dict:
12
+ try:
13
+ return json.loads(CONFIG_PATH.read_text())
14
+ except (FileNotFoundError, json.JSONDecodeError):
15
+ return {}
16
+
17
+
18
+ def save_config(cfg: dict) -> None:
19
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
20
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
21
+ os.chmod(CONFIG_PATH, 0o600)
kettle/miio.py ADDED
@@ -0,0 +1,220 @@
1
+ """Minimal raw miio client (UDP 54321): handshake, AES-128-CBC, JSON-RPC."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import socket
7
+ import struct
8
+ import time
9
+ from dataclasses import dataclass
10
+
11
+ from . import aes
12
+
13
+ PORT = 54321
14
+ MAGIC = 0x2131
15
+ HELLO = bytes.fromhex("21310020") + b"\xff" * 28
16
+
17
+
18
+ class MiioError(Exception):
19
+ pass
20
+
21
+
22
+ class MiioTimeout(MiioError):
23
+ pass
24
+
25
+
26
+ class MiioRpcError(MiioError):
27
+ def __init__(self, error: dict):
28
+ super().__init__(f"device returned error: {error}")
29
+ self.error = error
30
+
31
+
32
+ @dataclass
33
+ class Hello:
34
+ did: int
35
+ stamp: int
36
+ checksum: bytes # holds the token on some unprovisioned/older devices
37
+
38
+ @property
39
+ def token(self) -> bytes | None:
40
+ """Token if the device leaks it in the hello reply, else None."""
41
+ if self.checksum in (b"\x00" * 16, b"\xff" * 16):
42
+ return None
43
+ return self.checksum
44
+
45
+
46
+ def _keys(token: bytes) -> tuple[bytes, bytes]:
47
+ key = hashlib.md5(token).digest()
48
+ iv = hashlib.md5(key + token).digest()
49
+ return key, iv
50
+
51
+
52
+ def encrypt(token: bytes, data: bytes) -> bytes:
53
+ key, iv = _keys(token)
54
+ return aes.cbc_encrypt(key, iv, data)
55
+
56
+
57
+ def decrypt(token: bytes, data: bytes) -> bytes:
58
+ key, iv = _keys(token)
59
+ return aes.cbc_decrypt(key, iv, data)
60
+
61
+
62
+ def build_packet(token: bytes, did: int, stamp: int, payload: dict) -> bytes:
63
+ body = encrypt(token, json.dumps(payload, separators=(",", ":")).encode())
64
+ head = struct.pack(">HHIII", MAGIC, 32 + len(body), 0, did, stamp)
65
+ checksum = hashlib.md5(head + token + body).digest()
66
+ return head + checksum + body
67
+
68
+
69
+ def parse_packet(data: bytes) -> tuple[int, int, int, bytes, bytes]:
70
+ """Returns (length, did, stamp, checksum, body). Raises MiioError if malformed."""
71
+ if len(data) < 32:
72
+ raise MiioError(f"short packet ({len(data)} bytes)")
73
+ magic, length, _unk, did, stamp = struct.unpack(">HHIII", data[:16])
74
+ if magic != MAGIC or length != len(data):
75
+ raise MiioError(f"bad header: magic={magic:#x} length={length} actual={len(data)}")
76
+ return length, did, stamp, data[16:32], data[32:]
77
+
78
+
79
+ def decode_response(token: bytes, data: bytes) -> dict:
80
+ _length, _did, _stamp, checksum, body = parse_packet(data)
81
+ if hashlib.md5(data[:16] + token + body).digest() != checksum:
82
+ raise MiioError("response checksum mismatch (wrong token?)")
83
+ return json.loads(decrypt(token, body).rstrip(b"\x00").decode())
84
+
85
+
86
+ class MiioClient:
87
+ def __init__(self, ip: str, token: bytes | None = None, timeout: float = 3.0,
88
+ retries: int = 3, port: int = PORT, debug: bool = False):
89
+ self.ip, self.port = ip, port
90
+ self.token = token
91
+ self.timeout, self.retries, self.debug = timeout, retries, debug
92
+ self.did: int | None = None
93
+ self.persistent = False # shell mode: `with` must not close the shared socket
94
+ self._stamp_base = 0
95
+ self._stamp_at = 0.0
96
+ self._id = 0
97
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
98
+ self._sock.settimeout(timeout)
99
+
100
+ def close(self) -> None:
101
+ self._sock.close()
102
+
103
+ def __enter__(self):
104
+ return self
105
+
106
+ def __exit__(self, *exc):
107
+ if not self.persistent:
108
+ self.close()
109
+
110
+ def _log(self, *a):
111
+ if self.debug:
112
+ print("[miio]", *a)
113
+
114
+ def _roundtrip(self, packet: bytes) -> bytes:
115
+ self._log("->", packet.hex())
116
+ self._sock.sendto(packet, (self.ip, self.port))
117
+ try:
118
+ data, _ = self._sock.recvfrom(4096)
119
+ except socket.timeout:
120
+ raise MiioTimeout(f"no reply from {self.ip}:{self.port} in {self.timeout}s") from None
121
+ self._log("<-", data.hex())
122
+ return data
123
+
124
+ def handshake(self) -> Hello:
125
+ """Send hello, sync device id and stamp. Retries on timeout."""
126
+ last: Exception | None = None
127
+ for _ in range(self.retries):
128
+ try:
129
+ data = self._roundtrip(HELLO)
130
+ except MiioTimeout as e:
131
+ last = e
132
+ continue
133
+ _l, did, stamp, checksum, _b = parse_packet(data)
134
+ self.did, self._stamp_base, self._stamp_at = did, stamp, time.monotonic()
135
+ # A device that leaks its token in the hello reply is telling us its *current* one
136
+ # (it is regenerated every time setup mode is entered), so it beats any saved token.
137
+ if Hello(did, stamp, checksum).token:
138
+ self.token = checksum
139
+ return Hello(did, stamp, checksum)
140
+ raise last or MiioTimeout("handshake failed")
141
+
142
+ def _stamp(self) -> int:
143
+ return self._stamp_base + int(time.monotonic() - self._stamp_at) + 1
144
+
145
+ def call(self, method: str, params=None, retry: bool = True) -> object:
146
+ """Send one RPC; re-handshake transparently on timeout or stale stamp.
147
+
148
+ retry=False sends exactly once (for non-idempotent calls like config_router)."""
149
+ if self.token is None:
150
+ raise MiioError("no token available (the kettle hides it); run `kettle setup`")
151
+ last: Exception | None = None
152
+ for attempt in range(self.retries if retry else 1):
153
+ try:
154
+ if self.did is None or attempt > 0:
155
+ self.handshake()
156
+ self._id = self._id % 9999 + 1
157
+ req = {"id": self._id, "method": method, "params": params if params is not None else []}
158
+ self._log("request", req)
159
+ data = self._roundtrip(build_packet(self.token, self.did, self._stamp(), req))
160
+ resp = decode_response(self.token, data)
161
+ self._log("response", resp)
162
+ if resp.get("id") != self._id:
163
+ raise MiioError(f"id mismatch: sent {self._id}, got {resp.get('id')}")
164
+ if "error" in resp:
165
+ raise MiioRpcError(resp["error"])
166
+ return resp.get("result")
167
+ except MiioRpcError:
168
+ raise
169
+ except (MiioError, ValueError, OSError) as e: # timeout, bad checksum, stale stamp, no route...
170
+ last = e
171
+ self.did = None
172
+ raise MiioError(f"{method} failed after {self.retries} attempts: {last}")
173
+
174
+ # --- MIoT helpers -------------------------------------------------
175
+ def get_properties(self, props: list[tuple[int, int]]) -> list[dict]:
176
+ req = [{"did": f"{s}-{p}", "siid": s, "piid": p} for s, p in props]
177
+ return self.call("get_properties", req)
178
+
179
+ def set_properties(self, items: list[tuple[int, int, object]]) -> list[dict]:
180
+ req = [{"did": f"{s}-{p}", "siid": s, "piid": p, "value": v} for s, p, v in items]
181
+ return self.call("set_properties", req)
182
+
183
+ def action(self, siid: int, aiid: int, args: list | None = None) -> dict:
184
+ return self.call("action", {"did": f"action-{siid}-{aiid}", "siid": siid,
185
+ "aiid": aiid, "in": args or []})
186
+
187
+
188
+ def local_subnet() -> str:
189
+ """First three octets of this computer's outgoing IPv4 address, e.g. "192.168.1"."""
190
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
191
+ try:
192
+ s.connect(("192.0.2.1", 9)) # no packet is sent; just picks the outgoing interface
193
+ return s.getsockname()[0].rsplit(".", 1)[0]
194
+ finally:
195
+ s.close()
196
+
197
+
198
+ def discover(base: str, wait: float = 4.0, port: int = PORT) -> list:
199
+ """Hello-sweep BASE.1-254; returns [(ip, did)] for every miio device that answers."""
200
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
201
+ sock.settimeout(0.3)
202
+ found = {}
203
+ try:
204
+ for host in range(1, 255):
205
+ try:
206
+ sock.sendto(HELLO, (f"{base}.{host}", port))
207
+ except OSError:
208
+ continue
209
+ deadline = time.monotonic() + wait
210
+ while time.monotonic() < deadline:
211
+ try:
212
+ data, addr = sock.recvfrom(4096)
213
+ found[addr[0]] = parse_packet(data)[1]
214
+ except socket.timeout:
215
+ continue
216
+ except MiioError:
217
+ continue
218
+ finally:
219
+ sock.close()
220
+ return sorted(found.items(), key=lambda kv: tuple(int(p) for p in kv[0].split(".")))
kettle/wizard.py ADDED
@@ -0,0 +1,167 @@
1
+ """`kettle setup`: guided first-time setup with on-screen instructions.
2
+
3
+ Steps: pair in the Xiaomi Home app, get the token with the open-source extractor, paste it,
4
+ find the kettle on the LAN (the token tells us which device is the kettle), verify and save.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import getpass
9
+ import ipaddress
10
+ import re
11
+ import sys
12
+
13
+ from . import cli, config
14
+ from .miio import MiioClient, MiioError, discover, local_subnet
15
+
16
+ EXTRACTOR_URL = "https://github.com/PiotrMachowski/Xiaomi-Cloud-Tokens-Extractor"
17
+ EXTRACTOR_ZIP = ("https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor"
18
+ "/releases/latest/download/token_extractor.zip")
19
+ TOKEN_RE = re.compile(r"^[0-9a-f]{32}$")
20
+ RULE = "-" * 64
21
+
22
+
23
+ def clean_token(raw: str) -> str:
24
+ """Lower-case a pasted token and drop spaces/quotes/colons; "" if it is not 32 hex digits."""
25
+ token = re.sub(r"[\s'\"`:]", "", raw).lower()
26
+ return token if TOKEN_RE.match(token) else ""
27
+
28
+
29
+ def probe(ip: str, token: bytes, timeout: float = 1.5):
30
+ """miIO.info from ip with the token, or None if it does not answer / cannot decrypt."""
31
+ c = MiioClient(ip, token, timeout=timeout, retries=2)
32
+ try:
33
+ info = c.call("miIO.info")
34
+ return info if isinstance(info, dict) else None
35
+ except MiioError:
36
+ return None
37
+ finally:
38
+ c.close()
39
+
40
+
41
+ def run_setup(args, ask=input, secret=getpass.getpass, out=print) -> None:
42
+ def step(n: int, title: str) -> None:
43
+ out(f"\n{RULE}\nStep {n}/4: {title}\n{RULE}")
44
+
45
+ scripted = bool(args.token) # token on the command line: no prompts, for scripts
46
+ if not scripted and not sys.stdin.isatty() and ask is input:
47
+ cli.die("setup is interactive; for scripts pass --token (and --ip) instead")
48
+
49
+ out("kettle setup: connect this computer to your Xiaomi Smart Kettle 2 Pro")
50
+ saved = config.load_config()
51
+ if saved.get("token") and saved.get("ip") and not scripted:
52
+ out(f"\nAlready set up for {saved['ip']} ({saved.get('model', 'unknown model')}).")
53
+ if ask("Run setup again? [y/N] ").strip().lower() != "y":
54
+ out("Nothing changed. Try `kettle status`.")
55
+ return
56
+
57
+ # ---- 1. pairing -------------------------------------------------
58
+ if not scripted:
59
+ step(1, "Add the kettle to the Xiaomi Home app")
60
+ out("""The kettle gets onto your Wi-Fi through Xiaomi's app, and Xiaomi's cloud is the
61
+ only place that knows the kettle's token (its local password). You need this once.
62
+
63
+ 1. Install "Xiaomi Home" (iOS / Android) and sign in. Remember the server region.
64
+ 2. Add device > Xiaomi Smart Kettle 2 Pro, and follow the in-app Wi-Fi setup.
65
+ The kettle only works on a 2.4 GHz network.
66
+ 3. Wait until the app shows the kettle as online.
67
+
68
+ Do not reset or re-add the kettle later: that creates a new token.""")
69
+ ask("\nPress Enter when the kettle is online in the app... ")
70
+
71
+ # ---- 2. token ---------------------------------------------------
72
+ if not scripted:
73
+ step(2, "Get the token")
74
+ token_hex = ""
75
+ if scripted:
76
+ token_hex = clean_token(args.token)
77
+ if not token_hex:
78
+ cli.die("--token must be 32 hex characters")
79
+ else:
80
+ out(f"""Use the free open-source extractor (MIT licensed):
81
+ {EXTRACTOR_URL}
82
+
83
+ Windows: download token_extractor.exe from the latest release and run it.
84
+ macOS / Linux: needs Python 3; in a Terminal run
85
+ curl -L -o token_extractor.zip {EXTRACTOR_ZIP}
86
+ unzip token_extractor.zip && cd token_extractor
87
+ pip3 install -r requirements.txt
88
+ python3 token_extractor.py
89
+
90
+ It asks for your Xiaomi account login (or a QR code) and a server region; leave the
91
+ region empty to check all of them. Your login goes only to the extractor, never to kettle.
92
+ In its list find "Xiaomi Smart Kettle 2 Pro" (model xiaomi.kettle.v20) and copy its
93
+ TOKEN: 32 letters and digits. Treat it like a password.""")
94
+ for attempt in range(3):
95
+ token_hex = clean_token(secret("\nPaste the token here (hidden, then Enter): "))
96
+ if token_hex:
97
+ break
98
+ out("That is not a 32-character hex token (0-9, a-f). Copy just the token and try again.")
99
+ if not token_hex:
100
+ cli.die("no valid token entered; run `kettle setup` again when you have it", 2)
101
+ out(f"Token accepted (ends in ...{token_hex[-4:]}).")
102
+ token = bytes.fromhex(token_hex)
103
+
104
+ # ---- 3. find the kettle -----------------------------------------
105
+ step(3, "Find the kettle on your network")
106
+ ip = args.ip
107
+ info = None
108
+ if ip:
109
+ out(f"Using {ip}.")
110
+ else:
111
+ base = args.subnet.rsplit(".", 1)[0] if args.subnet and args.subnet.count(".") == 3 else (
112
+ args.subnet or local_subnet())
113
+ out(f"Looking for Xiaomi devices on {base}.0/24 (a few seconds)...")
114
+ try:
115
+ devices = discover(base)
116
+ except OSError as e:
117
+ cli.die(f"network scan failed: {e}")
118
+ out(f"{len(devices)} Xiaomi device(s) answered; testing your token on each...")
119
+ for dev_ip, _did in devices:
120
+ found = probe(dev_ip, token)
121
+ if found:
122
+ ip, info = dev_ip, found
123
+ out(f" {dev_ip}: {found.get('model')} <- your token works here")
124
+ break
125
+ out(f" {dev_ip}: not the kettle (token rejected)")
126
+ if not ip:
127
+ if scripted:
128
+ cli.die("kettle not found; check it is online, or pass --ip")
129
+ out("""
130
+ Could not find the kettle automatically. Its address is in your router's client list
131
+ (look for a device named "xiaomi", "kettle" or with a MAC starting with E4:FE:43), or in
132
+ the extractor's output. Same Wi-Fi as this computer is required.""")
133
+ for _ in range(3):
134
+ raw = ask("Kettle IP address (Enter to quit): ").strip()
135
+ if not raw:
136
+ cli.die("setup cancelled", 2)
137
+ try:
138
+ ip = str(ipaddress.ip_address(raw))
139
+ break
140
+ except ValueError:
141
+ out("That is not an IPv4 address, like 192.168.1.36.")
142
+ if not ip:
143
+ cli.die("no valid IP entered", 2)
144
+
145
+ # ---- 4. verify and save -----------------------------------------
146
+ step(4, "Check and save")
147
+ info = info or probe(ip, token, timeout=3.0)
148
+ if info is None:
149
+ cli.die(f"""{ip} did not accept the token, so nothing was saved. Usual reasons:
150
+ - the token is from a different device or an old pairing (the extractor shows several);
151
+ - the kettle was re-added to the app since, so its token changed: run the extractor again;
152
+ - the kettle is offline or on another network.""")
153
+ model = info.get("model")
154
+ if model != cli.EXPECTED_MODEL and not args.force_model:
155
+ cli.die(f"the device at {ip} is {model!r}, but kettle supports {cli.EXPECTED_MODEL!r} only "
156
+ f"(Xiaomi Smart Kettle 2 Pro, EU). Nothing was saved.")
157
+ c = MiioClient(ip, token, timeout=3.0)
158
+ try:
159
+ did = c.handshake().did
160
+ finally:
161
+ c.close()
162
+ config.save_config({"ip": ip, "token": token_hex, "did": did, "model": model})
163
+ out(f"Saved to {config.CONFIG_PATH} (readable by you only).")
164
+ out(f"\nKettle: {model}, firmware {info.get('fw_ver', '?')}, MAC {info.get('mac', '?')}, IP {ip}")
165
+ out("Tip: give it a fixed address (DHCP reservation) in your router so the IP never changes.")
166
+ out("If it ever moves, `kettle find` locates it again.")
167
+ out(f"\n{RULE}\nAll set. Try:\n kettle status\n kettle boil\n kettle shell\n{RULE}")
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: kettle-x2p-cli
3
+ Version: 1.0.0
4
+ Summary: Local control of the Xiaomi Smart Kettle 2 Pro over your Wi-Fi, no cloud at runtime.
5
+ Author-email: Ilia Petrov-Komotskii <ilia@inkedkettle.art>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ManeFunction/kettle-x2p-cli
8
+ Project-URL: Repository, https://github.com/ManeFunction/kettle-x2p-cli
9
+ Project-URL: Issues, https://github.com/ManeFunction/kettle-x2p-cli/issues
10
+ Project-URL: Changelog, https://github.com/ManeFunction/kettle-x2p-cli/blob/main/CHANGELOG.md
11
+ Keywords: xiaomi,mijia,miot,miio,kettle,smart-home,home-assistant,cli
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Environment :: Console
15
+ Classifier: Topic :: Home Automation
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # kettle
22
+
23
+ Control your [Xiaomi Smart Kettle 2 Pro](https://www.mi.com/global/product/xiaomi-smart-kettle-2-pro/) from the
24
+ Terminal: boil, heat to a temperature, keep warm for up to 24 hours, check the state or stop it. Everything runs over
25
+ your own Wi-Fi, directly to the kettle - no Xiaomi Home app and no cloud connection when you use it.
26
+
27
+ ```
28
+ $ kettle warm-keep 80 120
29
+ warm to 80 C and keep warm 120 min: water at 41 C, idle, no fault.
30
+ About to send set_properties:
31
+ (2,4) = 80
32
+ ...
33
+ Start heating? [y/N] y
34
+ status: heating, 41 C, fault 0
35
+ ```
36
+
37
+ Written for the EU model (BHR9107EU, `xiaomi.kettle.v20`, 1.7 L, 1800 W), tested on firmware `2.2.7_0016`.
38
+ It has no dependencies except Python itself. This is the standalone tool and a documented protocol reference ([SPEC.md](https://github.com/ManeFunction/kettle-x2p-cli/blob/main/SPEC.md))
39
+ made just for fun... or not. Setting a kettle right from the Terminal while working is pretty neat, actually :)
40
+
41
+ ---
42
+
43
+ ## Features
44
+
45
+ | Command | What it does |
46
+ |---|---|
47
+ | `kettle setup` | Guided first-time setup: asks for the token, finds the kettle, checks and saves it. |
48
+ | `kettle boil` | Heats to boiling (99 °C, the kettle's boil mode). |
49
+ | `kettle warm-to <40-99>` | Heats to that temperature in °C, then stops. |
50
+ | `kettle warm-keep <40-99> <60-1440>` | Heats to that temperature and keeps it warm for that many minutes (1-24 h). |
51
+ | `kettle cancel` | Stops heating / keep-warm. |
52
+ | `kettle status` | State, current temperature, target, fault, keep-warm settings. |
53
+ | `kettle shell` | Interactive session with the same commands. |
54
+ | `kettle info` | Model, firmware and MAC from the kettle. |
55
+ | `kettle find` | Finds the kettle on the network again if its IP changed. |
56
+
57
+ Useful flags go before the command: `--dry-run` (print the requests, send nothing), `--yes` (skip the heating
58
+ confirmation), `--debug` (raw packets), `--ip`, `--token`.
59
+
60
+ **Safety.** Heating commands refuse to start unless the kettle is a `xiaomi.kettle.v20`, idle, on its base and
61
+ reporting no fault, and a status read that fails or is unclear never starts anything. They print exactly what they
62
+ will send and ask `y/N` (without a terminal, `--yes` is required). This model has no water-level reading, so a
63
+ low-water condition only shows up as a fault code. Heat water remotely only when you know the kettle has water in it.
64
+
65
+ ---
66
+
67
+ ## Installation
68
+
69
+ **kettle** is available from a variety of sources.
70
+ `pip` or `brew` is recommended, because they have a convenient way to manage updates automatically.
71
+
72
+ 1) **pip (Recommended for everyone with a Python environment)**
73
+ - You can check if you have Python installed by running `python --version` in the Terminal or cmd.
74
+ - For Mac and Linux users, there is a high chance that you already have Python installed on your system.
75
+ - For Windows users, you can download Python from the [official website](https://www.python.org/downloads/).
76
+ The tool is built and tested for macOS and Linux; it should work on Windows too, but that is untested.
77
+ - After confirmation, install **kettle** through the [PyPI](https://pypi.org/project/kettle-x2p-cli) package
78
+ manager, typing `pip install kettle-x2p-cli` in the Terminal. For Mac users, you may need to use `pip3`
79
+ instead of `pip`. The package is called `kettle-x2p-cli`; the command it installs is `kettle`.
80
+ - Verify the installation with `kettle --version`.
81
+ - You are perfect, you can use the app with `kettle` from any folder in your system.
82
+ 2) **brew (Recommended for Mac and Linux users)**
83
+ - Type `brew tap manefunction/tap` in your Terminal to add my custom tap (app source) to your brew sources,
84
+ if you haven't already.
85
+ - Type `brew install kettle` to install the application itself.
86
+ - Verify the installation with `kettle --version`.
87
+ - You are perfect, you can use the app with `kettle` from any folder in your system.
88
+ 3) **Python package (manual installation, for advanced users)**
89
+ - Clone the repository or download the source code from GitHub.
90
+ - Go to the folder with the code in your Terminal.
91
+ - Run `pip install .` to install `kettle` to your system.
92
+ - Run `kettle --version` to verify the install.
93
+
94
+ Updates arrive with `pip install --upgrade kettle-x2p-cli` or `brew upgrade`. Your saved kettle settings live outside the install
95
+ (`~/.config/kettle/`), so upgrading or reinstalling never loses them. (`pip` may ask you to use a virtual environment or `pipx` on newer
96
+ systems; `pipx install kettle-x2p-cli` is a good fit for a command line tool.)
97
+
98
+ ---
99
+
100
+ ## First-time setup
101
+
102
+ Run `kettle setup` and follow the instructions on the screen. It takes about five minutes the first time and is
103
+ never needed again unless you re-pair the kettle.
104
+
105
+ 1. **Add the kettle to the Xiaomi Home app** (iOS / Android) so it joins your 2.4 GHz Wi-Fi.
106
+ 2. **Get the kettle's token** with the free, open-source
107
+ [Xiaomi Cloud Tokens Extractor](https://github.com/PiotrMachowski/Xiaomi-Cloud-Tokens-Extractor). The setup
108
+ screen shows the exact commands for Windows, macOS and Linux. It asks for your Xiaomi login and lists your devices
109
+ with their tokens; copy the 32-character token of *Xiaomi Smart Kettle 2 Pro* (`xiaomi.kettle.v20`).
110
+ 3. **Paste the token** into `kettle setup` (the input is hidden). It scans your network, tries the token on every
111
+ Xiaomi device it finds, and the one that answers is your kettle.
112
+ 4. It checks the model and saves everything to `~/.config/kettle/config.json` (readable by you only).
113
+
114
+ Then try `kettle status`. Reserve the kettle's address in your router (DHCP reservation) so it never moves; if it
115
+ does, `kettle find` locates it again.
116
+
117
+ For scripts, skip the prompts: `kettle --token <token> --ip <ip> setup`. Note that a token typed on the command
118
+ line is visible in your shell history, so the interactive prompt is the safer way.
119
+
120
+ ### Why the Xiaomi app and cloud are needed once
121
+
122
+ The kettle talks to the local network with a secret 16-byte token, and only Xiaomi's cloud keeps it for you.
123
+ On firmware `2.2.7` the kettle changes that token when it joins your Wi-Fi, so the token it shows while in
124
+ setup mode stops working afterwards. I tried the alternatives (including the app's own extra provisioning parameters,
125
+ `kettle provision --app-params`); none gave a usable token without the cloud. After the one-time extraction,
126
+ all control is local. If you re-pair or reset the kettle, the token changes and you run `kettle setup` again.
127
+
128
+ ---
129
+
130
+ ## Usage
131
+
132
+ ```bash
133
+ kettle status # what is it doing?
134
+ kettle boil # asks for confirmation, then heats to boiling
135
+ kettle warm-to 70 # 70 °C, then stop (green tea)
136
+ kettle warm-keep 85 90 # 85 °C and keep it there for 90 minutes
137
+ kettle cancel # stop everything
138
+ kettle --dry-run warm-to 70 # show what would be sent
139
+ kettle shell # keep a session open
140
+ ```
141
+
142
+ In `kettle shell` you type the same commands without the `kettle` prefix (`boil`, `warm-to 70`, `status`, `quit`).
143
+ Keep-warm time is limited by the kettle to 60-1440 minutes, and the target temperature to 40-99 °C; anything
144
+ else is rejected before it is sent.
145
+
146
+ ### Troubleshooting
147
+
148
+ - **"no reply from ..."**: the kettle is offline, the IP changed (`kettle find`), or the token is out of date
149
+ (the kettle silently ignores requests it cannot decrypt). Run `kettle setup` again if you re-paired it.
150
+ - **Setup does not find the kettle**: your computer and the kettle must be on the same network (no guest network
151
+ or VLAN between them). You can enter the IP by hand; it is in your router's client list.
152
+ - **"kettle reports fault ..."**: check the water level and that the kettle sits on its base. Fault codes are not
153
+ documented by Xiaomi; `0` means none.
154
+
155
+ ---
156
+
157
+ ## How it works
158
+
159
+ The kettle speaks Xiaomi's *miio* protocol over UDP port 54321: a hello handshake that returns the device id and
160
+ a timestamp, then JSON-RPC calls encrypted with AES-128-CBC keyed from the token. Its functions are described by
161
+ a MIoT spec (service/property numbers); the ones used here are listed in [SPEC.md](https://github.com/ManeFunction/kettle-x2p-cli/blob/main/SPEC.md). The client is a few hundred
162
+ lines of standard-library Python, including its own AES, and handles re-handshakes and timestamp drift by itself.
163
+
164
+ ---
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ python3 -m venv .venv && .venv/bin/pip install -e .
170
+ .venv/bin/python -m unittest discover -s tests -v
171
+ ```
172
+
173
+ The tests run against a fake kettle on the loopback interface that speaks the real wire format; nothing touches a
174
+ real device. The release workflow checks that the tag matches `src/kettle/__init__.py`, builds the package and
175
+ publishes it to PyPI and creates the GitHub release; the Homebrew formula is bumped automatically.
176
+
177
+ ---
178
+
179
+ ## Credits
180
+
181
+ - [python-miio](https://github.com/rytilahti/python-miio) for documenting the miio protocol.
182
+ - [Xiaomi Cloud Tokens Extractor](https://github.com/PiotrMachowski/Xiaomi-Cloud-Tokens-Extractor) by Piotr Machowski.
183
+ - [miot-spec.org](https://home.miot-spec.com/) for the device specs, and username-ardrey for
184
+ [Xiaomi Smart Kettle integration for Home Assistant](https://github.com/username-andrey/xiaomi-smart-kettle-ha),
185
+ which shows how this model is started.
186
+
187
+ This is an unofficial project and is not affiliated with Xiaomi.
188
+
189
+
190
+ ## Repository info
191
+
192
+ This repo follows the [Conventional Commits](https://www.conventionalcommits.org/) specification.
193
+
194
+ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/ManeFunction/kettle-x2p-cli)](https://github.com/ManeFunction/kettle-x2p-cli/releases/latest)
195
+ [![GitHub All Releases](https://img.shields.io/github/downloads/ManeFunction/kettle-x2p-cli/total)](https://github.com/ManeFunction/kettle-x2p-cli/releases)
196
+ [![PyPI version](https://img.shields.io/pypi/v/kettle-x2p-cli)](https://pypi.org/project/kettle-x2p-cli/)
197
+ [![PyPI downloads](https://img.shields.io/pypi/dm/kettle-x2p-cli)](https://pypi.org/project/kettle-x2p-cli/)
198
+ [![GitHub Sponsors](https://img.shields.io/github/sponsors/ManeFunction?label=Sponsor&logo=GitHubSponsors&style=flat)](https://github.com/sponsors/ManeFunction)
@@ -0,0 +1,13 @@
1
+ kettle/__init__.py,sha256=dc-o4WFlOYPBeu9k2cG7M0K6uAsf67Ndji-YeZt-XEk,86
2
+ kettle/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ kettle/aes.py,sha256=AkpK_smvLQJQeG_A7l3yyf3NMT84GO0JEUuGyILrjYY,4624
4
+ kettle/cli.py,sha256=56SGetUBaBVP9Ml6kqJIcvUJw0zVx_tO1N-5tI-9aA4,18034
5
+ kettle/config.py,sha256=1Tds5JTYQ1sSt0hzo8bse486V8t_NU0TJoGI2FceEA8,589
6
+ kettle/miio.py,sha256=GhlViyUzmXZugcRIwS5zaICj3bXyz0yJQ17vwoBm2oA,8217
7
+ kettle/wizard.py,sha256=X2ShDfaq8aGabKiqg3ulRvGeB-M-paBt3ABR8kdxDn8,7540
8
+ kettle_x2p_cli-1.0.0.dist-info/licenses/LICENSE,sha256=4ns8nl0KWTDY69GjkDm3TnKKnec1MeAGioDzBPi-HNw,1105
9
+ kettle_x2p_cli-1.0.0.dist-info/METADATA,sha256=xli8e2M7pX71rkM3Jgyp9eGllX-3a-JaKzWMATjduz0,11019
10
+ kettle_x2p_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ kettle_x2p_cli-1.0.0.dist-info/entry_points.txt,sha256=hOy4SxavQKxPJjoTo-sISJgkt_EOlumgKgqLvvWBRns,43
12
+ kettle_x2p_cli-1.0.0.dist-info/top_level.txt,sha256=LJMwvPf4zNJ7KHw-sIgdw8bRfkEeBtrc9gF3MQE5WYY,7
13
+ kettle_x2p_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kettle = kettle.cli:main
@@ -0,0 +1,22 @@
1
+
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2026 Ilia Petrov-Komotskii (Mane Function)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ kettle