fix(garmin): 同步代码调用的 garminconnect API 全是错的 装上库核对签名后发现(garminconnect 0.2.8),原代码从未被真正 执行过,三处调用都不成立: 1. get_activities(date) —— 该方法签名是 (start, limit),收的是 分页下标和条数,不是日期。传日期等于在问"第 2026-08-23 条运动", 而且少传一个参数必然 TypeError。 改为 get_activities_by_date(start, end),整个窗口一次取回, 顺带把原来"每天一次调用"减为一次。 2. 睡眠数据不在 get_user_summary 里,是独立的 get_sleep_data(cdate); 原代码从 summary 里读 sleep 字段,结果会把每一晚都记成无睡眠数据。 HRV 同理,走 get_hrv_data(cdate)。 3. 字段名不符:实际是 totalSteps / totalKilocalories / averageStressLevel,原代码用的是 steps / calories.total / stress.average。 其他改进: - 运动记录改用 Garmin 自己的 activityId 作主键,重复同步同一窗口 不再产生重复行(原来每次同步都会把同一条运动再插一遍) - 某天无数据时返回全 None,不再写入空行让读端点再过滤掉 - 原来 bare except 吞掉每天的异常,全部失败也报 success; 现在全窗口失败会如实返回 error 并记录原因 - 同步天数、是否走 Garmin 中国区改为环境变量可配 tests/test_garmin_sync.py (23 通过): - 用 StubClient 模拟真实 0.2.8 的接口形状,无需库、凭证或网络 - 回归用例覆盖上述三处误用:活动必须按日期区间一次取回、 睡眠与 HRV 必须走各自端点 - 重复同步不产生重复的天和重复的运动记录 - 单天失败跳过、全窗口失败报错、登录失败如实上报 部署: - NAS 安装 garminconnect(pydantic-core 有 cp38 x86_64 轮子, 无需 gcc) - 新增 deploy/S99garmin.sh 开机自启脚本,与 NAS 上既有的 S99frpc.sh 同一套惯例;以 root 启动但降权到 ericwyuan 运行, 因为服务不需要特权而 .env 里有数据库和 API 凭证 - PID 文件放应用目录而非 /var/run(非特权用户写不了) NAS 真机全量测试: 240 passed Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
157 lines
5.3 KiB
Python
157 lines
5.3 KiB
Python
"""
|
|
Health data service: read endpoints + upsert helpers used by the Garmin sync.
|
|
|
|
Mirrors the original Node HealthService, including the camelCase JSON mapping.
|
|
Upserts use backend-specific SQL because SQLite does not support
|
|
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
|
|
"""
|
|
import uuid
|
|
|
|
from db import execute, query_one, query_all
|
|
from config import DB_TYPE
|
|
|
|
|
|
def _range_sql(user_id, start=None, end=None):
|
|
params = [user_id]
|
|
sql = "WHERE user_id = ?"
|
|
if start:
|
|
sql += " AND date >= ?"
|
|
params.append(start)
|
|
if end:
|
|
sql += " AND date <= ?"
|
|
params.append(end)
|
|
return sql, params
|
|
|
|
|
|
def get_summary(user_id, start=None, end=None):
|
|
sql, params = _range_sql(user_id, start, end)
|
|
rows = query_all(
|
|
"SELECT date, steps, heart_rate, heart_rate_variability, "
|
|
"sleep_duration, sleep_quality, stress, calories_burned "
|
|
f"FROM health_data {sql} ORDER BY date ASC",
|
|
params,
|
|
)
|
|
return [
|
|
{
|
|
"date": r["date"],
|
|
"steps": r["steps"],
|
|
"heartRate": r["heart_rate"],
|
|
"heartRateVariability": r["heart_rate_variability"],
|
|
"sleep": (
|
|
{"duration": r["sleep_duration"], "quality": r["sleep_quality"]}
|
|
if r["sleep_duration"] is not None
|
|
else None
|
|
),
|
|
"stress": r["stress"],
|
|
"caloriesBurned": r["calories_burned"],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def get_steps(user_id, start=None, end=None):
|
|
sql, params = _range_sql(user_id, start, end)
|
|
rows = query_all(
|
|
f"SELECT date, steps FROM health_data {sql} AND steps IS NOT NULL ORDER BY date ASC",
|
|
params,
|
|
)
|
|
return [{"date": r["date"], "steps": r["steps"]} for r in rows]
|
|
|
|
|
|
def get_heart_rate(user_id, start=None, end=None):
|
|
sql, params = _range_sql(user_id, start, end)
|
|
rows = query_all(
|
|
f"SELECT date, heart_rate, heart_rate_variability FROM health_data {sql} "
|
|
"AND heart_rate IS NOT NULL ORDER BY date ASC",
|
|
params,
|
|
)
|
|
return [
|
|
{
|
|
"date": r["date"],
|
|
"heartRate": r["heart_rate"],
|
|
"heartRateVariability": r["heart_rate_variability"],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def get_sleep(user_id, start=None, end=None):
|
|
sql, params = _range_sql(user_id, start, end)
|
|
rows = query_all(
|
|
f"SELECT date, sleep_duration, sleep_quality FROM health_data {sql} "
|
|
"AND sleep_duration IS NOT NULL ORDER BY date ASC",
|
|
params,
|
|
)
|
|
return [
|
|
{"date": r["date"], "duration": r["sleep_duration"], "quality": r["sleep_quality"]}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def get_activities(user_id, start=None, end=None):
|
|
sql, params = _range_sql(user_id, start, end)
|
|
rows = query_all(
|
|
"SELECT id, activity_type, start_time, end_time, duration, distance, "
|
|
"calories, heart_rate_average, heart_rate_max "
|
|
f"FROM activities {sql} ORDER BY start_time DESC",
|
|
params,
|
|
)
|
|
return rows
|
|
|
|
|
|
def upsert_health_daily(user_id, record):
|
|
hid = f"{user_id}-{record['date']}"
|
|
cols = [
|
|
"id", "user_id", "date", "steps", "heart_rate",
|
|
"heart_rate_variability", "blood_pressure_systolic",
|
|
"blood_pressure_diastolic", "sleep_duration", "sleep_quality",
|
|
"stress", "calories_burned",
|
|
]
|
|
placeholders = ", ".join(["?"] * len(cols))
|
|
vals = [
|
|
hid, user_id, record.get("date"), record.get("steps"),
|
|
record.get("heartRate"), record.get("heartRateVariability"),
|
|
record.get("bloodPressureSystolic"), record.get("bloodPressureDiastolic"),
|
|
record.get("sleepDuration"), record.get("sleepQuality"),
|
|
record.get("stress"), record.get("caloriesBurned"),
|
|
]
|
|
if DB_TYPE == "mariadb":
|
|
update_cols = [c for c in cols if c not in ("id", "user_id")]
|
|
updates = ", ".join([f"{c}=VALUES({c})" for c in update_cols])
|
|
sql = (
|
|
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
|
|
f"ON DUPLICATE KEY UPDATE {updates}, updated_at=CURRENT_TIMESTAMP"
|
|
)
|
|
else:
|
|
update_cols = [c for c in cols if c not in ("id", "user_id")]
|
|
updates = ", ".join([f"{c}=excluded.{c}" for c in update_cols])
|
|
sql = (
|
|
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
|
|
f"ON CONFLICT(user_id, date) DO UPDATE SET {updates}, updated_at=CURRENT_TIMESTAMP"
|
|
)
|
|
execute(sql, vals)
|
|
return hid
|
|
|
|
|
|
def insert_activity(user_id, activity):
|
|
# Prefer Garmin's own activity id when the caller has one: it is stable
|
|
# across syncs, which is what lets a re-synced window skip what is already
|
|
# stored instead of inserting it again.
|
|
aid = str(activity.get("id") or uuid.uuid4())
|
|
cols = [
|
|
"id", "user_id", "activity_type", "start_time", "end_time",
|
|
"duration", "distance", "calories", "heart_rate_average", "heart_rate_max",
|
|
]
|
|
placeholders = ", ".join(["?"] * len(cols))
|
|
vals = [
|
|
aid, user_id, activity.get("activityType"), activity.get("startTime"),
|
|
activity.get("endTime"), activity.get("duration"), activity.get("distance"),
|
|
activity.get("calories"), activity.get("heartRateAverage"),
|
|
activity.get("heartRateMax"),
|
|
]
|
|
execute(
|
|
f"INSERT INTO activities ({', '.join(cols)}) VALUES ({placeholders})",
|
|
vals,
|
|
)
|
|
return aid
|