feat: add miband bot application

This commit is contained in:
Alex
2026-05-24 21:53:04 +03:00
parent 23876f70e2
commit 7962c423b4
71 changed files with 12533 additions and 0 deletions
@@ -0,0 +1,99 @@
"""Mi Fitness —— 小米运动健康亲友数据 SDK。"""
from mi_fitness.auth import XiaomiAuth
from mi_fitness.client import MiHealthClient
from mi_fitness.exceptions import (
APIError,
AuthError,
CaptchaRequiredError,
DataNotSharedError,
DataOutOfSharedTimeScopeError,
DeviceUntrustedError,
FamilyMemberNotFoundError,
MiSDKError,
TokenExpiredError,
)
from mi_fitness.models import (
AggregatedDataItem,
AggregatedDataResponse,
AuthToken,
BloodPressureData,
CaloriesData,
CheckNewMsgResponse,
DailySummary,
DeleteRelativeResponse,
FamilyMember,
GoalData,
GoalItem,
GoalMetric,
HeartRateData,
IntensityData,
InviteMessage,
InviteResponse,
InviteUniqueIdResponse,
LatestDataItem,
LatestDataResponse,
LatestDataSnapshot,
LatestHeartRate,
MessageListResponse,
OperateInviteResponse,
RelativeListResponse,
SharedDataTypesResponse,
SleepData,
SleepSegment,
Spo2Data,
Spo2SummaryData,
StepData,
ValidStandData,
VerifiedUserInfo,
VerifyUserResponse,
WeightData,
)
__all__ = [
"APIError",
"AggregatedDataItem",
"AggregatedDataResponse",
"AuthError",
"AuthToken",
"BloodPressureData",
"CaloriesData",
"CaptchaRequiredError",
"CheckNewMsgResponse",
"DailySummary",
"DataNotSharedError",
"DataOutOfSharedTimeScopeError",
"DeleteRelativeResponse",
"DeviceUntrustedError",
"FamilyMember",
"FamilyMemberNotFoundError",
"GoalData",
"GoalItem",
"GoalMetric",
"HeartRateData",
"IntensityData",
"InviteMessage",
"InviteResponse",
"InviteUniqueIdResponse",
"LatestDataItem",
"LatestDataResponse",
"LatestDataSnapshot",
"LatestHeartRate",
"MessageListResponse",
"MiHealthClient",
"MiSDKError",
"OperateInviteResponse",
"RelativeListResponse",
"SharedDataTypesResponse",
"SleepData",
"SleepSegment",
"Spo2Data",
"Spo2SummaryData",
"StepData",
"TokenExpiredError",
"ValidStandData",
"VerifiedUserInfo",
"VerifyUserResponse",
"WeightData",
"XiaomiAuth",
]
@@ -0,0 +1,8 @@
"""小米账号认证子包。
对外只暴露 ``XiaomiAuth``,内部按登录方式拆分为独立模块。
"""
from mi_fitness.auth.manager import XiaomiAuth
__all__ = ["XiaomiAuth"]
@@ -0,0 +1,128 @@
"""认证模块内部工具函数。"""
from __future__ import annotations
import asyncio
import json
import re
from typing import TYPE_CHECKING
from loguru import logger
from mi_fitness.const import DEFAULT_LOGIN_USER_AGENT
from mi_fitness.exceptions import AuthError
from mi_fitness.http import RetryAsyncClient
if TYPE_CHECKING:
from mi_fitness.models import AuthToken
_COOKIE_DOMAINS = ("xiaomi.com", "mi.com")
def parse_mi_response(text: str) -> dict:
"""解析小米 API ``&&&START&&&`` 前缀的 JSON 响应。"""
body = text
if body.startswith("&&&START&&&"):
body = body[len("&&&START&&&") :]
try:
return json.loads(body)
except json.JSONDecodeError as e:
raise AuthError(f"响应解析失败: {text[:200]}") from e
def create_login_http() -> RetryAsyncClient:
"""创建登录流程专用的 HTTP 客户端。"""
return RetryAsyncClient(
follow_redirects=False,
timeout=30.0,
headers={
"User-Agent": DEFAULT_LOGIN_USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
},
)
def normalize_captcha_url(captcha_url: str) -> str:
"""补全图形验证码 URL。"""
if captcha_url.startswith("/"):
return f"https://account.xiaomi.com{captcha_url}"
return captcha_url
def set_cookie_for_domains(
http: RetryAsyncClient,
name: str,
value: str,
) -> None:
"""为小米登录相关域名批量写入 cookie。"""
for domain in _COOKIE_DOMAINS:
http.cookies.set(name, value, domain=domain)
async def extract_service_token(http: RetryAsyncClient, location: str) -> str:
"""跟随登录重定向,从响应 cookie 中提取 serviceToken。
Args:
http: HTTP 客户端。
location: 登录返回的重定向 URL。
Returns:
serviceToken 值。
"""
resp = await http.get(location)
service_token = ""
for header_val in resp.headers.get_list("set-cookie"):
if "serviceToken=" in header_val:
match = re.search(r"serviceToken=([^;]+)", header_val)
if match:
service_token = match.group(1)
break
if not service_token:
from urllib.parse import parse_qs, urlparse
parsed = urlparse(str(resp.headers.get("location", location)))
qs = parse_qs(parsed.query)
service_token = qs.get("serviceToken", [""])[0]
if not service_token:
service_token = str(http.cookies.get("serviceToken", "") or "")
if not service_token:
raise AuthError("未能获取 serviceToken")
return service_token
async def extract_credentials(
http: RetryAsyncClient,
data: dict,
token: "AuthToken",
) -> None:
"""从登录响应中提取并保存凭证到 token。
Args:
http: HTTP 客户端。
data: 登录接口返回的 JSON dict。
token: 要写入的 AuthToken 实例。
"""
token.ssecurity = data["ssecurity"]
token.user_id = str(data.get("userId", ""))
token.pass_token = data.get("passToken", "")
token.c_user_id = data.get("cUserId", "")
location = data.get("location", "")
if location:
service_token = await extract_service_token(http, location)
token.service_token = service_token
logger.debug(
"凭证提取完成, ssecurity={}, user_id={}",
token.ssecurity[:8] + "...",
token.user_id,
)
async def async_sleep(seconds: float) -> None:
"""异步等待,方便测试时 mock。"""
await asyncio.sleep(seconds)
@@ -0,0 +1,451 @@
"""小米账号认证管理器。
负责编排登录流程、token 持久化。具体登录实现委托给
``password``、``qr``、``passtoken`` 等子模块。
"""
from __future__ import annotations
import os
import tempfile
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Self, TypeVar
from loguru import logger
from mi_fitness.exceptions import (
AuthError,
CaptchaRequiredError,
DeviceUntrustedError,
TokenExpiredError,
)
from mi_fitness.http import RetryAsyncClient
from mi_fitness.models import AuthToken
from . import passtoken as _pt
from . import password as _pwd
from . import qr as _qr
from . import sts as _sts
from ._helpers import create_login_http
_MAX_CAPTCHA_RETRIES = 3
_CaptchaStepT = TypeVar("_CaptchaStepT")
def _write_secret_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
os.chmod(path, 0o600)
finally:
try:
tmp_path.unlink()
except FileNotFoundError:
pass
class XiaomiAuth:
"""小米账号认证管理器。
负责登录流程、token 持久化。通过 serviceLogin 获取 ssecurity
和 serviceToken,后续 API 请求使用 RC4 加密。
Attributes:
username: 小米账号(手机号或邮箱)。
token: 当前认证凭证。
"""
def __init__(
self,
username: str = "",
password: str = "",
*,
device_id: str = "",
):
"""
Args:
username: 小米账号。
password: 密码(仅登录时需要,不会被存储)。
device_id: 设备标识符。留空则自动生成随机值。新设备首次登录
会触发短信验证码验证,可通过 ``login()`` 的
``verification_code_handler`` 回调自动处理。
"""
self.username = username
self._password = password
self.token = AuthToken()
if device_id:
self.token.device_id = device_id
self._http: RetryAsyncClient | None = None
self._ticket_token: str = ""
self._token_path: Path | None = None
@classmethod
def from_token(cls, path: Path | str) -> Self:
"""从文件加载已有 token,一步完成初始化。
Args:
path: token 文件路径。
Returns:
已加载 token 的认证管理器。
Raises:
AuthError: 文件不存在或格式错误。
"""
instance = cls()
instance.load_token(path)
return instance
def _ensure_http(self) -> RetryAsyncClient:
"""确保 HTTP 客户端已初始化(惰性创建)。"""
if self._http is None:
self._http = create_login_http()
return self._http
def _ensure_device_cookie(self) -> RetryAsyncClient:
"""确保 deviceId 已生成并写入登录 cookie。"""
http = self._ensure_http()
if not self.token.device_id:
self.token.device_id = f"an_{os.urandom(16).hex()}"
http.cookies.set("deviceId", self.token.device_id)
return http
async def _run_with_captcha_retries(
self,
http: RetryAsyncClient,
action: Callable[[str], Awaitable[_CaptchaStepT]],
*,
captcha_handler: Callable[[bytes], Awaitable[str]] | None = None,
) -> _CaptchaStepT:
"""统一处理图形验证码重试。"""
captcha_code = ""
for _ in range(_MAX_CAPTCHA_RETRIES):
try:
return await action(captcha_code)
except CaptchaRequiredError as e:
if captcha_handler is None:
raise
image = await _pwd.fetch_captcha_image(http, e.captcha_url)
captcha_code = await captcha_handler(image)
raise AuthError(f"图形验证码验证失败:已连续重试 {_MAX_CAPTCHA_RETRIES}")
# region 公共方法
async def login(
self,
*,
verification_code_handler: Callable[[str], Awaitable[str]] | None = None,
captcha_handler: Callable[[bytes], Awaitable[str]] | None = None,
) -> AuthToken:
"""执行完整登录流程。
Args:
verification_code_handler: 短信验证码回调。接收脱敏手机号
(如 ``"191******54"``),返回用户输入的 6 位验证码。
新设备首次登录需要短信验证时自动调用。
若未提供且需要验证,将抛出 ``DeviceUntrustedError``。
captcha_handler: 图形验证码回调。接收验证码图片字节
(PNG/JPEG),返回用户识别的验证码文本。
当登录流程触发图形验证码风控时自动调用。
若未提供且需要验证码,将抛出 ``CaptchaRequiredError``。
Returns:
登录成功后的 AuthToken。
Raises:
AuthError: 登录失败(密码错误等)。
DeviceUntrustedError: 需要短信验证但未提供回调。
CaptchaRequiredError: 需要图形验证码但未提供回调。
"""
if not self.username or not self._password:
raise AuthError("用户名和密码不能为空")
http = self._ensure_device_cookie()
logger.info("开始小米账号登录: {}", self.username)
sign, callback = await _pwd.get_login_page(http)
try:
await _pwd.submit_login(
http,
self.token,
self.username,
self._password,
sign,
callback,
)
except DeviceUntrustedError:
if verification_code_handler is None:
raise
phone = await self.send_verification_code(
captcha_handler=captcha_handler,
)
code = await verification_code_handler(phone)
await self.login_with_verification_code(code)
return self.token
await _sts.sts_exchange(http, self.token)
self._password = ""
logger.info("登录成功, user_id={}", self.token.user_id)
return self.token
def save_token(self, path: Path | str) -> None:
"""将 token 保存到 JSON 文件,便于下次免登录恢复。
Args:
path: 保存路径。
"""
path = Path(path)
_write_secret_text(path, self.token.model_dump_json(indent=2) + "\n")
self._token_path = path
logger.info("Token 已保存至 {}", path)
def load_token(self, path: Path | str) -> AuthToken:
"""从文件加载 token。
Args:
path: token 文件路径。
Returns:
加载的 AuthToken。
Raises:
AuthError: 文件不存在或格式错误。
"""
path = Path(path)
if not path.exists():
raise AuthError(f"Token 文件不存在: {path}")
try:
data = path.read_text(encoding="utf-8")
self.token = AuthToken.model_validate_json(data)
self._token_path = path
logger.info("Token 已从 {} 加载, user_id={}", path, self.token.user_id)
return self.token
except Exception as e:
raise AuthError(f"Token 文件解析失败: {e}") from e
@property
def is_authenticated(self) -> bool:
"""检查是否已登录。"""
return bool(self.token.service_token and self.token.ssecurity)
@property
def can_refresh(self) -> bool:
"""当前 token 是否具备自动刷新条件。"""
return bool(self.token.pass_token and self.token.user_id)
async def close(self) -> None:
"""关闭 HTTP 客户端(如有)。"""
if self._http is not None:
await self._http.aclose()
self._http = None
async def refresh(self) -> AuthToken:
"""用已有 passToken 刷新 serviceToken / ssecurity。
Returns:
刷新后的 AuthToken。
Raises:
TokenExpiredError: 当前 token 无法刷新,或刷新失败。
"""
if not self.can_refresh:
raise TokenExpiredError("Token 已过期,且缺少 passToken 或 user_id,无法自动刷新")
logger.info("开始刷新登录凭证, user_id={}", self.token.user_id)
try:
token = await self.login_passtoken(
pass_token=self.token.pass_token,
user_id=self.token.user_id,
device_id=self.token.device_id,
)
except AuthError as e:
raise TokenExpiredError(f"Token 已过期,自动刷新失败: {e}") from e
if self._token_path is not None:
self.save_token(self._token_path)
logger.info("登录凭证刷新成功, user_id={}", token.user_id)
return token
async def send_verification_code(
self,
*,
captcha_handler: Callable[[bytes], Awaitable[str]] | None = None,
) -> str:
"""发送短信验证码到用户手机。
在 ``login()`` 抛出 ``DeviceUntrustedError`` 后调用此方法
手动发起短信验证流程。
Args:
captcha_handler: 图形验证码回调。接收验证码图片字节,
返回用户识别的验证码文本。未提供时触发验证码将直接抛出
``CaptchaRequiredError``。
Returns:
脱敏手机号(如 ``"191******54"``)。
Raises:
AuthError: 获取手机信息或发送验证码失败。
CaptchaRequiredError: 需要图形验证码但未提供回调。
"""
http = self._ensure_http()
await _pwd.ensure_ticket_login_ready(http)
await self._run_with_captcha_retries(
http,
lambda captcha_code: _pwd.send_ticket(
http,
self.username,
captcha_code=captcha_code,
),
captcha_handler=captcha_handler,
)
phone, ticket_token = await self._run_with_captcha_retries(
http,
lambda captcha_code: _pwd.get_phone_info(
http,
self.username,
captcha_code=captcha_code,
),
captcha_handler=captcha_handler,
)
self._ticket_token = ticket_token
logger.info("验证码已发送至 {}", phone)
return phone
async def login_with_verification_code(self, code: str) -> AuthToken:
"""使用短信验证码完成登录。
在 ``send_verification_code()`` 之后调用,提交用户收到的验证码。
Args:
code: 6 位短信验证码。
Returns:
登录成功后的 AuthToken。
Raises:
AuthError: 验证码错误或登录失败。
"""
if not self._ticket_token:
raise AuthError("请先调用 send_verification_code() 发送验证码")
http = self._ensure_http()
http.cookies.set("ticketToken", self._ticket_token)
sign, callback = await _pwd.get_login_page(http, login_sign="ticket")
await _pwd.submit_ticket_auth(http, self.token, code, sign, callback)
await _sts.sts_exchange(http, self.token)
self._password = ""
self._ticket_token = ""
logger.info("短信验证码登录成功, user_id={}", self.token.user_id)
return self.token
def __repr__(self) -> str:
status = "已认证" if self.is_authenticated else "未认证"
uid = self.token.user_id or "N/A"
return f"XiaomiAuth(user={self.username or uid!r}, {status})"
async def login_qr(
self,
*,
qr_callback: Callable[[str, str], Awaitable[None]] | None = None,
poll_interval: float = 2.0,
max_wait: float = 300.0,
) -> AuthToken:
"""二维码扫码登录(无需密码,绕过验证码风控)。
用户用小米账号 APP 扫描二维码完成登录,SDK 通过长轮询
检测扫码结果并自动提取凭证。
Args:
qr_callback: 二维码展示回调。接收 ``(qr_image_url, login_url)``
其中 ``qr_image_url`` 是二维码图片 URL(可下载显示),
``login_url`` 是备选的浏览器登录链接。
默认将信息打印到控制台。
poll_interval: 长轮询间隔(秒)。
max_wait: 扫码超时时间(秒)。
Returns:
登录成功后的 AuthToken。
Raises:
AuthError: 获取二维码失败或扫码超时。
"""
http = self._ensure_device_cookie()
await _qr.login_qr(
http,
self.token,
qr_callback=qr_callback,
poll_interval=poll_interval,
max_wait=max_wait,
)
await _sts.sts_exchange(http, self.token)
logger.info("二维码登录成功, user_id={}", self.token.user_id)
return self.token
async def login_passtoken(
self,
*,
pass_token: str = "",
user_id: str = "",
device_id: str = "",
) -> AuthToken:
"""使用 passToken 换取完整登录凭证(无需密码)。
passToken 可通过 ``migate.get_passtoken()`` 或浏览器登录小米账号
后从 Cookie 中提取获取。此方法用 passToken 调用 ``serviceLogin``
换取 ``ssecurity`` 和 ``serviceToken``。
Args:
pass_token: 小米账号 passToken。
user_id: 小米账号 userId。
device_id: 设备标识符(可选)。
Returns:
登录成功后的 AuthToken。
Raises:
AuthError: passToken 无效或换取凭证失败。
"""
http = self._ensure_http()
await _pt.login_passtoken(
http,
self.token,
pass_token=pass_token,
user_id=user_id,
device_id=device_id,
)
await _sts.sts_exchange(http, self.token)
logger.info("passToken 登录成功, user_id={}", self.token.user_id)
return self.token
# endregion
# region 上下文管理器
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
await self.close()
# endregion
@@ -0,0 +1,92 @@
"""passToken 交换登录。"""
from __future__ import annotations
import base64
import hashlib
import os
from urllib.parse import quote
from loguru import logger
from mi_fitness.const import SERVICE_SID_HEALTH, XIAOMI_LOGIN_URL
from mi_fitness.exceptions import AuthError
from mi_fitness.http import RetryAsyncClient
from mi_fitness.models import AuthToken
from ._helpers import extract_service_token, parse_mi_response, set_cookie_for_domains
async def login_passtoken(
http: RetryAsyncClient,
token: AuthToken,
*,
pass_token: str,
user_id: str,
device_id: str = "",
) -> None:
"""使用 passToken 换取完整登录凭证。
Args:
http: HTTP 客户端。
token: 要写入的 AuthToken。
pass_token: 小米账号 passToken。
user_id: 小米账号 userId。
device_id: 设备标识符(可选)。
Raises:
AuthError: passToken 无效或换取凭证失败。
"""
if not pass_token:
raise AuthError("passToken 不能为空")
if not user_id:
raise AuthError("userId 不能为空")
token.pass_token = pass_token
token.user_id = user_id
if device_id:
token.device_id = device_id
elif not token.device_id:
token.device_id = f"an_{os.urandom(16).hex()}"
# 设置 cookiespassToken + deviceId + userId
for name, value in {
"passToken": pass_token,
"deviceId": token.device_id,
"userId": user_id,
}.items():
set_cookie_for_domains(http, name, value)
logger.info("使用 passToken 换取凭证, userId={}", user_id)
# 调用 serviceLogin,带上 passToken cookie 会让服务端直接返回凭证
resp = await http.get(
XIAOMI_LOGIN_URL,
params={"_json": "true", "sid": SERVICE_SID_HEALTH},
)
resp.raise_for_status()
data = parse_mi_response(resp.text)
ssecurity = data.get("ssecurity", "")
location = data.get("location", "")
nonce_val = data.get("nonce", "")
c_user_id = data.get("cUserId", "")
if not ssecurity:
raise AuthError(
"passToken 换取凭证失败:serviceLogin 未返回 ssecurity。"
f"响应字段: {', '.join(sorted(data.keys()))}"
)
token.ssecurity = ssecurity
token.c_user_id = c_user_id
# 跟随 location 重定向获取 serviceToken
if location:
sign_text = f"nonce={nonce_val}&{ssecurity}"
sha1_digest = hashlib.sha1(sign_text.encode()).digest()
client_sign = quote(base64.b64encode(sha1_digest).decode())
full_url = f"{location}&clientSign={client_sign}"
token.service_token = await extract_service_token(http, full_url)
logger.info("passToken 凭证交换完成, user_id={}", token.user_id)
@@ -0,0 +1,328 @@
"""密码登录 + 短信验证码流程。"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from loguru import logger
from mi_fitness.const import (
APP_NAME,
ERR_DEVICE_UNTRUST,
SERVICE_SID_HEALTH,
XIAOMI_LOGIN_AUTH_URL,
XIAOMI_LOGIN_URL,
XIAOMI_PHONE_INFO_URL,
XIAOMI_PREFERENCE_URL,
XIAOMI_SEND_TICKET_URL,
XIAOMI_TICKET_AUTH_URL,
)
from mi_fitness.exceptions import AuthError, CaptchaRequiredError, DeviceUntrustedError
from mi_fitness.http import RetryAsyncClient
from mi_fitness.models import AuthToken
from ._helpers import extract_credentials, normalize_captcha_url, parse_mi_response
# region 登录页
async def get_login_page(
http: RetryAsyncClient,
*,
login_sign: str = "",
) -> tuple[str, str]:
"""请求登录页,获取 _sign 和 callback。
Args:
http: HTTP 客户端。
login_sign: 登录签名类型。``""`` 为密码登录,
``"ticket"`` 为短信验证码登录。
Returns:
(sign, callback) 元组。
"""
import re
params: dict[str, str] = {
"_json": "true",
"appName": APP_NAME,
"sid": SERVICE_SID_HEALTH,
"_locale": "zh_CN",
}
if login_sign:
params["_loginSign"] = login_sign
resp = await http.get(XIAOMI_LOGIN_URL, params=params)
resp.raise_for_status()
body = resp.text
if body.startswith("&&&START&&&"):
body = body[len("&&&START&&&") :]
try:
data = json.loads(body)
except json.JSONDecodeError:
sign_match = re.search(r'"_sign"\s*:\s*"([^"]+)"', resp.text)
callback_match = re.search(r'"callback"\s*:\s*"([^"]+)"', resp.text)
sign = sign_match.group(1) if sign_match else ""
callback = callback_match.group(1) if callback_match else ""
return sign, callback
sign = data.get("_sign", "")
callback = data.get("callback", "")
return sign, callback
# endregion
# region 密码提交
async def submit_login(
http: RetryAsyncClient,
token: AuthToken,
username: str,
password: str,
sign: str,
callback: str,
) -> None:
"""提交密码并处理登录响应。
成功时直接写入 token;设备未信任时抛出 DeviceUntrustedError。
Raises:
AuthError: 密码错误。
DeviceUntrustedError: 设备未信任,需短信验证。
"""
data = await _raw_submit_login(http, username, password, sign, callback)
if data.get("ssecurity"):
await extract_credentials(http, data, token)
return
if data.get("code") == ERR_DEVICE_UNTRUST:
raise DeviceUntrustedError(
f"登录需要二次验证(code={ERR_DEVICE_UNTRUST})。将自动进入短信验证码流程。",
security_status=16,
)
security_status = data.get("securityStatus", 0)
if security_status != 0:
raise DeviceUntrustedError(
f"设备未受信任 (securityStatus={security_status})"
f"需要短信验证码完成登录。\n"
f"请传入 verification_code_handler 回调,"
f"或手动调用 send_verification_code() + "
f"login_with_verification_code()。",
security_status=security_status,
)
keys = ", ".join(sorted(data.keys()))
raise AuthError(f"登录异常:密码正确但未返回凭证。响应字段: {keys}")
async def _raw_submit_login(
http: RetryAsyncClient,
username: str,
password: str,
sign: str,
callback: str,
) -> dict:
"""提交密码到 serviceLoginAuth2 并返回解析后的响应。"""
pwd_hash = hashlib.md5(password.encode()).hexdigest().upper()
form_data = {
"sid": SERVICE_SID_HEALTH,
"_json": "true",
"_sign": sign,
"callback": callback,
"user": username,
"hash": pwd_hash,
"qs": f"%3Fsid%3D{SERVICE_SID_HEALTH}",
"_locale": "zh_CN",
}
resp = await http.post(
XIAOMI_LOGIN_AUTH_URL,
data=form_data,
headers={"Referer": XIAOMI_LOGIN_URL},
)
resp.raise_for_status()
data = parse_mi_response(resp.text)
code = data.get("code", -1)
if code == ERR_DEVICE_UNTRUST:
return data
if code != 0:
desc = data.get("desc", "未知错误")
raise AuthError(f"登录失败 (code={code}): {desc}")
return data
# endregion
# region 短信验证码
async def ensure_ticket_login_ready(http: RetryAsyncClient) -> None:
"""请求 preference 页准备 ticket 登录上下文。"""
resp = await http.get(XIAOMI_PREFERENCE_URL, params={"_locale": "zh_CN"})
resp.raise_for_status()
data = parse_mi_response(resp.text)
if data.get("code", -1) != 0:
raise AuthError(f"登录偏好初始化失败: {data.get('description', '未知错误')}")
def _build_ticket_form_data(username: str, *, captcha_code: str = "") -> dict[str, str]:
"""构造短信验证相关接口的公共表单。"""
form_data: dict[str, str] = {
"sid": SERVICE_SID_HEALTH,
"_json": "true",
"_locale": "zh_CN",
"user": username,
}
if captcha_code:
form_data["captCode"] = captcha_code
return form_data
async def _post_ticket_request(
http: RetryAsyncClient,
url: str,
username: str,
*,
captcha_code: str = "",
error_prefix: str,
) -> dict[str, Any]:
"""提交短信验证相关请求并统一处理验证码风控。"""
resp = await http.post(
url,
data=_build_ticket_form_data(username, captcha_code=captcha_code),
)
resp.raise_for_status()
data = parse_mi_response(resp.text)
if data.get("code", -1) == 0:
return data
desc = data.get("description", "未知错误")
captcha_url = normalize_captcha_url(data.get("captchaUrl", ""))
if captcha_url:
raise CaptchaRequiredError(
f"{error_prefix}:触发了图形验证码风控 (code={data.get('code')})",
captcha_url=captcha_url,
)
raise AuthError(f"{error_prefix}: {desc}")
async def send_ticket(
http: RetryAsyncClient,
username: str,
*,
captcha_code: str = "",
) -> None:
"""发送短信验证码到用户手机。
Raises:
CaptchaRequiredError: 触发图形验证码风控。
AuthError: 发送失败。
"""
data = await _post_ticket_request(
http,
XIAOMI_SEND_TICKET_URL,
username,
captcha_code=captcha_code,
error_prefix="验证码发送失败",
)
logger.debug("验证码已发送, vCodeLen={}", data.get("data", {}).get("vCodeLen"))
async def get_phone_info(
http: RetryAsyncClient,
username: str,
*,
captcha_code: str = "",
) -> tuple[str, str]:
"""获取手机号信息和 ticketToken。
Returns:
(脱敏手机号, ticketToken) 元组。
Raises:
CaptchaRequiredError: 触发图形验证码风控。
AuthError: 获取失败。
"""
data = await _post_ticket_request(
http,
XIAOMI_PHONE_INFO_URL,
username,
captcha_code=captcha_code,
error_prefix="获取手机信息失败",
)
info = data.get("data", {})
phone = info.get("phone", "未知号码")
ticket_token = info.get("ticketToken", "")
if not ticket_token:
raise AuthError("服务端未返回 ticketToken,无法发送验证码")
return phone, ticket_token
async def fetch_captcha_image(http: RetryAsyncClient, captcha_url: str) -> bytes:
"""下载图形验证码图片。"""
logger.debug("下载图形验证码: {}", captcha_url)
resp = await http.get(captcha_url)
resp.raise_for_status()
return resp.content
async def submit_ticket_auth(
http: RetryAsyncClient,
token: AuthToken,
code: str,
sign: str,
callback: str,
) -> None:
"""使用验证码完成 serviceLoginTicketAuth 登录。
Args:
http: HTTP 客户端。
token: 要写入的 AuthToken。
code: 用户输入的 6 位短信验证码。
sign: 登录页获取的 _sign。
callback: 回调 URL。
"""
form_data = {
"sid": SERVICE_SID_HEALTH,
"_json": "true",
"_sign": sign,
"callback": callback,
"ticket": code,
"qs": (
f"%3F_loginSign%3Dticket%26_json%3Dtrue%26sid%3D{SERVICE_SID_HEALTH}%26_locale%3Dzh_CN"
),
"_locale": "zh_CN",
}
resp = await http.post(
XIAOMI_TICKET_AUTH_URL,
data=form_data,
headers={"Referer": XIAOMI_LOGIN_URL},
)
resp.raise_for_status()
data = parse_mi_response(resp.text)
code_val = data.get("code", -1)
if code_val != 0:
desc = data.get("desc", "未知错误")
raise AuthError(f"验证码验证失败 (code={code_val}): {desc}")
if not data.get("ssecurity"):
raise AuthError("验证码验证成功但未返回凭证")
await extract_credentials(http, data, token)
# endregion
+116
View File
@@ -0,0 +1,116 @@
"""二维码扫码登录。"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
import httpx
from loguru import logger
from mi_fitness.const import SERVICE_SID_HEALTH, STS_HEALTH_URL, XIAOMI_QR_LOGIN_URL
from mi_fitness.exceptions import AuthError
from mi_fitness.http import RetryAsyncClient
from mi_fitness.models import AuthToken
from ._helpers import async_sleep, extract_credentials, parse_mi_response
async def login_qr(
http: RetryAsyncClient,
token: AuthToken,
*,
qr_callback: Callable[[str, str], Awaitable[None]] | None = None,
poll_interval: float = 2.0,
max_wait: float = 300.0,
) -> None:
"""执行二维码扫码登录流程。
Args:
http: HTTP 客户端。
token: 要写入的 AuthToken。
qr_callback: 二维码展示回调。接收 ``(qr_image_url, login_url)``。
poll_interval: 长轮询间隔(秒)。
max_wait: 扫码超时时间(秒)。
Raises:
AuthError: 获取二维码失败或扫码超时。
"""
logger.info("开始二维码扫码登录")
# Step 1: 获取二维码信息
qr_params = {
"_qrsize": "480",
"qs": f"%3Fsid%3D{SERVICE_SID_HEALTH}%26_json%3Dtrue",
"callback": STS_HEALTH_URL,
"_hasLogo": "false",
"sid": SERVICE_SID_HEALTH,
"serviceParam": "",
"_locale": "zh_CN",
"_dc": str(int(time.time() * 1000)),
}
resp = await http.get(XIAOMI_QR_LOGIN_URL, params=qr_params)
resp.raise_for_status()
qr_data = parse_mi_response(resp.text)
qr_image_url = qr_data.get("qr", "")
login_url = qr_data.get("loginUrl", "")
long_polling_url = qr_data.get("lp", "")
qr_timeout = qr_data.get("timeout", max_wait)
if not qr_image_url or not long_polling_url:
raise AuthError(f"获取二维码失败: {qr_data}")
# 通知调用方展示二维码
if qr_callback:
await qr_callback(qr_image_url, login_url)
else:
logger.info("请使用小米账号 APP 扫描二维码登录")
logger.info("二维码图片: {}", qr_image_url)
if login_url:
logger.info("或在浏览器打开: {}", login_url)
# Step 2: 长轮询等待扫码
effective_timeout = min(float(qr_timeout), max_wait)
poll_request_timeout = 60.0
logger.debug(
"二维码长轮询开始: effective_timeout={}s, request_timeout={}s",
f"{effective_timeout:.0f}",
f"{poll_request_timeout:.0f}",
)
start_time = time.time()
while True:
elapsed = time.time() - start_time
if elapsed > effective_timeout:
raise AuthError(f"二维码扫码超时({effective_timeout:.0f}s),请重新获取")
try:
# 直接调用 httpx.AsyncClient.get 绕过 RetryAsyncClient 的重试
resp = await httpx.AsyncClient.request(
http, "GET", long_polling_url, timeout=poll_request_timeout
)
except (asyncio.CancelledError, KeyboardInterrupt):
logger.warning("二维码登录轮询被中断")
raise
except httpx.TimeoutException:
logger.debug("长轮询超时,继续等待...")
continue
except httpx.RequestError as e:
logger.warning("长轮询请求失败: {}", e)
await async_sleep(poll_interval)
continue
if resp.status_code != 200:
logger.debug("长轮询返回 {},继续等待...", resp.status_code)
await async_sleep(poll_interval)
continue
break
data = parse_mi_response(resp.text)
logger.info("扫码成功, userId={}", data.get("userId"))
# Step 3: 提取凭证
await extract_credentials(http, data, token)
@@ -0,0 +1,67 @@
"""STS 安全令牌交换。"""
from __future__ import annotations
import time
import os
from loguru import logger
from mi_fitness.const import STS_HEALTH_URL
from mi_fitness.http import RetryAsyncClient
from mi_fitness.models import AuthToken
async def sts_exchange(http: RetryAsyncClient, token: AuthToken) -> None:
"""STS 安全令牌交换。
使用 deviceId 完成 STS 验证。此步骤非致命,失败仅打印警告。
Args:
http: HTTP 客户端。
token: 已有 device_id 的 AuthToken。
"""
params = {
"d": token.device_id,
"ticket": "0",
"pwd": "0",
"p_ts": str(int(time.time() * 1000)),
"fid": "0",
"p_lm": "2",
"p_ur": "CN",
"sid": "hlth.io.mi.com",
}
client_sign = os.environ.get("MI_CLIENT_SIGN")
if client_sign:
params["clientSign"] = client_sign
cookies = {}
if token.user_id:
cookies["userId"] = token.user_id
if token.c_user_id:
cookies["cUserId"] = token.c_user_id
if token.pass_token:
cookies["passToken"] = token.pass_token
try:
resp = await http.get(STS_HEALTH_URL, params=params, cookies=cookies)
if resp.text.strip() == "ok":
logger.debug("STS 交换成功")
# Extract STS serviceToken from cookies and save it to AuthToken
logger.debug("Куки в клиенте во время STS:")
for cookie in http.cookies.jar:
logger.debug(f" Cookie: {cookie.name}, Domain: {cookie.domain}, Value: {cookie.value[:15]}...")
sts_token = None
for cookie in http.cookies.jar:
if cookie.name == "serviceToken" and "hlth.io.mi.com" in (cookie.domain or ""):
sts_token = cookie.value
break
if not sts_token:
sts_token = http.cookies.get("serviceToken")
if sts_token:
token.service_token = sts_token
logger.debug("STS serviceToken успешно сохранен в AuthToken: {}", sts_token[:15] + "...")
else:
logger.warning("STS 交换响应: {}", resp.text[:100])
except Exception as e:
logger.warning("STS 交换失败(非致命): {}", e)
+74
View File
@@ -0,0 +1,74 @@
"""二维码扫码登录,获取 Token。"""
from __future__ import annotations
import asyncio
from pathlib import Path
import qrcode
import qrcode.constants
from mi_fitness.auth import XiaomiAuth
from mi_fitness.exceptions import AuthError
TOKEN_FILE = Path("token.json")
def _print_qr_to_terminal(data: str) -> None:
"""用 Unicode 半块字符将二维码紧凑渲染到终端。"""
qr = qrcode.QRCode(border=1, error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
# 补齐奇数行
if len(matrix) % 2:
matrix.append([False] * len(matrix[0]))
# 每两行像素合并为一行字符:用 ▀▄█ 和空格表示四种组合
# False = 白色模块(前景色块),True = 黑色模块(背景留空)
for r in range(0, len(matrix), 2):
line: list[str] = []
for c in range(len(matrix[0])):
top_white = not matrix[r][c]
bot_white = not matrix[r + 1][c]
if top_white and bot_white:
line.append("\u2588") # █
elif top_white:
line.append("\u2580") # ▀
elif bot_white:
line.append("\u2584") # ▄
else:
line.append(" ")
print("".join(line))
async def _qr_login() -> None:
async def show_qr(qr_image_url: str, login_url: str) -> None:
print("\n📱 请用小米账号 APP 扫描二维码登录\n")
if login_url:
_print_qr_to_terminal(login_url)
print(f"\n 二维码图片: {qr_image_url}")
if login_url:
print(f" 浏览器打开: {login_url}")
print("\n⏳ 等待扫码...\n")
async with XiaomiAuth() as auth:
try:
await auth.login_qr(qr_callback=show_qr)
except AuthError as e:
print(f"❌ 扫码登录失败: {e}")
raise
auth.save_token(TOKEN_FILE)
print(f"✅ 扫码登录成功!user_id = {auth.token.user_id}")
print(f" Token 已保存至 {TOKEN_FILE.resolve()}")
def main() -> None:
"""CLI 入口。"""
asyncio.run(_qr_login())
if __name__ == "__main__":
main()
@@ -0,0 +1,5 @@
"""小米运动健康 API 客户端子包。"""
from mi_fitness.client.api import MiHealthClient
__all__ = ["MiHealthClient"]
@@ -0,0 +1,438 @@
"""MiHealthClient —— 小米运动健康 API 客户端。
薄编排层:持有 HTTP 客户端与认证状态,
所有业务逻辑委托给 relatives / data / messages 子模块。
"""
from __future__ import annotations
import asyncio
from datetime import date
from pathlib import Path
from typing import Any, Self
from mi_fitness.auth import XiaomiAuth
from mi_fitness.client import data as _data
from mi_fitness.client import messages as _msg
from mi_fitness.client import relatives as _rel
from mi_fitness.client.base import create_api_http, encrypted_request
from mi_fitness.const import HEALTH_API_BASE
from mi_fitness.exceptions import TokenExpiredError
from mi_fitness.models import (
AggregatedDataResponse,
BloodPressureData,
CaloriesData,
DailySummary,
FamilyMember,
GoalData,
HeartRateData,
IntensityData,
InviteMessage,
LatestDataItem,
LatestDataSnapshot,
SleepData,
Spo2Data,
Spo2SummaryData,
StepData,
ValidStandData,
VerifiedUserInfo,
WeightData,
)
class MiHealthClient:
"""小米运动健康 API 客户端。
通过已登录的 XiaomiAuth 实例访问亲友健康数据 API。
所有请求使用 RC4 加密,通过 cookie 认证。
Attributes:
auth: 认证管理器。
base_url: API 基础 URL。
"""
def __init__(
self,
auth: XiaomiAuth,
base_url: str = HEALTH_API_BASE,
):
"""
Args:
auth: 已通过登录的认证管理器。
base_url: API 基础 URL(默认国内节点)。
"""
self.auth = auth
self.base_url = base_url.rstrip("/")
self._http = create_api_http()
self._refresh_lock = asyncio.Lock()
@classmethod
def from_token(cls, path: Path | str, **kwargs: Any) -> Self:
"""从 token 文件一步创建客户端。
Args:
path: token 文件路径。
**kwargs: 传递给 MiHealthClient 的额外参数(如 base_url)。
Returns:
已就绪的 MiHealthClient 实例。
Raises:
AuthError: 文件不存在或格式错误。
"""
auth = XiaomiAuth.from_token(path)
return cls(auth, **kwargs)
def __repr__(self) -> str:
uid = self.auth.token.user_id or "N/A"
return f"MiHealthClient(user_id={uid!r}, base_url={self.base_url!r})"
# region 内部请求
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
_allow_refresh: bool = True,
) -> dict[str, Any]:
"""发送 RC4 加密的 API 请求。"""
expired_service_token = self.auth.token.service_token
try:
return await encrypted_request(
self._http,
self.auth.token,
method,
path,
self.base_url,
params=params,
)
except TokenExpiredError:
if not _allow_refresh:
raise
await self._refresh_auth(expired_service_token)
return await self._request(method, path, params=params, _allow_refresh=False)
async def _refresh_auth(self, expired_service_token: str) -> None:
"""串行化自动刷新,避免并发请求重复刷新 token。"""
async with self._refresh_lock:
current_service_token = self.auth.token.service_token
if (
expired_service_token
and current_service_token
and current_service_token != expired_service_token
and self.auth.is_authenticated
):
return
await self.auth.refresh()
# endregion
# region 亲友管理
async def get_relatives(self) -> list[FamilyMember]:
"""获取亲友列表。"""
return await _rel.get_relatives(self)
async def find_relative(self, keyword: str | int) -> FamilyMember:
"""按备注名或 UID 查找亲友。"""
return await _rel.find_relative(self, keyword)
async def verify_user(
self,
verify_id: int,
*,
verify_type: int = 1,
) -> VerifiedUserInfo | None:
"""按 UID 或扫码 ID 验证用户信息。"""
return await _rel.verify_user(self, verify_id, verify_type=verify_type)
async def invite_relative(
self,
relative_uid: int,
*,
shared_data_types: list[str] | None = None,
auth_time_range: int = 3,
relative_note: str = "",
) -> bool:
"""发送亲友邀请。"""
return await _rel.invite_relative(
self,
relative_uid,
shared_data_types=shared_data_types,
auth_time_range=auth_time_range,
relative_note=relative_note,
)
async def accept_invite(
self,
invite_id: int,
msg_id: int,
*,
shared_data_types: list[str] | None = None,
auth_time_range: int = 3,
) -> bool:
"""同意亲友邀请。"""
return await _rel.accept_invite(
self,
invite_id,
msg_id,
shared_data_types=shared_data_types,
auth_time_range=auth_time_range,
)
async def reject_invite(self, invite_id: int, msg_id: int) -> bool:
"""拒绝亲友邀请。"""
return await _rel.reject_invite(self, invite_id, msg_id)
async def delete_relative(self, relative_uid: int) -> bool:
"""删除亲友关系。"""
return await _rel.delete_relative(self, relative_uid)
async def get_invite_link_id(self) -> int:
"""获取二维码邀请链接 ID。"""
return await _rel.get_invite_link_id(self)
async def get_shared_data_types(
self,
relative_uid: int,
*,
direction: int = 2,
) -> list[str]:
"""获取亲友共享的数据类型列表。"""
return await _rel.get_shared_data_types(self, relative_uid, direction=direction)
async def get_applied_shared_data_types(self, relative_uid: int) -> list[str]:
"""获取已申请的共享数据类型。"""
return await _rel.get_applied_shared_data_types(self, relative_uid)
async def get_family_members(self) -> list[dict[str, Any]]:
"""获取家庭成员列表。"""
return await _rel.get_family_members(self)
async def get_topic_subscriptions(
self,
relative_uid: int,
topics: list[str] | None = None,
) -> dict[str, Any]:
"""获取亲友的消息订阅状态。"""
return await _rel.get_topic_subscriptions(self, relative_uid, topics)
# endregion
# region 数据查询
async def get_latest_items(self, relative_uid: int) -> list[LatestDataItem]:
"""获取亲友的原始最新数据项列表。"""
return await _data.get_latest_items(self, relative_uid)
async def get_latest_data(self, relative_uid: int) -> LatestDataSnapshot:
"""获取亲友的最新数据快照(强类型聚合视图)。"""
return await _data.get_latest_data(self, relative_uid)
async def get_aggregated_data(
self,
relative_uid: int,
key: str,
start_time: int,
end_time: int,
*,
tag: str = "daily_report",
limit: int = 30,
) -> AggregatedDataResponse:
"""获取亲友的聚合数据。"""
return await _data.get_aggregated_data(
self,
relative_uid,
key,
start_time,
end_time,
tag=tag,
limit=limit,
)
async def get_fitness_data(
self,
relative_uid: int,
key: str,
start_time: int,
end_time: int,
*,
limit: int = 30,
) -> AggregatedDataResponse:
"""获取亲友的原始测量/事件数据(如体重、血压、异常心率等)。"""
return await _data.get_fitness_data(
self,
relative_uid,
key,
start_time,
end_time,
limit=limit,
)
async def get_heart_rate(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[HeartRateData]:
"""获取亲友的心率数据。"""
return await _data.get_heart_rate(self, relative_uid, query_date, days=days)
async def get_sleep(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[SleepData]:
"""获取亲友的睡眠数据。"""
return await _data.get_sleep(self, relative_uid, query_date, days=days)
async def get_steps(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[StepData]:
"""获取亲友的步数数据。"""
return await _data.get_steps(self, relative_uid, query_date, days=days)
async def get_weight_history(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[WeightData]:
"""获取亲友在指定窗口内的体重测量记录。"""
return await _data.get_weight_history(self, relative_uid, query_date, days=days)
async def get_blood_pressure_history(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[BloodPressureData]:
"""获取亲友在指定窗口内的血压测量记录。"""
return await _data.get_blood_pressure_history(self, relative_uid, query_date, days=days)
async def get_weight(self, relative_uid: int) -> WeightData | None:
"""获取亲友最新体重数据。"""
return await _data.get_weight(self, relative_uid)
async def get_calories_history(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[CaloriesData]:
"""获取亲友按天聚合的活动卡路里数据。"""
return await _data.get_calories_history(self, relative_uid, query_date, days=days)
async def get_valid_stand_history(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[ValidStandData]:
"""获取亲友按天聚合的有效站立次数。"""
return await _data.get_valid_stand_history(self, relative_uid, query_date, days=days)
async def get_intensity_history(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[IntensityData]:
"""获取亲友按天聚合的中高强度活动时长。"""
return await _data.get_intensity_history(self, relative_uid, query_date, days=days)
async def get_spo2_history(
self,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[Spo2SummaryData]:
"""获取亲友按天聚合的血氧摘要。"""
return await _data.get_spo2_history(self, relative_uid, query_date, days=days)
async def get_goal(self, relative_uid: int) -> GoalData | None:
"""获取亲友最新目标完成情况。"""
return await _data.get_goal(self, relative_uid)
async def get_blood_pressure(self, relative_uid: int) -> BloodPressureData | None:
"""获取亲友最新血压数据。"""
return await _data.get_blood_pressure(self, relative_uid)
async def get_calories(self, relative_uid: int) -> CaloriesData | None:
"""获取亲友最新活动卡路里。"""
return await _data.get_calories(self, relative_uid)
async def get_valid_stand(self, relative_uid: int) -> ValidStandData | None:
"""获取亲友最新有效站立次数。"""
return await _data.get_valid_stand(self, relative_uid)
async def get_intensity(self, relative_uid: int) -> IntensityData | None:
"""获取亲友最新中高强度活动时长。"""
return await _data.get_intensity(self, relative_uid)
async def get_spo2(self, relative_uid: int) -> Spo2Data | None:
"""获取亲友最新血氧数据。"""
return await _data.get_spo2(self, relative_uid)
async def get_daily_summary(
self,
relative_uid: int,
query_date: date | None = None,
) -> DailySummary:
"""获取亲友的每日综合健康摘要。"""
return await _data.get_daily_summary(self, relative_uid, query_date)
async def get_latest_daily_summary(self, relative_uid: int) -> DailySummary:
"""获取亲友最近一次同步数据的每日综合健康摘要。"""
return await _data.get_latest_daily_summary(self, relative_uid)
# endregion
# region 消息
async def get_invite_messages(
self,
*,
limit: int = 30,
pending_only: bool = False,
) -> list[InviteMessage]:
"""获取亲友邀请消息列表。"""
return await _msg.get_invite_messages(self, limit=limit, pending_only=pending_only)
async def has_new_invite(self) -> bool:
"""检查是否有新的亲友邀请消息。"""
return await _msg.has_new_invite(self)
# endregion
# region 静态工具
@staticmethod
def _date_to_timestamps(query_date: date | None = None) -> tuple[int, int]:
"""将日期转换为当日 00:00 ~ 23:59:59 的时间戳。"""
return _data._date_to_timestamps(query_date)
# endregion
# region 生命周期
async def close(self) -> None:
"""关闭 HTTP 客户端。"""
await self._http.aclose()
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
await self.close()
# endregion
@@ -0,0 +1,205 @@
"""RC4 加密请求基础层。"""
from __future__ import annotations
import json
from typing import Any, NoReturn
from mi_fitness.const import (
DEFAULT_USER_AGENT,
ERR_NOT_RELATIVES,
ERR_NOT_SHARED_DATA_TYPE,
HEALTH_API_BASE,
REGION_TAG,
)
from mi_fitness.crypto import build_encrypted_params, decrypt_response
from mi_fitness.exceptions import (
APIError,
AuthError,
DataNotSharedError,
DataOutOfSharedTimeScopeError,
FamilyMemberNotFoundError,
TokenExpiredError,
)
from mi_fitness.http import RetryAsyncClient
from mi_fitness.models import AuthToken
def _coerce_api_code(value: Any, default: int = -1) -> int:
"""尽力兼容 int / str 形式的业务码。"""
try:
return int(value)
except (TypeError, ValueError):
return default
def _extract_api_message(result: dict[str, Any]) -> str:
"""兼容不同字段名的错误消息。"""
for key in ("message", "msg", "desc", "description"):
value = result.get(key)
if isinstance(value, str) and value.strip():
return value
return "未知错误"
def _is_time_scope_error(message: str) -> bool:
"""识别“超出亲友共享时间范围”类错误。"""
normalized = message.strip().lower()
return "time out of data shared time scope" in normalized
def _extract_requested_data_type(params: dict[str, Any] | None) -> str:
"""从业务参数中提取当前请求的数据类型 key。"""
if not isinstance(params, dict):
return ""
key = params.get("key")
return str(key) if key is not None else ""
def _build_auth_cookies(token: AuthToken) -> dict[str, str]:
"""构造健康接口请求所需的认证 cookie。"""
return {
"cUserId": token.c_user_id,
"serviceToken": token.service_token,
}
async def _send_encrypted_http_request(
http: RetryAsyncClient,
method: str,
url: str,
enc_params: dict[str, Any],
cookies: dict[str, str],
):
"""按 HTTP 方法发送已加密请求。"""
if method.upper() == "GET":
return await http.get(url, params=enc_params, cookies=cookies)
return await http.post(url, data=enc_params, cookies=cookies)
def _raise_for_http_status(resp: Any, method: str, path: str) -> None:
"""将 HTTP 状态码转换为 SDK 异常。"""
if resp.status_code == 401:
raise TokenExpiredError(f"认证已过期: {method} {path} -> 401")
if resp.status_code != 200:
raise APIError(
f"API 请求失败: {method} {path} -> {resp.status_code}",
status_code=resp.status_code,
response_body=resp.text,
)
def _decrypt_result(ssecurity: str, nonce: str, resp: Any) -> dict[str, Any]:
"""解密并校验响应体。"""
try:
result = decrypt_response(ssecurity, nonce, resp.text)
except Exception as e:
raise APIError(
f"响应解密失败: {e}",
status_code=resp.status_code,
response_body=resp.text[:200],
) from e
if not isinstance(result, dict):
raise APIError(
f"解密后非 JSON 对象: {type(result)}",
response_body=str(result)[:200],
)
return result
def _raise_for_business_code(
code: int,
result: dict[str, Any],
*,
params: dict[str, Any] | None = None,
) -> NoReturn:
"""将业务错误码映射为 SDK 异常。"""
msg = _extract_api_message(result)
body = json.dumps(result, ensure_ascii=False)
if code == ERR_NOT_RELATIVES:
raise FamilyMemberNotFoundError(f"非亲友关系 (code={code}): {msg}")
if code == ERR_NOT_SHARED_DATA_TYPE:
data_type = _extract_requested_data_type(params)
suffix = f", key={data_type}" if data_type else ""
if _is_time_scope_error(msg):
raise DataOutOfSharedTimeScopeError(
f"超出亲友共享时间范围 (code={code}{suffix}): {msg}",
data_type=data_type,
)
raise DataNotSharedError(
f"未共享该数据类型 (code={code}{suffix}): {msg}",
data_type=data_type,
)
raise APIError(
f"API 业务错误 (code={code}): {msg}",
code=code,
response_body=body,
)
def create_api_http() -> RetryAsyncClient:
"""创建 API 请求专用的 HTTP 客户端。"""
return RetryAsyncClient(
timeout=30.0,
headers={
"User-Agent": DEFAULT_USER_AGENT,
"region_tag": REGION_TAG,
"handleparams": "true",
},
)
async def encrypted_request(
http: RetryAsyncClient,
token: AuthToken,
method: str,
path: str,
base_url: str = HEALTH_API_BASE,
*,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""发送 RC4 加密的 API 请求并解密响应。
Args:
http: HTTP 客户端。
token: 已登录的 AuthToken。
method: HTTP 方法(GET / POST)。
path: API 路径。
base_url: API 基础 URL。
params: 业务参数。
Returns:
解密后的响应 JSON dict。
Raises:
APIError: 请求或解密失败。
AuthError: 当前未登录。
TokenExpiredError: 401 认证过期。
FamilyMemberNotFoundError: 非亲友关系。
DataNotSharedError: 亲友未共享当前请求的数据类型。
DataOutOfSharedTimeScopeError: 请求日期超出亲友共享时间范围。
"""
if not token.service_token or not token.ssecurity:
raise AuthError("未登录,请先调用 auth.login()")
ssecurity = token.ssecurity
signing_path = path
if path == "/healthapp/service/gen_download_url":
signing_path = "/service/gen_download_url"
enc_params = build_encrypted_params(method, signing_path, ssecurity, params)
nonce = enc_params["_nonce"]
cookies = _build_auth_cookies(token)
url = base_url.rstrip("/") + path
resp = await _send_encrypted_http_request(http, method, url, enc_params, cookies)
_raise_for_http_status(resp, method, path)
result = _decrypt_result(ssecurity, nonce, resp)
code = _coerce_api_code(result.get("code"), default=-1)
if code != 0:
_raise_for_business_code(code, result, params=params)
return result
@@ -0,0 +1,522 @@
"""健康数据查询(心率、睡眠、步数等)。"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from datetime import UTC, date, datetime, timedelta, timezone
from typing import TYPE_CHECKING, TypeVar
from pydantic import ValidationError
from mi_fitness.const import (
DATA_KEY_BLOOD_PRESSURE,
DATA_KEY_CALORIES,
DATA_KEY_GOAL,
DATA_KEY_HEART_RATE,
DATA_KEY_INTENSITY,
DATA_KEY_SLEEP,
DATA_KEY_SPO2,
DATA_KEY_STEPS,
DATA_KEY_VALID_STAND,
DATA_KEY_WEIGHT,
DATA_TAG_DAILY_REPORT,
RELATIVES_AGGREGATED_DATA_PATH,
RELATIVES_FITNESS_DATA_PATH,
RELATIVES_LATEST_DATA_PATH,
)
from mi_fitness.exceptions import DataNotSharedError, DataOutOfSharedTimeScopeError
from mi_fitness.models import (
AggregatedDataItem,
AggregatedDataResponse,
BloodPressureData,
CaloriesData,
DailySummary,
GoalData,
HeartRateData,
IntensityData,
LatestDataItem,
LatestDataResponse,
LatestDataSnapshot,
SleepData,
Spo2Data,
Spo2SummaryData,
StepData,
ValidStandData,
WeightData,
)
if TYPE_CHECKING:
from mi_fitness.client.api import MiHealthClient
_SeriesDataT = TypeVar("_SeriesDataT")
_LatestMetricT = TypeVar(
"_LatestMetricT",
GoalData,
BloodPressureData,
CaloriesData,
IntensityData,
Spo2Data,
ValidStandData,
WeightData,
)
async def _get_first_shared_or_none(
fetcher: Callable[[], Awaitable[list[_SeriesDataT]]],
) -> _SeriesDataT | None:
"""摘要接口专用:未共享时返回 None,其余异常继续向上抛。"""
try:
result = await fetcher()
except DataOutOfSharedTimeScopeError:
raise
except DataNotSharedError:
return None
return result[0] if result else None
# region 最新数据
async def _get_latest_response(client: MiHealthClient, relative_uid: int) -> LatestDataResponse:
"""获取并解析最新数据响应。"""
resp = await client._request(
"GET",
RELATIVES_LATEST_DATA_PATH,
params={"relative_uid": relative_uid},
)
return LatestDataResponse(**resp)
async def get_latest_items(client: MiHealthClient, relative_uid: int) -> list[LatestDataItem]:
"""获取亲友的原始最新数据项列表。"""
return (await _get_latest_response(client, relative_uid)).data_items
async def get_latest_data(client: MiHealthClient, relative_uid: int) -> LatestDataSnapshot:
"""获取亲友的最新数据快照(强类型聚合视图)。"""
return (await _get_latest_response(client, relative_uid)).snapshot
async def _get_latest_metric(
client: MiHealthClient,
relative_uid: int,
*,
attr_name: str,
shared_key: str,
) -> _LatestMetricT | None:
"""读取最新快照中的单项数据,并在未共享时抛出明确异常。"""
latest = await get_latest_data(client, relative_uid)
value = getattr(latest, attr_name)
if value is not None:
return value
shared_types = await client.get_shared_data_types(relative_uid)
if shared_key not in shared_types:
raise DataNotSharedError(f"未共享该数据类型: {shared_key}", data_type=shared_key)
return None
# endregion
# region 聚合数据
async def get_aggregated_data(
client: MiHealthClient,
relative_uid: int,
key: str,
start_time: int,
end_time: int,
*,
tag: str = DATA_TAG_DAILY_REPORT,
limit: int = 30,
) -> AggregatedDataResponse:
"""获取亲友的聚合数据。"""
resp = await client._request(
"GET",
RELATIVES_AGGREGATED_DATA_PATH,
params={
"relative_uid": relative_uid,
"key": key,
"tag": tag,
"start_time": start_time,
"end_time": end_time,
"limit": limit,
},
)
return AggregatedDataResponse(**resp)
async def get_fitness_data(
client: MiHealthClient,
relative_uid: int,
key: str,
start_time: int,
end_time: int,
*,
limit: int = 30,
) -> AggregatedDataResponse:
"""获取亲友的原始测量/事件数据(如体重、血压、异常心率等)。"""
resp = await client._request(
"GET",
RELATIVES_FITNESS_DATA_PATH,
params={
"relative_uid": relative_uid,
"key": key,
"start_time": start_time,
"end_time": end_time,
"limit": limit,
},
)
return AggregatedDataResponse(**resp)
# endregion
# region 便捷方法
def _date_to_timestamps(query_date: date | None = None) -> tuple[int, int]:
"""将日期转换为当日 00:00 ~ 23:59:59 的时间戳。"""
d = query_date or date.today()
tz = timezone(timedelta(hours=8))
start = int(datetime(d.year, d.month, d.day, tzinfo=tz).timestamp())
end = start + 86400 - 1
return start, end
def _build_window_timestamps(query_date: date | None, days: int) -> tuple[int, int, int]:
"""构造以 ``query_date`` 为结束日的查询窗口。"""
window_days = max(days, 1)
_, end = _date_to_timestamps(query_date)
start = end - 86400 * window_days + 1
return start, end, window_days
def _parse_series_items(
items: list[AggregatedDataItem],
parser: Callable[[AggregatedDataItem], _SeriesDataT],
) -> list[_SeriesDataT]:
"""逐条解析数据项,跳过单条脏数据。"""
series: list[_SeriesDataT] = []
for item in items:
try:
series.append(parser(item))
except ValidationError:
continue
return series
async def _get_aggregated_series(
client: MiHealthClient,
relative_uid: int,
key: str,
parser: Callable[[AggregatedDataItem], _SeriesDataT],
query_date: date | None = None,
*,
days: int = 1,
) -> list[_SeriesDataT]:
"""按日期范围拉取聚合数据并转换为目标模型。
``query_date`` 视为窗口结束日;``days=7`` 表示获取该日及之前 6 天的聚合数据。
"""
start, end, window_days = _build_window_timestamps(query_date, days)
resp = await get_aggregated_data(client, relative_uid, key, start, end, limit=window_days)
return _parse_series_items(resp.data_items, parser)
async def _get_fitness_series(
client: MiHealthClient,
relative_uid: int,
key: str,
parser: Callable[[AggregatedDataItem], _SeriesDataT],
query_date: date | None = None,
*,
days: int = 1,
) -> list[_SeriesDataT]:
"""按时间窗口拉取原始测量记录。
``get_fitness_data`` 不是按天一条的聚合接口,因此固定使用较宽松的 ``limit=30``
以避免一周内存在多次测量时被 ``days`` 误伤截断。
"""
start, end, window_days = _build_window_timestamps(query_date, days)
resp = await get_fitness_data(
client,
relative_uid,
key,
start,
end,
limit=max(window_days, 30),
)
return _parse_series_items(resp.data_items, parser)
async def get_heart_rate(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[HeartRateData]:
"""获取亲友的心率数据。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_HEART_RATE,
AggregatedDataItem.as_heart_rate,
query_date,
days=days,
)
async def get_sleep(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[SleepData]:
"""获取亲友的睡眠数据。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_SLEEP,
AggregatedDataItem.as_sleep,
query_date,
days=days,
)
async def get_steps(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[StepData]:
"""获取亲友的步数数据。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_STEPS,
AggregatedDataItem.as_steps,
query_date,
days=days,
)
async def get_calories_history(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[CaloriesData]:
"""获取亲友按天聚合的活动卡路里数据。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_CALORIES,
AggregatedDataItem.as_calories,
query_date,
days=days,
)
async def get_valid_stand_history(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[ValidStandData]:
"""获取亲友按天聚合的有效站立次数。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_VALID_STAND,
AggregatedDataItem.as_valid_stand,
query_date,
days=days,
)
async def get_intensity_history(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[IntensityData]:
"""获取亲友按天聚合的中高强度活动时长。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_INTENSITY,
AggregatedDataItem.as_intensity,
query_date,
days=days,
)
async def get_spo2_history(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[Spo2SummaryData]:
"""获取亲友按天聚合的血氧摘要。"""
return await _get_aggregated_series(
client,
relative_uid,
DATA_KEY_SPO2,
AggregatedDataItem.as_spo2,
query_date,
days=days,
)
async def get_weight_history(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[WeightData]:
"""获取亲友在指定窗口内的体重测量记录。"""
return await _get_fitness_series(
client,
relative_uid,
DATA_KEY_WEIGHT,
AggregatedDataItem.as_weight,
query_date,
days=days,
)
async def get_blood_pressure_history(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
*,
days: int = 1,
) -> list[BloodPressureData]:
"""获取亲友在指定窗口内的血压测量记录。"""
return await _get_fitness_series(
client,
relative_uid,
DATA_KEY_BLOOD_PRESSURE,
AggregatedDataItem.as_blood_pressure,
query_date,
days=days,
)
async def get_weight(client: MiHealthClient, relative_uid: int) -> WeightData | None:
"""获取亲友最新体重数据。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="weight",
shared_key=DATA_KEY_WEIGHT,
)
async def get_goal(client: MiHealthClient, relative_uid: int) -> GoalData | None:
"""获取亲友最新目标完成情况。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="goal",
shared_key=DATA_KEY_GOAL,
)
async def get_blood_pressure(client: MiHealthClient, relative_uid: int) -> BloodPressureData | None:
"""获取亲友最新血压数据。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="blood_pressure",
shared_key=DATA_KEY_BLOOD_PRESSURE,
)
async def get_calories(client: MiHealthClient, relative_uid: int) -> CaloriesData | None:
"""获取亲友最新活动卡路里。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="calories",
shared_key=DATA_KEY_CALORIES,
)
async def get_valid_stand(client: MiHealthClient, relative_uid: int) -> ValidStandData | None:
"""获取亲友最新有效站立次数。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="valid_stand",
shared_key=DATA_KEY_VALID_STAND,
)
async def get_intensity(client: MiHealthClient, relative_uid: int) -> IntensityData | None:
"""获取亲友最新中高强度活动时长。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="intensity",
shared_key=DATA_KEY_INTENSITY,
)
async def get_spo2(client: MiHealthClient, relative_uid: int) -> Spo2Data | None:
"""获取亲友最新血氧数据。"""
return await _get_latest_metric(
client,
relative_uid,
attr_name="spo2",
shared_key=DATA_KEY_SPO2,
)
async def get_daily_summary(
client: MiHealthClient,
relative_uid: int,
query_date: date | None = None,
) -> DailySummary:
"""获取亲友的每日综合健康摘要(并发请求心率、睡眠、步数)。"""
d = query_date or date.today()
heart_rate, sleep, steps = await asyncio.gather(
_get_first_shared_or_none(lambda: get_heart_rate(client, relative_uid, d)),
_get_first_shared_or_none(lambda: get_sleep(client, relative_uid, d)),
_get_first_shared_or_none(lambda: get_steps(client, relative_uid, d)),
)
return DailySummary(
date=d.isoformat(),
relative_uid=relative_uid,
heart_rate=heart_rate,
sleep=sleep,
steps=steps,
)
async def get_latest_daily_summary(
client: MiHealthClient,
relative_uid: int,
) -> DailySummary:
"""获取亲友最近一次有同步数据的每日综合健康摘要。"""
member = await client.find_relative(relative_uid)
query_date = date.today()
if member.latest_data_time > 0:
query_date = datetime.fromtimestamp(member.latest_data_time, tz=UTC).date()
else:
latest = await get_latest_data(client, relative_uid)
if latest.updated_time > 0:
query_date = datetime.fromtimestamp(latest.updated_time, tz=UTC).date()
return await get_daily_summary(client, relative_uid, query_date)
# endregion
@@ -0,0 +1,52 @@
"""消息(邀请通知)查询。"""
from __future__ import annotations
from typing import TYPE_CHECKING
from loguru import logger
from mi_fitness.const import (
MESSAGE_CHECK_NEW_PATH,
MESSAGE_GET_LIST_PATH,
MESSAGE_MODULE_RELATIVES,
)
from mi_fitness.models import CheckNewMsgResponse, InviteMessage, MessageListResponse
if TYPE_CHECKING:
from mi_fitness.client.api import MiHealthClient
async def get_invite_messages(
client: MiHealthClient,
*,
limit: int = 30,
pending_only: bool = False,
) -> list[InviteMessage]:
"""获取亲友邀请消息列表。"""
resp = await client._request(
"POST",
MESSAGE_GET_LIST_PATH,
params={"module": MESSAGE_MODULE_RELATIVES, "limit": limit},
)
parsed = MessageListResponse(**resp)
messages = parsed.messages
if pending_only:
messages = [m for m in messages if m.is_pending]
logger.debug(
"获取邀请消息: {}条 (待处理: {}条)",
len(parsed.messages),
sum(1 for m in parsed.messages if m.is_pending),
)
return messages
async def has_new_invite(client: MiHealthClient) -> bool:
"""检查是否有新的亲友邀请消息。"""
resp = await client._request(
"POST",
MESSAGE_CHECK_NEW_PATH,
params={"module": [MESSAGE_MODULE_RELATIVES], "begin_time": 0},
)
parsed = CheckNewMsgResponse(**resp)
return parsed.has_new(MESSAGE_MODULE_RELATIVES)
@@ -0,0 +1,236 @@
"""亲友关系管理(添加 / 删除 / 设置)。"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from loguru import logger
from mi_fitness.const import (
ALL_SHARED_DATA_TYPES,
RELATIVES_DELETE_PATH,
RELATIVES_GET_APPLIED_SHARED_TYPES_PATH,
RELATIVES_GET_FAMILY_MEMBER_PATH,
RELATIVES_GET_INVITE_ID_PATH,
RELATIVES_GET_SHARED_TYPES_PATH,
RELATIVES_GET_TOPIC_SUBS_PATH,
RELATIVES_LIST_PATH,
RELATIVES_OPERATE_INVITE_PATH,
RELATIVES_SEND_INVITE_PATH,
RELATIVES_VERIFY_USER_PATH,
VERIFY_TYPE_XIAOMI_ID,
)
from mi_fitness.exceptions import FamilyMemberNotFoundError
from mi_fitness.models import (
DeleteRelativeResponse,
FamilyMember,
FamilyMemberResponse,
InviteResponse,
InviteUniqueIdResponse,
OperateInviteResponse,
RelativeListResponse,
SharedDataTypesResponse,
VerifiedUserInfo,
VerifyUserResponse,
)
if TYPE_CHECKING:
from mi_fitness.client.api import MiHealthClient
_DEFAULT_TOPICS = ("abnormal_event",)
def _build_auth_content(
shared_data_types: list[str] | None,
auth_time_range: int,
) -> dict[str, Any]:
"""构造亲友邀请相关的 auth_content。"""
return {
"auth_time_range": auth_time_range,
"auth_data": shared_data_types or ALL_SHARED_DATA_TYPES,
}
async def get_relatives(client: MiHealthClient) -> list[FamilyMember]:
"""获取亲友列表。"""
resp = await client._request("GET", RELATIVES_LIST_PATH)
parsed = RelativeListResponse(**resp)
members = parsed.relatives
logger.info("获取到 {} 位亲友", len(members))
return members
async def find_relative(client: MiHealthClient, keyword: str | int) -> FamilyMember:
"""按备注名或 UID 查找亲友。"""
members = await get_relatives(client)
for m in members:
if (isinstance(keyword, int) and m.relative_uid == keyword) or (
isinstance(keyword, str) and keyword.lower() in m.relative_note.lower()
):
return m
raise FamilyMemberNotFoundError(f"未找到亲友: {keyword}")
async def verify_user(
client: MiHealthClient,
verify_id: int,
*,
verify_type: int = VERIFY_TYPE_XIAOMI_ID,
) -> VerifiedUserInfo | None:
"""按 UID 或扫码 ID 验证用户信息。"""
resp = await client._request(
"GET",
RELATIVES_VERIFY_USER_PATH,
params={"verify_id": verify_id, "verify_type": verify_type},
)
parsed = VerifyUserResponse(**resp)
return parsed.user_info
async def invite_relative(
client: MiHealthClient,
relative_uid: int,
*,
shared_data_types: list[str] | None = None,
auth_time_range: int = 3,
relative_note: str = "",
) -> bool:
"""发送亲友邀请。"""
params: dict[str, Any] = {
"auth_content": _build_auth_content(shared_data_types, auth_time_range),
"relative_uid": relative_uid,
}
if relative_note:
params["relative_note"] = relative_note
resp = await client._request("POST", RELATIVES_SEND_INVITE_PATH, params=params)
parsed = InviteResponse(**resp)
logger.info("邀请发送 {} (uid={})", "成功" if parsed.success else "失败", relative_uid)
return parsed.success
async def accept_invite(
client: MiHealthClient,
invite_id: int,
msg_id: int,
*,
shared_data_types: list[str] | None = None,
auth_time_range: int = 3,
) -> bool:
"""同意亲友邀请。"""
return await _operate_invite(
client,
invite_id,
msg_id,
operate=1,
shared_data_types=shared_data_types,
auth_time_range=auth_time_range,
)
async def reject_invite(
client: MiHealthClient,
invite_id: int,
msg_id: int,
) -> bool:
"""拒绝亲友邀请。"""
return await _operate_invite(client, invite_id, msg_id, operate=2)
async def _operate_invite(
client: MiHealthClient,
invite_id: int,
msg_id: int,
*,
operate: int,
shared_data_types: list[str] | None = None,
auth_time_range: int = 3,
) -> bool:
"""操作亲友邀请(内部)。"""
params: dict[str, Any] = {
"auth_content": _build_auth_content(shared_data_types, auth_time_range),
"invite_id": invite_id,
"msg_id": msg_id,
"operate": operate,
}
resp = await client._request("POST", RELATIVES_OPERATE_INVITE_PATH, params=params)
parsed = OperateInviteResponse(**resp)
op_name = "同意" if operate == 1 else "拒绝"
logger.info(
"{}邀请 {} (invite_id={})",
op_name,
"成功" if parsed.success else "失败",
invite_id,
)
return parsed.success
async def delete_relative(client: MiHealthClient, relative_uid: int) -> bool:
"""删除亲友关系。"""
resp = await client._request(
"POST",
RELATIVES_DELETE_PATH,
params={"relative_uid": relative_uid},
)
parsed = DeleteRelativeResponse(**resp)
logger.info("删除亲友 {} (uid={})", "成功" if parsed.success else "失败", relative_uid)
return parsed.success
async def get_invite_link_id(client: MiHealthClient) -> int:
"""获取二维码邀请链接 ID。"""
resp = await client._request("GET", RELATIVES_GET_INVITE_ID_PATH)
parsed = InviteUniqueIdResponse(**resp)
logger.debug("获取邀请 ID: {}", parsed.invite_link_id)
return parsed.invite_link_id
async def get_shared_data_types(
client: MiHealthClient,
relative_uid: int,
*,
direction: int = 2,
) -> list[str]:
"""获取亲友共享的数据类型列表。"""
resp = await client._request(
"GET",
RELATIVES_GET_SHARED_TYPES_PATH,
params={"relative_uid": relative_uid, "type": direction},
)
parsed = SharedDataTypesResponse(**resp)
return parsed.keys
async def get_applied_shared_data_types(
client: MiHealthClient,
relative_uid: int,
) -> list[str]:
"""获取已申请的共享数据类型。"""
resp = await client._request(
"GET",
RELATIVES_GET_APPLIED_SHARED_TYPES_PATH,
params={"relative_uid": relative_uid},
)
return resp.get("result", {}).get("keys", [])
async def get_family_members(client: MiHealthClient) -> list[dict[str, Any]]:
"""获取家庭成员列表。"""
resp = await client._request("GET", RELATIVES_GET_FAMILY_MEMBER_PATH)
parsed = FamilyMemberResponse(**resp)
return parsed.family_user_list
async def get_topic_subscriptions(
client: MiHealthClient,
relative_uid: int,
topics: list[str] | None = None,
) -> dict[str, Any]:
"""获取亲友的消息订阅状态。"""
topic_list = list(topics or _DEFAULT_TOPICS)
resp = await client._request(
"GET",
RELATIVES_GET_TOPIC_SUBS_PATH,
params={"relative_uid": relative_uid, "topics": topic_list},
)
return resp.get("result", {})
+95
View File
@@ -0,0 +1,95 @@
"""常量与配置。"""
# region 小米账号 OAuth 端点
XIAOMI_LOGIN_URL = "https://account.xiaomi.com/pass/serviceLogin"
XIAOMI_LOGIN_AUTH_URL = "https://account.xiaomi.com/pass/serviceLoginAuth2"
XIAOMI_PREFERENCE_URL = "https://account.xiaomi.com/pass/preference"
XIAOMI_PHONE_INFO_URL = "https://account.xiaomi.com/pass/phoneInfo"
XIAOMI_SEND_TICKET_URL = "https://account.xiaomi.com/pass/sendServiceLoginTicket"
XIAOMI_TICKET_AUTH_URL = "https://account.xiaomi.com/pass/serviceLoginTicketAuth"
XIAOMI_QR_LOGIN_URL = "https://account.xiaomi.com/longPolling/loginUrl"
# endregion
# region 服务 SIDserviceLogin 的 sid 参数)
SERVICE_SID_HEALTH = "miothealth"
# endregion
# region STS (安全令牌交换) 端点
STS_HEALTH_URL = "https://sts-hlth.io.mi.com/healthapp/sts"
# endregion
# region API 基础 URL
HEALTH_API_BASE = "https://ru.hlth.io.mi.com"
# endregion
# region 亲友 API 路径
RELATIVES_LIST_PATH = "/app/v1/relatives/get_relative_list"
RELATIVES_LATEST_DATA_PATH = "/app/v1/data/get_latest_fitness_data"
RELATIVES_AGGREGATED_DATA_PATH = "/app/v1/data/get_aggregated_fitness_data_by_time"
RELATIVES_FITNESS_DATA_PATH = "/app/v1/data/get_fitness_data_by_time"
RELATIVES_VERIFY_USER_PATH = "/app/v1/relatives/verify_userinfo_by_id"
RELATIVES_SEND_INVITE_PATH = "/app/v1/relatives/send_invite"
RELATIVES_OPERATE_INVITE_PATH = "/app/v1/relatives/operate_invite"
RELATIVES_DELETE_PATH = "/app/v1/relatives/delete_relative"
RELATIVES_GET_SHARED_TYPES_PATH = "/app/v1/relatives/get_shared_data_types"
RELATIVES_GET_APPLIED_SHARED_TYPES_PATH = "/app/v1/relatives/get_applied_shared_data_types"
RELATIVES_GET_FAMILY_MEMBER_PATH = "/app/v1/relatives/get_family_member"
RELATIVES_GET_INVITE_ID_PATH = "/app/v1/relatives/get_invite_unique_id"
RELATIVES_GET_TOPIC_SUBS_PATH = "/app/v1/relatives/get_topic_subscriptions"
# endregion
# region 消息 API 路径
MESSAGE_GET_LIST_PATH = "/app/v1/message/get_msg_list"
MESSAGE_CHECK_NEW_PATH = "/app/v1/message/check_new_msg"
MESSAGE_MODULE_RELATIVES = 1
# endregion
# region 业务错误码
ERR_NOT_RELATIVES = -4002001
ERR_NOT_SHARED_DATA_TYPE = -4002004
ERR_DEVICE_UNTRUST = 70016
# endregion
# region 数据类型 key(用于 get_aggregated_data / get_fitness_data 请求)
DATA_KEY_GOAL = "goal"
DATA_KEY_HEART_RATE = "heart_rate"
DATA_KEY_SLEEP = "sleep"
DATA_KEY_BLOOD_PRESSURE = "blood_pressure"
DATA_KEY_STEPS = "steps"
DATA_KEY_CALORIES = "calories"
DATA_KEY_VALID_STAND = "valid_stand"
DATA_KEY_INTENSITY = "intensity"
DATA_KEY_WEIGHT = "weight"
DATA_KEY_SPO2 = "spo2"
DATA_TAG_DAILY_REPORT = "daily_report"
# endregion
# region 可共享数据类型(send_invite 的 auth_data 全量)
ALL_SHARED_DATA_TYPES: tuple[str, ...] = (
"goal",
"heart_rate",
"sleep",
"blood_pressure",
"steps",
"calories",
"valid_stand",
"intensity",
"weight",
"spo2",
)
# endregion
# region verify_userinfo_by_id 的 verify_type 枚举
VERIFY_TYPE_XIAOMI_ID = 1
# endregion
# region HTTP 公共 Header
DEFAULT_USER_AGENT = "Android-12-3.53.1-vivo-V2284A"
DEFAULT_LOGIN_USER_AGENT = (
"Dalvik/2.1.0 (Linux; U; Android 12; V2284A Build/ab8c0d1.1) "
"APP/mi.health APPV/353001 MK/VjIyODRB "
"SDKV/5.3.0.release.68 CPN/com.mi.health PassportSDK/"
)
APP_NAME = "com.mi.health"
REGION_TAG = "ru"
# endregion
+289
View File
@@ -0,0 +1,289 @@
"""小米云服务加密模块。"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import struct
import time
from typing import Any
def _rc4_crypt(key: bytes, data: bytes, *, skip: int = 1024) -> bytes:
"""RC4 加密/解密(带前 N 字节跳过,防止密钥流弱点)。
Args:
key: RC4 密钥。
data: 待加密/解密的数据。
skip: 跳过前 N 字节的密钥流(默认 1024)。
Returns:
加密/解密后的字节数据。
"""
s = list(range(256))
j = 0
# KSA (Key-Scheduling Algorithm)
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
# PRGA (Pseudo-Random Generation Algorithm)
i = 0
j = 0
# 跳过前 skip 字节密钥流
for _ in range(skip):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
# 加密/解密
result = bytearray(len(data))
for idx in range(len(data)):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
result[idx] = data[idx] ^ s[(s[i] + s[j]) & 0xFF]
return bytes(result)
def generate_nonce() -> str:
"""生成请求 nonce。
格式: base64(random_8_bytes + minutes_since_epoch_4bytes_BE)
Returns:
Base64 编码的 nonce 字符串。
"""
random_part = os.urandom(8)
minutes = int(time.time() / 60)
time_part = struct.pack(">I", minutes)
return base64.b64encode(random_part + time_part).decode()
def compute_signed_nonce(ssecurity: str, nonce: str) -> str:
"""计算签名 nonce(用于密钥派生)。
signed_nonce = base64(SHA256(b64decode(ssecurity) + b64decode(nonce)))
Args:
ssecurity: 登录时获取的 ssecuritybase64 编码)。
nonce: 请求 nonce。
Returns:
Base64 编码的 signed_nonce(同时作为 RC4 和 HMAC 的密钥)。
"""
hash_val = hashlib.sha256(base64.b64decode(ssecurity) + base64.b64decode(nonce)).digest()
return base64.b64encode(hash_val).decode()
# endregion
# region 签名生成
def _sha1_b64(message: str) -> str:
"""纯 SHA1 哈希 → Base64 编码。
App 使用 MessageDigest("SHA1") 而非 HMAC 来生成签名。
Args:
message: 待哈希的 UTF-8 字符串。
Returns:
Base64 编码的 SHA1 摘要(28 字符)。
"""
digest = hashlib.sha1(message.encode("utf-8")).digest()
return base64.b64encode(digest).decode()
def _build_sig_message(
method: str,
url_path: str,
params: dict[str, str],
signed_nonce: str,
) -> str:
"""构建签名消息字符串(z94.b 格式)。
格式: METHOD&/path&k1=v1&k2=v2&...&signedNonce_b64
- method 大写
- path 带前导 /
- params 按 key 字典序(TreeMap)排序
- 最后追加 signedNonce 的 base64 字符串
Args:
method: HTTP 方法。
url_path: URL 路径(须含前导 /)。
params: 参数字典(已排除空 key/value)。
signed_nonce: Base64 编码的 signed_nonce。
Returns:
用 & 连接的签名消息。
"""
parts: list[str] = [method.upper()]
if not url_path.startswith("/"):
url_path = "/" + url_path
parts.append(url_path)
for k in sorted(params.keys()):
parts.append(f"{k}={params[k]}")
parts.append(signed_nonce)
return "&".join(parts)
def _rc4_stream_encrypt_values(
key_bytes: bytes,
sorted_entries: list[tuple[str, str]],
) -> dict[str, str]:
"""用连续 RC4 流加密多个值。
模拟 App 中 d8k 的行为:构造时 drop 1024 字节,
然后对 TreeMap 中每个 entry 的 value 按排序顺序
依次加密,共用同一个 RC4 密钥流。
Args:
key_bytes: RC4 密钥(signed_nonce 的原始字节)。
sorted_entries: 按 key 排序的 (key, value) 对列表。
Returns:
{key: base64(encrypted_value)} 字典。
"""
# 将所有 value 拼接,一次性过 RC4 流
all_bytes = b"".join(v.encode("utf-8") for _, v in sorted_entries)
encrypted_all = _rc4_crypt(key_bytes, all_bytes, skip=1024)
result: dict[str, str] = {}
pos = 0
for k, v in sorted_entries:
vlen = len(v.encode("utf-8"))
result[k] = base64.b64encode(encrypted_all[pos : pos + vlen]).decode()
pos += vlen
return result
# endregion
# region 数据加密/解密
def encrypt_data(signed_nonce: str, plaintext: str) -> str:
"""用 RC4 加密数据。
Args:
signed_nonce: 密钥(base64 编码的 SHA256 哈希)。
plaintext: 明文 JSON 字符串。
Returns:
Base64 编码的密文。
"""
key = base64.b64decode(signed_nonce)
encrypted = _rc4_crypt(key, plaintext.encode("utf-8"))
return base64.b64encode(encrypted).decode()
def decrypt_data(signed_nonce: str, ciphertext_b64: str) -> str:
"""用 RC4 解密数据。
Args:
signed_nonce: 密钥(base64 编码的 SHA256 哈希)。
ciphertext_b64: Base64 编码的密文。
Returns:
解密后的明文字符串。
"""
key = base64.b64decode(signed_nonce)
decrypted = _rc4_crypt(key, base64.b64decode(ciphertext_b64))
return decrypted.decode("utf-8")
# endregion
# region 封装:构建加密请求参数
def build_encrypted_params(
method: str,
url_path: str,
ssecurity: str,
params: dict[str, Any] | None = None,
) -> dict[str, str]:
"""构建完整的加密请求参数。
流程(对应 App 中 ua4.c 方法):
1. 计算 signed_nonce = base64(SHA256(ssecurity + nonce))
2. 构建原始参数 TreeMap(排除空 key/value
3. rc4_hash__ = SHA1(METHOD&/path&k=v&...&signedNonce) → base64
4. 将 rc4_hash__ 加入 TreeMap
5. 用连续 RC4 流加密所有 TreeMap 值(按 key 排序,drop 1024
6. signature = SHA1(METHOD&/path&k=enc_v&...&signedNonce) → base64
7. 返回 {加密后各参数, signature, _nonce}
Args:
method: HTTP 方法。
url_path: API 路径(如 /app/v1/relatives/get_relative_list)。
ssecurity: 登录时获取的 ssecurity。
params: 要发送的参数字典(将被 JSON 序列化后加密)。
Returns:
包含 data, signature, rc4_hash__, _nonce 的参数字典。
"""
nonce = generate_nonce()
snonce = compute_signed_nonce(ssecurity, nonce)
snonce_bytes = base64.b64decode(snonce)
# Step 1: 构建原始参数 TreeMap(排除空 key/value
raw_tree: dict[str, str] = {}
if params:
plaintext = json.dumps(params, separators=(",", ":"), ensure_ascii=False)
raw_tree["data"] = plaintext
# Step 2: 计算 rc4_hash__(基于原始参数)
rc4_msg = _build_sig_message(method, url_path, raw_tree, snonce)
rc4_hash_raw = _sha1_b64(rc4_msg)
# Step 3: 将 rc4_hash__ 插入 TreeMap
raw_tree["rc4_hash__"] = rc4_hash_raw
# Step 4: 用连续 RC4 流加密所有值
sorted_entries = sorted(raw_tree.items())
encrypted_values = _rc4_stream_encrypt_values(snonce_bytes, sorted_entries)
# Step 5: 构建加密后参数 TreeMap,计算 signature
sig_msg = _build_sig_message(method, url_path, encrypted_values, snonce)
signature = _sha1_b64(sig_msg)
# Step 6: 组装最终结果
result: dict[str, str] = {}
for k, v in encrypted_values.items():
result[k] = v
result["signature"] = signature
result["_nonce"] = nonce
return result
def decrypt_response(
ssecurity: str,
nonce: str,
ciphertext_b64: str,
) -> Any:
"""解密 API 响应。
Args:
ssecurity: 登录时获取的 ssecurity。
nonce: 请求时使用的 nonce。
ciphertext_b64: Base64 编码的响应密文。
Returns:
解密后的 JSON 对象。
"""
snonce = compute_signed_nonce(ssecurity, nonce)
plaintext = decrypt_data(snonce, ciphertext_b64)
try:
return json.loads(plaintext)
except json.JSONDecodeError:
# 可能不是 JSON,返回原始字符串
return plaintext
# endregion
@@ -0,0 +1,88 @@
"""自定义异常。"""
class MiSDKError(Exception):
"""MiSDK 基础异常。"""
class AuthError(MiSDKError):
"""认证相关错误(登录失败、token 过期等)。"""
class APIError(MiSDKError):
"""API 请求返回非预期结果。
Attributes:
status_code: HTTP 状态码。
code: 业务错误码(``result["code"]``,仅业务层错误时有值)。
response_body: 原始响应体。
"""
def __init__(
self,
message: str,
*,
status_code: int = 0,
code: int = 0,
response_body: str = "",
):
super().__init__(message)
self.status_code = status_code
self.code = code
self.response_body = response_body
def __repr__(self) -> str:
return f"APIError(status_code={self.status_code}, code={self.code}, message={str(self)!r})"
class DeviceUntrustedError(AuthError):
"""设备未信任,需要短信验证码完成登录。
新设备首次登录时触发 ``securityStatus != 0``,需要通过短信验证码
完成身份验证。可通过 ``login(verification_code_handler=...)`` 自动
处理,或手动调用 ``send_verification_code()`` +
``login_with_verification_code()``。
Attributes:
security_status: 服务端返回的安全状态码。
"""
def __init__(self, message: str, *, security_status: int = 0):
super().__init__(message)
self.security_status = security_status
class CaptchaRequiredError(AuthError):
"""触发图形验证码风控,需要人工识别通过。
在登录流程中服务端可能要求完成图形验证码验证(错误码 87001)。
可通过 ``login(captcha_handler=...)`` 自动处理,
或捕获此异常后自行下载 ``captcha_url`` 的验证码图片并重试。
Attributes:
captcha_url: 验证码图片完整 URL。
"""
def __init__(self, message: str, *, captcha_url: str = ""):
super().__init__(message)
self.captcha_url = captcha_url
class TokenExpiredError(AuthError):
"""Token 已过期,需要重新登录。"""
class DataNotSharedError(MiSDKError):
"""亲友未共享当前请求的数据类型。"""
def __init__(self, message: str, *, data_type: str = ""):
super().__init__(message)
self.data_type = data_type
class DataOutOfSharedTimeScopeError(DataNotSharedError):
"""请求日期超出亲友允许共享的时间范围。"""
class FamilyMemberNotFoundError(MiSDKError):
"""找不到指定的亲友。"""
+93
View File
@@ -0,0 +1,93 @@
"""HTTP 客户端扩展。"""
from __future__ import annotations
from collections.abc import Collection
from typing import Any
import httpx
from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_exponential
_DEFAULT_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
class _RetryableStatusError(Exception):
"""内部异常:用于触发 tenacity 的状态码重试。"""
def __init__(self, response: httpx.Response):
super().__init__(f"retryable status: {response.status_code}")
self.response = response
def _is_retryable_error(exc: BaseException) -> bool:
"""判断异常是否可重试。"""
if isinstance(exc, _RetryableStatusError):
return True
return isinstance(exc, (httpx.NetworkError, httpx.TimeoutException, httpx.RemoteProtocolError))
class RetryAsyncClient(httpx.AsyncClient):
"""带重试能力的异步 HTTP 客户端。
继承 ``httpx.AsyncClient``,在 ``request`` 上增加 tenacity 退避重试。
默认仅对幂等方法启用重试,避免对发送短信/提交表单等非幂等请求重复提交。
Attributes:
retry_attempts: 最大重试次数(含首轮请求)。
retry_wait_min: 指数退避最小等待秒数。
retry_wait_max: 指数退避最大等待秒数。
retry_wait_multiplier: 指数退避倍率。
retry_statuses: 触发重试的 HTTP 状态码集合。
retry_non_idempotent: 是否允许对非幂等方法重试。
"""
def __init__(
self,
*args: Any,
retry_attempts: int = 3,
retry_wait_min: float = 0.2,
retry_wait_max: float = 2.0,
retry_wait_multiplier: float = 0.5,
retry_statuses: Collection[int] = _DEFAULT_RETRY_STATUSES,
retry_non_idempotent: bool = False,
**kwargs: Any,
):
super().__init__(*args, **kwargs)
self.retry_attempts = retry_attempts
self.retry_wait_min = retry_wait_min
self.retry_wait_max = retry_wait_max
self.retry_wait_multiplier = retry_wait_multiplier
self.retry_statuses = frozenset(retry_statuses)
self.retry_non_idempotent = retry_non_idempotent
async def request(
self, method: str, url: str | httpx.URL, *args: Any, **kwargs: Any
) -> httpx.Response:
"""发送请求并按策略自动重试。"""
method_upper = method.upper()
allow_retry = self.retry_non_idempotent or method_upper in _IDEMPOTENT_METHODS
if self.retry_attempts <= 1 or not allow_retry:
return await super().request(method, url, *args, **kwargs)
try:
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self.retry_attempts),
wait=wait_exponential(
multiplier=self.retry_wait_multiplier,
min=self.retry_wait_min,
max=self.retry_wait_max,
),
retry=retry_if_exception(_is_retryable_error),
reraise=True,
):
with attempt:
response = await super().request(method, url, *args, **kwargs)
if response.status_code in self.retry_statuses:
raise _RetryableStatusError(response)
return response
except _RetryableStatusError as exc:
return exc.response
# 理论上不会到达这里,保留兜底以满足类型检查。
return await super().request(method, url, *args, **kwargs)
File diff suppressed because it is too large Load Diff