主要变更: **类型系统重构** - 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 敏感配置
422 lines
12 KiB
Go
422 lines
12 KiB
Go
// Package miot provides MIoT core client for Xiaomi Home devices.
|
|
// It's a Golang port of MIoTClient from xiaomihome-py/miot/.
|
|
package miot
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// subDevPattern matches .s\d+ suffix for sub-devices.
|
|
var subDevPattern = regexp.MustCompile(`\.s\d+$`)
|
|
|
|
// ============================================================================
|
|
// Device instance management
|
|
// ============================================================================
|
|
|
|
// getOrCreateDevice returns or creates a MIoTDevice instance for the given DID.
|
|
// Lazily parses SPEC on first access and caches the result.
|
|
func (c *MIoTClient) getOrCreateDevice(did string, info map[string]interface{}) *MIoTDevice {
|
|
c.mu.RLock()
|
|
if dev, ok := c.devices[did]; ok {
|
|
c.mu.RUnlock()
|
|
return dev
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
if c.specParser == nil {
|
|
return nil
|
|
}
|
|
|
|
urn, _ := info["urn"].(string)
|
|
model, _ := info["model"].(string)
|
|
if urn == "" && model == "" {
|
|
urn, _ = info["type"].(string)
|
|
}
|
|
|
|
spec, err := c.specParser.Parse(urn, model)
|
|
if err != nil {
|
|
c.lgr.Errorf("[MIoTClient] getOrCreateDevice: SPEC parse failed for %s (%s/%s): %v\n", did, urn, model, err)
|
|
return nil
|
|
}
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
// Double-check after acquiring write lock
|
|
if dev, ok := c.devices[did]; ok {
|
|
return dev
|
|
}
|
|
dev := NewMIoTDevice(c, info, spec)
|
|
if c.devices == nil {
|
|
c.devices = make(map[string]*MIoTDevice)
|
|
}
|
|
c.devices[did] = dev
|
|
return dev
|
|
}
|
|
|
|
// ============================================================================
|
|
// Device management (aligned with Python miot_client.py)
|
|
// ============================================================================
|
|
|
|
// RefreshDevices refreshes device list from cloud for the given home IDs (nil=all).
|
|
// Aligns with Python: __update_devices_from_cloud_async (line 1384).
|
|
func (c *MIoTClient) RefreshDevices(homeIDs []string) error {
|
|
if c.http == nil {
|
|
return fmt.Errorf("HTTP client not available")
|
|
}
|
|
|
|
// 1. Get full device list from cloud
|
|
result, err := c.http.GetDevices(homeIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get devices: %v", err)
|
|
}
|
|
|
|
devicesRaw, ok := result["devices"].(map[string]map[string]interface{})
|
|
if !ok {
|
|
// Try interface{} cast
|
|
dev, ok := result["devices"].(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("invalid devices response")
|
|
}
|
|
devicesRaw = make(map[string]map[string]interface{})
|
|
for did, info := range dev {
|
|
if infoMap, ok := info.(map[string]interface{}); ok {
|
|
devicesRaw[did] = infoMap
|
|
}
|
|
}
|
|
}
|
|
|
|
c.deviceListMu.Lock()
|
|
defer c.deviceListMu.Unlock()
|
|
|
|
// 2. Build processed device maps
|
|
cloudDevices := make(map[string]map[string]interface{})
|
|
sharedDevices := make(map[string]map[string]interface{})
|
|
subDevices := make(map[string]map[string]map[string]interface{}) // parentDID -> subKey -> device
|
|
|
|
for did, device := range devicesRaw {
|
|
// Clone device info
|
|
info := make(map[string]interface{})
|
|
for k, v := range device {
|
|
info[k] = v
|
|
}
|
|
|
|
// BLE/proxy devices default online
|
|
connectType := getConnectType(info)
|
|
if connectType == 23 || connectType == 8 || connectType == 12 || connectType == 0 {
|
|
info["online"] = true
|
|
}
|
|
|
|
// Handle sub-devices: .s\d+ suffix
|
|
match := subDevPattern.FindString(did)
|
|
if match != "" {
|
|
parentDID := strings.TrimSuffix(did, match)
|
|
subKey := strings.TrimPrefix(match, ".")
|
|
if subDevices[parentDID] == nil {
|
|
subDevices[parentDID] = make(map[string]map[string]interface{})
|
|
}
|
|
subDevices[parentDID][subKey] = info
|
|
continue
|
|
}
|
|
|
|
// Handle shared devices: owner.userid exists
|
|
if isSharedDevice(info) {
|
|
sharedDevices[did] = info
|
|
continue
|
|
}
|
|
|
|
cloudDevices[did] = info
|
|
}
|
|
|
|
// Merge sub-devices into parents
|
|
for parentDID, subs := range subDevices {
|
|
if parent, exists := cloudDevices[parentDID]; exists {
|
|
if parent["sub_devices"] == nil {
|
|
parent["sub_devices"] = make(map[string]interface{})
|
|
}
|
|
existingSubs, _ := parent["sub_devices"].(map[string]interface{})
|
|
for subKey, subInfo := range subs {
|
|
existingSubs[subKey] = subInfo
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Merge shared devices into cloud devices (python treats them the same)
|
|
for did, info := range sharedDevices {
|
|
cloudDevices[did] = info
|
|
}
|
|
|
|
// 4. Diff: compare cloudDevices with current deviceListCloud
|
|
changed := false
|
|
|
|
// Detect additions and updates
|
|
for did, info := range cloudDevices {
|
|
oldInfo, exists := c.deviceListCloud[did]
|
|
if !exists {
|
|
// New device
|
|
c.deviceListCloud[did] = info
|
|
if cacheInfo, cacheExists := c.deviceListCache[did]; cacheExists {
|
|
// Update existing cache entry
|
|
for k, v := range info {
|
|
cacheInfo[k] = v
|
|
}
|
|
} else {
|
|
c.deviceListCache[did] = cloneMap(info)
|
|
}
|
|
c.lgr.Infof("[MIoTClient] Device added: %s (%v)\n", did, info["name"])
|
|
changed = true
|
|
} else {
|
|
// Check if online status changed
|
|
newOnline := getOnline(info)
|
|
oldOnline := getOnline(oldInfo)
|
|
if newOnline != oldOnline {
|
|
oldInfo["online"] = newOnline
|
|
c.deviceListCloud[did] = oldInfo
|
|
if cacheInfo, ok := c.deviceListCache[did]; ok {
|
|
cacheInfo["online"] = newOnline
|
|
}
|
|
c.lgr.Infof("[MIoTClient] Device online changed: %s online=%v\n", did, newOnline)
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect deletions
|
|
for did := range c.deviceListCloud {
|
|
if _, exists := cloudDevices[did]; !exists {
|
|
delete(c.deviceListCloud, did)
|
|
if cacheInfo, ok := c.deviceListCache[did]; ok {
|
|
cacheInfo["online"] = false
|
|
cacheInfo["push_available"] = false
|
|
}
|
|
c.lgr.Infof("[MIoTClient] Device removed from cloud: %s\n", did)
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
// 4. Aggregate three sources: cloud + gateway + lan
|
|
for did := range c.deviceListCache {
|
|
c.aggregateDeviceState(did)
|
|
}
|
|
|
|
// 5. Update subscription routing
|
|
for did := range cloudDevices {
|
|
c.updateDeviceMsgSub(did)
|
|
}
|
|
|
|
// 6. Save device list to storage
|
|
if c.storage != nil {
|
|
deviceListForStorage := make(map[string]interface{})
|
|
for did, info := range c.deviceListCache {
|
|
deviceListForStorage[did] = info
|
|
}
|
|
deviceKey := fmt.Sprintf("%s_%s", c.uid, c.cloudServer)
|
|
if err := c.storage.Save("miot_devices", deviceKey, deviceListForStorage); err != nil {
|
|
c.lgr.Warnf("[MIoTClient] Warning: failed to save devices: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// 7. Notify device list changed
|
|
if changed {
|
|
c.ShowDevicesChangedNotify()
|
|
}
|
|
|
|
c.lgr.Infof("[MIoTClient] Devices refreshed: cloud=%d, cache=%d\n", len(cloudDevices), len(c.deviceListCache))
|
|
return nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper functions for device list management
|
|
// ============================================================================
|
|
|
|
// getConnectType extracts the connect_type from device info.
|
|
// Returns 0 if not found.
|
|
func getConnectType(info map[string]interface{}) int {
|
|
if ct, ok := info["connect_type"].(float64); ok {
|
|
return int(ct)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// getOnline extracts the online status from device info.
|
|
func getOnline(info map[string]interface{}) bool {
|
|
if online, ok := info["online"].(bool); ok {
|
|
return online
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isSharedDevice checks if a device has an owner with a userid (shared device).
|
|
func isSharedDevice(info map[string]interface{}) bool {
|
|
owner, ok := info["owner"].(map[string]interface{})
|
|
if !ok || owner == nil {
|
|
return false
|
|
}
|
|
_, hasUserID := owner["userid"]
|
|
return hasUserID
|
|
}
|
|
|
|
// cloneMap performs a shallow clone of a map.
|
|
func cloneMap(src map[string]interface{}) map[string]interface{} {
|
|
dst := make(map[string]interface{}, len(src))
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// aggregateDeviceState merges cloud/gateway/lan state for a device into cache.
|
|
// Aligns with Python: __check_device_state.
|
|
func (c *MIoTClient) aggregateDeviceState(did string) {
|
|
// NOTE: caller must hold c.deviceListMu
|
|
|
|
cacheInfo, cacheExists := c.deviceListCache[did]
|
|
if !cacheExists {
|
|
return
|
|
}
|
|
|
|
// Aggregate from three sources
|
|
online := false
|
|
|
|
if cloudInfo, exists := c.deviceListCloud[did]; exists {
|
|
if o, ok := cloudInfo["online"].(bool); ok && o {
|
|
online = true
|
|
}
|
|
}
|
|
if !online {
|
|
if gwInfo, exists := c.deviceListGateway[did]; exists {
|
|
if o, ok := gwInfo["online"].(bool); ok && o {
|
|
online = true
|
|
}
|
|
}
|
|
}
|
|
if !online {
|
|
if lanInfo, exists := c.deviceListLan[did]; exists {
|
|
if o, ok := lanInfo["online"].(bool); ok && o {
|
|
online = true
|
|
}
|
|
}
|
|
}
|
|
|
|
cacheInfo["online"] = online
|
|
}
|
|
|
|
// LoadDevices loads devices from cache or cloud.
|
|
// Aligns with Python: __load_cache_device_async.
|
|
func (c *MIoTClient) LoadDevices() error {
|
|
// Try to load from cache first
|
|
if err := c.loadCacheDevice(); err != nil {
|
|
c.lgr.Errorf("[MIoTClient] Failed to load from cache: %v\n", err)
|
|
}
|
|
|
|
// Then refresh from cloud
|
|
return c.RefreshDevices(c.homeIDs)
|
|
}
|
|
|
|
// GetDeviceInfo returns device metadata for a given DID.
|
|
// Returns nil if device not found.
|
|
// Aligns with Python: device_list.get(did).
|
|
func (c *MIoTClient) GetDeviceInfo(did string) map[string]interface{} {
|
|
c.deviceListMu.RLock()
|
|
defer c.deviceListMu.RUnlock()
|
|
|
|
if info, ok := c.deviceListCache[did]; ok {
|
|
return info
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetDeviceInfos returns all device metadata.
|
|
// Aligns with Python: device_list property.
|
|
func (c *MIoTClient) GetDeviceInfos() map[string]map[string]interface{} {
|
|
c.deviceListMu.RLock()
|
|
defer c.deviceListMu.RUnlock()
|
|
|
|
// Return a copy
|
|
result := make(map[string]map[string]interface{})
|
|
for did, info := range c.deviceListCache {
|
|
result[did] = info
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ScheduleRefreshDevices schedules a device refresh after delay.
|
|
// Aligns with Python: __request_refresh_cloud_devices.
|
|
func (c *MIoTClient) ScheduleRefreshDevices(delay time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Cancel previous timer if exists
|
|
if c.refreshCloudDevicesTimer != nil {
|
|
c.refreshCloudDevicesTimer.Stop()
|
|
}
|
|
|
|
c.refreshCloudDevicesTimer = time.AfterFunc(delay, func() {
|
|
c.lgr.Infof("[MIoTClient] Refreshing devices...\n")
|
|
if err := c.RefreshDevices(c.homeIDs); err != nil {
|
|
c.lgr.Errorf("[MIoTClient] Refresh failed, will retry: %v\n", err)
|
|
// Retry after 60s
|
|
c.ScheduleRefreshDevices(RefreshCloudDevicesRetryDelay * time.Millisecond)
|
|
}
|
|
})
|
|
|
|
c.lgr.Infof("[MIoTClient] Device refresh scheduled in %v\n", delay)
|
|
}
|
|
|
|
// Start starts the MIoTClient and schedules initial device load.
|
|
// Aligns with Python: init_async called at startup.
|
|
func (c *MIoTClient) Start() error {
|
|
// Initialize
|
|
if err := c.Init(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Schedule initial device load (delayed, aligns with REFRESH_CLOUD_DEVICES_DELAY=6 in Python)
|
|
c.ScheduleRefreshDevices(RefreshCloudDevicesDelay * time.Millisecond)
|
|
|
|
c.lgr.Infof("[MIoTClient] Started\n")
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the MIoTClient and all timers.
|
|
func (c *MIoTClient) Stop() error {
|
|
return c.Deinit()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Device state change handling (aligned with Python miot_client.py)
|
|
// ============================================================================
|
|
|
|
// OnDeviceStateChanged handles device state changes.
|
|
// Aligns with Python: __on_cloud_device_state_changed.
|
|
func (c *MIoTClient) OnDeviceStateChanged(did string, state interface{}) {
|
|
c.deviceListMu.Lock()
|
|
defer c.deviceListMu.Unlock()
|
|
|
|
stateMap, ok := state.(map[string]interface{})
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Update or create entry in deviceListCloud (the source triggering this callback)
|
|
if dev, ok := c.deviceListCloud[did]; ok {
|
|
for k, v := range stateMap {
|
|
dev[k] = v
|
|
}
|
|
} else if _, ok := stateMap["online"]; ok {
|
|
c.deviceListCloud[did] = cloneMap(stateMap)
|
|
}
|
|
|
|
// Aggregate from three sources before updating cache
|
|
c.aggregateDeviceState(did)
|
|
|
|
// Propagate to subDeviceState subscribers
|
|
if handler, ok := c.subDeviceState[did]; ok {
|
|
go handler.Handler(did, state)
|
|
}
|
|
}
|