// 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, _ := result["has_more"].(bool) 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, _ := result["has_more"].(bool) maxID, _ := result["max_id"].(string) if hasMore && maxID != "" { moreList, err := c.getDevRoomPage(maxID) if err == nil { 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, _ := result["has_more"].(bool) if hasMore && nextStartDID != "" { nextInfos, err := c.getDeviceListPage(dids, nextStartDID) if err == nil { 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 }