feat: 初始化小米 IoT (MIoT) 智能家居 Go 库

- 实现 MIoT 客户端核心功能(MQTT 连接、设备管理、属性读写)
- 支持云端 API 调用与局域网设备发现(mDNS)
- 集成国际化(i18n)多语言支持
- 添加 MIoT 设备规约解析器(spec_parser)
- 包含单元测试与使用示例
This commit is contained in:
2026-06-28 22:05:49 +08:00
parent ac442812b4
commit 3a44cb9e6a
118 changed files with 31630 additions and 0 deletions
+935
View File
@@ -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)
}