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