Files
xiaomihome/xiaomi/devices/speaker.go
T
4566704 b808e51b3e refactor: 重大架构升级 — HomeID/RoomID int64化 + 设备驱动重构 + 结构化日志
主要变更:

**类型系统重构**
- HomeInfo.ID/RoomInfo.ID/HomeID 从 string 改为 int64,json tag 使用 ,string
- GetHome/GetRooms/GetRoomDevices 接口同步修改
- 新增 int64FromStr 工具函数,更新 parseHomeInfo 解析逻辑

**新增 Speaker 设备**
- 新增 KindSpeaker 类型
- classify 映射覆盖 speaker/bluetooth-speaker/alarm-clock/media-player/playback/music

**设备驱动重构**
- AC 驱动彻底重写:从硬编码常量改为基于 SPEC ValueList 的动态 valMapper
- 所有设备(Fan/Humidifier/Light/OccupancySensor/Switch/TempHumiditySensor/Thermostat/Vacuum/WaterHeater/Cover)新增 GetState() 和 FetchState(ctx) 接口
- base.go 新增 specResolver.format() 方法

**MIoT 模块增强**
- MIoTClient 新增 homeIDs 字段,支持按家庭过滤设备
- RefreshDevices 支持 homeIDs 参数
- SpecStdLib 引入 logger.Logger,log.Printf 全部迁移为结构化日志

**其他**
- Config 新增 RedirectURL 和 HomeIDs 字段
- AuthInfo.ExpiresIn 类型从 int 改为 int32
- UUID 生成简化为 hex.EncodeToString
- 新增 github.com/sirupsen/logrus 依赖
- .gitignore 新增 examples/*/data/ 忽略规则
- 删除已提交的 config.json 敏感配置
2026-07-13 10:40:20 +08:00

435 lines
12 KiB
Go

package devices
import (
"context"
"strings"
"xiaomihome/miot"
"xiaomihome/xiaomi"
)
// Speaker 音箱通用控制接口
type Speaker interface {
Device
TurnOn(ctx context.Context) error
TurnOff(ctx context.Context) error
Toggle(ctx context.Context) error
// 能力查询
HasVolume() bool
HasMute() bool
HasPlayControl() bool
// 音量 (0-100)
SetVolume(ctx context.Context, pct int) error
GetVolume(ctx context.Context) (int, error)
// 静音
SetMute(ctx context.Context, mute bool) error
GetMute(ctx context.Context) (bool, error)
// 播放控制 (0=停止, 1=播放, 2=暂停)
SetPlayState(ctx context.Context, state int) error
GetPlayState(ctx context.Context) (int, error)
// ── 智能音箱 (intelligent-speaker) ──
// 睡眠模式
HasSleepMode() bool
SetSleepMode(ctx context.Context, sleep bool) error
GetSleepMode(ctx context.Context) (bool, error)
// 音频 ID (只读/通知)
GetAudioID(ctx context.Context) (string, error)
// 动作
WakeUp(ctx context.Context) error
PlayRadio(ctx context.Context) error
PlayText(ctx context.Context, text string) error
ExecuteTextDirective(ctx context.Context, text string, silent bool) error
PlayMusic(ctx context.Context) error
OnStateChanged(handler func(state SpeakerState, changed string)) (string, error)
GetState() SpeakerState
FetchState(ctx context.Context) (SpeakerState, error)
}
// SpeakerState 音箱完整状态(字段名对齐 SPEC type)
type SpeakerState struct {
On bool `json:"on"` // SPEC: "on"
Volume int `json:"volume"` // SPEC: "volume"
Mute bool `json:"mute"` // SPEC: "mute"
PlayState int `json:"playState"` // SPEC: "tds-in" or "playing-state" (0=stop,1=play,2=pause)
SleepMode bool `json:"sleepMode"` // SPEC: "sleep-mode" (intelligent-speaker)
AudioID string `json:"audioId"` // SPEC: "audio-id" (intelligent-speaker, read-only)
}
// isAction 智能音箱动作 siid/aiid 对
type isAction struct{ siid, aiid int }
type speakerDevice struct {
BaseDevice
propOn siidPiid // "on"
propVolume siidPiid // "volume"
propMute siidPiid // "mute"
propPlayState siidPiid // "playing-state" or "tds-in"
propSleepMode siidPiid // "sleep-mode" (intelligent-speaker)
propAudioID siidPiid // "audio-id" (intelligent-speaker)
// intelligent-speaker 服务 siid
isSiid int
// 动作
actWakeUp isAction
actPlayRadio isAction
actPlayText isAction
actExecuteTextDirective isAction
actPlayMusic isAction
stateCache SpeakerState
}
// NewSpeaker creates a Speaker control for the given device.
func NewSpeaker(client *xiaomi.Client, info *xiaomi.DeviceInfo) (Speaker, error) {
d := &speakerDevice{
BaseDevice: NewBaseDevice(client, info),
}
dev := d.getMIoTDevice()
if dev == nil {
return nil, xiaomi.ErrNoSPEC
}
spec := dev.SpecInstance()
if spec == nil {
return nil, xiaomi.ErrNoSPEC
}
// 定位 intelligent-speaker 服务
var isSvc *miot.MIoTSpecService
for _, svc := range spec.Services {
if shortName(svc.Type) == "intelligent-speaker" {
isSvc = svc
d.isSiid = svc.IID
break
}
}
// 先用 resolver 解析通用属性
resolver := d.getSpecResolver()
if resolver == nil {
return nil, xiaomi.ErrNoSPEC
}
siid, piid, ok := resolver.find("on")
if !ok {
return nil, xiaomi.ErrNotSupported
}
d.propOn = siidPiid{siid, piid, true}
if s, p, ok := resolver.find("volume"); ok {
d.propVolume = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("mute"); ok {
d.propMute = siidPiid{s, p, true}
}
if s, p, ok := resolver.find("playing-state"); ok {
d.propPlayState = siidPiid{s, p, true}
} else if s, p, ok := resolver.find("tds-in"); ok {
d.propPlayState = siidPiid{s, p, true}
}
// intelligent-speaker 属性
if isSvc != nil {
for _, p := range isSvc.Properties {
switch shortName(p.Type) {
case "sleep-mode":
d.propSleepMode = siidPiid{d.isSiid, p.PIID, true}
case "audio-id":
d.propAudioID = siidPiid{d.isSiid, p.PIID, true}
}
}
// 动作
for _, a := range isSvc.Actions {
switch shortName(a.Type) {
case "wake-up":
d.actWakeUp = isAction{d.isSiid, a.AIID}
case "play-radio":
d.actPlayRadio = isAction{d.isSiid, a.AIID}
case "play-text":
d.actPlayText = isAction{d.isSiid, a.AIID}
case "execute-text-directive":
d.actExecuteTextDirective = isAction{d.isSiid, a.AIID}
case "play-music":
d.actPlayMusic = isAction{d.isSiid, a.AIID}
}
}
}
return d, nil
}
func (d *speakerDevice) TurnOn(ctx context.Context) error {
if !d.propOn.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, true)
}
func (d *speakerDevice) TurnOff(ctx context.Context) error {
if !d.propOn.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propOn.siid, d.propOn.piid, false)
}
func (d *speakerDevice) Toggle(ctx context.Context) error {
s := d.GetState()
if s.On {
return d.TurnOff(ctx)
}
return d.TurnOn(ctx)
}
func (d *speakerDevice) HasVolume() bool { return d.propVolume.valid }
func (d *speakerDevice) HasMute() bool { return d.propMute.valid }
func (d *speakerDevice) HasPlayControl() bool { return d.propPlayState.valid }
func (d *speakerDevice) SetVolume(ctx context.Context, pct int) error {
if !d.propVolume.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propVolume.siid, d.propVolume.piid, pct)
}
func (d *speakerDevice) GetVolume(ctx context.Context) (int, error) {
if !d.propVolume.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propVolume.siid, d.propVolume.piid)
if err != nil {
return 0, err
}
return toInt(prop.Value), nil
}
func (d *speakerDevice) SetMute(ctx context.Context, mute bool) error {
if !d.propMute.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propMute.siid, d.propMute.piid, mute)
}
func (d *speakerDevice) GetMute(ctx context.Context) (bool, error) {
if !d.propMute.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propMute.siid, d.propMute.piid)
if err != nil {
return false, err
}
if b, ok := prop.Value.(bool); ok {
return b, nil
}
return false, nil
}
func (d *speakerDevice) SetPlayState(ctx context.Context, state int) error {
if !d.propPlayState.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propPlayState.siid, d.propPlayState.piid, state)
}
func (d *speakerDevice) GetPlayState(ctx context.Context) (int, error) {
if !d.propPlayState.valid {
return 0, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propPlayState.siid, d.propPlayState.piid)
if err != nil {
return 0, err
}
return toInt(prop.Value), nil
}
// ── 智能音箱属性 ──
func (d *speakerDevice) HasSleepMode() bool { return d.propSleepMode.valid }
func (d *speakerDevice) SetSleepMode(ctx context.Context, sleep bool) error {
if !d.propSleepMode.valid {
return xiaomi.ErrNotSupported
}
return d.SetProp(ctx, d.propSleepMode.siid, d.propSleepMode.piid, sleep)
}
func (d *speakerDevice) GetSleepMode(ctx context.Context) (bool, error) {
if !d.propSleepMode.valid {
return false, xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propSleepMode.siid, d.propSleepMode.piid)
if err != nil {
return false, err
}
if b, ok := prop.Value.(bool); ok {
return b, nil
}
return false, nil
}
func (d *speakerDevice) GetAudioID(ctx context.Context) (string, error) {
if !d.propAudioID.valid {
return "", xiaomi.ErrNotSupported
}
prop, err := d.GetProp(ctx, d.propAudioID.siid, d.propAudioID.piid)
if err != nil {
return "", err
}
if s, ok := prop.Value.(string); ok {
return s, nil
}
return "", nil
}
// ── 智能音箱动作 ──
func (d *speakerDevice) WakeUp(ctx context.Context) error {
if d.actWakeUp.siid == 0 {
return xiaomi.ErrNotSupported
}
_, err := d.Action(ctx, d.actWakeUp.siid, d.actWakeUp.aiid, nil)
return err
}
func (d *speakerDevice) PlayRadio(ctx context.Context) error {
if d.actPlayRadio.siid == 0 {
return xiaomi.ErrNotSupported
}
_, err := d.Action(ctx, d.actPlayRadio.siid, d.actPlayRadio.aiid, nil)
return err
}
func (d *speakerDevice) PlayText(ctx context.Context, text string) error {
if d.actPlayText.siid == 0 {
return xiaomi.ErrNotSupported
}
_, err := d.Action(ctx, d.actPlayText.siid, d.actPlayText.aiid, []interface{}{
map[string]interface{}{"piid": 1, "value": text},
})
return err
}
func (d *speakerDevice) ExecuteTextDirective(ctx context.Context, text string, silent bool) error {
if d.actExecuteTextDirective.siid == 0 {
return xiaomi.ErrNotSupported
}
_, err := d.Action(ctx, d.actExecuteTextDirective.siid, d.actExecuteTextDirective.aiid, []interface{}{
map[string]interface{}{"piid": 1, "value": text},
map[string]interface{}{"piid": 2, "value": silent},
})
return err
}
func (d *speakerDevice) PlayMusic(ctx context.Context) error {
if d.actPlayMusic.siid == 0 {
return xiaomi.ErrNotSupported
}
_, err := d.Action(ctx, d.actPlayMusic.siid, d.actPlayMusic.aiid, nil)
return err
}
// ── 订阅 / 状态 ──
func (d *speakerDevice) OnStateChanged(handler func(state SpeakerState, changed string)) (string, error) {
type field struct {
siid, piid int
name string
fill func(*SpeakerState, interface{})
}
var fields []field
add := func(sp siidPiid, name string, fill func(*SpeakerState, interface{})) {
if sp.valid {
fields = append(fields, field{sp.siid, sp.piid, name, fill})
}
}
add(d.propOn, "on", func(s *SpeakerState, v interface{}) { s.On, _ = v.(bool) })
add(d.propVolume, "volume", func(s *SpeakerState, v interface{}) { s.Volume = toInt(v) })
add(d.propMute, "mute", func(s *SpeakerState, v interface{}) { s.Mute, _ = v.(bool) })
add(d.propPlayState, "play-state", func(s *SpeakerState, v interface{}) { s.PlayState = toInt(v) })
add(d.propSleepMode, "sleep-mode", func(s *SpeakerState, v interface{}) { s.SleepMode, _ = v.(bool) })
add(d.propAudioID, "audio-id", func(s *SpeakerState, v interface{}) {
if str, ok := v.(string); ok {
s.AudioID = str
}
})
if len(fields) == 0 {
return "", xiaomi.ErrNotSupported
}
return d.SubProp(0, 0, func(did string, prop *xiaomi.PropertyValue) {
for _, f := range fields {
if f.siid == prop.SIID && f.piid == prop.PIID {
f.fill(&d.stateCache, prop.Value)
handler(d.stateCache, f.name)
return
}
}
})
}
func (d *speakerDevice) GetState() SpeakerState { return d.stateCache }
func (d *speakerDevice) FetchState(ctx context.Context) (SpeakerState, error) {
keys := []xiaomi.PropKey{}
add := func(sp siidPiid) {
if sp.valid {
keys = append(keys, xiaomi.PropKey{DID: d.info.DID, SIID: sp.siid, PIID: sp.piid})
}
}
add(d.propOn)
add(d.propVolume)
add(d.propMute)
add(d.propPlayState)
add(d.propSleepMode)
add(d.propAudioID)
props, err := d.client.GetProps(ctx, keys)
if err != nil {
return d.stateCache, err
}
state := SpeakerState{}
for _, p := range props {
switch {
case match(d.propOn, p):
state.On, _ = p.Value.(bool)
case match(d.propVolume, p):
state.Volume = toInt(p.Value)
case match(d.propMute, p):
state.Mute, _ = p.Value.(bool)
case match(d.propPlayState, p):
state.PlayState = toInt(p.Value)
case match(d.propSleepMode, p):
state.SleepMode, _ = p.Value.(bool)
case match(d.propAudioID, p):
if s, ok := p.Value.(string); ok {
state.AudioID = s
}
}
}
d.stateCache = state
return state, nil
}
func match(sp siidPiid, p *xiaomi.PropertyValue) bool {
return sp.valid && p.SIID == sp.siid && p.PIID == sp.piid
}
// shortName extracts the short name from a SPEC URN.
// E.g., "urn:miot-spec-v2:action:wake-up:..." → "wake-up".
func shortName(urn string) string {
parts := strings.Split(urn, ":")
if len(parts) >= 4 {
return parts[3]
}
return urn
}