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:
+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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user