package mijia import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "os" "path/filepath" "reflect" "strconv" "strings" "sync" "time" ) const ( defaultBaseURL = "https://api.mijia.tech/app" defaultLoginURL = "https://account.xiaomi.com/longPolling/loginUrl" defaultServiceLoginURL = "https://account.xiaomi.com/pass/serviceLogin" availabilityTTL = 60 * time.Second ) type Option func(*Client) error // WithHTTPClient configures the HTTP transport used by the client. func WithHTTPClient(httpClient *http.Client) Option { return func(client *Client) error { if httpClient == nil { return fmt.Errorf("HTTP client must not be nil") } client.httpClient = httpClient return nil } } // WithQRWriter configures where QR login output is written. func WithQRWriter(writer io.Writer) Option { return func(client *Client) error { isNil := writer == nil if !isNil { value := reflect.ValueOf(writer) switch value.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice, reflect.UnsafePointer: isNil = value.IsNil() } } if isNil { return fmt.Errorf("QR writer must not be nil") } client.qrWriter = writer return nil } } type Client struct { authPath string authMu sync.RWMutex authData AuthData loginMu sync.Mutex httpClientMu sync.RWMutex httpClient *http.Client baseURL string loginURL string serviceLoginURL string locale string qrWriter io.Writer availabilityMu sync.Mutex availability bool availabilityValid bool availabilityAt time.Time } func NewClient(authPath string, options ...Option) (*Client, error) { resolvedPath, err := resolveAuthPath(authPath) if err != nil { return nil, err } client := &Client{ authPath: resolvedPath, httpClient: http.DefaultClient, baseURL: defaultBaseURL, loginURL: defaultLoginURL, serviceLoginURL: defaultServiceLoginURL, locale: systemLocale(), qrWriter: os.Stdout, } for _, option := range options { if option == nil { continue } if err := option(client); err != nil { return nil, err } } if err := client.loadAuthData(); err != nil { return nil, err } client.ensureIdentity() client.initSession() return client, nil } func resolveAuthPath(authPath string) (string, error) { if authPath == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } return filepath.Join(home, ".config", "mijia-api", "auth.json"), nil } info, err := os.Stat(authPath) if err == nil && info.IsDir() { return filepath.Join(authPath, "auth.json"), nil } if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("inspect auth path: %w", err) } return authPath, nil } func systemLocale() string { locale := strings.Split(os.Getenv("LC_ALL"), ".")[0] if locale == "" { locale = strings.Split(os.Getenv("LANG"), ".")[0] } if parts := strings.Split(locale, "_"); len(parts) == 2 && len(parts[1]) >= 2 { return parts[0] + "_" + strings.ToUpper(parts[1][:2]) } return "zh_CN" } func (client *Client) initSession() { client.httpClientMu.Lock() defer client.httpClientMu.Unlock() client.httpClient = cloneHTTPClient(client.httpClient) } func cloneHTTPClient(httpClient *http.Client) *http.Client { clone := *httpClient clone.Jar, _ = cookiejar.New(nil) return &clone } func (client *Client) session() *http.Client { client.httpClientMu.RLock() defer client.httpClientMu.RUnlock() return client.httpClient } func (client *Client) newSession() *http.Client { return cloneHTTPClient(client.session()) } func (client *Client) request(ctx context.Context, uri string, data any, refresh bool) (json.RawMessage, error) { if refresh { if err := client.refreshToken(ctx); err != nil { return nil, err } } payload, err := json.Marshal(data) if err != nil { return nil, fmt.Errorf("marshal request data: %w", err) } nonce, err := generateNonce() if err != nil { return nil, err } authData := client.AuthData() signedNonceValue, err := signedNonce(authData.Ssecurity, nonce) if err != nil { return nil, err } params, err := generateEncryptedParams(uri, http.MethodPost, signedNonceValue, nonce, string(payload), authData.Ssecurity) if err != nil { return nil, err } form := url.Values{ "data": {params.Data}, "rc4_hash__": {params.RC4Hash}, "signature": {params.Signature}, "ssecurity": {params.Ssecurity}, "_nonce": {params.Nonce}, } req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+uri, strings.NewReader(form.Encode())) if err != nil { return nil, fmt.Errorf("create API request: %w", err) } client.setAPIHeaders(req, authData) response, err := client.session().Do(req) if err != nil { return nil, fmt.Errorf("send API request: %w", err) } defer response.Body.Close() body, err := readHTTPResponse(response) if err != nil { return nil, fmt.Errorf("read API response: %w", err) } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("API HTTP status %d: %s", response.StatusCode, strings.TrimSpace(string(body))) } result, err := decodeAPIResponse(authData.Ssecurity, nonce, body) if err != nil { return nil, fmt.Errorf("decode API response from %s: %w", uri, err) } return result, nil } func decodeAPIResponse(ssecurity, nonce string, body []byte) (json.RawMessage, error) { var envelope struct { Code int `json:"code"` Message string `json:"message"` Desc string `json:"desc"` Result json.RawMessage `json:"result"` } if err := json.Unmarshal(body, &envelope); err != nil { decrypted, decryptErr := decryptPayload(ssecurity, nonce, string(body)) if decryptErr != nil { return nil, fmt.Errorf("decode API response: %w", decryptErr) } if err := json.Unmarshal([]byte(decrypted), &envelope); err != nil { return nil, fmt.Errorf("decode decrypted API response: %w", err) } } if envelope.Code != 0 || envelope.Result == nil { message := envelope.Message if message == "" { message = envelope.Desc } if message == "" { message = "未知错误" } return nil, &APIError{Code: envelope.Code, Message: message} } return envelope.Result, nil } func (client *Client) setAPIHeaders(request *http.Request, authData AuthData) { request.Header.Set("User-Agent", authData.UA) request.Header.Set("Accept-Encoding", "identity") request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("miot-accept-encoding", "GZIP") request.Header.Set("miot-encrypt-algorithm", "ENCRYPT-RC4") request.Header.Set("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2") request.Header.Set("Cookie", client.apiCookieHeader(authData)) } func (client *Client) apiCookieHeader(authData AuthData) string { now := time.Now() _, offset := now.Zone() zoneName := now.Location().String() if zoneName == "Local" { zoneName, _ = now.Zone() } isDaylight, dstOffset := daylightValues(now) countryCode := "CN" if parts := strings.Split(client.locale, "_"); len(parts) == 2 { countryCode = parts[1] } timezone := fmt.Sprintf("GMT%+03d:%02d", offset/3600, abs(offset/60)%60) values := []string{ "cUserId=" + authData.CUserID, "yetAnotherServiceToken=" + authData.yetAnotherServiceToken(), "serviceToken=" + authData.ServiceToken, "timezone_id=" + zoneName, "timezone=" + timezone, "is_daylight=" + strconv.Itoa(isDaylight), "dst_offset=" + strconv.Itoa(dstOffset), "channel=MI_APP_STORE", "countryCode=" + countryCode, "PassportDeviceId=" + authData.DeviceID, "locale=" + client.locale, } return strings.Join(values, ";") } func daylightValues(now time.Time) (int, int) { location := now.Location() dstOffset := 0 if now.In(location).IsDST() { dstOffset = 60 * 60 * 1000 } for date := time.Date(now.Year(), time.January, 1, 12, 0, 0, 0, location); date.Year() == now.Year(); date = date.AddDate(0, 0, 1) { if date.IsDST() { return 1, dstOffset } } return 0, dstOffset } func abs(value int) int { if value < 0 { return -value } return value } func (client *Client) Available(ctx context.Context) (bool, error) { if !client.AuthData().complete() { return false, nil } client.availabilityMu.Lock() defer client.availabilityMu.Unlock() if client.availabilityValid && time.Since(client.availabilityAt) < availabilityTTL { return client.availability, nil } _, err := client.request(ctx, "/v2/message/v2/check_new_msg", map[string]int64{"begin_at": time.Now().Unix() - 3600}, false) if err != nil { client.availability = false client.availabilityValid = false client.availabilityAt = time.Time{} return false, err } client.availability = true client.availabilityValid = true client.availabilityAt = time.Now() return true, nil }