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:
2026-09-12 19:29:46 +08:00
parent c18c0adaae
commit a595330144
5 changed files with 437 additions and 15 deletions
+103 -10
View File
@@ -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) {