mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-08-10 16:23:18 +03:00
feat: add miband bot application
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user