mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-09-15 23:14:06 +03:00
feat: add miband bot application
This commit is contained in:
@@ -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()}"
|
||||
|
||||
# 设置 cookies(passToken + 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
|
||||
@@ -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)
|
||||
@@ -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", {})
|
||||
@@ -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 服务 SID(serviceLogin 的 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
|
||||
@@ -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: 登录时获取的 ssecurity(base64 编码)。
|
||||
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):
|
||||
"""找不到指定的亲友。"""
|
||||
@@ -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
Reference in New Issue
Block a user