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:
2026-06-29 08:51:21 +08:00
parent 35efed7d15
commit a3d94c4b9f
93 changed files with 7582 additions and 157 deletions
+459
View File
@@ -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
}
+169
View File
@@ -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
}
+92
View File
@@ -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
}
+187
View File
@@ -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")
}
}
+115
View File
@@ -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
}
+150
View File
@@ -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
}
+118
View File
@@ -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
}
+212
View File
@@ -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
}
+88
View File
@@ -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
}
+150
View File
@@ -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
}
+85
View File
@@ -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
}
+140
View File
@@ -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
}
+141
View File
@@ -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
}
+110
View File
@@ -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
}