Files
xiaomihome/miot/miot_cloud.go
T
4566704 86c21ce91c feat: AC 三合一设备支持 + 新增 Plug/Gateway 驱动 + Cover 结构化重构
**AC 驱动增强**
- 支持 thermostat + air-conditioner + air-fresh 多服务三合一设备,属性跨服务合并解析
- 新增 specResolver.findValListInService/findInService/hasService/serviceShortName 方法
- 移除 KindThermostat,heater/electric-blanket/thermostat 重新归类为 KindAirConditioner

**Cover 窗帘重构**
- CoverProps 结构化:Position/Status/Fault 分离
- SetPosition 优先写 target-position,回退 current-position
- OnPropsChanged/FetchProps 支持多属性同步刷新

**新增设备类型**
- Plug 驱动:插座/插头,支持开合、在线状态、属性变更订阅
- Gateway 驱动:网关设备类型识别
- miot/miot_errors.go:新增 MIoT 云端错误码定义

**MIoT 客户端健壮性**
- SetProp 空结果视为失败;设备被移除或离线时自动刷新设备列表
- GetDevices 的 has_more 改用 parseBool 严格解析
- MIPS SubscribeMany 批量订阅减少往返
- matchURN 改为按段精确匹配,避免 tofan 误匹配 fan

**Bridge**
- 移除 Thermostat case,Cover 改用结构化 CoverProps
- 新增设备创建后自动订阅属性变更

**其他**
- 日志整体 Info → Debug 降噪
- .gitignore 忽略 /.codebuddy/memory
- 更新 devices_test 与 08_adv_filter_classify 示例
2026-08-23 19:42:55 +08:00

1220 lines
34 KiB
Go

// Package miot provides MIoT core client for Xiaomi Home devices.
// Ported from py-miot/miot_cloud.py — MIoTOauthClient, MIoTHttpClient.
package miot
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// TokenExpiresTSRatio is the safety margin ratio for token expiry (0.7).
// Aligns with Python TOKEN_EXPIRES_TS_RATIO.
const TokenExpiresTSRatio = 0.7
// ============================================================================
// MIoTOauthClient — OAuth authorization client (aligned with Python lines 73-228)
// ============================================================================
// MIoTOauthClient handles OAuth2 authorization for Xiaomi Home devices.
// Aligns with Python miot_cloud.py MIoTOauthClient.
type MIoTOauthClient struct {
clientID int64 // OAuth client ID
redirectURL string // OAuth redirect URL
oauthHost string // OAuth API host (region-aware)
deviceID string // device ID in format "ha.{uuid}"
state string // SHA1 state hash
httpClient *http.Client
}
// NewMIoTOauthClient creates a new MIoTOauthClient.
// clientID: OAuth client ID string
// redirectURL: OAuth callback URL
// cloudServer: cloud region code (e.g. "cn")
// uuid: unique device identifier
// Aligns with Python: MIoTOauthClient.__init__.
func NewMIoTOauthClient(clientID, redirectURL, cloudServer, uuid string) *MIoTOauthClient {
host := DEFAULT_OAUTH2_API_HOST
if cloudServer != "cn" {
host = cloudServer + "." + DEFAULT_OAUTH2_API_HOST
}
deviceID := "ha." + uuid
stateHash := sha1.Sum([]byte("d=" + deviceID))
return &MIoTOauthClient{
clientID: parseInt64(clientID),
redirectURL: redirectURL,
oauthHost: host,
deviceID: deviceID,
state: hex.EncodeToString(stateHash[:]),
httpClient: &http.Client{Timeout: time.Duration(MIHOME_HTTP_API_TIMEOUT) * time.Second},
}
}
// State returns the SHA1 state hash.
func (c *MIoTOauthClient) State() string {
return c.state
}
// SetRedirectURL updates the OAuth redirect URL.
// Returns an error if the URL is empty.
// Aligns with Python: MIoTOauthClient.set_redirect_url.
func (c *MIoTOauthClient) SetRedirectURL(redirectURL string) error {
if strings.TrimSpace(redirectURL) == "" {
return NewMIoTOauthError("invalid redirect_url")
}
c.redirectURL = redirectURL
return nil
}
// GenAuthURL generates the OAuth authorization URL.
// Aligns with Python: gen_auth_url().
func (c *MIoTOauthClient) GenAuthURL(redirectURL, state string, scope []string, skipConfirm bool) string {
params := url.Values{}
if redirectURL != "" {
params.Set("redirect_uri", redirectURL)
} else {
params.Set("redirect_uri", c.redirectURL)
}
params.Set("client_id", fmt.Sprintf("%d", c.clientID))
params.Set("response_type", "code")
params.Set("device_id", c.deviceID)
if state != "" {
params.Set("state", state)
} else {
params.Set("state", c.state)
}
if len(scope) > 0 {
params.Set("scope", strings.Join(scope, " "))
}
if skipConfirm {
params.Set("skip_confirm", "true")
} else {
params.Set("skip_confirm", "false")
}
return OAUTH2_AUTH_URL + "?" + params.Encode()
}
// getToken makes the actual token request to the OAuth endpoint.
// Aligns with Python: __get_token_async(data).
func (c *MIoTOauthClient) getToken(data map[string]interface{}) (map[string]interface{}, error) {
jsonData, _ := json.Marshal(data)
reqURL := fmt.Sprintf("https://%s/app/v2/ha/oauth/get_token", c.oauthHost)
params := url.Values{"data": {string(jsonData)}}
req, err := http.NewRequest("GET", reqURL+"?"+params.Encode(), nil)
if err != nil {
return nil, NewMIoTOauthError("token request failed: " + err.Error())
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, NewMIoTOauthError("token request failed: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 {
return nil, NewMIoTOauthError("unauthorized(401)")
}
if resp.StatusCode != 200 {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http status code, %d", resp.StatusCode))
}
var resObj map[string]interface{}
if err := json.Unmarshal(body, &resObj); err != nil {
return nil, NewMIoTOauthError("invalid response: " + err.Error())
}
code, _ := resObj["code"].(float64)
if code != 0 {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
result, ok := resObj["result"].(map[string]interface{})
if !ok {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
if _, ok := result["access_token"]; !ok {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
if _, ok := result["refresh_token"]; !ok {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
if _, ok := result["expires_in"]; !ok {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
expiresIn, _ := result["expires_in"].(float64)
result["expires_ts"] = int64(float64(time.Now().Unix()) + expiresIn*TokenExpiresTSRatio)
return result, nil
}
// GetAccessToken obtains an access token using an authorization code.
// Aligns with Python: get_access_token_async(code).
func (c *MIoTOauthClient) GetAccessToken(code string) (map[string]interface{}, error) {
return c.getToken(map[string]interface{}{
"client_id": c.clientID,
"redirect_uri": c.redirectURL,
"code": code,
"device_id": c.deviceID,
})
}
// RefreshAccessToken refreshes an access token using a refresh token.
// Aligns with Python: refresh_access_token_async(refresh_token).
func (c *MIoTOauthClient) RefreshAccessToken(refreshToken string) (map[string]interface{}, error) {
return c.getToken(map[string]interface{}{
"client_id": c.clientID,
"redirect_uri": c.redirectURL,
"refresh_token": refreshToken,
})
}
// Close closes the HTTP client connections.
// Aligns with Python: deinit_async().
func (c *MIoTOauthClient) Close() {
if c == nil || c.httpClient == nil {
return
}
c.httpClient.CloseIdleConnections()
}
// ============================================================================
// MIoTHttpClient — cloud HTTP API client (aligned with Python lines 231-849)
// ============================================================================
// MIoTHttpClient handles all cloud API calls to Xiaomi Home.
// Aligns with Python miot_cloud.py MIoTHttpClient.
type MIoTHttpClient struct {
host string
baseURL string
clientID string
accessToken string
httpClient *http.Client
// Property aggregation (aligned with Python GET_PROP_AGGREGATE_INTERVAL)
getPropTimer *time.Timer
getPropList map[string]*pendingProp
}
// pendingProp tracks a pending property read request.
type pendingProp struct {
param map[string]interface{}
fut chan interface{} // receives the value when resolved
}
// NewMIoTHttpClient creates a new MIoTHttpClient.
// Aligns with Python: MIoTHttpClient.__init__.
func NewMIoTHttpClient(cloudServer, clientID, accessToken string) (*MIoTHttpClient, error) {
c := &MIoTHttpClient{
httpClient: &http.Client{Timeout: time.Duration(MIHOME_HTTP_API_TIMEOUT) * time.Second},
getPropList: make(map[string]*pendingProp),
}
c.UpdateHTTPHeader(cloudServer, clientID, accessToken)
return c, nil
}
// UpdateHTTPHeader updates the cloud server, client ID, and access token.
// Aligns with Python: update_http_header().
func (c *MIoTHttpClient) UpdateHTTPHeader(cloudServer, clientID, accessToken string) {
if cloudServer != "" {
if cloudServer != "cn" {
c.host = cloudServer + "." + DEFAULT_OAUTH2_API_HOST
} else {
c.host = DEFAULT_OAUTH2_API_HOST
}
c.baseURL = "https://" + c.host
}
if clientID != "" {
c.clientID = clientID
}
if accessToken != "" {
c.accessToken = accessToken
}
}
// apiRequestHeaders returns the standard API request headers.
// Aligns with Python: __api_request_headers property.
func (c *MIoTHttpClient) apiRequestHeaders() map[string]string {
return map[string]string{
"Host": c.host,
"X-Client-BizId": "haapi",
"Content-Type": "application/json",
"Authorization": "Bearer" + c.accessToken,
"X-Client-AppId": c.clientID,
}
}
// ============================================================================
// Internal HTTP helpers
// ============================================================================
// mihomeAPIGet performs a GET request to the MIoT cloud API.
// Aligns with Python: __mihome_api_get_async(url_path, params).
func (c *MIoTHttpClient) mihomeAPIGet(urlPath string, params url.Values) (map[string]interface{}, error) {
reqURL := c.baseURL + urlPath
if len(params) > 0 {
reqURL += "?" + params.Encode()
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, NewMIoTHttpError("api get failed: " + err.Error())
}
for k, v := range c.apiRequestHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, NewMIoTHttpError("api get failed: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 {
return nil, NewMIoTHttpError("mihome api get failed, unauthorized(401)")
}
if resp.StatusCode != 200 {
return nil, NewMIoTHttpError(fmt.Sprintf("mihome api get failed, %d, %s, %v", resp.StatusCode, urlPath, params))
}
var resObj map[string]interface{}
if err := json.Unmarshal(body, &resObj); err != nil {
return nil, NewMIoTHttpError("parse response: " + err.Error())
}
code, _ := resObj["code"].(float64)
if code != 0 {
msg, _ := resObj["message"].(string)
return nil, NewMIoTHttpError(fmt.Sprintf("invalid response code, %.0f, %s", code, msg))
}
return resObj, nil
}
// mihomeAPIPost performs a POST request to the MIoT cloud API.
// Aligns with Python: __mihome_api_post_async(url_path, data).
func (c *MIoTHttpClient) mihomeAPIPost(urlPath string, data map[string]interface{}) (map[string]interface{}, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, NewMIoTHttpError("marshal post data: " + err.Error())
}
req, err := http.NewRequest("POST", c.baseURL+urlPath, strings.NewReader(string(jsonData)))
if err != nil {
return nil, NewMIoTHttpError("api post failed: " + err.Error())
}
for k, v := range c.apiRequestHeaders() {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, NewMIoTHttpError("api post failed: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 401 {
return nil, NewMIoTHttpError("mihome api post failed, unauthorized(401)")
}
if resp.StatusCode != 200 {
return nil, NewMIoTHttpError(fmt.Sprintf("mihome api post failed, %d, %s, %s", resp.StatusCode, urlPath, string(jsonData)))
}
var resObj map[string]interface{}
if err := json.Unmarshal(body, &resObj); err != nil {
return nil, NewMIoTHttpError("parse response: " + err.Error())
}
code, _ := resObj["code"].(float64)
if code != 0 {
msg, _ := resObj["message"].(string)
return nil, NewMIoTHttpError(fmt.Sprintf("invalid response code, %.0f, %s", code, msg))
}
return resObj, nil
}
// ============================================================================
// User info / auth
// ============================================================================
// GetUserInfo retrieves the current user's profile information.
// Aligns with Python: get_user_info_async().
func (c *MIoTHttpClient) GetUserInfo() (map[string]interface{}, error) {
req, err := http.NewRequest("GET",
"https://open.account.xiaomi.com/user/profile?clientId="+c.clientID+"&token="+c.accessToken, nil)
if err != nil {
return nil, NewMIoTOauthError("get user info: " + err.Error())
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, NewMIoTOauthError("get user info: " + err.Error())
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var resObj map[string]interface{}
if err := json.Unmarshal(body, &resObj); err != nil {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
code, _ := resObj["code"].(float64)
if code != 0 {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
data, ok := resObj["data"].(map[string]interface{})
if !ok {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
if _, ok := data["miliaoNick"]; !ok {
return nil, NewMIoTOauthError(fmt.Sprintf("invalid http response, %s", string(body)))
}
return data, nil
}
// GetCentralCert retrieves a central gateway certificate using a CSR.
// Aligns with Python: get_central_cert_async(csr).
func (c *MIoTHttpClient) GetCentralCert(csr string) (string, error) {
resObj, err := c.mihomeAPIPost("/app/v2/ha/oauth/get_central_crt", map[string]interface{}{
"csr": csr, // caller should base64-encode before passing
})
if err != nil {
return "", err
}
result, ok := resObj["result"].(map[string]interface{})
if !ok {
return "", NewMIoTHttpError("invalid response result")
}
cert, ok := result["cert"].(string)
if !ok {
return "", NewMIoTHttpError("invalid cert")
}
return cert, nil
}
// ============================================================================
// Home info
// ============================================================================
// getDevRoomPage fetches one page of device-room mapping.
// Aligns with Python: __get_dev_room_page_async(max_id).
func (c *MIoTHttpClient) getDevRoomPage(maxID string) (map[string]map[string]interface{}, error) {
reqData := map[string]interface{}{
"limit": float64(150),
}
if maxID != "" {
reqData["start_id"] = maxID
}
resObj, err := c.mihomeAPIPost("/app/v2/homeroom/get_dev_room_page", reqData)
if err != nil {
return nil, err
}
result, ok := resObj["result"].(map[string]interface{})
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
info, ok := result["info"]
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
homeList := make(map[string]map[string]interface{})
for _, home := range info.([]interface{}) {
h, ok := home.(map[string]interface{})
if !ok {
continue
}
hid, ok := h["id"]
if !ok {
continue
}
hidStr := fmt.Sprintf("%.0f", toFloat64(hid))
dids := toStringSlice(h["dids"])
roomInfo := make(map[string]map[string]interface{})
for _, room := range toSlice(h["roomlist"]) {
r, ok := room.(map[string]interface{})
if !ok {
continue
}
rid, ok := r["id"]
if !ok {
continue
}
ridStr := fmt.Sprintf("%.0f", toFloat64(rid))
roomInfo[ridStr] = map[string]interface{}{
"dids": toStringSlice(r["dids"]),
}
}
homeList[hidStr] = map[string]interface{}{
"dids": dids,
"room_info": roomInfo,
}
}
// Check for more pages
hasMore := parseBool(result["has_more"])
nextMaxID, _ := result["max_id"].(string)
if hasMore && nextMaxID != "" {
nextList, err := c.getDevRoomPage(nextMaxID)
if err != nil {
return homeList, err // return what we have so far
}
for hid, info := range nextList {
if _, exists := homeList[hid]; !exists {
homeList[hid] = map[string]interface{}{
"dids": []string{},
"room_info": map[string]map[string]interface{}{},
}
}
homeList[hid]["dids"] = append(
toStringSlice(homeList[hid]["dids"]),
toStringSlice(info["dids"])...)
for rid, rinfo := range info["room_info"].(map[string]map[string]interface{}) {
if homeList[hid]["room_info"].(map[string]map[string]interface{})[rid] == nil {
homeList[hid]["room_info"].(map[string]map[string]interface{})[rid] = map[string]interface{}{
"dids": []string{},
}
}
homeList[hid]["room_info"].(map[string]map[string]interface{})[rid]["dids"] = append(
toStringSlice(homeList[hid]["room_info"].(map[string]map[string]interface{})[rid]["dids"]),
toStringSlice(rinfo["dids"])...)
}
}
}
return homeList, nil
}
// GetHomeInfos retrieves all home and room information.
// Aligns with Python: get_homeinfos_async().
func (c *MIoTHttpClient) GetHomeInfos() (map[string]interface{}, error) {
resObj, err := c.mihomeAPIPost("/app/v2/homeroom/gethome", map[string]interface{}{
"limit": float64(150),
"fetch_share": true,
"fetch_share_dev": true,
"plat_form": float64(0),
"app_ver": float64(9),
})
if err != nil {
return nil, err
}
result, ok := resObj["result"].(map[string]interface{})
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
var uid string
homeInfos := map[string]map[string]interface{}{
"homelist": {},
"share_home_list": {},
}
for _, deviceSource := range []string{"homelist", "share_home_list"} {
for _, home := range toSlice(result[deviceSource]) {
h, ok := home.(map[string]interface{})
if !ok {
continue
}
if h["id"] == nil || h["name"] == nil || h["roomlist"] == nil {
continue
}
hid := fmt.Sprintf("%.0f", toFloat64(h["id"]))
huid := fmt.Sprintf("%.0f", toFloat64(h["uid"]))
if uid == "" && deviceSource == "homelist" {
uid = huid
}
roomInfo := make(map[string]interface{})
for _, room := range toSlice(h["roomlist"]) {
r, ok := room.(map[string]interface{})
if !ok || r["id"] == nil {
continue
}
rid := fmt.Sprintf("%.0f", toFloat64(r["id"]))
rname, _ := r["name"].(string)
roomInfo[rid] = map[string]interface{}{
"room_id": rid,
"room_name": rname,
"dids": toStringSlice(r["dids"]),
}
}
homeInfos[deviceSource][hid] = map[string]interface{}{
"home_id": hid,
"home_name": h["name"],
"city_id": h["city_id"],
"longitude": h["longitude"],
"latitude": h["latitude"],
"address": h["address"],
"dids": toStringSlice(h["dids"]),
"room_info": roomInfo,
"group_id": CalcGroupID(huid, hid),
"uid": huid,
}
}
}
// Handle pagination
hasMore := parseBool(result["has_more"])
maxID, _ := result["max_id"].(string)
if hasMore && maxID != "" {
moreList, err := c.getDevRoomPage(maxID)
if err != nil {
return nil, err
}
for _, deviceSource := range []string{"homelist", "share_home_list"} {
for hidStr, info := range moreList {
if _, exists := homeInfos[deviceSource][hidStr]; !exists {
continue
}
h, _ := homeInfos[deviceSource][hidStr].(map[string]interface{})
if h == nil {
continue
}
h["dids"] = append(toStringSlice(h["dids"]), toStringSlice(info["dids"])...)
for rid, rinfo := range info["room_info"].(map[string]map[string]interface{}) {
hRoomInfo, _ := h["room_info"].(map[string]interface{})
if hRoomInfo == nil {
hRoomInfo = make(map[string]interface{})
h["room_info"] = hRoomInfo
}
if hRoomInfo[rid] == nil {
hRoomInfo[rid] = map[string]interface{}{
"room_id": rid,
"room_name": "",
"dids": []string{},
}
}
hrEntry, _ := hRoomInfo[rid].(map[string]interface{})
if hrEntry != nil {
hrEntry["dids"] = append(
toStringSlice(hrEntry["dids"]),
toStringSlice(rinfo["dids"])...)
}
}
}
}
}
return map[string]interface{}{
"uid": uid,
"home_list": homeInfos["homelist"],
"share_home_list": homeInfos["share_home_list"],
}, nil
}
// GetUID retrieves the user's UID.
// Aligns with Python: get_uid_async().
func (c *MIoTHttpClient) GetUID() (string, error) {
info, err := c.GetHomeInfos()
if err != nil {
return "", err
}
uid, _ := info["uid"].(string)
return uid, nil
}
// ============================================================================
// Device list
// ============================================================================
// getDeviceListPage fetches one page of device information.
// Aligns with Python: __get_device_list_page_async(dids, start_did).
func (c *MIoTHttpClient) getDeviceListPage(dids []string, startDID string) (map[string]map[string]interface{}, error) {
reqData := map[string]interface{}{
"limit": float64(200),
"get_split_device": true,
"get_third_device": true,
"dids": dids,
}
if startDID != "" {
reqData["start_did"] = startDID
}
resObj, err := c.mihomeAPIPost("/app/v2/home/device_list_page", reqData)
if err != nil {
return nil, err
}
result, ok := resObj["result"].(map[string]interface{})
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
deviceInfos := make(map[string]map[string]interface{})
for _, dev := range toSlice(result["list"]) {
device, ok := dev.(map[string]interface{})
if !ok {
continue
}
did, _ := device["did"].(string)
name, _ := device["name"].(string)
urn, _ := device["spec_type"].(string)
model, _ := device["model"].(string)
if did == "" || name == "" {
continue
}
if urn == "" || model == "" {
continue
}
if strings.HasPrefix(did, "miwifi.") {
continue
}
if isUnsupportedModel(model) {
continue
}
extra, _ := device["extra"].(map[string]interface{})
fwVersion := ""
if extra != nil {
fwVersion, _ = extra["fw_version"].(string)
}
deviceInfos[did] = map[string]interface{}{
"did": did,
"uid": device["uid"],
"name": name,
"urn": urn,
"model": model,
"connect_type": device["pid"],
"token": device["token"],
"online": device["isOnline"],
"icon": device["icon"],
"parent_id": device["parent_id"],
"manufacturer": strings.Split(model, ".")[0],
"voice_ctrl": device["voice_ctrl"],
"rssi": device["rssi"],
"owner": device["owner"],
"pid": device["pid"],
"local_ip": device["local_ip"],
"ssid": device["ssid"],
"bssid": device["bssid"],
"order_time": device["orderTime"],
"fw_version": fwVersion,
}
}
// Handle pagination
nextStartDID, _ := result["next_start_did"].(string)
hasMore := parseBool(result["has_more"])
if hasMore && nextStartDID != "" {
nextInfos, err := c.getDeviceListPage(dids, nextStartDID)
if err != nil {
return nil, err
}
for k, v := range nextInfos {
deviceInfos[k] = v
}
}
return deviceInfos, nil
}
// GetDevicesWithDIDs retrieves device information for specific DIDs.
// Aligns with Python: get_devices_with_dids_async(dids).
func (c *MIoTHttpClient) GetDevicesWithDIDs(dids []string) (map[string]map[string]interface{}, error) {
if len(dids) == 0 {
return make(map[string]map[string]interface{}), nil
}
devices := make(map[string]map[string]interface{})
// Batch by 150 (aligns with Python)
for i := 0; i < len(dids); i += 150 {
end := i + 150
if end > len(dids) {
end = len(dids)
}
batch := dids[i:end]
batchResult, err := c.getDeviceListPage(batch, "")
if err != nil {
return nil, err
}
for k, v := range batchResult {
devices[k] = v
}
}
return devices, nil
}
// GetDevices retrieves all devices across homes.
// Aligns with Python: get_devices_async(home_ids).
func (c *MIoTHttpClient) GetDevices(homeIDs []string) (map[string]interface{}, error) {
homeInfos, err := c.GetHomeInfos()
if err != nil {
return nil, err
}
homes := map[string]map[string]interface{}{
"home_list": {},
"share_home_list": {},
"separated_shared_list": {},
}
devices := make(map[string]map[string]interface{})
// Process homes
for _, deviceType := range []string{"home_list", "share_home_list"} {
homesForType, _ := homeInfos[deviceType].(map[string]interface{})
if homesForType == nil {
continue
}
for homeID, hi := range homesForType {
homeInfo, _ := hi.(map[string]interface{})
if homeInfo == nil {
continue
}
if len(homeIDs) > 0 && !containsString(homeIDs, homeID) {
continue
}
homeName, _ := homeInfo["home_name"].(string)
groupID, _ := homeInfo["group_id"].(string)
uid, _ := homeInfo["uid"].(string)
homes[deviceType][homeID] = map[string]interface{}{
"home_name": homeName,
"uid": uid,
"group_id": groupID,
"room_info": map[string]string{},
}
for _, did := range toStringSlice(homeInfo["dids"]) {
devices[did] = map[string]interface{}{
"home_id": homeID,
"home_name": homeName,
"room_id": homeID,
"room_name": homeName,
"group_id": groupID,
}
}
roomInfo, _ := homeInfo["room_info"].(map[string]interface{})
for roomID, ri := range roomInfo {
rinfo, _ := ri.(map[string]interface{})
if rinfo == nil {
continue
}
roomName, _ := rinfo["room_name"].(string)
if homeEntry, ok := homes[deviceType][homeID].(map[string]interface{}); ok {
if ri, ok := homeEntry["room_info"].(map[string]string); ok {
ri[roomID] = roomName
}
}
for _, did := range toStringSlice(rinfo["dids"]) {
devices[did] = map[string]interface{}{
"home_id": homeID,
"home_name": homeName,
"room_id": roomID,
"room_name": roomName,
"group_id": groupID,
}
}
}
}
}
// Handle separated shared devices
separatedShared, err := c.GetSeparatedSharedDevices()
if err == nil && len(separatedShared) > 0 {
for did, owner := range separatedShared {
ownerInfo, _ := owner.(map[string]interface{})
if ownerInfo == nil {
continue
}
userID, _ := ownerInfo["userid"].(string)
nickname, _ := ownerInfo["nickname"].(string)
homes["separated_shared_list"][userID] = map[string]interface{}{
"home_name": nickname,
"uid": userID,
"group_id": "NotSupport",
"room_info": map[string]string{"shared_device": "shared_device"},
}
devices[did] = map[string]interface{}{
"home_id": userID,
"home_name": nickname,
"room_id": "shared_device",
"room_name": "shared_device",
"group_id": "NotSupport",
}
}
}
// Get full device info
dids := make([]string, 0, len(devices))
for did := range devices {
dids = append(dids, did)
}
results, err := c.GetDevicesWithDIDs(dids)
if err != nil {
return nil, err
}
// sub-device pattern: .s\d+$
subDevRegex := regexp.MustCompile(`\.s\d+$`)
for _, did := range dids {
if info, ok := results[did]; ok {
for k, v := range info {
devices[did][k] = v
}
} else {
delete(devices, did)
continue
}
// Handle sub-devices
match := subDevRegex.FindString(did)
if match == "" {
continue
}
device := devices[did]
delete(devices, did)
parentDID := strings.TrimSuffix(did, match)
if _, ok := devices[parentDID]; ok {
if devices[parentDID]["sub_devices"] == nil {
devices[parentDID]["sub_devices"] = make(map[string]interface{})
}
devices[parentDID]["sub_devices"].(map[string]interface{})[strings.TrimPrefix(match, ".")] = device
}
}
return map[string]interface{}{
"uid": homeInfos["uid"],
"homes": homes,
"devices": devices,
}, nil
}
// GetSeparatedSharedDevices retrieves devices shared by other users.
// Aligns with Python: get_separated_shared_devices_async().
func (c *MIoTHttpClient) GetSeparatedSharedDevices() (map[string]interface{}, error) {
deviceList, err := c.getDeviceListPage([]string{}, "")
if err != nil {
return nil, err
}
separated := make(map[string]interface{})
for did, value := range deviceList {
owner, ok := value["owner"].(map[string]interface{})
if !ok || owner == nil {
continue
}
if _, ok := owner["userid"]; !ok {
continue
}
if _, ok := owner["nickname"]; !ok {
continue
}
separated[did] = owner
}
return separated, nil
}
// ============================================================================
// Property operations
// ============================================================================
// GetProps retrieves property values in batch from the cloud.
// Aligns with Python: get_props_async(params).
func (c *MIoTHttpClient) GetProps(params []PropParam) ([]PropResult, error) {
batch := make([]map[string]interface{}, len(params))
for i, p := range params {
batch[i] = map[string]interface{}{
"did": p.DID,
"siid": float64(p.Siid),
"piid": float64(p.Piid),
}
}
resObj, err := c.mihomeAPIPost("/app/v2/miotspec/prop/get", map[string]interface{}{
"datasource": float64(1),
"params": batch,
})
if err != nil {
return nil, err
}
result, ok := resObj["result"].([]interface{})
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
results := make([]PropResult, 0, len(result))
for _, r := range result {
rm, ok := r.(map[string]interface{})
if !ok {
continue
}
did, _ := rm["did"].(string)
results = append(results, PropResult{
DID: did,
Siid: int(toFloat64(rm["siid"])),
Piid: int(toFloat64(rm["piid"])),
Code: int(toFloat64(rm["code"])),
Value: rm["value"],
})
}
return results, nil
}
// GetProp retrieves a single property value from the cloud.
// Convenience wrapper around GetProps.
// Aligns with Python: get_prop_async(did, siid, piid).
func (c *MIoTHttpClient) GetProp(did string, siid, piid int) (map[string]interface{}, error) {
results, err := c.GetProps([]PropParam{{DID: did, Siid: siid, Piid: piid}})
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, NewMIoTHttpError("GetProp: empty result")
}
r := results[0]
return map[string]interface{}{
"did": r.DID,
"siid": float64(r.Siid),
"piid": float64(r.Piid),
"code": float64(r.Code),
"value": r.Value,
}, nil
}
// SetProp sets device properties in batch via the cloud.
// Aligns with Python: set_prop_async(params).
func (c *MIoTHttpClient) SetProp(params []PropParam) ([]PropResult, error) {
batch := make([]map[string]interface{}, len(params))
for i, p := range params {
batch[i] = map[string]interface{}{
"did": p.DID,
"siid": float64(p.Siid),
"piid": float64(p.Piid),
"value": p.Value,
}
}
resObj, err := c.mihomeAPIPost("/app/v2/miotspec/prop/set", map[string]interface{}{
"params": batch,
})
if err != nil {
return nil, err
}
result, ok := resObj["result"].([]interface{})
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
results := make([]PropResult, 0, len(result))
for _, r := range result {
rm, ok := r.(map[string]interface{})
if !ok {
continue
}
did, _ := rm["did"].(string)
results = append(results, PropResult{
DID: did,
Siid: int(toFloat64(rm["siid"])),
Piid: int(toFloat64(rm["piid"])),
Code: int(toFloat64(rm["code"])),
Value: rm["value"],
})
}
return results, nil
}
// DoAction executes a device action via the cloud API.
// Aligns with Python: action_async(did, siid, aiid, in_list).
func (c *MIoTHttpClient) DoAction(did string, siid, aiid int, inList []interface{}) (map[string]interface{}, error) {
// Extract values from inList (each item is {"value": ...})
values := make([]interface{}, len(inList))
for i, item := range inList {
if m, ok := item.(map[string]interface{}); ok {
values[i] = m["value"]
} else {
values[i] = item
}
}
resObj, err := c.mihomeAPIPost("/app/v2/miotspec/action", map[string]interface{}{
"params": map[string]interface{}{
"did": did,
"siid": float64(siid),
"aiid": float64(aiid),
"in": values,
},
})
if err != nil {
return nil, err
}
result, ok := resObj["result"].(map[string]interface{})
if !ok {
return nil, NewMIoTHttpError("invalid response result")
}
return result, nil
}
// Close closes the HTTP client connections.
func (c *MIoTHttpClient) Close() {
if c == nil {
return
}
if c.getPropTimer != nil {
c.getPropTimer.Stop()
}
if c.httpClient != nil {
c.httpClient.CloseIdleConnections()
}
}
// ============================================================================
// PropParam / PropResult (aligned with Python miot_cloud.py)
// ============================================================================
// PropParam represents a property parameter for cloud API calls.
type PropParam struct {
DID string
Siid int
Piid int
Value interface{}
}
// PropResult represents a property result from cloud API calls.
type PropResult struct {
DID string
Siid int
Piid int
Code int
Value interface{}
}
// ============================================================================
// Internal helpers
// ============================================================================
// parseInt64 converts a string to int64, returning 0 on failure.
func parseInt64(s string) int64 {
var result int64
for _, c := range s {
if c >= '0' && c <= '9' {
result = result*10 + int64(c-'0')
}
}
return result
}
// toFloat64 safely converts an interface{} to float64.
func toFloat64(v interface{}) float64 {
switch val := v.(type) {
case float64:
return val
case int:
return float64(val)
case int64:
return float64(val)
case string:
return float64(parseInt64(val))
}
return 0
}
// toSlice converts an interface{} to []interface{}, returning nil if not a slice.
func toSlice(v interface{}) []interface{} {
if v == nil {
return nil
}
switch val := v.(type) {
case []interface{}:
return val
}
return nil
}
// toStringSlice converts an interface{} to []string.
func toStringSlice(v interface{}) []string {
var result []string
switch val := v.(type) {
case []string:
return val
case []interface{}:
for _, item := range val {
if s, ok := item.(string); ok {
result = append(result, s)
} else {
result = append(result, fmt.Sprintf("%v", item))
}
}
}
return result
}
// isUnsupportedModel checks if a model is in the unsupported list.
func isUnsupportedModel(model string) bool {
for _, m := range UNSUPPORTED_MODELS {
if m == model {
return true
}
}
return false
}
// containsString checks if a string slice contains a value.
func containsString(slice []string, val string) bool {
for _, s := range slice {
if s == val {
return true
}
}
return false
}
// parseBool 兼容云端多种布尔表达(bool / 0|1 / "true"|"false")。
func parseBool(v interface{}) bool {
switch n := v.(type) {
case bool:
return n
case float64:
return n != 0
case float32:
return n != 0
case int:
return n != 0
case int64:
return n != 0
case string:
return n == "true" || n == "1"
}
return false
}