- 新增 xiaomi/examples/ 下 8 个示例程序(用户/家庭查询、设备列表、开关/灯光/空调控制、属性订阅、高级过滤分类、SPEC 解析) - miot_client_sub.go: 重构 SubProp/SubEvent,使用 buildPropTopic/buildEventTopic 支持通配符订阅(siid/piid=0 → +),并修复锁顺序问题(将 RequestRefreshProp 移到 Lock 外) - spec_parser.go: 新增 downloadSpecFile 方法,本地 SPEC 文件缺失时自动从 miot-spec.org 下载 - ARCH_PLAN.md: 架构设计从 Proposed 更新为 Accepted(v1.0→v1.1),补充设备分类/工厂/SPEC 映射等模块设计 - xiaomi/: 新增 miot 上层强类型封装模块,包含 Client 主入口、用户/家庭/设备 API、属性读写、动作调用、订阅通知,以及 devices/ 设备控制抽象(Switch/Light/AirConditioner/Fan/Cover/Humidifier/Vacuum/WaterHeater/Thermostat)和 specs/ SPEC 查询辅助 - 更多 xiaomi 示例(风扇/窗帘/传感器控制)
460 lines
11 KiB
Go
460 lines
11 KiB
Go
package devices
|
||
|
||
import (
|
||
"context"
|
||
"xiaomihome/xiaomi"
|
||
)
|
||
|
||
// ========== 空调模式/风速/摆风常量 ==========
|
||
|
||
// ACMode 空调模式
|
||
type ACMode string
|
||
|
||
const (
|
||
ModeCool ACMode = "cool"
|
||
ModeHeat ACMode = "heat"
|
||
ModeFan ACMode = "fan"
|
||
ModeDry ACMode = "dry"
|
||
ModeAuto ACMode = "auto"
|
||
)
|
||
|
||
// 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 空调统一控制接口
|
||
type AirConditioner interface {
|
||
Device
|
||
TurnOn(ctx context.Context) error
|
||
TurnOff(ctx context.Context) error
|
||
IsOn(ctx context.Context) (bool, error)
|
||
|
||
// 能力查询
|
||
HasFanSpeed() bool
|
||
HasSwing() bool
|
||
HasHumidity() bool
|
||
HasAuxHeat() bool
|
||
HasEco() bool
|
||
|
||
// 模式控制
|
||
SetMode(ctx context.Context, mode ACMode) error
|
||
GetMode(ctx context.Context) (ACMode, error)
|
||
|
||
// 温度控制
|
||
SetTargetTemp(ctx context.Context, celsius float64) error
|
||
GetTargetTemp(ctx context.Context) (float64, error)
|
||
GetCurrentTemp(ctx context.Context) (float64, error) // 室内温度
|
||
|
||
// 风速控制
|
||
SetFanSpeed(ctx context.Context, speed FanSpeed) error
|
||
GetFanSpeed(ctx context.Context) (FanSpeed, error)
|
||
|
||
// 摆风控制
|
||
SetSwing(ctx context.Context, mode SwingMode) error
|
||
GetSwing(ctx context.Context) (SwingMode, error)
|
||
|
||
// 湿度
|
||
SetTargetHumidity(ctx context.Context, pct int) error
|
||
GetTargetHumidity(ctx context.Context) (int, error)
|
||
GetCurrentHumidity(ctx context.Context) (int, error)
|
||
|
||
// 电辅热
|
||
SetAuxHeat(ctx context.Context, on bool) error
|
||
GetAuxHeat(ctx context.Context) (bool, error)
|
||
|
||
// 节能
|
||
SetEco(ctx context.Context, on bool) error
|
||
GetEco(ctx context.Context) (bool, error)
|
||
|
||
// 订阅
|
||
OnStateChanged(handler func(state ACState)) (string, error)
|
||
}
|
||
|
||
// ACState 空调完整状态
|
||
type ACState struct {
|
||
Power bool `json:"power"`
|
||
Mode ACMode `json:"mode"`
|
||
TargetTemp float64 `json:"target_temp"`
|
||
CurrentTemp float64 `json:"current_temp"`
|
||
TargetHumidity int `json:"target_humidity"`
|
||
CurrentHumidity int `json:"current_humidity"`
|
||
FanSpeed FanSpeed `json:"fan_speed"`
|
||
Swing SwingMode `json:"swing"`
|
||
AuxHeat bool `json:"aux_heat"`
|
||
Eco bool `json:"eco"`
|
||
}
|
||
|
||
type acDevice struct {
|
||
BaseDevice
|
||
propOn siidPiid // "on"
|
||
propMode siidPiid // "mode"
|
||
propTargetTemp siidPiid // "target-temperature"
|
||
propCurrentTemp siidPiid // "temperature"
|
||
propFanSpeed siidPiid // "fan-level"
|
||
propSwing siidPiid // "swing-mode" or "horizontal-swing" or "vertical-swing"
|
||
propTargetHumidity siidPiid // "target-humidity"
|
||
propCurrentHumidity siidPiid // "relative-humidity"
|
||
propAuxHeat siidPiid // "aux-heat" or "heater"
|
||
propEco siidPiid // "eco"
|
||
}
|
||
|
||
// NewAirConditioner creates an AirConditioner control for the given device.
|
||
func NewAirConditioner(client *xiaomi.Client, info *xiaomi.DeviceInfo) (AirConditioner, error) {
|
||
d := &acDevice{
|
||
BaseDevice: NewBaseDevice(client, info),
|
||
}
|
||
|
||
resolver := d.getSpecResolver()
|
||
if resolver == nil {
|
||
return nil, xiaomi.ErrNoSPEC
|
||
}
|
||
|
||
// "on" is required
|
||
siid, piid, ok := resolver.find("on")
|
||
if !ok {
|
||
return nil, xiaomi.ErrNotSupported
|
||
}
|
||
d.propOn = siidPiid{siid, piid, true}
|
||
|
||
// Optional properties — each independently resolved
|
||
if s, p, ok := resolver.find("mode"); ok {
|
||
d.propMode = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("target-temperature"); ok {
|
||
d.propTargetTemp = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("temperature"); ok {
|
||
d.propCurrentTemp = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("fan-level"); ok {
|
||
d.propFanSpeed = siidPiid{s, p, true}
|
||
}
|
||
// Try multiple swing mode type names
|
||
if s, p, ok := resolver.find("swing-mode"); ok {
|
||
d.propSwing = siidPiid{s, p, true}
|
||
} else if s, p, ok := resolver.find("horizontal-swing"); ok {
|
||
d.propSwing = siidPiid{s, p, true}
|
||
} else if s, p, ok := resolver.find("vertical-swing"); ok {
|
||
d.propSwing = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("target-humidity"); ok {
|
||
d.propTargetHumidity = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("relative-humidity"); ok {
|
||
d.propCurrentHumidity = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("heater"); ok {
|
||
d.propAuxHeat = siidPiid{s, p, true}
|
||
} else if s, p, ok := resolver.find("aux-heat"); ok {
|
||
d.propAuxHeat = siidPiid{s, p, true}
|
||
}
|
||
if s, p, ok := resolver.find("eco"); ok {
|
||
d.propEco = siidPiid{s, p, true}
|
||
}
|
||
|
||
return d, nil
|
||
}
|
||
|
||
func (d *acDevice) 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 *acDevice) 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 *acDevice) IsOn(ctx context.Context) (bool, error) {
|
||
if !d.propOn.valid {
|
||
return false, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if b, ok := prop.Value.(bool); ok {
|
||
return b, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func (d *acDevice) HasFanSpeed() bool { return d.propFanSpeed.valid }
|
||
func (d *acDevice) HasSwing() bool { return d.propSwing.valid }
|
||
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 {
|
||
if !d.propMode.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
val, ok := modeReverseMap[mode]
|
||
if !ok {
|
||
val = 4 // default auto
|
||
}
|
||
return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, val)
|
||
}
|
||
|
||
func (d *acDevice) GetMode(ctx context.Context) (ACMode, error) {
|
||
if !d.propMode.valid {
|
||
return ModeAuto, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid)
|
||
if err != nil {
|
||
return ModeAuto, err
|
||
}
|
||
v := toInt(prop.Value)
|
||
if m, ok := modeValueMap[v]; ok {
|
||
return m, nil
|
||
}
|
||
return ModeAuto, nil
|
||
}
|
||
|
||
func (d *acDevice) SetTargetTemp(ctx context.Context, celsius float64) error {
|
||
if !d.propTargetTemp.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
return d.SetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid, celsius)
|
||
}
|
||
|
||
func (d *acDevice) GetTargetTemp(ctx context.Context) (float64, error) {
|
||
if !d.propTargetTemp.valid {
|
||
return 0, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propTargetTemp.siid, d.propTargetTemp.piid)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return toFloat(prop.Value), nil
|
||
}
|
||
|
||
func (d *acDevice) GetCurrentTemp(ctx context.Context) (float64, error) {
|
||
if !d.propCurrentTemp.valid {
|
||
return 0, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propCurrentTemp.siid, d.propCurrentTemp.piid)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return toFloat(prop.Value), nil
|
||
}
|
||
|
||
func (d *acDevice) SetFanSpeed(ctx context.Context, speed FanSpeed) error {
|
||
if !d.propFanSpeed.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
val, ok := fanSpeedReverseMap[speed]
|
||
if !ok {
|
||
val = 3 // default auto
|
||
}
|
||
return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, val)
|
||
}
|
||
|
||
func (d *acDevice) GetFanSpeed(ctx context.Context) (FanSpeed, error) {
|
||
if !d.propFanSpeed.valid {
|
||
return SpeedAuto, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid)
|
||
if err != nil {
|
||
return SpeedAuto, err
|
||
}
|
||
v := toInt(prop.Value)
|
||
if s, ok := fanSpeedValueMap[v]; ok {
|
||
return s, nil
|
||
}
|
||
return SpeedAuto, nil
|
||
}
|
||
|
||
func (d *acDevice) SetSwing(ctx context.Context, mode SwingMode) error {
|
||
if !d.propSwing.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
val, ok := swingReverseMap[mode]
|
||
if !ok {
|
||
val = 0
|
||
}
|
||
return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, val)
|
||
}
|
||
|
||
func (d *acDevice) GetSwing(ctx context.Context) (SwingMode, error) {
|
||
if !d.propSwing.valid {
|
||
return SwingOff, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propSwing.siid, d.propSwing.piid)
|
||
if err != nil {
|
||
return SwingOff, err
|
||
}
|
||
v := toInt(prop.Value)
|
||
if s, ok := swingValueMap[v]; ok {
|
||
return s, nil
|
||
}
|
||
return SwingOff, nil
|
||
}
|
||
|
||
func (d *acDevice) SetTargetHumidity(ctx context.Context, pct int) error {
|
||
if !d.propTargetHumidity.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
return d.SetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid, pct)
|
||
}
|
||
|
||
func (d *acDevice) GetTargetHumidity(ctx context.Context) (int, error) {
|
||
if !d.propTargetHumidity.valid {
|
||
return 0, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propTargetHumidity.siid, d.propTargetHumidity.piid)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return toInt(prop.Value), nil
|
||
}
|
||
|
||
func (d *acDevice) GetCurrentHumidity(ctx context.Context) (int, error) {
|
||
if !d.propCurrentHumidity.valid {
|
||
return 0, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propCurrentHumidity.siid, d.propCurrentHumidity.piid)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return toInt(prop.Value), nil
|
||
}
|
||
|
||
func (d *acDevice) SetAuxHeat(ctx context.Context, on bool) error {
|
||
if !d.propAuxHeat.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
return d.SetProp(ctx, d.propAuxHeat.siid, d.propAuxHeat.piid, on)
|
||
}
|
||
|
||
func (d *acDevice) GetAuxHeat(ctx context.Context) (bool, error) {
|
||
if !d.propAuxHeat.valid {
|
||
return false, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propAuxHeat.siid, d.propAuxHeat.piid)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if b, ok := prop.Value.(bool); ok {
|
||
return b, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func (d *acDevice) SetEco(ctx context.Context, on bool) error {
|
||
if !d.propEco.valid {
|
||
return xiaomi.ErrNotSupported
|
||
}
|
||
return d.SetProp(ctx, d.propEco.siid, d.propEco.piid, on)
|
||
}
|
||
|
||
func (d *acDevice) GetEco(ctx context.Context) (bool, error) {
|
||
if !d.propEco.valid {
|
||
return false, xiaomi.ErrNotSupported
|
||
}
|
||
prop, err := d.GetProp(ctx, d.propEco.siid, d.propEco.piid)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if b, ok := prop.Value.(bool); ok {
|
||
return b, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func (d *acDevice) OnStateChanged(handler func(state ACState)) (string, error) {
|
||
if !d.propOn.valid {
|
||
return "", xiaomi.ErrNotSupported
|
||
}
|
||
return d.SubProp(d.propOn.siid, 0, func(did string, prop *xiaomi.PropertyValue) {
|
||
state := ACState{}
|
||
if b, ok := prop.Value.(bool); ok {
|
||
state.Power = b
|
||
}
|
||
handler(state)
|
||
})
|
||
}
|
||
|
||
// toFloat converts an interface{} to float64.
|
||
func toFloat(v interface{}) float64 {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return n
|
||
case float32:
|
||
return float64(n)
|
||
case int:
|
||
return float64(n)
|
||
case int64:
|
||
return float64(n)
|
||
}
|
||
return 0
|
||
}
|