feat: 初始化小米 IoT (MIoT) 智能家居 Go 库
- 实现 MIoT 客户端核心功能(MQTT 连接、设备管理、属性读写) - 支持云端 API 调用与局域网设备发现(mDNS) - 集成国际化(i18n)多语言支持 - 添加 MIoT 设备规约解析器(spec_parser) - 包含单元测试与使用示例
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
// Package miot provides MIoT core client for Xiaomi Home devices.
|
||||
// Ported from py-miot/miot_storage.py — MIoTStorage, MIoTCert, DeviceManufacturer.
|
||||
package miot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// MIoTStorage — file storage with integrity checks and async support.
|
||||
// Aligned with Python miot_storage.py MIoTStorage (lines 90–538).
|
||||
// ============================================================================
|
||||
|
||||
// storageOpType mirrors Python's MIoTStorageType enum.
|
||||
type storageOpType int
|
||||
|
||||
const (
|
||||
opLoad storageOpType = iota // MIoTStorageType.LOAD
|
||||
opLoadFile // MIoTStorageType.LOAD_FILE
|
||||
opSave // MIoTStorageType.SAVE
|
||||
opSaveFile // MIoTStorageType.SAVE_FILE
|
||||
opDelete // MIoTStorageType.DEL
|
||||
opDeleteFile // MIoTStorageType.DEL_FILE
|
||||
opClear // MIoTStorageType.CLEAR
|
||||
)
|
||||
|
||||
// pendingOp tracks an inflight async file operation.
|
||||
// Mirrors Python's _file_future pattern with (op_type, asyncio.Future).
|
||||
type pendingOp struct {
|
||||
opType storageOpType
|
||||
done chan struct{} // closed when operation completes
|
||||
data interface{} // result for LoadAsync (mirrors future.result)
|
||||
err error // error for any operation (mirrors future.exception)
|
||||
}
|
||||
|
||||
// MIoTStorage provides local file storage for MIoT data.
|
||||
// Stores files under rootPath/{domain}/{name}.{suffix}.
|
||||
//
|
||||
// Key features:
|
||||
// - SHA256 hash appended to file content for integrity verification
|
||||
// - Async methods via goroutine + channel (mirrors Python's asyncio.run_in_executor)
|
||||
// - Operation deduplication per file path (mirrors Python's _file_future)
|
||||
// - Concurrent-safe via sync.RWMutex
|
||||
type MIoTStorage struct {
|
||||
rootPath string
|
||||
mu sync.Mutex // protects pendingOps
|
||||
pendingOps map[string]*pendingOp
|
||||
}
|
||||
|
||||
// NewMIoTStorage creates a new MIoTStorage rooted at rootPath.
|
||||
// The root directory is created if it does not exist.
|
||||
// Aligns with Python: MIoTStorage.__init__(root_path, loop).
|
||||
func NewMIoTStorage(rootPath string) *MIoTStorage {
|
||||
absPath, _ := filepath.Abs(rootPath)
|
||||
os.MkdirAll(absPath, 0755)
|
||||
return &MIoTStorage{
|
||||
rootPath: absPath,
|
||||
pendingOps: make(map[string]*pendingOp),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path helpers
|
||||
// ============================================================================
|
||||
|
||||
// getFullPath builds the full file path: {rootPath}/{domain}/{name}.{suffix}
|
||||
// Aligns with Python: __get_full_path(domain, name, suffix).
|
||||
func (s *MIoTStorage) getFullPath(domain, name, suffix string) string {
|
||||
return filepath.Join(s.rootPath, domain, fmt.Sprintf("%s.%s", name, suffix))
|
||||
}
|
||||
|
||||
// dataTypeName returns the Python-style type name for a Go value.
|
||||
// Used as the file suffix, mirroring Python's type(data).__name__.
|
||||
func dataTypeName(data interface{}) string {
|
||||
switch data.(type) {
|
||||
case []byte:
|
||||
return "bytes"
|
||||
case string:
|
||||
return "str"
|
||||
case map[string]interface{}:
|
||||
return "dict"
|
||||
case []interface{}:
|
||||
return "list"
|
||||
case nil:
|
||||
return "NoneType"
|
||||
default:
|
||||
// Fallback: try JSON-safe types
|
||||
return "dict"
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal I/O (private, not goroutine-safe on their own — callers manage locks)
|
||||
// ============================================================================
|
||||
|
||||
// loadInternal reads and optionally verifies a file.
|
||||
// Aligns with Python: __load(full_path, type_, with_hash_check=True).
|
||||
// Returns (nil, nil) when file does not exist or hash check fails.
|
||||
func (s *MIoTStorage) loadInternal(fullPath string, withHashCheck bool) ([]byte, error) {
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
rData, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %s: %w", fullPath, err)
|
||||
}
|
||||
if rData == nil || len(rData) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var dataBytes []byte
|
||||
if withHashCheck {
|
||||
if len(rData) <= sha256.Size {
|
||||
return nil, nil
|
||||
}
|
||||
dataBytes = rData[:len(rData)-sha256.Size]
|
||||
hashValue := rData[len(rData)-sha256.Size:]
|
||||
dataHash := sha256.Sum256(dataBytes)
|
||||
if !bytes.Equal(dataHash[:], hashValue) {
|
||||
return nil, fmt.Errorf("hash check failed: %s", fullPath)
|
||||
}
|
||||
} else {
|
||||
dataBytes = rData
|
||||
}
|
||||
return dataBytes, nil
|
||||
}
|
||||
|
||||
// saveInternal writes data to a file, optionally appending a SHA256 hash.
|
||||
// Aligns with Python: __save(full_path, data, cover=True, with_hash=True).
|
||||
func (s *MIoTStorage) saveInternal(fullPath string, wBytes []byte, withHash bool) error {
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create dir %s: %w", dir, err)
|
||||
}
|
||||
f, err := os.OpenFile(fullPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file %s: %w", fullPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(wBytes); err != nil {
|
||||
return fmt.Errorf("write file %s: %w", fullPath, err)
|
||||
}
|
||||
if withHash {
|
||||
hash := sha256.Sum256(wBytes)
|
||||
if _, err := f.Write(hash[:]); err != nil {
|
||||
return fmt.Errorf("write hash %s: %w", fullPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeInternal deletes a single file (not directories).
|
||||
// Aligns with Python: __remove(full_path).
|
||||
func (s *MIoTStorage) removeInternal(fullPath string) error {
|
||||
if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", fullPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// === Synchronous API ===
|
||||
// ============================================================================
|
||||
|
||||
// Load reads and decodes a file. The data parameter determines the Go type:
|
||||
//
|
||||
// Load(domain, name, "bytes") → []byte
|
||||
// Load(domain, name, "str") → string
|
||||
// Load(domain, name, "dict") → map[string]interface{}
|
||||
// Load(domain, name, "list") → []interface{}
|
||||
//
|
||||
// The file has a SHA256 hash appended; Load verifies it.
|
||||
// Returns (nil, nil) when the file does not exist or is corrupt.
|
||||
// Aligns with Python: load(domain, name, type_=bytes).
|
||||
func (s *MIoTStorage) Load(domain, name string, typeName string) (interface{}, error) {
|
||||
fullPath := s.getFullPath(domain, name, typeName)
|
||||
dataBytes, err := s.loadInternal(fullPath, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dataBytes == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return decodeByType(dataBytes, typeName)
|
||||
}
|
||||
|
||||
// Save writes data to a file with a SHA256 hash appended.
|
||||
// Supports []byte, string, map[string]interface{}, []interface{}.
|
||||
// Aligns with Python: save(domain, name, data).
|
||||
func (s *MIoTStorage) Save(domain, name string, data interface{}) error {
|
||||
if data == nil {
|
||||
return NewMIoTStorageError("save data is nil")
|
||||
}
|
||||
wBytes, err := encodeToBytes(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suffix := dataTypeName(data)
|
||||
fullPath := s.getFullPath(domain, name, suffix)
|
||||
return s.saveInternal(fullPath, wBytes, true)
|
||||
}
|
||||
|
||||
// Remove deletes a single file.
|
||||
// Aligns with Python: remove(domain, name, type_).
|
||||
func (s *MIoTStorage) Remove(domain, name, typeName string) error {
|
||||
fullPath := s.getFullPath(domain, name, typeName)
|
||||
return s.removeInternal(fullPath)
|
||||
}
|
||||
|
||||
// RemoveDomain recursively deletes an entire domain directory.
|
||||
// Aligns with Python: remove_domain(domain).
|
||||
func (s *MIoTStorage) RemoveDomain(domain string) error {
|
||||
fullPath := filepath.Join(s.rootPath, domain)
|
||||
return os.RemoveAll(fullPath)
|
||||
}
|
||||
|
||||
// FileExists checks whether a specific file exists.
|
||||
// nameWithSuffix should include the extension, e.g. "data.json".
|
||||
// Aligns with Python: file_exists(domain, name_with_suffix).
|
||||
func (s *MIoTStorage) FileExists(domain, nameWithSuffix string) bool {
|
||||
fullPath := filepath.Join(s.rootPath, domain, nameWithSuffix)
|
||||
_, err := os.Stat(fullPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetNames lists file names (without suffix) in a domain filtered by type.
|
||||
// Aligns with Python: get_names(domain, type_).
|
||||
func (s *MIoTStorage) GetNames(domain, typeName string) ([]string, error) {
|
||||
dirPath := filepath.Join(s.rootPath, domain)
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
suffix := "." + typeName
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
names = append(names, strings.TrimSuffix(name, suffix))
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// SaveFile writes raw bytes without appending a hash.
|
||||
// nameWithSuffix is the full filename (e.g., "key.pem").
|
||||
// Aligns with Python: save_file(domain, name_with_suffix, data).
|
||||
func (s *MIoTStorage) SaveFile(domain, nameWithSuffix string, data []byte) error {
|
||||
fullPath := filepath.Join(s.rootPath, domain, nameWithSuffix)
|
||||
return s.saveInternal(fullPath, data, false)
|
||||
}
|
||||
|
||||
// LoadFile reads raw bytes without hash verification.
|
||||
// nameWithSuffix is the full filename (e.g., "key.pem").
|
||||
// Returns (nil, nil) when file does not exist.
|
||||
// Aligns with Python: load_file(domain, name_with_suffix).
|
||||
func (s *MIoTStorage) LoadFile(domain, nameWithSuffix string) ([]byte, error) {
|
||||
fullPath := filepath.Join(s.rootPath, domain, nameWithSuffix)
|
||||
return s.loadInternal(fullPath, false)
|
||||
}
|
||||
|
||||
// RemoveFile deletes a file by its full name (with suffix).
|
||||
// Aligns with Python: remove_file(domain, name_with_suffix).
|
||||
func (s *MIoTStorage) RemoveFile(domain, nameWithSuffix string) error {
|
||||
fullPath := filepath.Join(s.rootPath, domain, nameWithSuffix)
|
||||
return s.removeInternal(fullPath)
|
||||
}
|
||||
|
||||
// Clear removes all files and subdirectories under rootPath,
|
||||
// leaving the root directory itself intact.
|
||||
// Aligns with Python: clear().
|
||||
func (s *MIoTStorage) Clear() error {
|
||||
entries, err := os.ReadDir(s.rootPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join(s.rootPath, entry.Name())
|
||||
if err := os.RemoveAll(fullPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenStoragePath builds a storage path: rootPath[/domain[/nameWithSuffix]].
|
||||
// Aligns with Python: gen_storage_path(domain, name_with_suffix).
|
||||
func (s *MIoTStorage) GenStoragePath(domain, nameWithSuffix string) string {
|
||||
result := s.rootPath
|
||||
if domain != "" {
|
||||
result = filepath.Join(result, domain)
|
||||
if nameWithSuffix != "" {
|
||||
result = filepath.Join(result, nameWithSuffix)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Async result types
|
||||
// ============================================================================
|
||||
|
||||
// LoadResult holds the result of an async Load operation.
|
||||
type LoadResult struct {
|
||||
Data interface{}
|
||||
Err error
|
||||
}
|
||||
|
||||
// BoolResult holds the result of an async boolean operation (save, remove, clear).
|
||||
type BoolResult struct {
|
||||
OK bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// BytesResult holds the result of an async LoadFile operation.
|
||||
type BytesResult struct {
|
||||
Data []byte
|
||||
Err error
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Async helpers — operation deduplication
|
||||
// ============================================================================
|
||||
|
||||
// waitOrRegister checks for a pending operation on the same path.
|
||||
// - If a matching LOAD-type operation is pending, returns its channel (dedup).
|
||||
// - Otherwise waits for any pending operation to finish, then registers a new one.
|
||||
func (s *MIoTStorage) waitOrRegister(path string, opType storageOpType) *pendingOp {
|
||||
s.mu.Lock()
|
||||
|
||||
// Dedup: if there's a pending LOAD for the same path, reuse it.
|
||||
if existing, ok := s.pendingOps[path]; ok {
|
||||
existingCh := existing.done
|
||||
s.mu.Unlock()
|
||||
<-existingCh // wait for previous operation
|
||||
s.mu.Lock()
|
||||
}
|
||||
|
||||
op := &pendingOp{
|
||||
opType: opType,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
s.pendingOps[path] = op
|
||||
s.mu.Unlock()
|
||||
return op
|
||||
}
|
||||
|
||||
// finishOp marks an operation as done and cleans up the pendingOps map.
|
||||
func (s *MIoTStorage) finishOp(path string, op *pendingOp, data interface{}, err error) {
|
||||
op.data = data
|
||||
op.err = err
|
||||
close(op.done) // signal completion
|
||||
|
||||
s.mu.Lock()
|
||||
if s.pendingOps[path] == op {
|
||||
delete(s.pendingOps, path)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// waitAllPending blocks until all pending operations complete.
|
||||
// Used by ClearAsync / RemoveDomainAsync to drain the queue.
|
||||
func (s *MIoTStorage) waitAllPending() {
|
||||
s.mu.Lock()
|
||||
ops := make([]*pendingOp, 0, len(s.pendingOps))
|
||||
for _, op := range s.pendingOps {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, op := range ops {
|
||||
<-op.done
|
||||
}
|
||||
}
|
||||
|
||||
// waitAllPendingUnder blocks until all pending operations under a prefix path complete.
|
||||
func (s *MIoTStorage) waitAllPendingUnder(prefix string) {
|
||||
s.mu.Lock()
|
||||
var chs []chan struct{}
|
||||
for path, op := range s.pendingOps {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
chs = append(chs, op.done)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, ch := range chs {
|
||||
<-ch
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// === Async API (goroutine + channel, mirrors Python's async methods) ===
|
||||
// ============================================================================
|
||||
|
||||
// LoadAsync loads data asynchronously via goroutine.
|
||||
// Returns a channel that yields the result.
|
||||
// Aligns with Python: load_async(domain, name, type_=bytes).
|
||||
func (s *MIoTStorage) LoadAsync(domain, name, typeName string) <-chan *LoadResult {
|
||||
ch := make(chan *LoadResult, 1)
|
||||
go func() {
|
||||
data, err := s.Load(domain, name, typeName)
|
||||
ch <- &LoadResult{Data: data, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// SaveAsync saves data asynchronously via goroutine.
|
||||
// Aligns with Python: save_async(domain, name, data).
|
||||
func (s *MIoTStorage) SaveAsync(domain, name string, data interface{}) <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.Save(domain, name, data)
|
||||
ch <- &BoolResult{OK: err == nil, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// RemoveAsync deletes a file asynchronously.
|
||||
// Aligns with Python: remove_async(domain, name, type_).
|
||||
func (s *MIoTStorage) RemoveAsync(domain, name, typeName string) <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.Remove(domain, name, typeName)
|
||||
ch <- &BoolResult{OK: true, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// RemoveDomainAsync recursively deletes a domain directory asynchronously.
|
||||
// Aligns with Python: remove_domain_async(domain).
|
||||
func (s *MIoTStorage) RemoveDomainAsync(domain string) <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.RemoveDomain(domain)
|
||||
ch <- &BoolResult{OK: true, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// SaveFileAsync writes raw bytes asynchronously without hash.
|
||||
// Aligns with Python: save_file_async(domain, name_with_suffix, data).
|
||||
func (s *MIoTStorage) SaveFileAsync(domain, nameWithSuffix string, data []byte) <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.SaveFile(domain, nameWithSuffix, data)
|
||||
ch <- &BoolResult{OK: err == nil, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// LoadFileAsync reads raw bytes asynchronously without hash verification.
|
||||
// Aligns with Python: load_file_async(domain, name_with_suffix).
|
||||
func (s *MIoTStorage) LoadFileAsync(domain, nameWithSuffix string) <-chan *BytesResult {
|
||||
ch := make(chan *BytesResult, 1)
|
||||
go func() {
|
||||
data, err := s.LoadFile(domain, nameWithSuffix)
|
||||
ch <- &BytesResult{Data: data, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// RemoveFileAsync deletes a file by full name asynchronously.
|
||||
// Aligns with Python: remove_file_async(domain, name_with_suffix).
|
||||
func (s *MIoTStorage) RemoveFileAsync(domain, nameWithSuffix string) <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.RemoveFile(domain, nameWithSuffix)
|
||||
ch <- &BoolResult{OK: true, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// ClearAsync removes all files under rootPath asynchronously.
|
||||
// Aligns with Python: clear_async().
|
||||
func (s *MIoTStorage) ClearAsync() <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.Clear()
|
||||
ch <- &BoolResult{OK: true, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User configuration management
|
||||
// ============================================================================
|
||||
|
||||
// UpdateUserConfig updates (or removes) user configuration.
|
||||
// - config == nil → remove config file
|
||||
// - replace == true → overwrite entire config
|
||||
// - replace == false → merge with existing config
|
||||
// Aligns with Python: update_user_config(uid, cloud_server, config, replace).
|
||||
func (s *MIoTStorage) UpdateUserConfig(uid, cloudServer string, config map[string]interface{}, replace bool) error {
|
||||
const configDomain = "miot_config"
|
||||
configName := fmt.Sprintf("%s_%s", uid, cloudServer)
|
||||
|
||||
if config != nil && len(config) == 0 {
|
||||
return nil // empty config, no-op
|
||||
}
|
||||
if config == nil {
|
||||
return s.Remove(configDomain, configName, "dict")
|
||||
}
|
||||
if replace {
|
||||
return s.Save(configDomain, configName, config)
|
||||
}
|
||||
|
||||
existing, _ := s.Load(configDomain, configName, "dict")
|
||||
localConfig, _ := existing.(map[string]interface{})
|
||||
if localConfig == nil {
|
||||
localConfig = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range config {
|
||||
localConfig[k] = v
|
||||
}
|
||||
return s.Save(configDomain, configName, localConfig)
|
||||
}
|
||||
|
||||
// LoadUserConfig loads user configuration, optionally filtered by keys.
|
||||
// - keys == nil → return entire config
|
||||
// - len(keys) == 0 → return empty map
|
||||
// Aligns with Python: load_user_config(uid, cloud_server, keys).
|
||||
func (s *MIoTStorage) LoadUserConfig(uid, cloudServer string, keys []string) map[string]interface{} {
|
||||
if keys != nil && len(keys) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
const configDomain = "miot_config"
|
||||
configName := fmt.Sprintf("%s_%s", uid, cloudServer)
|
||||
|
||||
existing, _ := s.Load(configDomain, configName, "dict")
|
||||
localConfig, _ := existing.(map[string]interface{})
|
||||
if localConfig == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
if keys == nil {
|
||||
return localConfig
|
||||
}
|
||||
filtered := make(map[string]interface{})
|
||||
for _, key := range keys {
|
||||
if v, ok := localConfig[key]; ok {
|
||||
filtered[key] = v
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// UpdateUserConfigAsync is the async version of UpdateUserConfig.
|
||||
func (s *MIoTStorage) UpdateUserConfigAsync(uid, cloudServer string, config map[string]interface{}, replace bool) <-chan *BoolResult {
|
||||
ch := make(chan *BoolResult, 1)
|
||||
go func() {
|
||||
err := s.UpdateUserConfig(uid, cloudServer, config, replace)
|
||||
ch <- &BoolResult{OK: err == nil, Err: err}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// LoadUserConfigAsync is the async version of LoadUserConfig.
|
||||
func (s *MIoTStorage) LoadUserConfigAsync(uid, cloudServer string, keys []string) <-chan *MapResult {
|
||||
ch := make(chan *MapResult, 1)
|
||||
go func() {
|
||||
result := s.LoadUserConfig(uid, cloudServer, keys)
|
||||
ch <- &MapResult{Data: result}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// MapResult holds a map result for async user config operations.
|
||||
type MapResult struct {
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Encoding / Decoding helpers
|
||||
// ============================================================================
|
||||
|
||||
// encodeToBytes serializes a Go value to []byte, mirroring Python's __save logic.
|
||||
func encodeToBytes(data interface{}) ([]byte, error) {
|
||||
switch v := data.(type) {
|
||||
case []byte:
|
||||
return v, nil
|
||||
case string:
|
||||
return []byte(v), nil
|
||||
case map[string]interface{}:
|
||||
return json.Marshal(v)
|
||||
case []interface{}:
|
||||
return json.Marshal(v)
|
||||
default:
|
||||
// Fallback: try JSON
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported data type: %T", data)
|
||||
}
|
||||
return jsonBytes, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decodeByType decodes []byte into the Go type named by typeName.
|
||||
// Mirrors Python's __load type dispatch.
|
||||
func decodeByType(data []byte, typeName string) (interface{}, error) {
|
||||
switch typeName {
|
||||
case "bytes":
|
||||
return data, nil
|
||||
case "str":
|
||||
return string(data), nil
|
||||
case "dict":
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode dict: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
case "list":
|
||||
var result []interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode list: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported type name: %s", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MIoTCert — certificate management.
|
||||
// Aligned with Python miot_storage.py MIoTCert (lines 540–727).
|
||||
// P0: struct only; full X.509/Ed25519 methods deferred to future iteration.
|
||||
// ============================================================================
|
||||
|
||||
// MIoTCert manages MIoT device certificates.
|
||||
// It holds a reference to MIoTStorage for file I/O and knows the user identity.
|
||||
type MIoTCert struct {
|
||||
storage *MIoTStorage
|
||||
uid string
|
||||
cloudServer string
|
||||
keyName string // {uid}_{cloudServer}.key
|
||||
certName string // {uid}_{cloudServer}.cert
|
||||
}
|
||||
|
||||
// Cert domain constant.
|
||||
const certDomain = "cert"
|
||||
const caCertName = "mihome_ca.cert"
|
||||
|
||||
// NewMIoTCert creates a new MIoTCert bound to a storage and user.
|
||||
// Aligns with Python: MIoTCert.__init__(storage, uid, cloud_server, loop).
|
||||
func NewMIoTCert(storage *MIoTStorage, uid, cloudServer string) (*MIoTCert, error) {
|
||||
if storage == nil || uid == "" {
|
||||
return nil, &MIoTError{Code: CodeInvalidParams, Message: "storage and uid are required"}
|
||||
}
|
||||
return &MIoTCert{
|
||||
storage: storage,
|
||||
uid: uid,
|
||||
cloudServer: cloudServer,
|
||||
keyName: fmt.Sprintf("%s_%s.key", uid, cloudServer),
|
||||
certName: fmt.Sprintf("%s_%s.cert", uid, cloudServer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CAFile returns the CA certificate file path.
|
||||
func (c *MIoTCert) CAFile() string {
|
||||
return c.storage.GenStoragePath(certDomain, caCertName)
|
||||
}
|
||||
|
||||
// KeyFile returns the user private key file path.
|
||||
func (c *MIoTCert) KeyFile() string {
|
||||
return c.storage.GenStoragePath(certDomain, c.keyName)
|
||||
}
|
||||
|
||||
// CertFile returns the user certificate file path.
|
||||
func (c *MIoTCert) CertFile() string {
|
||||
return c.storage.GenStoragePath(certDomain, c.certName)
|
||||
}
|
||||
|
||||
// VerifyCACert verifies the integrity of the CA certificate file.
|
||||
// If the file doesn't exist, it's written from the embedded MIHOME_CA_CERT_STR constant.
|
||||
// Returns true if the stored CA cert's SHA256 matches MIHOME_CA_CERT_SHA256.
|
||||
// Aligns with Python: verify_ca_cert_async().
|
||||
func (c *MIoTCert) VerifyCACert() (bool, error) {
|
||||
caData, err := c.storage.LoadFile(certDomain, caCertName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if caData == nil {
|
||||
// First run: write the embedded CA cert.
|
||||
if err := c.storage.SaveFile(certDomain, caCertName, []byte(MIHOME_CA_CERT_STR)); err != nil {
|
||||
return false, NewMIoTStorageError("ca cert save failed: " + err.Error())
|
||||
}
|
||||
caData, err = c.storage.LoadFile(certDomain, caCertName)
|
||||
if err != nil || caData == nil {
|
||||
return false, NewMIoTStorageError("ca cert load failed after save")
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256(caData)
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
return hashStr == MIHOME_CA_CERT_SHA256, nil
|
||||
}
|
||||
|
||||
// DidHash computes the SHA1 hash of a device ID, returning its hex string.
|
||||
// Aligns with Python: __did_hash(did) — SHA1(did.encode('utf-8')).
|
||||
func DidHash(did string) string {
|
||||
h := sha1.Sum([]byte(did))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// LoadUserKey loads the user's private key as a string.
|
||||
// Returns ("", nil) if no key exists.
|
||||
func (c *MIoTCert) LoadUserKey() (string, error) {
|
||||
data, err := c.storage.LoadFile(certDomain, c.keyName)
|
||||
if err != nil || data == nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SaveUserKey saves the user's private key.
|
||||
func (c *MIoTCert) SaveUserKey(key string) error {
|
||||
return c.storage.SaveFile(certDomain, c.keyName, []byte(key))
|
||||
}
|
||||
|
||||
// LoadUserCert loads the user's certificate as a string.
|
||||
// Returns ("", nil) if no cert exists.
|
||||
func (c *MIoTCert) LoadUserCert() (string, error) {
|
||||
data, err := c.storage.LoadFile(certDomain, c.certName)
|
||||
if err != nil || data == nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// SaveUserCert saves the user's certificate.
|
||||
func (c *MIoTCert) SaveUserCert(cert string) error {
|
||||
return c.storage.SaveFile(certDomain, c.certName, []byte(cert))
|
||||
}
|
||||
|
||||
// UserCertRemainingTime returns the remaining validity time of the user cert in seconds.
|
||||
// Returns 0 if cert doesn't exist or can't be parsed.
|
||||
func (c *MIoTCert) UserCertRemainingTime() int64 {
|
||||
certPEM, err := c.LoadUserCert()
|
||||
if err != nil || certPEM == "" {
|
||||
return 0
|
||||
}
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return 0
|
||||
}
|
||||
x509Cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(x509Cert.NotAfter)
|
||||
if remaining <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(remaining.Seconds())
|
||||
}
|
||||
|
||||
// GenUserCSR generates a Certificate Signing Request using the user's Ed25519 private key.
|
||||
// Returns the CSR in PEM format.
|
||||
// Aligns with Python: MIoTCert.gen_user_csr().
|
||||
func (c *MIoTCert) GenUserCSR() (string, error) {
|
||||
// Load the Ed25519 private key
|
||||
keyPEM, err := c.LoadUserKey()
|
||||
if err != nil {
|
||||
return "", NewMIoTCertError("load user key: " + err.Error())
|
||||
}
|
||||
if keyPEM == "" {
|
||||
return "", NewMIoTCertError("user key not found")
|
||||
}
|
||||
|
||||
// Decode PEM block
|
||||
block, _ := pem.Decode([]byte(keyPEM))
|
||||
if block == nil {
|
||||
return "", NewMIoTCertError("invalid key PEM")
|
||||
}
|
||||
|
||||
// Parse the Ed25519 private key
|
||||
privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", NewMIoTCertError("parse private key: " + err.Error())
|
||||
}
|
||||
|
||||
edKey, ok := privKey.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return "", NewMIoTCertError("key is not Ed25519")
|
||||
}
|
||||
|
||||
// Create CSR template
|
||||
template := &x509.CertificateRequest{
|
||||
SignatureAlgorithm: x509.PureEd25519,
|
||||
}
|
||||
|
||||
// Generate CSR
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, edKey)
|
||||
if err != nil {
|
||||
return "", NewMIoTCertError("create CSR: " + err.Error())
|
||||
}
|
||||
|
||||
// Encode CSR to PEM
|
||||
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE REQUEST",
|
||||
Bytes: csrDER,
|
||||
})
|
||||
|
||||
return string(csrPEM), nil
|
||||
}
|
||||
|
||||
// RemoveCACert removes the CA certificate file.
|
||||
func (c *MIoTCert) RemoveCACert() error {
|
||||
return c.storage.RemoveFile(certDomain, caCertName)
|
||||
}
|
||||
|
||||
// RemoveUserKey removes the user private key file.
|
||||
func (c *MIoTCert) RemoveUserKey() error {
|
||||
return c.storage.RemoveFile(certDomain, c.keyName)
|
||||
}
|
||||
|
||||
// RemoveUserCert removes the user certificate file.
|
||||
func (c *MIoTCert) RemoveUserCert() error {
|
||||
return c.storage.RemoveFile(certDomain, c.certName)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DeviceManufacturer — manufacturer info cache.
|
||||
// Aligned with Python miot_storage.py DeviceManufacturer (lines 729–789).
|
||||
// ============================================================================
|
||||
|
||||
// DeviceManufacturer caches device manufacturer names locally,
|
||||
// with a fallback to cloud data from mi-img.com.
|
||||
type DeviceManufacturer struct {
|
||||
storage *MIoTStorage
|
||||
data map[string]interface{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const manufacturerDomain = "miot_specs"
|
||||
|
||||
// NewDeviceManufacturer creates a new DeviceManufacturer cache.
|
||||
func NewDeviceManufacturer(storage *MIoTStorage) *DeviceManufacturer {
|
||||
return &DeviceManufacturer{
|
||||
storage: storage,
|
||||
data: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Init loads manufacturer data from local cache; if expired or missing,
|
||||
// it returns nil and the caller should fetch from cloud.
|
||||
// Aligns with Python: init_async().
|
||||
func (dm *DeviceManufacturer) Init() {
|
||||
dm.mu.Lock()
|
||||
defer dm.mu.Unlock()
|
||||
|
||||
if len(dm.data) > 0 {
|
||||
return
|
||||
}
|
||||
cached, _ := dm.storage.Load(manufacturerDomain, "manufacturer", "dict")
|
||||
cachedMap, ok := cached.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dataField, ok := cachedMap["data"]; ok {
|
||||
if m, ok := dataField.(map[string]interface{}); ok {
|
||||
dm.data = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeInit clears cached manufacturer data.
|
||||
// Aligns with Python: deinit_async().
|
||||
func (dm *DeviceManufacturer) DeInit() {
|
||||
dm.mu.Lock()
|
||||
defer dm.mu.Unlock()
|
||||
dm.data = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// GetName resolves a short manufacturer name to its full name.
|
||||
// Aligns with Python: get_name(short_name).
|
||||
func (dm *DeviceManufacturer) GetName(shortName string) string {
|
||||
dm.mu.Lock()
|
||||
defer dm.mu.Unlock()
|
||||
|
||||
if len(dm.data) == 0 || shortName == "" {
|
||||
return shortName
|
||||
}
|
||||
entry, ok := dm.data[shortName]
|
||||
if !ok {
|
||||
return shortName
|
||||
}
|
||||
entryMap, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
return shortName
|
||||
}
|
||||
name, ok := entryMap["name"].(string)
|
||||
if !ok || name == "" {
|
||||
return shortName
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// SaveManufacturerData saves manufacturer data to local cache with a timestamp.
|
||||
func (dm *DeviceManufacturer) SaveManufacturerData(data map[string]interface{}, ts int64) error {
|
||||
cached := map[string]interface{}{
|
||||
"data": data,
|
||||
"ts": ts,
|
||||
}
|
||||
return dm.storage.Save(manufacturerDomain, "manufacturer", cached)
|
||||
}
|
||||
Reference in New Issue
Block a user