mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-21 05:00:13 +03:00
Add extended health metrics and trends UI
This commit is contained in:
@@ -5,6 +5,8 @@ __pycache__/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.test-tmp/
|
.test-tmp/
|
||||||
|
scratch/
|
||||||
|
plan_workouts.md
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
Личный self-hosted Telegram-бот для данных Xiaomi Fitness / Mi Band.
|
Личный self-hosted Telegram-бот для данных Xiaomi Fitness / Mi Band.
|
||||||
|
|
||||||
Забирает шаги, сон, пульс и SpO2 из облака Xiaomi Fitness, хранит их
|
Забирает шаги, сон, пульс, SpO2, стресс, суточную активность, вес
|
||||||
в локальной SQLite-базе и даёт доступ к ним прямо из Telegram —
|
и тренировки из облака Xiaomi Fitness, хранит их в локальной SQLite-базе
|
||||||
|
и даёт доступ к ним прямо из Telegram —
|
||||||
без сторонних сервисов и без передачи данных третьим лицам.
|
без сторонних сервисов и без передачи данных третьим лицам.
|
||||||
|
|
||||||
> **Проект рассчитан на одного владельца.**
|
> **Проект рассчитан на одного владельца.**
|
||||||
@@ -13,8 +14,9 @@
|
|||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Просмотр последних шагов, сна, пульса и SpO2 в Telegram.
|
- Просмотр последних шагов, сна, пульса, SpO2, стресса, веса и тренировок в Telegram.
|
||||||
- Ручная и автоматическая синхронизация по расписанию.
|
- Ручная и автоматическая синхронизация по расписанию.
|
||||||
|
- Автообновление закреплённого главного сообщения после фоновой синхронизации.
|
||||||
- Хранение истории в SQLite (`data/`).
|
- Хранение истории в SQLite (`data/`).
|
||||||
- Экспорт всех таблиц в ZIP с CSV-файлами прямо в чат.
|
- Экспорт всех таблиц в ZIP с CSV-файлами прямо в чат.
|
||||||
- Развёртывание через Docker Compose.
|
- Развёртывание через 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).
|
- Telegram bot token от [@BotFather](https://t.me/BotFather).
|
||||||
- Аккаунт Xiaomi с данными Xiaomi Fitness.
|
- Аккаунт Xiaomi с данными Xiaomi Fitness.
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ Docker Compose запускает два процесса:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Скрипт сам проверит окружение, пошагово поможет получить токен, создаст конфигурацию `secrets.env`, развернет окружение Python (если выбран запуск без Docker) и предложит запустить бота одной кнопкой.
|
Скрипт сам проверит окружение, пошагово поможет получить токен, создаст конфигурацию `secrets.env`, развернет окружение Python (если выбран запуск без Docker) и предложит запустить бота одной кнопкой.
|
||||||
После настройки бот можно запускать повторно через `run_local.bat` из папки `miband-bot`.
|
После настройки бот можно запускать повторно через `run_local.sh` на macOS/Linux или `run_local.bat` на Windows из папки `miband-bot`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
A personal self-hosted Telegram bot for your Xiaomi Fitness / Mi Band data.
|
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,
|
Fetches steps, sleep, heart rate, SpO2, stress, daily activity, weight,
|
||||||
stores them in a local SQLite database, and provides access to them directly from Telegram —
|
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.
|
without third-party services and without sharing your data with anyone.
|
||||||
|
|
||||||
> **This project is designed for a single owner.**
|
> **This project is designed for a single owner.**
|
||||||
@@ -13,8 +14,9 @@ without third-party services and without sharing your data with anyone.
|
|||||||
|
|
||||||
## Features
|
## 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.
|
- Manual and scheduled automatic synchronization.
|
||||||
|
- Auto-refresh of the pinned main menu message after background synchronization.
|
||||||
- History storage in SQLite (`data/`).
|
- History storage in SQLite (`data/`).
|
||||||
- Export of all tables to a ZIP archive with CSV files directly into the chat.
|
- Export of all tables to a ZIP archive with CSV files directly into the chat.
|
||||||
- Deployment via Docker Compose.
|
- Deployment via Docker Compose.
|
||||||
@@ -37,7 +39,7 @@ is prevented by a file-based lock.
|
|||||||
|
|
||||||
## Requirements
|
## 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).
|
- Telegram bot token from [@BotFather](https://t.me/BotFather).
|
||||||
- A Xiaomi account with Xiaomi Fitness data.
|
- 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!
|
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# Copyright (C) 2026 Alexey
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from datetime import time as dt_time
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
LOCAL_TZ = ZoneInfo("Europe/Moscow")
|
||||||
|
RU_MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]
|
||||||
|
RU_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
|
||||||
|
DEFAULT_STEP_GOAL = 10_000
|
||||||
|
|
||||||
|
SPORT_TYPE_LABELS: dict[str, str] = {
|
||||||
|
"free_training": "Свободная тренировка",
|
||||||
|
"outdoor_running": "Бег на улице",
|
||||||
|
"treadmill": "Беговая дорожка",
|
||||||
|
"walking": "Ходьба",
|
||||||
|
"cycling": "Велосипед",
|
||||||
|
"swimming": "Плавание",
|
||||||
|
"yoga": "Йога",
|
||||||
|
"strength_training": "Силовая",
|
||||||
|
"hiit": "HIIT",
|
||||||
|
"jump_rope": "Скакалка",
|
||||||
|
"elliptical": "Эллипсоид",
|
||||||
|
"rowing": "Гребля",
|
||||||
|
"outdoor_cycling": "Велосипед (улица)",
|
||||||
|
"basketball": "Баскетбол",
|
||||||
|
"football": "Футбол",
|
||||||
|
"table_tennis": "Настольный теннис",
|
||||||
|
"badminton": "Бадминтон",
|
||||||
|
"tennis": "Теннис",
|
||||||
|
"volleyball": "Волейбол",
|
||||||
|
"dancing": "Танцы",
|
||||||
|
"martial_arts": "Боевые искусства",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def esc(value: object) -> str:
|
||||||
|
return html.escape(str(value), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def format_mia_date(dt: date) -> str:
|
||||||
|
month_name = RU_MONTHS[dt.month - 1]
|
||||||
|
weekday_name = RU_WEEKDAYS[dt.weekday()]
|
||||||
|
return f"{dt.day} {month_name}, {weekday_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def make_sleep_bar(stage_min: int, total_min: int) -> str:
|
||||||
|
if total_min <= 0:
|
||||||
|
return "░" * 10
|
||||||
|
blocks = int(round((stage_min / total_min) * 10))
|
||||||
|
blocks = max(0, min(10, blocks))
|
||||||
|
return "█" * blocks + "░" * (10 - blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def format_epoch(epoch: int | float | None, with_date: bool = True) -> str:
|
||||||
|
is_en = os.getenv("BOT_LANG") == "en"
|
||||||
|
if not epoch:
|
||||||
|
return "n/a" if is_en else "н/д"
|
||||||
|
fmt = "%Y-%m-%d %H:%M" if with_date else "%H:%M"
|
||||||
|
return datetime.fromtimestamp(int(epoch), LOCAL_TZ).strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def format_relative_time(epoch: int | float | None) -> str:
|
||||||
|
is_en = os.getenv("BOT_LANG") == "en"
|
||||||
|
if not epoch:
|
||||||
|
return "n/a" if is_en else "н/д"
|
||||||
|
diff = int(time.time() - int(epoch))
|
||||||
|
if diff < 60:
|
||||||
|
return "just now" if is_en else "только что"
|
||||||
|
diff_min = diff // 60
|
||||||
|
if diff_min < 60:
|
||||||
|
return f"{diff_min} min. ago" if is_en else f"{diff_min} мин. назад"
|
||||||
|
diff_hours = diff_min // 60
|
||||||
|
if diff_hours < 24:
|
||||||
|
return f"{diff_hours} hr. ago" if is_en else f"{diff_hours} ч. назад"
|
||||||
|
diff_days = diff_hours // 24
|
||||||
|
if diff_days == 1:
|
||||||
|
return "yesterday" if is_en else "вчера"
|
||||||
|
return f"{diff_days} days ago" if is_en else f"{diff_days} дн. назад"
|
||||||
|
|
||||||
|
|
||||||
|
def format_minutes(minutes: int | float | None) -> str:
|
||||||
|
is_en = os.getenv("BOT_LANG") == "en"
|
||||||
|
if minutes is None:
|
||||||
|
return "n/a" if is_en else "н/д"
|
||||||
|
minutes = int(minutes)
|
||||||
|
if is_en:
|
||||||
|
return f"{minutes // 60} h {minutes % 60:02d} m"
|
||||||
|
return f"{minutes // 60} ч {minutes % 60:02d} мин"
|
||||||
|
|
||||||
|
|
||||||
|
def step_goal_bar(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
|
||||||
|
steps_int = int(steps or 0)
|
||||||
|
percent = min(100, round(steps_int / goal * 100)) if goal > 0 else 0
|
||||||
|
filled = percent // 10
|
||||||
|
bar = "█" * filled + "░" * (10 - filled)
|
||||||
|
return f"<code>[{bar}]</code> {percent}%"
|
||||||
|
|
||||||
|
|
||||||
|
def step_goal_text(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
|
||||||
|
is_en = os.getenv("BOT_LANG") == "en"
|
||||||
|
if steps is None:
|
||||||
|
return f"Goal {goal:,} steps".replace(",", " ") if is_en else f"Цель {goal:,} шагов".replace(",", " ")
|
||||||
|
steps_int = int(steps)
|
||||||
|
left = max(0, goal - steps_int)
|
||||||
|
if left:
|
||||||
|
text = f"Goal {goal:,} · {left:,} steps left" if is_en else f"Цель {goal:,} · осталось {left:,} шагов"
|
||||||
|
else:
|
||||||
|
text = f"Goal {goal:,} · daily goal achieved! 🎉" if is_en else f"Цель {goal:,} · дневная цель выполнена! 🎉"
|
||||||
|
return text.replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def make_sparkline(values: list[float | int]) -> str:
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
sparks = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
||||||
|
min_val = min(values)
|
||||||
|
max_val = max(values)
|
||||||
|
val_range = max_val - min_val
|
||||||
|
if val_range == 0:
|
||||||
|
return sparks[4] * min(len(values), 20)
|
||||||
|
sparkline = []
|
||||||
|
for val in values:
|
||||||
|
idx = int((val - min_val) / val_range * (len(sparks) - 1))
|
||||||
|
sparkline.append(sparks[idx])
|
||||||
|
return "".join(sparkline)
|
||||||
|
|
||||||
|
|
||||||
|
def relative_day_label(day_str: str | None) -> str:
|
||||||
|
is_en = os.getenv("BOT_LANG") == "en"
|
||||||
|
if not day_str:
|
||||||
|
return "Last day" if is_en else "Последний день"
|
||||||
|
try:
|
||||||
|
day_value = parse_day(day_str)
|
||||||
|
except ValueError:
|
||||||
|
return f"Day: {day_str}" if is_en else f"День: {day_str}"
|
||||||
|
today = datetime.now(LOCAL_TZ).date()
|
||||||
|
if day_value == today:
|
||||||
|
return "Today" if is_en else "Сегодня"
|
||||||
|
if day_value == today - timedelta(days=1):
|
||||||
|
return "Yesterday" if is_en else "Вчера"
|
||||||
|
return day_str
|
||||||
|
|
||||||
|
|
||||||
|
def day_bounds(day_value: date) -> tuple[int, int]:
|
||||||
|
start = datetime.combine(day_value, dt_time.min, tzinfo=LOCAL_TZ)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
return int(start.timestamp()), int(end.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_day(day_str: str) -> date:
|
||||||
|
return datetime.strptime(day_str, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_total(sleep) -> int:
|
||||||
|
total = int(sleep["total_duration_min"]) if sleep["total_duration_min"] else 0
|
||||||
|
if total > 0:
|
||||||
|
return total
|
||||||
|
return int(sleep["light_sleep_min"] or 0) + int(sleep["deep_sleep_min"] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def sleep_quality_label(total_min: int, deep_min: int) -> str:
|
||||||
|
is_en = os.getenv("BOT_LANG") == "en"
|
||||||
|
if total_min >= 420 and deep_min >= 60:
|
||||||
|
return "🟢 Excellent" if is_en else "🟢 Отличный"
|
||||||
|
if total_min >= 360 and deep_min >= 40:
|
||||||
|
return "🟡 Good" if is_en else "🟡 Хороший"
|
||||||
|
if total_min >= 300:
|
||||||
|
return "🟠 Average" if is_en else "🟠 Средний"
|
||||||
|
return "🔴 Poor" if is_en else "🔴 Недостаточный"
|
||||||
|
|
||||||
|
|
||||||
|
def workout_type_label(sport_type: str) -> str:
|
||||||
|
return SPORT_TYPE_LABELS.get(sport_type, sport_type.replace("_", " ").title())
|
||||||
@@ -16,7 +16,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from .config import Settings
|
from .config import Settings
|
||||||
|
|
||||||
LOCAL_TZ = ZoneInfo("Europe/Moscow")
|
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
|
@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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+234
-2
@@ -143,6 +143,10 @@ async def run_sync_for_user(
|
|||||||
"sleep_stages": 0,
|
"sleep_stages": 0,
|
||||||
"heart_rate": 0,
|
"heart_rate": 0,
|
||||||
"blood_oxygen": 0,
|
"blood_oxygen": 0,
|
||||||
|
"stress": 0,
|
||||||
|
"calories_daily": 0,
|
||||||
|
"weight": 0,
|
||||||
|
"workouts": 0,
|
||||||
}
|
}
|
||||||
log(
|
log(
|
||||||
f"Starting sync. Target UID: {relative_uid}. Query duration: {settings.query_duration} days. "
|
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
|
start_time = 0
|
||||||
end_time = 0
|
end_time = 0
|
||||||
if sleep.segment_details:
|
if sleep.segment_details:
|
||||||
start_time = sleep.segment_details[0].bedtime
|
start_time = min(seg.bedtime for seg in sleep.segment_details)
|
||||||
end_time = sleep.segment_details[-1].wake_up_time
|
end_time = max(seg.wake_up_time for seg in sleep.segment_details)
|
||||||
for segment in sleep.segment_details:
|
for segment in sleep.segment_details:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
@@ -273,6 +277,34 @@ async def run_sync_for_user(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Failed to fetch blood oxygen: {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()
|
conn.commit()
|
||||||
except TokenExpiredError:
|
except TokenExpiredError:
|
||||||
message = "Token has expired and auto-refresh failed. Action required: re-login."
|
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}")
|
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:
|
async def daemon_main(settings: Settings | None = None) -> int:
|
||||||
settings = settings or Settings.from_env()
|
settings = settings or Settings.from_env()
|
||||||
if settings.sync_interval <= 0:
|
if settings.sync_interval <= 0:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ target-version = "py311"
|
|||||||
extend-exclude = [
|
extend-exclude = [
|
||||||
".venv",
|
".venv",
|
||||||
"data",
|
"data",
|
||||||
|
"scratch",
|
||||||
"mi-fitness-python",
|
"mi-fitness-python",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,18 @@ echo OK Docker готов
|
|||||||
set DOCKER_ACTIVE=1
|
set DOCKER_ACTIVE=1
|
||||||
goto SETUP_ENV
|
goto SETUP_ENV
|
||||||
|
|
||||||
:MODE_PYTHON
|
:MODE_PYTHON
|
||||||
python --version >nul 2>nul
|
python --version >nul 2>nul
|
||||||
if not errorlevel 1 goto PYTHON_OK
|
if not errorlevel 1 (
|
||||||
|
python -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul
|
||||||
echo ! Python не найден: https://www.python.org/downloads/
|
if not errorlevel 1 goto PYTHON_OK
|
||||||
echo.
|
echo ! Найден Python, но нужна версия 3.11 или новее.
|
||||||
set install_python=y
|
)
|
||||||
set /p install_python=" Установить Python 3.11 автоматически? [Y/n]: "
|
|
||||||
|
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 )
|
if /i not "%install_python%"=="y" ( echo Установите Python вручную и повторите. & pause & exit /b 1 )
|
||||||
|
|
||||||
echo Скачивание Python 3.11.9...
|
echo Скачивание Python 3.11.9...
|
||||||
@@ -110,19 +114,24 @@ goto END_LAUNCH
|
|||||||
:PYTHON_LAUNCH
|
:PYTHON_LAUNCH
|
||||||
echo Подготовка...
|
echo Подготовка...
|
||||||
if not exist .venv ( python -m venv .venv >nul 2>nul )
|
if not exist .venv ( python -m venv .venv >nul 2>nul )
|
||||||
call .venv\Scripts\activate
|
call .venv\Scripts\activate
|
||||||
pip install -r requirements.txt -e mi-fitness-python > pip_install.log 2>&1
|
python -m pip install --upgrade pip setuptools wheel > pip_install.log 2>&1
|
||||||
if not errorlevel 1 (
|
if errorlevel 1 goto PIP_INSTALL_FAILED
|
||||||
del pip_install.log >nul 2>nul
|
python -m pip install -r requirements.txt -e mi-fitness-python >> pip_install.log 2>&1
|
||||||
echo OK Готово
|
if errorlevel 1 goto PIP_INSTALL_FAILED
|
||||||
) else (
|
goto PIP_INSTALL_OK
|
||||||
echo ! Ошибка установки зависимостей:
|
|
||||||
type pip_install.log
|
:PIP_INSTALL_FAILED
|
||||||
del pip_install.log >nul 2>nul
|
echo ! Ошибка установки зависимостей:
|
||||||
pause & exit /b 1
|
type pip_install.log
|
||||||
)
|
del pip_install.log >nul 2>nul
|
||||||
|
pause & exit /b 1
|
||||||
>run_local.bat echo @echo off
|
|
||||||
|
: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 chcp 65001 ^> nul
|
||||||
>>run_local.bat echo cd /d "%%~dp0"
|
>>run_local.bat echo cd /d "%%~dp0"
|
||||||
>>run_local.bat echo if not exist .venv ^(
|
>>run_local.bat echo if not exist .venv ^(
|
||||||
|
|||||||
@@ -43,14 +43,16 @@ if [ "$install_mode" = "2" ]; then
|
|||||||
DOCKER_MODE=true
|
DOCKER_MODE=true
|
||||||
else
|
else
|
||||||
PYTHON_CMD=""
|
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
|
if command -v "$cmd" &> /dev/null; then
|
||||||
ver=$("$cmd" -c 'import sys; print(sys.version_info[0])' 2>/dev/null)
|
if "$cmd" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
|
||||||
[ "$ver" = "3" ] && PYTHON_CMD="$cmd" && break
|
PYTHON_CMD="$cmd"
|
||||||
|
break
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$PYTHON_CMD" ]; then
|
if [ -z "$PYTHON_CMD" ]; then
|
||||||
err "Python 3 не найден. Установите: https://www.python.org/downloads/"
|
err "Python 3.11+ не найден. Установите: https://www.python.org/downloads/"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
ok "Python готов ($PYTHON_CMD)"
|
ok "Python готов ($PYTHON_CMD)"
|
||||||
@@ -119,7 +121,8 @@ if [ ! -d ".venv" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
source .venv/bin/activate
|
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
|
rm -f pip_install.log
|
||||||
ok "Готово"
|
ok "Готово"
|
||||||
else
|
else
|
||||||
|
|||||||
+163
-11
@@ -3,12 +3,15 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import miband_tracker.bot.app as bot_app
|
import miband_tracker.bot.app as bot_app
|
||||||
|
from miband_tracker import storage
|
||||||
from miband_tracker.config import Settings
|
from miband_tracker.config import Settings
|
||||||
from miband_tracker.sync import SyncResult
|
from miband_tracker.sync import SyncResult
|
||||||
|
|
||||||
@@ -23,6 +26,20 @@ class FakeUpdate:
|
|||||||
callback_query = None
|
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:
|
def test_service_menu_does_not_expose_invites_or_second_user() -> None:
|
||||||
keyboard = bot_app.more_keyboard()
|
keyboard = bot_app.more_keyboard()
|
||||||
labels = [
|
labels = [
|
||||||
@@ -84,17 +101,7 @@ async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu(
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
settings = Settings(
|
settings = _settings(tmp_path)
|
||||||
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,
|
|
||||||
)
|
|
||||||
run_sync = AsyncMock(return_value=SyncResult(True, user_id=123))
|
run_sync = AsyncMock(return_value=SyncResult(True, user_id=123))
|
||||||
show_main_menu = AsyncMock()
|
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"
|
assert data["service_token"] == "service"
|
||||||
run_sync.assert_awaited_once_with(123, settings)
|
run_sync.assert_awaited_once_with(123, settings)
|
||||||
show_main_menu.assert_awaited_once_with(update, context)
|
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
@@ -32,7 +32,54 @@ def test_init_health_db_is_idempotent(tmp_path: Path) -> None:
|
|||||||
row["name"]
|
row["name"]
|
||||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
|
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:
|
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 (?, ?, ?, ?, ?)",
|
"INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)",
|
||||||
("2026-05-24", 1000, 10.0, 800.0, 1),
|
("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()
|
conn.commit()
|
||||||
|
|
||||||
archive = storage.zip_export(settings)
|
archive = storage.zip_export(settings)
|
||||||
|
|
||||||
assert archive.getbuffer().nbytes > 0
|
assert archive.getbuffer().nbytes > 0
|
||||||
assert b"steps_daily.csv" in archive.getvalue()
|
assert b"steps_daily.csv" in archive.getvalue()
|
||||||
|
assert b"stress.csv" in archive.getvalue()
|
||||||
|
assert b"workouts.csv" in archive.getvalue()
|
||||||
|
|||||||
+48
-1
@@ -6,7 +6,8 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from miband_tracker.config import Settings
|
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
|
@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 not result.success
|
||||||
assert result.user_id == 123
|
assert result.user_id == 123
|
||||||
assert "Token file not found" in (result.error or "")
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user