chore: 提交高层 xiaomi 封装库及示例

- 新增 xiaomi/examples/ 下 8 个示例程序(用户/家庭查询、设备列表、开关/灯光/空调控制、属性订阅、高级过滤分类、SPEC 解析)
- miot_client_sub.go: 重构 SubProp/SubEvent,使用 buildPropTopic/buildEventTopic 支持通配符订阅(siid/piid=0 → +),并修复锁顺序问题(将 RequestRefreshProp 移到 Lock 外)
- spec_parser.go: 新增 downloadSpecFile 方法,本地 SPEC 文件缺失时自动从 miot-spec.org 下载
- ARCH_PLAN.md: 架构设计从 Proposed 更新为 Accepted(v1.0→v1.1),补充设备分类/工厂/SPEC 映射等模块设计
- xiaomi/: 新增 miot 上层强类型封装模块,包含 Client 主入口、用户/家庭/设备 API、属性读写、动作调用、订阅通知,以及 devices/ 设备控制抽象(Switch/Light/AirConditioner/Fan/Cover/Humidifier/Vacuum/WaterHeater/Thermostat)和 specs/ SPEC 查询辅助
- 更多 xiaomi 示例(风扇/窗帘/传感器控制)
This commit is contained in:
2026-06-29 08:51:21 +08:00
parent 35efed7d15
commit a3d94c4b9f
93 changed files with 7582 additions and 157 deletions
+168
View File
@@ -0,0 +1,168 @@
package xiaomi
import (
"encoding/json"
"testing"
)
func TestDeviceInfoParsing(t *testing.T) {
data := map[string]interface{}{
"name": "客厅空调",
"model": "xiaomi.aircondition.c1",
"manufacturer": "Xiaomi",
"fw_version": "2.1.0",
"icon": "https://example.com/icon.png",
"urn": "urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-c1:1",
"token": "abc123token",
"local_ip": "192.168.1.100",
"rssi": float64(-55),
"ssid": "MyWiFi",
"bssid": "AA:BB:CC:DD:EE:FF",
"online": true,
"home_id": "home1",
"home_name": "我的家",
"room_id": "room1",
"room_name": "客厅",
"owner_id": "user1",
"parent_id": "",
"parent_model": "",
"group_id": "group1",
"connect_type": float64(0),
}
info := parseDeviceInfo("lumi.12345", data)
if info.DID != "lumi.12345" {
t.Errorf("DID = %q, want lumi.12345", info.DID)
}
if info.Name != "客厅空调" {
t.Errorf("Name = %q", info.Name)
}
if info.Model != "xiaomi.aircondition.c1" {
t.Errorf("Model = %q", info.Model)
}
if info.IP != "192.168.1.100" {
t.Errorf("IP = %q", info.IP)
}
if info.RSSI != -55 {
t.Errorf("RSSI = %d", info.RSSI)
}
if !info.Online {
t.Error("Online should be true")
}
if info.HomeID != "home1" {
t.Errorf("HomeID = %q", info.HomeID)
}
if info.RoomName != "客厅" {
t.Errorf("RoomName = %q", info.RoomName)
}
}
func TestDeviceInfoEmpty(t *testing.T) {
info := parseDeviceInfo("test.did", map[string]interface{}{})
if info.DID != "test.did" {
t.Errorf("DID = %q, want test.did", info.DID)
}
if info.Name != "" {
t.Errorf("Name should be empty, got %q", info.Name)
}
if info.Online {
t.Error("Online should be false")
}
}
func TestHomeInfoParsing(t *testing.T) {
data := map[string]interface{}{
"home_id": "home001",
"home_name": "我的家",
"uid": "user001",
"group_id": "group001",
"dids": []interface{}{"device1", "device2"},
"room_info": map[string]interface{}{
"room001": map[string]interface{}{
"room_id": "room001",
"room_name": "客厅",
"dids": []interface{}{"device1"},
},
"room002": map[string]interface{}{
"room_id": "room002",
"room_name": "卧室",
"dids": []interface{}{"device2"},
},
},
}
home := parseHomeInfo(data, true)
if home.ID != "home001" {
t.Errorf("ID = %q", home.ID)
}
if home.Name != "我的家" {
t.Errorf("Name = %q", home.Name)
}
if len(home.DidList) != 2 {
t.Errorf("DidList len = %d, want 2", len(home.DidList))
}
if len(home.Rooms) != 2 {
t.Errorf("Rooms len = %d, want 2", len(home.Rooms))
}
names := map[string]bool{"客厅": false, "卧室": false}
for _, r := range home.Rooms {
names[r.Name] = true
if r.HomeID != "home001" {
t.Errorf("Room %s HomeID = %q, want home001", r.Name, r.HomeID)
}
}
if !names["客厅"] || !names["卧室"] {
t.Errorf("Expected rooms 客厅+卧室, got %v", names)
}
}
func TestUserInfoParsing(t *testing.T) {
data := map[string]interface{}{
"uid": "123456",
"miliao_nick": "测试用户",
"avatar_url": "https://example.com/avatar.jpg",
}
user := &UserInfo{
UID: strVal(data, "uid"),
NickName: strVal(data, "miliao_nick"),
AvatarURL: strVal(data, "avatar_url"),
}
if user.UID != "123456" {
t.Errorf("UID = %q", user.UID)
}
if user.NickName != "测试用户" {
t.Errorf("NickName = %q", user.NickName)
}
}
func TestJSONSerialization(t *testing.T) {
info := &DeviceInfo{
DID: "test.did",
Name: "测试设备",
Model: "test.model",
Online: true,
RSSI: -60,
IP: "192.168.1.1",
}
data, err := json.Marshal(info)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var restored DeviceInfo
if err := json.Unmarshal(data, &restored); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if restored.DID != info.DID {
t.Errorf("DID mismatch: %q vs %q", restored.DID, info.DID)
}
if restored.Online != info.Online {
t.Error("Online mismatch")
}
}