Add extended health metrics and trends UI

This commit is contained in:
Alex
2026-05-26 16:28:07 +03:00
parent a70439194e
commit d52a2d6d1d
13 changed files with 1387 additions and 380 deletions
+2
View File
@@ -5,6 +5,8 @@ __pycache__/
.ruff_cache/
.mypy_cache/
.test-tmp/
scratch/
plan_workouts.md
.coverage
htmlcov/
.DS_Store
+7 -5
View File
@@ -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`.
---
+7 -5
View File
@@ -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.
---
+604 -328
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import html
import os
import time
from datetime import date, datetime, timedelta
from datetime import time as dt_time
from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo("Europe/Moscow")
RU_MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]
RU_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
DEFAULT_STEP_GOAL = 10_000
SPORT_TYPE_LABELS: dict[str, str] = {
"free_training": "Свободная тренировка",
"outdoor_running": "Бег на улице",
"treadmill": "Беговая дорожка",
"walking": "Ходьба",
"cycling": "Велосипед",
"swimming": "Плавание",
"yoga": "Йога",
"strength_training": "Силовая",
"hiit": "HIIT",
"jump_rope": "Скакалка",
"elliptical": "Эллипсоид",
"rowing": "Гребля",
"outdoor_cycling": "Велосипед (улица)",
"basketball": "Баскетбол",
"football": "Футбол",
"table_tennis": "Настольный теннис",
"badminton": "Бадминтон",
"tennis": "Теннис",
"volleyball": "Волейбол",
"dancing": "Танцы",
"martial_arts": "Боевые искусства",
}
def esc(value: object) -> str:
return html.escape(str(value), quote=False)
def format_mia_date(dt: date) -> str:
month_name = RU_MONTHS[dt.month - 1]
weekday_name = RU_WEEKDAYS[dt.weekday()]
return f"{dt.day} {month_name}, {weekday_name}"
def make_sleep_bar(stage_min: int, total_min: int) -> str:
if total_min <= 0:
return "" * 10
blocks = int(round((stage_min / total_min) * 10))
blocks = max(0, min(10, blocks))
return "" * blocks + "" * (10 - blocks)
def format_epoch(epoch: int | float | None, with_date: bool = True) -> str:
is_en = os.getenv("BOT_LANG") == "en"
if not epoch:
return "n/a" if is_en else "н/д"
fmt = "%Y-%m-%d %H:%M" if with_date else "%H:%M"
return datetime.fromtimestamp(int(epoch), LOCAL_TZ).strftime(fmt)
def format_relative_time(epoch: int | float | None) -> str:
is_en = os.getenv("BOT_LANG") == "en"
if not epoch:
return "n/a" if is_en else "н/д"
diff = int(time.time() - int(epoch))
if diff < 60:
return "just now" if is_en else "только что"
diff_min = diff // 60
if diff_min < 60:
return f"{diff_min} min. ago" if is_en else f"{diff_min} мин. назад"
diff_hours = diff_min // 60
if diff_hours < 24:
return f"{diff_hours} hr. ago" if is_en else f"{diff_hours} ч. назад"
diff_days = diff_hours // 24
if diff_days == 1:
return "yesterday" if is_en else "вчера"
return f"{diff_days} days ago" if is_en else f"{diff_days} дн. назад"
def format_minutes(minutes: int | float | None) -> str:
is_en = os.getenv("BOT_LANG") == "en"
if minutes is None:
return "n/a" if is_en else "н/д"
minutes = int(minutes)
if is_en:
return f"{minutes // 60} h {minutes % 60:02d} m"
return f"{minutes // 60} ч {minutes % 60:02d} мин"
def step_goal_bar(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
steps_int = int(steps or 0)
percent = min(100, round(steps_int / goal * 100)) if goal > 0 else 0
filled = percent // 10
bar = "" * filled + "" * (10 - filled)
return f"<code>[{bar}]</code> {percent}%"
def step_goal_text(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
is_en = os.getenv("BOT_LANG") == "en"
if steps is None:
return f"Goal {goal:,} steps".replace(",", " ") if is_en else f"Цель {goal:,} шагов".replace(",", " ")
steps_int = int(steps)
left = max(0, goal - steps_int)
if left:
text = f"Goal {goal:,} · {left:,} steps left" if is_en else f"Цель {goal:,} · осталось {left:,} шагов"
else:
text = f"Goal {goal:,} · daily goal achieved! 🎉" if is_en else f"Цель {goal:,} · дневная цель выполнена! 🎉"
return text.replace(",", " ")
def make_sparkline(values: list[float | int]) -> str:
if not values:
return ""
sparks = [" ", "", "", "", "", "", "", ""]
min_val = min(values)
max_val = max(values)
val_range = max_val - min_val
if val_range == 0:
return sparks[4] * min(len(values), 20)
sparkline = []
for val in values:
idx = int((val - min_val) / val_range * (len(sparks) - 1))
sparkline.append(sparks[idx])
return "".join(sparkline)
def relative_day_label(day_str: str | None) -> str:
is_en = os.getenv("BOT_LANG") == "en"
if not day_str:
return "Last day" if is_en else "Последний день"
try:
day_value = parse_day(day_str)
except ValueError:
return f"Day: {day_str}" if is_en else f"День: {day_str}"
today = datetime.now(LOCAL_TZ).date()
if day_value == today:
return "Today" if is_en else "Сегодня"
if day_value == today - timedelta(days=1):
return "Yesterday" if is_en else "Вчера"
return day_str
def day_bounds(day_value: date) -> tuple[int, int]:
start = datetime.combine(day_value, dt_time.min, tzinfo=LOCAL_TZ)
end = start + timedelta(days=1)
return int(start.timestamp()), int(end.timestamp())
def parse_day(day_str: str) -> date:
return datetime.strptime(day_str, "%Y-%m-%d").date()
def sleep_total(sleep) -> int:
total = int(sleep["total_duration_min"]) if sleep["total_duration_min"] else 0
if total > 0:
return total
return int(sleep["light_sleep_min"] or 0) + int(sleep["deep_sleep_min"] or 0)
def sleep_quality_label(total_min: int, deep_min: int) -> str:
is_en = os.getenv("BOT_LANG") == "en"
if total_min >= 420 and deep_min >= 60:
return "🟢 Excellent" if is_en else "🟢 Отличный"
if total_min >= 360 and deep_min >= 40:
return "🟡 Good" if is_en else "🟡 Хороший"
if total_min >= 300:
return "🟠 Average" if is_en else "🟠 Средний"
return "🔴 Poor" if is_en else "🔴 Недостаточный"
def workout_type_label(sport_type: str) -> str:
return SPORT_TYPE_LABELS.get(sport_type, sport_type.replace("_", " ").title())
+40 -1
View File
@@ -16,7 +16,7 @@ from zoneinfo import ZoneInfo
from .config import Settings
LOCAL_TZ = ZoneInfo("Europe/Moscow")
EXPORT_TABLES = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress"]
EXPORT_TABLES = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen", "stress", "calories_daily", "weight", "workouts"]
@contextmanager
@@ -113,6 +113,45 @@ def init_health_db(db_path: Path) -> None:
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS calories_daily (
date TEXT PRIMARY KEY,
total_cal REAL,
active_cal REAL,
valid_stand_hours INTEGER,
intensity_minutes INTEGER,
last_sync INTEGER
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS weight (
timestamp INTEGER PRIMARY KEY,
weight_kg REAL,
bmi REAL,
body_fat_pct REAL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS workouts (
workout_id TEXT PRIMARY KEY,
sport_type TEXT,
start_time INTEGER,
end_time INTEGER,
duration_sec INTEGER,
calories REAL,
avg_hr INTEGER,
max_hr INTEGER,
min_hr INTEGER,
watermark INTEGER,
raw_json TEXT
)
"""
)
conn.commit()
+234 -2
View File
@@ -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:
+1
View File
@@ -65,6 +65,7 @@ target-version = "py311"
extend-exclude = [
".venv",
"data",
"scratch",
"mi-fitness-python",
]
+30 -21
View File
@@ -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 ^(
+8 -5
View File
@@ -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
+163 -11
View File
@@ -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</b> ккал" 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()
+63 -1
View File
@@ -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()
+48 -1
View File
@@ -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