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 +3 -0
- kettle/__main__.py +4 -0
- kettle/aes.py +151 -0
- kettle/cli.py +437 -0
- kettle/config.py +21 -0
- kettle/miio.py +220 -0
- kettle/wizard.py +167 -0
- kettle_x2p_cli-1.0.0.dist-info/METADATA +198 -0
- kettle_x2p_cli-1.0.0.dist-info/RECORD +13 -0
- kettle_x2p_cli-1.0.0.dist-info/WHEEL +5 -0
- kettle_x2p_cli-1.0.0.dist-info/entry_points.txt +2 -0
- kettle_x2p_cli-1.0.0.dist-info/licenses/LICENSE +22 -0
- kettle_x2p_cli-1.0.0.dist-info/top_level.txt +1 -0
kettle/__init__.py
ADDED
kettle/__main__.py
ADDED
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
|
+
[](https://github.com/ManeFunction/kettle-x2p-cli/releases/latest)
|
|
195
|
+
[](https://github.com/ManeFunction/kettle-x2p-cli/releases)
|
|
196
|
+
[](https://pypi.org/project/kettle-x2p-cli/)
|
|
197
|
+
[](https://pypi.org/project/kettle-x2p-cli/)
|
|
198
|
+
[](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,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
|