fix(miot): 米家已删除设备从缓存、控制器与订阅中彻底剔除
在米家 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 用例
This commit is contained in:
+16
-5
@@ -55,6 +55,21 @@ func (b *Bridge) RefreshDevices(ctx context.Context, homeIDs ...int64) error {
|
|||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
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 {
|
if len(homeIDs) > 0 {
|
||||||
for _, hid := range homeIDs {
|
for _, hid := range homeIDs {
|
||||||
devs, err := b.client.GetDevices(ctx, xiaomi.FilterByHome(strconv.FormatInt(hid, 10)))
|
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)
|
for _, info := range all {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, info := range devList {
|
|
||||||
if _, exists := b.dv[info.DID]; !exists {
|
if _, exists := b.dv[info.DID]; !exists {
|
||||||
if d, createErr := devices.Create(b.client, info); createErr == nil && d != nil {
|
if d, createErr := devices.Create(b.client, info); createErr == nil && d != nil {
|
||||||
b.dv[info.DID] = d
|
b.dv[info.DID] = d
|
||||||
|
|||||||
@@ -180,6 +180,19 @@ func (b *Bridge) Devices() map[string]string {
|
|||||||
return out
|
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 {
|
func deviceTypeName(d interface{}) string {
|
||||||
switch d.(type) {
|
switch d.(type) {
|
||||||
case devices.AirConditioner: return devices.TypeAirConditioner
|
case devices.AirConditioner: return devices.TypeAirConditioner
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-9
@@ -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()
|
c.deviceListMu.Lock()
|
||||||
defer c.deviceListMu.Unlock()
|
defer c.deviceListMu.Unlock()
|
||||||
|
|
||||||
@@ -179,18 +188,23 @@ func (c *MIoTClient) RefreshDevices(homeIDs []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect deletions
|
// Detect deletions:云端已不存在的设备要从三源 + 缓存 + 实例 + 订阅里彻底剔除
|
||||||
for did := range c.deviceListCloud {
|
removed := c.pruneRemovedDevicesLocked(cloudDevices, homeScope)
|
||||||
if _, exists := cloudDevices[did]; !exists {
|
if len(removed) > 0 {
|
||||||
delete(c.deviceListCloud, did)
|
// 设备实例由 c.mu 保护;此处持 deviceListMu,与下方 updateDeviceMsgSub 的
|
||||||
if cacheInfo, ok := c.deviceListCache[did]; ok {
|
// 加锁顺序保持一致(deviceListMu → c.mu)
|
||||||
cacheInfo["online"] = false
|
c.mu.Lock()
|
||||||
cacheInfo["push_available"] = false
|
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)
|
||||||
}
|
}
|
||||||
c.lgr.Infof("[MIoTClient] Device removed from cloud: %s\n", did)
|
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Aggregate three sources: cloud + gateway + lan
|
// 4. Aggregate three sources: cloud + gateway + lan
|
||||||
for did := range c.deviceListCache {
|
for did := range c.deviceListCache {
|
||||||
@@ -263,6 +277,85 @@ func cloneMap(src map[string]interface{}) map[string]interface{} {
|
|||||||
return dst
|
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.
|
// aggregateDeviceState merges cloud/gateway/lan state for a device into cache.
|
||||||
// Aligns with Python: __check_device_state.
|
// Aligns with Python: __check_device_state.
|
||||||
func (c *MIoTClient) aggregateDeviceState(did string) {
|
func (c *MIoTClient) aggregateDeviceState(did string) {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
package miot
|
package miot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -289,3 +293,252 @@ func TestGetDeviceInfo_EmptyCache(t *testing.T) {
|
|||||||
t.Errorf("GetDeviceInfo on empty cache should return nil, got %v", info)
|
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() 应保留云端仍存在的设备")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user