**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 示例
1220 lines
34 KiB
Go
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
|
|
}
|