mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-08-10 16:23:18 +03:00
1262 lines
37 KiB
Python
1262 lines
37 KiB
Python
"""健康数据结构。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import UTC, datetime
|
||
from enum import IntEnum
|
||
from functools import cached_property
|
||
from typing import Any, TypeVar
|
||
|
||
from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator
|
||
|
||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||
|
||
|
||
def _ts_to_datetime(ts: int) -> datetime | None:
|
||
"""将秒级时间戳转为 UTC datetime,0 返回 None。"""
|
||
if ts <= 0:
|
||
return None
|
||
return datetime.fromtimestamp(ts, tz=UTC)
|
||
|
||
|
||
def _coerce_bool(value: Any) -> bool:
|
||
"""将接口返回的 bool / int / str 统一归一化为布尔值。"""
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
return value != 0
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
if normalized in {"1", "true", "yes", "y", "on"}:
|
||
return True
|
||
if normalized in {"0", "false", "no", "n", "off", ""}:
|
||
return False
|
||
return bool(value)
|
||
|
||
|
||
def _coerce_int(value: Any, default: int = 0) -> int:
|
||
"""尽力将接口返回值转为整数。"""
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _coerce_optional_int(value: Any) -> int | None:
|
||
"""尽力将接口返回值转为整数,失败时返回 None。"""
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _coerce_dict(value: Any) -> dict[str, Any]:
|
||
"""仅保留 dict 结果,其余统一视为空对象。"""
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _coerce_dict_list(value: Any) -> list[dict[str, Any]]:
|
||
"""将单个对象或列表中的 dict 条目安全归一化。"""
|
||
if isinstance(value, dict):
|
||
candidates = [value]
|
||
elif isinstance(value, (list, tuple)):
|
||
candidates = list(value)
|
||
else:
|
||
return []
|
||
return [item for item in candidates if isinstance(item, dict) and item]
|
||
|
||
|
||
def _coerce_str_list(value: Any) -> list[str]:
|
||
"""仅保留非空字符串列表。"""
|
||
if isinstance(value, str):
|
||
return [value] if value else []
|
||
if not isinstance(value, (list, tuple)):
|
||
return []
|
||
return [item for item in value if isinstance(item, str) and item]
|
||
|
||
|
||
def _parse_model_list(value: Any, model: type[_ModelT]) -> list[_ModelT]:
|
||
"""安全解析模型列表,跳过明显损坏的条目。"""
|
||
parsed: list[_ModelT] = []
|
||
for item in _coerce_dict_list(value):
|
||
try:
|
||
parsed.append(model.model_validate(item))
|
||
except ValidationError:
|
||
continue
|
||
return parsed
|
||
|
||
|
||
def _parse_model(value: Any, model: type[_ModelT]) -> _ModelT | None:
|
||
"""安全解析单个模型,失败时返回 None。"""
|
||
try:
|
||
return model.model_validate(value)
|
||
except ValidationError:
|
||
return None
|
||
|
||
|
||
class _DictResultResponse(BaseModel):
|
||
"""result 应为 dict 的响应基类。"""
|
||
|
||
code: int = 0
|
||
message: str = ""
|
||
result: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
@field_validator("result", mode="before")
|
||
@classmethod
|
||
def _normalize_result(cls, value: Any) -> dict[str, Any]:
|
||
return _coerce_dict(value)
|
||
|
||
|
||
class _ListResultResponse(BaseModel):
|
||
"""result 应为列表的响应基类。"""
|
||
|
||
code: int = 0
|
||
message: str = ""
|
||
result: list[dict[str, Any]] = Field(default_factory=list)
|
||
|
||
@field_validator("result", mode="before")
|
||
@classmethod
|
||
def _normalize_result(cls, value: Any) -> list[dict[str, Any]]:
|
||
return _coerce_dict_list(value)
|
||
|
||
|
||
# region Token 持久化
|
||
class AuthToken(BaseModel):
|
||
"""登录凭证,可序列化用于持久化存储。
|
||
|
||
Attributes:
|
||
user_id: 小米用户 ID (userId)。
|
||
c_user_id: cUserId(cookie 认证用)。
|
||
service_token: serviceToken(cookie 认证用)。
|
||
ssecurity: 加密密钥(RC4 加解密用)。
|
||
pass_token: passToken(可用于 STS 交换)。
|
||
device_id: 设备标识符。
|
||
"""
|
||
|
||
user_id: str = ""
|
||
c_user_id: str = ""
|
||
service_token: str = ""
|
||
ssecurity: str = ""
|
||
pass_token: str = ""
|
||
device_id: str = ""
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 亲友
|
||
class FamilyMember(BaseModel):
|
||
"""亲友信息(来自 get_relative_list 响应)。
|
||
|
||
Attributes:
|
||
relative_uid: 亲友的小米用户 UID(整数)。
|
||
relative_note: 备注名。
|
||
relative_icon: 头像 URL。
|
||
latest_data_time: 最新数据时间戳。
|
||
latest_abnormal_record_time: 最新异常记录时间戳。
|
||
source_tag: 来源标记。
|
||
"""
|
||
|
||
relative_uid: int
|
||
relative_note: str = ""
|
||
relative_icon: str = ""
|
||
latest_data_time: int = 0
|
||
latest_abnormal_record_time: int | None = 0
|
||
source_tag: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return f"{self.relative_note or '未命名'} (UID: {self.relative_uid})"
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 最新心率
|
||
class LatestHeartRate(BaseModel):
|
||
"""实时心率点(来自心率聚合数据或 get_latest_data 的 latest_hr 字段)。
|
||
|
||
Attributes:
|
||
bpm: 心率(次/分)。
|
||
time: 采集时间戳。
|
||
"""
|
||
|
||
bpm: int = 0
|
||
time: int = 0
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 心率
|
||
class HeartRateData(BaseModel):
|
||
"""心率每日汇总(来自 get_aggregated_data key=heart_rate)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳(当天 0 点)。
|
||
avg_hr: 日均心率。
|
||
avg_rhr: 日均静息心率。
|
||
max_hr: 最大心率。
|
||
min_hr: 最小心率。
|
||
latest_hr: 最新一次心率采样。
|
||
abnormal_hr_count: 异常心率次数。
|
||
aerobic_hr_zone_duration: 有氧心率区间时长(分钟)。
|
||
anaerobic_hr_zone_duration: 无氧心率区间时长(分钟)。
|
||
extreme_hr_zone_duration: 极限心率区间时长(分钟)。
|
||
fat_burning_hr_zone_duration: 燃脂心率区间时长(分钟)。
|
||
warm_up_hr_zone_duration: 热身心率区间时长(分钟)。
|
||
"""
|
||
|
||
time: int = 0
|
||
avg_hr: int = 0
|
||
avg_rhr: int = 0
|
||
max_hr: int = 0
|
||
min_hr: int = 0
|
||
latest_hr: LatestHeartRate | None = None
|
||
abnormal_hr_count: int = 0
|
||
aerobic_hr_zone_duration: int = 0
|
||
anaerobic_hr_zone_duration: int = 0
|
||
extreme_hr_zone_duration: int = 0
|
||
fat_burning_hr_zone_duration: int = 0
|
||
warm_up_hr_zone_duration: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return (
|
||
f"HeartRate(avg={self.avg_hr}bpm, resting={self.avg_rhr}, "
|
||
f"range={self.min_hr}-{self.max_hr})"
|
||
)
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 睡眠片段
|
||
class SleepSegment(BaseModel):
|
||
"""睡眠片段(来自 sleep value 的 segment_details)。
|
||
|
||
Attributes:
|
||
bedtime: 入睡时间戳。
|
||
wake_up_time: 醒来时间戳。
|
||
duration: 持续时长(分钟)。
|
||
sleep_deep_duration: 深睡时长(分钟)。
|
||
sleep_light_duration: 浅睡时长(分钟)。
|
||
timezone: 时区偏移。
|
||
awake_count: 醒来次数。
|
||
sleep_awake_duration: 清醒时长(分钟)。
|
||
"""
|
||
|
||
bedtime: int = 0
|
||
wake_up_time: int = 0
|
||
duration: int = 0
|
||
sleep_deep_duration: int = 0
|
||
sleep_light_duration: int = 0
|
||
timezone: int = 0
|
||
awake_count: int = 0
|
||
sleep_awake_duration: int = 0
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 睡眠
|
||
class SleepData(BaseModel):
|
||
"""睡眠每日汇总(来自 get_aggregated_data key=sleep)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
total_duration: 总睡眠时长(分钟)。
|
||
sleep_score: 睡眠评分(0-100)。
|
||
sleep_stage: 睡眠阶段数。
|
||
sleep_deep_duration: 深睡时长(分钟)。
|
||
sleep_light_duration: 浅睡时长(分钟)。
|
||
sleep_rem_duration: REM 时长(分钟)。
|
||
sleep_awake_duration: 清醒时长(分钟)。
|
||
long_sleep_evaluation: 长期睡眠评估。
|
||
day_sleep_evaluation: 日间小睡评估。
|
||
avg_hr: 睡眠平均心率。
|
||
max_hr: 睡眠最大心率。
|
||
min_hr: 睡眠最小心率。
|
||
avg_spo2: 睡眠平均血氧。
|
||
segment_details: 睡眠片段列表。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
total_duration: int = 0
|
||
sleep_score: int = 0
|
||
sleep_stage: int = 0
|
||
sleep_deep_duration: int = 0
|
||
sleep_light_duration: int = 0
|
||
sleep_rem_duration: int = 0
|
||
sleep_awake_duration: int = 0
|
||
long_sleep_evaluation: int = 0
|
||
day_sleep_evaluation: int = 0
|
||
avg_hr: int = 0
|
||
max_hr: int = 0
|
||
min_hr: int = 0
|
||
avg_spo2: int = 0
|
||
segment_details: list[SleepSegment] = Field(default_factory=list)
|
||
|
||
def __str__(self) -> str:
|
||
return (
|
||
f"Sleep({self.total_duration}min, score={self.sleep_score}/100, "
|
||
f"deep={self.sleep_deep_duration}min, light={self.sleep_light_duration}min, "
|
||
f"rem={self.sleep_rem_duration}min)"
|
||
)
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 步数
|
||
class StepData(BaseModel):
|
||
"""步数每日汇总(来自 get_aggregated_data key=steps)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
steps: 步数。
|
||
distance: 距离(米)。
|
||
calories: 消耗卡路里。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
steps: int = 0
|
||
distance: int = 0
|
||
calories: int = 0
|
||
goal: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return f"Steps({self.steps}步, {self.distance}m, {self.calories}cal)"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 体重
|
||
class WeightData(BaseModel):
|
||
"""体重数据(来自 get_latest_data / get_fitness_data key=weight)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
weight: 体重(千克)。
|
||
bmi: BMI 指数。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
weight: float = 0.0
|
||
bmi: float = 0.0
|
||
|
||
def __str__(self) -> str:
|
||
return f"Weight({self.weight}kg, BMI={self.bmi})"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 血压
|
||
class BloodPressureData(BaseModel):
|
||
"""血压数据(来自 get_latest_data / get_fitness_data key=blood_pressure)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
systolic: 收缩压(高压 mmHg)。
|
||
diastolic: 舒张压(低压 mmHg)。
|
||
pulse: 脉搏(bpm)。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
systolic: int = Field(default=0, validation_alias=AliasChoices("systolic", "systolic_pressure"))
|
||
diastolic: int = Field(
|
||
default=0, validation_alias=AliasChoices("diastolic", "diastolic_pressure")
|
||
)
|
||
pulse: int | None = None
|
||
|
||
def __str__(self) -> str:
|
||
base = f"BloodPressure({self.systolic}/{self.diastolic} mmHg)"
|
||
return f"{base}, pulse={self.pulse}" if self.pulse is not None else base
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 最新快照指标
|
||
class GoalMetric(IntEnum):
|
||
"""活力指标中的单项目标类型。"""
|
||
|
||
STEPS = 1
|
||
CALORIES = 2
|
||
INTENSITY = 4
|
||
|
||
@classmethod
|
||
def from_field(cls, field: int) -> GoalMetric | None:
|
||
"""将接口返回的 field 编号映射为已知目标类型。"""
|
||
try:
|
||
return cls(field)
|
||
except ValueError:
|
||
return None
|
||
|
||
@property
|
||
def key(self) -> str:
|
||
"""稳定的英文键名,适合代码分支和序列化。"""
|
||
return {
|
||
GoalMetric.STEPS: "steps",
|
||
GoalMetric.CALORIES: "calories",
|
||
GoalMetric.INTENSITY: "intensity",
|
||
}[self]
|
||
|
||
@property
|
||
def label(self) -> str:
|
||
"""与 App 文案接近的展示名称。"""
|
||
return {
|
||
GoalMetric.STEPS: "步数",
|
||
GoalMetric.CALORIES: "卡路里",
|
||
GoalMetric.INTENSITY: "中高强度",
|
||
}[self]
|
||
|
||
|
||
class GoalItem(BaseModel):
|
||
"""单个健康目标条目。
|
||
|
||
Attributes:
|
||
field: 目标类型编号。
|
||
target_value: 目标值。
|
||
achieved_value: 已完成值。
|
||
"""
|
||
|
||
field: int = 0
|
||
target_value: int | float = 0
|
||
achieved_value: int | float = 0
|
||
|
||
@property
|
||
def metric(self) -> GoalMetric | None:
|
||
"""已知目标类型;未知 field 返回 None。"""
|
||
return GoalMetric.from_field(self.field)
|
||
|
||
@property
|
||
def metric_key(self) -> str:
|
||
"""目标键名;未知类型保留 field 以便继续排查。"""
|
||
metric = self.metric
|
||
return metric.key if metric is not None else f"unknown:{self.field}"
|
||
|
||
@property
|
||
def metric_label(self) -> str:
|
||
"""目标展示名;未知类型保留原始编号。"""
|
||
metric = self.metric
|
||
return metric.label if metric is not None else f"未知目标({self.field})"
|
||
|
||
|
||
class GoalData(BaseModel):
|
||
"""每日目标完成情况(来自 get_latest_data key=goal)。
|
||
|
||
Attributes:
|
||
time: 目标所属日期时间戳。
|
||
goal_items: 当日所有目标项。
|
||
|
||
便捷属性:
|
||
``steps_goal`` / ``calories_goal`` / ``intensity_goal`` 会返回对应的目标条目。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
goal_items: list[GoalItem] = Field(default_factory=list)
|
||
|
||
@field_validator("goal_items", mode="before")
|
||
@classmethod
|
||
def _normalize_goal_items(cls, value: Any) -> list[GoalItem]:
|
||
return _parse_model_list(value, GoalItem)
|
||
|
||
def __str__(self) -> str:
|
||
return f"GoalData({len(self.goal_items)} items)"
|
||
|
||
@cached_property
|
||
def items_by_field(self) -> dict[int, GoalItem]:
|
||
"""按原始 field 编号索引目标项。"""
|
||
return {item.field: item for item in self.goal_items}
|
||
|
||
@property
|
||
def available_metrics(self) -> list[GoalMetric]:
|
||
"""当前响应中出现的已知目标类型。"""
|
||
metrics: list[GoalMetric] = []
|
||
for item in self.goal_items:
|
||
if item.metric is not None:
|
||
metrics.append(item.metric)
|
||
return metrics
|
||
|
||
@property
|
||
def unknown_goal_items(self) -> list[GoalItem]:
|
||
"""当前响应中未识别的目标项。"""
|
||
return [item for item in self.goal_items if item.metric is None]
|
||
|
||
def get_item(self, metric: GoalMetric | int) -> GoalItem | None:
|
||
"""按目标类型读取对应条目。"""
|
||
return self.items_by_field.get(int(metric))
|
||
|
||
@property
|
||
def steps_goal(self) -> GoalItem | None:
|
||
"""步数目标。"""
|
||
return self.get_item(GoalMetric.STEPS)
|
||
|
||
@property
|
||
def calories_goal(self) -> GoalItem | None:
|
||
"""卡路里目标。"""
|
||
return self.get_item(GoalMetric.CALORIES)
|
||
|
||
@property
|
||
def intensity_goal(self) -> GoalItem | None:
|
||
"""中高强度活动目标。"""
|
||
return self.get_item(GoalMetric.INTENSITY)
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
class CaloriesData(BaseModel):
|
||
"""每日活动卡路里(来自 get_latest_data key=calories)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
calories: 已消耗活动卡路里。
|
||
goal: 卡路里目标值。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
calories: int = 0
|
||
goal: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return f"Calories({self.calories} cal, goal={self.goal})"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
class ValidStandData(BaseModel):
|
||
"""每日有效站立次数(来自 get_latest_data key=valid_stand)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
count: 有效站立次数。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
count: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return f"ValidStand({self.count})"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
class IntensityData(BaseModel):
|
||
"""每日中高强度活动时长(来自 get_latest_data key=intensity)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
duration: 中高强度活动时长(分钟)。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
duration: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return f"Intensity({self.duration} min)"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
class Spo2Data(BaseModel):
|
||
"""最新血氧数据(来自 get_latest_data key=spo2)。
|
||
|
||
Attributes:
|
||
time: 测量时间戳。
|
||
spo2: 血氧百分比。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
spo2: int = 0
|
||
|
||
def __str__(self) -> str:
|
||
return f"Spo2({self.spo2}%)"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
class Spo2SummaryData(BaseModel):
|
||
"""每日血氧摘要(来自 get_aggregated_data key=spo2)。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
avg_spo2: 平均血氧。
|
||
max_spo2: 最高血氧。
|
||
min_spo2: 最低血氧。
|
||
lack_spo2_count: 低血氧次数。
|
||
latest_spo2: 当日最近一次血氧采样。
|
||
"""
|
||
|
||
time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time"))
|
||
avg_spo2: int = 0
|
||
max_spo2: int = 0
|
||
min_spo2: int = 0
|
||
lack_spo2_count: int = 0
|
||
latest_spo2: Spo2Data | None = None
|
||
|
||
def __str__(self) -> str:
|
||
return (
|
||
f"Spo2Summary(avg={self.avg_spo2}%, range={self.min_spo2}-{self.max_spo2}, "
|
||
f"lack={self.lack_spo2_count})"
|
||
)
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""数据时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.time)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 用户验证
|
||
class VerifiedUserInfo(BaseModel):
|
||
"""verify_userinfo_by_id 响应中的用户信息。
|
||
|
||
Attributes:
|
||
user_id: 小米用户 UID。
|
||
nickname: 昵称。
|
||
icon: 头像 URL。
|
||
"""
|
||
|
||
user_id: int = Field(alias="userId", default=0)
|
||
nickname: str = ""
|
||
icon: str = ""
|
||
|
||
model_config = {"populate_by_name": True}
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 最新数据项
|
||
class LatestDataItem(BaseModel):
|
||
"""get_latest_data 响应中的单条数据项。
|
||
|
||
value 字段是 JSON 字符串,需要根据 key 解析为对应类型。
|
||
|
||
Attributes:
|
||
time: 数据时间戳。
|
||
key: 数据类型(heart_rate / sleep / steps / weight / blood_pressure 等)。
|
||
value: JSON 字符串或数值。
|
||
"""
|
||
|
||
time: int = 0
|
||
key: str = ""
|
||
value: str | int | float = ""
|
||
|
||
@field_validator("value", mode="before")
|
||
@classmethod
|
||
def _normalize_value(cls, value: Any) -> str | int | float:
|
||
"""确保 dict/list 形式的 value 也能被统一解析。"""
|
||
if isinstance(value, (dict, list)):
|
||
return json.dumps(value, ensure_ascii=False)
|
||
if isinstance(value, (str, int, float)):
|
||
return value
|
||
return ""
|
||
|
||
def parse_value(self) -> dict[str, Any] | int | float:
|
||
"""将 value 字段从 JSON 字符串解析为字典。
|
||
|
||
Returns:
|
||
解析后的字典或原始数值。
|
||
"""
|
||
if isinstance(self.value, (int, float)):
|
||
return self.value
|
||
try:
|
||
parsed = json.loads(self.value)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return {}
|
||
if isinstance(parsed, (dict, int, float)):
|
||
return parsed
|
||
return {}
|
||
|
||
def _parse_dict_value(self) -> dict[str, Any]:
|
||
"""将 value 解析为字典,失败时返回空对象。"""
|
||
parsed = self.parse_value()
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
|
||
def as_goal(self) -> GoalData | None:
|
||
"""解析为目标完成数据。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, GoalData)
|
||
|
||
def as_heart_rate(self) -> LatestHeartRate | None:
|
||
"""解析为最新一次心率采样。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, LatestHeartRate)
|
||
|
||
def as_sleep(self) -> SleepData | None:
|
||
"""解析为最新睡眠摘要。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
segments = data.pop("segment_details", [])
|
||
data["segment_details"] = _parse_model_list(segments, SleepSegment)
|
||
return _parse_model(data, SleepData)
|
||
|
||
def as_steps(self) -> StepData | None:
|
||
"""解析为最新步数摘要。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, StepData)
|
||
|
||
def as_weight(self) -> WeightData | None:
|
||
"""解析为最新体重。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, WeightData)
|
||
|
||
def as_blood_pressure(self) -> BloodPressureData | None:
|
||
"""解析为最新血压。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, BloodPressureData)
|
||
|
||
def as_calories(self) -> CaloriesData | None:
|
||
"""解析为最新卡路里摘要。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, CaloriesData)
|
||
|
||
def as_valid_stand(self) -> ValidStandData | None:
|
||
"""解析为最新有效站立统计。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, ValidStandData)
|
||
|
||
def as_intensity(self) -> IntensityData | None:
|
||
"""解析为最新中高强度活动时长。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, IntensityData)
|
||
|
||
def as_spo2(self) -> Spo2Data | None:
|
||
"""解析为最新血氧。"""
|
||
data = self._parse_dict_value()
|
||
if not data:
|
||
return None
|
||
data.setdefault("time", self.time)
|
||
return _parse_model(data, Spo2Data)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 聚合数据项
|
||
class AggregatedDataItem(BaseModel):
|
||
"""get_aggregated_data 响应中的单条数据项。
|
||
|
||
Attributes:
|
||
sid: 数据来源 SID。
|
||
tag: 数据标签(如 daily_report)。
|
||
key: 数据类型。
|
||
time: 数据时间戳。
|
||
value: JSON 字符串值。
|
||
update_time: 更新时间戳。
|
||
watermark: 水印(增量同步用)。
|
||
source_sid_list: 数据来源列表。
|
||
"""
|
||
|
||
sid: str = ""
|
||
tag: str = ""
|
||
key: str = ""
|
||
time: int = 0
|
||
value: str = ""
|
||
update_time: int = 0
|
||
watermark: str = ""
|
||
source_sid_list: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("watermark", mode="before")
|
||
@classmethod
|
||
def _stringify_watermark(cls, v: Any) -> str:
|
||
"""API 有时返回 int 类型的 watermark。"""
|
||
return str(v) if v is not None else ""
|
||
|
||
@field_validator("value", mode="before")
|
||
@classmethod
|
||
def _stringify_value(cls, v: Any) -> str:
|
||
"""确保 value 始终是字符串(API 可能返回 dict)。"""
|
||
if isinstance(v, (dict, list)):
|
||
return json.dumps(v, ensure_ascii=False)
|
||
return str(v) if v is not None else ""
|
||
|
||
def parse_value(self) -> dict[str, Any]:
|
||
"""将 value 字段从 JSON 字符串解析为字典。"""
|
||
try:
|
||
parsed = json.loads(self.value)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return {}
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
|
||
def as_heart_rate(self) -> HeartRateData:
|
||
"""解析为心率数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
latest = data.get("latest_hr")
|
||
if isinstance(latest, dict):
|
||
data["latest_hr"] = LatestHeartRate.model_validate(latest)
|
||
elif latest is not None:
|
||
data["latest_hr"] = None
|
||
return HeartRateData.model_validate(data)
|
||
|
||
def as_sleep(self) -> SleepData:
|
||
"""解析为睡眠数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
segments = data.pop("segment_details", [])
|
||
data["segment_details"] = _parse_model_list(segments, SleepSegment)
|
||
return SleepData.model_validate(data)
|
||
|
||
def as_steps(self) -> StepData:
|
||
"""解析为步数数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
return StepData.model_validate(data)
|
||
|
||
def as_weight(self) -> WeightData:
|
||
"""解析为体重历史数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
return WeightData.model_validate(data)
|
||
|
||
def as_blood_pressure(self) -> BloodPressureData:
|
||
"""解析为血压历史数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
return BloodPressureData.model_validate(data)
|
||
|
||
def as_calories(self) -> CaloriesData:
|
||
"""解析为卡路里数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
return CaloriesData.model_validate(data)
|
||
|
||
def as_valid_stand(self) -> ValidStandData:
|
||
"""解析为有效站立数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
return ValidStandData.model_validate(data)
|
||
|
||
def as_intensity(self) -> IntensityData:
|
||
"""解析为中高强度活动时长数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
return IntensityData.model_validate(data)
|
||
|
||
def as_spo2(self) -> Spo2SummaryData:
|
||
"""解析为血氧摘要数据。"""
|
||
data = self.parse_value()
|
||
data["time"] = self.time
|
||
latest = data.get("latest_spo2")
|
||
if isinstance(latest, dict):
|
||
data["latest_spo2"] = _parse_model(latest, Spo2Data)
|
||
elif latest is not None:
|
||
data["latest_spo2"] = None
|
||
return Spo2SummaryData.model_validate(data)
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 每日摘要
|
||
class DailySummary(BaseModel):
|
||
"""每日健康数据摘要(由 get_daily_summary 返回)。
|
||
|
||
Attributes:
|
||
date: 查询日期(ISO 格式)。
|
||
relative_uid: 亲友 UID。
|
||
heart_rate: 心率汇总数据。
|
||
sleep: 睡眠汇总数据。
|
||
steps: 步数汇总数据。
|
||
"""
|
||
|
||
date: str = ""
|
||
relative_uid: int = 0
|
||
heart_rate: HeartRateData | None = None
|
||
sleep: SleepData | None = None
|
||
steps: StepData | None = None
|
||
|
||
def __str__(self) -> str:
|
||
parts = [f"DailySummary({self.date}, UID={self.relative_uid}"]
|
||
if self.heart_rate:
|
||
parts.append(f"hr={self.heart_rate.avg_hr}bpm")
|
||
if self.sleep:
|
||
parts.append(f"sleep={self.sleep.total_duration}min")
|
||
if self.steps:
|
||
parts.append(f"steps={self.steps.steps}")
|
||
return ", ".join(parts) + ")"
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 最新快照
|
||
class LatestDataSnapshot(BaseModel):
|
||
"""最新健康快照。
|
||
|
||
将 get_latest_data 的异构 data_list 收敛为固定字段,未知 key 或解析失败的 payload
|
||
保留到 extras,避免上层因为单条脏数据失去整份快照。
|
||
"""
|
||
|
||
updated_time: int = 0
|
||
goal: GoalData | None = None
|
||
heart_rate: LatestHeartRate | None = None
|
||
sleep: SleepData | None = None
|
||
blood_pressure: BloodPressureData | None = None
|
||
steps: StepData | None = None
|
||
calories: CaloriesData | None = None
|
||
valid_stand: ValidStandData | None = None
|
||
intensity: IntensityData | None = None
|
||
weight: WeightData | None = None
|
||
spo2: Spo2Data | None = None
|
||
extras: dict[str, dict[str, Any] | int | float] = Field(default_factory=dict)
|
||
|
||
@classmethod
|
||
def from_items(
|
||
cls,
|
||
items: list[LatestDataItem],
|
||
*,
|
||
updated_time: int = 0,
|
||
) -> LatestDataSnapshot:
|
||
"""从原始 data_list 构建类型化快照。"""
|
||
payload: dict[str, Any] = {"updated_time": updated_time}
|
||
extras: dict[str, dict[str, Any] | int | float] = {}
|
||
parsers = {
|
||
"goal": LatestDataItem.as_goal,
|
||
"heart_rate": LatestDataItem.as_heart_rate,
|
||
"sleep": LatestDataItem.as_sleep,
|
||
"blood_pressure": LatestDataItem.as_blood_pressure,
|
||
"steps": LatestDataItem.as_steps,
|
||
"calories": LatestDataItem.as_calories,
|
||
"valid_stand": LatestDataItem.as_valid_stand,
|
||
"intensity": LatestDataItem.as_intensity,
|
||
"weight": LatestDataItem.as_weight,
|
||
"spo2": LatestDataItem.as_spo2,
|
||
}
|
||
|
||
for item in items:
|
||
parser = parsers.get(item.key)
|
||
raw_value = item.parse_value()
|
||
if parser is None:
|
||
extras[item.key] = raw_value
|
||
continue
|
||
|
||
parsed = parser(item)
|
||
if parsed is not None:
|
||
payload[item.key] = parsed
|
||
continue
|
||
|
||
if raw_value not in ({}, "", 0, 0.0):
|
||
extras[item.key] = raw_value
|
||
|
||
return cls(**payload, extras=extras)
|
||
|
||
def __str__(self) -> str:
|
||
keys = ", ".join(self.available_keys) or "empty"
|
||
return f"LatestDataSnapshot({keys})"
|
||
|
||
@property
|
||
def at(self) -> datetime | None:
|
||
"""快照更新时间(UTC datetime)。"""
|
||
return _ts_to_datetime(self.updated_time)
|
||
|
||
@property
|
||
def available_keys(self) -> list[str]:
|
||
"""当前快照中可用的数据键。"""
|
||
known_keys = [
|
||
"goal",
|
||
"heart_rate",
|
||
"sleep",
|
||
"blood_pressure",
|
||
"steps",
|
||
"calories",
|
||
"valid_stand",
|
||
"intensity",
|
||
"weight",
|
||
"spo2",
|
||
]
|
||
keys = [key for key in known_keys if getattr(self, key) is not None]
|
||
keys.extend(sorted(self.extras))
|
||
return keys
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region API 响应包装
|
||
class RelativeListResponse(_DictResultResponse):
|
||
"""get_relative_list 响应。"""
|
||
|
||
@property
|
||
def relatives(self) -> list[FamilyMember]:
|
||
"""解析亲友列表。"""
|
||
return _parse_model_list(self.result.get("relative_list"), FamilyMember)
|
||
|
||
|
||
class LatestDataResponse(_DictResultResponse):
|
||
"""get_latest_data 响应。"""
|
||
|
||
@property
|
||
def data_items(self) -> list[LatestDataItem]:
|
||
"""解析数据项列表。"""
|
||
return _parse_model_list(self.result.get("data_list"), LatestDataItem)
|
||
|
||
@property
|
||
def latest_data_time(self) -> int:
|
||
"""最新数据更新时间。"""
|
||
return _coerce_int(self.result.get("latest_data_time"), default=0)
|
||
|
||
@property
|
||
def snapshot(self) -> LatestDataSnapshot:
|
||
"""解析为类型化快照。"""
|
||
return LatestDataSnapshot.from_items(self.data_items, updated_time=self.latest_data_time)
|
||
|
||
|
||
class AggregatedDataResponse(_DictResultResponse):
|
||
"""get_aggregated_data / get_fitness_data 响应。"""
|
||
|
||
@property
|
||
def data_items(self) -> list[AggregatedDataItem]:
|
||
"""解析数据项列表。"""
|
||
return _parse_model_list(self.result.get("data_list"), AggregatedDataItem)
|
||
|
||
@property
|
||
def has_more(self) -> bool:
|
||
"""是否有更多数据。"""
|
||
return _coerce_bool(self.result.get("has_more", False))
|
||
|
||
@property
|
||
def next_key(self) -> str:
|
||
"""下一页的起始 key。"""
|
||
value = self.result.get("next_key", "")
|
||
return str(value) if value is not None else ""
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 亲友管理 API 响应
|
||
class VerifyUserResponse(_DictResultResponse):
|
||
"""verify_userinfo_by_id 响应。"""
|
||
|
||
@property
|
||
def user_info(self) -> VerifiedUserInfo | None:
|
||
"""解析用户信息,未找到时返回 None。"""
|
||
if not self.result or not self.result.get("userId"):
|
||
return None
|
||
return VerifiedUserInfo.model_validate(self.result)
|
||
|
||
|
||
class InviteResponse(_DictResultResponse):
|
||
"""send_invite 响应。"""
|
||
|
||
@property
|
||
def success(self) -> bool:
|
||
"""邀请是否发送成功。"""
|
||
return _coerce_int(self.result.get("send_ret"), default=0) == 1
|
||
|
||
|
||
class OperateInviteResponse(_DictResultResponse):
|
||
"""operate_invite 响应(同意/拒绝邀请)。"""
|
||
|
||
@property
|
||
def success(self) -> bool:
|
||
"""操作是否成功。"""
|
||
return _coerce_bool(self.result.get("operate_ret"))
|
||
|
||
|
||
class DeleteRelativeResponse(_DictResultResponse):
|
||
"""delete_relative 响应。"""
|
||
|
||
@property
|
||
def success(self) -> bool:
|
||
"""删除是否成功。"""
|
||
return _coerce_bool(self.result.get("delete_ret"))
|
||
|
||
|
||
class SharedDataTypesResponse(_DictResultResponse):
|
||
"""get_shared_data_types 响应。"""
|
||
|
||
@property
|
||
def keys(self) -> list[str]:
|
||
"""可共享的数据类型列表。"""
|
||
return _coerce_str_list(self.result.get("keys"))
|
||
|
||
|
||
class InviteUniqueIdResponse(_DictResultResponse):
|
||
"""get_invite_unique_id 响应。"""
|
||
|
||
@property
|
||
def invite_link_id(self) -> int:
|
||
"""二维码邀请链接 ID。"""
|
||
return _coerce_int(self.result.get("invite_link_id"), default=0)
|
||
|
||
|
||
class FamilyMemberResponse(_DictResultResponse):
|
||
"""get_family_member 响应。"""
|
||
|
||
@property
|
||
def family_user_list(self) -> list[dict[str, Any]]:
|
||
"""家庭成员列表(原始字典)。"""
|
||
return _coerce_dict_list(self.result.get("family_user_list"))
|
||
|
||
|
||
# endregion
|
||
|
||
|
||
# region 消息
|
||
class InviteMessage(BaseModel):
|
||
"""亲友邀请消息(来自 get_msg_list 响应)。
|
||
|
||
Attributes:
|
||
msg_id: 消息 ID(operate_invite 用)。
|
||
module: 消息模块(1=亲友)。
|
||
type: 消息类型(1=待处理邀请,5=历史通知)。
|
||
receiver: 接收方 UID。
|
||
sender: 发送方 UID。
|
||
extra_data: JSON 字符串,包含 invite_id、nick_name、icon 等。
|
||
is_new: 是否新消息。
|
||
data_status: 数据状态(0=待处理,1=已处理)。
|
||
create_time: 创建时间戳。
|
||
"""
|
||
|
||
msg_id: int = 0
|
||
module: int = 0
|
||
type: int = 0
|
||
receiver: int = 0
|
||
sender: int = 0
|
||
extra_data: str = ""
|
||
is_new: int = 0
|
||
data_status: int = 0
|
||
create_time: int = 0
|
||
last_modify: int = 0
|
||
|
||
@field_validator("extra_data", mode="before")
|
||
@classmethod
|
||
def _normalize_extra_data(cls, value: Any) -> str:
|
||
"""确保 extra_data 在解析前始终是 JSON 字符串。"""
|
||
if isinstance(value, (dict, list)):
|
||
return json.dumps(value, ensure_ascii=False)
|
||
return str(value) if value is not None else ""
|
||
|
||
@cached_property
|
||
def _parsed_extra(self) -> dict[str, Any]:
|
||
"""解析 extra_data JSON(缓存结果)。"""
|
||
try:
|
||
parsed = json.loads(self.extra_data)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return {}
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
|
||
@property
|
||
def invite_id(self) -> int | None:
|
||
"""解析 extra_data 中的 invite_id(仅 type=1 时存在)。"""
|
||
return _coerce_optional_int(self._parsed_extra.get("invite_id"))
|
||
|
||
@property
|
||
def nick_name(self) -> str:
|
||
"""解析 extra_data 中的昵称。"""
|
||
return self._parsed_extra.get("nick_name", "")
|
||
|
||
@property
|
||
def icon(self) -> str:
|
||
"""解析 extra_data 中的头像 URL。"""
|
||
return self._parsed_extra.get("icon", "")
|
||
|
||
@property
|
||
def is_pending(self) -> bool:
|
||
"""是否为待处理的邀请。"""
|
||
return self.type == 1 and self.data_status == 0
|
||
|
||
|
||
class MessageListResponse(_DictResultResponse):
|
||
"""get_msg_list 响应。"""
|
||
|
||
@property
|
||
def messages(self) -> list[InviteMessage]:
|
||
"""消息列表。"""
|
||
return _parse_model_list(self.result.get("messages"), InviteMessage)
|
||
|
||
@property
|
||
def msg_total(self) -> int:
|
||
"""消息总数。"""
|
||
return _coerce_int(self.result.get("msg_total"), default=0)
|
||
|
||
|
||
class CheckNewMsgResponse(_ListResultResponse):
|
||
"""check_new_msg 响应。"""
|
||
|
||
def has_new(self, module: int = 1) -> bool:
|
||
"""指定 module 是否有新消息。"""
|
||
for item in self.result:
|
||
if _coerce_int(item.get("module"), default=0) == module and _coerce_bool(
|
||
item.get("is_new")
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
# endregion
|