Files
xiaomihome/miot/miot_storage.go
T
4566704 3a44cb9e6a feat: 初始化小米 IoT (MIoT) 智能家居 Go 库
- 实现 MIoT 客户端核心功能(MQTT 连接、设备管理、属性读写)
- 支持云端 API 调用与局域网设备发现(mDNS)
- 集成国际化(i18n)多语言支持
- 添加 MIoT 设备规约解析器(spec_parser)
- 包含单元测试与使用示例
2026-06-28 22:05:49 +08:00

936 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}