Skip to content

Commit 3a051b6

Browse files
committed
Cache miIO protocol sequence IDs across CLI invocations
Devices track message sequence IDs and ignore duplicates. Without persisting the counter, restarting the CLI resets it to 0, causing devices to silently drop messages and time out. This generalizes the approach already used by the roborock vacuum CLI into the shared DeviceGroup infrastructure, so all device types benefit. Cache files are stored per-device (keyed by hashed IP) under the platformdirs user cache directory. Closes #1751
1 parent c5cc0f2 commit 3a051b6

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

miio/click_common.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import click
1515

16+
from .device_cache import read_cache, write_cache
1617
from .exceptions import DeviceError
1718

1819
try:
@@ -265,8 +266,21 @@ def group_callback(self, ctx, *args, **kwargs):
265266
gco = ctx.find_object(GlobalContextObject)
266267
if gco:
267268
kwargs["debug"] = gco.debug
269+
270+
ip = kwargs.get("ip")
271+
if ip:
272+
cached = read_cache(ip)
273+
kwargs.setdefault("start_id", cached["seq"])
274+
268275
ctx.obj = self.device_class(*args, **kwargs)
269276

277+
if ip:
278+
279+
def _save_cache() -> None:
280+
write_cache(ip, {"seq": ctx.obj.raw_id})
281+
282+
ctx.call_on_close(_save_cache)
283+
270284
def command_callback(self, miio_command, miio_device, *args, **kwargs):
271285
return miio_command.call(miio_device, *args, **kwargs)
272286

miio/device_cache.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Cache for device connection state.
2+
3+
Persists the miIO protocol message sequence counter between CLI invocations.
4+
Without this, restarting the CLI resets the counter to 0, and devices ignore
5+
messages with sequence IDs they've already seen, causing timeouts.
6+
"""
7+
8+
import hashlib
9+
import json
10+
import logging
11+
from pathlib import Path
12+
from typing import TypedDict
13+
14+
from platformdirs import user_cache_dir
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
CACHE_DIR = Path(user_cache_dir("python-miio"))
19+
20+
21+
class DeviceState(TypedDict):
22+
"""Cached state for a single device.
23+
24+
seq: The miIO protocol message sequence counter. Each message sent to a
25+
device increments this counter, and the device tracks seen IDs to
26+
deduplicate. Persisting it avoids ID reuse across CLI invocations.
27+
"""
28+
29+
seq: int
30+
31+
32+
def _cache_path(ip: str) -> Path:
33+
"""Return the cache file path for a device IP.
34+
35+
Uses a hash of the IP to avoid filesystem issues with IPv6 colons.
36+
"""
37+
ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16]
38+
return CACHE_DIR / f"{ip_hash}.json"
39+
40+
41+
def read_cache(ip: str) -> DeviceState:
42+
"""Read cached connection state for a device."""
43+
path = _cache_path(ip)
44+
try:
45+
data = json.loads(path.read_text())
46+
seq = int(data["seq"])
47+
_LOGGER.debug("Loaded cache for %s: seq=%d", ip, seq)
48+
return DeviceState(seq=seq)
49+
except FileNotFoundError:
50+
return DeviceState(seq=0)
51+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as ex:
52+
_LOGGER.warning("Corrupt cache for %s, ignoring: %s", ip, ex)
53+
return DeviceState(seq=0)
54+
55+
56+
def write_cache(ip: str, state: DeviceState) -> None:
57+
"""Write connection state to cache for a device."""
58+
path = _cache_path(ip)
59+
path.parent.mkdir(parents=True, exist_ok=True)
60+
path.write_text(json.dumps(state))
61+
_LOGGER.debug("Wrote cache for %s: %s", ip, state)

miio/tests/test_device_cache.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from miio.device_cache import DeviceState, _cache_path, read_cache, write_cache
7+
8+
9+
@pytest.fixture()
10+
def cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
11+
"""Override CACHE_DIR to use a temporary directory."""
12+
monkeypatch.setattr("miio.device_cache.CACHE_DIR", tmp_path)
13+
return tmp_path
14+
15+
16+
class TestCachePath:
17+
def test_ipv4_produces_valid_path(self, cache_dir: Path) -> None:
18+
path = _cache_path("192.168.1.1")
19+
assert path.parent == cache_dir
20+
assert path.suffix == ".json"
21+
22+
def test_ipv6_produces_valid_path(self, cache_dir: Path) -> None:
23+
path = _cache_path("fe80::1")
24+
assert path.parent == cache_dir
25+
assert path.suffix == ".json"
26+
assert ":" not in path.name
27+
28+
def test_different_ips_get_different_paths(self, cache_dir: Path) -> None:
29+
assert _cache_path("192.168.1.1") != _cache_path("192.168.1.2")
30+
31+
def test_same_ip_gets_same_path(self, cache_dir: Path) -> None:
32+
assert _cache_path("192.168.1.1") == _cache_path("192.168.1.1")
33+
34+
35+
class TestReadCache:
36+
def test_missing_file_returns_zero(self, cache_dir: Path) -> None:
37+
state: DeviceState = read_cache("192.168.1.1")
38+
assert state["seq"] == 0
39+
40+
def test_reads_written_data(self, cache_dir: Path) -> None:
41+
write_cache("192.168.1.1", DeviceState(seq=42))
42+
state: DeviceState = read_cache("192.168.1.1")
43+
assert state["seq"] == 42
44+
45+
def test_corrupt_json_returns_zero(self, cache_dir: Path) -> None:
46+
path = _cache_path("192.168.1.1")
47+
path.write_text("not json")
48+
state: DeviceState = read_cache("192.168.1.1")
49+
assert state["seq"] == 0
50+
51+
def test_missing_seq_key_returns_zero(self, cache_dir: Path) -> None:
52+
path = _cache_path("192.168.1.1")
53+
path.write_text(json.dumps({"other": 123}))
54+
state: DeviceState = read_cache("192.168.1.1")
55+
assert state["seq"] == 0
56+
57+
def test_non_int_seq_returns_zero(self, cache_dir: Path) -> None:
58+
path = _cache_path("192.168.1.1")
59+
path.write_text(json.dumps({"seq": "not_a_number"}))
60+
state: DeviceState = read_cache("192.168.1.1")
61+
assert state["seq"] == 0
62+
63+
64+
class TestWriteCache:
65+
def test_creates_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
66+
nested = tmp_path / "sub" / "dir"
67+
monkeypatch.setattr("miio.device_cache.CACHE_DIR", nested)
68+
write_cache("192.168.1.1", DeviceState(seq=5))
69+
assert nested.exists()
70+
71+
def test_overwrites_existing(self, cache_dir: Path) -> None:
72+
write_cache("192.168.1.1", DeviceState(seq=10))
73+
write_cache("192.168.1.1", DeviceState(seq=20))
74+
state: DeviceState = read_cache("192.168.1.1")
75+
assert state["seq"] == 20
76+
77+
def test_written_file_is_valid_json(self, cache_dir: Path) -> None:
78+
write_cache("192.168.1.1", DeviceState(seq=99))
79+
path = _cache_path("192.168.1.1")
80+
data: dict = json.loads(path.read_text())
81+
assert data == {"seq": 99}

0 commit comments

Comments
 (0)