feat: AC 三合一设备支持 + 新增 Plug/Gateway 驱动 + Cover 结构化重构

**AC 驱动增强**
- 支持 thermostat + air-conditioner + air-fresh 多服务三合一设备,属性跨服务合并解析
- 新增 specResolver.findValListInService/findInService/hasService/serviceShortName 方法
- 移除 KindThermostat,heater/electric-blanket/thermostat 重新归类为 KindAirConditioner

**Cover 窗帘重构**
- CoverProps 结构化:Position/Status/Fault 分离
- SetPosition 优先写 target-position,回退 current-position
- OnPropsChanged/FetchProps 支持多属性同步刷新

**新增设备类型**
- Plug 驱动:插座/插头,支持开合、在线状态、属性变更订阅
- Gateway 驱动:网关设备类型识别
- miot/miot_errors.go:新增 MIoT 云端错误码定义

**MIoT 客户端健壮性**
- SetProp 空结果视为失败;设备被移除或离线时自动刷新设备列表
- GetDevices 的 has_more 改用 parseBool 严格解析
- MIPS SubscribeMany 批量订阅减少往返
- matchURN 改为按段精确匹配,避免 tofan 误匹配 fan

**Bridge**
- 移除 Thermostat case,Cover 改用结构化 CoverProps
- 新增设备创建后自动订阅属性变更

**其他**
- 日志整体 Info → Debug 降噪
- .gitignore 忽略 /.codebuddy/memory
- 更新 devices_test 与 08_adv_filter_classify 示例
This commit is contained in:
2026-08-23 19:42:55 +08:00
parent 04333750ab
commit 86c21ce91c
25 changed files with 1129 additions and 155 deletions
+222
View File
@@ -0,0 +1,222 @@
// Command diag_device 诊断指定米家设备的云端属性(纯 HTTP,不初始化 MQTT)。
//
// 用途:对比 SDK 读到属性值 vs 米家 App 显示,验证 siid/piid 是否正确、
// 控制是否生效、设备连接类型等。可在生产环境旁路运行(不与生产 MQTT 冲突)。
//
// 用法:token/did 直接改下面常量,然后 go run . 即可。
package main
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"xiaomihome/miot"
)
// ── 配置:按需修改 ──
const (
accessToken = "V3_1GfW88WpawoKACRqwdYBb86In_I5e8r8stZdRPXI1PR4zZgIEm4cbjNwn0wYa6-ZQtIliJJerf-iI0W9Lx8t6fm7Ln16w0RVAmYO3e9XubJ0dK7D3eM_zBlCcVmQCla6Sx0BKQv_YXI9E2zXXopKaRjIUWyx0lb0YcE7oEopLuvfA0sEFoTohVKt543KnHJH" // 米家 OAuth access_token
targetDID = "2188673403" // 目标设备 did
homeID = "619001787902" // 家庭 ID(留空=全部家庭;先跑一遍看家庭列表再填)
dataDir = "/home/ubuntu/server/zoSmartUser/data" // spec 缓存目录(生产 Linux 路径)
setTest = "4,1,false|4,2,26" // 写属性测试:"siid,piid,value",多组用 | 分隔;留空跳过
// ──────────────────────
)
func main() {
if accessToken == "" {
fmt.Fprintln(os.Stderr, "请先在 main.go 顶部 const 里填写 accessToken")
os.Exit(1)
}
// 纯 HTTP 客户端(不连 MQTT,不占 mqtt client id)
httpClient, err := miot.NewMIoTHttpClient("cn", miot.OAUTH2_CLIENT_ID, accessToken)
if err != nil {
fmt.Fprintf(os.Stderr, "创建 HTTP 客户端失败: %v\n", err)
os.Exit(1)
}
fmt.Println("HTTP 客户端 OK(未连接 MQTT)")
// 1. 按 did 精确拉设备信息(不依赖家庭列表)
devMap, err := httpClient.GetDevicesWithDIDs([]string{targetDID})
if err != nil {
fmt.Fprintf(os.Stderr, "获取设备信息失败: %v\n", err)
os.Exit(1)
}
info, ok := devMap[targetDID]
if !ok {
fmt.Fprintf(os.Stderr, "设备 %s 未返回(共 %d 台)\n", targetDID, len(devMap))
os.Exit(1)
}
infoMap := info
fmt.Printf("\n=== 设备信息 %s ===\n", targetDID)
printDeviceInfo(infoMap)
urn, _ := infoMap["urn"].(string)
model, _ := infoMap["model"].(string)
fmt.Printf("URN: %s\nMODEL: %s\n", urn, model)
// 2. 解析 SPEC
parser := miot.NewMIoTSpecParser("en", miot.NewMIoTStorage(dataDir), dataDir+"/specs")
spec, err := parser.Parse(urn, model)
if err != nil {
fmt.Fprintf(os.Stderr, "SPEC 解析失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n=== SPEC 服务/属性(siid 布局) ===\n")
for _, svc := range spec.Services {
if svc == nil {
continue
}
fmt.Printf("svc[%d] %s\n", svc.IID, short(svc.Type))
names := make([]string, 0, len(svc.Properties))
for _, p := range svc.Properties {
if p == nil {
continue
}
names = append(names, fmt.Sprintf("%s(%d)", short(p.Type), p.PIID))
}
sort.Strings(names)
fmt.Printf(" %s\n", strings.Join(names, " "))
}
// 3. 批量读取全部可读属性
fmt.Printf("\n=== 读取全部可读属性 ===\n")
var params []miot.PropParam
keys := make([]string, 0)
for _, svc := range spec.Services {
if svc == nil {
continue
}
for _, p := range svc.Properties {
if p == nil {
continue
}
params = append(params, miot.PropParam{DID: targetDID, Siid: svc.IID, Piid: p.PIID})
keys = append(keys, fmt.Sprintf("%d.%d %s", svc.IID, p.PIID, short(p.Type)))
}
}
results, err := httpClient.GetProps(params)
if err != nil {
fmt.Fprintf(os.Stderr, "读取属性失败: %v\n", err)
} else {
for i, r := range results {
name := ""
if i < len(keys) {
name = keys[i]
}
fmt.Printf(" %-24s code=%d value=%v\n", name, r.Code, r.Value)
}
}
// 4. 三合一设备对比读取(空调/地暖双温度)
fmt.Printf("\n=== 温度属性对比(三合一设备) ===\n")
read := func(siid, piid int, label string) {
rs, err := httpClient.GetProps([]miot.PropParam{{DID: targetDID, Siid: siid, Piid: piid}})
if err != nil {
fmt.Printf(" %s: 错误 %v\n", label, err)
return
}
if len(rs) > 0 {
fmt.Printf(" %-28s code=%d value=%v\n", label, rs[0].Code, rs[0].Value)
}
}
read(2, 1, "siid=2/piid=1 on(空调)")
read(2, 2, "siid=2/piid=2 mode")
read(2, 4, "siid=2/piid=4 target-temperature")
read(4, 2, "siid=4/piid=2 target-temperature")
read(5, 2, "siid=5/piid=2 temperature(环境)")
// 5. 写属性测试(可选,多组用 | 分隔,如 "2,4,26|4,2,26")
if setTest != "" {
for _, spec := range strings.Split(setTest, "|") {
doSetAndVerify(httpClient, strings.TrimSpace(spec))
}
}
}
// doSetAndVerify 执行一次写属性并回读验证。
func doSetAndVerify(httpClient *miot.MIoTHttpClient, spec string) {
parts := strings.Split(spec, ",")
if len(parts) < 3 {
fmt.Fprintf(os.Stderr, "setTest 格式错误: %s (应为 siid,piid,value)\n", spec)
return
}
siid, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
piid, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
var val interface{}
if b, err := strconv.ParseBool(parts[2]); err == nil {
val = b
} else if f, err := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64); err == nil {
val = f
} else {
val = strings.TrimSpace(parts[2])
}
fmt.Printf("\n=== 写属性 siid=%d piid=%d value=%v ===\n", siid, piid, val)
rs, err := httpClient.SetProp([]miot.PropParam{{DID: targetDID, Siid: siid, Piid: piid, Value: val}})
if err != nil {
fmt.Printf("写属性失败: %v\n", err)
return
}
for _, r := range rs {
fmt.Printf(" code=%d (0/1=成功,非0=失败)\n", r.Code)
}
// 写后立即回读验证(区分"写错服务" vs "云端假成功")
fmt.Println("--- 写后回读 ---")
verify := func(vsiid, vpiid int, label string) {
vrs, err := httpClient.GetProps([]miot.PropParam{{DID: targetDID, Siid: vsiid, Piid: vpiid}})
if err != nil {
fmt.Printf(" %s: 错误 %v\n", label, err)
return
}
if len(vrs) > 0 {
fmt.Printf(" %-30s code=%d value=%v\n", label, vrs[0].Code, vrs[0].Value)
}
}
verify(siid, piid, "回读 siid=目标")
verify(2, 1, "siid=2/piid=1 on(地暖)")
verify(2, 2, "siid=2/piid=2 mode(地暖)")
verify(2, 4, "siid=2/piid=4 target-temperature(地暖)")
verify(4, 1, "siid=4/piid=1 on(空调)")
verify(4, 2, "siid=4/piid=2 target-temperature(空调)")
}
func printDeviceInfo(m map[string]interface{}) {
fields := []struct{ k, label string }{
{"name", "名称"}, {"model", "型号"}, {"connect_type", "connect_type"},
{"pid", "pid"}, {"online", "online"}, {"isOnline", "isOnline"},
{"rssi", "rssi"}, {"local_ip", "local_ip"}, {"parent_id", "parent_id"},
{"urn", "urn"},
}
for _, f := range fields {
if v, ok := m[f.k]; ok {
fmt.Printf(" %-12s = %v\n", f.label, v)
}
}
}
func short(urn string) string {
parts := strings.Split(urn, ":")
if len(parts) >= 4 {
return parts[3]
}
return urn
}
// toStrSlice 将 interface{}([]interface{})转为 []string。
func toStrSlice(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}