From d52a2d6d1dd0439ad8c7eea8e5c2bc101f29d011 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 May 2026 16:28:07 +0300 Subject: [PATCH] Add extended health metrics and trends UI --- .gitignore | 2 + README.md | 12 +- README_EN.md | 12 +- miband_tracker/bot/app.py | 932 ++++++++++++++++++++----------- miband_tracker/bot/formatting.py | 180 ++++++ miband_tracker/storage.py | 41 +- miband_tracker/sync.py | 236 +++++++- pyproject.toml | 1 + setup.bat | 51 +- setup.sh | 13 +- tests/test_bot_ui.py | 174 +++++- tests/test_storage.py | 64 ++- tests/test_sync.py | 49 +- 13 files changed, 1387 insertions(+), 380 deletions(-) create mode 100644 miband_tracker/bot/formatting.py diff --git a/.gitignore b/.gitignore index 64f409c..872da00 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ .ruff_cache/ .mypy_cache/ .test-tmp/ +scratch/ +plan_workouts.md .coverage htmlcov/ .DS_Store diff --git a/README.md b/README.md index 802e765..4d17695 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Личный self-hosted Telegram-бот для данных Xiaomi Fitness / Mi Band. -Забирает шаги, сон, пульс и SpO2 из облака Xiaomi Fitness, хранит их -в локальной SQLite-базе и даёт доступ к ним прямо из Telegram — +Забирает шаги, сон, пульс, SpO2, стресс, суточную активность, вес +и тренировки из облака Xiaomi Fitness, хранит их в локальной SQLite-базе +и даёт доступ к ним прямо из Telegram — без сторонних сервисов и без передачи данных третьим лицам. > **Проект рассчитан на одного владельца.** @@ -13,8 +14,9 @@ ## Возможности -- Просмотр последних шагов, сна, пульса и SpO2 в Telegram. +- Просмотр последних шагов, сна, пульса, SpO2, стресса, веса и тренировок в Telegram. - Ручная и автоматическая синхронизация по расписанию. +- Автообновление закреплённого главного сообщения после фоновой синхронизации. - Хранение истории в SQLite (`data/`). - Экспорт всех таблиц в ZIP с CSV-файлами прямо в чат. - Развёртывание через Docker Compose. @@ -37,7 +39,7 @@ Docker Compose запускает два процесса: ## Требования -- Docker и Docker Compose (или установленный Python 3.10+). +- Docker и Docker Compose (или установленный Python 3.11+). - Telegram bot token от [@BotFather](https://t.me/BotFather). - Аккаунт Xiaomi с данными Xiaomi Fitness. @@ -76,7 +78,7 @@ Docker Compose запускает два процесса: ``` Скрипт сам проверит окружение, пошагово поможет получить токен, создаст конфигурацию `secrets.env`, развернет окружение Python (если выбран запуск без Docker) и предложит запустить бота одной кнопкой. -После настройки бот можно запускать повторно через `run_local.bat` из папки `miband-bot`. +После настройки бот можно запускать повторно через `run_local.sh` на macOS/Linux или `run_local.bat` на Windows из папки `miband-bot`. --- diff --git a/README_EN.md b/README_EN.md index 194d8e0..82c95cf 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,8 +4,9 @@ A personal self-hosted Telegram bot for your Xiaomi Fitness / Mi Band data. -Fetches steps, sleep, heart rate, and SpO2 from the Xiaomi Fitness cloud, -stores them in a local SQLite database, and provides access to them directly from Telegram — +Fetches steps, sleep, heart rate, SpO2, stress, daily activity, weight, +and workouts from the Xiaomi Fitness cloud, stores them in a local SQLite database, +and provides access to them directly from Telegram — without third-party services and without sharing your data with anyone. > **This project is designed for a single owner.** @@ -13,8 +14,9 @@ without third-party services and without sharing your data with anyone. ## Features -- View recent steps, sleep, heart rate, and SpO2 in Telegram. +- View recent steps, sleep, heart rate, SpO2, stress, weight, and workouts in Telegram. - Manual and scheduled automatic synchronization. +- Auto-refresh of the pinned main menu message after background synchronization. - History storage in SQLite (`data/`). - Export of all tables to a ZIP archive with CSV files directly into the chat. - Deployment via Docker Compose. @@ -37,7 +39,7 @@ is prevented by a file-based lock. ## Requirements -- Docker and Docker Compose (or installed Python 3.10+). +- Docker and Docker Compose (or installed Python 3.11+). - Telegram bot token from [@BotFather](https://t.me/BotFather). - A Xiaomi account with Xiaomi Fitness data. @@ -76,7 +78,7 @@ If you have already cloned the repository via `git clone` or downloaded the ZIP ``` The script will automatically check your environment, guide you step-by-step to get your Telegram bot token, create the `secrets.env` configuration, set up the Python virtual environment (if you choose to run without Docker), and let you launch the bot with a single key press! -After setup, you can start the bot again with `run_local.bat` from the `miband-bot` folder. +After setup, you can start the bot again with `run_local.sh` on macOS/Linux or `run_local.bat` on Windows from the `miband-bot` folder. --- diff --git a/miband_tracker/bot/app.py b/miband_tracker/bot/app.py index 8f65482..bda2f34 100644 --- a/miband_tracker/bot/app.py +++ b/miband_tracker/bot/app.py @@ -5,17 +5,14 @@ from __future__ import annotations import asyncio -import html import io import logging +import os import sqlite3 import sys -import time from datetime import date, datetime, timedelta -from datetime import time as dt_time from functools import wraps from pathlib import Path -from zoneinfo import ZoneInfo from mi_fitness.auth import XiaomiAuth from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update @@ -30,6 +27,21 @@ from telegram.ext import ( ) from miband_tracker import storage +from miband_tracker.bot.formatting import ( + LOCAL_TZ, + RU_MONTHS, + day_bounds, + esc, + format_epoch, + format_minutes, + format_relative_time, + make_sleep_bar, + make_sparkline, + parse_day, + relative_day_label, + sleep_total, + workout_type_label, +) from miband_tracker.config import ConfigError, Settings from miband_tracker.secure_files import save_auth_token from miband_tracker.sync import run_sync @@ -62,9 +74,9 @@ SETTINGS = Settings.from_env() BOT_TOKEN = SETTINGS.telegram_bot_token ALLOWED_USER_ID = SETTINGS.telegram_allowed_user_id DB_PATH = str(SETTINGS.db_path) -LOCAL_TZ = ZoneInfo("Europe/Moscow") SYNC_LOCK = asyncio.Lock() AUTH_LOCK = asyncio.Lock() +AUTO_MENU_REFRESH_INTERVAL = max(5, int(os.getenv("AUTO_MENU_REFRESH_INTERVAL", "30"))) STEP_GOAL = 10_000 # можно вынести в env при желании @@ -123,83 +135,6 @@ def has_xiaomi_token() -> bool: return bool(token_path and token_path.exists()) -def esc(value: object) -> str: - return html.escape(str(value), quote=False) - - -# --------------------------------------------------------------------------- -# Formatters -# --------------------------------------------------------------------------- -def format_epoch(epoch: int | float | None, with_date: bool = True) -> str: - if not epoch: - return "н/д" - 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: - if not epoch: - return "н/д" - diff = int(time.time() - int(epoch)) - if diff < 60: - return "только что" - diff_min = diff // 60 - if diff_min < 60: - return f"{diff_min} мин. назад" - diff_hours = diff_min // 60 - if diff_hours < 24: - return f"{diff_hours} ч. назад" - diff_days = diff_hours // 24 - if diff_days == 1: - return "вчера" - return f"{diff_days} дн. назад" - - -def format_minutes(minutes: int | float | None) -> str: - if minutes is None: - return "н/д" - minutes = int(minutes) - return f"{minutes // 60} ч {minutes % 60:02d} мин" - - -def step_goal_bar(steps: int | float | None, goal: int = STEP_GOAL) -> str: - if steps is None: - steps = 0 - steps_int = int(steps) - percent = min(100, round(steps_int / goal * 100)) if goal > 0 else 0 - filled = percent // 10 - bar = "█" * filled + "░" * (10 - filled) - return f"[{bar}] {percent}%" - - -def step_goal_text(steps: int | float | None, goal: int = STEP_GOAL) -> str: - if steps is None: - return f"Цель {goal:,} шагов".replace(",", " ") - steps_int = int(steps) - left = max(0, goal - steps_int) - if left: - text = f"Цель {goal:,} · осталось {left:,} шагов" - else: - text = 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 get_sleep_sparkline(table: str, field: str, start_epoch: int, end_epoch: int) -> str: rows = fetch_all( f"SELECT {field} FROM {table} WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC", @@ -214,88 +149,69 @@ def get_sleep_sparkline(table: str, field: str, start_epoch: int, end_epoch: int return make_sparkline(values) -def relative_day_label(day_str: str | None) -> str: - if not day_str: - return "Последний день" - try: - day_value = parse_day(day_str) - except ValueError: - return f"День: {day_str}" - today = datetime.now(LOCAL_TZ).date() - if day_value == today: - return "Сегодня" - if day_value == today - timedelta(days=1): - return "Вчера" - 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() - - -# --------------------------------------------------------------------------- -# Sleep quality label -# --------------------------------------------------------------------------- -def sleep_total(sleep: sqlite3.Row) -> int: - """Реальное время сна: total_duration_min если есть, иначе light+deep.""" - 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: - """Оценка качества сна по общей длительности и доле глубокого сна.""" - if total_min >= 420 and deep_min >= 60: - return "🟢 Отличный" - if total_min >= 360 and deep_min >= 40: - return "🟡 Хороший" - if total_min >= 300: - return "🟠 Средний" - return "🔴 Недостаточный" - - # --------------------------------------------------------------------------- # Daily tip engine # --------------------------------------------------------------------------- def daily_tip(steps: sqlite3.Row | None, sleep: sqlite3.Row | None, hr: sqlite3.Row | None) -> str: """Генерирует один персональный инсайт на основе последних данных.""" tips = [] + is_en = os.getenv("BOT_LANG") == "en" if steps: s = int(steps["total_steps"] or 0) if s < 5000: - tips.append("💡 Сегодня совсем мало шагов. Прогулка в 20 минут добавит ~2 000 шагов и заметно поднимет настроение.") + if is_en: + tips.append("💡 Very few steps today. A 20-minute walk will add ~2 000 steps and boost your mood.") + else: + tips.append("💡 Сегодня совсем мало шагов. Прогулка в 20 минут добавит ~2 000 шагов и заметно поднимет настроение.") elif s >= STEP_GOAL: - tips.append("💡 Дневная норма шагов выполнена — отличный результат!") + if is_en: + tips.append("💡 Daily step goal reached — excellent job!") + else: + tips.append("💡 Дневная норма шагов выполнена — отличный результат!") elif s >= 7000: - tips.append(f"💡 До цели {STEP_GOAL:,} шагов осталось {STEP_GOAL - s:,} — почти дошли!".replace(",", " ")) + if is_en: + tips.append(f"💡 {STEP_GOAL - s:,} steps left to reach your goal of {STEP_GOAL:,} — almost there!".replace(",", " ")) + else: + tips.append(f"💡 До цели {STEP_GOAL:,} шагов осталось {STEP_GOAL - s:,} — почти дошли!".replace(",", " ")) if sleep: total = sleep_total(sleep) deep = int(sleep["deep_sleep_min"] or 0) if total < 300: - tips.append("💡 Ночной сон меньше 5 часов — это мало. Постарайтесь лечь пораньше сегодня.") + if is_en: + tips.append("💡 Sleep was under 5 hours — that's too short. Try to go to bed earlier tonight.") + else: + tips.append("💡 Ночной сон меньше 5 часов — это мало. Постарайтесь лечь пораньше сегодня.") elif total < 360: - tips.append("💡 Ночной сон меньше 6 часов. Постарайтесь лечь пораньше сегодня.") + if is_en: + tips.append("💡 Sleep was under 6 hours. Try to go to bed earlier tonight.") + else: + tips.append("💡 Ночной сон меньше 6 часов. Постарайтесь лечь пораньше сегодня.") elif deep < 30: - tips.append("💡 Глубокого сна было совсем мало. Попробуйте проветрить комнату и ограничить экраны за час до сна.") + if is_en: + tips.append("💡 Very little deep sleep. Try airing out the room and avoiding screens for an hour before bed.") + else: + tips.append("💡 Глубокого сна было совсем мало. Попробуйте проветрить комнату и ограничить экраны за час до сна.") if hr: bpm = int(hr["value"]) if bpm > 90: - tips.append("💡 Пульс в покое выше нормы — возможно, организм устал или есть стресс. Следите за самочувствием.") + if is_en: + tips.append("💡 Resting heart rate is elevated — the body might be tired or stressed. Keep an eye on how you feel.") + else: + tips.append("💡 Пульс в покое выше нормы — возможно, организм устал или есть стресс. Следите за самочувствием.") elif bpm < 50: - tips.append("💡 Очень низкий пульс — если это спортивная норма, всё хорошо. Если нет — стоит обратить внимание.") + if is_en: + tips.append("💡 Very low heart rate — if you are an athlete, it's fine. Otherwise, pay close attention.") + else: + tips.append("💡 Очень низкий пульс — если это спортивная норма, всё хорошо. Если нет — стоит обратить внимание.") if not tips: - tips.append("💡 Данных достаточно, продолжайте в том же духе!") + if is_en: + tips.append("💡 Enough data, keep up the good work!") + else: + tips.append("💡 Данных достаточно, продолжайте в том же духе!") return tips[0] # показываем один самый актуальный совет @@ -423,6 +339,54 @@ async def update_menu( await send_or_update_menu(context.bot, user_id, text, reply_markup, force_new) +async def auto_refresh_main_menu_loop(app: Application) -> None: + """Refresh the pinned main menu after the sync daemon writes a new status file.""" + if ALLOWED_USER_ID is None: + return + + last_seen_mtime: float | None = None + while True: + try: + status_path = SETTINGS.user_status_path(ALLOWED_USER_ID) + if status_path.exists(): + current_mtime = status_path.stat().st_mtime + if last_seen_mtime is None: + last_seen_mtime = current_mtime + elif current_mtime > last_seen_mtime: + last_seen_mtime = current_mtime + if get_user_menu_msg_id(ALLOWED_USER_ID): + await send_or_update_menu( + app.bot, + ALLOWED_USER_ID, + main_menu_text(), + main_keyboard(), + ) + logger.info("Auto-refreshed main menu for user %s", ALLOWED_USER_ID) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("Auto menu refresh failed: %s", exc) + + await asyncio.sleep(AUTO_MENU_REFRESH_INTERVAL) + + +async def start_background_tasks(app: Application) -> None: + app.bot_data["auto_refresh_main_menu_task"] = asyncio.create_task( + auto_refresh_main_menu_loop(app), + name="auto-refresh-main-menu", + ) + + +async def stop_background_tasks(app: Application) -> None: + task = app.bot_data.get("auto_refresh_main_menu_task") + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # --------------------------------------------------------------------------- # Data queries # --------------------------------------------------------------------------- @@ -466,6 +430,38 @@ def latest_spo2() -> sqlite3.Row | None: ) +def latest_stress() -> sqlite3.Row | None: + return fetch_one("SELECT timestamp, value FROM stress ORDER BY timestamp DESC LIMIT 1") + + +def latest_weight() -> sqlite3.Row | None: + return fetch_one("SELECT timestamp, weight_kg FROM weight ORDER BY timestamp DESC LIMIT 1") + + +def latest_calories() -> sqlite3.Row | None: + return fetch_one( + """ + SELECT date, total_cal, valid_stand_hours, intensity_minutes + FROM calories_daily + ORDER BY date DESC + LIMIT 1 + """ + ) + + +def recent_workouts(limit: int = 5) -> list[sqlite3.Row]: + return fetch_all( + """ + SELECT workout_id, sport_type, start_time, end_time, + duration_sec, calories, avg_hr, max_hr, min_hr + FROM workouts + ORDER BY start_time DESC + LIMIT ? + """, + (limit,), + ) + + def resting_hr(start_epoch: int, end_epoch: int) -> int | None: """Пульс покоя = минимальный за окно сна (игнорируем нули и аномалии < 30).""" row = fetch_one( @@ -530,7 +526,45 @@ def day_summary(day_str: str) -> dict: ) hr = metric_stats("heart_rate", "value", start_epoch, end_epoch) spo2 = metric_stats("blood_oxygen", "spo2", start_epoch, end_epoch) - return {"date": day_str, "steps": steps, "sleep": sleep, "hr": hr, "spo2": spo2} + stress = metric_stats("stress", "value", start_epoch, end_epoch) + calories = fetch_one( + """ + SELECT total_cal, active_cal, valid_stand_hours, intensity_minutes + FROM calories_daily + WHERE date = ? + """, + (day_str,), + ) + weight = fetch_one( + """ + SELECT weight_kg, bmi, body_fat_pct + FROM weight + WHERE timestamp <= ? + ORDER BY timestamp DESC + LIMIT 1 + """, + (end_epoch,), + ) + workouts = fetch_all( + """ + SELECT workout_id, sport_type, start_time, end_time, duration_sec, calories, avg_hr, max_hr, min_hr + FROM workouts + WHERE start_time >= ? AND start_time < ? + ORDER BY start_time ASC + """, + (start_epoch, end_epoch), + ) + return { + "date": day_str, + "steps": steps, + "sleep": sleep, + "hr": hr, + "spo2": spo2, + "stress": stress, + "calories": calories, + "weight": weight, + "workouts": workouts, + } def available_days(limit: int = 14) -> list[str]: @@ -583,6 +617,38 @@ def period_summary(days: int) -> dict: ) hr = metric_stats("heart_rate", "value", start_epoch, end_epoch) spo2 = metric_stats("blood_oxygen", "spo2", start_epoch, end_epoch) + stress = metric_stats("stress", "value", start_epoch, end_epoch) + + calories_rows = fetch_all( + """ + SELECT date, total_cal, active_cal, valid_stand_hours, intensity_minutes + FROM calories_daily + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + """, + (start_day.isoformat(), end_day.isoformat()), + ) + + weight_rows = fetch_all( + """ + SELECT timestamp, weight_kg, bmi, body_fat_pct + FROM weight + WHERE timestamp >= ? AND timestamp < ? + ORDER BY timestamp DESC + """, + (start_epoch, end_epoch), + ) + if not weight_rows: + latest_w = fetch_one( + """ + SELECT timestamp, weight_kg, bmi, body_fat_pct + FROM weight + ORDER BY timestamp DESC + LIMIT 1 + """ + ) + weight_rows = [latest_w] if latest_w else [] + return { "days": days, "start": start_day, @@ -591,6 +657,9 @@ def period_summary(days: int) -> dict: "sleep": sleep, "hr": hr, "spo2": spo2, + "stress": stress, + "calories": calories_rows, + "weight": weight_rows, } @@ -621,57 +690,50 @@ def main_menu_text() -> str: sleep = latest_sleep() hr = latest_hr() spo2 = latest_spo2() + stress = latest_stress() lines = [] - # Шаги + + + # 1. Шаги if steps: - label = relative_day_label(steps["date"]) steps_count = int(steps["total_steps"]) - lines.append(f"🚶 {esc(label)}: {steps_count:,} шагов".replace(",", " ")) - lines.append(f" {step_goal_bar(steps_count)}") - lines.append(f" {step_goal_text(steps_count)}") - lines.append( - f" {float(steps['distance_m']) / 1000:.1f} км · {float(steps['calories']):.0f} ккал" - ) + dist_km = float(steps['distance_m']) / 1000.0 + cals = float(steps['calories']) + lines.append(f"🚶 {steps_count:,} · {dist_km:.1f} км · {cals:.0f} ккал".replace(",", " ")) else: - lines.append("🚶 Шаги: жду первую синхронизацию") + lines.append("🚶 Шаги н/д") lines.append("") - # Сон — краткая сводка на дашборде + # 2. Сон if sleep: total_sleep = sleep_total(sleep) - deep = int(sleep["deep_sleep_min"] or 0) - quality = sleep_quality_label(total_sleep, deep) - lines.append(f"😴 Сон: {format_minutes(total_sleep)} · {quality}") - lines.append( - f" {esc(relative_day_label(sleep['date']))} · " - f"глубокий: {deep} мин · лёгкий: {sleep['light_sleep_min']} мин" - + (f" · REM: {sleep['rem_sleep_min']} мин" if sleep['rem_sleep_min'] else "") - ) + try: + start_str = format_epoch(sleep["start_time"], False) + end_str = format_epoch(sleep["end_time"], False) + time_arrow = f"{start_str}→{end_str} · " + except Exception: + time_arrow = "" + lines.append(f"😴 {time_arrow}{format_minutes(total_sleep)}") else: - lines.append("😴 Сон: данных пока нет") + lines.append("😴 Сон н/д") lines.append("") - # Пульс + # 3. Пульс, Кислород, Стресс + metrics_parts = [] if hr: - bpm = int(hr["value"]) - lines.append(f"❤️ Пульс: {bpm} bpm · {format_relative_time(hr['timestamp'])}") - else: - lines.append("❤️ Пульс: данных пока нет") - - # SpO2 + metrics_parts.append(f"❤️ {int(hr['value'])}") if spo2: - spo2_val = float(spo2["spo2"]) - color = "🟢" if spo2_val >= 95 else ("🟡" if spo2_val >= 90 else "🔴") - lines.append(f"🩸 SpO2: {color} {spo2_val:.1f}% · {format_relative_time(spo2['timestamp'])}") - else: - lines.append("🩸 SpO2: данных пока нет") + metrics_parts.append(f"🩸 {float(spo2['spo2']):.0f}%") + if stress: + metrics_parts.append(f"🧘 {int(stress['value'])}") - # Совет дня - tip = daily_tip(steps, sleep, hr) - lines.extend(["", tip]) + if metrics_parts: + lines.append(" · ".join(metrics_parts)) + else: + lines.append("❤️ 🩸 🧘 н/д") return "\n".join(lines) @@ -686,99 +748,157 @@ def day_text(data: dict) -> str: spo2 = data["spo2"] day_str = data["date"] - day_value = parse_day(day_str) - start_epoch, end_epoch = day_bounds(day_value) - day_label = relative_day_label(day_str) - lines = [f"📊 {esc(day_label)} · {esc(day_str)}", ""] + + # Заголовок без эмодзи, затем пустая строка + lines = [f"Детали за {esc(day_str)} ({esc(day_label)})", ""] # Шаги if steps: steps_count = int(steps["total_steps"]) - lines.extend( - [ - "🚶 Активность:", - f" Шаги: {steps_count:,}".replace(",", " "), - f" {step_goal_bar(steps_count)}", - f" {step_goal_text(steps_count)}", - f" Дистанция: {float(steps['distance_m']) / 1000:.1f} км · {float(steps['calories']):.0f} ккал", - ] - ) + dist_km = float(steps['distance_m']) / 1000.0 + lines.append(f"🚶 Шаги: {steps_count:,} · {dist_km:.1f} км".replace(",", " ")) else: lines.append("🚶 Шаги: за этот день данных нет") + # Активность и Калории + cals_row = data.get("calories") + if cals_row: + total_cal = float(cals_row["total_cal"] or 0) + active_cal = float(cals_row["active_cal"] or 0) + stand_hours = int(cals_row["valid_stand_hours"] or 0) + intensity_min = int(cals_row["intensity_minutes"] or 0) + lines.append(f"🧍 Активность: разминки: {stand_hours}ч · интенсивность: {intensity_min} мин") + lines.append(f"🔥 Энергия: всего: {total_cal:.0f} ккал (активные: {active_cal:.0f} ккал)") + elif steps: + cals = float(steps['calories']) + lines.append(f"🔥 Энергия: {cals:.0f} ккал") + lines.append("") # Сон if sleep: total_sleep = sleep_total(sleep) deep = int(sleep["deep_sleep_min"] or 0) - quality = sleep_quality_label(total_sleep, deep) - lines.extend( - [ - f"😴 Сон: {format_minutes(total_sleep)} · {quality}", - f" Глубокий: {format_minutes(deep)} · Лёгкий: {format_minutes(sleep['light_sleep_min'])}" - + (f" · REM: {format_minutes(sleep['rem_sleep_min'])}" if sleep['rem_sleep_min'] else ""), - f" Окно: {format_epoch(sleep['start_time'], False)} — {format_epoch(sleep['end_time'], False)}", - ] - ) - # Пульс покоя - rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"])) - if rest_hr: - lines.append(f" Пульс покоя во сне: {rest_hr} bpm") + light = int(sleep["light_sleep_min"] or 0) + score = int(sleep["sleep_score"] or 0) + score_part = f" · {score}/100" if score else "" - # Sparklines ЧСС и SpO2 - sleep_hr_spark = get_sleep_sparkline( - "heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"]) - ) - sleep_spo2_spark = get_sleep_sparkline( - "blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"]) - ) - if sleep_hr_spark: - lines.append(f" 📈 ЧСС: [{sleep_hr_spark}]") - if sleep_spo2_spark: - lines.append(f" 📉 SpO2: [{sleep_spo2_spark}]") + try: + start_str = format_epoch(sleep["start_time"], False) + end_str = format_epoch(sleep["end_time"], False) + time_arrow = f" · {start_str}→{end_str}" + except Exception: + time_arrow = "" + + lines.append(f"😴 Сон: {format_minutes(total_sleep)} (глубокий: {format_minutes(deep)} · легкий: {format_minutes(light)}){score_part}{time_arrow}") else: lines.append("😴 Сон: за этот день данных нет") lines.append("") - lines.append("❤️ За сутки:") + + # Показатели (Пульс, SpO2, Стресс, Вес) + metrics_parts = [] if hr and hr["count"]: - lines.append( - f" Пульс: ср. {hr['avg_value']} · диапазон {hr['min_value']}–{hr['max_value']} bpm" - ) + avg_hr = int(hr["avg_value"]) + min_hr = int(hr["min_value"]) + max_hr = int(hr["max_value"]) + rest_hr_part = "" + if sleep: + rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"])) + if rest_hr: + rest_hr_part = f" · во сне: {rest_hr} bpm" + metrics_parts.append(f"❤️ Пульс: ср. {avg_hr} ({min_hr}–{max_hr}) bpm{rest_hr_part}") else: - lines.append(" Пульс: точек нет") + metrics_parts.append("❤️ Пульс: за этот день данных нет") + # SpO2 if spo2 and spo2["count"]: - lines.append( - f" SpO2: ср. {spo2['avg_value']}% · диапазон {spo2['min_value']}–{spo2['max_value']}%" - ) + avg_spo2 = float(spo2["avg_value"]) + min_spo2 = float(spo2["min_value"]) + max_spo2 = float(spo2["max_value"]) + metrics_parts.append(f"🩸 Кислород: ср. {avg_spo2:.0f}% ({min_spo2:.0f}–{max_spo2:.0f}%) SpO2") else: - lines.append(" SpO2: точек нет") + metrics_parts.append("🩸 Кислород: за этот день данных нет") + + # Стресс + stress = data.get("stress") + if stress and stress["count"]: + avg_str = int(stress["avg_value"]) + min_str = int(stress["min_value"]) + max_str = int(stress["max_value"]) + metrics_parts.append(f"🧘 Стресс: ср. {avg_str} ({min_str}–{max_str})") + else: + metrics_parts.append("🧘 Стресс: за этот день данных нет") + + # Вес + weight = data.get("weight") + if weight: + w_kg = float(weight["weight_kg"] or 0) + bmi_part = "" + fat_part = "" + if weight["bmi"]: + bmi_part = f" · BMI: {float(weight['bmi']):.1f}" + if weight["body_fat_pct"]: + fat_part = f" · жир: {float(weight['body_fat_pct']):.1f}%" + metrics_parts.append(f"⚖️ Вес: {w_kg:.1f} кг{bmi_part}{fat_part}") + + lines.append("\n\n".join(metrics_parts)) + + # Тренировки + workouts = data.get("workouts") + if workouts: + lines.append("") + lines.append("🏋️ Тренировки:") + for w in workouts: + sport = workout_type_label(w["sport_type"]) + dur_min = int(w["duration_sec"] or 0) // 60 + dur_sec = int(w["duration_sec"] or 0) % 60 + dur_str = f"{dur_min}:{dur_sec:02d}" + cal = float(w["calories"] or 0) + avg_hr = int(w["avg_hr"] or 0) + max_hr = int(w["max_hr"] or 0) + + hr_part = "" + if avg_hr: + hr_part = f" · ❤️ ср. {avg_hr}" + if max_hr and max_hr != avg_hr: + hr_part += f" (макс {max_hr})" + hr_part += " bpm" + + try: + start_dt = datetime.fromtimestamp(w["start_time"], LOCAL_TZ) + time_str = f" в {start_dt.hour:02d}:{start_dt.minute:02d}" + except Exception: + time_str = "" + + lines.append(f"• {esc(sport)}{time_str} ({dur_str} · 🔥 {cal:.0f} ккал{hr_part})") - lines.extend( - ["", "Статистика с датчиков браслета. Не является медицинским заключением."] - ) return "\n".join(lines) # --------------------------------------------------------------------------- # History / Calendar # --------------------------------------------------------------------------- +def day_btn_label(date_str: str) -> str: + try: + dt = datetime.strptime(date_str, "%Y-%m-%d").date() + month_name = RU_MONTHS[dt.month - 1] + return f"{dt.day} {month_name}" + except Exception: + return date_str + def history_text(days: int = 7) -> str: summary = period_summary(days) rows = summary["steps"] sleep_by_day = {row["date"]: row for row in summary["sleep"]} - # Все уникальные дни из обоих источников all_days = sorted( set([r["date"] for r in rows] + list(sleep_by_day.keys())), reverse=True, )[:days] lines = [ - f"📅 Календарь · последние {days} дней", - f"{summary['start'].isoformat()} — {summary['end'].isoformat()}", + f"📅 Последние {days} дней", "", ] @@ -791,15 +911,21 @@ def history_text(days: int = 7) -> str: sleep_row = sleep_by_day.get(d) icon = day_emoji(steps_row, sleep_row) + try: + dt = datetime.strptime(d, "%Y-%m-%d").date() + date_formatted = f"{dt.day:02d}.{dt.month:02d}" + except Exception: + date_formatted = d + steps_text = f"{int(steps_row['total_steps']):,} шагов".replace(",", " ") if steps_row else "шаги н/д" sleep_text = "сон н/д" if sleep_row: total = sleep_total(sleep_row) sleep_text = format_minutes(total) - lines.append(f"{icon} {d}: {steps_text} · {sleep_text}") + lines.append(f"{icon} {date_formatted} {steps_text} · {sleep_text}") lines.append("") - lines.append("Нажми на кнопку дня ниже, чтобы открыть детальный разрез.") + lines.append("Нажми на день ниже для деталей") return "\n".join(lines) @@ -818,7 +944,7 @@ def history_keyboard(days: int = 7) -> InlineKeyboardMarkup: buttons: list[list[InlineKeyboardButton]] = [period_row] day_buttons = [ - InlineKeyboardButton(day, callback_data=f"day:{day}") + InlineKeyboardButton(day_btn_label(day), callback_data=f"day:{day}") for day in available_days(days) ] for idx in range(0, len(day_buttons), 3): @@ -860,65 +986,55 @@ def latest_sleep_text() -> str: total_sleep = sleep_total(sleep) deep = int(sleep["deep_sleep_min"] or 0) rem = int(sleep["rem_sleep_min"] or 0) - awake = int(sleep["awake_min"] or 0) score = int(sleep["sleep_score"] or 0) - quality = sleep_quality_label(total_sleep, deep) - lines = [ - f"😴 Ночной сон · {esc(relative_day_label(sleep['date']))}", - f"Дата: {esc(sleep['date'])}", - "", - f"⏳ Длительность: {format_minutes(total_sleep)} · {quality}" - + (f" · Счёт: {score}/100" if score else ""), - f" • Глубокий: {format_minutes(deep)}", - f" • Лёгкий: {format_minutes(sleep['light_sleep_min'])}", - ] - if rem: - lines.append(f" • REM: {format_minutes(rem)}") - if awake: - lines.append(f" • Пробуждений: {awake} мин") - lines.append(f" • Постель: {format_epoch(sleep['start_time'], False)} — {format_epoch(sleep['end_time'], False)}") + import datetime as _dt + try: + sleep_date = _dt.datetime.strptime(sleep["date"], "%Y-%m-%d").date() + date_formatted = f"{sleep_date.day} {RU_MONTHS[sleep_date.month - 1]}" + except Exception: + date_formatted = sleep["date"] - # Пульс покоя + start_str = format_epoch(sleep["start_time"], False) + end_str = format_epoch(sleep["end_time"], False) + + rest_hr_str = "н/д" if sleep["start_time"] and sleep["end_time"]: rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"])) if rest_hr: - lines.append(f" • Пульс покоя: {rest_hr} bpm") + rest_hr_str = f"{rest_hr} bpm" + + deep_bar = make_sleep_bar(deep, total_sleep) + light = int(sleep["light_sleep_min"] or 0) + light_bar = make_sleep_bar(light, total_sleep) + rem_bar = make_sleep_bar(rem, total_sleep) + + lines = [ + f"😴 Ночной сон · {date_formatted}", + "", + f"Длительность {format_minutes(total_sleep)}", + f"Качество {score} / 100" if score else "Качество н/д", + f"Постель {start_str} — {end_str}", + f"Пульс покоя {rest_hr_str}", + "", + f"Глубокий {deep_bar} {format_minutes(deep)}", + f"Лёгкий {light_bar} {format_minutes(light)}", + ] + if rem: + lines.append(f"REM {rem_bar} {format_minutes(rem)}") lines.append("") - # Sparklines - if sleep["start_time"] and sleep["end_time"]: - sleep_hr_spark = get_sleep_sparkline( - "heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"]) - ) - sleep_spo2_spark = get_sleep_sparkline( - "blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"]) - ) + hr_str = "н/д" + if hr and hr["count"]: + hr_str = f"ср. {int(hr['avg_value'])} · диапазон {int(hr['min_value'])}–{int(hr['max_value'])}" + lines.append(f"ЧСС во сне {hr_str}") - if sleep_hr_spark: - lines.append("📈 ЧСС во сне:") - lines.append(f" [{sleep_hr_spark}]") - if hr and hr["count"]: - lines.append( - f" Ср. {hr['avg_value']} bpm · диапазон {hr['min_value']}–{hr['max_value']} bpm" - ) - lines.append("") - else: - lines.append("📈 ЧСС: детальных точек нет\n") + spo2_str = "н/д" + if spo2 and spo2["count"]: + spo2_str = f"ср. {int(spo2['avg_value'])}% · мин. {int(spo2['min_value'])}%" + lines.append(f"SpO2 во сне {spo2_str}") - if sleep_spo2_spark: - lines.append("📉 SpO2 во сне:") - lines.append(f" [{sleep_spo2_spark}]") - if spo2 and spo2["count"]: - lines.append( - f" Ср. {spo2['avg_value']}% · мин. {spo2['min_value']}%" - ) - lines.append("") - else: - lines.append("📉 SpO2: детальных точек нет\n") - - lines.append("Детали подтягиваются автоматически при фоновой синхронизации с FDS.") return "\n".join(lines) @@ -932,9 +1048,7 @@ def period_text(days: int) -> str: total_steps = sum(int(row["total_steps"] or 0) for row in steps) avg_steps = round(total_steps / len(steps)) if steps else 0 - total_distance = sum(float(row["distance_m"] or 0) for row in steps) / 1000 best_steps = max(steps, key=lambda r: int(r["total_steps"] or 0), default=None) - worst_steps = min(steps, key=lambda r: int(r["total_steps"] or 0), default=None) sleep_totals = [ sleep_total(row) for row in sleep_rows @@ -945,50 +1059,105 @@ def period_text(days: int) -> str: hr = summary["hr"] spo2 = summary["spo2"] + stress = summary["stress"] + cals_rows = summary["calories"] + weight_rows = summary["weight"] + start_date = summary["start"] + end_date = summary["end"] + if start_date.month == end_date.month: + month_name = RU_MONTHS[start_date.month - 1] + date_range = f"{start_date.day}–{end_date.day} {month_name}" + else: + start_month = RU_MONTHS[start_date.month - 1] + end_month = RU_MONTHS[end_date.month - 1] + date_range = f"{start_date.day} {start_month} — {end_date.day} {end_month}" + + avg_total_cal = None + avg_active_cal = None + avg_stand_hours = None + avg_intensity_min = None + + if cals_rows: + valid_cals = [float(r["total_cal"]) for r in cals_rows if r["total_cal"] is not None] + valid_active = [float(r["active_cal"]) for r in cals_rows if r["active_cal"] is not None] + valid_stand = [int(r["valid_stand_hours"]) for r in cals_rows if r["valid_stand_hours"] is not None] + valid_intensity = [int(r["intensity_minutes"]) for r in cals_rows if r["intensity_minutes"] is not None] + + if valid_cals: + avg_total_cal = round(sum(valid_cals) / len(valid_cals)) + if valid_active: + avg_active_cal = round(sum(valid_active) / len(valid_active)) + if valid_stand: + avg_stand_hours = round(sum(valid_stand) / len(valid_stand)) + if valid_intensity: + avg_intensity_min = round(sum(valid_intensity) / len(valid_intensity)) + + stress_str = "н/д" + if stress and stress["count"]: + stress_str = f"ср. {int(stress['avg_value'])} · диапазон {int(stress['min_value'])}–{int(stress['max_value'])}" + + weight_str = None + if weight_rows: + latest_weight = weight_rows[0] + w_kg = float(latest_weight["weight_kg"] or 0) + bmi_val = latest_weight["bmi"] + fat_val = latest_weight["body_fat_pct"] + + weight_str = f"{w_kg:.1f} кг" + if bmi_val: + weight_str += f" (BMI: {float(bmi_val):.1f}" + if fat_val: + weight_str += f" · жир: {float(fat_val):.1f}%" + weight_str += ")" + + period_label = "Все время" if days >= 3650 else f"{days} дней" lines = [ - f"📊 Аналитика · {days} дней", - f"{summary['start'].isoformat()} — {summary['end'].isoformat()}", + f"📊 Тренды · {period_label} · {date_range}", "", - "🚶 Активность:", - f" Всего шагов: {total_steps:,}".replace(",", " "), - f" В среднем: {avg_steps:,} / день".replace(",", " "), - f" Дистанция: {total_distance:.1f} км", - f" Норма {STEP_GOAL:,} выполнена: {goal_days} из {len(steps)} дней".replace(",", " "), + f"🚶 Шаги всего {total_steps:,}".replace(",", " "), + f" В среднем / день {avg_steps:,}".replace(",", " "), + f" Норма {STEP_GOAL // 1000}k {goal_days} из {len(steps)} дней", ] if best_steps: - lines.append( - f" 🏆 Лучший день: {best_steps['date']} · {int(best_steps['total_steps']):,} шагов".replace(",", " ") - ) - if worst_steps and len(steps) > 1: - lines.append( - f" 📉 Слабейший: {worst_steps['date']} · {int(worst_steps['total_steps']):,} шагов".replace(",", " ") - ) + try: + best_dt = datetime.strptime(best_steps["date"], "%Y-%m-%d").date() + best_date_str = f"{best_dt.day:02d}.{best_dt.month:02d}" + except Exception: + best_date_str = best_steps["date"] + lines.append(f" 🏆 Лучший день {best_date_str} · {int(best_steps['total_steps']):,}".replace(",", " ")) lines.append("") - lines.append("😴 Сон:") - if avg_sleep is not None: - lines.append(f" Среднее: {format_minutes(avg_sleep)}") - else: - lines.append(" Среднее: н/д") - if best_sleep is not None: - lines.append(f" Лучшая ночь: {format_minutes(best_sleep)}") + + if avg_total_cal is not None: + lines.append("🧍 Активность ср.") + lines.append(f" Расход энергии {avg_total_cal} ккал (активные: {avg_active_cal} ккал)") + stand_part = f"{avg_stand_hours}ч" if avg_stand_hours is not None else "н/д" + intens_part = f"{avg_intensity_min} мин" if avg_intensity_min is not None else "н/д" + lines.append(f" Часы разминок {stand_part} · интенсивность: {intens_part}") + lines.append("") + + lines.append("😴 Сон среднее " + (format_minutes(avg_sleep) if avg_sleep else "н/д")) + if best_sleep: + lines.append(f" Лучшая ночь {format_minutes(best_sleep)}") lines.append("") - lines.append("❤️ ЧСС и SpO2:") + hr_str = "н/д" if hr and hr["count"]: - lines.append( - f" Пульс: ср. {hr['avg_value']} bpm · диапазон {hr['min_value']}–{hr['max_value']} bpm" - ) - else: - lines.append(" Пульс: данных нет") + hr_str = f"{int(hr['avg_value'])} bpm · {int(hr['min_value'])}–{int(hr['max_value'])}" + lines.append(f"❤️ Пульс ср. {hr_str}") + spo2_str = "н/д" if spo2 and spo2["count"]: - lines.append( - f" SpO2: ср. {spo2['avg_value']}% · мин. {spo2['min_value']}%" - ) - else: - lines.append(" SpO2: данных нет") + spo2_str = f"{float(spo2['avg_value']):.1f}% · мин. {int(spo2['min_value'])}%" + lines.append(f"🩸 SpO2 ср. {spo2_str}") + + # Стресс + lines.append(f"🧘 Стресс ср. {stress_str}") + + # Вес + if weight_str: + lines.append(f"⚖️ Вес (последний) {weight_str}") lines.extend(["", "Берегите здоровье!"]) return "\n".join(lines) @@ -1007,28 +1176,103 @@ def trends_keyboard(days: int) -> InlineKeyboardMarkup: callback_data="period:30d", ), ], + [ + InlineKeyboardButton("· Все время ·" if days >= 3650 else "Все время", callback_data="period:all"), + InlineKeyboardButton("🏋️ Тренировки", callback_data="menu:workouts"), + ], [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], ] ) +# --------------------------------------------------------------------------- +# Workouts screen +# --------------------------------------------------------------------------- +def workouts_text(limit: int = 10) -> str: + workouts = recent_workouts(limit) + lines = ["🏋️ Тренировки", ""] + if not workouts: + lines.append("Пока нет записей. Синхронизация загрузит тренировки автоматически.") + return "\n".join(lines) + + for w in workouts: + sport = workout_type_label(w["sport_type"]) + start = format_epoch(w["start_time"]) + dur_min = int(w["duration_sec"] or 0) // 60 + dur_sec = int(w["duration_sec"] or 0) % 60 + dur_str = f"{dur_min}:{dur_sec:02d}" + cal = float(w["calories"] or 0) + avg_hr = int(w["avg_hr"] or 0) + max_hr = int(w["max_hr"] or 0) + + lines.append(f"🏋️ {esc(sport)}") + lines.append(f" 🗓 {esc(start)}") + lines.append(f" ⏱ {dur_str} · 🔥 {cal:.0f} ккал") + if avg_hr: + lines.append(f" ❤️ ср. {avg_hr} bpm", ) + if max_hr and max_hr != avg_hr: + lines[-1] = lines[-1] + f" · макс {max_hr} bpm" + lines.append("") + + lines.append("Последние тренировки из Xiaomi Health.") + return "\n".join(lines) + + +def workouts_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [[InlineKeyboardButton("⬅️ Тренды", callback_data="menu:trends")]] + ) + + + + # --------------------------------------------------------------------------- # Service menu # --------------------------------------------------------------------------- +def db_total_records() -> int: + if not health_db_exists(): + return 0 + conn = health_conn() + tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress", "calories_daily", "weight", "workouts"] + total = 0 + try: + for table in tables: + exists = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ).fetchone() + if exists: + total += conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + except Exception: + pass + finally: + conn.close() + return total + def more_text() -> str: + status = read_status_file() + last_sync_epoch = status.get("last_sync") + last_sync_str = format_relative_time(last_sync_epoch) if last_sync_epoch else "н/д" + + interval_min = int(SETTINGS.sync_interval) // 60 + db_records = db_total_records() + return ( "⚙️ Сервис\n\n" - "Технические функции: принудительная синхронизация, статус базы данных и выгрузка CSV." + f"Устройство Mi Band\n" + f"Последний синк {last_sync_str}\n" + f"Интервал {interval_min} мин\n" + f"Записей в БД {db_records:,}".replace(",", " ") ) def more_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ - [InlineKeyboardButton("🔄 Принудительный синк", callback_data="menu:sync")], + [InlineKeyboardButton("🔄 Синхронизировать", callback_data="menu:sync")], [ InlineKeyboardButton("💾 Экспорт ZIP", callback_data="menu:export"), - InlineKeyboardButton("🧰 Статус БД", callback_data="menu:db_status"), + InlineKeyboardButton("📊 Статус БД", callback_data="menu:db_status"), ], [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], ] @@ -1043,7 +1287,8 @@ def db_status_text() -> str: conn = health_conn() try: - tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen"] + tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", + "stress", "calories_daily", "weight", "workouts"] for table in tables: exists = conn.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", @@ -1215,15 +1460,29 @@ async def run_initial_sync_after_login(update: Update, context: ContextTypes.DEF # Menu keyboards # --------------------------------------------------------------------------- def main_keyboard() -> InlineKeyboardMarkup: + is_en = os.getenv("BOT_LANG") == "en" + if is_en: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("😴 Sleep", callback_data="menu:sleep"), + InlineKeyboardButton("📊 Weekly", callback_data="menu:trends"), + ], + [ + InlineKeyboardButton("📅 History", callback_data="menu:history"), + InlineKeyboardButton("⚙️ Settings", callback_data="menu:more"), + ], + ] + ) return InlineKeyboardMarkup( [ [ - InlineKeyboardButton("📅 Календарь", callback_data="menu:history"), - InlineKeyboardButton("📊 Аналитика", callback_data="menu:trends"), + InlineKeyboardButton("😴 Сон", callback_data="menu:sleep"), + InlineKeyboardButton("📊 За неделю", callback_data="menu:trends"), ], [ - InlineKeyboardButton("😴 Детали сна", callback_data="menu:sleep"), - InlineKeyboardButton("⚙️ Сервис", callback_data="menu:more"), + InlineKeyboardButton("📅 История", callback_data="menu:history"), + InlineKeyboardButton("⚙️ Настройки", callback_data="menu:more"), ], ] ) @@ -1409,11 +1668,22 @@ async def cmd_sync(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not is_allowed(update): return + text = update.message.text if update.message else "" await safe_delete(update.message) if not has_xiaomi_token(): await show_onboarding(update, context) return - await show_main_menu(update, context) + + if "Сон" in text or "Sleep" in text: + await update_menu(update, context, latest_sleep_text(), back_keyboard()) + elif "За неделю" in text or "Weekly" in text: + await update_menu(update, context, period_text(7), trends_keyboard(7)) + elif "История" in text or "History" in text: + await show_history(update, context, 7) + elif "Настройки" in text or "Settings" in text: + await update_menu(update, context, more_text(), more_keyboard()) + else: + await show_main_menu(update, context) # --------------------------------------------------------------------------- @@ -1451,19 +1721,14 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> update, context, latest_sleep_text(), - InlineKeyboardMarkup( - [ - [InlineKeyboardButton("📅 Календарь", callback_data="menu:history")], - [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], - ] - ), + back_keyboard(), ) elif data == "menu:trends": - await update_menu(update, context, period_text(30), trends_keyboard(30)) + await update_menu(update, context, period_text(7), trends_keyboard(7)) elif data.startswith("period:"): - days = 7 if data == "period:7d" else 30 + days = 3650 if data == "period:all" else 7 if data == "period:7d" else 30 await update_menu(update, context, period_text(days), trends_keyboard(days)) elif data.startswith("day:"): @@ -1478,6 +1743,9 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> back_keyboard("menu:history"), ) + elif data == "menu:workouts": + await update_menu(update, context, workouts_text(), workouts_keyboard()) + elif data == "menu:more": await update_menu(update, context, more_text(), more_keyboard()) @@ -1509,13 +1777,21 @@ def main() -> None: if ALLOWED_USER_ID is not None: DB_PATH = str(SETTINGS.user_db_path(ALLOWED_USER_ID)) print(f"Запуск бота для пользователя ID {ALLOWED_USER_ID}...") + storage.init_health_db(Path(DB_PATH)) else: DB_PATH = str(SETTINGS.db_path) print("Бот запущен. Отправьте /start в Telegram чтобы привязать аккаунт.") + storage.init_health_db(Path(DB_PATH)) init_state_db() - app = Application.builder().token(BOT_TOKEN).build() + app = ( + Application.builder() + .token(BOT_TOKEN) + .post_init(start_background_tasks) + .post_shutdown(stop_background_tasks) + .build() + ) app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("sync", cmd_sync)) diff --git a/miband_tracker/bot/formatting.py b/miband_tracker/bot/formatting.py new file mode 100644 index 0000000..e9e6767 --- /dev/null +++ b/miband_tracker/bot/formatting.py @@ -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"[{bar}] {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()) diff --git a/miband_tracker/storage.py b/miband_tracker/storage.py index 5cc3af3..1be8a6b 100644 --- a/miband_tracker/storage.py +++ b/miband_tracker/storage.py @@ -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() diff --git a/miband_tracker/sync.py b/miband_tracker/sync.py index b70bbd0..38f95c8 100644 --- a/miband_tracker/sync.py +++ b/miband_tracker/sync.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index ee2a1ec..c376ab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ target-version = "py311" extend-exclude = [ ".venv", "data", + "scratch", "mi-fitness-python", ] diff --git a/setup.bat b/setup.bat index bc43659..2f2739c 100644 --- a/setup.bat +++ b/setup.bat @@ -26,14 +26,18 @@ echo OK Docker готов set DOCKER_ACTIVE=1 goto SETUP_ENV -:MODE_PYTHON -python --version >nul 2>nul -if not errorlevel 1 goto PYTHON_OK - -echo ! Python не найден: https://www.python.org/downloads/ -echo. -set install_python=y -set /p install_python=" Установить Python 3.11 автоматически? [Y/n]: " +:MODE_PYTHON +python --version >nul 2>nul +if not errorlevel 1 ( + python -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul + if not errorlevel 1 goto PYTHON_OK + echo ! Найден Python, но нужна версия 3.11 или новее. +) + +echo ! Python 3.11+ не найден: https://www.python.org/downloads/ +echo. +set install_python=y +set /p install_python=" Установить Python 3.11 автоматически? [Y/n]: " if /i not "%install_python%"=="y" ( echo Установите Python вручную и повторите. & pause & exit /b 1 ) echo Скачивание Python 3.11.9... @@ -110,19 +114,24 @@ goto END_LAUNCH :PYTHON_LAUNCH echo Подготовка... if not exist .venv ( python -m venv .venv >nul 2>nul ) -call .venv\Scripts\activate -pip install -r requirements.txt -e mi-fitness-python > pip_install.log 2>&1 -if not errorlevel 1 ( - del pip_install.log >nul 2>nul - echo OK Готово -) else ( - echo ! Ошибка установки зависимостей: - type pip_install.log - del pip_install.log >nul 2>nul - pause & exit /b 1 -) - ->run_local.bat echo @echo off +call .venv\Scripts\activate +python -m pip install --upgrade pip setuptools wheel > pip_install.log 2>&1 +if errorlevel 1 goto PIP_INSTALL_FAILED +python -m pip install -r requirements.txt -e mi-fitness-python >> pip_install.log 2>&1 +if errorlevel 1 goto PIP_INSTALL_FAILED +goto PIP_INSTALL_OK + +:PIP_INSTALL_FAILED +echo ! Ошибка установки зависимостей: +type pip_install.log +del pip_install.log >nul 2>nul +pause & exit /b 1 + +:PIP_INSTALL_OK +del pip_install.log >nul 2>nul +echo OK Готово + +>run_local.bat echo @echo off >>run_local.bat echo chcp 65001 ^> nul >>run_local.bat echo cd /d "%%~dp0" >>run_local.bat echo if not exist .venv ^( diff --git a/setup.sh b/setup.sh index 395ac76..aac76ff 100755 --- a/setup.sh +++ b/setup.sh @@ -43,14 +43,16 @@ if [ "$install_mode" = "2" ]; then DOCKER_MODE=true else PYTHON_CMD="" - for cmd in python3 python; do + for cmd in python3.13 python3.12 python3.11 python3 python; do if command -v "$cmd" &> /dev/null; then - ver=$("$cmd" -c 'import sys; print(sys.version_info[0])' 2>/dev/null) - [ "$ver" = "3" ] && PYTHON_CMD="$cmd" && break + if "$cmd" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then + PYTHON_CMD="$cmd" + break + fi fi done if [ -z "$PYTHON_CMD" ]; then - err "Python 3 не найден. Установите: https://www.python.org/downloads/" + err "Python 3.11+ не найден. Установите: https://www.python.org/downloads/" exit 1 fi ok "Python готов ($PYTHON_CMD)" @@ -119,7 +121,8 @@ if [ ! -d ".venv" ]; then fi source .venv/bin/activate -if pip install -r requirements.txt -e mi-fitness-python > pip_install.log 2>&1; then +if python -m pip install --upgrade pip setuptools wheel > pip_install.log 2>&1 \ + && python -m pip install -r requirements.txt -e mi-fitness-python >> pip_install.log 2>&1; then rm -f pip_install.log ok "Готово" else diff --git a/tests/test_bot_ui.py b/tests/test_bot_ui.py index a845900..0c1dcb8 100644 --- a/tests/test_bot_ui.py +++ b/tests/test_bot_ui.py @@ -3,12 +3,15 @@ import asyncio import json +import os +import time from pathlib import Path from unittest.mock import AsyncMock import pytest import miband_tracker.bot.app as bot_app +from miband_tracker import storage from miband_tracker.config import Settings from miband_tracker.sync import SyncResult @@ -23,6 +26,20 @@ class FakeUpdate: callback_query = None +def _settings(tmp_path: Path) -> Settings: + return Settings( + data_dir=tmp_path, + db_path=tmp_path / "miband.db", + status_path=tmp_path / "status.json", + bot_state_db_path=tmp_path / "fitness_bot_state.db", + telegram_bot_token="token", + telegram_allowed_user_id=123, + sync_interval=900, + query_duration=2, + enable_fds_sleep_details=True, + ) + + def test_service_menu_does_not_expose_invites_or_second_user() -> None: keyboard = bot_app.more_keyboard() labels = [ @@ -84,17 +101,7 @@ async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu( async def close(self): return None - settings = Settings( - data_dir=tmp_path, - db_path=tmp_path / "miband.db", - status_path=tmp_path / "status.json", - bot_state_db_path=tmp_path / "fitness_bot_state.db", - telegram_bot_token="token", - telegram_allowed_user_id=123, - sync_interval=900, - query_duration=2, - enable_fds_sleep_details=True, - ) + settings = _settings(tmp_path) run_sync = AsyncMock(return_value=SyncResult(True, user_id=123)) show_main_menu = AsyncMock() @@ -114,3 +121,148 @@ async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu( assert data["service_token"] == "service" run_sync.assert_awaited_once_with(123, settings) show_main_menu.assert_awaited_once_with(update, context) + + +def test_main_menu_renders_extended_metrics(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings(tmp_path) + db_path = settings.canonical_user_db_path() + storage.init_health_db(db_path) + with storage.sqlite_conn(db_path, row_factory=False) as conn: + conn.execute( + "INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)", + ("2026-05-26", 451, 22.0, 296.0, 1), + ) + conn.execute("INSERT INTO heart_rate (timestamp, value) VALUES (?, ?)", (1779768600, 73)) + conn.execute("INSERT INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)", (1779768900, 99.0, "latest")) + conn.execute("INSERT INTO stress (timestamp, value) VALUES (?, ?)", (1779757800, 29)) + conn.execute( + """ + INSERT INTO calories_daily + (date, total_cal, active_cal, valid_stand_hours, intensity_minutes, last_sync) + VALUES (?, ?, ?, ?, ?, ?) + """, + ("2026-05-26", 55.0, None, 1, 6, 1), + ) + conn.commit() + + monkeypatch.setattr(bot_app, "SETTINGS", settings) + monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123) + monkeypatch.setattr(bot_app, "DB_PATH", str(db_path)) + + rendered = bot_app.main_menu_text() + + assert "451" in rendered + assert "99%" in rendered + assert "🧘" in rendered + assert "22 ккал" in rendered + + +def test_workouts_text_renders_recent_workout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings(tmp_path) + db_path = settings.canonical_user_db_path() + storage.init_health_db(db_path) + with storage.sqlite_conn(db_path, row_factory=False) as conn: + conn.execute( + """ + INSERT INTO workouts + (workout_id, sport_type, start_time, end_time, duration_sec, + calories, avg_hr, max_hr, min_hr, watermark, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("w1", "free_training", 1779752903, 1779753050, 142, 7.0, 95, 152, 80, 1, "{}"), + ) + conn.commit() + + monkeypatch.setattr(bot_app, "SETTINGS", settings) + monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123) + monkeypatch.setattr(bot_app, "DB_PATH", str(db_path)) + + rendered = bot_app.workouts_text() + + assert "Тренировки" in rendered + assert "Свободная" in rendered + assert "95 bpm" in rendered + + +def test_sleep_text_has_no_fds_hint_or_calendar_button(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = _settings(tmp_path) + db_path = settings.canonical_user_db_path() + storage.init_health_db(db_path) + with storage.sqlite_conn(db_path, row_factory=False) as conn: + conn.execute( + """ + INSERT INTO sleep_daily + (date, light_sleep_min, deep_sleep_min, start_time, end_time, + rem_sleep_min, awake_min, total_duration_min, sleep_score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("2026-05-26", 137, 123, 1779744540, 1779770460, 136, 0, 396, 64), + ) + conn.commit() + + monkeypatch.setattr(bot_app, "SETTINGS", settings) + monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123) + monkeypatch.setattr(bot_app, "DB_PATH", str(db_path)) + + rendered = bot_app.latest_sleep_text() + keyboard_labels = [ + button.text + for row in bot_app.back_keyboard().inline_keyboard + for button in row + ] + + assert "Детали подтягиваются" not in rendered + assert "FDS" not in rendered + assert "Календарь" not in keyboard_labels + + +def test_trends_screen_labels_and_keyboard() -> None: + rendered = bot_app.period_text(3650) + keyboard = bot_app.trends_keyboard(3650) + labels = [ + button.text + for row in keyboard.inline_keyboard + for button in row + ] + + assert "Тренды" in rendered + assert "Все время" in rendered + assert not any("Аналитика" in label for label in labels) + assert not any("Детали сна" in label for label in labels) + assert any("Все время" in label for label in labels) + + +@pytest.mark.asyncio +async def test_auto_refresh_updates_saved_menu_after_status_change( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = _settings(tmp_path) + storage.init_health_db(settings.canonical_user_db_path()) + storage.init_state_db(settings) + storage.set_user_menu_msg_id(settings, 123, 99) + settings.canonical_user_status_path(123).write_text("{}", encoding="utf-8") + + class FakeBot: + edit_message_text = AsyncMock() + + class FakeApp: + bot = FakeBot() + + monkeypatch.setattr(bot_app, "SETTINGS", settings) + monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123) + monkeypatch.setattr(bot_app, "DB_PATH", str(settings.canonical_user_db_path())) + monkeypatch.setattr(bot_app, "AUTO_MENU_REFRESH_INTERVAL", 0.01) + + task = asyncio.create_task(bot_app.auto_refresh_main_menu_loop(FakeApp())) + await asyncio.sleep(0.02) + now = time.time() + 1 + path = settings.canonical_user_status_path(123) + path.write_text('{"last_sync": 1}', encoding="utf-8") + os.utime(path, (now, now)) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + FakeApp.bot.edit_message_text.assert_awaited() diff --git a/tests/test_storage.py b/tests/test_storage.py index 12cca70..9bf0be5 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -32,7 +32,54 @@ def test_init_health_db_is_idempotent(tmp_path: Path) -> None: row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() } - assert {"steps_daily", "sleep_daily", "heart_rate", "blood_oxygen"}.issubset(tables) + assert { + "steps_daily", + "sleep_daily", + "heart_rate", + "blood_oxygen", + "stress", + "calories_daily", + "weight", + "workouts", + }.issubset(tables) + + +def test_extended_health_tables_have_expected_columns(tmp_path: Path) -> None: + db_path = tmp_path / "miband_123.db" + storage.init_health_db(db_path) + + with storage.sqlite_conn(db_path, row_factory=False) as conn: + columns = { + table: { + row[1] + for row in conn.execute(f"PRAGMA table_info({table})").fetchall() + } + for table in ("stress", "calories_daily", "weight", "workouts") + } + + assert columns["stress"] == {"timestamp", "value"} + assert { + "date", + "total_cal", + "active_cal", + "valid_stand_hours", + "intensity_minutes", + "last_sync", + }.issubset(columns["calories_daily"]) + assert {"timestamp", "weight_kg", "bmi", "body_fat_pct"}.issubset(columns["weight"]) + assert { + "workout_id", + "sport_type", + "start_time", + "end_time", + "duration_sec", + "calories", + "avg_hr", + "max_hr", + "min_hr", + "watermark", + "raw_json", + }.issubset(columns["workouts"]) def test_zip_export_includes_non_empty_tables_only(tmp_path: Path) -> None: @@ -44,9 +91,24 @@ def test_zip_export_includes_non_empty_tables_only(tmp_path: Path) -> None: "INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)", ("2026-05-24", 1000, 10.0, 800.0, 1), ) + conn.execute( + "INSERT INTO stress (timestamp, value) VALUES (?, ?)", + (1779760000, 42), + ) + conn.execute( + """ + INSERT INTO workouts + (workout_id, sport_type, start_time, end_time, duration_sec, + calories, avg_hr, max_hr, min_hr, watermark, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("w1", "free_training", 1779760000, 1779760600, 600, 55.0, 110, 130, 90, 1, "{}"), + ) conn.commit() archive = storage.zip_export(settings) assert archive.getbuffer().nbytes > 0 assert b"steps_daily.csv" in archive.getvalue() + assert b"stress.csv" in archive.getvalue() + assert b"workouts.csv" in archive.getvalue() diff --git a/tests/test_sync.py b/tests/test_sync.py index ce6eee0..0a3f32a 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -6,7 +6,8 @@ from pathlib import Path import pytest from miband_tracker.config import Settings -from miband_tracker.sync import run_sync +from miband_tracker.storage import init_health_db, sqlite_conn +from miband_tracker.sync import _sync_workouts, run_sync @pytest.mark.asyncio @@ -28,3 +29,49 @@ async def test_run_sync_missing_token_returns_failed_result(tmp_path: Path) -> N assert not result.success assert result.user_id == 123 assert "Token file not found" in (result.error or "") + + +@pytest.mark.asyncio +async def test_sync_workouts_inserts_watermark_records(tmp_path: Path) -> None: + db_path = tmp_path / "miband_123.db" + init_health_db(db_path) + + class FakeClient: + async def _request(self, method, path, params): + assert method == "GET" + assert path == "/app/v1/data/get_sport_records_by_watermark" + assert params["relative_uid"] == 456 + return { + "result": { + "has_more": False, + "sport_records": [ + { + "sid": "w1", + "key": "free_training", + "time": 1779752903, + "watermark": 12345, + "value": ( + '{"start_time": 1779752903, "end_time": 1779753050, ' + '"duration": 142, "calories": 7, "avg_hrm": 95, ' + '"max_hrm": 152, "min_hrm": 80}' + ), + } + ], + } + } + + counters = {"workouts": 0} + with sqlite_conn(db_path, row_factory=False) as conn: + cursor = conn.cursor() + await _sync_workouts(FakeClient(), cursor, counters, 456) + conn.commit() + + with sqlite_conn(db_path) as conn: + row = conn.execute("SELECT * FROM workouts WHERE workout_id = ?", ("w1",)).fetchone() + + assert counters["workouts"] == 1 + assert row is not None + assert row["sport_type"] == "free_training" + assert row["duration_sec"] == 142 + assert row["avg_hr"] == 95 + assert row["watermark"] == 12345