mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-21 05:00:13 +03:00
Add extended health metrics and trends UI
This commit is contained in:
+604
-328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Alexey
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import os
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import time as dt_time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOCAL_TZ = ZoneInfo("Europe/Moscow")
|
||||
RU_MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||
RU_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||
DEFAULT_STEP_GOAL = 10_000
|
||||
|
||||
SPORT_TYPE_LABELS: dict[str, str] = {
|
||||
"free_training": "Свободная тренировка",
|
||||
"outdoor_running": "Бег на улице",
|
||||
"treadmill": "Беговая дорожка",
|
||||
"walking": "Ходьба",
|
||||
"cycling": "Велосипед",
|
||||
"swimming": "Плавание",
|
||||
"yoga": "Йога",
|
||||
"strength_training": "Силовая",
|
||||
"hiit": "HIIT",
|
||||
"jump_rope": "Скакалка",
|
||||
"elliptical": "Эллипсоид",
|
||||
"rowing": "Гребля",
|
||||
"outdoor_cycling": "Велосипед (улица)",
|
||||
"basketball": "Баскетбол",
|
||||
"football": "Футбол",
|
||||
"table_tennis": "Настольный теннис",
|
||||
"badminton": "Бадминтон",
|
||||
"tennis": "Теннис",
|
||||
"volleyball": "Волейбол",
|
||||
"dancing": "Танцы",
|
||||
"martial_arts": "Боевые искусства",
|
||||
}
|
||||
|
||||
|
||||
def esc(value: object) -> str:
|
||||
return html.escape(str(value), quote=False)
|
||||
|
||||
|
||||
def format_mia_date(dt: date) -> str:
|
||||
month_name = RU_MONTHS[dt.month - 1]
|
||||
weekday_name = RU_WEEKDAYS[dt.weekday()]
|
||||
return f"{dt.day} {month_name}, {weekday_name}"
|
||||
|
||||
|
||||
def make_sleep_bar(stage_min: int, total_min: int) -> str:
|
||||
if total_min <= 0:
|
||||
return "░" * 10
|
||||
blocks = int(round((stage_min / total_min) * 10))
|
||||
blocks = max(0, min(10, blocks))
|
||||
return "█" * blocks + "░" * (10 - blocks)
|
||||
|
||||
|
||||
def format_epoch(epoch: int | float | None, with_date: bool = True) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if not epoch:
|
||||
return "n/a" if is_en else "н/д"
|
||||
fmt = "%Y-%m-%d %H:%M" if with_date else "%H:%M"
|
||||
return datetime.fromtimestamp(int(epoch), LOCAL_TZ).strftime(fmt)
|
||||
|
||||
|
||||
def format_relative_time(epoch: int | float | None) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if not epoch:
|
||||
return "n/a" if is_en else "н/д"
|
||||
diff = int(time.time() - int(epoch))
|
||||
if diff < 60:
|
||||
return "just now" if is_en else "только что"
|
||||
diff_min = diff // 60
|
||||
if diff_min < 60:
|
||||
return f"{diff_min} min. ago" if is_en else f"{diff_min} мин. назад"
|
||||
diff_hours = diff_min // 60
|
||||
if diff_hours < 24:
|
||||
return f"{diff_hours} hr. ago" if is_en else f"{diff_hours} ч. назад"
|
||||
diff_days = diff_hours // 24
|
||||
if diff_days == 1:
|
||||
return "yesterday" if is_en else "вчера"
|
||||
return f"{diff_days} days ago" if is_en else f"{diff_days} дн. назад"
|
||||
|
||||
|
||||
def format_minutes(minutes: int | float | None) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if minutes is None:
|
||||
return "n/a" if is_en else "н/д"
|
||||
minutes = int(minutes)
|
||||
if is_en:
|
||||
return f"{minutes // 60} h {minutes % 60:02d} m"
|
||||
return f"{minutes // 60} ч {minutes % 60:02d} мин"
|
||||
|
||||
|
||||
def step_goal_bar(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
|
||||
steps_int = int(steps or 0)
|
||||
percent = min(100, round(steps_int / goal * 100)) if goal > 0 else 0
|
||||
filled = percent // 10
|
||||
bar = "█" * filled + "░" * (10 - filled)
|
||||
return f"<code>[{bar}]</code> {percent}%"
|
||||
|
||||
|
||||
def step_goal_text(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if steps is None:
|
||||
return f"Goal {goal:,} steps".replace(",", " ") if is_en else f"Цель {goal:,} шагов".replace(",", " ")
|
||||
steps_int = int(steps)
|
||||
left = max(0, goal - steps_int)
|
||||
if left:
|
||||
text = f"Goal {goal:,} · {left:,} steps left" if is_en else f"Цель {goal:,} · осталось {left:,} шагов"
|
||||
else:
|
||||
text = f"Goal {goal:,} · daily goal achieved! 🎉" if is_en else f"Цель {goal:,} · дневная цель выполнена! 🎉"
|
||||
return text.replace(",", " ")
|
||||
|
||||
|
||||
def make_sparkline(values: list[float | int]) -> str:
|
||||
if not values:
|
||||
return ""
|
||||
sparks = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
val_range = max_val - min_val
|
||||
if val_range == 0:
|
||||
return sparks[4] * min(len(values), 20)
|
||||
sparkline = []
|
||||
for val in values:
|
||||
idx = int((val - min_val) / val_range * (len(sparks) - 1))
|
||||
sparkline.append(sparks[idx])
|
||||
return "".join(sparkline)
|
||||
|
||||
|
||||
def relative_day_label(day_str: str | None) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if not day_str:
|
||||
return "Last day" if is_en else "Последний день"
|
||||
try:
|
||||
day_value = parse_day(day_str)
|
||||
except ValueError:
|
||||
return f"Day: {day_str}" if is_en else f"День: {day_str}"
|
||||
today = datetime.now(LOCAL_TZ).date()
|
||||
if day_value == today:
|
||||
return "Today" if is_en else "Сегодня"
|
||||
if day_value == today - timedelta(days=1):
|
||||
return "Yesterday" if is_en else "Вчера"
|
||||
return day_str
|
||||
|
||||
|
||||
def day_bounds(day_value: date) -> tuple[int, int]:
|
||||
start = datetime.combine(day_value, dt_time.min, tzinfo=LOCAL_TZ)
|
||||
end = start + timedelta(days=1)
|
||||
return int(start.timestamp()), int(end.timestamp())
|
||||
|
||||
|
||||
def parse_day(day_str: str) -> date:
|
||||
return datetime.strptime(day_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def sleep_total(sleep) -> int:
|
||||
total = int(sleep["total_duration_min"]) if sleep["total_duration_min"] else 0
|
||||
if total > 0:
|
||||
return total
|
||||
return int(sleep["light_sleep_min"] or 0) + int(sleep["deep_sleep_min"] or 0)
|
||||
|
||||
|
||||
def sleep_quality_label(total_min: int, deep_min: int) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if total_min >= 420 and deep_min >= 60:
|
||||
return "🟢 Excellent" if is_en else "🟢 Отличный"
|
||||
if total_min >= 360 and deep_min >= 40:
|
||||
return "🟡 Good" if is_en else "🟡 Хороший"
|
||||
if total_min >= 300:
|
||||
return "🟠 Average" if is_en else "🟠 Средний"
|
||||
return "🔴 Poor" if is_en else "🔴 Недостаточный"
|
||||
|
||||
|
||||
def workout_type_label(sport_type: str) -> str:
|
||||
return SPORT_TYPE_LABELS.get(sport_type, sport_type.replace("_", " ").title())
|
||||
@@ -16,7 +16,7 @@ from zoneinfo import ZoneInfo
|
||||
from .config import Settings
|
||||
|
||||
LOCAL_TZ = ZoneInfo("Europe/Moscow")
|
||||
EXPORT_TABLES = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress"]
|
||||
EXPORT_TABLES = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress", "calories_daily", "weight", "workouts"]
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -113,6 +113,45 @@ def init_health_db(db_path: Path) -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS calories_daily (
|
||||
date TEXT PRIMARY KEY,
|
||||
total_cal REAL,
|
||||
active_cal REAL,
|
||||
valid_stand_hours INTEGER,
|
||||
intensity_minutes INTEGER,
|
||||
last_sync INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS weight (
|
||||
timestamp INTEGER PRIMARY KEY,
|
||||
weight_kg REAL,
|
||||
bmi REAL,
|
||||
body_fat_pct REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
workout_id TEXT PRIMARY KEY,
|
||||
sport_type TEXT,
|
||||
start_time INTEGER,
|
||||
end_time INTEGER,
|
||||
duration_sec INTEGER,
|
||||
calories REAL,
|
||||
avg_hr INTEGER,
|
||||
max_hr INTEGER,
|
||||
min_hr INTEGER,
|
||||
watermark INTEGER,
|
||||
raw_json TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
+234
-2
@@ -143,6 +143,10 @@ async def run_sync_for_user(
|
||||
"sleep_stages": 0,
|
||||
"heart_rate": 0,
|
||||
"blood_oxygen": 0,
|
||||
"stress": 0,
|
||||
"calories_daily": 0,
|
||||
"weight": 0,
|
||||
"workouts": 0,
|
||||
}
|
||||
log(
|
||||
f"Starting sync. Target UID: {relative_uid}. Query duration: {settings.query_duration} days. "
|
||||
@@ -193,8 +197,8 @@ async def run_sync_for_user(
|
||||
start_time = 0
|
||||
end_time = 0
|
||||
if sleep.segment_details:
|
||||
start_time = sleep.segment_details[0].bedtime
|
||||
end_time = sleep.segment_details[-1].wake_up_time
|
||||
start_time = min(seg.bedtime for seg in sleep.segment_details)
|
||||
end_time = max(seg.wake_up_time for seg in sleep.segment_details)
|
||||
for segment in sleep.segment_details:
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -273,6 +277,34 @@ async def run_sync_for_user(
|
||||
except Exception as exc:
|
||||
log(f"Failed to fetch blood oxygen: {exc}")
|
||||
|
||||
await _sync_aggregated_metric(
|
||||
client, cursor, counters, relative_uid, settings,
|
||||
"heart_rate", "heart_rate", "bpm",
|
||||
"INSERT OR IGNORE INTO heart_rate (timestamp, value) VALUES (?, ?)",
|
||||
json_key="bpm",
|
||||
)
|
||||
await _sync_aggregated_metric(
|
||||
client, cursor, counters, relative_uid, settings,
|
||||
"spo2", "blood_oxygen", "spo2",
|
||||
"INSERT OR IGNORE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, 'point')",
|
||||
json_key="spo2",
|
||||
)
|
||||
await _sync_aggregated_metric(
|
||||
client, cursor, counters, relative_uid, settings,
|
||||
"stress", "stress", "stress",
|
||||
"INSERT OR IGNORE INTO stress (timestamp, value) VALUES (?, ?)",
|
||||
json_key="stress",
|
||||
)
|
||||
await _sync_calories_daily(
|
||||
client, cursor, counters, relative_uid, settings,
|
||||
)
|
||||
await _sync_weight(
|
||||
client, cursor, counters, relative_uid, settings,
|
||||
)
|
||||
await _sync_workouts(
|
||||
client, cursor, counters, relative_uid,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except TokenExpiredError:
|
||||
message = "Token has expired and auto-refresh failed. Action required: re-login."
|
||||
@@ -362,6 +394,206 @@ def _write_status_file(status_path: Path, latest_steps, latest_heart_rate, lates
|
||||
log(f"Status file written to {status_path}")
|
||||
|
||||
|
||||
async def _sync_aggregated_metric(
|
||||
client,
|
||||
cursor,
|
||||
counters: dict,
|
||||
relative_uid: int,
|
||||
settings,
|
||||
api_key: str,
|
||||
table: str,
|
||||
value_field: str,
|
||||
insert_sql: str,
|
||||
json_key: str = "",
|
||||
) -> None:
|
||||
"""Синхронизирует поточечные данные через get_fitness_data."""
|
||||
import json as _json
|
||||
|
||||
from mi_fitness.client.data import _build_window_timestamps
|
||||
try:
|
||||
start, end, _ = _build_window_timestamps(None, settings.query_duration)
|
||||
resp = await client.get_fitness_data(relative_uid, api_key, start, end, limit=1440 * settings.query_duration)
|
||||
for item in resp.data_items:
|
||||
try:
|
||||
raw = item.value
|
||||
if isinstance(raw, str) and raw.startswith("{"):
|
||||
d = _json.loads(raw)
|
||||
val = float(d.get(json_key or value_field, 0))
|
||||
elif isinstance(raw, dict):
|
||||
val = float(raw.get(json_key or value_field, 0))
|
||||
else:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError, Exception):
|
||||
continue
|
||||
if val == 0:
|
||||
continue
|
||||
cursor.execute(insert_sql, (item.time, int(val)))
|
||||
counters[table] += cursor.rowcount > 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to fetch '{api_key}': {exc}")
|
||||
|
||||
|
||||
async def _sync_calories_daily(
|
||||
client,
|
||||
cursor,
|
||||
counters: dict,
|
||||
relative_uid: int,
|
||||
settings,
|
||||
) -> None:
|
||||
"""Синхронизирует суточные калории, valid_stand и intensity в calories_daily."""
|
||||
import datetime as _dt
|
||||
|
||||
from mi_fitness.client.data import _build_window_timestamps
|
||||
|
||||
days = settings.query_duration
|
||||
start, end, _ = _build_window_timestamps(None, days)
|
||||
cal_by_date: dict[str, dict] = {}
|
||||
|
||||
async def _collect(api_key: str, field: str, json_key: str = "") -> None:
|
||||
import json as _json
|
||||
try:
|
||||
# aggregated = 1 запись в день, limit = days
|
||||
resp = await client.get_aggregated_data(relative_uid, api_key, start, end, limit=days)
|
||||
for item in resp.data_items:
|
||||
try:
|
||||
raw = item.value
|
||||
if isinstance(raw, str) and raw.startswith("{"):
|
||||
d = _json.loads(raw)
|
||||
val = float(d.get(json_key or field, 0))
|
||||
elif isinstance(raw, dict):
|
||||
val = float(raw.get(json_key or field, 0))
|
||||
else:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError, Exception):
|
||||
continue
|
||||
if val == 0:
|
||||
continue
|
||||
dt = _dt.datetime.fromtimestamp(item.time)
|
||||
date_str = dt.date().isoformat()
|
||||
entry = cal_by_date.setdefault(date_str, {})
|
||||
if field in ("total_cal", "active_cal"):
|
||||
entry[field] = entry.get(field, 0) + val
|
||||
else:
|
||||
entry[field] = max(entry.get(field, 0), int(val))
|
||||
except Exception as exc:
|
||||
log(f"Failed to fetch '{api_key}': {exc}")
|
||||
|
||||
await _collect("calories", "total_cal", "calories")
|
||||
await _collect("intensity", "intensity_minutes", "duration")
|
||||
await _collect("valid_stand", "valid_stand_hours", "count")
|
||||
|
||||
now_ts = int(time.time())
|
||||
for date_str, vals in cal_by_date.items():
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO calories_daily
|
||||
(date, total_cal, active_cal, valid_stand_hours, intensity_minutes, last_sync)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
date_str,
|
||||
vals.get("total_cal"),
|
||||
vals.get("active_cal"),
|
||||
vals.get("valid_stand_hours"),
|
||||
vals.get("intensity_minutes"),
|
||||
now_ts,
|
||||
),
|
||||
)
|
||||
counters["calories_daily"] += cursor.rowcount > 0
|
||||
|
||||
|
||||
async def _sync_weight(
|
||||
client,
|
||||
cursor,
|
||||
counters: dict,
|
||||
relative_uid: int,
|
||||
settings,
|
||||
) -> None:
|
||||
"""Синхронизирует данные веса."""
|
||||
try:
|
||||
weight_list = await client.get_weight_history(
|
||||
relative_uid, days=max(settings.query_duration, 180)
|
||||
)
|
||||
for item in weight_list:
|
||||
if not item.weight or item.weight <= 0:
|
||||
continue
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO weight (timestamp, weight_kg, bmi)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(item.time, item.weight, item.bmi),
|
||||
)
|
||||
counters["weight"] += cursor.rowcount > 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to fetch weight: {exc}")
|
||||
|
||||
|
||||
async def _sync_workouts(
|
||||
client,
|
||||
cursor,
|
||||
counters: dict,
|
||||
relative_uid: int,
|
||||
) -> None:
|
||||
"""Синхронизирует тренировки через watermark API (инкрементально)."""
|
||||
import json as _json
|
||||
|
||||
# Читаем последний watermark
|
||||
row = cursor.execute("SELECT MAX(watermark) FROM workouts").fetchone()
|
||||
last_watermark = row[0] if row and row[0] else 0
|
||||
|
||||
try:
|
||||
has_more = True
|
||||
wm = last_watermark
|
||||
while has_more:
|
||||
resp = await client._request(
|
||||
"GET",
|
||||
"/app/v1/data/get_sport_records_by_watermark",
|
||||
params={"relative_uid": relative_uid, "watermark": wm, "limit": 50},
|
||||
)
|
||||
result = resp.get("result", {})
|
||||
records = result.get("sport_records", [])
|
||||
has_more = result.get("has_more", False)
|
||||
|
||||
for rec in records:
|
||||
wm = max(wm, int(rec.get("watermark", 0)))
|
||||
raw_val = rec.get("value", "{}")
|
||||
try:
|
||||
val = _json.loads(raw_val) if isinstance(raw_val, str) else raw_val
|
||||
except Exception:
|
||||
val = {}
|
||||
workout_id = str(rec.get("sid", rec.get("did", "")))
|
||||
sport_type = rec.get("key") or rec.get("category", "unknown")
|
||||
start_time = int(val.get("start_time") or rec.get("time", 0))
|
||||
end_time = int(val.get("end_time") or (start_time + val.get("duration", 0)))
|
||||
duration_sec = int(val.get("duration", 0))
|
||||
calories = float(val.get("calories") or val.get("total_cal", 0))
|
||||
avg_hr = int(val.get("avg_hrm", 0))
|
||||
max_hr = int(val.get("max_hrm", 0))
|
||||
min_hr = int(val.get("min_hrm", 0))
|
||||
watermark_val = int(rec.get("watermark", 0))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO workouts
|
||||
(workout_id, sport_type, start_time, end_time, duration_sec,
|
||||
calories, avg_hr, max_hr, min_hr, watermark, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
workout_id, sport_type, start_time, end_time, duration_sec,
|
||||
calories, avg_hr, max_hr, min_hr, watermark_val,
|
||||
_json.dumps(val, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
counters["workouts"] += cursor.rowcount > 0
|
||||
|
||||
if not records:
|
||||
break
|
||||
except Exception as exc:
|
||||
log(f"Failed to fetch workouts: {exc}")
|
||||
|
||||
|
||||
async def daemon_main(settings: Settings | None = None) -> int:
|
||||
settings = settings or Settings.from_env()
|
||||
if settings.sync_interval <= 0:
|
||||
|
||||
Reference in New Issue
Block a user