chore: 提交高层 xiaomi 封装库及示例
- 新增 xiaomi/examples/ 下 8 个示例程序(用户/家庭查询、设备列表、开关/灯光/空调控制、属性订阅、高级过滤分类、SPEC 解析) - miot_client_sub.go: 重构 SubProp/SubEvent,使用 buildPropTopic/buildEventTopic 支持通配符订阅(siid/piid=0 → +),并修复锁顺序问题(将 RequestRefreshProp 移到 Lock 外) - spec_parser.go: 新增 downloadSpecFile 方法,本地 SPEC 文件缺失时自动从 miot-spec.org 下载 - ARCH_PLAN.md: 架构设计从 Proposed 更新为 Accepted(v1.0→v1.1),补充设备分类/工厂/SPEC 映射等模块设计 - xiaomi/: 新增 miot 上层强类型封装模块,包含 Client 主入口、用户/家庭/设备 API、属性读写、动作调用、订阅通知,以及 devices/ 设备控制抽象(Switch/Light/AirConditioner/Fan/Cover/Humidifier/Vacuum/WaterHeater/Thermostat)和 specs/ SPEC 查询辅助 - 更多 xiaomi 示例(风扇/窗帘/传感器控制)
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package devices provides strongly-typed device control abstractions for
|
||||
// Xiaomi smart home devices. Each device type defines an interface that hides
|
||||
// siid/piid details behind semantic methods like SetTargetTemp() and SetMode().
|
||||
//
|
||||
// The package uses SPEC property resolution to automatically discover the
|
||||
// correct siid/piid mappings for each device model, enabling cross-model
|
||||
// compatibility without hardcoded property IDs.
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"xiaomihome/miot"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Device is the common interface shared by all device control types.
|
||||
// All device kinds (Switch, Light, AirConditioner, etc.) embed this interface.
|
||||
type Device interface {
|
||||
// DID returns the device's unique identifier.
|
||||
DID() string
|
||||
// Name returns the device's display name.
|
||||
Name() string
|
||||
// Model returns the device's model identifier.
|
||||
Model() string
|
||||
// Online returns whether the device is currently online.
|
||||
Online() bool
|
||||
// Info returns the full device information.
|
||||
Info() *xiaomi.DeviceInfo
|
||||
}
|
||||
|
||||
// BaseDevice provides common functionality shared by all device types.
|
||||
// Each device type embeds BaseDevice and extends it with type-specific methods.
|
||||
// BaseDevice implements the Device interface.
|
||||
type BaseDevice struct {
|
||||
client *xiaomi.Client
|
||||
info *xiaomi.DeviceInfo
|
||||
miotDev *miot.MIoTDevice
|
||||
resolver *specResolver // cached SPEC resolver (created on demand)
|
||||
}
|
||||
|
||||
// NewBaseDevice creates a new BaseDevice instance.
|
||||
func NewBaseDevice(client *xiaomi.Client, info *xiaomi.DeviceInfo) BaseDevice {
|
||||
return BaseDevice{
|
||||
client: client,
|
||||
info: info,
|
||||
}
|
||||
}
|
||||
|
||||
// DID returns the device's unique identifier.
|
||||
func (d *BaseDevice) DID() string {
|
||||
return d.info.DID
|
||||
}
|
||||
|
||||
// Name returns the device's display name.
|
||||
func (d *BaseDevice) Name() string {
|
||||
return d.info.Name
|
||||
}
|
||||
|
||||
// Model returns the device's model identifier.
|
||||
func (d *BaseDevice) Model() string {
|
||||
return d.info.Model
|
||||
}
|
||||
|
||||
// Online returns whether the device is currently online.
|
||||
func (d *BaseDevice) Online() bool {
|
||||
return d.info.Online
|
||||
}
|
||||
|
||||
// Info returns the full device information.
|
||||
func (d *BaseDevice) Info() *xiaomi.DeviceInfo {
|
||||
return d.info
|
||||
}
|
||||
|
||||
// GetProp reads a single property value from the device.
|
||||
func (d *BaseDevice) GetProp(ctx context.Context, siid, piid int) (*xiaomi.PropertyValue, error) {
|
||||
return d.client.GetProp(ctx, d.info.DID, siid, piid)
|
||||
}
|
||||
|
||||
// SetProp writes a single property value to the device.
|
||||
func (d *BaseDevice) SetProp(ctx context.Context, siid, piid int, value interface{}) error {
|
||||
return d.client.SetProp(ctx, d.info.DID, siid, piid, value)
|
||||
}
|
||||
|
||||
// Action invokes a device action.
|
||||
func (d *BaseDevice) Action(ctx context.Context, siid, aiid int, params []interface{}) (*xiaomi.ActionResult, error) {
|
||||
return d.client.Action(ctx, d.info.DID, siid, aiid, params)
|
||||
}
|
||||
|
||||
// SubProp subscribes to property change notifications.
|
||||
func (d *BaseDevice) SubProp(siid, piid int, handler xiaomi.PropHandler) (string, error) {
|
||||
return d.client.SubProp(d.info.DID, siid, piid, handler)
|
||||
}
|
||||
|
||||
// SubEvent subscribes to event notifications.
|
||||
func (d *BaseDevice) SubEvent(siid, eiid int, handler xiaomi.EventHandler) (string, error) {
|
||||
return d.client.SubEvent(d.info.DID, siid, eiid, handler)
|
||||
}
|
||||
|
||||
// SubDeviceState subscribes to online/offline state changes.
|
||||
func (d *BaseDevice) SubDeviceState(handler xiaomi.DeviceStateHandler) error {
|
||||
return d.client.SubDeviceState(d.info.DID, handler)
|
||||
}
|
||||
|
||||
// getMIoTDevice returns the underlying MIoTDevice (lazy-loaded).
|
||||
func (d *BaseDevice) getMIoTDevice() *miot.MIoTDevice {
|
||||
if d.miotDev == nil {
|
||||
d.miotDev = d.client.GetMIoTDevice(d.info.DID)
|
||||
}
|
||||
return d.miotDev
|
||||
}
|
||||
|
||||
// getSpecResolver returns a SPEC property resolver (lazy-loaded).
|
||||
func (d *BaseDevice) getSpecResolver() *specResolver {
|
||||
if d.resolver == nil {
|
||||
dev := d.getMIoTDevice()
|
||||
if dev != nil {
|
||||
d.resolver = newSpecResolver(dev.SpecInstance())
|
||||
}
|
||||
}
|
||||
return d.resolver
|
||||
}
|
||||
|
||||
// specResolver is a thin wrapper to avoid importing specs package in every device file.
|
||||
type specResolver struct {
|
||||
byType map[string]*miot.MIoTSpecProperty
|
||||
}
|
||||
|
||||
func newSpecResolver(spec *miot.MIoTSpecInstance) *specResolver {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
r := &specResolver{
|
||||
byType: make(map[string]*miot.MIoTSpecProperty),
|
||||
}
|
||||
for _, svc := range spec.Services {
|
||||
for i := range svc.Properties {
|
||||
prop := svc.Properties[i]
|
||||
name := propertyShortName(prop.Type)
|
||||
if name != "" {
|
||||
if _, exists := r.byType[name]; !exists {
|
||||
r.byType[name] = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// propertyShortName extracts the short name from a SPEC property URN.
|
||||
// E.g., "urn:miot-spec-v2:property:on:00000006:..." → "on".
|
||||
func propertyShortName(urn string) string {
|
||||
parts := strings.Split(urn, ":")
|
||||
if len(parts) >= 4 && parts[2] == "property" {
|
||||
return parts[3]
|
||||
}
|
||||
return urn
|
||||
}
|
||||
|
||||
func (r *specResolver) find(propType string) (siid, piid int, found bool) {
|
||||
if r == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if prop, ok := r.byType[propType]; ok {
|
||||
return prop.SIID, prop.PIID, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Cover 窗帘/晾衣架统一控制接口
|
||||
type Cover interface {
|
||||
Device
|
||||
Open(ctx context.Context) error
|
||||
Close(ctx context.Context) error
|
||||
Stop(ctx context.Context) error
|
||||
|
||||
SetPosition(ctx context.Context, pct int) error // 0=关, 100=全开
|
||||
GetPosition(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type coverDevice struct {
|
||||
BaseDevice
|
||||
propMotor siidPiid // "motor-control" — open/close/stop
|
||||
propPosition siidPiid // "current-position"
|
||||
}
|
||||
|
||||
const (
|
||||
coverMotorOpen = 1
|
||||
coverMotorClose = 0
|
||||
coverMotorStop = 2
|
||||
)
|
||||
|
||||
// NewCover creates a Cover control for the given device.
|
||||
func NewCover(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Cover, error) {
|
||||
d := &coverDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
resolver := d.getSpecResolver()
|
||||
if resolver == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
|
||||
siid, piid, ok := resolver.find("motor-control")
|
||||
if !ok {
|
||||
return nil, xiaomi.ErrNotSupported
|
||||
}
|
||||
d.propMotor = siidPiid{siid, piid, true}
|
||||
|
||||
if s, p, ok := resolver.find("current-position"); ok {
|
||||
d.propPosition = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *coverDevice) Open(ctx context.Context) error {
|
||||
if !d.propMotor.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorOpen)
|
||||
}
|
||||
|
||||
func (d *coverDevice) Close(ctx context.Context) error {
|
||||
if !d.propMotor.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorClose)
|
||||
}
|
||||
|
||||
func (d *coverDevice) Stop(ctx context.Context) error {
|
||||
if !d.propMotor.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propMotor.siid, d.propMotor.piid, coverMotorStop)
|
||||
}
|
||||
|
||||
func (d *coverDevice) SetPosition(ctx context.Context, pct int) error {
|
||||
if !d.propPosition.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propPosition.siid, d.propPosition.piid, pct)
|
||||
}
|
||||
|
||||
func (d *coverDevice) GetPosition(ctx context.Context) (int, error) {
|
||||
if !d.propPosition.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propPosition.siid, d.propPosition.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
func TestToInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
want int
|
||||
}{
|
||||
{"float64", float64(42), 42},
|
||||
{"int", int(42), 42},
|
||||
{"int64", int64(42), 42},
|
||||
{"uint8", uint8(100), 100},
|
||||
{"uint16", uint16(1000), 1000},
|
||||
{"nil", nil, 0},
|
||||
{"string", "hello", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := toInt(tt.value)
|
||||
if got != tt.want {
|
||||
t.Errorf("toInt(%v) = %d, want %d", tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
want float64
|
||||
}{
|
||||
{"float64", float64(26.5), 26.5},
|
||||
{"float32", float32(30.0), 30.0},
|
||||
{"int", int(25), 25.0},
|
||||
{"int64", int64(20), 20.0},
|
||||
{"string", "26.5", 0.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := toFloat(tt.value)
|
||||
if got != tt.want {
|
||||
t.Errorf("toFloat(%v) = %f, want %f", tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchURN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
urn string
|
||||
types []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"air-conditioner",
|
||||
"urn:miot-spec-v2:device:air-conditioner:0000A004:xiaomi-c1:1",
|
||||
[]string{"air-conditioner"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"light",
|
||||
"urn:miot-spec-v2:device:light:0000A001:philips-bulb:1",
|
||||
[]string{"light"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"switch outlet",
|
||||
"urn:miot-spec-v2:device:outlet:0000A002:xiaomi-plug:1",
|
||||
[]string{"switch", "outlet"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"fan",
|
||||
"urn:miot-spec-v2:device:fan:0000A003:xiaomi-fan:1",
|
||||
[]string{"fan"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"curtain",
|
||||
"urn:miot-spec-v2:device:curtain:0000A005:aqara-c1:1",
|
||||
[]string{"curtain"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"humidifier",
|
||||
"urn:miot-spec-v2:device:humidifier:0000A006:xiaomi-h1:1",
|
||||
[]string{"humidifier"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"vacuum",
|
||||
"urn:miot-spec-v2:device:vacuum:0000A007:roborock-s5:1",
|
||||
[]string{"vacuum"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"water-heater",
|
||||
"urn:miot-spec-v2:device:water-heater:0000A008:xiaomi-wh1:1",
|
||||
[]string{"water-heater"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"heater thermostat",
|
||||
"urn:miot-spec-v2:device:heater:0000A009:xiaomi-h1:1",
|
||||
[]string{"heater"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"no match",
|
||||
"urn:miot-spec-v2:device:unknown:0000A099:test:1",
|
||||
[]string{"air-conditioner", "light", "switch"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"empty URN",
|
||||
"",
|
||||
[]string{"light"},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchURN(tt.urn, tt.types...)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchURN(%q, %v) = %v, want %v", tt.urn, tt.types, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryNilClientPanic(t *testing.T) {
|
||||
// Create with a nil client should panic (calling methods on nil client)
|
||||
// This is a documentation test noting expected behavior
|
||||
t.Skip("requires real or mock client")
|
||||
}
|
||||
|
||||
func TestCreateNilInfo(t *testing.T) {
|
||||
client := &xiaomi.Client{}
|
||||
result, err := Create(client, nil)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result, got %v", result)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("expected error for nil info")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Create creates the appropriate device control interface for the given device.
|
||||
// It uses the device's URN (SPEC type) to determine which control interface to return.
|
||||
//
|
||||
// Returns:
|
||||
// - Switch, Light, AirConditioner, Fan, Cover, Humidifier, Vacuum,
|
||||
// WaterHeater, or Thermostat depending on the device type
|
||||
// - nil, nil if the device type is not recognized (no error, just unsupported)
|
||||
// - an error if there was a problem creating the control
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// info, _ := client.GetDevice(ctx, "some-did")
|
||||
// ctrl, err := devices.Create(client, info)
|
||||
// switch d := ctrl.(type) {
|
||||
// case devices.Switch:
|
||||
// d.TurnOn(ctx)
|
||||
// case devices.Light:
|
||||
// d.SetBrightness(ctx, 80)
|
||||
// }
|
||||
func Create(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, error) {
|
||||
return createImpl(client, info)
|
||||
}
|
||||
|
||||
// NewDevice creates the appropriate device control interface, returning the
|
||||
// common Device interface. Use type switch to access type-specific methods:
|
||||
//
|
||||
// dev, err := devices.NewDevice(client, info)
|
||||
// switch d := dev.(type) {
|
||||
// case devices.Switch:
|
||||
// d.TurnOn(ctx)
|
||||
// case devices.Light:
|
||||
// d.SetBrightness(ctx, 80)
|
||||
// }
|
||||
func NewDevice(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Device, error) {
|
||||
ctrl, err := createImpl(client, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctrl == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return ctrl.(Device), nil
|
||||
}
|
||||
|
||||
// createImpl is the internal implementation shared by Create and NewDevice.
|
||||
func createImpl(client *xiaomi.Client, info *xiaomi.DeviceInfo) (interface{}, error) {
|
||||
if info == nil {
|
||||
return nil, xiaomi.ErrDeviceNotFound
|
||||
}
|
||||
|
||||
urn := info.URN
|
||||
|
||||
// Determine device type from URN and try constructors in order
|
||||
var ctrl interface{}
|
||||
var err error
|
||||
|
||||
// URN-based routing
|
||||
// miot-spec-v2: air-conditioner → urn:miot-spec-v2:device:air-conditioner:...
|
||||
// miot-spec-v2: light → urn:miot-spec-v2:device:light:...
|
||||
// etc.
|
||||
switch {
|
||||
case matchURN(urn, "air-conditioner", "airer", "air-condition-outlet", "air-condition-eco", "ac-partner"):
|
||||
ctrl, err = NewAirConditioner(client, info)
|
||||
case matchURN(urn, "light", "lightbulb", "night-light", "ceiling-light"):
|
||||
ctrl, err = NewLight(client, info)
|
||||
case matchURN(urn, "switch", "outlet", "powerstrip", "toggle-switch", "wireless-switch", "curtain-switch"):
|
||||
ctrl, err = NewSwitch(client, info)
|
||||
case matchURN(urn, "fan", "ceiling-fan", "ventilator", "air-fresh", "air-purifier"):
|
||||
ctrl, err = NewFan(client, info)
|
||||
case matchURN(urn, "curtain", "airer-rack", "airer"):
|
||||
ctrl, err = NewCover(client, info)
|
||||
case matchURN(urn, "humidifier", "dehumidifier", "humidifier-dehumidifier"):
|
||||
ctrl, err = NewHumidifier(client, info)
|
||||
case matchURN(urn, "vacuum", "sweeper", "robot-cleaner"):
|
||||
ctrl, err = NewVacuum(client, info)
|
||||
case matchURN(urn, "water-heater", "water-cooler", "kettle"):
|
||||
ctrl, err = NewWaterHeater(client, info)
|
||||
case matchURN(urn, "heater", "electric-blanket", "thermostat"):
|
||||
ctrl, err = NewThermostat(client, info)
|
||||
case matchURN(urn, "sensor-occupy", "motion-sensor", "magnet"):
|
||||
ctrl, err = NewOccupancySensor(client, info)
|
||||
case matchURN(urn, "sensor-temp", "sensor-humidity", "sensor-ht", "sensor-smoke", "sensor-gasm", "illumination"):
|
||||
ctrl, err = NewTempHumiditySensor(client, info)
|
||||
default:
|
||||
// Not a supported device type — not an error, just returns nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ctrl, nil
|
||||
}
|
||||
|
||||
// matchURN checks if the device URN contains any of the given type strings.
|
||||
func matchURN(urn string, types ...string) bool {
|
||||
for _, t := range types {
|
||||
if len(urn) >= len(t) {
|
||||
// Simple substring match — URN format: urn:miot-spec-v2:device:TYPE:...
|
||||
for i := 0; i <= len(urn)-len(t); i++ {
|
||||
if urn[i:i+len(t)] == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Fan 风扇统一控制接口
|
||||
type Fan interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
IsOn(ctx context.Context) (bool, error)
|
||||
|
||||
SetFanLevel(ctx context.Context, level int) error // 0=off, 1-4 档位
|
||||
GetFanLevel(ctx context.Context) (int, error)
|
||||
|
||||
SetOscillation(ctx context.Context, on bool) error
|
||||
IsOscillating(ctx context.Context) (bool, error)
|
||||
|
||||
SetNaturalWind(ctx context.Context, on bool) error
|
||||
IsNaturalWind(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type fanDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on"
|
||||
propFanLevel siidPiid // "fan-level"
|
||||
propOscillation siidPiid // "horizontal-swing" or "oscillation"
|
||||
propNaturalWind siidPiid // "mode" (natural wind mode)
|
||||
}
|
||||
|
||||
// NewFan creates a Fan control for the given device.
|
||||
func NewFan(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Fan, error) {
|
||||
d := &fanDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
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("fan-level"); ok {
|
||||
d.propFanLevel = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("horizontal-swing"); ok {
|
||||
d.propOscillation = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("mode"); ok {
|
||||
d.propNaturalWind = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *fanDevice) 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 *fanDevice) 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 *fanDevice) 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 *fanDevice) SetFanLevel(ctx context.Context, level int) error {
|
||||
if !d.propFanLevel.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid, level)
|
||||
}
|
||||
|
||||
func (d *fanDevice) GetFanLevel(ctx context.Context) (int, error) {
|
||||
if !d.propFanLevel.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *fanDevice) SetOscillation(ctx context.Context, on bool) error {
|
||||
if !d.propOscillation.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propOscillation.siid, d.propOscillation.piid, on)
|
||||
}
|
||||
|
||||
func (d *fanDevice) IsOscillating(ctx context.Context) (bool, error) {
|
||||
if !d.propOscillation.valid {
|
||||
return false, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propOscillation.siid, d.propOscillation.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *fanDevice) SetNaturalWind(ctx context.Context, on bool) error {
|
||||
if !d.propNaturalWind.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
mode := 0 // normal
|
||||
if on {
|
||||
mode = 1 // natural wind
|
||||
}
|
||||
return d.SetProp(ctx, d.propNaturalWind.siid, d.propNaturalWind.piid, mode)
|
||||
}
|
||||
|
||||
func (d *fanDevice) IsNaturalWind(ctx context.Context) (bool, error) {
|
||||
if !d.propNaturalWind.valid {
|
||||
return false, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propNaturalWind.siid, d.propNaturalWind.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return toInt(prop.Value) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Humidifier 加湿器/除湿机统一控制接口
|
||||
type Humidifier interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
IsOn(ctx context.Context) (bool, error)
|
||||
|
||||
SetTargetHumidity(ctx context.Context, pct int) error
|
||||
GetTargetHumidity(ctx context.Context) (int, error)
|
||||
|
||||
SetFanLevel(ctx context.Context, level int) error
|
||||
GetFanLevel(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type humidifierDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on"
|
||||
propTargetHumidity siidPiid // "target-humidity"
|
||||
propFanLevel siidPiid // "fan-level"
|
||||
}
|
||||
|
||||
// NewHumidifier creates a Humidifier control for the given device.
|
||||
func NewHumidifier(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Humidifier, error) {
|
||||
d := &humidifierDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
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("target-humidity"); ok {
|
||||
d.propTargetHumidity = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("fan-level"); ok {
|
||||
d.propFanLevel = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *humidifierDevice) 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 *humidifierDevice) 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 *humidifierDevice) 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 *humidifierDevice) 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 *humidifierDevice) 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 *humidifierDevice) SetFanLevel(ctx context.Context, level int) error {
|
||||
if !d.propFanLevel.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid, level)
|
||||
}
|
||||
|
||||
func (d *humidifierDevice) GetFanLevel(ctx context.Context) (int, error) {
|
||||
if !d.propFanLevel.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Light 灯光统一控制接口
|
||||
type Light interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
Toggle(ctx context.Context) error
|
||||
|
||||
// 能力查询
|
||||
HasBrightness() bool
|
||||
HasColorTemp() bool
|
||||
HasColor() bool
|
||||
|
||||
// 亮度 (1-100)
|
||||
SetBrightness(ctx context.Context, pct int) error
|
||||
GetBrightness(ctx context.Context) (int, error)
|
||||
|
||||
// 色温 (2700-6500K)
|
||||
SetColorTemp(ctx context.Context, kelvin int) error
|
||||
GetColorTemp(ctx context.Context) (int, error)
|
||||
|
||||
// 颜色 (0-255)
|
||||
SetColor(ctx context.Context, r, g, b int) error
|
||||
GetColor(ctx context.Context) (r, g, b int, err error)
|
||||
|
||||
OnStateChanged(handler func(state LightState)) (string, error)
|
||||
}
|
||||
|
||||
// LightState 灯光完整状态
|
||||
type LightState struct {
|
||||
Power bool `json:"power"`
|
||||
Brightness int `json:"brightness"`
|
||||
ColorTemp int `json:"color_temp"`
|
||||
ColorRGB [3]int `json:"color_rgb"`
|
||||
}
|
||||
|
||||
type lightDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on"
|
||||
propBrightness siidPiid // "brightness"
|
||||
propColorTemp siidPiid // "color-temperature"
|
||||
propColor siidPiid // "color"
|
||||
}
|
||||
|
||||
// NewLight creates a Light control for the given device.
|
||||
func NewLight(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Light, error) {
|
||||
d := &lightDevice{
|
||||
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
|
||||
if s, p, ok := resolver.find("brightness"); ok {
|
||||
d.propBrightness = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("color-temperature"); ok {
|
||||
d.propColorTemp = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("color"); ok {
|
||||
d.propColor = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *lightDevice) 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 *lightDevice) 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 *lightDevice) Toggle(ctx context.Context) error {
|
||||
on, err := d.isOn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if on {
|
||||
return d.TurnOff(ctx)
|
||||
}
|
||||
return d.TurnOn(ctx)
|
||||
}
|
||||
|
||||
func (d *lightDevice) isOn(ctx context.Context) (bool, error) {
|
||||
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 *lightDevice) HasBrightness() bool { return d.propBrightness.valid }
|
||||
func (d *lightDevice) HasColorTemp() bool { return d.propColorTemp.valid }
|
||||
func (d *lightDevice) HasColor() bool { return d.propColor.valid }
|
||||
|
||||
func (d *lightDevice) SetBrightness(ctx context.Context, pct int) error {
|
||||
if !d.propBrightness.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propBrightness.siid, d.propBrightness.piid, pct)
|
||||
}
|
||||
|
||||
func (d *lightDevice) GetBrightness(ctx context.Context) (int, error) {
|
||||
if !d.propBrightness.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propBrightness.siid, d.propBrightness.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *lightDevice) SetColorTemp(ctx context.Context, kelvin int) error {
|
||||
if !d.propColorTemp.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propColorTemp.siid, d.propColorTemp.piid, kelvin)
|
||||
}
|
||||
|
||||
func (d *lightDevice) GetColorTemp(ctx context.Context) (int, error) {
|
||||
if !d.propColorTemp.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propColorTemp.siid, d.propColorTemp.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *lightDevice) SetColor(ctx context.Context, r, g, b int) error {
|
||||
if !d.propColor.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
colorVal := (r << 16) | (g << 8) | b
|
||||
return d.SetProp(ctx, d.propColor.siid, d.propColor.piid, colorVal)
|
||||
}
|
||||
|
||||
func (d *lightDevice) GetColor(ctx context.Context) (r, g, b int, err error) {
|
||||
if !d.propColor.valid {
|
||||
return 0, 0, 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propColor.siid, d.propColor.piid)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
val := toInt(prop.Value)
|
||||
r = (val >> 16) & 0xFF
|
||||
g = (val >> 8) & 0xFF
|
||||
b = val & 0xFF
|
||||
return r, g, b, nil
|
||||
}
|
||||
|
||||
func (d *lightDevice) OnStateChanged(handler func(state LightState)) (string, error) {
|
||||
if !d.propOn.valid {
|
||||
return "", xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SubProp(d.propOn.siid, d.propOn.piid, func(did string, prop *xiaomi.PropertyValue) {
|
||||
state := LightState{}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
state.Power = b
|
||||
}
|
||||
handler(state)
|
||||
})
|
||||
}
|
||||
|
||||
// toInt converts an interface{} value to int.
|
||||
func toInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case uint8:
|
||||
return int(n)
|
||||
case uint16:
|
||||
return int(n)
|
||||
case uint32:
|
||||
return int(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// OccupancySensor 人在传感器读取接口(只读)。
|
||||
type OccupancySensor interface {
|
||||
Device
|
||||
|
||||
// IsOccupied 是否有人。
|
||||
IsOccupied(ctx context.Context) (bool, error)
|
||||
|
||||
// GetIlluminance 环境光照度(lux),部分传感器支持。
|
||||
GetIlluminance(ctx context.Context) (float64, error)
|
||||
}
|
||||
|
||||
// NewOccupancySensor 创建人在传感器。失败返回错误(含无 SPEC / 无相关属性)。
|
||||
func NewOccupancySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (OccupancySensor, error) {
|
||||
if info == nil {
|
||||
return nil, xiaomi.ErrDeviceNotFound
|
||||
}
|
||||
|
||||
miotDev := client.GetMIoTDevice(info.DID)
|
||||
if miotDev == nil {
|
||||
return nil, fmt.Errorf("%w: device not initialized", xiaomi.ErrDeviceNotFound)
|
||||
}
|
||||
spec := miotDev.SpecInstance()
|
||||
if spec == nil {
|
||||
return nil, fmt.Errorf("%w: device has no SPEC information", xiaomi.ErrNotSupported)
|
||||
}
|
||||
|
||||
d := &occupancyDevice{
|
||||
BaseDevice: BaseDevice{client: client, info: info},
|
||||
}
|
||||
|
||||
for _, svc := range spec.Services {
|
||||
for _, prop := range svc.Properties {
|
||||
switch propertyShortName(prop.Type) {
|
||||
case "occupancy":
|
||||
if !d.propOccupancy.valid {
|
||||
d.propOccupancy = siidPiid{svc.IID, prop.PIID, true}
|
||||
}
|
||||
case "illumination":
|
||||
if !d.propIlluminance.valid {
|
||||
d.propIlluminance = siidPiid{svc.IID, prop.PIID, true}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !d.propOccupancy.valid {
|
||||
return nil, fmt.Errorf("%w: no occupancy property in SPEC", xiaomi.ErrNotSupported)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
type occupancyDevice struct {
|
||||
BaseDevice
|
||||
propOccupancy siidPiid
|
||||
propIlluminance siidPiid
|
||||
}
|
||||
|
||||
func (d *occupancyDevice) IsOccupied(ctx context.Context) (bool, error) {
|
||||
prop, err := d.GetProp(ctx, d.propOccupancy.siid, d.propOccupancy.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *occupancyDevice) GetIlluminance(ctx context.Context) (float64, error) {
|
||||
if !d.propIlluminance.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propIlluminance.siid, d.propIlluminance.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toFloat(prop.Value), nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Switch 开关统一控制接口。
|
||||
// 单通道:TurnOn/TurnOff/Toggle/IsOn 操作唯一通道。
|
||||
// 多通道:ChannelCount > 1 时,TurnOn 等操作通道 1,或使用带 channel 参数的方法。
|
||||
type Switch interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
Toggle(ctx context.Context) error
|
||||
IsOn(ctx context.Context) (bool, error)
|
||||
|
||||
// 多通道开关支持(单通道时自动回退到上述方法)
|
||||
ChannelCount() int
|
||||
TurnOnChannel(ctx context.Context, ch int) error
|
||||
TurnOffChannel(ctx context.Context, ch int) error
|
||||
ToggleChannel(ctx context.Context, ch int) error
|
||||
IsChannelOn(ctx context.Context, ch int) (bool, error)
|
||||
|
||||
OnStateChanged(handler func(on bool)) (string, error)
|
||||
}
|
||||
|
||||
// switchDevice is the concrete Switch implementation.
|
||||
type switchDevice struct {
|
||||
BaseDevice
|
||||
channels []siidPiid // "on" property per channel (index 0 = channel 1)
|
||||
}
|
||||
|
||||
type siidPiid struct {
|
||||
siid, piid int
|
||||
valid bool
|
||||
}
|
||||
|
||||
// NewSwitch creates a Switch control for the given device.
|
||||
func NewSwitch(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Switch, error) {
|
||||
d := &switchDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
// Use the full SPEC to find all "on" properties (supports multi-channel)
|
||||
miotDev := d.getMIoTDevice()
|
||||
if miotDev == nil || miotDev.SpecInstance() == nil {
|
||||
return nil, xiaomi.ErrNoSPEC
|
||||
}
|
||||
|
||||
spec := miotDev.SpecInstance()
|
||||
var channels []siidPiid
|
||||
for _, svc := range spec.Services {
|
||||
for _, prop := range svc.Properties {
|
||||
if propertyShortName(prop.Type) == "on" {
|
||||
channels = append(channels, siidPiid{prop.SIID, prop.PIID, true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(channels) == 0 {
|
||||
return nil, xiaomi.ErrNotSupported
|
||||
}
|
||||
d.channels = channels
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *switchDevice) ChannelCount() int {
|
||||
return len(d.channels)
|
||||
}
|
||||
|
||||
func (d *switchDevice) TurnOn(ctx context.Context) error {
|
||||
return d.TurnOnChannel(ctx, 1)
|
||||
}
|
||||
|
||||
func (d *switchDevice) TurnOff(ctx context.Context) error {
|
||||
return d.TurnOffChannel(ctx, 1)
|
||||
}
|
||||
|
||||
func (d *switchDevice) Toggle(ctx context.Context) error {
|
||||
return d.ToggleChannel(ctx, 1)
|
||||
}
|
||||
|
||||
func (d *switchDevice) IsOn(ctx context.Context) (bool, error) {
|
||||
return d.IsChannelOn(ctx, 1)
|
||||
}
|
||||
|
||||
func (d *switchDevice) TurnOnChannel(ctx context.Context, ch int) error {
|
||||
p, ok := d.channel(ch)
|
||||
if !ok {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, p.siid, p.piid, true)
|
||||
}
|
||||
|
||||
func (d *switchDevice) TurnOffChannel(ctx context.Context, ch int) error {
|
||||
p, ok := d.channel(ch)
|
||||
if !ok {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, p.siid, p.piid, false)
|
||||
}
|
||||
|
||||
func (d *switchDevice) ToggleChannel(ctx context.Context, ch int) error {
|
||||
on, err := d.IsChannelOn(ctx, ch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if on {
|
||||
return d.TurnOffChannel(ctx, ch)
|
||||
}
|
||||
return d.TurnOnChannel(ctx, ch)
|
||||
}
|
||||
|
||||
func (d *switchDevice) IsChannelOn(ctx context.Context, ch int) (bool, error) {
|
||||
p, ok := d.channel(ch)
|
||||
if !ok {
|
||||
return false, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, p.siid, p.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *switchDevice) OnStateChanged(handler func(on bool)) (string, error) {
|
||||
p, ok := d.channel(1)
|
||||
if !ok {
|
||||
return "", xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SubProp(p.siid, p.piid, func(did string, prop *xiaomi.PropertyValue) {
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
handler(b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (d *switchDevice) channel(ch int) (siidPiid, bool) {
|
||||
i := ch - 1 // channel 1 → index 0
|
||||
if i < 0 || i >= len(d.channels) {
|
||||
return siidPiid{}, false
|
||||
}
|
||||
return d.channels[i], true
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// TempHumiditySensor 温湿度传感器读取接口(只读)。
|
||||
type TempHumiditySensor interface {
|
||||
Device
|
||||
|
||||
GetTemperature(ctx context.Context) (float64, error)
|
||||
GetHumidity(ctx context.Context) (float64, error)
|
||||
}
|
||||
|
||||
// NewTempHumiditySensor 创建温湿度传感器。失败返回错误(含无 SPEC / 无相关属性)。
|
||||
func NewTempHumiditySensor(client *xiaomi.Client, info *xiaomi.DeviceInfo) (TempHumiditySensor, error) {
|
||||
if info == nil {
|
||||
return nil, xiaomi.ErrDeviceNotFound
|
||||
}
|
||||
|
||||
miotDev := client.GetMIoTDevice(info.DID)
|
||||
if miotDev == nil {
|
||||
return nil, fmt.Errorf("%w: device not initialized", xiaomi.ErrDeviceNotFound)
|
||||
}
|
||||
spec := miotDev.SpecInstance()
|
||||
if spec == nil {
|
||||
return nil, fmt.Errorf("%w: device has no SPEC information", xiaomi.ErrNotSupported)
|
||||
}
|
||||
|
||||
d := &tempHumidityDevice{
|
||||
BaseDevice: BaseDevice{client: client, info: info},
|
||||
}
|
||||
|
||||
for _, svc := range spec.Services {
|
||||
for _, prop := range svc.Properties {
|
||||
switch propertyShortName(prop.Type) {
|
||||
case "temperature":
|
||||
if !d.propTemp.valid {
|
||||
d.propTemp = siidPiid{svc.IID, prop.PIID, true}
|
||||
}
|
||||
case "relative-humidity":
|
||||
if !d.propHumidity.valid {
|
||||
d.propHumidity = siidPiid{svc.IID, prop.PIID, true}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !d.propTemp.valid && !d.propHumidity.valid {
|
||||
return nil, fmt.Errorf("%w: no temperature or humidity property in SPEC", xiaomi.ErrNotSupported)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
type tempHumidityDevice struct {
|
||||
BaseDevice
|
||||
propTemp siidPiid
|
||||
propHumidity siidPiid
|
||||
}
|
||||
|
||||
func (d *tempHumidityDevice) GetTemperature(ctx context.Context) (float64, error) {
|
||||
if !d.propTemp.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propTemp.siid, d.propTemp.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toFloat(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *tempHumidityDevice) GetHumidity(ctx context.Context) (float64, error) {
|
||||
if !d.propHumidity.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propHumidity.siid, d.propHumidity.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toFloat(prop.Value), nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Thermostat 温控器/电热毯统一控制接口
|
||||
type Thermostat interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
IsOn(ctx context.Context) (bool, error)
|
||||
|
||||
SetTargetTemp(ctx context.Context, celsius float64) error
|
||||
GetTargetTemp(ctx context.Context) (float64, error)
|
||||
|
||||
GetCurrentTemp(ctx context.Context) (float64, error)
|
||||
|
||||
SetHeating(ctx context.Context, on bool) error
|
||||
IsHeating(ctx context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type thermostatDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on"
|
||||
propTargetTemp siidPiid // "target-temperature"
|
||||
propCurrentTemp siidPiid // "temperature"
|
||||
propHeating siidPiid // "heating" or "mode"
|
||||
}
|
||||
|
||||
// NewThermostat creates a Thermostat control for the given device.
|
||||
func NewThermostat(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Thermostat, error) {
|
||||
d := &thermostatDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
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("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("heating"); ok {
|
||||
d.propHeating = siidPiid{s, p, true}
|
||||
} else if s, p, ok := resolver.find("heater"); ok {
|
||||
d.propHeating = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *thermostatDevice) 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 *thermostatDevice) 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 *thermostatDevice) 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 *thermostatDevice) 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 *thermostatDevice) 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 *thermostatDevice) 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 *thermostatDevice) SetHeating(ctx context.Context, on bool) error {
|
||||
if !d.propHeating.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propHeating.siid, d.propHeating.piid, on)
|
||||
}
|
||||
|
||||
func (d *thermostatDevice) IsHeating(ctx context.Context) (bool, error) {
|
||||
if !d.propHeating.valid {
|
||||
return false, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propHeating.siid, d.propHeating.piid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// Vacuum 扫地机统一控制接口
|
||||
type Vacuum interface {
|
||||
Device
|
||||
Start(ctx context.Context) error
|
||||
Stop(ctx context.Context) error
|
||||
ReturnToDock(ctx context.Context) error
|
||||
|
||||
SetFanLevel(ctx context.Context, level int) error
|
||||
GetFanLevel(ctx context.Context) (int, error)
|
||||
|
||||
GetStatus(ctx context.Context) (VacuumStatus, error)
|
||||
}
|
||||
|
||||
// VacuumStatus 扫地机运行状态
|
||||
type VacuumStatus struct {
|
||||
Power bool `json:"power"`
|
||||
Mode int `json:"mode"` // 0=idle, 1=sweep, 2=mop, 3=sweep+mop, 4=charging
|
||||
FanLevel int `json:"fan_level"`
|
||||
Battery int `json:"battery"` // 0-100
|
||||
}
|
||||
|
||||
type vacuumDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on" — start/stop control
|
||||
propMode siidPiid // "mode" — 0=charge(dock), 1=sweep, etc.
|
||||
propFanLevel siidPiid // "fan-level" (suction power)
|
||||
propBattery siidPiid // "battery-level"
|
||||
}
|
||||
|
||||
// NewVacuum creates a Vacuum control for the given device.
|
||||
func NewVacuum(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Vacuum, error) {
|
||||
d := &vacuumDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
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("mode"); ok {
|
||||
d.propMode = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("fan-level"); ok {
|
||||
d.propFanLevel = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("battery-level"); ok {
|
||||
d.propBattery = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) Start(ctx context.Context) error {
|
||||
if !d.propOn.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true)
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) Stop(ctx context.Context) error {
|
||||
if !d.propOn.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false)
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) ReturnToDock(ctx context.Context) error {
|
||||
if !d.propMode.valid {
|
||||
// Try using on=false as fallback
|
||||
return d.Stop(ctx)
|
||||
}
|
||||
return d.SetProp(ctx, d.propMode.siid, d.propMode.piid, 0)
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) SetFanLevel(ctx context.Context, level int) error {
|
||||
if !d.propFanLevel.valid {
|
||||
return xiaomi.ErrNotSupported
|
||||
}
|
||||
return d.SetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid, level)
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) GetFanLevel(ctx context.Context) (int, error) {
|
||||
if !d.propFanLevel.valid {
|
||||
return 0, xiaomi.ErrNotSupported
|
||||
}
|
||||
prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return toInt(prop.Value), nil
|
||||
}
|
||||
|
||||
func (d *vacuumDevice) GetStatus(ctx context.Context) (VacuumStatus, error) {
|
||||
status := VacuumStatus{}
|
||||
|
||||
if d.propOn.valid {
|
||||
prop, err := d.GetProp(ctx, d.propOn.siid, d.propOn.piid)
|
||||
if err == nil {
|
||||
if b, ok := prop.Value.(bool); ok {
|
||||
status.Power = b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if d.propMode.valid {
|
||||
prop, err := d.GetProp(ctx, d.propMode.siid, d.propMode.piid)
|
||||
if err == nil {
|
||||
status.Mode = toInt(prop.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if d.propFanLevel.valid {
|
||||
prop, err := d.GetProp(ctx, d.propFanLevel.siid, d.propFanLevel.piid)
|
||||
if err == nil {
|
||||
status.FanLevel = toInt(prop.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if d.propBattery.valid {
|
||||
prop, err := d.GetProp(ctx, d.propBattery.siid, d.propBattery.piid)
|
||||
if err == nil {
|
||||
status.Battery = toInt(prop.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"xiaomihome/xiaomi"
|
||||
)
|
||||
|
||||
// WaterHeater 热水器统一控制接口
|
||||
type WaterHeater interface {
|
||||
Device
|
||||
TurnOn(ctx context.Context) error
|
||||
TurnOff(ctx context.Context) error
|
||||
IsOn(ctx context.Context) (bool, error)
|
||||
|
||||
SetTargetTemp(ctx context.Context, celsius float64) error
|
||||
GetTargetTemp(ctx context.Context) (float64, error)
|
||||
|
||||
GetCurrentTemp(ctx context.Context) (float64, error)
|
||||
}
|
||||
|
||||
type waterHeaterDevice struct {
|
||||
BaseDevice
|
||||
propOn siidPiid // "on"
|
||||
propTargetTemp siidPiid // "target-temperature"
|
||||
propCurrentTemp siidPiid // "temperature"
|
||||
}
|
||||
|
||||
// NewWaterHeater creates a WaterHeater control for the given device.
|
||||
func NewWaterHeater(client *xiaomi.Client, info *xiaomi.DeviceInfo) (WaterHeater, error) {
|
||||
d := &waterHeaterDevice{
|
||||
BaseDevice: NewBaseDevice(client, info),
|
||||
}
|
||||
|
||||
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("target-temperature"); ok {
|
||||
d.propTargetTemp = siidPiid{s, p, true}
|
||||
}
|
||||
if s, p, ok := resolver.find("temperature"); ok {
|
||||
d.propCurrentTemp = siidPiid{s, p, true}
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *waterHeaterDevice) 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 *waterHeaterDevice) 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 *waterHeaterDevice) 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 *waterHeaterDevice) 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 *waterHeaterDevice) 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 *waterHeaterDevice) 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
|
||||
}
|
||||
Reference in New Issue
Block a user