From a59533014490a9da8040e57326891bdf4c6c4be5 Mon Sep 17 00:00:00 2001 From: 4566704 <4566704@qq.com> Date: Sat, 12 Sep 2026 19:29:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(miot):=20=E7=B1=B3=E5=AE=B6=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=AE=BE=E5=A4=87=E4=BB=8E=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E3=80=81=E6=8E=A7=E5=88=B6=E5=99=A8=E4=B8=8E=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E4=B8=AD=E5=BD=BB=E5=BA=95=E5=89=94=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在米家 App 删除设备后设备列表仍能获取到:删除检测只 delete(deviceListCloud), deviceListCache 仅置 online=false,而对外 DeviceList()/GetDevices() 读的正是 cache;缓存又会在停机时落盘到 .dict、启动时原样恢复,已删设备跨重启复活。 - pruneRemovedDevicesLocked:候选集改为「缓存 ∪ 云列表」,云端不存在且网关/ 局域网非在线的设备从四张表彻底删除(旧实现只遍历 deviceListCloud,启动时它 为空,停机期间被删的设备永远检测不到) - 护栏:云端返回空列表时跳过剔除,避免接口异常清库 - homeScope:只刷新部分家庭时不再误删其它家庭的云列表 - 子设备(xxx.s1)不参与判定,它被归并到父设备的 sub_devices - 命中剔除的设备同时清理 c.devices 实例与 MQTT 订阅路由 - bridge: pruneControllers 剔除已删设备的控制器,AC()/Switch() 不再返回实例 - 新增 7 个 miot 用例(含 httptest 打桩的端到端)+ 3 个 bridge 用例 --- bridge/account.go | 21 ++- bridge/bridge.go | 13 ++ bridge/prune_test.go | 52 +++++++ miot/miot_client_device.go | 113 ++++++++++++-- miot/miot_client_device_test.go | 253 ++++++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 bridge/prune_test.go diff --git a/bridge/account.go b/bridge/account.go index 371ae3a..1245c6f 100644 --- a/bridge/account.go +++ b/bridge/account.go @@ -55,6 +55,21 @@ func (b *Bridge) RefreshDevices(ctx context.Context, homeIDs ...int64) error { b.mu.Lock() defer b.mu.Unlock() + // 全量设备列表(SDK 已在 RefreshDevices 中剔除米家已删除的设备), + // 本地控制器集合必须与它保持一致 + all, err := b.client.GetDevices(ctx) + if err != nil { + return err + } + alive := make(map[string]struct{}, len(all)) + for _, info := range all { + alive[info.DID] = struct{}{} + } + if removed := pruneControllers(b.dv, alive); len(removed) > 0 { + b.lgr.Infof("[bridge] %d: %d device(s) removed from Mi Home, controllers dropped: %v", + b.id, len(removed), removed) + } + if len(homeIDs) > 0 { for _, hid := range homeIDs { devs, err := b.client.GetDevices(ctx, xiaomi.FilterByHome(strconv.FormatInt(hid, 10))) @@ -74,11 +89,7 @@ func (b *Bridge) RefreshDevices(ctx context.Context, homeIDs ...int64) error { } // 全量 - devList, err := b.client.GetDevices(ctx) - if err != nil { - return err - } - for _, info := range devList { + for _, info := range all { if _, exists := b.dv[info.DID]; !exists { if d, createErr := devices.Create(b.client, info); createErr == nil && d != nil { b.dv[info.DID] = d diff --git a/bridge/bridge.go b/bridge/bridge.go index 3fc2b90..438a30b 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -180,6 +180,19 @@ func (b *Bridge) Devices() map[string]string { return out } +// pruneControllers 从本地控制器集合中剔除「米家已不存在」的设备,返回被剔除的 DID。 +// 没有这一步,AC()/Switch()/Fan() 等访问器会继续为已删设备返回控制器,控制必然失败。 +func pruneControllers(dv map[string]interface{}, alive map[string]struct{}) []string { + var removed []string + for did := range dv { + if _, ok := alive[did]; !ok { + delete(dv, did) + removed = append(removed, did) + } + } + return removed +} + func deviceTypeName(d interface{}) string { switch d.(type) { case devices.AirConditioner: return devices.TypeAirConditioner diff --git a/bridge/prune_test.go b/bridge/prune_test.go new file mode 100644 index 0000000..ed71de8 --- /dev/null +++ b/bridge/prune_test.go @@ -0,0 +1,52 @@ +package bridge + +import "testing" + +// TestPruneControllers_RemovesAbsentDevice 米家已删除设备的控制器必须被剔除, +// 否则 AC()/Switch() 等访问器仍会为已删设备返回控制器。 +func TestPruneControllers_RemovesAbsentDevice(t *testing.T) { + dv := map[string]interface{}{ + "did-keep": struct{}{}, + "did-gone": struct{}{}, + } + + removed := pruneControllers(dv, map[string]struct{}{"did-keep": {}}) + + if len(removed) != 1 || removed[0] != "did-gone" { + t.Fatalf("removed = %v, want [did-gone]", removed) + } + if _, ok := dv["did-gone"]; ok { + t.Error("已删设备应被剔除") + } + if _, ok := dv["did-keep"]; !ok { + t.Error("云端仍存在的设备不应被剔除") + } +} + +// TestPruneControllers_NoChange 全部设备仍存在时不做任何改动。 +func TestPruneControllers_NoChange(t *testing.T) { + dv := map[string]interface{}{"did1": struct{}{}, "did2": struct{}{}} + + removed := pruneControllers(dv, map[string]struct{}{"did1": {}, "did2": {}, "did3": {}}) + + if len(removed) != 0 { + t.Errorf("removed = %v, want empty", removed) + } + if len(dv) != 2 { + t.Errorf("dv len = %d, want 2", len(dv)) + } +} + +// TestPruneControllers_EmptyAlive RemovedAll:设备列表为空时全部剔除(调用方保证非空列表)。 +func TestPruneControllers_EmptyAlive(t *testing.T) { + dv := map[string]interface{}{"did1": struct{}{}, "did2": struct{}{}} + + removed := pruneControllers(dv, nil) + + if len(removed) != 2 { + t.Fatalf("removed = %v, want 2 entries", removed) + } + if len(dv) != 0 { + t.Errorf("dv len = %d, want 0", len(dv)) + } +} diff --git a/miot/miot_client_device.go b/miot/miot_client_device.go index 2ffbfbc..7f8f8fd 100644 --- a/miot/miot_client_device.go +++ b/miot/miot_client_device.go @@ -89,6 +89,15 @@ func (c *MIoTClient) RefreshDevices(homeIDs []string) error { } } + // 剔除检测的家庭作用域:非空表示本次只刷新了部分家庭(GetDevices 只返回这些家庭), + // 未命中的设备一律不动,避免 RefreshDevices(homeA) 把其它家庭的设备误剔除。 + homeScope := make(map[string]struct{}, len(homeIDs)) + for _, hid := range homeIDs { + if hid != "" { + homeScope[hid] = struct{}{} + } + } + c.deviceListMu.Lock() defer c.deviceListMu.Unlock() @@ -179,17 +188,22 @@ func (c *MIoTClient) RefreshDevices(homeIDs []string) error { } } - // Detect deletions - for did := range c.deviceListCloud { - if _, exists := cloudDevices[did]; !exists { - delete(c.deviceListCloud, did) - if cacheInfo, ok := c.deviceListCache[did]; ok { - cacheInfo["online"] = false - cacheInfo["push_available"] = false - } - c.lgr.Infof("[MIoTClient] Device removed from cloud: %s\n", did) - changed = true + // Detect deletions:云端已不存在的设备要从三源 + 缓存 + 实例 + 订阅里彻底剔除 + removed := c.pruneRemovedDevicesLocked(cloudDevices, homeScope) + if len(removed) > 0 { + // 设备实例由 c.mu 保护;此处持 deviceListMu,与下方 updateDeviceMsgSub 的 + // 加锁顺序保持一致(deviceListMu → c.mu) + c.mu.Lock() + for _, did := range removed { + delete(c.devices, did) + c.lgr.Infof("[MIoTClient] Device removed: %s\n", did) } + c.mu.Unlock() + // 取消该设备在云/网关/局域网的订阅(三源均已清空 → 内部会退订) + for _, did := range removed { + c.updateDeviceMsgSub(did) + } + changed = true } // 4. Aggregate three sources: cloud + gateway + lan @@ -263,6 +277,85 @@ func cloneMap(src map[string]interface{}) map[string]interface{} { return dst } +// deviceOnlineFlag reports whether info explicitly carries online=true. +func deviceOnlineFlag(info map[string]interface{}) bool { + if info == nil { + return false + } + online, _ := info["online"].(bool) + return online +} + +// deviceHomeID returns the non-empty home_id recorded for did across all sources. +func (c *MIoTClient) deviceHomeID(did string) string { + for _, m := range []map[string]map[string]interface{}{ + c.deviceListCache, c.deviceListCloud, c.deviceListGateway, c.deviceListLan, + } { + if info, ok := m[did]; ok { + if home, _ := info["home_id"].(string); home != "" { + return home + } + } + } + return "" +} + +// pruneRemovedDevicesLocked 剔除「云端列表中已不存在」的设备,返回被剔除的 DID 列表。 +// NOTE: caller must hold c.deviceListMu. +// +// 与上游 Python 的差异(有意为之):Python 只把设备置为离线、永不从 +// _device_list_cache 删除,那是为 Home Assistant 的设备注册表服务的。本库的使用方 +// 需要「设备列表 == 米家当前设备」,因此在米家删除的设备必须从缓存中真正移除, +// 否则 DeviceList()/GetDevices() 会一直返回已删除的设备(且随 .dict 跨重启复活)。 +// +// 判定规则: +// 1. 云端返回空列表 → 视为接口异常(网络抖动、鉴权异常),不剔除任何设备; +// 2. 候选集 = 缓存 ∪ 云列表:必须遍历缓存,否则启动时 deviceListCloud 尚为空, +// 会漏掉「服务停机期间在米家被删除」的设备; +// 3. 网关或局域网仍报告在线 → 保留(云端列表可能还没同步到); +// 4. 子设备 DID(xxx.s1)不参与判定(它被归并到父设备的 sub_devices 中); +// 5. homeScope 非空时只处理该家庭下的设备。 +func (c *MIoTClient) pruneRemovedDevicesLocked(cloudDevices map[string]map[string]interface{}, homeScope map[string]struct{}) []string { + if len(cloudDevices) == 0 { + c.lgr.Warnf("[MIoTClient] empty cloud device list, skip removal detection\n") + return nil + } + + candidates := make(map[string]struct{}, len(c.deviceListCache)+len(c.deviceListCloud)) + for did := range c.deviceListCache { + candidates[did] = struct{}{} + } + for did := range c.deviceListCloud { + candidates[did] = struct{}{} + } + + var removed []string + for did := range candidates { + if _, ok := cloudDevices[did]; ok { + continue + } + // 子设备(xxx.s1)在 RefreshDevices 中被归并进父设备的 sub_devices, + // 本来就不会出现在 cloudDevices 里,不能据此判定为已删除 + if subDevPattern.MatchString(did) { + continue + } + if deviceOnlineFlag(c.deviceListGateway[did]) || deviceOnlineFlag(c.deviceListLan[did]) { + continue + } + if len(homeScope) > 0 { + if _, inScope := homeScope[c.deviceHomeID(did)]; !inScope { + continue + } + } + delete(c.deviceListCloud, did) + delete(c.deviceListCache, did) + delete(c.deviceListGateway, did) + delete(c.deviceListLan, did) + removed = append(removed, did) + } + return removed +} + // aggregateDeviceState merges cloud/gateway/lan state for a device into cache. // Aligns with Python: __check_device_state. func (c *MIoTClient) aggregateDeviceState(did string) { diff --git a/miot/miot_client_device_test.go b/miot/miot_client_device_test.go index 4e23171..a8ed635 100644 --- a/miot/miot_client_device_test.go +++ b/miot/miot_client_device_test.go @@ -2,6 +2,10 @@ package miot import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" ) @@ -289,3 +293,252 @@ func TestGetDeviceInfo_EmptyCache(t *testing.T) { t.Errorf("GetDeviceInfo on empty cache should return nil, got %v", info) } } + +// ============================================================================ +// 米家删除设备必须从缓存中剔除 +// ============================================================================ + +// TestPruneRemovedDevices_RemovesAbsentDevice 云端不再返回的设备要从三源 + 缓存里彻底剔除。 +func TestPruneRemovedDevices_RemovesAbsentDevice(t *testing.T) { + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + defer c.Stop() + + c.deviceListMu.Lock() + c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "home_id": "h1", "online": true} + c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": true} + c.deviceListCloud["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": true} + c.deviceListGateway["did-gone"] = map[string]interface{}{"did": "did-gone", "online": false} + c.deviceListLan["did-gone"] = map[string]interface{}{"did": "did-gone", "online": false} + + removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ + "did-keep": {"did": "did-keep", "home_id": "h1", "online": true}, + }, nil) + + if len(removed) != 1 || removed[0] != "did-gone" { + t.Fatalf("removed = %v, want [did-gone]", removed) + } + if _, ok := c.deviceListCache["did-gone"]; ok { + t.Error("deviceListCache 应剔除已删设备") + } + if _, ok := c.deviceListCloud["did-gone"]; ok { + t.Error("deviceListCloud 应剔除已删设备") + } + if _, ok := c.deviceListGateway["did-gone"]; ok { + t.Error("deviceListGateway 应剔除已删设备") + } + if _, ok := c.deviceListLan["did-gone"]; ok { + t.Error("deviceListLan 应剔除已删设备") + } + if _, ok := c.deviceListCache["did-keep"]; !ok { + t.Error("云端仍存在的设备不应被剔除") + } + c.deviceListMu.Unlock() +} + +// TestPruneRemovedDevices_StartupCacheOnly 回归:重启后 deviceListCloud 尚为空, +// 只遍历它(旧实现)会漏掉「停机期间在米家被删除」的设备,必须遍历缓存。 +func TestPruneRemovedDevices_StartupCacheOnly(t *testing.T) { + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + defer c.Stop() + + c.deviceListMu.Lock() + c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "home_id": "h1", "online": false} + c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": false} + + removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ + "did-keep": {"did": "did-keep", "home_id": "h1", "online": true}, + }, nil) + + if len(removed) != 1 || removed[0] != "did-gone" { + t.Fatalf("removed = %v, want [did-gone]", removed) + } + if _, ok := c.deviceListCache["did-gone"]; ok { + t.Error("从 .dict 恢复的已删设备应被剔除") + } + c.deviceListMu.Unlock() +} + +// TestPruneRemovedDevices_KeepsDeviceReportedOnlineByGatewayOrLan 网关/局域网仍在线则保留。 +func TestPruneRemovedDevices_KeepsDeviceReportedOnlineByGatewayOrLan(t *testing.T) { + for _, tc := range []string{"gateway", "lan"} { + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + + c.deviceListMu.Lock() + c.deviceListCache["did-x"] = map[string]interface{}{"did": "did-x", "home_id": "h1", "online": true} + if tc == "gateway" { + c.deviceListGateway["did-x"] = map[string]interface{}{"online": true, "push_available": true} + } else { + c.deviceListLan["did-x"] = map[string]interface{}{"online": true, "push_available": true} + } + + removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ + "did-other": {"did": "did-other", "home_id": "h1", "online": true}, + }, nil) + + if len(removed) != 0 { + t.Errorf("%s 仍在线时不应剔除,removed = %v", tc, removed) + } + if _, ok := c.deviceListCache["did-x"]; !ok { + t.Errorf("%s 仍在线时缓存应保留", tc) + } + c.deviceListMu.Unlock() + c.Stop() + } +} + +// TestPruneRemovedDevices_HomeScope 只刷新部分家庭时,不碰其它家庭与无 home 的共享设备。 +func TestPruneRemovedDevices_HomeScope(t *testing.T) { + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + defer c.Stop() + + c.deviceListMu.Lock() + c.deviceListCache["did-h1"] = map[string]interface{}{"did": "did-h1", "home_id": "h1", "online": true} + c.deviceListCache["did-h2"] = map[string]interface{}{"did": "did-h2", "home_id": "h2", "online": true} + c.deviceListCache["did-shared"] = map[string]interface{}{"did": "did-shared", "home_id": "", "online": true} + + // 本次只刷新了 h1:h2 与共享设备不受影响 + removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ + "did-h1": {"did": "did-h1", "home_id": "h1", "online": true}, + }, map[string]struct{}{"h1": {}}) + + if len(removed) != 0 { + t.Fatalf("家庭作用域内不应剔除,removed = %v", removed) + } + if _, ok := c.deviceListCache["did-h2"]; !ok { + t.Error("其它家庭的设备不应被剔除") + } + if _, ok := c.deviceListCache["did-shared"]; !ok { + t.Error("无 home_id 的共享设备不应被剔除") + } + + // 全量刷新(scope 为空)时,h2 与共享设备都要按云端列表剔除 + removed = c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ + "did-h1": {"did": "did-h1", "home_id": "h1", "online": true}, + }, nil) + + if len(removed) != 2 { + t.Fatalf("全量刷新应剔除 2 台,removed = %v", removed) + } + if _, ok := c.deviceListCache["did-h2"]; ok { + t.Error("did-h2 应被剔除") + } + if _, ok := c.deviceListCache["did-shared"]; ok { + t.Error("did-shared 应被剔除") + } + c.deviceListMu.Unlock() +} + +// TestPruneRemovedDevices_KeepsSubDevice 子设备(xxx.s1)被归并到父设备,不参与删除判定。 +func TestPruneRemovedDevices_KeepsSubDevice(t *testing.T) { + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + defer c.Stop() + + c.deviceListMu.Lock() + c.deviceListCache["gateway1.s1"] = map[string]interface{}{"did": "gateway1.s1", "home_id": "h1", "online": true} + + removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{ + "gateway1": {"did": "gateway1", "home_id": "h1", "online": true}, + }, nil) + + if len(removed) != 0 { + t.Fatalf("子设备不应被剔除,removed = %v", removed) + } + if _, ok := c.deviceListCache["gateway1.s1"]; !ok { + t.Error("子设备应保留在缓存中") + } + c.deviceListMu.Unlock() +} + +// TestPruneRemovedDevices_EmptyCloudListSkipsRemoval 云端返回空列表视为接口异常,不清库。 +func TestPruneRemovedDevices_EmptyCloudListSkipsRemoval(t *testing.T) { + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + defer c.Stop() + + c.deviceListMu.Lock() + c.deviceListCache["did1"] = map[string]interface{}{"did": "did1", "home_id": "h1", "online": true} + + removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{}, nil) + + if len(removed) != 0 { + t.Errorf("空云列表不应剔除任何设备,removed = %v", removed) + } + if _, ok := c.deviceListCache["did1"]; !ok { + t.Error("空云列表时缓存应保持原样") + } + c.deviceListMu.Unlock() +} + +// TestRefreshDevices_PrunesDeletedDevice 端到端:打桩云端接口,云端只剩 1 台设备, +// 缓存里的另一台(米家已删除)必须从 DeviceList() 中消失。 +func TestRefreshDevices_PrunesDeletedDevice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "/homeroom/gethome"): + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": float64(0), + "result": map[string]interface{}{ + "homelist": []interface{}{ + map[string]interface{}{ + "id": float64(100), "name": "网吧2", "uid": float64(7), + "roomlist": []interface{}{ + map[string]interface{}{ + "id": float64(200), "name": "客厅", + "dids": []interface{}{"did-keep"}, + }, + }, + }, + }, + }, + }) + case strings.Contains(r.URL.Path, "/home/device_list_page"): + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "code": float64(0), + "result": map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{ + "did": "did-keep", "name": "保留设备", + "spec_type": "urn:miot-spec-v2:device:plug:0000A001:x:1", + "model": "cuco.plug.v3", + "isOnline": true, + }, + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + httpCli, err := NewMIoTHttpClient("cn", "cid", "token") + if err != nil { + t.Fatal(err) + } + defer httpCli.Close() + httpCli.baseURL = server.URL + + c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud) + defer c.Stop() + c.SetHTTPClient(httpCli) + + // 预置缓存(等价于 .dict 恢复):云端已删除的 did-gone 仍在缓存里 + c.deviceListMu.Lock() + c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "name": "保留设备", "home_id": "100", "online": false} + c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "name": "已删设备", "home_id": "100", "online": true} + c.deviceListCloud["did-gone"] = map[string]interface{}{"did": "did-gone", "name": "已删设备", "home_id": "100", "online": true} + c.deviceListMu.Unlock() + + if err = c.RefreshDevices(nil); err != nil { + t.Fatalf("RefreshDevices: %v", err) + } + + list := c.DeviceList() + if _, ok := list["did-gone"]; ok { + t.Error("DeviceList() 不应再返回米家已删除的设备") + } + if _, ok := list["did-keep"]; !ok { + t.Error("DeviceList() 应保留云端仍存在的设备") + } +} +