package devices import ( "context" "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) display := item.Desc if display == "" { display = item.Name } key := strings.ToLower(display) vm.val2name[v] = display // 原始描述做展示 vm.name2val[key] = v // 小写 key 做匹配 vm.names = append(vm.names, display) } 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[strings.ToLower(name)] return v, ok } func (vm *valMapper) allNames() []string { if vm == nil { return nil } return vm.names } // ========== 空调模式/风速/摆风常量 ========== const ( 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" ) // ========== 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 string) error GetMode(ctx context.Context) (string, 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 string) error GetFanSpeed(ctx context.Context) (string, error) // 摆风控制 SetSwing(ctx context.Context, mode string) error GetSwing(ctx context.Context) (string, 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) // 功率 HasPower() bool GetElectricPower(ctx context.Context) (float64, error) // 当前电功率(W) GetPowerConsumption(ctx context.Context) (float64, error) // 累计耗电量(kWh) // 订阅 OnPropsChanged(handler func(props ACProps, changed string)) (string, error) GetProps() ACProps FetchProps(ctx context.Context) (ACProps, error) } // ACProps 空调完整状态(字段名对齐 SPEC type) type ACProps struct { 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"` ElectricPower float64 `json:"electricPower"` // 当前电功率(W),来自 electric-power PowerConsumption float64 `json:"powerConsumption"` // 累计耗电量(kWh),来自 power-consumption } 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" propElectricPower siidPiid // "electric-power" propPowerConsumption siidPiid // "power-consumption" modeMap *valMapper // SPEC value→name for mode fanMap *valMapper // SPEC value→name for fan-level swingMap *valMapper // SPEC value→name for swing propsCache ACProps // MQTT 推送维护的本地缓存 } // 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), } 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 { 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} d.modeMap = newValMapper(findValList("mode")) } 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} 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} } 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} } if s, p, ok := resolver.find("electric-power"); ok { d.propElectricPower = siidPiid{s, p, true} } if s, p, ok := resolver.find("power-consumption"); ok { d.propPowerConsumption = 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) HasPower() bool { return d.propElectricPower.valid || d.propPowerConsumption.valid } func (d *acDevice) SetMode(ctx context.Context, mode string) error { if !d.propMode.valid { return xiaomi.ErrNotSupported } v, ok := d.modeMap.val(mode) if !ok { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, v) } func (d *acDevice) GetMode(ctx context.Context) (string, error) { if !d.propMode.valid { return ACModeAuto, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid) if err != nil { return ACModeAuto, err } return d.modeMap.name(toInt(prop.Value)), 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 string) error { if !d.propFanSpeed.valid { return xiaomi.ErrNotSupported } v, ok := d.fanMap.val(speed) if !ok { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid, v) } func (d *acDevice) GetFanSpeed(ctx context.Context) (string, error) { if !d.propFanSpeed.valid { return ACFanAuto, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propFanSpeed.siid, d.propFanSpeed.piid) if err != nil { return ACFanAuto, err } return d.fanMap.name(toInt(prop.Value)), nil } func (d *acDevice) SetSwing(ctx context.Context, mode string) error { if !d.propSwing.valid { return xiaomi.ErrNotSupported } v, ok := d.swingMap.val(mode) if !ok { return xiaomi.ErrNotSupported } return d.SetProp(ctx, d.propSwing.siid, d.propSwing.piid, v) } func (d *acDevice) GetSwing(ctx context.Context) (string, error) { if !d.propSwing.valid { return ACSwingOff, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propSwing.siid, d.propSwing.piid) if err != nil { return ACSwingOff, err } return d.swingMap.name(toInt(prop.Value)), 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) GetElectricPower(ctx context.Context) (float64, error) { if !d.propElectricPower.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propElectricPower.siid, d.propElectricPower.piid) if err != nil { return 0, err } return toFloat(prop.Value), nil } func (d *acDevice) GetPowerConsumption(ctx context.Context) (float64, error) { if !d.propPowerConsumption.valid { return 0, xiaomi.ErrNotSupported } prop, err := d.GetProp(ctx, d.propPowerConsumption.siid, d.propPowerConsumption.piid) if err != nil { return 0, err } return toFloat(prop.Value), nil } // propField maps a SPEC short name to a siid/piid pair and a cache-update function. type propField struct { siid, piid int name string fill func(*ACProps, interface{}) valType byte // 'b'=bool, 'i'=int, 'f'=float, 's'=string } func (d *acDevice) OnPropsChanged(handler func(props ACProps, changed string)) (string, error) { fields := d.propFields() if len(fields) == 0 { return "", xiaomi.ErrNotSupported } // 每个属性精确订阅 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.propsCache, prop.Value) handler(d.propsCache, 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, specType string, fill func(*ACProps, interface{})) { if sp.valid { 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", specFormat(r, "on"), func(s *ACProps, v interface{}) { s.On, _ = v.(bool) }) add(d.propMode, "mode", specFormat(r, "mode"), func(s *ACProps, v interface{}) { s.Mode = d.modeMap.name(toInt(v)) }) add(d.propTargetTemp, "target-temperature", specFormat(r, "target-temperature"), func(s *ACProps, v interface{}) { s.TargetTemperature = toFloat(v) }) add(d.propCurrentTemp, "temperature", specFormat(r, "temperature"), func(s *ACProps, v interface{}) { s.Temperature = toFloat(v) }) add(d.propFanSpeed, "fan-level", specFormat(r, "fan-level"), func(s *ACProps, v interface{}) { s.FanLevel = d.fanMap.name(toInt(v)) }) add(d.propSwing, "swing", specFormat(r, "swing-mode"), func(s *ACProps, v interface{}) { s.Swing = d.swingMap.name(toInt(v)) }) add(d.propTargetHumidity, "target-humidity", specFormat(r, "target-humidity"), func(s *ACProps, v interface{}) { s.TargetHumidity = int(toFloat(v)) }) add(d.propCurrentHumidity, "relative-humidity", specFormat(r, "relative-humidity"), func(s *ACProps, v interface{}) { s.CurrentHumidity = int(toFloat(v)) }) add(d.propAuxHeat, "heater", specFormat(r, "heater"), func(s *ACProps, v interface{}) { s.AuxHeat, _ = v.(bool) }) add(d.propEco, "eco", specFormat(r, "eco"), func(s *ACProps, v interface{}) { s.Eco, _ = v.(bool) }) add(d.propElectricPower, "electric-power", specFormat(r, "electric-power"), func(s *ACProps, v interface{}) { s.ElectricPower = toFloat(v) }) add(d.propPowerConsumption, "power-consumption", specFormat(r, "power-consumption"), func(s *ACProps, v interface{}) { s.PowerConsumption = toFloat(v) }) 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) { case float64: return n case float32: return float64(n) case int: return float64(n) case int64: return float64(n) } return 0 } // toBool converts an interface{} to bool. 支持 bool 及数值(0/1)类型。 func toBool(v interface{}) bool { switch n := v.(type) { case bool: return n case float64: return n != 0 case float32: return n != 0 case int: return n != 0 case int64: return n != 0 } return false } func (d *acDevice) GetProps() ACProps { return d.propsCache } func (d *acDevice) FetchProps(ctx context.Context) (ACProps, 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.propsCache, err } result := ACProps{} for _, p := range props { for _, f := range fields { if f.siid == p.SIID && f.piid == p.PIID { f.fill(&result, p.Value) break } } } d.propsCache = result return result, nil }