refactor: 重大架构升级 — HomeID/RoomID int64化 + 设备驱动重构 + 结构化日志
主要变更: **类型系统重构** - HomeInfo.ID/RoomInfo.ID/HomeID 从 string 改为 int64,json tag 使用 ,string - GetHome/GetRooms/GetRoomDevices 接口同步修改 - 新增 int64FromStr 工具函数,更新 parseHomeInfo 解析逻辑 **新增 Speaker 设备** - 新增 KindSpeaker 类型 - classify 映射覆盖 speaker/bluetooth-speaker/alarm-clock/media-player/playback/music **设备驱动重构** - AC 驱动彻底重写:从硬编码常量改为基于 SPEC ValueList 的动态 valMapper - 所有设备(Fan/Humidifier/Light/OccupancySensor/Switch/TempHumiditySensor/Thermostat/Vacuum/WaterHeater/Cover)新增 GetState() 和 FetchState(ctx) 接口 - base.go 新增 specResolver.format() 方法 **MIoT 模块增强** - MIoTClient 新增 homeIDs 字段,支持按家庭过滤设备 - RefreshDevices 支持 homeIDs 参数 - SpecStdLib 引入 logger.Logger,log.Printf 全部迁移为结构化日志 **其他** - Config 新增 RedirectURL 和 HomeIDs 字段 - AuthInfo.ExpiresIn 类型从 int 改为 int32 - UUID 生成简化为 hex.EncodeToString - 新增 github.com/sirupsen/logrus 依赖 - .gitignore 新增 examples/*/data/ 忽略规则 - 删除已提交的 config.json 敏感配置
This commit is contained in:
@@ -39,6 +39,12 @@ var deviceTypeToKind = map[string]Kind{
|
||||
"heater": KindThermostat,
|
||||
"electric-blanket": KindThermostat,
|
||||
"thermostat": KindThermostat,
|
||||
"speaker": KindSpeaker,
|
||||
"bluetooth-speaker": KindSpeaker,
|
||||
"alarm-clock": KindSpeaker,
|
||||
"media-player": KindSpeaker,
|
||||
"playback": KindSpeaker,
|
||||
"music": KindSpeaker,
|
||||
"sensor": KindSensor,
|
||||
"sensor-ht": KindSensor,
|
||||
"sensor-occupy": KindSensor,
|
||||
|
||||
+25
-11
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"xiaomihome/logger"
|
||||
"xiaomihome/miot"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ type Client struct {
|
||||
mipsCloud *miot.MipsCloudClient
|
||||
specParser *miot.MIoTSpecParser
|
||||
uuid string
|
||||
lgr logger.Logger
|
||||
deviceMu sync.RWMutex
|
||||
devices map[string]*miot.MIoTDevice
|
||||
|
||||
@@ -70,35 +72,47 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) {
|
||||
)
|
||||
miotClient.SetMipsCloud(mipsCloud)
|
||||
|
||||
var ids []string
|
||||
if len(cfg.HomeIDs) > 0 {
|
||||
ids = make([]string, len(cfg.HomeIDs))
|
||||
for i, v := range cfg.HomeIDs {
|
||||
ids[i] = fmt.Sprintf("%d", v)
|
||||
}
|
||||
miotClient.SetHomeIDs(ids)
|
||||
}
|
||||
|
||||
if err := miotClient.Init(); err != nil {
|
||||
return nil, fmt.Errorf("xiaomi: init: %w", err)
|
||||
}
|
||||
|
||||
_ = miotClient.RefreshDevices()
|
||||
// Use the same homeIDs slice from above (nil if not set)
|
||||
_ = miotClient.RefreshDevices(ids)
|
||||
|
||||
if err := mipsCloud.Connect(); err != nil {
|
||||
return nil, fmt.Errorf("xiaomi: mips connect: %w", err)
|
||||
}
|
||||
|
||||
// 启动后台 token 自动刷新
|
||||
if cfg.OnTokenRefreshed != nil {
|
||||
miotClient.SetTokenRefreshCallback(cfg.OnTokenRefreshed)
|
||||
}
|
||||
miotClient.RequestRefreshAuthInfo(10)
|
||||
|
||||
return &Client{
|
||||
c := &Client{
|
||||
cfg: cfg,
|
||||
inner: miotClient,
|
||||
http: httpClient,
|
||||
mipsCloud: mipsCloud,
|
||||
uuid: uuid,
|
||||
lgr: logger.Default(),
|
||||
specParser: miot.NewMIoTSpecParser("en", miot.NewMIoTStorage(cfg.DataDir), cfg.DataDir+"/specs"),
|
||||
devices: make(map[string]*miot.MIoTDevice),
|
||||
propSubs: make(map[string]string),
|
||||
eventSubs: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SetLogger 设置日志记录器。
|
||||
func (c *Client) SetLogger(l logger.Logger) { c.lgr = l }
|
||||
|
||||
// SetHomeIDs 动态设置 miot 层的家庭过滤,供后台定时 RefreshDevices 使用。
|
||||
func (c *Client) SetHomeIDs(homeIDs []string) { c.inner.SetHomeIDs(homeIDs) }
|
||||
|
||||
// Close 关闭客户端。
|
||||
func (c *Client) Close() error {
|
||||
if c.mipsCloud != nil {
|
||||
@@ -119,7 +133,7 @@ func (c *Client) RefreshToken(ctx context.Context) (*TokenResult, error) {
|
||||
return &TokenResult{
|
||||
AccessToken: strVal(result, "access_token"),
|
||||
RefreshToken: strVal(result, "refresh_token"),
|
||||
ExpiresIn: intVal(result, "expires_in"),
|
||||
ExpiresIn: int32(intVal(result, "expires_in")),
|
||||
ExpiresTS: int64(intVal(result, "expires_ts")),
|
||||
UID: strVal(result, "uid"),
|
||||
}, nil
|
||||
@@ -140,7 +154,7 @@ func (c *Client) getOrCreateDevice(did string, info map[string]interface{}) *mio
|
||||
if err == nil {
|
||||
spec = s
|
||||
} else {
|
||||
fmt.Printf("[xiaomi] spec parse failed for %s (%s): %v\n", did, urn, err)
|
||||
c.lgr.Warnf("[xiaomi] spec parse failed for %s (%s): %v", did, urn, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@ package xiaomi
|
||||
type AuthInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"` // 有效期(秒)
|
||||
ExpiresIn int32 `json:"expires_in"` // 有效期(秒)
|
||||
ExpiresTS int64 `json:"expires_ts"` // 过期时间戳(unix),0 则内部按 expires_in 计算
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type Config struct {
|
||||
UID string // 小米帐号 UID,OAuth 后从 API 获取
|
||||
DataDir string // 存储根路径(token/设备/SPEC 缓存),多用户场景每个用户用不同目录,如 ./data/{uid}
|
||||
|
||||
// OnTokenRefreshed 后台自动刷新成功时回调,供调用方将新 token 持久化到数据库。
|
||||
OnTokenRefreshed func(accessToken, refreshToken string)
|
||||
RedirectURL string // OAuth 授权回调 URL
|
||||
|
||||
HomeIDs []int64 // 需要管理的家庭 ID 列表,nil/空=全部
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,6 +1,9 @@
|
||||
package xiaomi
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// map → 类型安全提取工具函数
|
||||
|
||||
@@ -38,6 +41,9 @@ func boolVal(m map[string]interface{}, key string) bool {
|
||||
|
||||
func strSliceVal(m map[string]interface{}, key string) []string {
|
||||
if v, ok := m[key]; ok {
|
||||
if arr, ok := v.([]string); ok {
|
||||
return arr
|
||||
}
|
||||
if arr, ok := v.([]interface{}); ok {
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
@@ -69,3 +75,8 @@ func toFloat64(v interface{}) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func int64FromStr(s string) int64 {
|
||||
v, _ := strconv.ParseInt(s, 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
+3
-3
@@ -83,9 +83,9 @@ func (c *Client) GetDevice(ctx context.Context, did string) (*DeviceInfo, error)
|
||||
return parseDeviceInfo(did, raw), nil
|
||||
}
|
||||
|
||||
// RefreshDevices 刷新设备列表
|
||||
func (c *Client) RefreshDevices(ctx context.Context) error {
|
||||
return c.inner.RefreshDevices()
|
||||
// RefreshDevices 刷新设备列表,homeIDs 为空则全量
|
||||
func (c *Client) RefreshDevices(ctx context.Context, homeIDs ...string) error {
|
||||
return c.inner.RefreshDevices(homeIDs)
|
||||
}
|
||||
|
||||
// GetMIoTDevice 获取底层 MIoTDevice 实例(供设备控制抽象使用)
|
||||
|
||||
+215
-151
@@ -2,89 +2,72 @@ package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"xiaomihome/miot"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// valMapper maps between device raw values and semantic names. Built from SPEC ValueList.
|
||||
// Different brands assign different raw values for the same mode name,
|
||||
// so this must be per-device. Names are lowercased from SPEC description for consistency.
|
||||
type valMapper struct {
|
||||
val2name map[int]string
|
||||
name2val map[string]int
|
||||
names []string
|
||||
}
|
||||
|
||||
func newValMapper(vl miot.MIoTSpecValueList) *valMapper {
|
||||
vm := &valMapper{
|
||||
val2name: make(map[int]string),
|
||||
name2val: make(map[string]int),
|
||||
}
|
||||
for _, item := range vl {
|
||||
v := toInt(item.Value)
|
||||
name := strings.ToLower(item.Desc)
|
||||
vm.val2name[v] = name
|
||||
vm.name2val[name] = v
|
||||
vm.names = append(vm.names, name)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
func (vm *valMapper) name(v int) string {
|
||||
if vm == nil { return "" }
|
||||
return vm.val2name[v]
|
||||
}
|
||||
|
||||
func (vm *valMapper) val(name string) (int, bool) {
|
||||
if vm == nil { return 0, false }
|
||||
v, ok := vm.name2val[name]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (vm *valMapper) allNames() []string {
|
||||
if vm == nil { return nil }
|
||||
return vm.names
|
||||
}
|
||||
|
||||
// ========== 空调模式/风速/摆风常量 ==========
|
||||
|
||||
// ACMode 空调模式
|
||||
type ACMode string
|
||||
|
||||
const (
|
||||
ModeCool ACMode = "cool"
|
||||
ModeHeat ACMode = "heat"
|
||||
ModeFan ACMode = "fan"
|
||||
ModeDry ACMode = "dry"
|
||||
ModeAuto ACMode = "auto"
|
||||
ACModeCool = "cool"
|
||||
ACModeHeat = "heat"
|
||||
ACModeFan = "fan"
|
||||
ACModeDry = "dry"
|
||||
ACModeAuto = "auto"
|
||||
|
||||
ACFanLow = "low"
|
||||
ACFanMedium = "medium"
|
||||
ACFanHigh = "high"
|
||||
ACFanAuto = "auto"
|
||||
|
||||
ACSwingOff = "off"
|
||||
ACSwingVertical = "vertical"
|
||||
ACSwingHorizontal = "horizontal"
|
||||
ACSwingBoth = "both"
|
||||
)
|
||||
|
||||
// modeValueMap 模式值映射(uint8 → ACMode)
|
||||
var modeValueMap = map[int]ACMode{
|
||||
0: ModeCool,
|
||||
1: ModeHeat,
|
||||
2: ModeFan,
|
||||
3: ModeDry,
|
||||
4: ModeAuto,
|
||||
}
|
||||
|
||||
var modeReverseMap = map[ACMode]int{
|
||||
ModeCool: 0,
|
||||
ModeHeat: 1,
|
||||
ModeFan: 2,
|
||||
ModeDry: 3,
|
||||
ModeAuto: 4,
|
||||
}
|
||||
|
||||
// FanSpeed 风速
|
||||
type FanSpeed string
|
||||
|
||||
const (
|
||||
SpeedLow FanSpeed = "low"
|
||||
SpeedMedium FanSpeed = "medium"
|
||||
SpeedHigh FanSpeed = "high"
|
||||
SpeedAuto FanSpeed = "auto"
|
||||
)
|
||||
|
||||
var fanSpeedValueMap = map[int]FanSpeed{
|
||||
0: SpeedLow,
|
||||
1: SpeedMedium,
|
||||
2: SpeedHigh,
|
||||
3: SpeedAuto,
|
||||
}
|
||||
|
||||
var fanSpeedReverseMap = map[FanSpeed]int{
|
||||
SpeedLow: 0,
|
||||
SpeedMedium: 1,
|
||||
SpeedHigh: 2,
|
||||
SpeedAuto: 3,
|
||||
}
|
||||
|
||||
// SwingMode 摆风模式
|
||||
type SwingMode string
|
||||
|
||||
const (
|
||||
SwingOff SwingMode = "off"
|
||||
SwingVertical SwingMode = "vertical"
|
||||
SwingHorizontal SwingMode = "horizontal"
|
||||
SwingBoth SwingMode = "both"
|
||||
)
|
||||
|
||||
var swingValueMap = map[int]SwingMode{
|
||||
0: SwingOff,
|
||||
1: SwingVertical,
|
||||
2: SwingHorizontal,
|
||||
3: SwingBoth,
|
||||
}
|
||||
|
||||
var swingReverseMap = map[SwingMode]int{
|
||||
SwingOff: 0,
|
||||
SwingVertical: 1,
|
||||
SwingHorizontal: 2,
|
||||
SwingBoth: 3,
|
||||
}
|
||||
|
||||
// ========== AirConditioner 接口 ==========
|
||||
|
||||
// AirConditioner 空调统一控制接口
|
||||
@@ -102,8 +85,8 @@ type AirConditioner interface {
|
||||
HasEco() bool
|
||||
|
||||
// 模式控制
|
||||
SetMode(ctx context.Context, mode ACMode) error
|
||||
GetMode(ctx context.Context) (ACMode, error)
|
||||
SetMode(ctx context.Context, mode string) error
|
||||
GetMode(ctx context.Context) (string, error)
|
||||
|
||||
// 温度控制
|
||||
SetTargetTemp(ctx context.Context, celsius float64) error
|
||||
@@ -111,12 +94,12 @@ type AirConditioner interface {
|
||||
GetCurrentTemp(ctx context.Context) (float64, error) // 室内温度
|
||||
|
||||
// 风速控制
|
||||
SetFanSpeed(ctx context.Context, speed FanSpeed) error
|
||||
GetFanSpeed(ctx context.Context) (FanSpeed, error)
|
||||
SetFanSpeed(ctx context.Context, speed string) error
|
||||
GetFanSpeed(ctx context.Context) (string, error)
|
||||
|
||||
// 摆风控制
|
||||
SetSwing(ctx context.Context, mode SwingMode) error
|
||||
GetSwing(ctx context.Context) (SwingMode, error)
|
||||
SetSwing(ctx context.Context, mode string) error
|
||||
GetSwing(ctx context.Context) (string, error)
|
||||
|
||||
// 湿度
|
||||
SetTargetHumidity(ctx context.Context, pct int) error
|
||||
@@ -133,20 +116,22 @@ type AirConditioner interface {
|
||||
|
||||
// 订阅
|
||||
OnStateChanged(handler func(state ACState, changed string)) (string, error)
|
||||
GetState() ACState
|
||||
FetchState(ctx context.Context) (ACState, error)
|
||||
}
|
||||
|
||||
// ACState 空调完整状态(字段名对齐 SPEC type)
|
||||
type ACState struct {
|
||||
On bool `json:"on"` // SPEC: "on"
|
||||
Mode ACMode `json:"mode"` // SPEC: "mode"
|
||||
TargetTemperature float64 `json:"targetTemperature"` // SPEC: "target-temperature"
|
||||
Temperature float64 `json:"temperature"` // SPEC: "temperature" (当前温度)
|
||||
TargetHumidity int `json:"targetHumidity"` // SPEC: "target-humidity"
|
||||
CurrentHumidity int `json:"currentHumidity"` // SPEC: "relative-humidity"
|
||||
FanLevel FanSpeed `json:"fanLevel"` // SPEC: "fan-level"
|
||||
Swing SwingMode `json:"swing"` // SPEC: "swing"
|
||||
AuxHeat bool `json:"auxHeat"` // SPEC: "heater"
|
||||
Eco bool `json:"eco"` // SPEC: "eco"
|
||||
On bool `json:"on"`
|
||||
Mode string `json:"mode"` // SPEC ValueList 解析后的名称:cool/heat/fan/dry/auto
|
||||
TargetTemperature float64 `json:"targetTemperature"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
TargetHumidity int `json:"targetHumidity"`
|
||||
CurrentHumidity int `json:"currentHumidity"`
|
||||
FanLevel string `json:"fanLevel"` // SPEC ValueList 解析后的名称:low/medium/high/auto
|
||||
Swing string `json:"swing"` // SPEC ValueList 解析后的名称:off/vertical/horizontal/both
|
||||
AuxHeat bool `json:"auxHeat"`
|
||||
Eco bool `json:"eco"`
|
||||
}
|
||||
|
||||
type acDevice struct {
|
||||
@@ -162,6 +147,10 @@ type acDevice struct {
|
||||
propAuxHeat siidPiid // "aux-heat" or "heater"
|
||||
propEco siidPiid // "eco"
|
||||
|
||||
modeMap *valMapper // SPEC value→name for mode
|
||||
fanMap *valMapper // SPEC value→name for fan-level
|
||||
swingMap *valMapper // SPEC value→name for swing
|
||||
|
||||
stateCache ACState // MQTT 推送维护的本地缓存
|
||||
}
|
||||
|
||||
@@ -171,11 +160,32 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
dev := d.getMIoTDevice()
|
||||
if dev == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
spec := dev.SpecInstance()
|
||||
if spec == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
|
||||
resolver := d.getSpecResolver()
|
||||
if resolver == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
|
||||
// Helper: find property ValueList from spec by type name
|
||||
findValList := func(propType string) miot.MIoTSpecValueList {
|
||||
for _, svc := range spec.Services {
|
||||
for _, p := range svc.Properties {
|
||||
if propertyShortName(p.Type) == propType {
|
||||
return p.ValueList
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// "on" is required
|
||||
siid, piid, ok := resolver.find("on")
|
||||
if !ok {
|
||||
@@ -186,6 +196,7 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi
|
||||
// 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 {
|
||||
d.propTargetTemp = siidPiid{s, p, true}
|
||||
@@ -195,14 +206,18 @@ func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirCondi
|
||||
}
|
||||
if s, p, ok := resolver.find("fan-level"); ok {
|
||||
d.propFanSpeed = siidPiid{s, p, true}
|
||||
d.fanMap = newValMapper(findValList("fan-level"))
|
||||
}
|
||||
// Try multiple swing mode type names
|
||||
if s, p, ok := resolver.find("swing-mode"); ok {
|
||||
d.propSwing = siidPiid{s, p, true}
|
||||
d.swingMap = newValMapper(findValList("swing-mode"))
|
||||
} else if s, p, ok := resolver.find("horizontal-swing"); ok {
|
||||
d.propSwing = siidPiid{s, p, true}
|
||||
d.swingMap = newValMapper(findValList("horizontal-swing"))
|
||||
} else if s, p, ok := resolver.find("vertical-swing"); ok {
|
||||
d.propSwing = siidPiid{s, p, true}
|
||||
d.swingMap = newValMapper(findValList("vertical-swing"))
|
||||
}
|
||||
if s, p, ok := resolver.find("target-humidity"); ok {
|
||||
d.propTargetHumidity = siidPiid{s, p, true}
|
||||
@@ -256,30 +271,26 @@ func (d *acDevice) HasHumidity() bool { return d.propTargetHumidity.valid }
|
||||
func (d *acDevice) HasAuxHeat() bool { return d.propAuxHeat.valid }
|
||||
func (d *acDevice) HasEco() bool { return d.propEco.valid }
|
||||
|
||||
func (d *acDevice) SetMode(ctx context.Context, mode ACMode) error {
|
||||
func (d *acDevice) SetMode(ctx context.Context, mode string) error {
|
||||
if !d.propMode.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
val, ok := modeReverseMap[mode]
|
||||
v, ok := d.modeMap.val(mode)
|
||||
if !ok {
|
||||
val = 4 // default auto
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, val)
|
||||
return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, v)
|
||||
}
|
||||
|
||||
func (d *acDevice) GetMode(ctx context.Context) (ACMode, error) {
|
||||
func (d *acDevice) GetMode(ctx context.Context) (string, error) {
|
||||
if !d.propMode.valid {
|
||||
return ModeAuto, xiaomi.ErrNotSupported
|
||||
return ACModeAuto, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid)
|
||||
if err != nil {
|
||||
return ModeAuto, err
|
||||
return ACModeAuto, err
|
||||
}
|
||||
v := toInt(prop.Value)
|
||||
if m, ok := modeValueMap[v]; ok {
|
||||
return m, nil
|
||||
}
|
||||
return ModeAuto, nil
|
||||
return d.modeMap.name(toInt(prop.Value)), nil
|
||||
}
|
||||
|
||||
func (d *acDevice) SetTargetTemp(ctx context.Context, celsius float64) error {
|
||||
@@ -311,56 +322,48 @@ func (d *acDevice) GetCurrentTemp(ctx context.Context) (float64, error) {
|
||||
return toFloat(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *acDevice) SetFanSpeed(ctx context.Context, speed FanSpeed) error {
|
||||
func (d *acDevice) SetFanSpeed(ctx context.Context, speed string) error {
|
||||
if !d.propFanSpeed.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
val, ok := fanSpeedReverseMap[speed]
|
||||
v, ok := d.fanMap.val(speed)
|
||||
if !ok {
|
||||
val = 3 // default auto
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, val)
|
||||
return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, v)
|
||||
}
|
||||
|
||||
func (d *acDevice) GetFanSpeed(ctx context.Context) (FanSpeed, error) {
|
||||
func (d *acDevice) GetFanSpeed(ctx context.Context) (string, error) {
|
||||
if !d.propFanSpeed.valid {
|
||||
return SpeedAuto, xiaomi.ErrNotSupported
|
||||
return ACFanAuto, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid)
|
||||
if err != nil {
|
||||
return SpeedAuto, err
|
||||
return ACFanAuto, err
|
||||
}
|
||||
v := toInt(prop.Value)
|
||||
if s, ok := fanSpeedValueMap[v]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return SpeedAuto, nil
|
||||
return d.fanMap.name(toInt(prop.Value)), nil
|
||||
}
|
||||
|
||||
func (d *acDevice) SetSwing(ctx context.Context, mode SwingMode) error {
|
||||
func (d *acDevice) SetSwing(ctx context.Context, mode string) error {
|
||||
if !d.propSwing.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
val, ok := swingReverseMap[mode]
|
||||
v, ok := d.swingMap.val(mode)
|
||||
if !ok {
|
||||
val = 0
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, val)
|
||||
return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, v)
|
||||
}
|
||||
|
||||
func (d *acDevice) GetSwing(ctx context.Context) (SwingMode, error) {
|
||||
func (d *acDevice) GetSwing(ctx context.Context) (string, error) {
|
||||
if !d.propSwing.valid {
|
||||
return SwingOff, xiaomi.ErrNotSupported
|
||||
return ACSwingOff, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propSwing.siid, d.propSwing.piid)
|
||||
if err != nil {
|
||||
return SwingOff, err
|
||||
return ACSwingOff, err
|
||||
}
|
||||
v := toInt(prop.Value)
|
||||
if s, ok := swingValueMap[v]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return SwingOff, nil
|
||||
return d.swingMap.name(toInt(prop.Value)), nil
|
||||
}
|
||||
|
||||
func (d *acDevice) SetTargetHumidity(ctx context.Context, pct int) error {
|
||||
@@ -439,6 +442,7 @@ type propField struct {
|
||||
siid, piid int
|
||||
name string
|
||||
fill func(*ACState, interface{})
|
||||
valType byte // 'b'=bool, 'i'=int, 'f'=float, 's'=string
|
||||
}
|
||||
|
||||
func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) (string, error) {
|
||||
@@ -446,46 +450,81 @@ func (d *acDevice) OnStateChanged(handler func(state ACState, changed string)) (
|
||||
if len(fields) == 0 {
|
||||
return "", xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) {
|
||||
for _, f := range fields {
|
||||
if f.siid == prop.SIID && f.piid == prop.PIID {
|
||||
f.fill(&d.stateCache, prop.Value)
|
||||
handler(d.stateCache, f.name)
|
||||
return
|
||||
// 每个属性精确订阅 siid/piid,避免 lumi.acpartner.mcn02 等设备
|
||||
// siid/piid 冲突时把风速 int 路由到 on 槽位
|
||||
var firstID string
|
||||
for i, f := range fields {
|
||||
idx := i
|
||||
subID, err := d.SubProp(f.siid, f.piid, func(did string, prop *xiaomi.PropertyValue) {
|
||||
// lumi.acpartner.mcn02 固件 bug:on 通道会收到 int 值,静默丢弃
|
||||
switch f.valType {
|
||||
case 'b':
|
||||
if _, ok := prop.Value.(bool); !ok {
|
||||
return
|
||||
}
|
||||
case 's':
|
||||
if _, ok := prop.Value.(string); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
fields[idx].fill(&d.stateCache, prop.Value)
|
||||
handler(d.stateCache, f.name)
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
})
|
||||
if firstID == "" {
|
||||
firstID = subID
|
||||
}
|
||||
}
|
||||
return firstID, nil
|
||||
}
|
||||
|
||||
func (d *acDevice) propFields() []propField {
|
||||
r := d.getSpecResolver()
|
||||
var fields []propField
|
||||
add := func(sp siidPiid, name string, fill func(*ACState, interface{})) {
|
||||
add := func(sp siidPiid, name string, specType string, fill func(*ACState, interface{})) {
|
||||
if sp.valid {
|
||||
fields = append(fields, propField{sp.siid, sp.piid, name, fill})
|
||||
vt := byte(0)
|
||||
switch specType {
|
||||
case "bool":
|
||||
vt = 'b'
|
||||
case "uint8", "uint16", "uint32", "int8", "int16", "int32":
|
||||
vt = 'i'
|
||||
case "float":
|
||||
vt = 'f'
|
||||
case "string":
|
||||
vt = 's'
|
||||
}
|
||||
fields = append(fields, propField{sp.siid, sp.piid, name, fill, vt})
|
||||
}
|
||||
}
|
||||
add(d.propOn, "on", func(s *ACState, v interface{}) { s.On, _ = v.(bool) })
|
||||
add(d.propMode, "mode", func(s *ACState, v interface{}) {
|
||||
switch m := v.(type) {
|
||||
case string: s.Mode = ACMode(m)
|
||||
case float64: s.Mode = ACMode(fmt.Sprintf("%.0f", m))
|
||||
}
|
||||
add(d.propOn, "on", specFormat(r, "on"), func(s *ACState, v interface{}) { s.On, _ = v.(bool) })
|
||||
add(d.propMode, "mode", specFormat(r, "mode"), func(s *ACState, v interface{}) {
|
||||
s.Mode = d.modeMap.name(toInt(v))
|
||||
})
|
||||
add(d.propTargetTemp, "target-temperature", func(s *ACState, v interface{}) { s.TargetTemperature = toFloat(v) })
|
||||
add(d.propCurrentTemp, "temperature", func(s *ACState, v interface{}) { s.Temperature = toFloat(v) })
|
||||
add(d.propFanSpeed, "fan-level", func(s *ACState, v interface{}) {
|
||||
if v, ok := v.(float64); ok { s.FanLevel = FanSpeed(fmt.Sprintf("%.0f", v)) }
|
||||
add(d.propTargetTemp, "target-temperature", specFormat(r, "target-temperature"), func(s *ACState, v interface{}) { s.TargetTemperature = toFloat(v) })
|
||||
add(d.propCurrentTemp, "temperature", specFormat(r, "temperature"), func(s *ACState, v interface{}) { s.Temperature = toFloat(v) })
|
||||
add(d.propFanSpeed, "fan-level", specFormat(r, "fan-level"), func(s *ACState, v interface{}) {
|
||||
s.FanLevel = d.fanMap.name(toInt(v))
|
||||
})
|
||||
add(d.propSwing, "swing", func(s *ACState, v interface{}) {
|
||||
if v, ok := v.(float64); ok { s.Swing = SwingMode(fmt.Sprintf("%.0f", v)) }
|
||||
add(d.propSwing, "swing", specFormat(r, "swing-mode"), func(s *ACState, v interface{}) {
|
||||
s.Swing = d.swingMap.name(toInt(v))
|
||||
})
|
||||
add(d.propTargetHumidity, "target-humidity", func(s *ACState, v interface{}) { s.TargetHumidity = int(toFloat(v)) })
|
||||
add(d.propCurrentHumidity, "relative-humidity", func(s *ACState, v interface{}) { s.CurrentHumidity = int(toFloat(v)) })
|
||||
add(d.propAuxHeat, "heater", func(s *ACState, v interface{}) { s.AuxHeat, _ = v.(bool) })
|
||||
add(d.propEco, "eco", func(s *ACState, v interface{}) { s.Eco, _ = v.(bool) })
|
||||
add(d.propTargetHumidity, "target-humidity", specFormat(r, "target-humidity"), func(s *ACState, v interface{}) { s.TargetHumidity = int(toFloat(v)) })
|
||||
add(d.propCurrentHumidity, "relative-humidity", specFormat(r, "relative-humidity"), func(s *ACState, v interface{}) { s.CurrentHumidity = int(toFloat(v)) })
|
||||
add(d.propAuxHeat, "heater", specFormat(r, "heater"), func(s *ACState, v interface{}) { s.AuxHeat, _ = v.(bool) })
|
||||
add(d.propEco, "eco", specFormat(r, "eco"), func(s *ACState, v interface{}) { s.Eco, _ = v.(bool) })
|
||||
return fields
|
||||
}
|
||||
|
||||
func specFormat(r *specResolver, propType string) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.format(propType)
|
||||
}
|
||||
|
||||
// toFloat converts an interface{} to float64.
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch n := v.(type) {
|
||||
@@ -500,3 +539,28 @@ func toFloat(v interface{}) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *acDevice) GetState() ACState { return d.stateCache }
|
||||
|
||||
func (d *acDevice) FetchState(ctx context.Context) (ACState, error) {
|
||||
fields := d.propFields()
|
||||
keys := make([]xiaomi.PropKey, len(fields))
|
||||
for i, f := range fields {
|
||||
keys[i] = xiaomi.PropKey{DID: d.info.DID, SIID: f.siid, PIID: f.piid}
|
||||
}
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil {
|
||||
return d.stateCache, err
|
||||
}
|
||||
state := ACState{}
|
||||
for _, p := range props {
|
||||
for _, f := range fields {
|
||||
if f.siid == p.SIID && f.piid == p.PIID {
|
||||
f.fill(&state, p.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -167,3 +167,14 @@ func (r *specResolver) find(propType string) (siid, piid int, found bool) {
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// format returns the SPEC Format for a property type ("bool", "uint8", "float", "string").
|
||||
func (r *specResolver) format(propType string) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if prop, ok := r.byType[propType]; ok {
|
||||
return prop.Format
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type Cover interface {
|
||||
GetPosition(ctx context.Context) (int, error)
|
||||
|
||||
OnStateChanged(handler func(position int)) (string, error)
|
||||
GetState() int
|
||||
FetchState(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type coverDevice struct {
|
||||
@@ -81,3 +83,11 @@ func (d *coverDevice) OnStateChanged(handler func(position int)) (string, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *coverDevice) GetState() int { return d.stateCache }
|
||||
func (d *coverDevice) FetchState(ctx context.Context) (int, error) {
|
||||
pos, err := d.GetPosition(ctx)
|
||||
if err != nil { return d.stateCache, err }
|
||||
d.stateCache = pos
|
||||
return pos, nil
|
||||
}
|
||||
|
||||
@@ -53,34 +53,20 @@ func TestToFloat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAirConditionerModeConstants(t *testing.T) {
|
||||
// Verify all mode values have reverse mappings
|
||||
for _, m := range []ACMode{ModeCool, ModeHeat, ModeFan, ModeDry, ModeAuto} {
|
||||
if _, ok := modeReverseMap[m]; !ok {
|
||||
t.Errorf("missing reverse mapping for %s", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all value entries map back correctly
|
||||
for v, m := range modeValueMap {
|
||||
if r, ok := modeReverseMap[m]; !ok || r != v {
|
||||
t.Errorf("modeReverseMap[modeValueMap[%d]] = %v, want %d", v, r, v)
|
||||
}
|
||||
if ACModeCool != "cool" || ACModeHeat != "heat" || ACModeDry != "dry" || ACModeAuto != "auto" {
|
||||
t.Error("AC mode constants mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanSpeedConstants(t *testing.T) {
|
||||
for _, s := range []FanSpeed{SpeedLow, SpeedMedium, SpeedHigh, SpeedAuto} {
|
||||
if _, ok := fanSpeedReverseMap[s]; !ok {
|
||||
t.Errorf("missing reverse mapping for %s", s)
|
||||
}
|
||||
if ACFanLow != "low" || ACFanAuto != "auto" {
|
||||
t.Error("Fan speed constants mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwingModeConstants(t *testing.T) {
|
||||
for _, s := range []SwingMode{SwingOff, SwingVertical, SwingHorizontal, SwingBoth} {
|
||||
if _, ok := swingReverseMap[s]; !ok {
|
||||
t.Errorf("missing reverse mapping for %s", s)
|
||||
}
|
||||
if ACSwingOff != "off" || ACSwingVertical != "vertical" {
|
||||
t.Error("Swing mode constants mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, er
|
||||
ctrl, err = NewOccupancySensor(client, info)
|
||||
case matchURN(urn, "sensor-temp", "sensor-humidity", "sensor-ht", "sensor-smoke", "sensor-gasm", "illumination"):
|
||||
ctrl, err = NewTempHumiditySensor(client, info)
|
||||
case matchURN(urn, "speaker", "bluetooth-speaker", "alarm-clock", "media-player", "playback", "music"):
|
||||
ctrl, err = NewSpeaker(client, info)
|
||||
default:
|
||||
// Not a supported device type — not an error, just returns nil
|
||||
return nil, nil
|
||||
|
||||
@@ -22,6 +22,8 @@ type Fan interface {
|
||||
IsNaturalWind(ctx context.Context) (bool, error)
|
||||
|
||||
OnStateChanged(handler func(state FanState, changed string)) (string, error)
|
||||
GetState() FanState
|
||||
FetchState(ctx context.Context) (FanState, error)
|
||||
}
|
||||
|
||||
// FanState 风扇完整状态(MQTT 缓存)
|
||||
@@ -193,3 +195,25 @@ func (d *fanDevice) OnStateChanged(handler func(state FanState, changed string))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *fanDevice) GetState() FanState { return d.stateCache }
|
||||
func (d *fanDevice) FetchState(ctx context.Context) (FanState, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) }
|
||||
if d.propFanLevel.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFanLevel.siid, PIID: d.propFanLevel.piid}) }
|
||||
if d.propOscillation.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOscillation.siid, PIID: d.propOscillation.piid}) }
|
||||
if d.propNaturalWind.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propNaturalWind.siid, PIID: d.propNaturalWind.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache, err }
|
||||
state := FanState{}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool)
|
||||
case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: state.FanLevel = toInt(p.Value)
|
||||
case d.propOscillation.valid && p.SIID == d.propOscillation.siid && p.PIID == d.propOscillation.piid: state.Oscillation, _ = p.Value.(bool)
|
||||
case d.propNaturalWind.valid && p.SIID == d.propNaturalWind.siid && p.PIID == d.propNaturalWind.piid: state.NaturalWind = toInt(p.Value) == 1
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ type Humidifier interface {
|
||||
GetFanLevel(ctx context.Context) (int, error)
|
||||
|
||||
OnStateChanged(handler func(state HumidifierState, changed string)) (string, error)
|
||||
GetState() HumidifierState
|
||||
FetchState(ctx context.Context) (HumidifierState, error)
|
||||
}
|
||||
|
||||
// HumidifierState 加湿器状态(MQTT 缓存)
|
||||
@@ -105,3 +107,23 @@ func (d *humidifierDevice) OnStateChanged(handler func(state HumidifierState, ch
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *humidifierDevice) GetState() HumidifierState { return d.stateCache }
|
||||
func (d *humidifierDevice) FetchState(ctx context.Context) (HumidifierState, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) }
|
||||
if d.propTargetHumidity.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTargetHumidity.siid, PIID: d.propTargetHumidity.piid}) }
|
||||
if d.propFanLevel.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propFanLevel.siid, PIID: d.propFanLevel.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache, err }
|
||||
state := HumidifierState{}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool)
|
||||
case d.propTargetHumidity.valid && p.SIID == d.propTargetHumidity.siid && p.PIID == d.propTargetHumidity.piid: state.TargetHumidity = toInt(p.Value)
|
||||
case d.propFanLevel.valid && p.SIID == d.propFanLevel.siid && p.PIID == d.propFanLevel.piid: state.FanLevel = toInt(p.Value)
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type Light interface {
|
||||
GetColor(ctx context.Context) (r, g, b int, err error)
|
||||
|
||||
OnStateChanged(handler func(state LightState, changed string)) (string, error)
|
||||
GetState() LightState
|
||||
FetchState(ctx context.Context) (LightState, error)
|
||||
}
|
||||
|
||||
// LightState 灯光完整状态(字段名对齐 SPEC type)
|
||||
@@ -241,3 +243,26 @@ func toInt(v interface{}) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *lightDevice) GetState() LightState { return d.stateCache }
|
||||
func (d *lightDevice) FetchState(ctx context.Context) (LightState, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) }
|
||||
if d.propBrightness.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propBrightness.siid, PIID: d.propBrightness.piid}) }
|
||||
if d.propColorTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propColorTemp.siid, PIID: d.propColorTemp.piid}) }
|
||||
if d.propColor.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propColor.siid, PIID: d.propColor.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache, err }
|
||||
state := LightState{}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool)
|
||||
case d.propBrightness.valid && p.SIID == d.propBrightness.siid && p.PIID == d.propBrightness.piid: state.Brightness = toInt(p.Value)
|
||||
case d.propColorTemp.valid && p.SIID == d.propColorTemp.siid && p.PIID == d.propColorTemp.piid: state.ColorTemperature = toInt(p.Value)
|
||||
case d.propColor.valid && p.SIID == d.propColor.siid && p.PIID == d.propColor.piid:
|
||||
if v, ok := p.Value.(float64); ok { c := int(v); state.ColorRGB = [3]int{(c>>16)&0xFF, (c>>8)&0xFF, c&0xFF} }
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ type OccupancySensor interface {
|
||||
GetIlluminance(ctx context.Context) (float64, error)
|
||||
|
||||
OnStateChanged(handler func(occupied bool, illuminance float64)) (string, error)
|
||||
GetState() (bool, float64)
|
||||
FetchState(ctx context.Context) (bool, float64, error)
|
||||
}
|
||||
|
||||
type occupancyDevice struct {
|
||||
@@ -71,3 +73,32 @@ func (d *occupancyDevice) OnStateChanged(handler func(occupied bool, illuminance
|
||||
handler(occ, lux)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *occupancyDevice) GetState() (bool, float64) {
|
||||
b, _ := d.stateCache[0].(bool)
|
||||
f, _ := d.stateCache[1].(float64)
|
||||
return b, f
|
||||
}
|
||||
func (d *occupancyDevice) FetchState(ctx context.Context) (bool, float64, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propOccupancy.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOccupancy.siid, PIID: d.propOccupancy.piid}) }
|
||||
if d.propIlluminance.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propIlluminance.siid, PIID: d.propIlluminance.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil {
|
||||
b, _ := d.stateCache[0].(bool)
|
||||
f, _ := d.stateCache[1].(float64)
|
||||
return b, f, err
|
||||
}
|
||||
var occupied bool
|
||||
var illuminance float64
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propOccupancy.valid && p.SIID == d.propOccupancy.siid && p.PIID == d.propOccupancy.piid:
|
||||
occupied, _ = p.Value.(bool)
|
||||
case d.propIlluminance.valid && p.SIID == d.propIlluminance.siid && p.PIID == d.propIlluminance.piid:
|
||||
illuminance = toFloat(p.Value)
|
||||
}
|
||||
}
|
||||
d.stateCache = [2]interface{}{occupied, illuminance}
|
||||
return occupied, illuminance, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package devices
|
||||
|
||||
// ── 设备属性名常量 ──
|
||||
// 用于 OnStateChanged 回调的 changed 字段,switch 前先用 deviceType 区分设备类型。
|
||||
|
||||
const (
|
||||
// ── 通用 ──
|
||||
PropOn = "on" // 所有设备
|
||||
PropMode = "mode" // AC / Fan / Vacuum
|
||||
PropSwing = "swing" // AC / Fan
|
||||
PropFanLevel = "fan-level" // AC / Fan / Humidifier / Vacuum
|
||||
PropTargetTemperature = "target-temperature" // AC / Thermostat / WaterHeater
|
||||
PropTemperature = "temperature" // AC / Thermostat / WaterHeater
|
||||
PropTargetHumidity = "target-humidity" // AC / Humidifier
|
||||
PropRelativeHumidity = "relative-humidity" // AC
|
||||
|
||||
// ── 空调专属 ──
|
||||
PropHeater = "heater"
|
||||
PropEco = "eco"
|
||||
|
||||
// ── 灯光 ──
|
||||
PropBrightness = "brightness"
|
||||
PropColorTemperature = "color-temperature"
|
||||
PropColor = "color"
|
||||
|
||||
// ── 扫地机 ──
|
||||
PropBattery = "battery"
|
||||
|
||||
// ── 窗帘 ──
|
||||
PropPosition = "position"
|
||||
|
||||
// ── 传感器 ──
|
||||
PropTemperatureHumidity = "temperature_humidity"
|
||||
PropOccupancy = "occupancy"
|
||||
|
||||
// ── 音箱 ──
|
||||
PropVolume = "volume"
|
||||
PropMute = "mute"
|
||||
PropPlayState = "play-state"
|
||||
)
|
||||
|
||||
const (
|
||||
PropValueTrue = "true"
|
||||
PropValueFalse = "false"
|
||||
)
|
||||
@@ -0,0 +1,434 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"xiaomihome/miot"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Speaker 音箱通用控制接口
|
||||
type Speaker interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
Toggle(ctx context.Context) error
|
||||
|
||||
// 能力查询
|
||||
HasVolume() bool
|
||||
HasMute() bool
|
||||
HasPlayControl() bool
|
||||
|
||||
// 音量 (0-100)
|
||||
SetVolume(ctx context.Context, pct int) error
|
||||
GetVolume(ctx context.Context) (int, error)
|
||||
|
||||
// 静音
|
||||
SetMute(ctx context.Context, mute bool) error
|
||||
GetMute(ctx context.Context) (bool, error)
|
||||
|
||||
// 播放控制 (0=停止, 1=播放, 2=暂停)
|
||||
SetPlayState(ctx context.Context, state int) error
|
||||
GetPlayState(ctx context.Context) (int, error)
|
||||
|
||||
// ── 智能音箱 (intelligent-speaker) ──
|
||||
|
||||
// 睡眠模式
|
||||
HasSleepMode() bool
|
||||
SetSleepMode(ctx context.Context, sleep bool) error
|
||||
GetSleepMode(ctx context.Context) (bool, error)
|
||||
|
||||
// 音频 ID (只读/通知)
|
||||
GetAudioID(ctx context.Context) (string, error)
|
||||
|
||||
// 动作
|
||||
WakeUp(ctx context.Context) error
|
||||
PlayRadio(ctx context.Context) error
|
||||
PlayText(ctx context.Context, text string) error
|
||||
ExecuteTextDirective(ctx context.Context, text string, silent bool) error
|
||||
PlayMusic(ctx context.Context) error
|
||||
|
||||
OnStateChanged(handler func(state SpeakerState, changed string)) (string, error)
|
||||
GetState() SpeakerState
|
||||
FetchState(ctx context.Context) (SpeakerState, error)
|
||||
}
|
||||
|
||||
// SpeakerState 音箱完整状态(字段名对齐 SPEC type)
|
||||
type SpeakerState struct {
|
||||
On bool `json:"on"` // SPEC: "on"
|
||||
Volume int `json:"volume"` // SPEC: "volume"
|
||||
Mute bool `json:"mute"` // SPEC: "mute"
|
||||
PlayState int `json:"playState"` // SPEC: "tds-in" or "playing-state" (0=stop,1=play,2=pause)
|
||||
SleepMode bool `json:"sleepMode"` // SPEC: "sleep-mode" (intelligent-speaker)
|
||||
AudioID string `json:"audioId"` // SPEC: "audio-id" (intelligent-speaker, read-only)
|
||||
}
|
||||
|
||||
// isAction 智能音箱动作 siid/aiid 对
|
||||
type isAction struct{ siid, aiid int }
|
||||
|
||||
type speakerDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on"
|
||||
propVolume siidPiid // "volume"
|
||||
propMute siidPiid // "mute"
|
||||
propPlayState siidPiid // "playing-state" or "tds-in"
|
||||
propSleepMode siidPiid // "sleep-mode" (intelligent-speaker)
|
||||
propAudioID siidPiid // "audio-id" (intelligent-speaker)
|
||||
|
||||
// intelligent-speaker 服务 siid
|
||||
isSiid int
|
||||
|
||||
// 动作
|
||||
actWakeUp isAction
|
||||
actPlayRadio isAction
|
||||
actPlayText isAction
|
||||
actExecuteTextDirective isAction
|
||||
actPlayMusic isAction
|
||||
|
||||
stateCache SpeakerState
|
||||
}
|
||||
|
||||
// NewSpeaker creates a Speaker control for the given device.
|
||||
func NewSpeaker(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Speaker, error) {
|
||||
d := &speakerDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
dev := d.getMIoTDevice()
|
||||
if dev == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
spec := dev.SpecInstance()
|
||||
if spec == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
|
||||
// 定位 intelligent-speaker 服务
|
||||
var isSvc *miot.MIoTSpecService
|
||||
for _, svc := range spec.Services {
|
||||
if shortName(svc.Type) == "intelligent-speaker" {
|
||||
isSvc = svc
|
||||
d.isSiid = svc.IID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 先用 resolver 解析通用属性
|
||||
resolver := d.getSpecResolver()
|
||||
if resolver == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
|
||||
siid, piid, ok := resolver.find("on")
|
||||
if !ok {
|
||||
return nil, xiaomi.ErrNotSupported
|
||||
}
|
||||
d.propOn = siidPiid{siid, piid, true}
|
||||
|
||||
if s, p, ok := resolver.find("volume"); ok {
|
||||
d.propVolume = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("mute"); ok {
|
||||
d.propMute = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("playing-state"); ok {
|
||||
d.propPlayState = siidPiid{s, p, true}
|
||||
} else if s, p, ok := resolver.find("tds-in"); ok {
|
||||
d.propPlayState = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
// intelligent-speaker 属性
|
||||
if isSvc != nil {
|
||||
for _, p := range isSvc.Properties {
|
||||
switch shortName(p.Type) {
|
||||
case "sleep-mode":
|
||||
d.propSleepMode = siidPiid{d.isSiid, p.PIID, true}
|
||||
case "audio-id":
|
||||
d.propAudioID = siidPiid{d.isSiid, p.PIID, true}
|
||||
}
|
||||
}
|
||||
// 动作
|
||||
for _, a := range isSvc.Actions {
|
||||
switch shortName(a.Type) {
|
||||
case "wake-up":
|
||||
d.actWakeUp = isAction{d.isSiid, a.AIID}
|
||||
case "play-radio":
|
||||
d.actPlayRadio = isAction{d.isSiid, a.AIID}
|
||||
case "play-text":
|
||||
d.actPlayText = isAction{d.isSiid, a.AIID}
|
||||
case "execute-text-directive":
|
||||
d.actExecuteTextDirective = isAction{d.isSiid, a.AIID}
|
||||
case "play-music":
|
||||
d.actPlayMusic = isAction{d.isSiid, a.AIID}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *speakerDevice) TurnOn(ctx context.Context) error {
|
||||
if !d.propOn.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) TurnOff(ctx context.Context) error {
|
||||
if !d.propOn.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) Toggle(ctx context.Context) error {
|
||||
s := d.GetState()
|
||||
if s.On {
|
||||
return d.TurnOff(ctx)
|
||||
}
|
||||
return d.TurnOn(ctx)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) HasVolume() bool { return d.propVolume.valid }
|
||||
func (d *speakerDevice) HasMute() bool { return d.propMute.valid }
|
||||
func (d *speakerDevice) HasPlayControl() bool { return d.propPlayState.valid }
|
||||
|
||||
func (d *speakerDevice) SetVolume(ctx context.Context, pct int) error {
|
||||
if !d.propVolume.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propVolume.siid, d.propVolume.piid, pct)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) GetVolume(ctx context.Context) (int, error) {
|
||||
if !d.propVolume.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propVolume.siid, d.propVolume.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *speakerDevice) SetMute(ctx context.Context, mute bool) error {
|
||||
if !d.propMute.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propMute.siid, d.propMute.piid, mute)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) GetMute(ctx context.Context) (bool, error) {
|
||||
if !d.propMute.valid {
|
||||
return false, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propMute.siid, d.propMute.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *speakerDevice) SetPlayState(ctx context.Context, state int) error {
|
||||
if !d.propPlayState.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propPlayState.siid, d.propPlayState.piid, state)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) GetPlayState(ctx context.Context) (int, error) {
|
||||
if !d.propPlayState.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propPlayState.siid, d.propPlayState.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
|
||||
// ── 智能音箱属性 ──
|
||||
|
||||
func (d *speakerDevice) HasSleepMode() bool { return d.propSleepMode.valid }
|
||||
|
||||
func (d *speakerDevice) SetSleepMode(ctx context.Context, sleep bool) error {
|
||||
if !d.propSleepMode.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propSleepMode.siid, d.propSleepMode.piid, sleep)
|
||||
}
|
||||
|
||||
func (d *speakerDevice) GetSleepMode(ctx context.Context) (bool, error) {
|
||||
if !d.propSleepMode.valid {
|
||||
return false, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propSleepMode.siid, d.propSleepMode.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *speakerDevice) GetAudioID(ctx context.Context) (string, error) {
|
||||
if !d.propAudioID.valid {
|
||||
return "", xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propAudioID.siid, d.propAudioID.piid)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s, ok := prop.Value.(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ── 智能音箱动作 ──
|
||||
|
||||
func (d *speakerDevice) WakeUp(ctx context.Context) error {
|
||||
if d.actWakeUp.siid == 0 {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
_, err := d.Action(ctx, d.actWakeUp.siid, d.actWakeUp.aiid, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *speakerDevice) PlayRadio(ctx context.Context) error {
|
||||
if d.actPlayRadio.siid == 0 {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
_, err := d.Action(ctx, d.actPlayRadio.siid, d.actPlayRadio.aiid, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *speakerDevice) PlayText(ctx context.Context, text string) error {
|
||||
if d.actPlayText.siid == 0 {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
_, err := d.Action(ctx, d.actPlayText.siid, d.actPlayText.aiid, []interface{}{
|
||||
map[string]interface{}{"piid": 1, "value": text},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *speakerDevice) ExecuteTextDirective(ctx context.Context, text string, silent bool) error {
|
||||
if d.actExecuteTextDirective.siid == 0 {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
_, err := d.Action(ctx, d.actExecuteTextDirective.siid, d.actExecuteTextDirective.aiid, []interface{}{
|
||||
map[string]interface{}{"piid": 1, "value": text},
|
||||
map[string]interface{}{"piid": 2, "value": silent},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *speakerDevice) PlayMusic(ctx context.Context) error {
|
||||
if d.actPlayMusic.siid == 0 {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
_, err := d.Action(ctx, d.actPlayMusic.siid, d.actPlayMusic.aiid, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── 订阅 / 状态 ──
|
||||
|
||||
func (d *speakerDevice) OnStateChanged(handler func(state SpeakerState, changed string)) (string, error) {
|
||||
type field struct {
|
||||
siid, piid int
|
||||
name string
|
||||
fill func(*SpeakerState, interface{})
|
||||
}
|
||||
var fields []field
|
||||
add := func(sp siidPiid, name string, fill func(*SpeakerState, interface{})) {
|
||||
if sp.valid {
|
||||
fields = append(fields, field{sp.siid, sp.piid, name, fill})
|
||||
}
|
||||
}
|
||||
add(d.propOn, "on", func(s *SpeakerState, v interface{}) { s.On, _ = v.(bool) })
|
||||
add(d.propVolume, "volume", func(s *SpeakerState, v interface{}) { s.Volume = toInt(v) })
|
||||
add(d.propMute, "mute", func(s *SpeakerState, v interface{}) { s.Mute, _ = v.(bool) })
|
||||
add(d.propPlayState, "play-state", func(s *SpeakerState, v interface{}) { s.PlayState = toInt(v) })
|
||||
add(d.propSleepMode, "sleep-mode", func(s *SpeakerState, v interface{}) { s.SleepMode, _ = v.(bool) })
|
||||
add(d.propAudioID, "audio-id", func(s *SpeakerState, v interface{}) {
|
||||
if str, ok := v.(string); ok {
|
||||
s.AudioID = str
|
||||
}
|
||||
})
|
||||
|
||||
if len(fields) == 0 {
|
||||
return "", xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) {
|
||||
for _, f := range fields {
|
||||
if f.siid == prop.SIID && f.piid == prop.PIID {
|
||||
f.fill(&d.stateCache, prop.Value)
|
||||
handler(d.stateCache, f.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *speakerDevice) GetState() SpeakerState { return d.stateCache }
|
||||
|
||||
func (d *speakerDevice) FetchState(ctx context.Context) (SpeakerState, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
add := func(sp siidPiid) {
|
||||
if sp.valid {
|
||||
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: sp.siid, PIID: sp.piid})
|
||||
}
|
||||
}
|
||||
add(d.propOn)
|
||||
add(d.propVolume)
|
||||
add(d.propMute)
|
||||
add(d.propPlayState)
|
||||
add(d.propSleepMode)
|
||||
add(d.propAudioID)
|
||||
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil {
|
||||
return d.stateCache, err
|
||||
}
|
||||
state := SpeakerState{}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case match(d.propOn, p):
|
||||
state.On, _ = p.Value.(bool)
|
||||
case match(d.propVolume, p):
|
||||
state.Volume = toInt(p.Value)
|
||||
case match(d.propMute, p):
|
||||
state.Mute, _ = p.Value.(bool)
|
||||
case match(d.propPlayState, p):
|
||||
state.PlayState = toInt(p.Value)
|
||||
case match(d.propSleepMode, p):
|
||||
state.SleepMode, _ = p.Value.(bool)
|
||||
case match(d.propAudioID, p):
|
||||
if s, ok := p.Value.(string); ok {
|
||||
state.AudioID = s
|
||||
}
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func match(sp siidPiid, p *xiaomi.PropertyValue) bool {
|
||||
return sp.valid && p.SIID == sp.siid && p.PIID == sp.piid
|
||||
}
|
||||
|
||||
// shortName extracts the short name from a SPEC URN.
|
||||
// E.g., "urn:miot-spec-v2:action:wake-up:..." → "wake-up".
|
||||
func shortName(urn string) string {
|
||||
parts := strings.Split(urn, ":")
|
||||
if len(parts) >= 4 {
|
||||
return parts[3]
|
||||
}
|
||||
return urn
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type Switch interface {
|
||||
IsChannelOn(ctx context.Context, ch int) (bool, error)
|
||||
|
||||
OnStateChanged(handler func(ch int, on bool)) (string, error)
|
||||
GetState() []bool
|
||||
FetchState(ctx context.Context) ([]bool, error)
|
||||
}
|
||||
|
||||
// switchDevice is the concrete Switch implementation.
|
||||
@@ -161,3 +163,24 @@ func (d *switchDevice) channel(ch int) (siidPiid, bool) {
|
||||
}
|
||||
return d.channels[i], true
|
||||
}
|
||||
|
||||
func (d *switchDevice) GetState() []bool { return d.stateCache }
|
||||
func (d *switchDevice) FetchState(ctx context.Context) ([]bool, error) {
|
||||
keys := make([]xiaomi.PropKey, len(d.channels))
|
||||
for i, c := range d.channels {
|
||||
keys[i] = xiaomi.PropKey{DID: d.info.DID, SIID: c.siid, PIID: c.piid}
|
||||
}
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache, err }
|
||||
states := make([]bool, len(d.channels))
|
||||
for i, c := range d.channels {
|
||||
for _, p := range props {
|
||||
if p.SIID == c.siid && p.PIID == c.piid {
|
||||
states[i], _ = p.Value.(bool)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
d.stateCache = states
|
||||
return states, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ type TempHumiditySensor interface {
|
||||
GetHumidity(ctx context.Context) (float64, error)
|
||||
|
||||
OnStateChanged(handler func(temp, humidity float64)) (string, error)
|
||||
GetState() (float64, float64)
|
||||
FetchState(ctx context.Context) (float64, float64, error)
|
||||
}
|
||||
|
||||
type tempHumidityDevice struct {
|
||||
@@ -71,3 +73,21 @@ func (d *tempHumidityDevice) OnStateChanged(handler func(temp, humidity float64)
|
||||
handler(d.stateCache[0], d.stateCache[1])
|
||||
})
|
||||
}
|
||||
|
||||
func (d *tempHumidityDevice) GetState() (float64, float64) { return d.stateCache[0], d.stateCache[1] }
|
||||
func (d *tempHumidityDevice) FetchState(ctx context.Context) (float64, float64, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTemp.siid, PIID: d.propTemp.piid}) }
|
||||
if d.propHumidity.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propHumidity.siid, PIID: d.propHumidity.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache[0], d.stateCache[1], err }
|
||||
state := [2]float64{d.stateCache[0], d.stateCache[1]}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propTemp.valid && p.SIID == d.propTemp.siid && p.PIID == d.propTemp.piid: state[0] = toFloat(p.Value)
|
||||
case d.propHumidity.valid && p.SIID == d.propHumidity.siid && p.PIID == d.propHumidity.piid: state[1] = toFloat(p.Value)
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state[0], state[1], nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ type Thermostat interface {
|
||||
IsHeating(ctx context.Context) (bool, error)
|
||||
|
||||
OnStateChanged(handler func(state HeaterState, changed string)) (string, error)
|
||||
GetState() HeaterState
|
||||
FetchState(ctx context.Context) (HeaterState, error)
|
||||
}
|
||||
|
||||
type thermostatDevice struct {
|
||||
@@ -108,3 +110,23 @@ func (d *thermostatDevice) OnStateChanged(handler func(state HeaterState, change
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *thermostatDevice) GetState() HeaterState { return d.stateCache }
|
||||
func (d *thermostatDevice) FetchState(ctx context.Context) (HeaterState, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) }
|
||||
if d.propTargetTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTargetTemp.siid, PIID: d.propTargetTemp.piid}) }
|
||||
if d.propCurrentTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propCurrentTemp.siid, PIID: d.propCurrentTemp.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache, err }
|
||||
state := HeaterState{}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool)
|
||||
case d.propTargetTemp.valid && p.SIID == d.propTargetTemp.siid && p.PIID == d.propTargetTemp.piid: state.TargetTemp = toFloat(p.Value)
|
||||
case d.propCurrentTemp.valid && p.SIID == d.propCurrentTemp.siid && p.PIID == d.propCurrentTemp.piid: state.CurrentTemp = toFloat(p.Value)
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type Vacuum interface {
|
||||
GetStatus(ctx context.Context) (VacuumStatus, error)
|
||||
|
||||
OnStateChanged(handler func(state VacuumStatus, changed string)) (string, error)
|
||||
GetState() VacuumStatus
|
||||
FetchState(ctx context.Context) (VacuumStatus, error)
|
||||
}
|
||||
|
||||
// VacuumStatus 扫地机运行状态(字段名对齐 SPEC type)
|
||||
@@ -121,3 +123,11 @@ func (d *vacuumDevice) OnStateChanged(handler func(state VacuumStatus, changed s
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) GetState() VacuumStatus { return d.stateCache }
|
||||
func (d *vacuumDevice) FetchState(ctx context.Context) (VacuumStatus, error) {
|
||||
status, err := d.GetStatus(ctx)
|
||||
if err != nil { return d.stateCache, err }
|
||||
d.stateCache = status
|
||||
return status, nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type WaterHeater interface {
|
||||
GetCurrentTemp(ctx context.Context) (float64, error)
|
||||
|
||||
OnStateChanged(handler func(state HeaterState, changed string)) (string, error)
|
||||
GetState() HeaterState
|
||||
FetchState(ctx context.Context) (HeaterState, error)
|
||||
}
|
||||
|
||||
// HeaterState 热水器状态(MQTT 缓存)
|
||||
@@ -100,3 +102,23 @@ func (d *waterHeaterDevice) OnStateChanged(handler func(state HeaterState, chang
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *waterHeaterDevice) GetState() HeaterState { return d.stateCache }
|
||||
func (d *waterHeaterDevice) FetchState(ctx context.Context) (HeaterState, error) {
|
||||
keys := []xiaomi.PropKey{}
|
||||
if d.propOn.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propOn.siid, PIID: d.propOn.piid}) }
|
||||
if d.propTargetTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propTargetTemp.siid, PIID: d.propTargetTemp.piid}) }
|
||||
if d.propCurrentTemp.valid { keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: d.propCurrentTemp.siid, PIID: d.propCurrentTemp.piid}) }
|
||||
props, err := d.client.GetProps(ctx, keys)
|
||||
if err != nil { return d.stateCache, err }
|
||||
state := HeaterState{}
|
||||
for _, p := range props {
|
||||
switch {
|
||||
case d.propOn.valid && p.SIID == d.propOn.siid && p.PIID == d.propOn.piid: state.On, _ = p.Value.(bool)
|
||||
case d.propTargetTemp.valid && p.SIID == d.propTargetTemp.siid && p.PIID == d.propTargetTemp.piid: state.TargetTemp = toFloat(p.Value)
|
||||
case d.propCurrentTemp.valid && p.SIID == d.propCurrentTemp.siid && p.PIID == d.propCurrentTemp.piid: state.CurrentTemp = toFloat(p.Value)
|
||||
}
|
||||
}
|
||||
d.stateCache = state
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
@@ -49,7 +49,7 @@ func main() {
|
||||
log.Printf("共 %d 个家庭", len(homes))
|
||||
|
||||
for _, home := range homes {
|
||||
log.Printf(" 家庭: %s (ID: %s)", home.Name, home.ID)
|
||||
log.Printf(" 家庭: %s (ID: %d)", home.Name, home.ID)
|
||||
log.Printf(" 位置: UID=%s GroupID=%s", home.UID, home.GroupID)
|
||||
log.Printf(" 分享状态: %v", home.ShareState)
|
||||
|
||||
@@ -61,7 +61,7 @@ func main() {
|
||||
log.Printf(" 共 %d 个房间", len(rooms))
|
||||
|
||||
for _, room := range rooms {
|
||||
log.Printf(" 房间: %s (ID: %s)", room.Name, room.ID)
|
||||
log.Printf(" 房间: %s (ID: %d)", room.Name, room.ID)
|
||||
didList, err := client.GetRoomDevices(ctx, home.ID, room.ID)
|
||||
if err != nil {
|
||||
log.Printf(" 获取设备列表失败: %v", err)
|
||||
|
||||
@@ -24,7 +24,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -25,7 +25,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -25,7 +25,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,7 +25,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
@@ -52,13 +52,13 @@ func main() {
|
||||
log.Printf("空调: %s (DID: %s, 型号: %s)", info.Name, info.DID, info.Model)
|
||||
|
||||
on, _ := ac.IsOn(ctx)
|
||||
mode, _ := ac.GetMode(ctx)
|
||||
modeName, _ := ac.GetMode(ctx)
|
||||
targetTemp, _ := ac.GetTargetTemp(ctx)
|
||||
currentTemp, _ := ac.GetCurrentTemp(ctx)
|
||||
fanSpeed, _ := ac.GetFanSpeed(ctx)
|
||||
swing, _ := ac.GetSwing(ctx)
|
||||
fanName, _ := ac.GetFanSpeed(ctx)
|
||||
swingName, _ := ac.GetSwing(ctx)
|
||||
log.Printf("当前状态: on=%v mode=%s target=%.1f current=%.1f fan=%s swing=%s",
|
||||
on, mode, targetTemp, currentTemp, fanSpeed, swing)
|
||||
on, modeName, targetTemp, currentTemp, fanName, swingName)
|
||||
|
||||
log.Println("=== 执行控制: 开 -> 制冷 -> 26度 -> 自动风速 -> 垂直摆风 ===")
|
||||
|
||||
@@ -69,23 +69,23 @@ func main() {
|
||||
}
|
||||
|
||||
log.Println(" 设置制冷模式...")
|
||||
ac.SetMode(ctx, devices.ModeCool)
|
||||
ac.SetMode(ctx, devices.ACModeCool)
|
||||
log.Println(" 设置目标温度 26度...")
|
||||
ac.SetTargetTemp(ctx, 26.0)
|
||||
log.Println(" 设置自动风速...")
|
||||
ac.SetFanSpeed(ctx, devices.SpeedAuto)
|
||||
ac.SetFanSpeed(ctx, devices.ACFanAuto)
|
||||
log.Println(" 设置垂直摆风...")
|
||||
ac.SetSwing(ctx, devices.SwingVertical)
|
||||
ac.SetSwing(ctx, devices.ACSwingVertical)
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
mode, _ = ac.GetMode(ctx)
|
||||
modeName, _ = ac.GetMode(ctx)
|
||||
targetTemp, _ = ac.GetTargetTemp(ctx)
|
||||
currentTemp, _ = ac.GetCurrentTemp(ctx)
|
||||
fanSpeed, _ = ac.GetFanSpeed(ctx)
|
||||
swing, _ = ac.GetSwing(ctx)
|
||||
fanName, _ = ac.GetFanSpeed(ctx)
|
||||
swingName, _ = ac.GetSwing(ctx)
|
||||
log.Printf("更新后状态: mode=%s target=%.1f current=%.1f fan=%s swing=%s",
|
||||
mode, targetTemp, currentTemp, fanSpeed, swing)
|
||||
modeName, targetTemp, currentTemp, fanName, swingName)
|
||||
|
||||
log.Println("设置目标湿度 50...")
|
||||
if err := ac.SetTargetHumidity(ctx, 50); err != nil {
|
||||
|
||||
@@ -25,7 +25,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -24,7 +24,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -29,7 +29,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
@@ -120,7 +120,7 @@ func main() {
|
||||
}
|
||||
mode := "N/A"
|
||||
if m, err := d.GetMode(ctx); err == nil {
|
||||
mode = string(m)
|
||||
mode = m
|
||||
}
|
||||
log.Printf("[%d] %-20s %-25s %-18s %-8s 温度=%s 模式=%s",
|
||||
i, info.Name, info.Model, kind, status, temp, mode)
|
||||
|
||||
@@ -30,7 +30,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -25,7 +25,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -24,7 +24,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -23,7 +23,7 @@ func main() {
|
||||
AuthInfo: xiaomi.AuthInfo{
|
||||
AccessToken: cfg.AuthInfo.AccessToken,
|
||||
RefreshToken: cfg.AuthInfo.RefreshToken,
|
||||
ExpiresIn: cfg.AuthInfo.ExpiresIn,
|
||||
ExpiresIn: int32(cfg.AuthInfo.ExpiresIn),
|
||||
ExpiresTS: cfg.AuthInfo.ExpiresTS,
|
||||
},
|
||||
DataDir: "./data",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 示例 13:多用户模式(bridge 包)
|
||||
//
|
||||
// 演示 Pool + UserBridge 门面,外部只需 import bridge 一个包。
|
||||
// 不直接依赖 xiaomi / devices / miot。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"xiaomihome/bridge"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
type UserCfg struct {
|
||||
UID int64 `json:"uid,string"`
|
||||
UUID string `json:"uuid"`
|
||||
Auth xiaomi.AuthInfo `json:"auth"`
|
||||
DataDir string `json:"data_dir"`
|
||||
HomeIDs []int64 `json:"home_ids,omitempty"`
|
||||
}
|
||||
|
||||
func loadUsers(path string) ([]UserCfg, error) {
|
||||
data, _ := os.ReadFile(path)
|
||||
var users []UserCfg
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
allUsers, err := loadUsers("users.json")
|
||||
if err != nil || len(allUsers) == 0 {
|
||||
log.Fatalf("请创建 users.json: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool := bridge.NewPool()
|
||||
|
||||
for _, u := range allUsers {
|
||||
b, err := pool.Add(ctx, u.UID, u.UUID, u.Auth, u.DataDir, u.HomeIDs)
|
||||
if err != nil {
|
||||
log.Printf("用户 %d 初始化失败: %v", u.UID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 注册全局回调 → 推送到 NATS / WebSocket / 数据库
|
||||
b.OnStateChanged(func(id int64, did, deviceType, changed string, state interface{}) {
|
||||
log.Printf("[%d] %s/%s changed=%s → %+v", id, did, deviceType, changed, state)
|
||||
})
|
||||
|
||||
// 下单 MQTT 订阅
|
||||
b.StartReceiving()
|
||||
|
||||
// 云端拉初始状态
|
||||
b.FetchAllStates(ctx)
|
||||
}
|
||||
|
||||
defer pool.Close()
|
||||
|
||||
log.Printf("\n%d 个用户就绪", len(allUsers))
|
||||
|
||||
// 演示:操作某台空调
|
||||
demoControl(ctx, pool, 2897121707, "2053225728")
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt)
|
||||
<-ch
|
||||
log.Println("退出")
|
||||
}
|
||||
|
||||
func demoControl(ctx context.Context, pool *bridge.Pool, id int64, did string) {
|
||||
b, ok := pool.Get(id)
|
||||
if !ok {
|
||||
log.Printf("id %d 不存在", id)
|
||||
return
|
||||
}
|
||||
|
||||
ac, err := b.AC(did)
|
||||
if err != nil {
|
||||
log.Printf("设备 %s 不是空调: %v", did, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 读缓存
|
||||
state := ac.GetState()
|
||||
fmt.Printf("[demo] 缓存: on=%v mode=%s target=%.1f current=%.1f\n",
|
||||
state.On, state.Mode, state.TargetTemperature, state.Temperature)
|
||||
|
||||
// 云端刷新
|
||||
state2, err := ac.FetchState(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[demo] 云端刷新失败: %v", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("[demo] 云端: on=%v mode=%s target=%.1f current=%.1f\n",
|
||||
state2.On, state2.Mode, state2.TargetTemperature, state2.Temperature)
|
||||
|
||||
// 控制
|
||||
ac.TurnOn(ctx)
|
||||
ac.SetMode(ctx, "cool")
|
||||
ac.SetTargetTemp(ctx, 26)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"uid": "2897121707",
|
||||
"uuid": "0684e27d49b7a234ff1a4d82f8fca234",
|
||||
"data_dir": "./data/2897121707",
|
||||
"auth": {
|
||||
"access_token": "这里填你的token",
|
||||
"refresh_token": "这里填你的refresh_token",
|
||||
"expires_in": 86400,
|
||||
"expires_ts": 1751289600
|
||||
}
|
||||
},
|
||||
{
|
||||
"uid": "another_uid",
|
||||
"uuid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"data_dir": "./data/another_uid",
|
||||
"auth": {
|
||||
"access_token": "",
|
||||
"refresh_token": "",
|
||||
"expires_in": 0,
|
||||
"expires_ts": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,185 @@
|
||||
// 示例 14:多用户业务场景
|
||||
//
|
||||
// 场景:管理后台控制空调 → 状态变化 → 存数据库 → 推 WebSocket
|
||||
// 核心展示 bridge.Pool 用法,DB/WS 只是桩方法。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"xiaomihome/bridge"
|
||||
"xiaomihome/xiaomi"
|
||||
"xiaomihome/xiaomi/devices"
|
||||
)
|
||||
|
||||
// ── 业务桩方法:type-switch 拿到类型化字段(类型来自 bridge 包) ──
|
||||
|
||||
func saveToDB(id int64, did, deviceType, changed string, state interface{}) {
|
||||
// 用 devices 包的常量判断变更字段,deviceType 区分设备类型
|
||||
var propLabel string
|
||||
switch changed {
|
||||
case devices.PropTargetTemperature:
|
||||
propLabel = "目标温度"
|
||||
case devices.PropTemperature:
|
||||
propLabel = "当前温度"
|
||||
case devices.PropBrightness:
|
||||
propLabel = "亮度"
|
||||
default:
|
||||
propLabel = changed
|
||||
}
|
||||
if propLabel != "" {
|
||||
log.Printf("[DB] %s changed=%s(%s)", did, propLabel, changed)
|
||||
}
|
||||
|
||||
// 同类化 state 拿到完整 State 结构体
|
||||
switch s := state.(type) {
|
||||
case bridge.ACState:
|
||||
log.Printf("[DB] 空调 %d/%s changed=%s: on=%v mode=%s target=%.1f current=%.1f",
|
||||
id, did, changed, s.On, s.Mode, s.TargetTemperature, s.Temperature)
|
||||
case bridge.LightState:
|
||||
log.Printf("[DB] 灯 %d/%s changed=%s: on=%v brightness=%d colorTemp=%d",
|
||||
id, did, changed, s.On, s.Brightness, s.ColorTemperature)
|
||||
default:
|
||||
log.Printf("[DB] %d/%s changed=%s: %+v", id, did, changed, s)
|
||||
}
|
||||
}
|
||||
|
||||
func pushWS(id int64, did, deviceType, changed string, state interface{}) {
|
||||
switch s := state.(type) {
|
||||
case bridge.ACState:
|
||||
log.Printf("[WS→%d] 空调 %s changed=%s: on=%v mode=%s target=%.1f current=%.1f",
|
||||
id, did, changed, s.On, s.Mode, s.TargetTemperature, s.Temperature)
|
||||
case bridge.LightState:
|
||||
log.Printf("[WS→%d] 灯 %s changed=%s: on=%v brightness=%d",
|
||||
id, did, changed, s.On, s.Brightness)
|
||||
default:
|
||||
log.Printf("[WS→%d] %s changed=%s: %+v", id, did, changed, s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 用户配置 ──
|
||||
|
||||
type UserCfg struct {
|
||||
UID int64 `json:"uid,string"`
|
||||
UUID string `json:"uuid"`
|
||||
Auth xiaomi.AuthInfo `json:"auth"`
|
||||
DataDir string `json:"data_dir"`
|
||||
HomeIDs []int64 `json:"home_ids,omitempty"`
|
||||
}
|
||||
|
||||
func loadUsers(path string) ([]UserCfg, error) {
|
||||
data, _ := os.ReadFile(path)
|
||||
var users []UserCfg
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// ── 业务初始化 ──
|
||||
|
||||
func initBridge(ctx context.Context, pool *bridge.Pool, cfg UserCfg) error {
|
||||
b, err := pool.Add(ctx, cfg.UID, cfg.UUID, cfg.Auth, cfg.DataDir, cfg.HomeIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 注册回调:MQTT 推送 → DB + WS
|
||||
b.OnStateChanged(func(id int64, did, deviceType, changed string, state interface{}) {
|
||||
saveToDB(id, did, deviceType, changed, state)
|
||||
pushWS(id, did, deviceType, changed, state)
|
||||
})
|
||||
|
||||
b.StartReceiving()
|
||||
log.Printf("[Init] %d: %d 台设备", cfg.UID, len(b.Devices()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 业务操作 ──
|
||||
|
||||
func controlAC(ctx context.Context, pool *bridge.Pool, id int64, did string) error {
|
||||
b, ok := pool.Get(id)
|
||||
if !ok {
|
||||
return fmt.Errorf("id %d 不存在", id)
|
||||
}
|
||||
|
||||
ac, err := b.AC(did)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 读缓存
|
||||
state := ac.GetState()
|
||||
fmt.Printf("\n空调 %s:", did)
|
||||
fmt.Printf(" on=%v mode=%s target=%.1f current=%.1f\n",
|
||||
state.On, state.Mode, state.TargetTemperature, state.Temperature)
|
||||
|
||||
// 云端刷新
|
||||
if s, err := ac.FetchState(ctx); err == nil {
|
||||
fmt.Printf(" 云端: on=%v mode=%s target=%.1f current=%.1f\n",
|
||||
s.On, s.Mode, s.TargetTemperature, s.Temperature)
|
||||
}
|
||||
|
||||
// 控制
|
||||
fmt.Println("\n管理后台操作:开机制冷 26°C")
|
||||
ac.TurnOn(ctx)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
ac.SetMode(ctx, devices.ACModeCool)
|
||||
ac.SetTargetTemp(ctx, 26)
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 最终状态
|
||||
final := ac.GetState()
|
||||
fmt.Printf("操作后: on=%v mode=%s target=%.1f current=%.1f\n",
|
||||
final.On, final.Mode, final.TargetTemperature, final.Temperature)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 主流程 ──
|
||||
|
||||
func main() {
|
||||
allUsers, err := loadUsers("users.json")
|
||||
if err != nil || len(allUsers) == 0 {
|
||||
log.Fatalf("请创建 users.json: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool := bridge.NewPool()
|
||||
|
||||
for _, u := range allUsers {
|
||||
if err := initBridge(ctx, pool, u); err != nil {
|
||||
log.Printf("id %d 失败: %v", u.UID, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("\n%d 个就绪\n", len(allUsers))
|
||||
|
||||
// 找第一台空调演示
|
||||
id := allUsers[0].UID
|
||||
b, ok := pool.Get(id)
|
||||
if !ok || b == nil {
|
||||
log.Fatal("没有设备初始化成功")
|
||||
}
|
||||
var targetDID string
|
||||
for did, typ := range b.Devices() {
|
||||
if typ == "air_conditioner" {
|
||||
targetDID = did
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetDID != "" {
|
||||
controlAC(ctx, pool, id, targetDID)
|
||||
}
|
||||
|
||||
fmt.Println("\nCtrl+C 退出...")
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt)
|
||||
<-ch
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"uid": "2897121707",
|
||||
"uuid": "2882aa541cfc47b78ed178bb42b43302",
|
||||
"data_dir": "./data/2897121707",
|
||||
"auth": {
|
||||
"access_token": "V3_iTf6riwKt698wtjY8tvu1o6kX1Izsid-XHKCJK1RNZXscm3htdWXCV6lUj8nwncsk58pjy3Vmnu93Ggfa1mYUNe-839wtyCAGyND-Q-diRcC4LbVO_tUDyvNXPAN2VknYKfY7tzUGYzEzOzSFq_Feo_vhZsPEJ6oHnY58s2xtqQOxPAJRa8B3mZrggi2TNkm",
|
||||
"refresh_token": "R3_9Z2sYiFbiOWSRTTUBmVOTlSXnyqOpIpY2IJ0oadNpJqrcrPPKKbvGrADaadudLjaF3VIYJTY2mM9HgXlnr3eoRQCbNETOoEsAS04zxJa9K8YH8pkJT9voDDTPEuhpXdATv5SOrzyZgDxn-gJKMUHzxotwAyfaTlxuA6Dtv80uoyYZuHresy-zqfiiABVCwUVY0saSa1fayXCeUYzRfEgng",
|
||||
"expires_ts": 1783080922,
|
||||
"expires_in": 259200
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
// 示例 15:OAuth 授权流程(无需 import miot)
|
||||
//
|
||||
// go run . 首次运行自动创建 user.json,打开授权链接完成授权后粘贴 code。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
type userFile struct {
|
||||
UID string `json:"uid"`
|
||||
UUID string `json:"uuid"`
|
||||
Auth struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresTS int64 `json:"expires_ts"`
|
||||
} `json:"auth"`
|
||||
DataDir string `json:"data_dir"`
|
||||
}
|
||||
|
||||
const filePath = "user.json"
|
||||
|
||||
func load() *userFile {
|
||||
data, _ := os.ReadFile(filePath)
|
||||
var u userFile
|
||||
json.Unmarshal(data, &u)
|
||||
if u.UUID == "" {
|
||||
u.UUID = newUUID()
|
||||
save(&u)
|
||||
}
|
||||
return &u
|
||||
}
|
||||
|
||||
func save(u *userFile) {
|
||||
data, _ := json.MarshalIndent(u, "", " ")
|
||||
os.WriteFile(filePath, data, 0600)
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
u := load()
|
||||
|
||||
if u.Auth.AccessToken != "" {
|
||||
fmt.Println("已完成授权")
|
||||
fmt.Printf("UID: %s\n", u.UID)
|
||||
fmt.Printf("AccessToken: %s...\n", u.Auth.AccessToken[:min(20, len(u.Auth.AccessToken))])
|
||||
fmt.Printf("RefreshToken: %s...\n", u.Auth.RefreshToken[:min(20, len(u.Auth.RefreshToken))])
|
||||
return
|
||||
}
|
||||
|
||||
authURL := xiaomi.AuthURL(xiaomi.Config{UUID: u.UUID})
|
||||
fmt.Println("========== OAuth 授权 ==========")
|
||||
fmt.Println("请打开以下链接完成授权,然后把地址栏中的 code 粘贴回来:")
|
||||
fmt.Println()
|
||||
fmt.Println(" ", authURL)
|
||||
fmt.Println()
|
||||
fmt.Print("输入 code: ")
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan()
|
||||
code := scanner.Text()
|
||||
if code == "" {
|
||||
log.Fatal("未输入 code")
|
||||
}
|
||||
|
||||
result, err := xiaomi.ExchangeCode(ctx, xiaomi.Config{UUID: u.UUID}, code)
|
||||
if err != nil {
|
||||
log.Fatal("ExchangeCode 失败:", err)
|
||||
}
|
||||
|
||||
u.UID = result.UID
|
||||
u.Auth.AccessToken = result.AccessToken
|
||||
u.Auth.RefreshToken = result.RefreshToken
|
||||
u.Auth.ExpiresTS = result.ExpiresTS
|
||||
u.DataDir = "./data/" + u.UID
|
||||
save(u)
|
||||
|
||||
fmt.Println("\n=== Token 已保存 ===")
|
||||
fmt.Printf("UID: %s\n", u.UID)
|
||||
fmt.Printf("AccessToken: %s...\n", u.Auth.AccessToken[:min(20, len(u.Auth.AccessToken))])
|
||||
fmt.Printf("RefreshToken: %s...\n", u.Auth.RefreshToken[:min(20, len(u.Auth.RefreshToken))])
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"uid": "",
|
||||
"uuid": "2882aa541cfc47b78ed178bb42b43302",
|
||||
"auth": {
|
||||
"access_token": "V3_iTf6riwKt698wtjY8tvu1o6kX1Izsid-XHKCJK1RNZXscm3htdWXCV6lUj8nwncsk58pjy3Vmnu93Ggfa1mYUNe-839wtyCAGyND-Q-diRcC4LbVO_tUDyvNXPAN2VknYKfY7tzUGYzEzOzSFq_Feo_vhZsPEJ6oHnY58s2xtqQOxPAJRa8B3mZrggi2TNkm",
|
||||
"refresh_token": "R3_9Z2sYiFbiOWSRTTUBmVOTlSXnyqOpIpY2IJ0oadNpJqrcrPPKKbvGrADaadudLjaF3VIYJTY2mM9HgXlnr3eoRQCbNETOoEsAS04zxJa9K8YH8pkJT9voDDTPEuhpXdATv5SOrzyZgDxn-gJKMUHzxotwAyfaTlxuA6Dtv80uoyYZuHresy-zqfiiABVCwUVY0saSa1fayXCeUYzRfEgng",
|
||||
"expires_ts": 1783080922
|
||||
},
|
||||
"data_dir": "./data/"
|
||||
}
|
||||
+11
-7
@@ -1,6 +1,9 @@
|
||||
package xiaomi
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GetHomeList 获取所有家庭列表
|
||||
// 合并自有家庭 (home_list) 和共享家庭 (share_home_list)
|
||||
@@ -34,7 +37,7 @@ func (c *Client) GetHomeList(ctx context.Context) ([]*HomeInfo, error) {
|
||||
}
|
||||
|
||||
// GetHome 获取指定家庭详情(含房间)
|
||||
func (c *Client) GetHome(ctx context.Context, homeID string) (*HomeInfo, error) {
|
||||
func (c *Client) GetHome(ctx context.Context, homeID int64) (*HomeInfo, error) {
|
||||
homes, err := c.GetHomeList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,7 +53,7 @@ func (c *Client) GetHome(ctx context.Context, homeID string) (*HomeInfo, error)
|
||||
}
|
||||
|
||||
// GetRooms 获取指定家庭的所有房间
|
||||
func (c *Client) GetRooms(ctx context.Context, homeID string) ([]*RoomInfo, error) {
|
||||
func (c *Client) GetRooms(ctx context.Context, homeID int64) ([]*RoomInfo, error) {
|
||||
home, err := c.GetHome(ctx, homeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -64,7 +67,7 @@ func (c *Client) GetRooms(ctx context.Context, homeID string) ([]*RoomInfo, erro
|
||||
|
||||
// GetRoomDevices 获取指定房间的设备 DID 列表。
|
||||
// 优先使用 homeroom API 返回的 dids,为空时从设备列表按 room_id 过滤。
|
||||
func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID string) ([]string, error) {
|
||||
func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID int64) ([]string, error) {
|
||||
rooms, err := c.GetRooms(ctx, homeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -80,7 +83,7 @@ func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID string) ([]s
|
||||
}
|
||||
|
||||
// 退化:从设备列表按 room_id 过滤
|
||||
devices, err := c.GetDevices(ctx, FilterByRoom(roomID))
|
||||
devices, err := c.GetDevices(ctx, FilterByRoom(strconv.FormatInt(roomID, 10)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -94,7 +97,7 @@ func (c *Client) GetRoomDevices(ctx context.Context, homeID, roomID string) ([]s
|
||||
// parseHomeInfo 从 miot 返回的 map 解析 HomeInfo(含房间列表)
|
||||
func parseHomeInfo(m map[string]interface{}, shared bool) *HomeInfo {
|
||||
h := &HomeInfo{
|
||||
ID: strVal(m, "home_id"),
|
||||
ID: int64FromStr(strVal(m, "home_id")),
|
||||
Name: strVal(m, "home_name"),
|
||||
UID: strVal(m, "uid"),
|
||||
GroupID: strVal(m, "group_id"),
|
||||
@@ -118,8 +121,9 @@ func parseHomeInfo(m map[string]interface{}, shared bool) *HomeInfo {
|
||||
if roomMap, ok := m["room_info"].(map[string]interface{}); ok {
|
||||
for _, v := range roomMap {
|
||||
if rm, ok := v.(map[string]interface{}); ok {
|
||||
roomID := int64FromStr(strVal(rm, "room_id"))
|
||||
h.Rooms = append(h.Rooms, RoomInfo{
|
||||
ID: strVal(rm, "room_id"),
|
||||
ID: roomID,
|
||||
Name: strVal(rm, "room_name"),
|
||||
HomeID: h.ID,
|
||||
DidList: strSliceVal(rm, "dids"),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# token 管理重构方案
|
||||
|
||||
> 2026-06-29
|
||||
|
||||
## 原则
|
||||
|
||||
- mloT 层方法签名不变(`Init()` / `NewMIoTClient` 等)
|
||||
- xiaomi 层只改 `NewClient` 签名(架构必须改),其他全部不动
|
||||
|
||||
---
|
||||
|
||||
## 修改清单
|
||||
|
||||
### 1. miot: `Init()` 内部逻辑修复(签名不变)
|
||||
|
||||
当前 Init 从 entryData 取 token,storage 白读。改为优先读 storage,首次没数据才回退 entryData 并写入 storage:
|
||||
|
||||
```go
|
||||
func (c *MIoTClient) Init() error {
|
||||
// 尝试从 storage 取 token
|
||||
if c.storage != nil {
|
||||
authInfo := c.storage.LoadUserConfig(c.uid, c.cloudServer, nil)
|
||||
if authInfo == nil {
|
||||
// 首次:从 entryData 取,写入 storage
|
||||
c.storage.UpdateUserConfig(c.uid, c.cloudServer, map[string]interface{}{
|
||||
"access_token": c.entryData["access_token"],
|
||||
"refresh_token": c.entryData["refresh_token"],
|
||||
"expires_ts": c.entryData["expires_ts"],
|
||||
}, true)
|
||||
} else if t, ok := authInfo["access_token"].(string); ok && t != "" {
|
||||
c.entryData["access_token"] = t // 覆盖给后续 HTTP/MIPS 创建用
|
||||
}
|
||||
}
|
||||
// 后续现有逻辑不变...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. miot: 新增 `RefreshAuthInfo()`(不破坏旧签名)
|
||||
|
||||
```go
|
||||
// RefreshAuthInfo 手动触发 token 刷新(对齐 Python refresh_oauth_info_async)
|
||||
// 返回 {access_token, refresh_token, expires_ts}
|
||||
func (c *MIoTClient) RefreshAuthInfo() (map[string]interface{}, error)
|
||||
```
|
||||
|
||||
内部复用现有的 `refreshAuthInfo` 逻辑,改为同步返回结果。
|
||||
|
||||
### 3. xiaomi: Config 改签名(唯一需要调用方改的地方)
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
AccessToken string
|
||||
RefreshToken string // 可选,用于自动/手动刷新
|
||||
}
|
||||
```
|
||||
|
||||
`NewClient(ctx, Config)` 替代 `NewClient(inner, httpClient)`。内部把 `AccessToken` / `RefreshToken` 放进 entryData,Init 自动写入 storage。
|
||||
|
||||
### 4. xiaomi: 新增 `RefreshToken()`(其他方法不动)
|
||||
|
||||
```go
|
||||
// RefreshToken 手动刷新 token,返回新值让调用方存库
|
||||
func (c *Client) RefreshToken(ctx context.Context) (*TokenResult, error)
|
||||
|
||||
type TokenResult struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresTS int64
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 清理 entryData 无用字段
|
||||
|
||||
miot 中去掉 `country_code`,xiaomi 中不再出现。
|
||||
|
||||
---
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 层 | 方法 | 改动 | 调用方影响 |
|
||||
|----|------|------|------------|
|
||||
| miot | `Init()` | 内部逻辑 | 无(签名不变) |
|
||||
| miot | `RefreshAuthInfo()` | 新增 | 无(新增方法) |
|
||||
| xiaomi | `NewClient` | 签名改为 `(ctx, Config)` | **调用方需改** |
|
||||
| xiaomi | `Close()` | 新增 | 无 |
|
||||
| xiaomi | `RefreshToken()` | 新增 | 无 |
|
||||
| xiaomi | 其他所有方法 | 不动 | 无 |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
```go
|
||||
client, err := xiaomi.NewClient(ctx, xiaomi.Config{
|
||||
AccessToken: token,
|
||||
RefreshToken: refreshToken,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 手动刷新
|
||||
result, err := client.RefreshToken(ctx)
|
||||
db.Save(userID, result.AccessToken, result.RefreshToken)
|
||||
```
|
||||
+13
-15
@@ -14,20 +14,28 @@ import (
|
||||
type TokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ExpiresIn int32 `json:"expires_in"`
|
||||
ExpiresTS int64 `json:"expires_ts"`
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
// AuthURL 生成小米 OAuth 授权页面 URL。
|
||||
func AuthURL(cfg Config) string {
|
||||
oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, "", "cn", generateUUID())
|
||||
uuid := cfg.UUID
|
||||
if uuid == "" { uuid = generateUUID() }
|
||||
redirectURL := cfg.RedirectURL
|
||||
if redirectURL == "" { redirectURL = miot.OAUTH_REDIRECT_URL }
|
||||
oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, redirectURL, "cn", uuid)
|
||||
return oa.GenAuthURL("", "", nil, false)
|
||||
}
|
||||
|
||||
// ExchangeCode 用 authorization code 交换 access token。
|
||||
func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, error) {
|
||||
oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, "", "cn", generateUUID())
|
||||
uuid := cfg.UUID
|
||||
if uuid == "" { uuid = generateUUID() }
|
||||
redirectURL := cfg.RedirectURL
|
||||
if redirectURL == "" { redirectURL = miot.OAUTH_REDIRECT_URL }
|
||||
oa := miot.NewMIoTOauthClient(miot.OAUTH2_CLIENT_ID, redirectURL, "cn", uuid)
|
||||
result, err := oa.GetAccessToken(code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("xiaomi: exchange token: %w", err)
|
||||
@@ -36,7 +44,7 @@ func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, e
|
||||
return &TokenResult{
|
||||
AccessToken: strVal(result, "access_token"),
|
||||
RefreshToken: strVal(result, "refresh_token"),
|
||||
ExpiresIn: expiresIn,
|
||||
ExpiresIn: int32(expiresIn),
|
||||
ExpiresTS: time.Now().Unix() + int64(expiresIn),
|
||||
UID: strVal(result, "uid"),
|
||||
}, nil
|
||||
@@ -45,15 +53,5 @@ func ExchangeCode(ctx context.Context, cfg Config, code string) (*TokenResult, e
|
||||
func generateUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
buf := make([]byte, 36)
|
||||
hex.Encode(buf[:8], b[:4])
|
||||
buf[8] = '-'
|
||||
hex.Encode(buf[9:13], b[4:6])
|
||||
buf[13] = '-'
|
||||
hex.Encode(buf[14:18], b[6:8])
|
||||
buf[18] = '-'
|
||||
hex.Encode(buf[19:23], b[8:10])
|
||||
buf[23] = '-'
|
||||
hex.Encode(buf[24:], b[10:16])
|
||||
return string(buf)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
+5
-4
@@ -14,7 +14,7 @@ type UserInfo struct {
|
||||
|
||||
// HomeInfo 家庭信息
|
||||
type HomeInfo struct {
|
||||
ID string `json:"id"`
|
||||
ID int64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
UID string `json:"uid"`
|
||||
GroupID string `json:"group_id"`
|
||||
@@ -29,10 +29,10 @@ type HomeInfo struct {
|
||||
|
||||
// RoomInfo 房间信息
|
||||
type RoomInfo struct {
|
||||
ID string `json:"id"`
|
||||
ID int64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
HomeID string `json:"home_id"`
|
||||
DidList []string `json:"did_list"` // 房间内设备 DID
|
||||
HomeID int64 `json:"home_id,string"`
|
||||
DidList []string `json:"did_list"`
|
||||
}
|
||||
|
||||
// DeviceInfo 设备信息(替代 map[string]interface{})
|
||||
@@ -155,6 +155,7 @@ const (
|
||||
KindVacuum Kind = "vacuum"
|
||||
KindWaterHeater Kind = "water_heater"
|
||||
KindThermostat Kind = "thermostat"
|
||||
KindSpeaker Kind = "speaker"
|
||||
KindSensor Kind = "sensor"
|
||||
KindUnknown Kind = "unknown"
|
||||
)
|
||||
|
||||
@@ -73,19 +73,19 @@ func TestDeviceInfoEmpty(t *testing.T) {
|
||||
|
||||
func TestHomeInfoParsing(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"home_id": "home001",
|
||||
"home_id": "123",
|
||||
"home_name": "我的家",
|
||||
"uid": "user001",
|
||||
"group_id": "group001",
|
||||
"dids": []interface{}{"device1", "device2"},
|
||||
"room_info": map[string]interface{}{
|
||||
"room001": map[string]interface{}{
|
||||
"room_id": "room001",
|
||||
"1": map[string]interface{}{
|
||||
"room_id": "1",
|
||||
"room_name": "客厅",
|
||||
"dids": []interface{}{"device1"},
|
||||
},
|
||||
"room002": map[string]interface{}{
|
||||
"room_id": "room002",
|
||||
"2": map[string]interface{}{
|
||||
"room_id": "2",
|
||||
"room_name": "卧室",
|
||||
"dids": []interface{}{"device2"},
|
||||
},
|
||||
@@ -94,8 +94,8 @@ func TestHomeInfoParsing(t *testing.T) {
|
||||
|
||||
home := parseHomeInfo(data, true)
|
||||
|
||||
if home.ID != "home001" {
|
||||
t.Errorf("ID = %q", home.ID)
|
||||
if home.ID != 123 {
|
||||
t.Errorf("ID = %d", home.ID)
|
||||
}
|
||||
if home.Name != "我的家" {
|
||||
t.Errorf("Name = %q", home.Name)
|
||||
@@ -109,8 +109,8 @@ func TestHomeInfoParsing(t *testing.T) {
|
||||
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 r.HomeID != 123 {
|
||||
t.Errorf("Room %s HomeID = %d, want 123", r.Name, r.HomeID)
|
||||
}
|
||||
}
|
||||
if !names["客厅"] || !names["卧室"] {
|
||||
|
||||
Reference in New Issue
Block a user