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:
@@ -49,3 +49,4 @@ ip.xdb
|
||||
/xiaomi/examples/05_ac_control/data/miot_config/*.dict
|
||||
*.dict
|
||||
/xiaomi/examples/*/data/
|
||||
/.codebuddy/memory
|
||||
|
||||
+2
-10
@@ -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) }
|
||||
|
||||
+4
-12
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+30
-30
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+57
-36
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 "未知错误"
|
||||
}
|
||||
+43
-19
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
+47
-2
@@ -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 {
|
||||
|
||||
+75
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == '/'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,7 +12,6 @@ const (
|
||||
TypeHumidifier = "humidifier" // 加湿器
|
||||
TypeVacuum = "vacuum" // 吸尘器/扫地机器人
|
||||
TypeWaterHeater = "water_heater" // 热水器
|
||||
TypeThermostat = "thermostat" // 温控器
|
||||
TypeOccupancySensor = "sensor_occupy" // 人体传感器
|
||||
TypeTempHumidSensor = "sensor" // 温湿度传感器(官方 URN type)
|
||||
TypeSpeaker = "speaker" // 音箱
|
||||
|
||||
@@ -70,7 +70,6 @@ func main() {
|
||||
xiaomi.KindHumidifier,
|
||||
xiaomi.KindVacuum,
|
||||
xiaomi.KindWaterHeater,
|
||||
xiaomi.KindThermostat,
|
||||
}
|
||||
|
||||
for _, kind := range allKinds {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user