diff --git a/.gitignore b/.gitignore index 61d2389..1bed8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ ip.xdb /xiaomi/examples/05_ac_control/data/miot_config/*.dict *.dict /xiaomi/examples/*/data/ +/.codebuddy/memory diff --git a/bridge/bridge.go b/bridge/bridge.go index f2b789b..78da418 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -143,7 +143,7 @@ func newBridge(ctx context.Context, id int64, uuid string, auth xiaomi.AuthInfo, for _, info := range devList { d, createErr := devices.Create(client, info) - if createErr != nil { + if createErr != nil || d == nil { continue } b.dv[info.DID] = d @@ -179,7 +179,6 @@ func deviceTypeName(d interface{}) string { case devices.Humidifier: return devices.TypeHumidifier case devices.Vacuum: return devices.TypeVacuum case devices.WaterHeater: return devices.TypeWaterHeater - case devices.Thermostat: return devices.TypeThermostat case devices.TempHumiditySensor: return devices.TypeTempHumidSensor case devices.OccupancySensor: return devices.TypeOccupancySensor case devices.Speaker: return devices.TypeSpeaker @@ -195,7 +194,7 @@ func (b *Bridge) AC(did string) (devices.AirConditioner, error) { d, ok := b.dv[did] if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } ac, ok := d.(devices.AirConditioner) - if !ok { return nil, fmt.Errorf("bridge: device %s is not an AirConditioner", did) } + if !ok { return nil, fmt.Errorf("bridge: device %s is not an AirConditioner (got %T)", did, d) } return ac, nil } func (b *Bridge) Light(did string) (devices.Light, error) { @@ -247,13 +246,6 @@ func (b *Bridge) WaterHeater(did string) (devices.WaterHeater, error) { if !ok { return nil, fmt.Errorf("bridge: device %s is not a WaterHeater", did) } return wh, nil } -func (b *Bridge) Thermostat(did string) (devices.Thermostat, error) { - d, ok := b.dv[did] - if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } - t, ok := d.(devices.Thermostat) - if !ok { return nil, fmt.Errorf("bridge: device %s is not a Thermostat", did) } - return t, nil -} func (b *Bridge) TempHumidity(did string) (devices.TempHumiditySensor, error) { d, ok := b.dv[did] if !ok { return nil, fmt.Errorf("bridge: device %s not found", did) } diff --git a/bridge/subscribe.go b/bridge/subscribe.go index a679b8b..a3e0f56 100644 --- a/bridge/subscribe.go +++ b/bridge/subscribe.go @@ -55,8 +55,8 @@ func (b *Bridge) subscribeDevice(did string, d interface{}) { b.fire(did, devType, ch, props) }) case devices.Cover: - ctrl.OnPropsChanged(func(pos int) { - b.fire(did, devType, devices.PropPosition, pos) + ctrl.OnPropsChanged(func(props devices.CoverProps) { + b.fire(did, devType, devices.PropPosition, props) }) case devices.Humidifier: ctrl.OnPropsChanged(func(props devices.HumidifierProps, ch string) { @@ -70,10 +70,6 @@ func (b *Bridge) subscribeDevice(did string, d interface{}) { ctrl.OnPropsChanged(func(props devices.HeaterProps, ch string) { b.fire(did, devType, ch, props) }) - case devices.Thermostat: - ctrl.OnPropsChanged(func(props devices.HeaterProps, ch string) { - b.fire(did, devType, ch, props) - }) case devices.TempHumiditySensor: ctrl.OnPropsChanged(func(temp, hum float64) { b.fire(did, devType, "temperature_humidity", map[string]float64{"temperature": temp, "humidity": hum}) @@ -164,13 +160,9 @@ func (b *Bridge) FetchAllStates(ctx context.Context) { if s, err := ctrl.FetchProps(ctx); err == nil { b.lgr.Debugf("[bridge] %d Heater: on=%v target=%.1f", b.id, s.On, s.TargetTemp) } - case devices.Thermostat: - if s, err := ctrl.FetchProps(ctx); err == nil { - b.lgr.Debugf("[bridge] %d Thermostat: target=%.1f current=%.1f", b.id, s.TargetTemp, s.CurrentTemp) - } case devices.Cover: - if pos, err := ctrl.FetchProps(ctx); err == nil { - b.lgr.Debugf("[bridge] %d Cover: pos=%d", b.id, pos) + if s, err := ctrl.FetchProps(ctx); err == nil { + b.lgr.Debugf("[bridge] %d Cover: pos=%d status=%d fault=%d", b.id, s.Position, s.Status, s.Fault) } case devices.TempHumiditySensor: if t, h, err := ctrl.FetchProps(ctx); err == nil { diff --git a/miot/examples/config.json b/miot/examples/config.json new file mode 100644 index 0000000..404d69e --- /dev/null +++ b/miot/examples/config.json @@ -0,0 +1,17 @@ +{ + "auth_info": { + "access_token": "V3_R0Je3kB-S6Mf1O7kKDPm3EDvgHiwsJMze4mVkPG0rRGHYv0KQEUVUyNRJ-ittoSSOmbNxso1syxYrKjOIAGck6EatzRIJsyULI5maduCo-GUyfHu4A6ZYa8zWD6EXp6pmnLim8KT8i1mDbczyOsFTQhNxWY1tq4CM54xlVhFqQwGS65SVV1DhrXvW9XU3nPa", + "refresh_token": "R3_WZYXokg6Nnmj2Ceib_bcPtI2fr548hNrjCnK5aG_Lm8-8X5PJeK5SHpicsOBy4Z5cWP4672pviFfWORSsSCpH3tF7UXY8Xuib8uyQP4ppxRiPssybXb67VLFfEHa7raQYtVH_2NHkp53flOZKiRtcFHOX4HAZoC7_AKuIdfWcIa4P3k03XmQF1WG0Ccp4BMH12P7eOJBHyU3LScJbFqVLQ", + "mac_key": "0K_213_6BrQPCpmmfolHVZpTR1Y", + "expires_in": 259200, + "expires_ts": 1783011905 + }, + "uid": "2897121707", + "cloud_server": "cn", + "uuid": "84259d29c352cad945d1f5e3dab3d89b", + "redirect_url": "http://homeassistant.local:8123", + "language": "en", + "ctrl_mode": "auto", + "storage_path": "", + "nick_name": "Xiaomi" +} \ No newline at end of file diff --git a/miot/examples/diag_control/main.go b/miot/examples/diag_control/main.go new file mode 100644 index 0000000..f262743 --- /dev/null +++ b/miot/examples/diag_control/main.go @@ -0,0 +1,90 @@ +// Command diag_control 直接控制米家设备(纯 HTTP,不初始化 MQTT)。 +// +// 控制序列:开空调 → 设温度 → 设模式 → 回读验证。 +// 与生产 SDK 同一云端链路(miotspec/prop/set),不占 MQTT client id。 +// +// 用法:token/did 改下面常量,在生产环境 go run .(Linux 下 dataDir 填生产路径)。 +package main + +import ( + "fmt" + "os" + + "xiaomihome/miot" +) + +// ── 配置:按需修改 ── +const ( + accessToken = "V3_1GfW88WpawoKACRqwdYBb86In_I5e8r8stZdRPXI1PR4zZgIEm4cbjNwn0wYa6-ZQtIliJJerf-iI0W9Lx8t6fm7Ln16w0RVAmYO3e9XubJ0dK7D3eM_zBlCcVmQCla6Sx0BKQv_YXI9E2zXXopKaRjIUWyx0lb0YcE7oEopLuvfA0sEFoTohVKt543KnHJH" // 米家 OAuth access_token + targetDID = "2188673403" // 目标设备 did + dataDir = "/home/ubuntu/server/zoSmartUser/data" // spec 缓存目录(生产 Linux) + // ────────────────────── +) + +func main() { + if accessToken == "" { + fmt.Fprintln(os.Stderr, "请先填写 accessToken") + os.Exit(1) + } + + 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.Printf("HTTP 客户端 OK(未连接 MQTT),设备 %s\n", targetDID) + + // 读取当前状态(控制前) + readAll("控制前状态", httpClient) + + // ── 控制序列 ── + set("开空调 siid=4/piid=1=true", 4, 1, true, httpClient) + set("设温度 siid=4/piid=2=26", 4, 2, float64(26), httpClient) + set("设模式 siid=2/piid=2=1(Cool)", 2, 2, float64(1), httpClient) + set("关空调 siid=4/piid=1=false", 4, 1, false, httpClient) + + // 回读验证(控制后) + readAll("控制后状态", httpClient) +} + +// set 写属性并回读验证。 +func set(label string, siid, piid int, value interface{}, c *miot.MIoTHttpClient) { + fmt.Printf("\n=== %s ===\n", label) + rs, err := c.SetProp([]miot.PropParam{{DID: targetDID, Siid: siid, Piid: piid, Value: value}}) + if err != nil { + fmt.Printf(" 写失败: %v\n", err) + return + } + for _, r := range rs { + fmt.Printf(" 写返回 code=%d\n", r.Code) + } + // 回读 + gr, err := c.GetProps([]miot.PropParam{{DID: targetDID, Siid: siid, Piid: piid}}) + if err != nil { + fmt.Printf(" 回读失败: %v\n", err) + return + } + if len(gr) > 0 { + fmt.Printf(" 回读 siid=%d/piid=%d = %v\n", siid, piid, gr[0].Value) + } +} + +// readAll 读取关键属性。 +func readAll(label string, c *miot.MIoTHttpClient) { + fmt.Printf("\n--- %s ---\n", label) + read := func(siid, piid int, name string) { + rs, err := c.GetProps([]miot.PropParam{{DID: targetDID, Siid: siid, Piid: piid}}) + if err != nil { + fmt.Printf(" %-28s 错误: %v\n", name, err) + return + } + if len(rs) > 0 { + fmt.Printf(" %-28s = %v\n", name, rs[0].Value) + } + } + read(4, 1, "空调开关 siid=4/piid=1") + read(4, 2, "空调温度 siid=4/piid=2") + read(2, 2, "模式 siid=2/piid=2") + read(2, 4, "地暖温度 siid=2/piid=4") + read(5, 2, "环境温度 siid=5/piid=2") +} diff --git a/miot/examples/diag_device/main.go b/miot/examples/diag_device/main.go new file mode 100644 index 0000000..a666855 --- /dev/null +++ b/miot/examples/diag_device/main.go @@ -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 +} diff --git a/miot/examples/diag_sdk/main.go b/miot/examples/diag_sdk/main.go new file mode 100644 index 0000000..414e288 --- /dev/null +++ b/miot/examples/diag_sdk/main.go @@ -0,0 +1,130 @@ +// Command diag_sdk 用完整 SDK 封装(xiaomi.NewClient + devices.Create)重现生产控制路径。 +// +// 目的:验证 SDK 封装层解析出的属性绑定(on/target-temperature 绑到哪个 siid)。 +// 与生产同链路:ac.TurnOn / ac.SetTargetTemp → SetProp(绑定siid) → 云端。 +// +// 注意:会初始化 MQTT,但用自定义 uuid(client id 唯一,不和生产进程冲突)。 +// 在开发机运行;dataDir 指向与生产相同的 spec 缓存(拷一份或指定生产目录)。 +package main + +import ( + "context" + "fmt" + "os" + "time" + + "xiaomihome/xiaomi" + "xiaomihome/xiaomi/devices" +) + +// ── 配置:按需修改 ── +const ( + accessToken = "V3_1GfW88WpawoKACRqwdYBb86In_I5e8r8stZdRPXI1PR4zZgIEm4cbjNwn0wYa6-ZQtIliJJerf-iI0W9Lx8t6fm7Ln16w0RVAmYO3e9XubJ0dK7D3eM_zBlCcVmQCla6Sx0BKQv_YXI9E2zXXopKaRjIUWyx0lb0YcE7oEopLuvfA0sEFoTohVKt543KnHJH" // 米家 OAuth access_token + uid = "2897121707" // 小米帐号 UID + homeID = int64(619001787902) // 家庭 ID + targetDID = "2188673403" // 目标设备 did + dataDir = "D:/xiaomihome/go-xiaomihome/miot/examples/diag_sdk/data" // 本 demo 自己的 spec 缓存目录 + // ────────────────────── +) + +func main() { + ctx := context.Background() + + cfg := xiaomi.Config{ + AuthInfo: xiaomi.AuthInfo{AccessToken: accessToken}, + UUID: "diag-sdk-" + time.Now().Format("150405"), // 唯一 uuid,不占生产 client id + UID: uid, + DataDir: dataDir, + HomeIDs: []int64{homeID}, + } + client, err := xiaomi.NewClient(ctx, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "NewClient 失败: %v\n", err) + os.Exit(1) + } + defer client.Close() + fmt.Println("SDK 客户端 OK") + + info, err := client.GetDevice(ctx, targetDID) + if err != nil || info == nil { + fmt.Fprintf(os.Stderr, "获取设备 %s 失败: %v\n", targetDID, err) + os.Exit(1) + } + fmt.Printf("设备: %s (%s)\nURN: %s\n", info.Name, info.Model, info.URN) + + dev, err := devices.NewDevice(client, info) + if err != nil { + fmt.Fprintf(os.Stderr, "创建设备控制失败: %v\n", err) + os.Exit(1) + } + if dev == nil { + fmt.Fprintf(os.Stderr, "设备类型不支持\n") + os.Exit(1) + } + + ac, ok := dev.(devices.AirConditioner) + if !ok { + fmt.Fprintf(os.Stderr, "设备不是 AirConditioner(got %T)\n", dev) + os.Exit(1) + } + fmt.Println("AirConditioner 封装 OK") + + // 读取当前状态(封装层) + if props, err := ac.FetchProps(ctx); err != nil { + fmt.Printf("FetchProps 失败: %v\n", err) + } else { + fmt.Printf("封装层状态: on=%v mode=%s target=%.0f current=%.0f fan=%s hum=%d\n", + props.On, props.Mode, props.TargetTemperature, props.Temperature, + props.FanLevel, props.CurrentHumidity) + } + + // 控制(观察日志里 SetProp 写的 siid) + fmt.Println("\n=== 控制序列(看 SetProp 日志的 siid) ===") + if err := ac.TurnOn(ctx); err != nil { + fmt.Printf("TurnOn 失败: %v\n", err) + } else { + fmt.Println("TurnOn OK") + } + time.Sleep(500 * time.Millisecond) + + if err := ac.SetTargetTemp(ctx, 26); err != nil { + fmt.Printf("SetTargetTemp 失败: %v\n", err) + } else { + fmt.Println("SetTargetTemp(26) OK") + } + time.Sleep(500 * time.Millisecond) + + if ac.HasFanSpeed() { + if err := ac.SetFanSpeed(ctx, "medium"); err != nil { + fmt.Printf("SetFanSpeed 失败: %v\n", err) + } else { + fmt.Println("SetFanSpeed(medium) OK") + } + } else { + fmt.Println("HasFanSpeed = false") + } + time.Sleep(500 * time.Millisecond) + + if err := ac.SetMode(ctx, "cool"); err != nil { + fmt.Printf("SetMode 失败: %v\n", err) + } else { + fmt.Println("SetMode(cool) OK") + } + time.Sleep(500 * time.Millisecond) + + if err := ac.TurnOff(ctx); err != nil { + fmt.Printf("TurnOff 失败: %v\n", err) + } else { + fmt.Println("TurnOff OK") + } + time.Sleep(500 * time.Millisecond) + + // 控制后回读 + if props, err := ac.FetchProps(ctx); err != nil { + fmt.Printf("FetchProps 失败: %v\n", err) + } else { + fmt.Printf("控制后状态: on=%v mode=%s target=%.0f current=%.0f fan=%s hum=%d\n", + props.On, props.Mode, props.TargetTemperature, props.Temperature, + props.FanLevel, props.CurrentHumidity) + } +} diff --git a/miot/miot_client.go b/miot/miot_client.go index 33f18e3..458c68b 100644 --- a/miot/miot_client.go +++ b/miot/miot_client.go @@ -156,7 +156,7 @@ func (c *MIoTClient) Init() error { if et, ok := authInfo["expires_ts"].(float64); ok { c.entryData["expires_ts"] = et } - c.lgr.Infof("[MIoTClient] Loaded token from storage\n") + c.lgr.Debugf("[MIoTClient] Loaded token from storage\n") } else if accessToken != "" { // First run: write entryData token to storage expiresTS := float64(time.Now().Unix()) + 3600*24*30 // default 30 days @@ -168,7 +168,7 @@ func (c *MIoTClient) Init() error { "refresh_token": refreshToken, "expires_ts": expiresTS, }, true) - c.lgr.Infof("[MIoTClient] Saved initial token to storage\n") + c.lgr.Debugf("[MIoTClient] Saved initial token to storage\n") } } @@ -187,7 +187,7 @@ func (c *MIoTClient) Init() error { oauthRedirectURL, _ := c.entryData["oauth_redirect_url"].(string) uuid, _ := c.entryData["uuid"].(string) c.oauth = NewMIoTOauthClient(OAUTH2_CLIENT_ID, oauthRedirectURL, c.cloudServer, uuid) - c.lgr.Infof("[MIoTClient] OAuth client initialized\n") + c.lgr.Debugf("[MIoTClient] OAuth client initialized\n") } // 3.2 Initialize Cert client @@ -197,7 +197,7 @@ func (c *MIoTClient) Init() error { } else { c.cert = cert c.cert.VerifyCACert() - c.lgr.Infof("[MIoTClient] Cert client initialized\n") + c.lgr.Debugf("[MIoTClient] Cert client initialized\n") } // 4. Initialize HTTP client (use token from storage if available) @@ -207,7 +207,7 @@ func (c *MIoTClient) Init() error { c.lgr.Warnf("[MIoTClient] Warning: failed to create HTTP client: %v\n", err) } else { c.http = httpClient - c.lgr.Infof("[MIoTClient] HTTP client created\n") + c.lgr.Debugf("[MIoTClient] HTTP client created\n") } } @@ -225,7 +225,7 @@ func (c *MIoTClient) Init() error { if err := mips.Connect(); err != nil { c.lgr.Warnf("[MIoTClient] Warning: MIPS cloud connect failed: %v\n", err) } else { - c.lgr.Infof("[MIoTClient] MIPS cloud connected\n") + c.lgr.Debugf("[MIoTClient] MIPS cloud connected\n") } c.mu.Lock() } @@ -239,7 +239,7 @@ func (c *MIoTClient) Init() error { nw.SubNetworkStatus(key, c.onNetworkStatusChanged) status := nw.GetNetworkStatus() c.onNetworkStatusChanged(status) - c.lgr.Infof("[MIoTClient] Network status subscribed (online=%v)\n", status) + c.lgr.Debugf("[MIoTClient] Network status subscribed (online=%v)\n", status) } } @@ -258,12 +258,12 @@ func (c *MIoTClient) Init() error { groupID, _ := info["group_id"].(string) if groupID != "" { svc.SubServiceChange(key, groupID, c.onMipsServiceStateChange) - c.lgr.Infof("[MIoTClient] mDNS service subscribed: home=%s, group=%s\n", homeID, groupID) + c.lgr.Debugf("[MIoTClient] mDNS service subscribed: home=%s, group=%s\n", homeID, groupID) // Check if service already discovered serviceData := svc.GetServices(groupID) if sd, exists := serviceData[groupID]; exists && sd.validService() { - c.lgr.Infof("[MIoTClient] Central mips service scanned: %s, %v\n", homeID, sd) + c.lgr.Debugf("[MIoTClient] Central mips service scanned: %s, %v\n", homeID, sd) mips := NewMipsLocalClient( getVirtualDID(c.entryData), sd.Addresses[0], @@ -294,7 +294,7 @@ func (c *MIoTClient) Init() error { if lan.InitDone() { c.onMiotLanStateChange(true) } - c.lgr.Infof("[MIoTClient] LAN control initialized\n") + c.lgr.Debugf("[MIoTClient] LAN control initialized\n") } } } else { @@ -324,7 +324,7 @@ func (c *MIoTClient) Init() error { } c.i18n = NewMIoTI18n(lang) c.i18n.Init() - c.lgr.Infof("[MIoTClient] i18n initialized (lang=%s)\n", lang) + c.lgr.Debugf("[MIoTClient] i18n initialized (lang=%s)\n", lang) c.lgr.Infof("[MIoTClient] Initialized\n") return nil @@ -358,7 +358,7 @@ func (c *MIoTClient) Deinit() error { if c.mipsCloud != nil { c.mipsCloud.UnsubState(key) c.mipsCloud.Disconnect() - c.lgr.Infof("[MIoTClient] MIPS cloud disconnected\n") + c.lgr.Debugf("[MIoTClient] MIPS cloud disconnected\n") } // 4. Cancel refresh cloud devices timer @@ -493,7 +493,7 @@ func (c *MIoTClient) loadCacheDevice() error { c.deviceListCache[did] = infoMap } } - c.lgr.Infof("[MIoTClient] Loaded %d devices from cache\n", len(deviceList)) + c.lgr.Debugf("[MIoTClient] Loaded %d devices from cache\n", len(deviceList)) return nil } @@ -647,7 +647,7 @@ func (c *MIoTClient) RefreshAuthInfo() (map[string]interface{}, error) { c.mipsCloud.UpdateAccessToken(ac) } - c.lgr.Infof("[MIoTClient] RefreshAuthInfo: token refreshed\n") + c.lgr.Debugf("[MIoTClient] RefreshAuthInfo: token refreshed\n") return result, nil } @@ -690,7 +690,7 @@ func (c *MIoTClient) refreshUserCert() { nextDelay = 60 } c.RequestRefreshUserCert(nextDelay) - c.lgr.Infof("[MIoTClient] Cert still valid for %ds, recheck in %ds\n", remainSec, nextDelay) + c.lgr.Debugf("[MIoTClient] Cert still valid for %ds, recheck in %ds\n", remainSec, nextDelay) return } @@ -720,7 +720,7 @@ func (c *MIoTClient) refreshUserCert() { } // Save cert - if err := c.cert.SaveUserCert(certPEM); err != nil { + if err = c.cert.SaveUserCert(certPEM); err != nil { c.lgr.Errorf("[MIoTClient] Cert refresh: save cert FAILED: %v\n", err) return } @@ -783,13 +783,13 @@ func (c *MIoTClient) ShowCentralStateChangedNotify(connected bool) { // Aligns with Python: __on_mips_cloud_state_changed. func (c *MIoTClient) onMipsCloudStateChanged(key string, connected bool) { if connected { - c.lgr.Infof("[MIoTClient] MIPS cloud connected\n") + c.lgr.Debugf("[MIoTClient] MIPS cloud connected\n") // Refresh devices on reconnect c.ScheduleRefreshDevices(RefreshCloudDevicesDelay * time.Millisecond) return } - c.lgr.Infof("[MIoTClient] MIPS cloud disconnected\n") + c.lgr.Debugf("[MIoTClient] MIPS cloud disconnected\n") // Disconnect: mark all cloud devices offline c.deviceListMu.Lock() @@ -821,7 +821,7 @@ func (c *MIoTClient) onMipsCloudStateChanged(key string, connected bool) { // onNetworkStatusChanged handles network connectivity changes. // Aligns with Python: __on_network_status_changed. func (c *MIoTClient) onNetworkStatusChanged(status bool) { - c.lgr.Infof("[MIoTClient] Network status changed: online=%v\n", status) + c.lgr.Debugf("[MIoTClient] Network status changed: online=%v\n", status) if status { // Network is back; trigger device refresh and reconnect MIPS if c.mipsCloud != nil && !c.mipsCloud.IsConnected() { @@ -834,15 +834,15 @@ func (c *MIoTClient) onNetworkStatusChanged(status bool) { // onMipsServiceStateChange handles mDNS service discoveries for central gateways. // Aligns with Python: __on_mips_service_state_change. func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceState, data map[string]interface{}) { - c.lgr.Infof("[MIoTClient] MIPS service change: group=%s, state=%s\n", groupID, state) + c.lgr.Debugf("[MIoTClient] MIPS service change: group=%s, state=%s\n", groupID, state) virtualDID := getVirtualDID(c.entryData) if virtualDID == "" { - c.lgr.Infof("[MIoTClient] MIPS service: missing virtual_did, skip\n") + c.lgr.Debugf("[MIoTClient] MIPS service: missing virtual_did, skip\n") return } if c.cert == nil { - c.lgr.Infof("[MIoTClient] MIPS service: cert not initialized, skip\n") + c.lgr.Debugf("[MIoTClient] MIPS service: cert not initialized, skip\n") return } @@ -851,7 +851,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS // Create new MipsLocalClient host, _ := data["addresses"].([]interface{}) if len(host) == 0 { - c.lgr.Infof("[MIoTClient] MIPS service: no addresses for %s\n", groupID) + c.lgr.Debugf("[MIoTClient] MIPS service: no addresses for %s\n", groupID) return } hostStr, _ := host[0].(string) @@ -872,7 +872,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS mips.SubState(groupID, c.onMipsLocalStateChanged) mips.Connect() c.mipsLocal[groupID] = mips - c.lgr.Infof("[MIoTClient] MIPS local client created: %s @ %s:%d\n", groupID, hostStr, int(port)) + c.lgr.Debugf("[MIoTClient] MIPS local client created: %s @ %s:%d\n", groupID, hostStr, int(port)) case MipsServiceUpdated: // Disconnect old, connect new @@ -902,7 +902,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS mips.SubState(groupID, c.onMipsLocalStateChanged) mips.Connect() c.mipsLocal[groupID] = mips - c.lgr.Infof("[MIoTClient] MIPS local client updated: %s @ %s:%d\n", groupID, hostStr, int(port)) + c.lgr.Debugf("[MIoTClient] MIPS local client updated: %s @ %s:%d\n", groupID, hostStr, int(port)) case MipsServiceRemoved: if mips, exists := c.mipsLocal[groupID]; exists { @@ -910,7 +910,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS mips.UnsubState(groupID) mips.Disconnect() delete(c.mipsLocal, groupID) - c.lgr.Infof("[MIoTClient] MIPS local client removed: %s\n", groupID) + c.lgr.Debugf("[MIoTClient] MIPS local client removed: %s\n", groupID) } } } @@ -919,7 +919,7 @@ func (c *MIoTClient) onMipsServiceStateChange(groupID string, state MipsServiceS // Aligns with Python: __on_mips_local_state_changed. // key is the groupID of the gateway. func (c *MIoTClient) onMipsLocalStateChanged(key string, connected bool) { - c.lgr.Infof("[MIoTClient] MIPS local state changed: group=%s, connected=%v\n", key, connected) + c.lgr.Debugf("[MIoTClient] MIPS local state changed: group=%s, connected=%v\n", key, connected) if connected { // Reconnect: pull gateway device list @@ -966,7 +966,7 @@ func (c *MIoTClient) onMipsLocalStateChanged(key string, connected bool) { // onMiotLanStateChange handles LAN controller state transitions. // Aligns with Python: __on_miot_lan_state_change. func (c *MIoTClient) onMiotLanStateChange(initialized bool) { - c.lgr.Infof("[MIoTClient] LAN state changed: initialized=%v\n", initialized) + c.lgr.Debugf("[MIoTClient] LAN state changed: initialized=%v\n", initialized) if initialized { c.deviceListMu.Lock() lan, ok := c.miotLan.(*MIoTLan) @@ -1026,7 +1026,7 @@ func (c *MIoTClient) onLanDeviceStateChanged(did string, state map[string]interf // Aligns with Python: __on_gw_device_list_changed. // Called when a MipsLocalClient reports that its device list has changed. func (c *MIoTClient) onGWDeviceListChanged() { - c.lgr.Infof("[MIoTClient] GW device list changed\n") + c.lgr.Debugf("[MIoTClient] GW device list changed\n") for groupID, mips := range c.mipsLocal { if mips == nil || !mips.IsConnected() { @@ -1090,7 +1090,7 @@ func (c *MIoTClient) updateDevicesFromGW(groupID string, devices map[string]inte } if changed { - c.lgr.Infof("[MIoTClient] GW devices updated: group=%s, count=%d\n", groupID, len(devices)) + c.lgr.Debugf("[MIoTClient] GW devices updated: group=%s, count=%d\n", groupID, len(devices)) } } diff --git a/miot/miot_client_api.go b/miot/miot_client_api.go index ff6f16d..3106ea6 100644 --- a/miot/miot_client_api.go +++ b/miot/miot_client_api.go @@ -31,8 +31,26 @@ func (c *MIoTClient) SetProp(did string, siid, piid int, value interface{}) erro return err } + // Aligns with Python: 空 result 视为失败(Python 会 raise device_exec_error) + if len(results) == 0 { + return fmt.Errorf("set property failed: empty result (device may be offline)") + } + // code=0 或 code=1 均视为成功(0=读取成功,1=设置成功) - if len(results) > 0 && results[0].Code != 0 && results[0].Code != 1 { + if results[0].Code != 0 && results[0].Code != 1 { + // 设备移除或离线:刷新设备列表(Aligns with Python) + if results[0].Code == -704010000 || results[0].Code == -704042011 { + c.lgr.Errorf("[MIoTClient] device may be removed or offline, %s\n", did) + if devs, rerr := c.http.GetDevices(nil); rerr == nil { + c.deviceListMu.Lock() + for k := range c.deviceListCloud { + if _, ok := devs[k]; !ok { + delete(c.deviceListCloud, k) + } + } + c.deviceListMu.Unlock() + } + } return fmt.Errorf("set property failed, code=%d", results[0].Code) } diff --git a/miot/miot_client_sub.go b/miot/miot_client_sub.go index a134b53..f80343f 100644 --- a/miot/miot_client_sub.go +++ b/miot/miot_client_sub.go @@ -155,7 +155,7 @@ func (c *MIoTClient) updateDeviceMsgSub(did string) { // Update subscription source c.subSourceList[did] = newSource - c.lgr.Infof("[MIoTClient] updateDeviceMsgSub: %s source %s → %s (gw=%v/%v, lan=%v/%v, cloud=%v)\n", + c.lgr.Debugf("[MIoTClient] updateDeviceMsgSub: %s source %s → %s (gw=%v/%v, lan=%v/%v, cloud=%v)\n", did, oldSource, newSource, gwOnline, gwPushAvailable, lanOnline, lanPushAvailable, cloudOnline) } diff --git a/miot/miot_cloud.go b/miot/miot_cloud.go index 4ca0da0..3ce1f2c 100644 --- a/miot/miot_cloud.go +++ b/miot/miot_cloud.go @@ -477,7 +477,7 @@ func (c *MIoTHttpClient) getDevRoomPage(maxID string) (map[string]map[string]int } // Check for more pages - hasMore, _ := result["has_more"].(bool) + hasMore := parseBool(result["has_more"]) nextMaxID, _ := result["max_id"].(string) if hasMore && nextMaxID != "" { nextList, err := c.getDevRoomPage(nextMaxID) @@ -582,42 +582,43 @@ func (c *MIoTHttpClient) GetHomeInfos() (map[string]interface{}, error) { } // Handle pagination - hasMore, _ := result["has_more"].(bool) + hasMore := parseBool(result["has_more"]) maxID, _ := result["max_id"].(string) if hasMore && maxID != "" { moreList, err := c.getDevRoomPage(maxID) - if err == nil { - for _, deviceSource := range []string{"homelist", "share_home_list"} { - for hidStr, info := range moreList { - if _, exists := homeInfos[deviceSource][hidStr]; !exists { - continue - } - h, _ := homeInfos[deviceSource][hidStr].(map[string]interface{}) - if h == nil { - continue - } - h["dids"] = append(toStringSlice(h["dids"]), toStringSlice(info["dids"])...) + if err != nil { + return nil, err + } + for _, deviceSource := range []string{"homelist", "share_home_list"} { + for hidStr, info := range moreList { + if _, exists := homeInfos[deviceSource][hidStr]; !exists { + continue + } + h, _ := homeInfos[deviceSource][hidStr].(map[string]interface{}) + if h == nil { + continue + } + h["dids"] = append(toStringSlice(h["dids"]), toStringSlice(info["dids"])...) - for rid, rinfo := range info["room_info"].(map[string]map[string]interface{}) { - hRoomInfo, _ := h["room_info"].(map[string]interface{}) - if hRoomInfo == nil { - hRoomInfo = make(map[string]interface{}) - h["room_info"] = hRoomInfo - } - if hRoomInfo[rid] == nil { - hRoomInfo[rid] = map[string]interface{}{ - "room_id": rid, - "room_name": "", - "dids": []string{}, - } - } - hrEntry, _ := hRoomInfo[rid].(map[string]interface{}) - if hrEntry != nil { - hrEntry["dids"] = append( - toStringSlice(hrEntry["dids"]), - toStringSlice(rinfo["dids"])...) + for rid, rinfo := range info["room_info"].(map[string]map[string]interface{}) { + hRoomInfo, _ := h["room_info"].(map[string]interface{}) + if hRoomInfo == nil { + hRoomInfo = make(map[string]interface{}) + h["room_info"] = hRoomInfo + } + if hRoomInfo[rid] == nil { + hRoomInfo[rid] = map[string]interface{}{ + "room_id": rid, + "room_name": "", + "dids": []string{}, } } + hrEntry, _ := hRoomInfo[rid].(map[string]interface{}) + if hrEntry != nil { + hrEntry["dids"] = append( + toStringSlice(hrEntry["dids"]), + toStringSlice(rinfo["dids"])...) + } } } } @@ -725,13 +726,14 @@ func (c *MIoTHttpClient) getDeviceListPage(dids []string, startDID string) (map[ // Handle pagination nextStartDID, _ := result["next_start_did"].(string) - hasMore, _ := result["has_more"].(bool) + hasMore := parseBool(result["has_more"]) if hasMore && nextStartDID != "" { nextInfos, err := c.getDeviceListPage(dids, nextStartDID) - if err == nil { - for k, v := range nextInfos { - deviceInfos[k] = v - } + if err != nil { + return nil, err + } + for k, v := range nextInfos { + deviceInfos[k] = v } } @@ -1196,3 +1198,22 @@ func containsString(slice []string, val string) bool { } return false } + +// parseBool 兼容云端多种布尔表达(bool / 0|1 / "true"|"false")。 +func parseBool(v interface{}) bool { + switch n := v.(type) { + case bool: + return n + case float64: + return n != 0 + case float32: + return n != 0 + case int: + return n != 0 + case int64: + return n != 0 + case string: + return n == "true" || n == "1" + } + return false +} diff --git a/miot/miot_errors.go b/miot/miot_errors.go new file mode 100644 index 0000000..6266b83 --- /dev/null +++ b/miot/miot_errors.go @@ -0,0 +1,142 @@ +package miot + +// MIoT 云端错误码 +// 来源: https://iot.mi.com/v2/new/doc/help-support/faq/spec/error-code +const ( + ErrCodeOK = -702000000 // OK + ErrCodeAccept = -702010000 // accept + ErrCodeProcessing = -702022036 // 操作正在处理中 + ErrCodeBadRequest = -704000000 // 错误的请求 + ErrCodeBadBody = -704000001 // 错误的请求体 + ErrCodeScheduleOnlyMi = -704001000 // 定时只支持小米设备 + ErrCodeDeviceError = -704002000 // 设备错误(通用) + ErrCodeUnauthenticated = -704010000 // 未认证 + ErrCodeTokenExpired = -704012901 // token 不存在或过期 + ErrCodeTokenInvalid = -704012902 // token 非法 + ErrCodeAuthExpired = -704012903 // 授权过期 + ErrCodeNotAuthToXiaoAi = -704012904 // 设备未授权控制能力给小爱 + ErrCodeDeviceUnbound = -704012905 // 设备未绑定 + ErrCodeAuthFailed = -704012906 // 认证失败 + ErrCodeIRNotSupported = -704013101 // 红外设备不支持此操作 + ErrCodePropertyNotRead = -704030013 // 属性不可读 + ErrCodePropertyNotWrite = -704030023 // 属性不可写 + ErrCodePropertyNotNotify = -704030033 // 属性不可上报 + ErrCodeTooFrequent = -704030992 // 请求过于频繁 + ErrCodeServiceNotFound = -704040002 // 服务不存在 + ErrCodePropertyNotFound = -704040003 // 属性不存在 + ErrCodeEventNotFound = -704040004 // 事件不存在 + ErrCodeMethodNotFound = -704040005 // 方法不存在 + ErrCodeFeatureOffline = -704040999 // 功能未上线 + ErrCodeCloudNotFound = -704041007 // cloud not found + ErrCodeDeviceNotFound2 = -704042001 // 未找到设备 + ErrCodeSceneNotFound = -704042009 // 未找到场景 + ErrCodeSceneError = -704042010 // 触发场景异常 + ErrCodeDeviceOffline = -704042011 // 设备离线 + ErrCodeSceneNoPermission = -704042012 // 场景无权限 + ErrCodeSpecNotFound = -704044006 // 未找到功能定义 + ErrCodeCannotExecute = -704053100 // 无法执行此操作 + ErrCodeCameraSleeping = -704053101 // 摄像机休眠中 + ErrCodeTimeout = -704083036 // 操作超时 + ErrCodeDeviceNotFound = -704090001 // 未找到设备 + ErrCodeInvalidID = -704220008 // 非法的 ID + ErrCodeInvalidUID = -704220009 // 非法的 uid + ErrCodeInvalidSubID = -704220010 // 非法的订阅 ID + ErrCodeActionParamCount = -704220025 // 方法输入参数数量不匹配 + ErrCodeActionParamError = -704220035 // 方法输入参数错误 + ErrCodeInvalidPropValue = -704220043 // 属性值不正确 + ErrCodeEventParamCount = -704222034 // 事件参数数量不匹配 + ErrCodeActionOutParamError = -704222035 // 方法输出参数错误 + ErrCodeOpenPlatformError = -705001000 // 开放平台服务器内部错误 + ErrCodeSpecServerError = -705004000 // MIoT Spec 服务器内部错误 + ErrCodeSubIDEncodeError = -705005000 // 订阅 ID 编码错误 + ErrCodeNotifyError = -705006000 // notify error + ErrCodeReadPropFailed = -705201013 // 读属性失败 + ErrCodeActionFailed = -705201015 // 方法执行失败 + ErrCodeWritePropFailed = -705201023 // 写属性失败 + ErrCodeReportPropFailed = -705201033 // 上报属性失败 + ErrCodeInvalidSpec = -705204006 // 非法的功能定义 + ErrCodeInstanceNotLoaded = -705204007 // 实例未加载 + ErrCodeRemoteServiceError = -706010002 // 远程服务异常 + ErrCodeMIoTError = -706010004 // MIoT 错误 + ErrCodePropCacheFailed = -706010005 // 属性缓存失败 + ErrCodeThirdPartyError = -706012000 // 三方云服务器内部错误 + ErrCodeThirdReadPropFailed = -706012013 // 三方云读属性失败 + ErrCodeThirdActionFailed = -706012015 // 三方云方法执行失败 + ErrCodeThirdWritePropFailed = -706012023 // 三方云写属性失败 + ErrCodeThirdReportPropFailed = -706012033 // 三方云上报属性失败 + ErrCodeThirdSubFailed = -706012043 // 三方云订阅失败 +) + +// errDescriptions 错误码说明 +var errDescriptions = map[int]string{ + ErrCodeOK: "OK", + ErrCodeAccept: "accept", + ErrCodeProcessing: "操作正在处理中", + ErrCodeBadRequest: "错误的请求", + ErrCodeBadBody: "错误的请求体", + ErrCodeScheduleOnlyMi: "定时只支持小米设备", + ErrCodeDeviceError: "设备错误(通用)", + ErrCodeUnauthenticated: "未认证", + ErrCodeTokenExpired: "token 不存在或过期", + ErrCodeTokenInvalid: "token 非法", + ErrCodeAuthExpired: "授权过期", + ErrCodeNotAuthToXiaoAi: "设备未授权控制能力给小爱", + ErrCodeDeviceUnbound: "设备未绑定", + ErrCodeAuthFailed: "认证失败", + ErrCodeIRNotSupported: "红外设备不支持此操作", + ErrCodePropertyNotRead: "属性不可读", + ErrCodePropertyNotWrite: "属性不可写", + ErrCodePropertyNotNotify: "属性不可上报", + ErrCodeTooFrequent: "请求过于频繁", + ErrCodeServiceNotFound: "服务不存在", + ErrCodePropertyNotFound: "属性不存在", + ErrCodeEventNotFound: "事件不存在", + ErrCodeMethodNotFound: "方法不存在", + ErrCodeFeatureOffline: "功能未上线", + ErrCodeCloudNotFound: "cloud not found", + ErrCodeDeviceNotFound2: "未找到设备", + ErrCodeSceneNotFound: "未找到场景", + ErrCodeSceneError: "触发场景异常", + ErrCodeDeviceOffline: "设备离线", + ErrCodeSceneNoPermission: "场景无权限", + ErrCodeSpecNotFound: "未找到功能定义", + ErrCodeCannotExecute: "无法执行此操作", + ErrCodeCameraSleeping: "摄像机休眠中", + ErrCodeTimeout: "操作超时", + ErrCodeDeviceNotFound: "未找到设备", + ErrCodeInvalidID: "非法的 ID", + ErrCodeInvalidUID: "非法的 uid", + ErrCodeInvalidSubID: "非法的订阅 ID", + ErrCodeActionParamCount: "方法输入参数数量不匹配", + ErrCodeActionParamError: "方法输入参数错误", + ErrCodeInvalidPropValue: "属性值不正确", + ErrCodeEventParamCount: "事件参数数量不匹配", + ErrCodeActionOutParamError: "方法输出参数错误", + ErrCodeOpenPlatformError: "开放平台服务器内部错误", + ErrCodeSpecServerError: "MIoT Spec 服务器内部错误", + ErrCodeSubIDEncodeError: "订阅 ID 编码错误", + ErrCodeNotifyError: "notify error", + ErrCodeReadPropFailed: "读属性失败", + ErrCodeActionFailed: "方法执行失败", + ErrCodeWritePropFailed: "写属性失败", + ErrCodeReportPropFailed: "上报属性失败", + ErrCodeInvalidSpec: "非法的功能定义", + ErrCodeInstanceNotLoaded: "实例未加载", + ErrCodeRemoteServiceError: "远程服务异常", + ErrCodeMIoTError: "MIoT 错误", + ErrCodePropCacheFailed: "属性缓存失败", + ErrCodeThirdPartyError: "三方云服务器内部错误", + ErrCodeThirdReadPropFailed: "三方云读属性失败", + ErrCodeThirdActionFailed: "三方云方法执行失败", + ErrCodeThirdWritePropFailed: "三方云写属性失败", + ErrCodeThirdReportPropFailed: "三方云上报属性失败", + ErrCodeThirdSubFailed: "三方云订阅失败", +} + +// ErrDesc 返回错误码的中文说明。 +func ErrDesc(code int) string { + if s, ok := errDescriptions[code]; ok { + return s + } + return "未知错误" +} diff --git a/miot/mips_client.go b/miot/mips_client.go index 1dac23d..750a10f 100644 --- a/miot/mips_client.go +++ b/miot/mips_client.go @@ -25,7 +25,6 @@ const ( mipsQoS = 2 mipsReconnectIntervalMin = 10.0 // seconds mipsReconnectIntervalMax = 600.0 // seconds - mipsSubPatch = 300 mipsSubInterval = 1 // second mipsMQTTInterval = 1 // second uint32max = 0xFFFFFFFF @@ -269,6 +268,7 @@ type MQTTClient interface { Disconnect() IsConnected() bool Subscribe(topic string, qos byte, handler func(topic string, payload []byte)) error + SubscribeMany(topics map[string]byte) error Unsubscribe(topic string) error Publish(topic string, qos byte, payload []byte) error SetCredentials(username, password string) @@ -528,34 +528,42 @@ func (c *mipsClient) flushPendingSubs() { return } + // 收集所有待订阅 topic,一次 SUBSCRIBE 包批量订阅(大幅减少网络往返) c.pendingSubMu.Lock() - subbed := 0 + batch := make(map[string]byte, len(c.pendingSubs)) for topic, count := range c.pendingSubs { - if subbed >= mipsSubPatch { - break - } if count > 3 { delete(c.pendingSubs, topic) c.lgr.Debugf("[mips] retry sub exceeded: %s", topic) continue } - subbed++ - - if err := c.mqttConn.Subscribe(topic, byte(mipsQoS), func(t string, p []byte) { - c.onMQTTMessage(t, p) - }); err == nil { - delete(c.pendingSubs, topic) - c.lgr.Debugf("[mips] sub success: %s", topic) - } else { - c.pendingSubs[topic] = count + 1 - c.lgr.Debugf("[mips] retry sub %d: %s %v", count, topic, err) - } + batch[topic] = byte(mipsQoS) } - - hasMore := len(c.pendingSubs) > 0 c.pendingSubMu.Unlock() - if hasMore { + if len(batch) == 0 { + return + } + + if err := c.mqttConn.SubscribeMany(batch); err == nil { + c.pendingSubMu.Lock() + for t := range batch { + delete(c.pendingSubs, t) + c.lgr.Debugf("[mips] sub success: %s", t) + } + hasMore := len(c.pendingSubs) > 0 + c.pendingSubMu.Unlock() + if hasMore { + time.Sleep(time.Duration(mipsSubInterval) * time.Second) + c.flushPendingSubs() + } + } else { + c.lgr.Debugf("[mips] batch sub error: %v", err) + c.pendingSubMu.Lock() + for t := range batch { + c.pendingSubs[t] = c.pendingSubs[t] + 1 + } + c.pendingSubMu.Unlock() time.Sleep(time.Duration(mipsSubInterval) * time.Second) c.flushPendingSubs() } @@ -1042,6 +1050,22 @@ func (p *pahoMQTTClient) Subscribe(topic string, qos byte, handler func(topic st return nil } +// SubscribeMany 用单个 SUBSCRIBE 包批量订阅多个 topic,显著减少网络往返。 +// 消息分发仍走 DefaultPublishHandler,与 Subscribe 行为一致。 +func (p *pahoMQTTClient) SubscribeMany(topics map[string]byte) error { + if p.client == nil || !p.client.IsConnected() { + return fmt.Errorf("not connected") + } + if len(topics) == 0 { + return nil + } + token := p.client.SubscribeMultiple(topics, nil) + if token.Wait() && token.Error() != nil { + return token.Error() + } + return nil +} + func (p *pahoMQTTClient) Unsubscribe(topic string) error { if p.client == nil || !p.client.IsConnected() { return fmt.Errorf("not connected") diff --git a/miot/mips_client_test.go b/miot/mips_client_test.go index f55de1b..f6a7f2a 100644 --- a/miot/mips_client_test.go +++ b/miot/mips_client_test.go @@ -183,9 +183,6 @@ func TestMipsConstants(t *testing.T) { if mipsQoS != 2 { t.Errorf("mipsQoS = %d, want 2", mipsQoS) } - if mipsSubPatch != 300 { - t.Errorf("mipsSubPatch = %d, want 300", mipsSubPatch) - } if MipsRequestTimeoutDefault != 10000 { t.Errorf("MipsRequestTimeoutDefault = %d, want 10000", MipsRequestTimeoutDefault) } diff --git a/xiaomi/classify.go b/xiaomi/classify.go index af126f8..f2990d6 100644 --- a/xiaomi/classify.go +++ b/xiaomi/classify.go @@ -38,9 +38,9 @@ var deviceTypeToKind = map[string]Kind{ "water-heater": KindWaterHeater, "water-cooler": KindWaterHeater, "kettle": KindWaterHeater, - "heater": KindThermostat, - "electric-blanket": KindThermostat, - "thermostat": KindThermostat, + "heater": KindAirConditioner, + "electric-blanket": KindAirConditioner, + "thermostat": KindAirConditioner, "speaker": KindSpeaker, "bluetooth-speaker": KindSpeaker, "alarm-clock": KindSpeaker, diff --git a/xiaomi/devices/air_conditioner.go b/xiaomi/devices/air_conditioner.go index 7265a4e..97f32b8 100644 --- a/xiaomi/devices/air_conditioner.go +++ b/xiaomi/devices/air_conditioner.go @@ -199,25 +199,67 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi return nil } - // "on" is required - siid, piid, ok := resolver.find("on") - if !ok { - return nil, xiaomi.ErrNotSupported + // findValListInService 从指定服务(短名)内查找属性的 ValueList。 + findValListInService := func(svcName, propType string) miot.MIoTSpecValueList { + for _, svc := range spec.Services { + if serviceShortName(svc.Type) != svcName { + continue + } + for _, p := range svc.Properties { + if propertyShortName(p.Type) == propType { + return p.ValueList + } + } + } + return nil + } + + // "on" is required。 + // 三合一设备(thermostat+air-conditioner+air-fresh 多服务同名冲突)的空调实体在 thermostat 服务: + // 存在 thermostat 服务时优先从它取 on/target-temperature,否则取第一个同名属性(普通空调)。 + if resolver.hasService("thermostat") { + if s, p, ok := resolver.findInService("thermostat", "on"); ok { + d.propOn = siidPiid{s, p, true} + } else if s, p, ok := resolver.find("on"); ok { + d.propOn = siidPiid{s, p, true} + } + } else { + siid, piid, ok := resolver.find("on") + if !ok { + return nil, xiaomi.ErrNotSupported + } + d.propOn = siidPiid{siid, piid, true} } - d.propOn = siidPiid{siid, piid, true} // Optional properties — each independently resolved if s, p, ok := resolver.find("mode"); ok { d.propMode = siidPiid{s, p, true} d.modeMap = newValMapper(findValList("mode")) } - if s, p, ok := resolver.find("target-temperature"); ok { + if resolver.hasService("thermostat") { + // 三合一设备:target-temperature 优先 thermostat 服务(空调温度) + if s, p, ok := resolver.findInService("thermostat", "target-temperature"); ok { + d.propTargetTemp = siidPiid{s, p, true} + } else if s, p, ok := resolver.find("target-temperature"); ok { + d.propTargetTemp = siidPiid{s, p, true} + } + } else if s, p, ok := resolver.find("target-temperature"); ok { d.propTargetTemp = siidPiid{s, p, true} } if s, p, ok := resolver.find("temperature"); ok { d.propCurrentTemp = siidPiid{s, p, true} } - if s, p, ok := resolver.find("fan-level"); ok { + // fan-level:三合一设备优先 thermostat 服务(空调风速 siid=4/piid=3), + // 否则取第一个同名属性(普通空调 air-conditioner 服务 / air-fresh 新风机)。 + if resolver.hasService("thermostat") { + if s, p, ok := resolver.findInService("thermostat", "fan-level"); ok { + d.propFanSpeed = siidPiid{s, p, true} + d.fanMap = newValMapper(findValListInService("thermostat", "fan-level")) + } else if s, p, ok := resolver.find("fan-level"); ok { + d.propFanSpeed = siidPiid{s, p, true} + d.fanMap = newValMapper(findValList("fan-level")) + } + } else if s, p, ok := resolver.find("fan-level"); ok { d.propFanSpeed = siidPiid{s, p, true} d.fanMap = newValMapper(findValList("fan-level")) } diff --git a/xiaomi/devices/base.go b/xiaomi/devices/base.go index b15efd7..b56bdbc 100644 --- a/xiaomi/devices/base.go +++ b/xiaomi/devices/base.go @@ -142,7 +142,8 @@ func (d *BaseDevice) getSpecResolver() *specResolver { // specResolver is a thin wrapper to avoid importing specs package in every device file. type specResolver struct { - byType map[string]*miot.MIoTSpecProperty + byType map[string]*miot.MIoTSpecProperty // property short name → first match + services []*miot.MIoTSpecService // 保留服务列表(支持按服务查找) } func newSpecResolver(spec *miot.MIoTSpecInstance) *specResolver { @@ -150,7 +151,8 @@ func newSpecResolver(spec *miot.MIoTSpecInstance) *specResolver { return nil } r := &specResolver{ - byType: make(map[string]*miot.MIoTSpecProperty), + byType: make(map[string]*miot.MIoTSpecProperty), + services: spec.Services, } for _, svc := range spec.Services { for i := range svc.Properties { @@ -176,6 +178,17 @@ func propertyShortName(urn string) string { return urn } +// serviceShortName extracts the short name from a SPEC service URN. +// E.g., "urn:miot-spec-v2:service:thermostat:0000784A:..." → "thermostat". +// 注意:不能用 propertyShortName 解析服务 URN(其只认 parts[2]=="property")。 +func serviceShortName(urn string) string { + parts := strings.Split(urn, ":") + if len(parts) >= 4 && parts[2] == "service" { + return parts[3] + } + return urn +} + func (r *specResolver) find(propType string) (siid, piid int, found bool) { if r == nil { return 0, 0, false @@ -186,6 +199,38 @@ func (r *specResolver) find(propType string) (siid, piid int, found bool) { return 0, 0, false } +// findInService 在指定服务(短名,如 "thermostat")内查找属性。 +// 用于多服务同名属性冲突时按服务精确定位(如三合一设备空调实体在 thermostat 服务)。 +func (r *specResolver) findInService(svcName, propType string) (siid, piid int, found bool) { + if r == nil { + return 0, 0, false + } + for _, svc := range r.services { + if svc == nil || serviceShortName(svc.Type) != svcName { + continue + } + for _, prop := range svc.Properties { + if prop != nil && propertyShortName(prop.Type) == propType { + return svc.IID, prop.PIID, true + } + } + } + return 0, 0, false +} + +// hasService 判断设备是否包含指定服务(短名)。 +func (r *specResolver) hasService(svcName string) bool { + if r == nil { + return false + } + for _, svc := range r.services { + if svc != nil && serviceShortName(svc.Type) == svcName { + return true + } + } + return false +} + // prop returns the full SPEC property for the given type name. func (r *specResolver) prop(propType string) *miot.MIoTSpecProperty { if r == nil { diff --git a/xiaomi/devices/cover.go b/xiaomi/devices/cover.go index e10cbe8..3e5d3ba 100644 --- a/xiaomi/devices/cover.go +++ b/xiaomi/devices/cover.go @@ -5,6 +5,13 @@ import ( "xiaomihome/xiaomi" ) +// CoverProps 窗帘完整状态。 +type CoverProps struct { + Position int `json:"position"` // 当前位置 0-100 + Status int `json:"status"` // 运动状态:0=停止 1=开启中 2=关闭中 + Fault int `json:"fault"` // 故障码,0=无故障 +} + // Cover 窗帘/晾衣架统一控制接口 type Cover interface { Device @@ -15,17 +22,20 @@ type Cover interface { SetPosition(ctx context.Context, pct int) error GetPosition(ctx context.Context) (int, error) - OnPropsChanged(handler func(position int)) (string, error) - GetProps() int - FetchProps(ctx context.Context) (int, error) + OnPropsChanged(handler func(props CoverProps)) (string, error) + GetProps() CoverProps + FetchProps(ctx context.Context) (CoverProps, error) } type coverDevice struct { BaseDevice - propMotor siidPiid - propPosition siidPiid + propMotor siidPiid // motor-control(write) + propPosition siidPiid // current-position(read) + propTarget siidPiid // target-position(write,SetPosition 用) + propStatus siidPiid // status(read) + propFault siidPiid // fault(read) - propsCache int + propsCache CoverProps } const ( @@ -48,6 +58,15 @@ func NewCover(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Cover, error) { if s, p, ok := resolver.find("current-position"); ok { d.propPosition = siidPiid{s, p, true} } + if s, p, ok := resolver.find("target-position"); ok { + d.propTarget = siidPiid{s, p, true} + } + if s, p, ok := resolver.find("status"); ok { + d.propStatus = siidPiid{s, p, true} + } + if s, p, ok := resolver.find("fault"); ok { + d.propFault = siidPiid{s, p, true} + } return d, nil } @@ -63,10 +82,19 @@ func (d *coverDevice) Stop(ctx context.Context) error { if !d.propMotor.valid { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorStop) } + +// SetPosition 设定目标位置,写 target-position(可写属性)。 +// 老设备无 target-position 时回退写 current-position。 func (d *coverDevice) SetPosition(ctx context.Context, pct int) error { - if !d.propPosition.valid { return xiaomi.ErrNotSupported } - return d.SetProp(ctx, d.propPosition.siid, d.propPosition.piid, pct) + if d.propTarget.valid { + return d.SetProp(ctx, d.propTarget.siid, d.propTarget.piid, pct) + } + if d.propPosition.valid { + return d.SetProp(ctx, d.propPosition.siid, d.propPosition.piid, pct) + } + return xiaomi.ErrNotSupported } + func (d *coverDevice) GetPosition(ctx context.Context) (int, error) { if !d.propPosition.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propPosition.siid, d.propPosition.piid) @@ -74,20 +102,48 @@ func (d *coverDevice) GetPosition(ctx context.Context) (int, error) { return toInt(prop.Value), nil } -func (d *coverDevice) OnPropsChanged(handler func(position int)) (string, error) { - if !d.propPosition.valid { return "", xiaomi.ErrNotSupported } +func (d *coverDevice) OnPropsChanged(handler func(props CoverProps)) (string, error) { + if !d.propPosition.valid && !d.propStatus.valid && !d.propFault.valid { + return "", xiaomi.ErrNotSupported + } return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) { - if prop.SIID == d.propPosition.siid && prop.PIID == d.propPosition.piid { - d.propsCache = toInt(prop.Value) - handler(d.propsCache) + switch { + case d.propPosition.valid && prop.SIID == d.propPosition.siid && prop.PIID == d.propPosition.piid: + d.propsCache.Position = toInt(prop.Value) + case d.propStatus.valid && prop.SIID == d.propStatus.siid && prop.PIID == d.propStatus.piid: + d.propsCache.Status = toInt(prop.Value) + case d.propFault.valid && prop.SIID == d.propFault.siid && prop.PIID == d.propFault.piid: + d.propsCache.Fault = toInt(prop.Value) + default: + return // 其他属性不触发回调 } + handler(d.propsCache) }) } -func (d *coverDevice) GetProps() int { return d.propsCache } -func (d *coverDevice) FetchProps(ctx context.Context) (int, error) { - pos, err := d.GetPosition(ctx) - if err != nil { return d.propsCache, err } - d.propsCache = pos - return pos, nil +func (d *coverDevice) GetProps() CoverProps { return d.propsCache } + +func (d *coverDevice) FetchProps(ctx context.Context) (CoverProps, error) { + keys := []xiaomi.PropKey{} + if d.propPosition.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propPosition.siid, PIID: d.propPosition.piid}) } + if d.propStatus.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propStatus.siid, PIID: d.propStatus.piid}) } + if d.propFault.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFault.siid, PIID: d.propFault.piid}) } + if len(keys) == 0 { + return d.propsCache, xiaomi.ErrNotSupported + } + props, err := d.client.GetProps(ctx, keys) + if err != nil { + return d.propsCache, err + } + for _, p := range props { + switch { + case d.propPosition.valid && p.SIID == d.propPosition.siid && p.PIID == d.propPosition.piid: + d.propsCache.Position = toInt(p.Value) + case d.propStatus.valid && p.SIID == d.propStatus.siid && p.PIID == d.propStatus.piid: + d.propsCache.Status = toInt(p.Value) + case d.propFault.valid && p.SIID == d.propFault.siid && p.PIID == d.propFault.piid: + d.propsCache.Fault = toInt(p.Value) + } + } + return d.propsCache, nil } diff --git a/xiaomi/devices/devices_test.go b/xiaomi/devices/devices_test.go index b5cba24..c406fa3 100644 --- a/xiaomi/devices/devices_test.go +++ b/xiaomi/devices/devices_test.go @@ -131,6 +131,18 @@ func TestMatchURN(t *testing.T) { []string{"heater"}, true, }, + { + "tofan-wk01 not fan", // "tofan" 含 "fan" 子串,不得误匹配 fan 系列 + "urn:miot-spec-v2:device:thermostat:0000A031:tofan-wk01:1:0000C822", + []string{"fan", "ceiling-fan", "ventilator", "air-fresh", "air-purifier"}, + false, + }, + { + "tofan-wk01 thermostat", + "urn:miot-spec-v2:device:thermostat:0000A031:tofan-wk01:1:0000C822", + []string{"heater", "electric-blanket", "thermostat"}, + true, + }, { "no match", "urn:miot-spec-v2:device:unknown:0000A099:test:1", @@ -171,3 +183,20 @@ func TestCreateNilInfo(t *testing.T) { t.Error("expected error for nil info") } } + +func TestServiceShortName(t *testing.T) { + cases := []struct { + urn string + want string + }{ + {"urn:miot-spec-v2:service:thermostat:0000784A:tofan-wk01:1", "thermostat"}, + {"urn:miot-spec-v2:service:air-conditioner:0000780F:tofan-wk01:1", "air-conditioner"}, + {"urn:tofan-spec:service:adv-setting:00007801:tofan-wk01:1", "adv-setting"}, + {"urn:miot-spec-v2:property:on:00000006:tofan-wk01:1", "urn:miot-spec-v2:property:on:00000006:tofan-wk01:1"}, + } + for _, c := range cases { + if got := serviceShortName(c.urn); got != c.want { + t.Errorf("serviceShortName(%q) = %q, want %q", c.urn, got, c.want) + } + } +} diff --git a/xiaomi/devices/factory.go b/xiaomi/devices/factory.go index 72c3906..8f18fd8 100644 --- a/xiaomi/devices/factory.go +++ b/xiaomi/devices/factory.go @@ -1,6 +1,8 @@ package devices import ( + "strings" + "xiaomihome/xiaomi" ) @@ -84,7 +86,7 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er case matchURN(urn, "water-heater", "water-cooler", "kettle"): ctrl, err = NewWaterHeater(client, info) case matchURN(urn, "heater", "electric-blanket", "thermostat"): - ctrl, err = NewThermostat(client, info) + ctrl, err = NewAirConditioner(client, info) case matchURN(urn, "sensor-occupy", "sensor_occupy", "occupancy-sensor", "occupancy_sensor", "motion-sensor", "motion_sensor", "magnet"): ctrl, err = NewOccupancySensor(client, info) case matchURN(urn, "sensor-temp", "sensor_temp", "sensor-humidity", "sensor_humidity", "sensor-ht", "sensor_ht", "sensor-smoke", "sensor-gasm", "illumination"): @@ -105,17 +107,50 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er return ctrl, nil } -// matchURN checks if the device URN contains any of the given type strings. +// matchURN 匹配 URN 的 device 类型段。 +// 标准格式 urn:miot-spec-v2:device:{TYPE}:... 做精确段匹配(避免 "tofan" 撞 "fan" 这类子串误判); +// 非标准 URN 回退到边界子串匹配。 func matchURN(urn string, types ...string) bool { - for _, t := range types { - if len(urn) >= len(t) { - // Simple substring match — URN format: urn:miot-spec-v2:device:TYPE:... - for i := 0; i <= len(urn)-len(t); i++ { - if urn[i:i+len(t)] == t { - return true - } + parts := strings.Split(urn, ":") + if len(parts) >= 4 && parts[2] == "device" { + devType := parts[3] + for _, t := range types { + if devType == t { + return true } } + return false + } + // 非标准 URN:按边界匹配,前后必须是分隔符,避免误撞 + for _, t := range types { + if boundaryContains(urn, t) { + return true + } } return false } + +// boundaryContains 检查 sub 是否在 s 中出现且前后是分隔符或边界。 +func boundaryContains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] != sub { + continue + } + beforeOK := i == 0 + if !beforeOK { + beforeOK = isBoundary(s[i-1]) + } + afterOK := i+len(sub) == len(s) + if !afterOK { + afterOK = isBoundary(s[i+len(sub)]) + } + if beforeOK && afterOK { + return true + } + } + return false +} + +func isBoundary(c byte) bool { + return c == ':' || c == '-' || c == '.' || c == '_' || c == '/' +} diff --git a/xiaomi/devices/gateway.go b/xiaomi/devices/gateway.go new file mode 100644 index 0000000..c20d7ca --- /dev/null +++ b/xiaomi/devices/gateway.go @@ -0,0 +1,22 @@ +package devices + +import "xiaomihome/xiaomi" + +// Gateway 网关设备(仅类型识别,不参与控制)。 +type Gateway interface { + Device +} + +type gatewayDevice struct { + BaseDevice +} + +// NewGateway creates a Gateway device wrapper for type recognition. +func NewGateway(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Gateway, error) { + if info == nil { + return nil, xiaomi.ErrDeviceNotFound + } + return &gatewayDevice{ + BaseDevice: NewBaseDevice(client, info), + }, nil +} diff --git a/xiaomi/devices/plug.go b/xiaomi/devices/plug.go new file mode 100644 index 0000000..ed6043c --- /dev/null +++ b/xiaomi/devices/plug.go @@ -0,0 +1,102 @@ +package devices + +import ( + "context" + "fmt" + + "xiaomihome/xiaomi" +) + +// Plug 插座/插头统一控制接口(电脑开机卡、智能插座等)。 +type Plug interface { + Device + TurnOn(ctx context.Context) error + TurnOff(ctx context.Context) error + IsOn(ctx context.Context) (bool, error) + + OnPropsChanged(handler func(on bool)) (string, error) + GetProps() PlugProps + FetchProps(ctx context.Context) (PlugProps, error) +} + +// PlugProps 插座完整状态。 +type PlugProps struct { + On bool `json:"on"` +} + +type plugDevice struct { + BaseDevice + propOn siidPiid + propsCache PlugProps +} + +// NewPlug creates a Plug control for the given device. +func NewPlug(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Plug, error) { + if info == nil { + return nil, xiaomi.ErrDeviceNotFound + } + miotDev := client.GetMIoTDevice(info.DID) + if miotDev == nil || miotDev.SpecInstance() == nil { + return nil, fmt.Errorf("%w: device has no SPEC", xiaomi.ErrNotSupported) + } + + d := &plugDevice{ + BaseDevice: NewBaseDevice(client, info), + } + + for _, svc := range miotDev.SpecInstance().Services { + for _, prop := range svc.Properties { + if propertyShortName(prop.Type) == "on" && !d.propOn.valid { + d.propOn = siidPiid{prop.SIID, prop.PIID, true} + } + } + } + if !d.propOn.valid { + return nil, fmt.Errorf("%w: no on property", xiaomi.ErrNotSupported) + } + + return d, nil +} + +func (d *plugDevice) TurnOn(ctx context.Context) error { + return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true) +} + +func (d *plugDevice) TurnOff(ctx context.Context) error { + return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false) +} + +func (d *plugDevice) IsOn(ctx context.Context) (bool, error) { + prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid) + if err != nil { + return false, err + } + b, _ := prop.Value.(bool) + return b, nil +} + +func (d *plugDevice) OnPropsChanged(handler func(on bool)) (string, error) { + return d.SubProp(d.propOn.siid, d.propOn.piid, func(did string, prop *xiaomi.PropertyValue) { + if b, ok := prop.Value.(bool); ok { + d.propsCache.On = b + handler(b) + } + }) +} + +func (d *plugDevice) GetProps() PlugProps { return d.propsCache } + +func (d *plugDevice) FetchProps(ctx context.Context) (PlugProps, error) { + props, err := d.client.GetProps(ctx, []xiaomi.PropKey{ + {DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}, + }) + if err != nil { + return d.propsCache, err + } + for _, p := range props { + if p.SIID == d.propOn.siid && p.PIID == d.propOn.piid { + d.propsCache.On, _ = p.Value.(bool) + } + } + return d.propsCache, nil +} diff --git a/xiaomi/devices/properties.go b/xiaomi/devices/properties.go index 49292b7..0abf004 100644 --- a/xiaomi/devices/properties.go +++ b/xiaomi/devices/properties.go @@ -12,7 +12,6 @@ const ( TypeHumidifier = "humidifier" // 加湿器 TypeVacuum = "vacuum" // 吸尘器/扫地机器人 TypeWaterHeater = "water_heater" // 热水器 - TypeThermostat = "thermostat" // 温控器 TypeOccupancySensor = "sensor_occupy" // 人体传感器 TypeTempHumidSensor = "sensor" // 温湿度传感器(官方 URN type) TypeSpeaker = "speaker" // 音箱 diff --git a/xiaomi/examples/08_adv_filter_classify/main.go b/xiaomi/examples/08_adv_filter_classify/main.go index f1726e5..2a9f3eb 100644 --- a/xiaomi/examples/08_adv_filter_classify/main.go +++ b/xiaomi/examples/08_adv_filter_classify/main.go @@ -70,7 +70,6 @@ func main() { xiaomi.KindHumidifier, xiaomi.KindVacuum, xiaomi.KindWaterHeater, - xiaomi.KindThermostat, } for _, kind := range allKinds { diff --git a/xiaomi/types.go b/xiaomi/types.go index deeaa21..521efa3 100644 --- a/xiaomi/types.go +++ b/xiaomi/types.go @@ -154,7 +154,6 @@ const ( KindHumidifier Kind = "humidifier" KindVacuum Kind = "vacuum" KindWaterHeater Kind = "water_heater" - KindThermostat Kind = "thermostat" KindSpeaker Kind = "speaker" KindGateway Kind = "gateway" KindPlug Kind = "plug"