diff --git a/.dockerignore b/.dockerignore index 1b17f72..e8bc8d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,16 +1,6 @@ .git .gitignore .dockerignore -.venv -__pycache__ -*.py[cod] -.pytest_cache -.ruff_cache -.mypy_cache -.test-tmp -.coverage -htmlcov -.DS_Store data secrets.env *.env @@ -23,3 +13,5 @@ status*.json *.zip *.tar *.tar.gz +node_modules +dist diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5a71423 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +TELEGRAM_BOT_TOKEN= +TELEGRAM_ALLOWED_USER_IDS= +SYNC_INTERVAL=900 +QUERY_DURATION=2 +ENABLE_FDS_SLEEP_DETAILS=true +DATA_DIR=./data +BOT_MODE=polling +PORT=8080 +BIND_HOST=127.0.0.1 +TZ=Europe/Moscow diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6a1600..a9f1e68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,47 +7,23 @@ on: pull_request: jobs: - test: - name: Python ${{ matrix.python-version }} + check: + name: Bun checks runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12"] - steps: - name: Check out repository uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up Bun + uses: oven-sh/setup-bun@v2 with: - python-version: ${{ matrix.python-version }} - cache: pip - cache-dependency-path: | - requirements.txt - requirements-dev.txt - mi-fitness-python/pyproject.toml + bun-version: 1.3.14 - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements-dev.txt -e mi-fitness-python + run: bun install --frozen-lockfile - - name: Compile project modules - run: python -m py_compile fitness_bot.py miband_sync.py $(find miband_tracker -name '*.py' | sort) - - - name: Ruff - run: ruff check . - - - name: Root tests - run: python -m pytest - - - name: Vendored SDK tests - run: python -m pytest mi-fitness-python/tests/unit - - - name: Dependency check - run: python -m pip check + - name: Run checks + run: bun run check docker: name: Docker build diff --git a/.gitignore b/.gitignore index 872da00..0d76d14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,7 @@ .venv/ __pycache__/ -*.egg-info/ .pytest_cache/ .ruff_cache/ -.mypy_cache/ -.test-tmp/ scratch/ plan_workouts.md .coverage @@ -22,7 +19,5 @@ status*.json *.zip *.tar *.tar.gz - -# Generated launchers -run_local.sh -run_local.bat +node_modules/ +dist/ diff --git a/AUTHORS.md b/AUTHORS.md deleted file mode 100644 index 902423d..0000000 --- a/AUTHORS.md +++ /dev/null @@ -1,11 +0,0 @@ -# Authors - -## Maintainer - -- Alexey / `iAlexeyRu` - `miband-bot` integration, Telegram bot, Docker runtime and project maintenance. - -## Vendored SDK - -- Misty02600 / MistEO - upstream `mi-fitness-python` SDK, vendored from `https://github.com/MistEO/MiSDK`. - -See `VENDORED.md` for details about the vendored source copy and its license. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 14c9330..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,23 +0,0 @@ -# Changelog - -All notable changes to this project are documented here. - -The format follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project is licensed under GNU GPL v3.0 or later. - -## Unreleased - -### Added - -- Human-first Russian and English README files. -- Security, contributing, vendored dependency and authorship documentation. -- Example `secrets.env.example` for safe setup. -- Root GitHub Actions CI workflow for tests, linting, packaging checks and Docker build. - -### Changed - -- Root project metadata is now described in `pyproject.toml`. -- Ruff configuration covers a broader set of checks while allowing intentional Cyrillic UI text. - -### Security - -- Documented secret storage, token rotation and Telegram CSV export privacy expectations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b9d0c1d..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,54 +0,0 @@ -# Contributing - -Thanks for improving `miband-bot`. This project is intentionally small: keep changes practical, testable and clear for self-hosted users. - -## Local Setup - -```sh -python3 -m venv .venv -.venv/bin/pip install -r requirements-dev.txt -e mi-fitness-python -``` - -Run checks before opening a pull request: - -```sh -.venv/bin/python -m py_compile fitness_bot.py miband_sync.py $(find miband_tracker -name '*.py' | sort) -.venv/bin/python -m pytest -.venv/bin/python -m pytest mi-fitness-python/tests/unit -.venv/bin/ruff check . -.venv/bin/python -m pip check -docker compose build -``` - -## Secrets - -Never commit real files from: - -- `secrets.env`; -- `data/`; -- `token*.json`; -- `status*.json`; -- SQLite databases or CSV exports. - -Use `secrets.env.example` in documentation and tests. - -## Code Style - -- Prefer the existing Python style and small focused modules. -- Keep user-facing Telegram text clear and concise. -- Add tests for behavior changes. -- Keep Russian UI text valid; Ruff `RUF001/RUF002/RUF003` are ignored because Cyrillic strings are intentional. -- Do not mass-format vendored `mi-fitness-python` unless the change is specifically about updating that vendor copy. - -## Reverse Engineering Etiquette - -This project talks to unofficial Xiaomi Fitness APIs. Contributions must stay focused on legitimate personal use: - -- do not add features for accessing other people's data without permission; -- do not publish real credentials, account ids, request signatures or private exports; -- document fragile API assumptions when adding reverse-engineered behavior; -- make optional/best-effort features fail gracefully when Xiaomi changes an endpoint. - -## Vendored SDK - -`mi-fitness-python` is kept in this repository as a vendored source copy. See `VENDORED.md` before changing it. When updating the vendor copy, document the upstream source, version or commit, and any local patches. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0c8092e..0000000 --- a/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -FROM python:3.11-slim - -ARG APP_UID=1000 -ARG APP_GID=1000 - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 - -WORKDIR /app - -COPY requirements.txt /app/ -RUN pip install -r /app/requirements.txt - -COPY mi-fitness-python /app/mi-fitness-python -RUN pip install -e /app/mi-fitness-python - -COPY miband_tracker /app/miband_tracker -COPY miband_sync.py /app/ -COPY fitness_bot.py /app/ - -RUN groupadd --gid "${APP_GID}" app \ - && useradd --uid "${APP_UID}" --gid app --create-home --shell /usr/sbin/nologin app \ - && mkdir -p /opt/miband-tracker/data \ - && chown -R app:app /app /opt/miband-tracker - -USER app - -HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 \ - CMD python -c "import os; from pathlib import Path; p = Path(os.environ.get('DATA_DIR', '/opt/miband-tracker/data')); p.mkdir(parents=True, exist_ok=True); raise SystemExit(0 if os.access(p, os.W_OK) else 1)" - -CMD ["python", "-u", "miband_sync.py"] diff --git a/Dockerfile.bun b/Dockerfile.bun new file mode 100644 index 0000000..9b87b7e --- /dev/null +++ b/Dockerfile.bun @@ -0,0 +1,19 @@ +FROM oven/bun:1.3.14 AS build + +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY tsconfig.json biome.json ./ +COPY src ./src +RUN bun run build + +FROM oven/bun:1.3.14 +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production +COPY --from=build /app/dist ./dist +RUN mkdir -p /app/data && chown -R bun:bun /app +USER bun +ENV NODE_ENV=production BOT_MODE=polling BIND_HOST=0.0.0.0 PORT=8080 +EXPOSE 8080 +CMD ["bun", "dist/src/index.js"] diff --git a/README.es-ES.md b/README.es-ES.md new file mode 100644 index 0000000..8dd5995 --- /dev/null +++ b/README.es-ES.md @@ -0,0 +1,46 @@ +# miband-bot + +[Русский](README.ru.md) | [English](README.md) | Español + +Bot de Telegram self-hosted para datos de Xiaomi Fitness y Mi Band. + +Sincroniza pasos, sueño, pulso, SpO2, estrés, actividad diaria, peso +y entrenamientos en archivos SQLite locales, y permite consultarlos o exportarlos +desde Telegram. No utiliza servicios externos para tus datos. + +> Diseñado para un único propietario. No es un bot público ni un servicio médico. + +## Funciones + +- Resúmenes diarios, historial, informes semanales, tendencias, estadísticas familiares y comparaciones. +- Inicio de sesión QR de Xiaomi y sincronización manual o programada. +- Exportación CSV/ZIP desde Telegram. +- Interfaz en inglés por defecto, con ruso y español disponibles en Settings. +- Runtime Bun/TypeScript y Docker Compose. + +## Inicio rápido + +` +cp .env.example secrets.env +# Define TELEGRAM_BOT_TOKEN en secrets.env +bun install +bun run check +docker compose up -d --build +` + +Los datos de ejecución se guardan en `./data/`. Mantén privados `secrets.env`, `data/`, +los tokens de Xiaomi, las bases SQLite y las exportaciones. + +## Desarrollo + +` +bun install +bun run check +bun run dev +` + +El servicio expone `/healthz` y `/readyz` en el puerto `8080`. + +## Licencia + +[GNU GPL v3.0 o posterior](LICENSE). diff --git a/README.md b/README.md index 4d17695..e805da4 100644 --- a/README.md +++ b/README.md @@ -1,198 +1,46 @@ # miband-bot -Русский | [English](README_EN.md) +[Русский](README.ru.md) | English | [Español](README.es-ES.md) -Личный self-hosted Telegram-бот для данных Xiaomi Fitness / Mi Band. +Self-hosted Telegram bot for Xiaomi Fitness and Mi Band health data. -Забирает шаги, сон, пульс, SpO2, стресс, суточную активность, вес -и тренировки из облака Xiaomi Fitness, хранит их в локальной SQLite-базе -и даёт доступ к ним прямо из Telegram — -без сторонних сервисов и без передачи данных третьим лицам. +It synchronizes steps, sleep, heart rate, SpO2, stress, daily activity, weight, +and workouts into local SQLite files and lets you view or export them from Telegram. +No third-party data service is involved. -> **Проект рассчитан на одного владельца.** -> Это не публичный бот и не медицинский сервис. +> Designed for one private owner. Not a public bot or a medical service. -## Возможности +## Features -- Просмотр последних шагов, сна, пульса, SpO2, стресса, веса и тренировок в Telegram. -- Ручная и автоматическая синхронизация по расписанию. -- Автообновление закреплённого главного сообщения после фоновой синхронизации. -- Хранение истории в SQLite (`data/`). -- Экспорт всех таблиц в ZIP с CSV-файлами прямо в чат. -- Развёртывание через Docker Compose. -- Атомарная запись Xiaomi-токена с правами `0600`. -- Умная автопривязка к первому пользователю (whitelist). +- Daily health summaries, history, weekly reports, trends, family stats, and comparisons. +- Xiaomi QR login and scheduled or manual synchronization. +- CSV/ZIP export from Telegram. +- English interface by default, with Russian and Spanish switchers in Settings. +- Bun/TypeScript runtime with Docker Compose. -## Как это работает +## Quick start -```text -Mi Band → Xiaomi Fitness cloud → miband-bot → SQLite → Telegram / CSV -``` +` +cp .env.example secrets.env +# Set TELEGRAM_BOT_TOKEN in secrets.env +bun install +bun run check +docker compose up -d --build +` -Docker Compose запускает два процесса: +Runtime data is stored in `./data`. Keep `secrets.env`, `data/`, Xiaomi tokens, +SQLite databases, and exports private. -- `tracker` — периодически синхронизирует данные из Xiaomi Fitness; -- `fitness-bot` — обслуживает Telegram-меню, ручной sync и экспорт. +## Development -Оба процесса работают с одной папкой `./data`. Конкурентная запись -исключена файловым lock-ом. +` +bun install +bun run check +bun run dev +` -## Требования +The service exposes `/healthz` and `/readyz` on port `8080`. -- Docker и Docker Compose (или установленный Python 3.11+). -- Telegram bot token от [@BotFather](https://t.me/BotFather). -- Аккаунт Xiaomi с данными Xiaomi Fitness. +## License -## Быстрый запуск - -### Способ 1: Бесшовная установка в один клик (Рекомендуется) - -Если у вас еще нет проекта на компьютере, вы можете автоматически скачать и настроить его одной командой в терминале: - -- **macOS / Linux:** - ```sh - curl -fsSL https://raw.githubusercontent.com/iAlexeyRu/miband-bot/main/install.sh | bash - ``` -- **Windows (PowerShell):** - ```powershell - powershell -c "irm https://raw.githubusercontent.com/iAlexeyRu/miband-bot/main/install.ps1 | iex" - ``` - -Установщик сам создаст папку `miband-bot`, загрузит и распакует файлы проекта, проверит окружение и запустит интерактивную настройку! -Повторный запуск этой же PowerShell-команды в уже настроенной установке обновит файлы и сразу запустит бота без повторного ввода токена. - ---- - -### Способ 2: Запуск из скачанной папки - -Если вы уже склонировали репозиторий через `git clone` или скачали архив вручную: - -- **macOS / Linux:** - ```sh - ./setup.sh - ``` -- **Windows:** - Запустите двойным кликом файл `setup.bat` или выполните в консоли: - ```cmd - setup.bat - ``` - -Скрипт сам проверит окружение, пошагово поможет получить токен, создаст конфигурацию `secrets.env`, развернет окружение Python (если выбран запуск без Docker) и предложит запустить бота одной кнопкой. -После настройки бот можно запускать повторно через `run_local.sh` на macOS/Linux или `run_local.bat` на Windows из папки `miband-bot`. - ---- - -### Способ 3: Полностью ручная настройка (manual setup): - -1. Скопируйте шаблон конфигурации: - ```sh - cp secrets.env.example secrets.env - ``` -2. Укажите ваш `TELEGRAM_BOT_TOKEN` в файле `secrets.env`. Переменную `TELEGRAM_ALLOWED_USER_ID` **оставьте пустой** — бот автоматически привяжется к вам при первом старте. -3. Запустите Docker контейнеры: - ```sh - docker compose up -d --build - ``` -4. Откройте вашего созданного бота в Telegram и отправьте ему команду `/start` — бот распознает ваш аккаунт, привяжет его как единственного владельца и начнет синхронизацию! - -## Настройки - -Все переменные — в `secrets.env`: - -| Переменная | По умолчанию | Описание | -| -------------------------- | ------------ | --------------------------------------- | -| `TELEGRAM_BOT_TOKEN` | — | Token Telegram-бота | -| `TELEGRAM_ALLOWED_USER_ID` | — | Разрешённый user id (оставьте пустым для автопривязки) | -| `SYNC_INTERVAL` | `900` | Интервал фоновой синхронизации, секунды | -| `QUERY_DURATION` | `2` | Глубина запроса при sync, дней | -| `ENABLE_FDS_SLEEP_DETAILS` | `true` | Загружать детальные ночные данные FDS | - -Пути к базе и статусу заданы в `compose.yaml`. При запуске без Docker -смотрите `secrets.env.example`. - -## Файлы данных - -Runtime-файлы создаются в `./data`: - -| Файл | Содержимое | -| ---------------------- | --------------------------------- | -| `token_.json` | Xiaomi auth token (**секретный**) | -| `miband_.db` | SQLite-база с health-данными | -| `status_.json` | Последний статус синхронизации | -| `allowed_user.id` | ID привязанного владельца | -| `fitness_bot_state.db` | Служебное состояние Telegram-меню | -| `sync_.lock` | Lock-файл синхронизации | - -`secrets.env`, `data/`, `*.db`, `token*.json` и `status*.json` -добавлены в `.gitignore` — не коммитьте их. - -## Команды - -| Команда | Действие | -| --------- | ------------------------------------- | -| `/start` | Открыть меню или начать вход в Xiaomi | -| `/sync` | Запустить ручную синхронизацию | -| `/status` | Показать состояние локальной базы | - -## Локальная разработка - -```sh -python3 -m venv .venv -.venv/bin/pip install -r requirements-dev.txt -e mi-fitness-python -.venv/bin/python -m py_compile fitness_bot.py miband_sync.py \ - $(find miband_tracker -name '*.py' | sort) -.venv/bin/python -m pytest -.venv/bin/python -m pytest mi-fitness-python/tests/unit -.venv/bin/ruff check . -.venv/bin/python -m pip check -``` - -Точки входа: - -```sh -python -u miband_sync.py # или: miband-sync -python -u fitness_bot.py # или: miband-fitness-bot -``` - -## Troubleshooting - -**Бот не отвечает** — проверьте `TELEGRAM_BOT_TOKEN`, логи, а также убедитесь, что вы первыми отправили `/start` боту для привязки. При необходимости сбросить привязанного владельца просто удалите файл `data/allowed_user.id` и отправьте `/start` снова. - -```sh -docker compose logs -f fitness-bot -``` - -**Token не найден** — отправьте `/start` и пройдите Xiaomi login flow. - -**Token истёк** — запустите повторный вход из меню; старый файл -можно удалить из `data/`. - -**Нет SpO2 или деталей сна** — убедитесь, что эти данные отображаются -в самом приложении Xiaomi Fitness. Доступность зависит от модели -браслета и настроек шаринга. - -**После обновления Xiaomi всё сломалось** — это ожидаемый риск -при работе с неофициальным API. Проверьте issues и логи, затем -обновите код или временно отключите проблемный модуль. - -## Важно: reverse engineering и ограничения - -`miband-bot` — неофициальный проект, не связанный с Xiaomi, Zepp, -Huami или Telegram. - -Доступ к данным реализован через reverse engineering закрытых API, -поэтому: - -- Xiaomi может изменить API без предупреждения; -- авторизация или синхронизация могут временно не работать; -- используйте проект только со своими аккаунтами и данными; -- соблюдайте законодательство и условия использования сервисов; -- данные браслета не являются медицинским заключением. - -## Лицензия - -Проект распространяется под [GNU GPL v3.0 or later](LICENSE). - -SDK `mi-fitness-python` включён как vendored source copy под -[GNU GPL v3.0](mi-fitness-python/LICENSE). Подробности — в -[VENDORED.md](VENDORED.md). +[GNU GPL v3.0 or later](LICENSE). diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..35926e5 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,46 @@ +# miband-bot + +[Русский](README.ru.md) | [English](README.md) | [Español](README.es-ES.md) + +Личный self-hosted Telegram-бот для данных Xiaomi Fitness и Mi Band. + +Он синхронизирует шаги, сон, пульс, SpO2, стресс, суточную активность, вес +и тренировки в локальные SQLite-файлы, а смотреть и экспортировать их можно прямо +из Telegram. Данные не уходят в сторонние сервисы. + +> Проект рассчитан на одного владельца. Это не публичный бот и не медицинский сервис. + +## Возможности + +- Сводки за день, история, недельные отчёты, тренды, семейная статистика и сравнения. +- QR-вход Xiaomi и ручная или плановая синхронизация. +- Экспорт CSV/ZIP из Telegram. +- По умолчанию интерфейс на английском; русский и испанский включаются в Settings. +- Bun/TypeScript runtime и Docker Compose. + +## Быстрый запуск + +` +cp .env.example secrets.env +# Укажите TELEGRAM_BOT_TOKEN в secrets.env +bun install +bun run check +docker compose up -d --build +` + +Рабочие данные находятся в `./data`. Файлы `secrets.env`, `data/`, токены Xiaomi, +SQLite-базы и экспорты должны оставаться приватными. + +## Разработка + +` +bun install +bun run check +bun run dev +` + +Сервис открывает `/healthz` и `/readyz` на порту `8080`. + +## Лицензия + +[GNU GPL v3.0 или новее](LICENSE). diff --git a/README_EN.md b/README_EN.md deleted file mode 100644 index 82c95cf..0000000 --- a/README_EN.md +++ /dev/null @@ -1,196 +0,0 @@ -# miband-bot - -[Русский](README.md) | English - -A personal self-hosted Telegram bot for your Xiaomi Fitness / Mi Band data. - -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.** -> This is not a public bot or a medical service. - -## Features - -- 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. -- Atomic writing of the Xiaomi token with `0600` permissions. -- Smart auto-binding to the first user (whitelist). - -## How it works - -```text -Mi Band → Xiaomi Fitness cloud → miband-bot → SQLite → Telegram / CSV -``` - -Docker Compose runs two processes: - -- `tracker` — periodically synchronizes data from Xiaomi Fitness; -- `fitness-bot` — serves the Telegram menu, handles manual sync, and performs exports. - -Both processes work with the same `./data` folder. Concurrent write access -is prevented by a file-based lock. - -## Requirements - -- 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. - -## Quick Start - -### Method 1: Seamless One-Click Installation (Recommended) - -If you don't have the project files on your machine yet, you can automatically download and set everything up using a single command in your terminal: - -- **macOS / Linux:** - ```sh - curl -fsSL https://raw.githubusercontent.com/iAlexeyRu/miband-bot/main/install.sh | bash - ``` -- **Windows (PowerShell):** - ```powershell - powershell -c "irm https://raw.githubusercontent.com/iAlexeyRu/miband-bot/main/install.ps1 | iex" - ``` - -The installer will automatically create a `miband-bot` directory, download and extract the project files, verify dependencies, and launch the interactive setup! -Running the same PowerShell command again on an already configured install will update the files and start the bot without asking for the Telegram token again. - ---- - -### Method 2: Launch from Downloaded Directory - -If you have already cloned the repository via `git clone` or downloaded the ZIP archive manually: - -- **macOS / Linux:** - ```sh - ./setup.sh - ``` -- **Windows:** - Double-click the `setup.bat` file or run it in the console: - ```cmd - setup.bat - ``` - -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.sh` on macOS/Linux or `run_local.bat` on Windows from the `miband-bot` folder. - ---- - -### Method 3: Fully Manual Setup: - -1. Copy the configuration template: - ```sh - cp secrets.env.example secrets.env - ``` -2. Specify your `TELEGRAM_BOT_TOKEN` in the `secrets.env` file. **Leave the `TELEGRAM_ALLOWED_USER_ID` variable blank** — the bot will automatically bind to you upon the first start. -3. Start the Docker containers: - ```sh - docker compose up -d --build - ``` -4. Open your created bot in Telegram and send the `/start` command — the bot will recognize your account, bind it as the sole owner, and begin synchronization! - -## Settings - -All variables are in `secrets.env`: - -| Variable | Default | Description | -| --- | --- | --- | -| `TELEGRAM_BOT_TOKEN` | — | Telegram bot token | -| `TELEGRAM_ALLOWED_USER_ID` | — | Allowed user ID (leave empty for auto-binding) | -| `SYNC_INTERVAL` | `900` | Background sync interval, in seconds | -| `QUERY_DURATION` | `2` | Fetch depth during sync, in days | -| `ENABLE_FDS_SLEEP_DETAILS` | `true` | Download detailed FDS night sleep data | - -Paths to the database and status files are defined in `compose.yaml`. For running without Docker, refer to `secrets.env.example`. - -## Data Files - -Runtime files are created in `./data`: - -| File | Content | -| --- | --- | -| `token_.json` | Xiaomi auth token (**secret**) | -| `miband_.db` | SQLite database with health data | -| `status_.json` | Last sync status | -| `allowed_user.id` | ID of the bound owner | -| `fitness_bot_state.db` | Telegram menu internal state | -| `sync_.lock` | Sync lock file | - -`secrets.env`, `data/`, `*.db`, `token*.json`, and `status*.json` -are added to `.gitignore` — do not commit them. - -## Commands - -| Command | Action | -| --- | --- | -| `/start` | Open menu or start Xiaomi login flow | -| `/sync` | Start manual synchronization | -| `/status` | Show local database status | - -## Local Development - -```sh -python3 -m venv .venv -.venv/bin/pip install -r requirements-dev.txt -e mi-fitness-python -.venv/bin/python -m py_compile fitness_bot.py miband_sync.py \ - $(find miband_tracker -name '*.py' | sort) -.venv/bin/python -m pytest -.venv/bin/python -m pytest mi-fitness-python/tests/unit -.venv/bin/ruff check . -.venv/bin/python -m pip check -``` - -Entry points: - -```sh -python -u miband_sync.py # or: miband-sync -python -u fitness_bot.py # or: miband-fitness-bot -``` - -## Troubleshooting - -**The bot does not respond** — check `TELEGRAM_BOT_TOKEN`, check the logs, and make sure you were the first to send `/start` to the bot to bind it. If you need to reset the bound owner, simply delete the file `data/allowed_user.id` and send `/start` again. - -```sh -docker compose logs -f fitness-bot -``` - -**Token not found** — send `/start` and complete the Xiaomi login flow. - -**Token expired** — start a re-login from the menu; the old file -can be deleted from `data/`. - -**No SpO2 or sleep details** — make sure this data is visible -in the Xiaomi Fitness app itself. Availability depends on the band model -and data sharing settings. - -**Everything broke after a Xiaomi update** — this is an expected risk -when working with unofficial APIs. Check issues and logs, then -update the code or temporarily disable the problematic module. - -## Important: Reverse Engineering and Limitations - -`miband-bot` is an unofficial project, not affiliated with Xiaomi, Zepp, -Huami, or Telegram. - -Data access is implemented via reverse engineering of closed APIs, -therefore: - -- Xiaomi may change the API without warning; -- authorization or synchronization may temporarily stop working; -- use this project only with your own accounts and data; -- comply with applicable laws and services' terms of use; -- wristband data is not a medical opinion. - -## License - -The project is distributed under the [GNU GPL v3.0 or later](LICENSE). - -SDK `mi-fitness-python` is included as a vendored source copy under -[GNU GPL v3.0](mi-fitness-python/LICENSE). Details are in [VENDORED.md](VENDORED.md). diff --git a/SECURITY.md b/SECURITY.md index 84efe8c..87a82cc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,7 +35,7 @@ Open a private report if the hosting platform supports it, or contact the mainta Useful safe context includes: - project commit; -- Python and Docker versions; +- Bun and Docker versions; - sanitized logs with tokens removed; - exact steps to reproduce using placeholder credentials. diff --git a/VENDORED.md b/VENDORED.md deleted file mode 100644 index 3285803..0000000 --- a/VENDORED.md +++ /dev/null @@ -1,31 +0,0 @@ -# Vendored Dependencies - -This repository currently vendors the Xiaomi Fitness SDK source under `mi-fitness-python/`. - -## `mi-fitness-python` - -- Upstream repository: `https://github.com/MistEO/MiSDK` -- Package/import name: `mi-fitness` / `mi_fitness` -- Vendored path: `mi-fitness-python/` -- License: GNU GPL v3.0, see `mi-fitness-python/LICENSE` -- Upstream author metadata: `Misty02600 ` in `mi-fitness-python/pyproject.toml` - -## Why It Is Vendored - -The bot depends on Xiaomi Fitness behavior that can change without notice. Keeping the SDK source in-tree makes the Docker image and local development workflow self-contained: - -- users can clone one repository and run Docker Compose; -- CI does not depend on a separate unpublished fork; -- local fixes for Xiaomi API changes can be tested together with the bot. - -## Update Policy - -When updating `mi-fitness-python`: - -1. Record the upstream repository URL and commit/tag used. -2. Preserve upstream license and attribution files. -3. Keep local changes small and documented in the pull request. -4. Run both root tests and `mi-fitness-python/tests/unit`. -5. Avoid unrelated formatting churn in vendored files. - -If the SDK stabilizes as a public package that contains all required fixes, the project can later move from vendoring to a PyPI dependency. Until then, vendoring is the preferred release path for user-friendly Docker setup. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b02dec7 --- /dev/null +++ b/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { "includes": ["**", "!dist", "!node_modules", "!data", "!*.log", "!Dockerfile.bun"] }, + "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 }, + "linter": { "enabled": true, "rules": { "preset": "recommended" } }, + "javascript": { "formatter": { "quoteStyle": "double", "trailingCommas": "all", "semicolons": "always" } } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1345f16 --- /dev/null +++ b/bun.lock @@ -0,0 +1,112 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "miband-bot", + "dependencies": { + "grammy": "^1.45.1", + "hono": "^4.12.31", + "zod": "^4.4.3", + }, + "devDependencies": { + "@biomejs/biome": "^2.5.7", + "@types/bun": "^1.3.14", + "typescript": "^7.0.2", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="], + + "@grammyjs/types": ["@grammyjs/types@4.0.0", "", {}, "sha512-Z8lDLTvOlo12e5Vnly/vQh3JC9ppaitS1dGZ3w068gNitOd/y8tTSiib+Xm38aBGbtYGmUZwOc6afYrLs2CSTg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "grammy": ["grammy@1.45.1", "", { "dependencies": { "@grammyjs/types": "4.0.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-Y4VL/hqJMZZxwlUr5ZgM68CFu2iIeEkNLR1cY3+Ww68CIvWARDsoXFix7+31rmyC0+7L85ZI+Pq3E5JSG5+nLQ=="], + + "hono": ["hono@4.13.1", "", {}, "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + } +} diff --git a/compose.vm106.yaml b/compose.vm106.yaml new file mode 100644 index 0000000..a3b977d --- /dev/null +++ b/compose.vm106.yaml @@ -0,0 +1,28 @@ +services: + app: + container_name: miband-bot-ts + build: + context: . + dockerfile: Dockerfile.bun + restart: unless-stopped + env_file: + - /opt/miband-tracker/secrets.env + environment: + BOT_MODE: polling + BIND_HOST: 0.0.0.0 + DATA_DIR: /app/data + DB_PATH: /app/data/miband.db + STATUS_PATH: /app/data/status.json + BOT_STATE_DB_PATH: /app/data/fitness_bot_state.db + SYNC_INTERVAL: 900 + QUERY_DURATION: 2 + volumes: + - /opt/miband-tracker/data:/app/data + ports: + - "127.0.0.1:18080:8080" + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/compose.yaml b/compose.yaml index d1527f4..bcfe1d6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,32 +1,26 @@ services: - tracker: - container_name: miband-tracker - build: . + app: + container_name: miband-bot + build: + context: . + dockerfile: Dockerfile.bun restart: unless-stopped - volumes: - - ./data:/opt/miband-tracker/data env_file: - secrets.env environment: - - DB_PATH=/opt/miband-tracker/data/miband.db - - STATUS_PATH=/opt/miband-tracker/data/status.json - - BOT_STATE_DB_PATH=/opt/miband-tracker/data/fitness_bot_state.db - - DATA_DIR=/opt/miband-tracker/data - - SYNC_INTERVAL=900 - - QUERY_DURATION=2 - - - fitness-bot: - container_name: miband-fitness-bot - build: . - restart: unless-stopped - command: ["python", "-u", "fitness_bot.py"] + BOT_MODE: polling + BIND_HOST: 0.0.0.0 + DATA_DIR: /app/data + DB_PATH: /app/data/miband.db + STATUS_PATH: /app/data/status.json + BOT_STATE_DB_PATH: /app/data/fitness_bot_state.db volumes: - - ./data:/opt/miband-tracker/data - env_file: - - secrets.env - environment: - - DB_PATH=/opt/miband-tracker/data/miband.db - - STATUS_PATH=/opt/miband-tracker/data/status.json - - BOT_STATE_DB_PATH=/opt/miband-tracker/data/fitness_bot_state.db - - DATA_DIR=/opt/miband-tracker/data + - ./data:/app/data + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/fitness_bot.py b/fitness_bot.py deleted file mode 100644 index 26e4391..0000000 --- a/fitness_bot.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -from miband_tracker.stdio import configure_utf8_stdio - -configure_utf8_stdio() - -from miband_tracker.bot.app import main # noqa: E402 - -if __name__ == "__main__": - main() diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index e58fafc..0000000 --- a/install.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -# Отключаем показ прогресс-баров (скрывает спам при скачивании) -$ProgressPreference = 'SilentlyContinue' - -# Кодировка UTF-8 для корректного вывода русских символов -$OutputEncoding = [System.Text.Encoding]::UTF8 -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -Write-Host "=== Установка miband-bot ===" -ForegroundColor Blue - -function Test-ConfiguredInstall { - param([string]$ProjectPath) - - $secretsPath = Join-Path $ProjectPath "secrets.env" - if (-not (Test-Path -LiteralPath $secretsPath)) { - return $false - } - - try { - return [bool](Select-String -LiteralPath $secretsPath -Pattern '^TELEGRAM_BOT_TOKEN=.+$' -Quiet) - } catch { - return $true - } -} - -# Проверяем, запущен ли скрипт в защищенной системной папке (например, System32 или C:\Windows) -if ($PWD.Path -like "*\system32*" -or $PWD.Path -eq $env:SystemRoot) { - Write-Host "Предупреждение: Вы находитесь в защищенной системной папке ($($PWD.Path))." -ForegroundColor Yellow - Write-Host "Чтобы избежать ошибок доступа, переключаемся в вашу домашнюю папку..." -ForegroundColor Yellow - Set-Location -Path $env:USERPROFILE - Write-Host "Новый путь установки: $($PWD.Path)\miband-bot`n" -ForegroundColor Gray -} - -$INSTALL_DIR = "miband-bot" -$projectPath = Join-Path $PWD.Path $INSTALL_DIR -$autoStart = $false - -# 1. Проверяем существование директории -if (Test-Path -Path $INSTALL_DIR) { - if (Test-ConfiguredInstall -ProjectPath $projectPath) { - Write-Host "Найдена настроенная установка. Обновляю файлы и запускаю бота..." -ForegroundColor Green - $autoStart = $true - } else { - Write-Host "Папка '$INSTALL_DIR' уже существует в этой директории." -ForegroundColor Yellow - $overwrite = Read-Host "Хотите перезаписать файлы проекта внутри нее? [Y/n]" - if ($overwrite -eq "") { $overwrite = "y" } - if ($overwrite -notmatch "^[Yy]$") { - Write-Host "Установка отменена." -ForegroundColor Red - Exit - } - } -} else { - New-Item -ItemType Directory -Force -Path $INSTALL_DIR | Out-Null -} - -# Переходим в папку проекта -Set-Location -Path $INSTALL_DIR - -# 2. Скачиваем ZIP-архив с GitHub -$zipUrl = "https://github.com/iAlexeyRu/miband-bot/archive/refs/heads/main.zip" -$tempZip = Join-Path $env:TEMP "miband-bot-temp.zip" -$unpackDir = "temp-unpack" - -Write-Host "Загрузка последней версии проекта с GitHub..." -ForegroundColor Gray -if (Test-Path -LiteralPath $tempZip) { - Remove-Item -LiteralPath $tempZip -Force -} -Invoke-WebRequest -Uri $zipUrl -OutFile $tempZip - -# 3. Распаковываем во временную папку -Write-Host "Распаковка файлов проекта..." -ForegroundColor Gray -if (Test-Path -LiteralPath $unpackDir) { - Remove-Item -LiteralPath $unpackDir -Recurse -Force -} -Expand-Archive -Path $tempZip -DestinationPath $unpackDir -Force -Remove-Item $tempZip - -# 4. Копируем все файлы (включая скрытые) в корень папки установки -Get-ChildItem -Path "$unpackDir\miband-bot-main" -Force | ForEach-Object { - Copy-Item -Path $_.FullName -Destination "." -Recurse -Force -} -Remove-Item -Path $unpackDir -Recurse -Force - -# 5. Запускаем интерактивный setup.bat -if ($autoStart) { - Write-Host "`n✓ Проект обновлён. Запускаю бота..." -ForegroundColor Green - $setupArgs = "/c setup.bat --start" -} else { - Write-Host "`n✓ Проект успешно загружен! Запускаем интерактивную настройку..." -ForegroundColor Green - $setupArgs = "/c setup.bat" -} -# Используем $PWD.Path вместо "." для передачи абсолютного корректного пути рабочей папки -Start-Process -FilePath "cmd.exe" -ArgumentList $setupArgs -WorkingDirectory $PWD.Path -NoNewWindow -Wait diff --git a/install.sh b/install.sh deleted file mode 100644 index 11e2c67..0000000 --- a/install.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# Скрипт бесшовной установки miband-bot для macOS / Linux -set -e - -# Цвета для вывода в консоль -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[0;33m' -RED='\033[0;31m' -BOLD='\033[1m' -NC='\033[0m' - -echo -e "${BLUE}=== Установка miband-bot ===${NC}" - -# 1. Проверяем зависимости (curl или wget, и unzip) -if ! command -v curl &> /dev/null && ! command -v wget &> /dev/null; then - echo -e "${RED}Ошибка: Для загрузки требуется утилита curl или wget.${NC}" - exit 1 -fi - -if ! command -v unzip &> /dev/null; then - echo -e "${RED}Ошибка: Для установки требуется утилита unzip (установите ее через ваш менеджер пакетов).${NC}" - exit 1 -fi - -# 2. Создаем или проверяем директорию установки -INSTALL_DIR="miband-bot" -if [ -d "$INSTALL_DIR" ]; then - echo -e "${YELLOW}Папка '$INSTALL_DIR' уже существует в этой директории.${NC}" - read -p "Хотите перезаписать файлы проекта внутри нее? [Y/n]: " overwrite_confirm /dev/null; then - curl -sSL -o "$TEMP_ZIP" "$ZIP_URL" -else - wget -q -O "$TEMP_ZIP" "$ZIP_URL" -fi - -# 4. Распаковываем и очищаем временные файлы -echo "Распаковка файлов проекта..." -unzip -q -o "$TEMP_ZIP" -rm "$TEMP_ZIP" - -# 5. Копируем файлы из вложенной папки и удаляем ее -cp -r miband-bot-main/. . -rm -rf miband-bot-main - -# 6. Запускаем интерактивный setup.sh -chmod +x setup.sh -echo -e "${GREEN}✓ Проект успешно загружен! Запускаем интерактивную настройку...${NC}" -echo "" - -# Перенаправляем stdin на /dev/tty, чтобы интерактивные read-промпты работали корректно при запуске через пайп -./setup.sh > $GITHUB_OUTPUT - echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Check Version - if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION - run: exit 1 - - - name: Generate Changelog - uses: orhun/git-cliff-action@v4 - id: changelog - with: - config: cliff.toml - args: --latest --strip header - env: - GITHUB_REPO: ${{ github.repository }} - - - name: Build - run: | - uv build - uv publish - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: ${{ github.event.repository.name }} ${{ steps.version.outputs.TAG_NAME }} - body: ${{ steps.changelog.outputs.content }} - files: | - dist/*.tar.gz - dist/*.whl - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/mi-fitness-python/.gitignore b/mi-fitness-python/.gitignore deleted file mode 100644 index 0bf70f5..0000000 --- a/mi-fitness-python/.gitignore +++ /dev/null @@ -1,238 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments -.env -.env.dev -.env.prod -.env.e2e -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml - -# VisualStudioCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -.DS_Store -junit.xml - -# AI -instructions/ -AGENT.md -.agent/rules/ - -# Local -token.json diff --git a/mi-fitness-python/.pre-commit-config.yaml b/mi-fitness-python/.pre-commit-config.yaml deleted file mode 100644 index b80259c..0000000 --- a/mi-fitness-python/.pre-commit-config.yaml +++ /dev/null @@ -1,23 +0,0 @@ -default_install_hook_types: [pre-commit, commit-msg] -repos: - - repo: builtin - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.13 - hooks: - - id: ruff-check - args: [--fix] - stages: [pre-commit] - - id: ruff-format - stages: [pre-commit] - - - repo: https://github.com/commitizen-tools/commitizen - rev: v4.12.0 - hooks: - - id: commitizen - stages: [commit-msg] diff --git a/mi-fitness-python/.python-version b/mi-fitness-python/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/mi-fitness-python/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/mi-fitness-python/LICENSE b/mi-fitness-python/LICENSE deleted file mode 100644 index f288702..0000000 --- a/mi-fitness-python/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/mi-fitness-python/README.md b/mi-fitness-python/README.md deleted file mode 100644 index 1a1bcc1..0000000 --- a/mi-fitness-python/README.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -обновлено: 2026-05-23 ---- -# Mi Fitness - - -小米运动健康 SDK, 通过亲友列表获取其他账号的的心率、睡眠、步数等健康数据。 - -> **⚠️ 仅供学习与测试使用。** API 端点可能随版本更新而变化。 - -## 安装 - -```bash -pip install mi-fitness -# 或使用 uv -uv add mi-fitness -``` - -从源码安装: - -```bash -git clone https://github.com/MistEO/MiSDK.git && cd MiSDK -uv sync -``` - -## 快速开始 - -### 登录(二维码扫码) - -使用小米账号二维码扫码方式登录: - -```python -import asyncio -from mi_fitness import XiaomiAuth - -async def login(): - async with XiaomiAuth() as auth: - await auth.login_qr() - auth.save_token("token.json") - print(f"登录成功!user_id = {auth.token.user_id}") - -asyncio.run(login()) -``` - -自定义二维码展示回调: - -```python -async def login_with_callback(): - async def on_qr(qr_image_url: str, login_url: str) -> None: - # qr_image_url 是二维码图片 URL - print(f"请扫描: {qr_image_url}") - - async with XiaomiAuth() as auth: - await auth.login_qr(qr_callback=on_qr) - auth.save_token("token.json") -``` - -CLI 一行命令登录: - -```bash -uv run python -m mi_fitness.cli qr-login -``` - -### 查询数据 - -```python -import asyncio -from mi_fitness import MiHealthClient - -async def main(): - async with MiHealthClient.from_token("token.json") as client: - # 亲友列表 - relatives = await client.get_relatives() - for r in relatives: - print(f"[{r.relative_uid}] {r.relative_note}") - - uid = relatives[0].relative_uid - - # 最新快照(强类型) - latest = await client.get_latest_data(uid) - print(latest.available_keys) - print(latest.heart_rate) # LatestHeartRate(bpm=84, ...) - print(latest.steps) # StepData(steps=3716, ...) - - # 最近同步日摘要(心率+睡眠+步数并发获取) - summary = await client.get_latest_daily_summary(uid) - print(f"步数: {summary.steps}, 睡眠: {summary.sleep}, 心率: {summary.heart_rate}") - -asyncio.run(main()) -``` - -### 亲友管理 - -```python -async def manage(): - async with MiHealthClient.from_token("token.json") as client: - # 验证用户 - info = await client.verify_user(小米ID) - print(f"找到: {info.nickname} (UID: {info.user_id})") - - # 发送邀请(默认共享全部数据类型) - await client.invite_relative(info.user_id) - - # 删除亲友 - await client.delete_relative(亲友UID) -``` - -### 异常处理 - -```python -from mi_fitness import MiHealthClient, TokenExpiredError, APIError - -async def safe_query(): - try: - async with MiHealthClient.from_token("token.json") as client: - relatives = await client.get_relatives() - except TokenExpiredError: - print("Token 已过期,请重新登录") - except APIError as e: - print(f"API 错误: {e} (HTTP {e.status_code})") -``` - -## API 一览 - -### 数据查询 - -| 方法 | 返回类型 | 说明 | -|------|---------|------| -| `get_heart_rate(uid, date)` | `list[HeartRateData]` | 日均/静息/最大/最小心率、最新采样 | -| `get_sleep(uid, date)` | `list[SleepData]` | 时长/评分/深睡/浅睡/REM/片段详情 | -| `get_steps(uid, date)` | `list[StepData]` | 步数/距离/卡路里 | -| `get_calories_history(uid, date, days=1)` | `list[CaloriesData]` | 按天/按周获取活动卡路里 | -| `get_valid_stand_history(uid, date, days=1)` | `list[ValidStandData]` | 按天/按周获取有效站立次数 | -| `get_intensity_history(uid, date, days=1)` | `list[IntensityData]` | 按天/按周获取中高强度活动时长 | -| `get_spo2_history(uid, date, days=1)` | `list[Spo2SummaryData]` | 按天/按周获取血氧摘要 | -| `get_weight_history(uid, date, days=1)` | `list[WeightData]` | 获取时间窗口内的体重测量记录 | -| `get_blood_pressure_history(uid, date, days=1)` | `list[BloodPressureData]` | 获取时间窗口内的血压测量记录 | -| `get_weight(uid)` | `WeightData \| None` | 体重/BMI | -| `get_goal(uid)` | `GoalData \| None` | 最新活力目标集合,支持 `steps_goal / calories_goal / intensity_goal` 便捷访问;不提供历史目标值 | -| `get_blood_pressure(uid)` | `BloodPressureData \| None` | 最新血压 | -| `get_calories(uid)` | `CaloriesData \| None` | 最新活动卡路里 | -| `get_valid_stand(uid)` | `ValidStandData \| None` | 最新有效站立次数 | -| `get_intensity(uid)` | `IntensityData \| None` | 最新中高强度活动时长 | -| `get_spo2(uid)` | `Spo2Data \| None` | 最新血氧 | -| `get_latest_data(uid)` | `LatestDataSnapshot` | 强类型最新快照(goal/heart_rate/sleep/steps/weight/...) | -| `get_latest_items(uid)` | `list[LatestDataItem]` | 原始 `data_list`,适合调试 | -| `get_daily_summary(uid, date)` | `DailySummary` | 心率+睡眠+步数并发获取 | -| `get_latest_daily_summary(uid)` | `DailySummary` | 自动使用最近一次同步日,减少空结果 | -| `get_aggregated_data(uid, key, start, end)` | `AggregatedDataResponse` | 自定义时间范围和数据类型 | -| `get_fitness_data(uid, key, start, end)` | `AggregatedDataResponse` | 原始测量/事件数据(如体重、血压、异常心率) | - -### 亲友管理 - -| 方法 | 说明 | -|------|------| -| `get_relatives()` | 获取所有已绑定亲友 | -| `find_relative(keyword)` | 按备注名或 UID 查找 | -| `verify_user(xiaomi_id)` | 添加亲友前验证用户信息 | -| `invite_relative(uid)` | 邀请用户成为亲友 | -| `delete_relative(uid)` | 解除亲友关系 | -| `accept_invite(msg) / reject_invite(msg)` | 接受/拒绝邀请 | -| `has_new_invite()` | 是否有新邀请 | -| `get_invite_link_id()` | 获取二维码邀请链接 ID | -| `get_shared_data_types(uid)` | 查看对方共享了哪些数据类型 | -| `get_family_members()` | 家庭组成员列表 | - -### 异常体系 - -| 异常 | 说明 | -|------|------| -| `MiSDKError` | 基础异常 | -| `AuthError` | 认证相关(登录失败等) | -| `TokenExpiredError` | Token 过期且自动刷新失败 | -| `DataNotSharedError` | 亲友未共享当前请求的数据类型 | -| `DataOutOfSharedTimeScopeError` | 查询日期超出亲友允许共享的时间范围 | -| `APIError` | API 非预期响应(含 `status_code` 和 `response_body`) | -| `DeviceUntrustedError` | 新设备需要短信验证 | -| `CaptchaRequiredError` | 触发图形验证码风控 | -| `FamilyMemberNotFoundError` | 找不到指定亲友 | diff --git a/mi-fitness-python/cliff.toml b/mi-fitness-python/cliff.toml deleted file mode 100644 index 530dc02..0000000 --- a/mi-fitness-python/cliff.toml +++ /dev/null @@ -1,35 +0,0 @@ -# git-cliff configuration file -# https://git-cliff.org/docs/configuration - -[changelog] -# 头部模板 -header = """ -# Changelog\n -""" -# 提交信息模板 -body = """ -{% for group, commits in commits | group_by(attribute="group") -%} -### {{ group | striptags | trim | upper_first }} -{% for commit in commits -%} -- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | split(pat="\n") | first | trim | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/commit/{{ commit.id }})){% if commit.remote.username %} by @{{ commit.remote.username }}{% endif %}{% if commit.remote.pr_number %} in [#{{ commit.remote.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ commit.remote.pr_number }}){% endif %} -{% endfor %} - -{% endfor -%} -""" -# 移除尾部空白 -trim = true -# 底部模板 -footer = "" -# 后处理器 -postprocessors = [] - -[git] -# 提交分组 -commit_parsers = [ - { message = "^feat", group = "✨ Features" }, - { message = "^fix", group = "🐛 Fixes" }, -] -# 保护不匹配的破坏性提交 -protect_breaking_commits = true -# 只保留能被 commit_parsers 匹配到的提交 -filter_commits = true diff --git a/mi-fitness-python/examples/basic_usage.py b/mi-fitness-python/examples/basic_usage.py deleted file mode 100644 index 48cedb6..0000000 --- a/mi-fitness-python/examples/basic_usage.py +++ /dev/null @@ -1,69 +0,0 @@ -"""使用示例 —— 登录并查询亲友健康数据。 - -运行前请确保: -1. 已在小米运动健康 App 中添加了亲友关系 -2. 亲友已在 App 中授权共享数据 - -用法: - uv run python examples/basic_usage.py -""" - -from __future__ import annotations - -import asyncio -from pathlib import Path - -from mi_fitness import MiHealthClient, XiaomiAuth - -TOKEN_PATH = Path("token.json") - - -async def main() -> None: - # region 登录(首次运行需要) - if not TOKEN_PATH.exists(): - print("首次使用,请扫码登录:") - async with XiaomiAuth() as auth: - await auth.login_qr() - auth.save_token(TOKEN_PATH) - print(f"登录成功!Token 已保存至 {TOKEN_PATH}") - # endregion - - # region 查询数据(一步创建客户端) - async with MiHealthClient.from_token(TOKEN_PATH) as client: - # 1. 获取亲友列表 - relatives = await client.get_relatives() - print(f"\n已绑定 {len(relatives)} 位亲友:") - for r in relatives: - print(f" - [{r.relative_uid}] {r.relative_note or '(未设置备注)'}") - - if not relatives: - print("未找到亲友,请先在 App 中添加亲友关系") - return - - # 2. 查询第一位亲友的最近同步数据 - target = relatives[0] - uid = target.relative_uid - print(f"\n查询 [{target.relative_note}] (UID: {uid}) 的最近同步健康数据:") - - latest = await client.get_latest_data(uid) - print(f" 可用指标: {', '.join(latest.available_keys)}") - - if latest.heart_rate: - print(f" 最新心率: {latest.heart_rate.bpm} bpm") - if latest.sleep: - print(f" 最新睡眠: {latest.sleep.total_duration}分钟 评分{latest.sleep.sleep_score}/100") - if latest.steps: - print(f" 最新步数: {latest.steps.steps}步 / {latest.steps.distance}米 / {latest.steps.calories}卡") - if latest.weight: - print(f" 最新体重: {latest.weight.weight}kg BMI {latest.weight.bmi}") - - summary = await client.get_latest_daily_summary(uid) - print(f"\n最近同步日摘要 ({summary.date}):") - print(f" 心率: {summary.heart_rate or '暂无数据'}") - print(f" 睡眠: {summary.sleep or '暂无数据'}") - print(f" 步数: {summary.steps or '暂无数据'}") - # endregion - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/mi-fitness-python/justfile b/mi-fitness-python/justfile deleted file mode 100644 index bb53454..0000000 --- a/mi-fitness-python/justfile +++ /dev/null @@ -1,38 +0,0 @@ -set windows-shell := ["powershell", "-NoProfile", "-Command"] - -# 默认任务列表 -default: - @just --list - -# 运行测试 -test: - uv run pytest - -# 版本发布(更新版本号、更新 lock 文件) -bump: - uv run cz bump - uv lock - -# 生成 changelog -changelog: - uv run git-cliff --latest - -# 安装 pre-commit hooks -hooks: - uv run prek install - -# 代码检查 -lint: - uv run ruff check . --fix - -# 代码格式化 -format: - uv run ruff format . - -# 类型检查 -check: - uv run basedpyright - -# 更新 pre-commit hooks -update: - uv run prek auto-update diff --git a/mi-fitness-python/pyproject.toml b/mi-fitness-python/pyproject.toml deleted file mode 100644 index aad5e24..0000000 --- a/mi-fitness-python/pyproject.toml +++ /dev/null @@ -1,125 +0,0 @@ -[project] -name = "mi-fitness" -version = "0.2.0" -description = "小米运动健康亲友数据 SDK" -readme = "README.md" -requires-python = ">=3.11" -authors = [{ name = "Misty02600", email = "xiao02600@gmail.com" }] -dependencies = [ - "httpx>=0.28.1", - "loguru>=0.7.3", - "pydantic>=2.12.5", - "qrcode>=8.0", - "tenacity>=9.0.0", -] - -[project.urls] -Homepage = "https://github.com/MistEO/MiSDK" -Issues = "https://github.com/MistEO/MiSDK/issues" -Repository = "https://github.com/MistEO/MiSDK.git" - -[project.scripts] -mi-fitness-login = "mi_fitness.cli:main" - -[dependency-groups] -dev = [ - "basedpyright>=1.16.0", - "commitizen>=4.1.0", - "git-cliff>=2.11.0,<3.0.0", - "prek>=0.2.0", - "ruff>=0.14.13,<1.0.0", - { include-group = "test" }, -] -test = [ - "pytest>=8.0.0", - "pytest-asyncio>=1.3.0,<1.4.0", - "pytest-cov>=7.0.0", - "pytest-xdist>=3.8.0,<4.0.0", -] - -[build-system] -requires = ["uv_build>=0.9.2,<0.10.0"] -build-backend = "uv_build" - -[tool.commitizen] -name = "cz_conventional_commits" -version = "0.2.0" -tag_format = "v$version" -version_files = ["pyproject.toml:^version"] -major_version_zero = true - -[tool.coverage.report] -exclude_lines = [ - "raise NotImplementedError", - "if TYPE_CHECKING:", - "@overload", - "except ImportError:", -] - -[tool.pyright] -include = ["src", "tests/unit"] -pythonVersion = "3.11" -pythonPlatform = "All" -typeCheckingMode = "standard" - -[[tool.pyright.executionEnvironments]] -root = "tests" -reportPrivateUsage = "none" -reportUnknownMemberType = "none" - -[tool.pytest.ini_options] -addopts = [ - "--import-mode=importlib", - "--strict-markers", - "--tb=short", - "-ra", -] -testpaths = ["tests/unit"] -pythonpath = ["src", "tests/unit"] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" - -[tool.ruff] -line-length = 100 -src = ["src", "tests/unit"] - -[tool.ruff.format] -line-ending = "lf" - -[tool.ruff.lint] -select = [ - "F", # Pyflakes - "W", # pycodestyle warnings - "E", # pycodestyle errors - "I", # isort - "B", # flake8-bugbear - "UP", # pyupgrade - "ASYNC", # flake8-async - "C4", # flake8-comprehensions - "T10", # flake8-debugger - "T20", # flake8-print - "PYI", # flake8-pyi - "PT", # flake8-pytest-style - "Q", # flake8-quotes - "TID", # flake8-tidy-imports - "RUF", # Ruff-specific -] -ignore = [ - "E501", # 行长度由 formatter 控制 - "E402", # 允许模块导入不在文件顶部 - "UP037", # 允许引号类型注解 - "RUF001", # 允许字符串中的中文字符 - "RUF002", # 允许文档字符串中的中文字符 - "RUF003", # 允许注释中的中文字符 - "W191", # 允许制表符缩进 - "TID252", # 允许相对导入 - "B008", # 允许函数参数默认值中使用函数调用 -] - -[tool.ruff.lint.isort] -extra-standard-library = ["typing_extensions"] - -[tool.ruff.lint.per-file-ignores] -"examples/*" = ["T201", "ASYNC240", "ASYNC250"] -"src/mi_fitness/cli.py" = ["T201", "ASYNC240"] -"tests/e2e/*" = ["T201", "ASYNC230", "ASYNC240", "ASYNC250", "F541"] diff --git a/mi-fitness-python/src/mi_fitness/__init__.py b/mi-fitness-python/src/mi_fitness/__init__.py deleted file mode 100644 index 6f00e1c..0000000 --- a/mi-fitness-python/src/mi_fitness/__init__.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Mi Fitness —— 小米运动健康亲友数据 SDK。""" - -from mi_fitness.auth import XiaomiAuth -from mi_fitness.client import MiHealthClient -from mi_fitness.exceptions import ( - APIError, - AuthError, - CaptchaRequiredError, - DataNotSharedError, - DataOutOfSharedTimeScopeError, - DeviceUntrustedError, - FamilyMemberNotFoundError, - MiSDKError, - TokenExpiredError, -) -from mi_fitness.models import ( - AggregatedDataItem, - AggregatedDataResponse, - AuthToken, - BloodPressureData, - CaloriesData, - CheckNewMsgResponse, - DailySummary, - DeleteRelativeResponse, - FamilyMember, - GoalData, - GoalItem, - GoalMetric, - HeartRateData, - IntensityData, - InviteMessage, - InviteResponse, - InviteUniqueIdResponse, - LatestDataItem, - LatestDataResponse, - LatestDataSnapshot, - LatestHeartRate, - MessageListResponse, - OperateInviteResponse, - RelativeListResponse, - SharedDataTypesResponse, - SleepData, - SleepSegment, - Spo2Data, - Spo2SummaryData, - StepData, - ValidStandData, - VerifiedUserInfo, - VerifyUserResponse, - WeightData, -) - -__all__ = [ - "APIError", - "AggregatedDataItem", - "AggregatedDataResponse", - "AuthError", - "AuthToken", - "BloodPressureData", - "CaloriesData", - "CaptchaRequiredError", - "CheckNewMsgResponse", - "DailySummary", - "DataNotSharedError", - "DataOutOfSharedTimeScopeError", - "DeleteRelativeResponse", - "DeviceUntrustedError", - "FamilyMember", - "FamilyMemberNotFoundError", - "GoalData", - "GoalItem", - "GoalMetric", - "HeartRateData", - "IntensityData", - "InviteMessage", - "InviteResponse", - "InviteUniqueIdResponse", - "LatestDataItem", - "LatestDataResponse", - "LatestDataSnapshot", - "LatestHeartRate", - "MessageListResponse", - "MiHealthClient", - "MiSDKError", - "OperateInviteResponse", - "RelativeListResponse", - "SharedDataTypesResponse", - "SleepData", - "SleepSegment", - "Spo2Data", - "Spo2SummaryData", - "StepData", - "TokenExpiredError", - "ValidStandData", - "VerifiedUserInfo", - "VerifyUserResponse", - "WeightData", - "XiaomiAuth", -] diff --git a/mi-fitness-python/src/mi_fitness/auth/__init__.py b/mi-fitness-python/src/mi_fitness/auth/__init__.py deleted file mode 100644 index 72d604c..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""小米账号认证子包。 - -对外只暴露 ``XiaomiAuth``,内部按登录方式拆分为独立模块。 -""" - -from mi_fitness.auth.manager import XiaomiAuth - -__all__ = ["XiaomiAuth"] diff --git a/mi-fitness-python/src/mi_fitness/auth/_helpers.py b/mi-fitness-python/src/mi_fitness/auth/_helpers.py deleted file mode 100644 index 52fa24a..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/_helpers.py +++ /dev/null @@ -1,124 +0,0 @@ -"""认证模块内部工具函数。""" - -from __future__ import annotations - -import asyncio -import json -import re -from typing import TYPE_CHECKING - -from loguru import logger - -from mi_fitness.const import DEFAULT_LOGIN_USER_AGENT -from mi_fitness.exceptions import AuthError -from mi_fitness.http import RetryAsyncClient - -if TYPE_CHECKING: - from mi_fitness.models import AuthToken - -_COOKIE_DOMAINS = ("xiaomi.com", "mi.com") - - -def parse_mi_response(text: str) -> dict: - """解析小米 API ``&&&START&&&`` 前缀的 JSON 响应。""" - body = text - if body.startswith("&&&START&&&"): - body = body[len("&&&START&&&") :] - try: - return json.loads(body) - except json.JSONDecodeError as e: - raise AuthError(f"响应解析失败: {text[:200]}") from e - - -def create_login_http() -> RetryAsyncClient: - """创建登录流程专用的 HTTP 客户端。""" - return RetryAsyncClient( - follow_redirects=False, - timeout=30.0, - headers={ - "User-Agent": DEFAULT_LOGIN_USER_AGENT, - "Content-Type": "application/x-www-form-urlencoded", - }, - ) - - -def normalize_captcha_url(captcha_url: str) -> str: - """补全图形验证码 URL。""" - if captcha_url.startswith("/"): - return f"https://account.xiaomi.com{captcha_url}" - return captcha_url - - -def set_cookie_for_domains( - http: RetryAsyncClient, - name: str, - value: str, -) -> None: - """为小米登录相关域名批量写入 cookie。""" - for domain in _COOKIE_DOMAINS: - http.cookies.set(name, value, domain=domain) - - -async def extract_service_token(http: RetryAsyncClient, location: str) -> str: - """跟随登录重定向,从响应 cookie 中提取 serviceToken。 - - Args: - http: HTTP 客户端。 - location: 登录返回的重定向 URL。 - - Returns: - serviceToken 值。 - """ - resp = await http.get(location) - service_token = "" - for header_val in resp.headers.get_list("set-cookie"): - if "serviceToken=" in header_val: - match = re.search(r"serviceToken=([^;]+)", header_val) - if match: - service_token = match.group(1) - break - - if not service_token: - from urllib.parse import parse_qs, urlparse - - parsed = urlparse(str(resp.headers.get("location", location))) - qs = parse_qs(parsed.query) - service_token = qs.get("serviceToken", [""])[0] - - if not service_token: - service_token = str(http.cookies.get("serviceToken", "") or "") - - if not service_token: - raise AuthError("未能获取 serviceToken") - - return service_token - - -async def extract_credentials( - http: RetryAsyncClient, - data: dict, - token: "AuthToken", -) -> None: - """从登录响应中提取并保存凭证到 token。 - - Args: - http: HTTP 客户端。 - data: 登录接口返回的 JSON dict。 - token: 要写入的 AuthToken 实例。 - """ - token.ssecurity = data["ssecurity"] - token.user_id = str(data.get("userId", "")) - token.pass_token = data.get("passToken", "") - token.c_user_id = data.get("cUserId", "") - - location = data.get("location", "") - if location: - service_token = await extract_service_token(http, location) - token.service_token = service_token - - logger.debug("凭证提取完成, user_id={}", token.user_id) - - -async def async_sleep(seconds: float) -> None: - """异步等待,方便测试时 mock。""" - await asyncio.sleep(seconds) diff --git a/mi-fitness-python/src/mi_fitness/auth/manager.py b/mi-fitness-python/src/mi_fitness/auth/manager.py deleted file mode 100644 index fc27a47..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/manager.py +++ /dev/null @@ -1,451 +0,0 @@ -"""小米账号认证管理器。 - -负责编排登录流程、token 持久化。具体登录实现委托给 -``password``、``qr``、``passtoken`` 等子模块。 -""" - -from __future__ import annotations - -import os -import tempfile -from collections.abc import Awaitable, Callable -from pathlib import Path -from typing import Self, TypeVar - -from loguru import logger - -from mi_fitness.exceptions import ( - AuthError, - CaptchaRequiredError, - DeviceUntrustedError, - TokenExpiredError, -) -from mi_fitness.http import RetryAsyncClient -from mi_fitness.models import AuthToken - -from . import passtoken as _pt -from . import password as _pwd -from . import qr as _qr -from . import sts as _sts -from ._helpers import create_login_http - -_MAX_CAPTCHA_RETRIES = 3 -_CaptchaStepT = TypeVar("_CaptchaStepT") - - -def _write_secret_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) - tmp_path = Path(tmp_name) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(text) - fh.flush() - os.fsync(fh.fileno()) - os.chmod(tmp_path, 0o600) - os.replace(tmp_path, path) - os.chmod(path, 0o600) - finally: - try: - tmp_path.unlink() - except FileNotFoundError: - pass - - -class XiaomiAuth: - """小米账号认证管理器。 - - 负责登录流程、token 持久化。通过 serviceLogin 获取 ssecurity - 和 serviceToken,后续 API 请求使用 RC4 加密。 - - Attributes: - username: 小米账号(手机号或邮箱)。 - token: 当前认证凭证。 - """ - - def __init__( - self, - username: str = "", - password: str = "", - *, - device_id: str = "", - ): - """ - Args: - username: 小米账号。 - password: 密码(仅登录时需要,不会被存储)。 - device_id: 设备标识符。留空则自动生成随机值。新设备首次登录 - 会触发短信验证码验证,可通过 ``login()`` 的 - ``verification_code_handler`` 回调自动处理。 - """ - self.username = username - self._password = password - self.token = AuthToken() - if device_id: - self.token.device_id = device_id - self._http: RetryAsyncClient | None = None - self._ticket_token: str = "" - self._token_path: Path | None = None - - @classmethod - def from_token(cls, path: Path | str) -> Self: - """从文件加载已有 token,一步完成初始化。 - - Args: - path: token 文件路径。 - - Returns: - 已加载 token 的认证管理器。 - - Raises: - AuthError: 文件不存在或格式错误。 - """ - instance = cls() - instance.load_token(path) - return instance - - def _ensure_http(self) -> RetryAsyncClient: - """确保 HTTP 客户端已初始化(惰性创建)。""" - if self._http is None: - self._http = create_login_http() - return self._http - - def _ensure_device_cookie(self) -> RetryAsyncClient: - """确保 deviceId 已生成并写入登录 cookie。""" - http = self._ensure_http() - if not self.token.device_id: - self.token.device_id = f"an_{os.urandom(16).hex()}" - http.cookies.set("deviceId", self.token.device_id) - return http - - async def _run_with_captcha_retries( - self, - http: RetryAsyncClient, - action: Callable[[str], Awaitable[_CaptchaStepT]], - *, - captcha_handler: Callable[[bytes], Awaitable[str]] | None = None, - ) -> _CaptchaStepT: - """统一处理图形验证码重试。""" - captcha_code = "" - for _ in range(_MAX_CAPTCHA_RETRIES): - try: - return await action(captcha_code) - except CaptchaRequiredError as e: - if captcha_handler is None: - raise - image = await _pwd.fetch_captcha_image(http, e.captcha_url) - captcha_code = await captcha_handler(image) - raise AuthError(f"图形验证码验证失败:已连续重试 {_MAX_CAPTCHA_RETRIES} 次") - - # region 公共方法 - async def login( - self, - *, - verification_code_handler: Callable[[str], Awaitable[str]] | None = None, - captcha_handler: Callable[[bytes], Awaitable[str]] | None = None, - ) -> AuthToken: - """执行完整登录流程。 - - Args: - verification_code_handler: 短信验证码回调。接收脱敏手机号 - (如 ``"191******54"``),返回用户输入的 6 位验证码。 - 新设备首次登录需要短信验证时自动调用。 - 若未提供且需要验证,将抛出 ``DeviceUntrustedError``。 - captcha_handler: 图形验证码回调。接收验证码图片字节 - (PNG/JPEG),返回用户识别的验证码文本。 - 当登录流程触发图形验证码风控时自动调用。 - 若未提供且需要验证码,将抛出 ``CaptchaRequiredError``。 - - Returns: - 登录成功后的 AuthToken。 - - Raises: - AuthError: 登录失败(密码错误等)。 - DeviceUntrustedError: 需要短信验证但未提供回调。 - CaptchaRequiredError: 需要图形验证码但未提供回调。 - """ - if not self.username or not self._password: - raise AuthError("用户名和密码不能为空") - - http = self._ensure_device_cookie() - - logger.info("开始小米账号登录: {}", self.username) - - sign, callback = await _pwd.get_login_page(http) - - try: - await _pwd.submit_login( - http, - self.token, - self.username, - self._password, - sign, - callback, - ) - except DeviceUntrustedError: - if verification_code_handler is None: - raise - phone = await self.send_verification_code( - captcha_handler=captcha_handler, - ) - code = await verification_code_handler(phone) - await self.login_with_verification_code(code) - return self.token - - await _sts.sts_exchange(http, self.token) - - self._password = "" - logger.info("登录成功, user_id={}", self.token.user_id) - return self.token - - def save_token(self, path: Path | str) -> None: - """将 token 保存到 JSON 文件,便于下次免登录恢复。 - - Args: - path: 保存路径。 - """ - path = Path(path) - _write_secret_text(path, self.token.model_dump_json(indent=2) + "\n") - self._token_path = path - logger.info("Token 已保存至 {}", path) - - def load_token(self, path: Path | str) -> AuthToken: - """从文件加载 token。 - - Args: - path: token 文件路径。 - - Returns: - 加载的 AuthToken。 - - Raises: - AuthError: 文件不存在或格式错误。 - """ - path = Path(path) - if not path.exists(): - raise AuthError(f"Token 文件不存在: {path}") - try: - data = path.read_text(encoding="utf-8") - self.token = AuthToken.model_validate_json(data) - self._token_path = path - logger.info("Token 已从 {} 加载, user_id={}", path, self.token.user_id) - return self.token - except Exception as e: - raise AuthError(f"Token 文件解析失败: {e}") from e - - @property - def is_authenticated(self) -> bool: - """检查是否已登录。""" - return bool(self.token.service_token and self.token.ssecurity) - - @property - def can_refresh(self) -> bool: - """当前 token 是否具备自动刷新条件。""" - return bool(self.token.pass_token and self.token.user_id) - - async def close(self) -> None: - """关闭 HTTP 客户端(如有)。""" - if self._http is not None: - await self._http.aclose() - self._http = None - - async def refresh(self) -> AuthToken: - """用已有 passToken 刷新 serviceToken / ssecurity。 - - Returns: - 刷新后的 AuthToken。 - - Raises: - TokenExpiredError: 当前 token 无法刷新,或刷新失败。 - """ - if not self.can_refresh: - raise TokenExpiredError("Token 已过期,且缺少 passToken 或 user_id,无法自动刷新") - - logger.info("开始刷新登录凭证, user_id={}", self.token.user_id) - try: - token = await self.login_passtoken( - pass_token=self.token.pass_token, - user_id=self.token.user_id, - device_id=self.token.device_id, - ) - except AuthError as e: - raise TokenExpiredError(f"Token 已过期,自动刷新失败: {e}") from e - - if self._token_path is not None: - self.save_token(self._token_path) - - logger.info("登录凭证刷新成功, user_id={}", token.user_id) - return token - - async def send_verification_code( - self, - *, - captcha_handler: Callable[[bytes], Awaitable[str]] | None = None, - ) -> str: - """发送短信验证码到用户手机。 - - 在 ``login()`` 抛出 ``DeviceUntrustedError`` 后调用此方法 - 手动发起短信验证流程。 - - Args: - captcha_handler: 图形验证码回调。接收验证码图片字节, - 返回用户识别的验证码文本。未提供时触发验证码将直接抛出 - ``CaptchaRequiredError``。 - - Returns: - 脱敏手机号(如 ``"191******54"``)。 - - Raises: - AuthError: 获取手机信息或发送验证码失败。 - CaptchaRequiredError: 需要图形验证码但未提供回调。 - """ - http = self._ensure_http() - await _pwd.ensure_ticket_login_ready(http) - - await self._run_with_captcha_retries( - http, - lambda captcha_code: _pwd.send_ticket( - http, - self.username, - captcha_code=captcha_code, - ), - captcha_handler=captcha_handler, - ) - phone, ticket_token = await self._run_with_captcha_retries( - http, - lambda captcha_code: _pwd.get_phone_info( - http, - self.username, - captcha_code=captcha_code, - ), - captcha_handler=captcha_handler, - ) - - self._ticket_token = ticket_token - logger.info("验证码已发送至 {}", phone) - return phone - - async def login_with_verification_code(self, code: str) -> AuthToken: - """使用短信验证码完成登录。 - - 在 ``send_verification_code()`` 之后调用,提交用户收到的验证码。 - - Args: - code: 6 位短信验证码。 - - Returns: - 登录成功后的 AuthToken。 - - Raises: - AuthError: 验证码错误或登录失败。 - """ - if not self._ticket_token: - raise AuthError("请先调用 send_verification_code() 发送验证码") - - http = self._ensure_http() - http.cookies.set("ticketToken", self._ticket_token) - - sign, callback = await _pwd.get_login_page(http, login_sign="ticket") - await _pwd.submit_ticket_auth(http, self.token, code, sign, callback) - await _sts.sts_exchange(http, self.token) - - self._password = "" - self._ticket_token = "" - logger.info("短信验证码登录成功, user_id={}", self.token.user_id) - return self.token - - def __repr__(self) -> str: - status = "已认证" if self.is_authenticated else "未认证" - uid = self.token.user_id or "N/A" - return f"XiaomiAuth(user={self.username or uid!r}, {status})" - - async def login_qr( - self, - *, - qr_callback: Callable[[str, str], Awaitable[None]] | None = None, - poll_interval: float = 2.0, - max_wait: float = 300.0, - ) -> AuthToken: - """二维码扫码登录(无需密码,绕过验证码风控)。 - - 用户用小米账号 APP 扫描二维码完成登录,SDK 通过长轮询 - 检测扫码结果并自动提取凭证。 - - Args: - qr_callback: 二维码展示回调。接收 ``(qr_image_url, login_url)``, - 其中 ``qr_image_url`` 是二维码图片 URL(可下载显示), - ``login_url`` 是备选的浏览器登录链接。 - 默认将信息打印到控制台。 - poll_interval: 长轮询间隔(秒)。 - max_wait: 扫码超时时间(秒)。 - - Returns: - 登录成功后的 AuthToken。 - - Raises: - AuthError: 获取二维码失败或扫码超时。 - """ - http = self._ensure_device_cookie() - - await _qr.login_qr( - http, - self.token, - qr_callback=qr_callback, - poll_interval=poll_interval, - max_wait=max_wait, - ) - - await _sts.sts_exchange(http, self.token) - - logger.info("二维码登录成功, user_id={}", self.token.user_id) - return self.token - - async def login_passtoken( - self, - *, - pass_token: str = "", - user_id: str = "", - device_id: str = "", - ) -> AuthToken: - """使用 passToken 换取完整登录凭证(无需密码)。 - - passToken 可通过 ``migate.get_passtoken()`` 或浏览器登录小米账号 - 后从 Cookie 中提取获取。此方法用 passToken 调用 ``serviceLogin`` - 换取 ``ssecurity`` 和 ``serviceToken``。 - - Args: - pass_token: 小米账号 passToken。 - user_id: 小米账号 userId。 - device_id: 设备标识符(可选)。 - - Returns: - 登录成功后的 AuthToken。 - - Raises: - AuthError: passToken 无效或换取凭证失败。 - """ - http = self._ensure_http() - - await _pt.login_passtoken( - http, - self.token, - pass_token=pass_token, - user_id=user_id, - device_id=device_id, - ) - - await _sts.sts_exchange(http, self.token) - - logger.info("passToken 登录成功, user_id={}", self.token.user_id) - return self.token - - # endregion - - # region 上下文管理器 - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: object) -> None: - await self.close() - - # endregion diff --git a/mi-fitness-python/src/mi_fitness/auth/passtoken.py b/mi-fitness-python/src/mi_fitness/auth/passtoken.py deleted file mode 100644 index f3423d8..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/passtoken.py +++ /dev/null @@ -1,92 +0,0 @@ -"""passToken 交换登录。""" - -from __future__ import annotations - -import base64 -import hashlib -import os -from urllib.parse import quote - -from loguru import logger - -from mi_fitness.const import SERVICE_SID_HEALTH, XIAOMI_LOGIN_URL -from mi_fitness.exceptions import AuthError -from mi_fitness.http import RetryAsyncClient -from mi_fitness.models import AuthToken - -from ._helpers import extract_service_token, parse_mi_response, set_cookie_for_domains - - -async def login_passtoken( - http: RetryAsyncClient, - token: AuthToken, - *, - pass_token: str, - user_id: str, - device_id: str = "", -) -> None: - """使用 passToken 换取完整登录凭证。 - - Args: - http: HTTP 客户端。 - token: 要写入的 AuthToken。 - pass_token: 小米账号 passToken。 - user_id: 小米账号 userId。 - device_id: 设备标识符(可选)。 - - Raises: - AuthError: passToken 无效或换取凭证失败。 - """ - if not pass_token: - raise AuthError("passToken 不能为空") - if not user_id: - raise AuthError("userId 不能为空") - - token.pass_token = pass_token - token.user_id = user_id - if device_id: - token.device_id = device_id - elif not token.device_id: - token.device_id = f"an_{os.urandom(16).hex()}" - - # 设置 cookies(passToken + deviceId + userId) - for name, value in { - "passToken": pass_token, - "deviceId": token.device_id, - "userId": user_id, - }.items(): - set_cookie_for_domains(http, name, value) - - logger.info("使用 passToken 换取凭证, userId={}", user_id) - - # 调用 serviceLogin,带上 passToken cookie 会让服务端直接返回凭证 - resp = await http.get( - XIAOMI_LOGIN_URL, - params={"_json": "true", "sid": SERVICE_SID_HEALTH}, - ) - resp.raise_for_status() - data = parse_mi_response(resp.text) - - ssecurity = data.get("ssecurity", "") - location = data.get("location", "") - nonce_val = data.get("nonce", "") - c_user_id = data.get("cUserId", "") - - if not ssecurity: - raise AuthError( - "passToken 换取凭证失败:serviceLogin 未返回 ssecurity。" - f"响应字段: {', '.join(sorted(data.keys()))}" - ) - - token.ssecurity = ssecurity - token.c_user_id = c_user_id - - # 跟随 location 重定向获取 serviceToken - if location: - sign_text = f"nonce={nonce_val}&{ssecurity}" - sha1_digest = hashlib.sha1(sign_text.encode()).digest() - client_sign = quote(base64.b64encode(sha1_digest).decode()) - full_url = f"{location}&clientSign={client_sign}" - token.service_token = await extract_service_token(http, full_url) - - logger.info("passToken 凭证交换完成, user_id={}", token.user_id) diff --git a/mi-fitness-python/src/mi_fitness/auth/password.py b/mi-fitness-python/src/mi_fitness/auth/password.py deleted file mode 100644 index 224287d..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/password.py +++ /dev/null @@ -1,328 +0,0 @@ -"""密码登录 + 短信验证码流程。""" - -from __future__ import annotations - -import hashlib -import json -from typing import Any - -from loguru import logger - -from mi_fitness.const import ( - APP_NAME, - ERR_DEVICE_UNTRUST, - SERVICE_SID_HEALTH, - XIAOMI_LOGIN_AUTH_URL, - XIAOMI_LOGIN_URL, - XIAOMI_PHONE_INFO_URL, - XIAOMI_PREFERENCE_URL, - XIAOMI_SEND_TICKET_URL, - XIAOMI_TICKET_AUTH_URL, -) -from mi_fitness.exceptions import AuthError, CaptchaRequiredError, DeviceUntrustedError -from mi_fitness.http import RetryAsyncClient -from mi_fitness.models import AuthToken - -from ._helpers import extract_credentials, normalize_captcha_url, parse_mi_response - - -# region 登录页 -async def get_login_page( - http: RetryAsyncClient, - *, - login_sign: str = "", -) -> tuple[str, str]: - """请求登录页,获取 _sign 和 callback。 - - Args: - http: HTTP 客户端。 - login_sign: 登录签名类型。``""`` 为密码登录, - ``"ticket"`` 为短信验证码登录。 - - Returns: - (sign, callback) 元组。 - """ - import re - - params: dict[str, str] = { - "_json": "true", - "appName": APP_NAME, - "sid": SERVICE_SID_HEALTH, - "_locale": "zh_CN", - } - if login_sign: - params["_loginSign"] = login_sign - - resp = await http.get(XIAOMI_LOGIN_URL, params=params) - resp.raise_for_status() - - body = resp.text - if body.startswith("&&&START&&&"): - body = body[len("&&&START&&&") :] - - try: - data = json.loads(body) - except json.JSONDecodeError: - sign_match = re.search(r'"_sign"\s*:\s*"([^"]+)"', resp.text) - callback_match = re.search(r'"callback"\s*:\s*"([^"]+)"', resp.text) - sign = sign_match.group(1) if sign_match else "" - callback = callback_match.group(1) if callback_match else "" - return sign, callback - - sign = data.get("_sign", "") - callback = data.get("callback", "") - return sign, callback - - -# endregion - - -# region 密码提交 -async def submit_login( - http: RetryAsyncClient, - token: AuthToken, - username: str, - password: str, - sign: str, - callback: str, -) -> None: - """提交密码并处理登录响应。 - - 成功时直接写入 token;设备未信任时抛出 DeviceUntrustedError。 - - Raises: - AuthError: 密码错误。 - DeviceUntrustedError: 设备未信任,需短信验证。 - """ - data = await _raw_submit_login(http, username, password, sign, callback) - - if data.get("ssecurity"): - await extract_credentials(http, data, token) - return - - if data.get("code") == ERR_DEVICE_UNTRUST: - raise DeviceUntrustedError( - f"登录需要二次验证(code={ERR_DEVICE_UNTRUST})。将自动进入短信验证码流程。", - security_status=16, - ) - - security_status = data.get("securityStatus", 0) - if security_status != 0: - raise DeviceUntrustedError( - f"设备未受信任 (securityStatus={security_status})," - f"需要短信验证码完成登录。\n" - f"请传入 verification_code_handler 回调," - f"或手动调用 send_verification_code() + " - f"login_with_verification_code()。", - security_status=security_status, - ) - - keys = ", ".join(sorted(data.keys())) - raise AuthError(f"登录异常:密码正确但未返回凭证。响应字段: {keys}") - - -async def _raw_submit_login( - http: RetryAsyncClient, - username: str, - password: str, - sign: str, - callback: str, -) -> dict: - """提交密码到 serviceLoginAuth2 并返回解析后的响应。""" - pwd_hash = hashlib.md5(password.encode()).hexdigest().upper() - - form_data = { - "sid": SERVICE_SID_HEALTH, - "_json": "true", - "_sign": sign, - "callback": callback, - "user": username, - "hash": pwd_hash, - "qs": f"%3Fsid%3D{SERVICE_SID_HEALTH}", - "_locale": "zh_CN", - } - - resp = await http.post( - XIAOMI_LOGIN_AUTH_URL, - data=form_data, - headers={"Referer": XIAOMI_LOGIN_URL}, - ) - resp.raise_for_status() - - data = parse_mi_response(resp.text) - - code = data.get("code", -1) - if code == ERR_DEVICE_UNTRUST: - return data - if code != 0: - desc = data.get("desc", "未知错误") - raise AuthError(f"登录失败 (code={code}): {desc}") - - return data - - -# endregion - - -# region 短信验证码 -async def ensure_ticket_login_ready(http: RetryAsyncClient) -> None: - """请求 preference 页准备 ticket 登录上下文。""" - resp = await http.get(XIAOMI_PREFERENCE_URL, params={"_locale": "zh_CN"}) - resp.raise_for_status() - data = parse_mi_response(resp.text) - if data.get("code", -1) != 0: - raise AuthError(f"登录偏好初始化失败: {data.get('description', '未知错误')}") - - -def _build_ticket_form_data(username: str, *, captcha_code: str = "") -> dict[str, str]: - """构造短信验证相关接口的公共表单。""" - form_data: dict[str, str] = { - "sid": SERVICE_SID_HEALTH, - "_json": "true", - "_locale": "zh_CN", - "user": username, - } - if captcha_code: - form_data["captCode"] = captcha_code - return form_data - - -async def _post_ticket_request( - http: RetryAsyncClient, - url: str, - username: str, - *, - captcha_code: str = "", - error_prefix: str, -) -> dict[str, Any]: - """提交短信验证相关请求并统一处理验证码风控。""" - resp = await http.post( - url, - data=_build_ticket_form_data(username, captcha_code=captcha_code), - ) - resp.raise_for_status() - data = parse_mi_response(resp.text) - - if data.get("code", -1) == 0: - return data - - desc = data.get("description", "未知错误") - captcha_url = normalize_captcha_url(data.get("captchaUrl", "")) - if captcha_url: - raise CaptchaRequiredError( - f"{error_prefix}:触发了图形验证码风控 (code={data.get('code')})", - captcha_url=captcha_url, - ) - raise AuthError(f"{error_prefix}: {desc}") - - -async def send_ticket( - http: RetryAsyncClient, - username: str, - *, - captcha_code: str = "", -) -> None: - """发送短信验证码到用户手机。 - - Raises: - CaptchaRequiredError: 触发图形验证码风控。 - AuthError: 发送失败。 - """ - data = await _post_ticket_request( - http, - XIAOMI_SEND_TICKET_URL, - username, - captcha_code=captcha_code, - error_prefix="验证码发送失败", - ) - - logger.debug("验证码已发送, vCodeLen={}", data.get("data", {}).get("vCodeLen")) - - -async def get_phone_info( - http: RetryAsyncClient, - username: str, - *, - captcha_code: str = "", -) -> tuple[str, str]: - """获取手机号信息和 ticketToken。 - - Returns: - (脱敏手机号, ticketToken) 元组。 - - Raises: - CaptchaRequiredError: 触发图形验证码风控。 - AuthError: 获取失败。 - """ - data = await _post_ticket_request( - http, - XIAOMI_PHONE_INFO_URL, - username, - captcha_code=captcha_code, - error_prefix="获取手机信息失败", - ) - - info = data.get("data", {}) - phone = info.get("phone", "未知号码") - ticket_token = info.get("ticketToken", "") - if not ticket_token: - raise AuthError("服务端未返回 ticketToken,无法发送验证码") - return phone, ticket_token - - -async def fetch_captcha_image(http: RetryAsyncClient, captcha_url: str) -> bytes: - """下载图形验证码图片。""" - logger.debug("下载图形验证码: {}", captcha_url) - resp = await http.get(captcha_url) - resp.raise_for_status() - return resp.content - - -async def submit_ticket_auth( - http: RetryAsyncClient, - token: AuthToken, - code: str, - sign: str, - callback: str, -) -> None: - """使用验证码完成 serviceLoginTicketAuth 登录。 - - Args: - http: HTTP 客户端。 - token: 要写入的 AuthToken。 - code: 用户输入的 6 位短信验证码。 - sign: 登录页获取的 _sign。 - callback: 回调 URL。 - """ - form_data = { - "sid": SERVICE_SID_HEALTH, - "_json": "true", - "_sign": sign, - "callback": callback, - "ticket": code, - "qs": ( - f"%3F_loginSign%3Dticket%26_json%3Dtrue%26sid%3D{SERVICE_SID_HEALTH}%26_locale%3Dzh_CN" - ), - "_locale": "zh_CN", - } - - resp = await http.post( - XIAOMI_TICKET_AUTH_URL, - data=form_data, - headers={"Referer": XIAOMI_LOGIN_URL}, - ) - resp.raise_for_status() - data = parse_mi_response(resp.text) - - code_val = data.get("code", -1) - if code_val != 0: - desc = data.get("desc", "未知错误") - raise AuthError(f"验证码验证失败 (code={code_val}): {desc}") - - if not data.get("ssecurity"): - raise AuthError("验证码验证成功但未返回凭证") - - await extract_credentials(http, data, token) - - -# endregion diff --git a/mi-fitness-python/src/mi_fitness/auth/qr.py b/mi-fitness-python/src/mi_fitness/auth/qr.py deleted file mode 100644 index 66e66ab..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/qr.py +++ /dev/null @@ -1,116 +0,0 @@ -"""二维码扫码登录。""" - -from __future__ import annotations - -import asyncio -import time -from collections.abc import Awaitable, Callable - -import httpx -from loguru import logger - -from mi_fitness.const import SERVICE_SID_HEALTH, STS_HEALTH_URL, XIAOMI_QR_LOGIN_URL -from mi_fitness.exceptions import AuthError -from mi_fitness.http import RetryAsyncClient -from mi_fitness.models import AuthToken - -from ._helpers import async_sleep, extract_credentials, parse_mi_response - - -async def login_qr( - http: RetryAsyncClient, - token: AuthToken, - *, - qr_callback: Callable[[str, str], Awaitable[None]] | None = None, - poll_interval: float = 2.0, - max_wait: float = 300.0, -) -> None: - """执行二维码扫码登录流程。 - - Args: - http: HTTP 客户端。 - token: 要写入的 AuthToken。 - qr_callback: 二维码展示回调。接收 ``(qr_image_url, login_url)``。 - poll_interval: 长轮询间隔(秒)。 - max_wait: 扫码超时时间(秒)。 - - Raises: - AuthError: 获取二维码失败或扫码超时。 - """ - logger.info("开始二维码扫码登录") - - # Step 1: 获取二维码信息 - qr_params = { - "_qrsize": "480", - "qs": f"%3Fsid%3D{SERVICE_SID_HEALTH}%26_json%3Dtrue", - "callback": STS_HEALTH_URL, - "_hasLogo": "false", - "sid": SERVICE_SID_HEALTH, - "serviceParam": "", - "_locale": "zh_CN", - "_dc": str(int(time.time() * 1000)), - } - resp = await http.get(XIAOMI_QR_LOGIN_URL, params=qr_params) - resp.raise_for_status() - qr_data = parse_mi_response(resp.text) - - qr_image_url = qr_data.get("qr", "") - login_url = qr_data.get("loginUrl", "") - long_polling_url = qr_data.get("lp", "") - qr_timeout = qr_data.get("timeout", max_wait) - - if not qr_image_url or not long_polling_url: - raise AuthError(f"获取二维码失败: {qr_data}") - - # 通知调用方展示二维码 - if qr_callback: - await qr_callback(qr_image_url, login_url) - else: - logger.info("请使用小米账号 APP 扫描二维码登录") - logger.info("二维码图片已获取") - if login_url: - logger.info("浏览器登录链接已获取") - - # Step 2: 长轮询等待扫码 - effective_timeout = min(float(qr_timeout), max_wait) - poll_request_timeout = 60.0 - logger.debug( - "二维码长轮询开始: effective_timeout={}s, request_timeout={}s", - f"{effective_timeout:.0f}", - f"{poll_request_timeout:.0f}", - ) - start_time = time.time() - - while True: - elapsed = time.time() - start_time - if elapsed > effective_timeout: - raise AuthError(f"二维码扫码超时({effective_timeout:.0f}s),请重新获取") - - try: - # 直接调用 httpx.AsyncClient.get 绕过 RetryAsyncClient 的重试 - resp = await httpx.AsyncClient.request( - http, "GET", long_polling_url, timeout=poll_request_timeout - ) - except (asyncio.CancelledError, KeyboardInterrupt): - logger.warning("二维码登录轮询被中断") - raise - except httpx.TimeoutException: - logger.debug("长轮询超时,继续等待...") - continue - except httpx.RequestError as e: - logger.warning("长轮询请求失败: {}", e) - await async_sleep(poll_interval) - continue - - if resp.status_code != 200: - logger.debug("长轮询返回 {},继续等待...", resp.status_code) - await async_sleep(poll_interval) - continue - - break - - data = parse_mi_response(resp.text) - logger.info("扫码成功, userId={}", data.get("userId")) - - # Step 3: 提取凭证 - await extract_credentials(http, data, token) diff --git a/mi-fitness-python/src/mi_fitness/auth/sts.py b/mi-fitness-python/src/mi_fitness/auth/sts.py deleted file mode 100644 index f8b67fc..0000000 --- a/mi-fitness-python/src/mi_fitness/auth/sts.py +++ /dev/null @@ -1,63 +0,0 @@ -"""STS 安全令牌交换。""" - -from __future__ import annotations - -import os -import time - -from loguru import logger - -from mi_fitness.const import STS_HEALTH_URL -from mi_fitness.http import RetryAsyncClient -from mi_fitness.models import AuthToken - - -async def sts_exchange(http: RetryAsyncClient, token: AuthToken) -> None: - """STS 安全令牌交换。 - - 使用 deviceId 完成 STS 验证。此步骤非致命,失败仅打印警告。 - - Args: - http: HTTP 客户端。 - token: 已有 device_id 的 AuthToken。 - """ - params = { - "d": token.device_id, - "ticket": "0", - "pwd": "0", - "p_ts": str(int(time.time() * 1000)), - "fid": "0", - "p_lm": "2", - "p_ur": "CN", - "sid": "hlth.io.mi.com", - } - client_sign = os.environ.get("MI_CLIENT_SIGN") - if client_sign: - params["clientSign"] = client_sign - cookies = {} - if token.user_id: - cookies["userId"] = token.user_id - if token.c_user_id: - cookies["cUserId"] = token.c_user_id - if token.pass_token: - cookies["passToken"] = token.pass_token - - try: - resp = await http.get(STS_HEALTH_URL, params=params, cookies=cookies) - if resp.text.strip() == "ok": - logger.debug("STS 交换成功") - sts_token = None - for cookie in http.cookies.jar: - if cookie.name == "serviceToken" and "hlth.io.mi.com" in (cookie.domain or ""): - sts_token = cookie.value - break - if not sts_token: - sts_token = http.cookies.get("serviceToken") - - if sts_token: - token.service_token = sts_token - logger.debug("STS serviceToken успешно сохранен в AuthToken") - else: - logger.warning("STS 交换响应: {}", resp.text[:100]) - except Exception as e: - logger.warning("STS 交换失败(非致命): {}", e) diff --git a/mi-fitness-python/src/mi_fitness/cli.py b/mi-fitness-python/src/mi_fitness/cli.py deleted file mode 100644 index a9b81d0..0000000 --- a/mi-fitness-python/src/mi_fitness/cli.py +++ /dev/null @@ -1,74 +0,0 @@ -"""二维码扫码登录,获取 Token。""" - -from __future__ import annotations - -import asyncio -from pathlib import Path - -import qrcode -import qrcode.constants - -from mi_fitness.auth import XiaomiAuth -from mi_fitness.exceptions import AuthError - -TOKEN_FILE = Path("token.json") - - -def _print_qr_to_terminal(data: str) -> None: - """用 Unicode 半块字符将二维码紧凑渲染到终端。""" - qr = qrcode.QRCode(border=1, error_correction=qrcode.constants.ERROR_CORRECT_L) - qr.add_data(data) - qr.make(fit=True) - matrix = qr.get_matrix() - - # 补齐奇数行 - if len(matrix) % 2: - matrix.append([False] * len(matrix[0])) - - # 每两行像素合并为一行字符:用 ▀▄█ 和空格表示四种组合 - # False = 白色模块(前景色块),True = 黑色模块(背景留空) - for r in range(0, len(matrix), 2): - line: list[str] = [] - for c in range(len(matrix[0])): - top_white = not matrix[r][c] - bot_white = not matrix[r + 1][c] - if top_white and bot_white: - line.append("\u2588") # █ - elif top_white: - line.append("\u2580") # ▀ - elif bot_white: - line.append("\u2584") # ▄ - else: - line.append(" ") - print("".join(line)) - - -async def _qr_login() -> None: - async def show_qr(qr_image_url: str, login_url: str) -> None: - print("\n📱 请用小米账号 APP 扫描二维码登录\n") - if login_url: - _print_qr_to_terminal(login_url) - elif qr_image_url: - _print_qr_to_terminal(qr_image_url) - print("\n 登录二维码已显示在终端;URL 不会打印。") - print("\n⏳ 等待扫码...\n") - - async with XiaomiAuth() as auth: - try: - await auth.login_qr(qr_callback=show_qr) - except AuthError as e: - print(f"❌ 扫码登录失败: {e}") - raise - - auth.save_token(TOKEN_FILE) - print(f"✅ 扫码登录成功!user_id = {auth.token.user_id}") - print(f" Token 已保存至 {TOKEN_FILE.resolve()}") - - -def main() -> None: - """CLI 入口。""" - asyncio.run(_qr_login()) - - -if __name__ == "__main__": - main() diff --git a/mi-fitness-python/src/mi_fitness/client/__init__.py b/mi-fitness-python/src/mi_fitness/client/__init__.py deleted file mode 100644 index 5181632..0000000 --- a/mi-fitness-python/src/mi_fitness/client/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""小米运动健康 API 客户端子包。""" - -from mi_fitness.client.api import MiHealthClient - -__all__ = ["MiHealthClient"] diff --git a/mi-fitness-python/src/mi_fitness/client/api.py b/mi-fitness-python/src/mi_fitness/client/api.py deleted file mode 100644 index ce10980..0000000 --- a/mi-fitness-python/src/mi_fitness/client/api.py +++ /dev/null @@ -1,438 +0,0 @@ -"""MiHealthClient —— 小米运动健康 API 客户端。 - -薄编排层:持有 HTTP 客户端与认证状态, -所有业务逻辑委托给 relatives / data / messages 子模块。 -""" - -from __future__ import annotations - -import asyncio -from datetime import date -from pathlib import Path -from typing import Any, Self - -from mi_fitness.auth import XiaomiAuth -from mi_fitness.client import data as _data -from mi_fitness.client import messages as _msg -from mi_fitness.client import relatives as _rel -from mi_fitness.client.base import create_api_http, encrypted_request -from mi_fitness.const import HEALTH_API_BASE -from mi_fitness.exceptions import TokenExpiredError -from mi_fitness.models import ( - AggregatedDataResponse, - BloodPressureData, - CaloriesData, - DailySummary, - FamilyMember, - GoalData, - HeartRateData, - IntensityData, - InviteMessage, - LatestDataItem, - LatestDataSnapshot, - SleepData, - Spo2Data, - Spo2SummaryData, - StepData, - ValidStandData, - VerifiedUserInfo, - WeightData, -) - - -class MiHealthClient: - """小米运动健康 API 客户端。 - - 通过已登录的 XiaomiAuth 实例访问亲友健康数据 API。 - 所有请求使用 RC4 加密,通过 cookie 认证。 - - Attributes: - auth: 认证管理器。 - base_url: API 基础 URL。 - """ - - def __init__( - self, - auth: XiaomiAuth, - base_url: str = HEALTH_API_BASE, - ): - """ - Args: - auth: 已通过登录的认证管理器。 - base_url: API 基础 URL(默认国内节点)。 - """ - self.auth = auth - self.base_url = base_url.rstrip("/") - self._http = create_api_http() - self._refresh_lock = asyncio.Lock() - - @classmethod - def from_token(cls, path: Path | str, **kwargs: Any) -> Self: - """从 token 文件一步创建客户端。 - - Args: - path: token 文件路径。 - **kwargs: 传递给 MiHealthClient 的额外参数(如 base_url)。 - - Returns: - 已就绪的 MiHealthClient 实例。 - - Raises: - AuthError: 文件不存在或格式错误。 - """ - auth = XiaomiAuth.from_token(path) - return cls(auth, **kwargs) - - def __repr__(self) -> str: - uid = self.auth.token.user_id or "N/A" - return f"MiHealthClient(user_id={uid!r}, base_url={self.base_url!r})" - - # region 内部请求 - async def _request( - self, - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - _allow_refresh: bool = True, - ) -> dict[str, Any]: - """发送 RC4 加密的 API 请求。""" - expired_service_token = self.auth.token.service_token - try: - return await encrypted_request( - self._http, - self.auth.token, - method, - path, - self.base_url, - params=params, - ) - except TokenExpiredError: - if not _allow_refresh: - raise - await self._refresh_auth(expired_service_token) - return await self._request(method, path, params=params, _allow_refresh=False) - - async def _refresh_auth(self, expired_service_token: str) -> None: - """串行化自动刷新,避免并发请求重复刷新 token。""" - async with self._refresh_lock: - current_service_token = self.auth.token.service_token - if ( - expired_service_token - and current_service_token - and current_service_token != expired_service_token - and self.auth.is_authenticated - ): - return - await self.auth.refresh() - - # endregion - - # region 亲友管理 - async def get_relatives(self) -> list[FamilyMember]: - """获取亲友列表。""" - return await _rel.get_relatives(self) - - async def find_relative(self, keyword: str | int) -> FamilyMember: - """按备注名或 UID 查找亲友。""" - return await _rel.find_relative(self, keyword) - - async def verify_user( - self, - verify_id: int, - *, - verify_type: int = 1, - ) -> VerifiedUserInfo | None: - """按 UID 或扫码 ID 验证用户信息。""" - return await _rel.verify_user(self, verify_id, verify_type=verify_type) - - async def invite_relative( - self, - relative_uid: int, - *, - shared_data_types: list[str] | None = None, - auth_time_range: int = 3, - relative_note: str = "", - ) -> bool: - """发送亲友邀请。""" - return await _rel.invite_relative( - self, - relative_uid, - shared_data_types=shared_data_types, - auth_time_range=auth_time_range, - relative_note=relative_note, - ) - - async def accept_invite( - self, - invite_id: int, - msg_id: int, - *, - shared_data_types: list[str] | None = None, - auth_time_range: int = 3, - ) -> bool: - """同意亲友邀请。""" - return await _rel.accept_invite( - self, - invite_id, - msg_id, - shared_data_types=shared_data_types, - auth_time_range=auth_time_range, - ) - - async def reject_invite(self, invite_id: int, msg_id: int) -> bool: - """拒绝亲友邀请。""" - return await _rel.reject_invite(self, invite_id, msg_id) - - async def delete_relative(self, relative_uid: int) -> bool: - """删除亲友关系。""" - return await _rel.delete_relative(self, relative_uid) - - async def get_invite_link_id(self) -> int: - """获取二维码邀请链接 ID。""" - return await _rel.get_invite_link_id(self) - - async def get_shared_data_types( - self, - relative_uid: int, - *, - direction: int = 2, - ) -> list[str]: - """获取亲友共享的数据类型列表。""" - return await _rel.get_shared_data_types(self, relative_uid, direction=direction) - - async def get_applied_shared_data_types(self, relative_uid: int) -> list[str]: - """获取已申请的共享数据类型。""" - return await _rel.get_applied_shared_data_types(self, relative_uid) - - async def get_family_members(self) -> list[dict[str, Any]]: - """获取家庭成员列表。""" - return await _rel.get_family_members(self) - - async def get_topic_subscriptions( - self, - relative_uid: int, - topics: list[str] | None = None, - ) -> dict[str, Any]: - """获取亲友的消息订阅状态。""" - return await _rel.get_topic_subscriptions(self, relative_uid, topics) - - # endregion - - # region 数据查询 - async def get_latest_items(self, relative_uid: int) -> list[LatestDataItem]: - """获取亲友的原始最新数据项列表。""" - return await _data.get_latest_items(self, relative_uid) - - async def get_latest_data(self, relative_uid: int) -> LatestDataSnapshot: - """获取亲友的最新数据快照(强类型聚合视图)。""" - return await _data.get_latest_data(self, relative_uid) - - async def get_aggregated_data( - self, - relative_uid: int, - key: str, - start_time: int, - end_time: int, - *, - tag: str = "daily_report", - limit: int = 30, - ) -> AggregatedDataResponse: - """获取亲友的聚合数据。""" - return await _data.get_aggregated_data( - self, - relative_uid, - key, - start_time, - end_time, - tag=tag, - limit=limit, - ) - - async def get_fitness_data( - self, - relative_uid: int, - key: str, - start_time: int, - end_time: int, - *, - limit: int = 30, - ) -> AggregatedDataResponse: - """获取亲友的原始测量/事件数据(如体重、血压、异常心率等)。""" - return await _data.get_fitness_data( - self, - relative_uid, - key, - start_time, - end_time, - limit=limit, - ) - - async def get_heart_rate( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[HeartRateData]: - """获取亲友的心率数据。""" - return await _data.get_heart_rate(self, relative_uid, query_date, days=days) - - async def get_sleep( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[SleepData]: - """获取亲友的睡眠数据。""" - return await _data.get_sleep(self, relative_uid, query_date, days=days) - - async def get_steps( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[StepData]: - """获取亲友的步数数据。""" - return await _data.get_steps(self, relative_uid, query_date, days=days) - - async def get_weight_history( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[WeightData]: - """获取亲友在指定窗口内的体重测量记录。""" - return await _data.get_weight_history(self, relative_uid, query_date, days=days) - - async def get_blood_pressure_history( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[BloodPressureData]: - """获取亲友在指定窗口内的血压测量记录。""" - return await _data.get_blood_pressure_history(self, relative_uid, query_date, days=days) - - async def get_weight(self, relative_uid: int) -> WeightData | None: - """获取亲友最新体重数据。""" - return await _data.get_weight(self, relative_uid) - - async def get_calories_history( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[CaloriesData]: - """获取亲友按天聚合的活动卡路里数据。""" - return await _data.get_calories_history(self, relative_uid, query_date, days=days) - - async def get_valid_stand_history( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[ValidStandData]: - """获取亲友按天聚合的有效站立次数。""" - return await _data.get_valid_stand_history(self, relative_uid, query_date, days=days) - - async def get_intensity_history( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[IntensityData]: - """获取亲友按天聚合的中高强度活动时长。""" - return await _data.get_intensity_history(self, relative_uid, query_date, days=days) - - async def get_spo2_history( - self, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, - ) -> list[Spo2SummaryData]: - """获取亲友按天聚合的血氧摘要。""" - return await _data.get_spo2_history(self, relative_uid, query_date, days=days) - - async def get_goal(self, relative_uid: int) -> GoalData | None: - """获取亲友最新目标完成情况。""" - return await _data.get_goal(self, relative_uid) - - async def get_blood_pressure(self, relative_uid: int) -> BloodPressureData | None: - """获取亲友最新血压数据。""" - return await _data.get_blood_pressure(self, relative_uid) - - async def get_calories(self, relative_uid: int) -> CaloriesData | None: - """获取亲友最新活动卡路里。""" - return await _data.get_calories(self, relative_uid) - - async def get_valid_stand(self, relative_uid: int) -> ValidStandData | None: - """获取亲友最新有效站立次数。""" - return await _data.get_valid_stand(self, relative_uid) - - async def get_intensity(self, relative_uid: int) -> IntensityData | None: - """获取亲友最新中高强度活动时长。""" - return await _data.get_intensity(self, relative_uid) - - async def get_spo2(self, relative_uid: int) -> Spo2Data | None: - """获取亲友最新血氧数据。""" - return await _data.get_spo2(self, relative_uid) - - async def get_daily_summary( - self, - relative_uid: int, - query_date: date | None = None, - ) -> DailySummary: - """获取亲友的每日综合健康摘要。""" - return await _data.get_daily_summary(self, relative_uid, query_date) - - async def get_latest_daily_summary(self, relative_uid: int) -> DailySummary: - """获取亲友最近一次同步数据的每日综合健康摘要。""" - return await _data.get_latest_daily_summary(self, relative_uid) - - # endregion - - # region 消息 - async def get_invite_messages( - self, - *, - limit: int = 30, - pending_only: bool = False, - ) -> list[InviteMessage]: - """获取亲友邀请消息列表。""" - return await _msg.get_invite_messages(self, limit=limit, pending_only=pending_only) - - async def has_new_invite(self) -> bool: - """检查是否有新的亲友邀请消息。""" - return await _msg.has_new_invite(self) - - # endregion - - # region 静态工具 - @staticmethod - def _date_to_timestamps(query_date: date | None = None) -> tuple[int, int]: - """将日期转换为当日 00:00 ~ 23:59:59 的时间戳。""" - return _data._date_to_timestamps(query_date) - - # endregion - - # region 生命周期 - async def close(self) -> None: - """关闭 HTTP 客户端。""" - await self._http.aclose() - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: object) -> None: - await self.close() - - # endregion diff --git a/mi-fitness-python/src/mi_fitness/client/base.py b/mi-fitness-python/src/mi_fitness/client/base.py deleted file mode 100644 index 9f9fa8c..0000000 --- a/mi-fitness-python/src/mi_fitness/client/base.py +++ /dev/null @@ -1,205 +0,0 @@ -"""RC4 加密请求基础层。""" - -from __future__ import annotations - -import json -from typing import Any, NoReturn - -from mi_fitness.const import ( - DEFAULT_USER_AGENT, - ERR_NOT_RELATIVES, - ERR_NOT_SHARED_DATA_TYPE, - HEALTH_API_BASE, - REGION_TAG, -) -from mi_fitness.crypto import build_encrypted_params, decrypt_response -from mi_fitness.exceptions import ( - APIError, - AuthError, - DataNotSharedError, - DataOutOfSharedTimeScopeError, - FamilyMemberNotFoundError, - TokenExpiredError, -) -from mi_fitness.http import RetryAsyncClient -from mi_fitness.models import AuthToken - - -def _coerce_api_code(value: Any, default: int = -1) -> int: - """尽力兼容 int / str 形式的业务码。""" - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _extract_api_message(result: dict[str, Any]) -> str: - """兼容不同字段名的错误消息。""" - for key in ("message", "msg", "desc", "description"): - value = result.get(key) - if isinstance(value, str) and value.strip(): - return value - return "未知错误" - - -def _is_time_scope_error(message: str) -> bool: - """识别“超出亲友共享时间范围”类错误。""" - normalized = message.strip().lower() - return "time out of data shared time scope" in normalized - - -def _extract_requested_data_type(params: dict[str, Any] | None) -> str: - """从业务参数中提取当前请求的数据类型 key。""" - if not isinstance(params, dict): - return "" - key = params.get("key") - return str(key) if key is not None else "" - - -def _build_auth_cookies(token: AuthToken) -> dict[str, str]: - """构造健康接口请求所需的认证 cookie。""" - return { - "cUserId": token.c_user_id, - "serviceToken": token.service_token, - } - - -async def _send_encrypted_http_request( - http: RetryAsyncClient, - method: str, - url: str, - enc_params: dict[str, Any], - cookies: dict[str, str], -): - """按 HTTP 方法发送已加密请求。""" - if method.upper() == "GET": - return await http.get(url, params=enc_params, cookies=cookies) - return await http.post(url, data=enc_params, cookies=cookies) - - -def _raise_for_http_status(resp: Any, method: str, path: str) -> None: - """将 HTTP 状态码转换为 SDK 异常。""" - if resp.status_code == 401: - raise TokenExpiredError(f"认证已过期: {method} {path} -> 401") - if resp.status_code != 200: - raise APIError( - f"API 请求失败: {method} {path} -> {resp.status_code}", - status_code=resp.status_code, - response_body=resp.text, - ) - - -def _decrypt_result(ssecurity: str, nonce: str, resp: Any) -> dict[str, Any]: - """解密并校验响应体。""" - try: - result = decrypt_response(ssecurity, nonce, resp.text) - except Exception as e: - raise APIError( - f"响应解密失败: {e}", - status_code=resp.status_code, - response_body=resp.text[:200], - ) from e - - if not isinstance(result, dict): - raise APIError( - f"解密后非 JSON 对象: {type(result)}", - response_body=str(result)[:200], - ) - return result - - -def _raise_for_business_code( - code: int, - result: dict[str, Any], - *, - params: dict[str, Any] | None = None, -) -> NoReturn: - """将业务错误码映射为 SDK 异常。""" - msg = _extract_api_message(result) - body = json.dumps(result, ensure_ascii=False) - - if code == ERR_NOT_RELATIVES: - raise FamilyMemberNotFoundError(f"非亲友关系 (code={code}): {msg}") - - if code == ERR_NOT_SHARED_DATA_TYPE: - data_type = _extract_requested_data_type(params) - suffix = f", key={data_type}" if data_type else "" - if _is_time_scope_error(msg): - raise DataOutOfSharedTimeScopeError( - f"超出亲友共享时间范围 (code={code}{suffix}): {msg}", - data_type=data_type, - ) - raise DataNotSharedError( - f"未共享该数据类型 (code={code}{suffix}): {msg}", - data_type=data_type, - ) - - raise APIError( - f"API 业务错误 (code={code}): {msg}", - code=code, - response_body=body, - ) - - -def create_api_http() -> RetryAsyncClient: - """创建 API 请求专用的 HTTP 客户端。""" - return RetryAsyncClient( - timeout=30.0, - headers={ - "User-Agent": DEFAULT_USER_AGENT, - "region_tag": REGION_TAG, - "handleparams": "true", - }, - ) - - -async def encrypted_request( - http: RetryAsyncClient, - token: AuthToken, - method: str, - path: str, - base_url: str = HEALTH_API_BASE, - *, - params: dict[str, Any] | None = None, -) -> dict[str, Any]: - """发送 RC4 加密的 API 请求并解密响应。 - - Args: - http: HTTP 客户端。 - token: 已登录的 AuthToken。 - method: HTTP 方法(GET / POST)。 - path: API 路径。 - base_url: API 基础 URL。 - params: 业务参数。 - - Returns: - 解密后的响应 JSON dict。 - - Raises: - APIError: 请求或解密失败。 - AuthError: 当前未登录。 - TokenExpiredError: 401 认证过期。 - FamilyMemberNotFoundError: 非亲友关系。 - DataNotSharedError: 亲友未共享当前请求的数据类型。 - DataOutOfSharedTimeScopeError: 请求日期超出亲友共享时间范围。 - """ - if not token.service_token or not token.ssecurity: - raise AuthError("未登录,请先调用 auth.login()") - - ssecurity = token.ssecurity - signing_path = path - if path == "/healthapp/service/gen_download_url": - signing_path = "/service/gen_download_url" - enc_params = build_encrypted_params(method, signing_path, ssecurity, params) - nonce = enc_params["_nonce"] - cookies = _build_auth_cookies(token) - url = base_url.rstrip("/") + path - resp = await _send_encrypted_http_request(http, method, url, enc_params, cookies) - _raise_for_http_status(resp, method, path) - result = _decrypt_result(ssecurity, nonce, resp) - - code = _coerce_api_code(result.get("code"), default=-1) - if code != 0: - _raise_for_business_code(code, result, params=params) - - return result diff --git a/mi-fitness-python/src/mi_fitness/client/data.py b/mi-fitness-python/src/mi_fitness/client/data.py deleted file mode 100644 index 55f7f0e..0000000 --- a/mi-fitness-python/src/mi_fitness/client/data.py +++ /dev/null @@ -1,522 +0,0 @@ -"""健康数据查询(心率、睡眠、步数等)。""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from datetime import UTC, date, datetime, timedelta, timezone -from typing import TYPE_CHECKING, TypeVar - -from pydantic import ValidationError - -from mi_fitness.const import ( - DATA_KEY_BLOOD_PRESSURE, - DATA_KEY_CALORIES, - DATA_KEY_GOAL, - DATA_KEY_HEART_RATE, - DATA_KEY_INTENSITY, - DATA_KEY_SLEEP, - DATA_KEY_SPO2, - DATA_KEY_STEPS, - DATA_KEY_VALID_STAND, - DATA_KEY_WEIGHT, - DATA_TAG_DAILY_REPORT, - RELATIVES_AGGREGATED_DATA_PATH, - RELATIVES_FITNESS_DATA_PATH, - RELATIVES_LATEST_DATA_PATH, -) -from mi_fitness.exceptions import DataNotSharedError, DataOutOfSharedTimeScopeError -from mi_fitness.models import ( - AggregatedDataItem, - AggregatedDataResponse, - BloodPressureData, - CaloriesData, - DailySummary, - GoalData, - HeartRateData, - IntensityData, - LatestDataItem, - LatestDataResponse, - LatestDataSnapshot, - SleepData, - Spo2Data, - Spo2SummaryData, - StepData, - ValidStandData, - WeightData, -) - -if TYPE_CHECKING: - from mi_fitness.client.api import MiHealthClient - -_SeriesDataT = TypeVar("_SeriesDataT") -_LatestMetricT = TypeVar( - "_LatestMetricT", - GoalData, - BloodPressureData, - CaloriesData, - IntensityData, - Spo2Data, - ValidStandData, - WeightData, -) - - -async def _get_first_shared_or_none( - fetcher: Callable[[], Awaitable[list[_SeriesDataT]]], -) -> _SeriesDataT | None: - """摘要接口专用:未共享时返回 None,其余异常继续向上抛。""" - try: - result = await fetcher() - except DataOutOfSharedTimeScopeError: - raise - except DataNotSharedError: - return None - return result[0] if result else None - - -# region 最新数据 -async def _get_latest_response(client: MiHealthClient, relative_uid: int) -> LatestDataResponse: - """获取并解析最新数据响应。""" - resp = await client._request( - "GET", - RELATIVES_LATEST_DATA_PATH, - params={"relative_uid": relative_uid}, - ) - return LatestDataResponse(**resp) - - -async def get_latest_items(client: MiHealthClient, relative_uid: int) -> list[LatestDataItem]: - """获取亲友的原始最新数据项列表。""" - return (await _get_latest_response(client, relative_uid)).data_items - - -async def get_latest_data(client: MiHealthClient, relative_uid: int) -> LatestDataSnapshot: - """获取亲友的最新数据快照(强类型聚合视图)。""" - return (await _get_latest_response(client, relative_uid)).snapshot - - -async def _get_latest_metric( - client: MiHealthClient, - relative_uid: int, - *, - attr_name: str, - shared_key: str, -) -> _LatestMetricT | None: - """读取最新快照中的单项数据,并在未共享时抛出明确异常。""" - latest = await get_latest_data(client, relative_uid) - value = getattr(latest, attr_name) - if value is not None: - return value - - shared_types = await client.get_shared_data_types(relative_uid) - if shared_key not in shared_types: - raise DataNotSharedError(f"未共享该数据类型: {shared_key}", data_type=shared_key) - return None - - -# endregion - - -# region 聚合数据 -async def get_aggregated_data( - client: MiHealthClient, - relative_uid: int, - key: str, - start_time: int, - end_time: int, - *, - tag: str = DATA_TAG_DAILY_REPORT, - limit: int = 30, -) -> AggregatedDataResponse: - """获取亲友的聚合数据。""" - resp = await client._request( - "GET", - RELATIVES_AGGREGATED_DATA_PATH, - params={ - "relative_uid": relative_uid, - "key": key, - "tag": tag, - "start_time": start_time, - "end_time": end_time, - "limit": limit, - }, - ) - return AggregatedDataResponse(**resp) - - -async def get_fitness_data( - client: MiHealthClient, - relative_uid: int, - key: str, - start_time: int, - end_time: int, - *, - limit: int = 30, -) -> AggregatedDataResponse: - """获取亲友的原始测量/事件数据(如体重、血压、异常心率等)。""" - resp = await client._request( - "GET", - RELATIVES_FITNESS_DATA_PATH, - params={ - "relative_uid": relative_uid, - "key": key, - "start_time": start_time, - "end_time": end_time, - "limit": limit, - }, - ) - return AggregatedDataResponse(**resp) - - -# endregion - - -# region 便捷方法 -def _date_to_timestamps(query_date: date | None = None) -> tuple[int, int]: - """将日期转换为当日 00:00 ~ 23:59:59 的时间戳。""" - d = query_date or date.today() - tz = timezone(timedelta(hours=8)) - start = int(datetime(d.year, d.month, d.day, tzinfo=tz).timestamp()) - end = start + 86400 - 1 - return start, end - - -def _build_window_timestamps(query_date: date | None, days: int) -> tuple[int, int, int]: - """构造以 ``query_date`` 为结束日的查询窗口。""" - window_days = max(days, 1) - _, end = _date_to_timestamps(query_date) - start = end - 86400 * window_days + 1 - return start, end, window_days - - -def _parse_series_items( - items: list[AggregatedDataItem], - parser: Callable[[AggregatedDataItem], _SeriesDataT], -) -> list[_SeriesDataT]: - """逐条解析数据项,跳过单条脏数据。""" - series: list[_SeriesDataT] = [] - for item in items: - try: - series.append(parser(item)) - except ValidationError: - continue - return series - - -async def _get_aggregated_series( - client: MiHealthClient, - relative_uid: int, - key: str, - parser: Callable[[AggregatedDataItem], _SeriesDataT], - query_date: date | None = None, - *, - days: int = 1, -) -> list[_SeriesDataT]: - """按日期范围拉取聚合数据并转换为目标模型。 - - ``query_date`` 视为窗口结束日;``days=7`` 表示获取该日及之前 6 天的聚合数据。 - """ - start, end, window_days = _build_window_timestamps(query_date, days) - resp = await get_aggregated_data(client, relative_uid, key, start, end, limit=window_days) - return _parse_series_items(resp.data_items, parser) - - -async def _get_fitness_series( - client: MiHealthClient, - relative_uid: int, - key: str, - parser: Callable[[AggregatedDataItem], _SeriesDataT], - query_date: date | None = None, - *, - days: int = 1, -) -> list[_SeriesDataT]: - """按时间窗口拉取原始测量记录。 - - ``get_fitness_data`` 不是按天一条的聚合接口,因此固定使用较宽松的 ``limit=30``, - 以避免一周内存在多次测量时被 ``days`` 误伤截断。 - """ - start, end, window_days = _build_window_timestamps(query_date, days) - resp = await get_fitness_data( - client, - relative_uid, - key, - start, - end, - limit=max(window_days, 30), - ) - return _parse_series_items(resp.data_items, parser) - - -async def get_heart_rate( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[HeartRateData]: - """获取亲友的心率数据。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_HEART_RATE, - AggregatedDataItem.as_heart_rate, - query_date, - days=days, - ) - - -async def get_sleep( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[SleepData]: - """获取亲友的睡眠数据。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_SLEEP, - AggregatedDataItem.as_sleep, - query_date, - days=days, - ) - - -async def get_steps( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[StepData]: - """获取亲友的步数数据。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_STEPS, - AggregatedDataItem.as_steps, - query_date, - days=days, - ) - - -async def get_calories_history( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[CaloriesData]: - """获取亲友按天聚合的活动卡路里数据。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_CALORIES, - AggregatedDataItem.as_calories, - query_date, - days=days, - ) - - -async def get_valid_stand_history( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[ValidStandData]: - """获取亲友按天聚合的有效站立次数。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_VALID_STAND, - AggregatedDataItem.as_valid_stand, - query_date, - days=days, - ) - - -async def get_intensity_history( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[IntensityData]: - """获取亲友按天聚合的中高强度活动时长。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_INTENSITY, - AggregatedDataItem.as_intensity, - query_date, - days=days, - ) - - -async def get_spo2_history( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[Spo2SummaryData]: - """获取亲友按天聚合的血氧摘要。""" - return await _get_aggregated_series( - client, - relative_uid, - DATA_KEY_SPO2, - AggregatedDataItem.as_spo2, - query_date, - days=days, - ) - - -async def get_weight_history( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[WeightData]: - """获取亲友在指定窗口内的体重测量记录。""" - return await _get_fitness_series( - client, - relative_uid, - DATA_KEY_WEIGHT, - AggregatedDataItem.as_weight, - query_date, - days=days, - ) - - -async def get_blood_pressure_history( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, - *, - days: int = 1, -) -> list[BloodPressureData]: - """获取亲友在指定窗口内的血压测量记录。""" - return await _get_fitness_series( - client, - relative_uid, - DATA_KEY_BLOOD_PRESSURE, - AggregatedDataItem.as_blood_pressure, - query_date, - days=days, - ) - - -async def get_weight(client: MiHealthClient, relative_uid: int) -> WeightData | None: - """获取亲友最新体重数据。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="weight", - shared_key=DATA_KEY_WEIGHT, - ) - - -async def get_goal(client: MiHealthClient, relative_uid: int) -> GoalData | None: - """获取亲友最新目标完成情况。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="goal", - shared_key=DATA_KEY_GOAL, - ) - - -async def get_blood_pressure(client: MiHealthClient, relative_uid: int) -> BloodPressureData | None: - """获取亲友最新血压数据。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="blood_pressure", - shared_key=DATA_KEY_BLOOD_PRESSURE, - ) - - -async def get_calories(client: MiHealthClient, relative_uid: int) -> CaloriesData | None: - """获取亲友最新活动卡路里。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="calories", - shared_key=DATA_KEY_CALORIES, - ) - - -async def get_valid_stand(client: MiHealthClient, relative_uid: int) -> ValidStandData | None: - """获取亲友最新有效站立次数。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="valid_stand", - shared_key=DATA_KEY_VALID_STAND, - ) - - -async def get_intensity(client: MiHealthClient, relative_uid: int) -> IntensityData | None: - """获取亲友最新中高强度活动时长。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="intensity", - shared_key=DATA_KEY_INTENSITY, - ) - - -async def get_spo2(client: MiHealthClient, relative_uid: int) -> Spo2Data | None: - """获取亲友最新血氧数据。""" - return await _get_latest_metric( - client, - relative_uid, - attr_name="spo2", - shared_key=DATA_KEY_SPO2, - ) - - -async def get_daily_summary( - client: MiHealthClient, - relative_uid: int, - query_date: date | None = None, -) -> DailySummary: - """获取亲友的每日综合健康摘要(并发请求心率、睡眠、步数)。""" - d = query_date or date.today() - - heart_rate, sleep, steps = await asyncio.gather( - _get_first_shared_or_none(lambda: get_heart_rate(client, relative_uid, d)), - _get_first_shared_or_none(lambda: get_sleep(client, relative_uid, d)), - _get_first_shared_or_none(lambda: get_steps(client, relative_uid, d)), - ) - - return DailySummary( - date=d.isoformat(), - relative_uid=relative_uid, - heart_rate=heart_rate, - sleep=sleep, - steps=steps, - ) - - -async def get_latest_daily_summary( - client: MiHealthClient, - relative_uid: int, -) -> DailySummary: - """获取亲友最近一次有同步数据的每日综合健康摘要。""" - member = await client.find_relative(relative_uid) - query_date = date.today() - if member.latest_data_time > 0: - query_date = datetime.fromtimestamp(member.latest_data_time, tz=UTC).date() - else: - latest = await get_latest_data(client, relative_uid) - if latest.updated_time > 0: - query_date = datetime.fromtimestamp(latest.updated_time, tz=UTC).date() - return await get_daily_summary(client, relative_uid, query_date) - - -# endregion diff --git a/mi-fitness-python/src/mi_fitness/client/messages.py b/mi-fitness-python/src/mi_fitness/client/messages.py deleted file mode 100644 index cc2f6c2..0000000 --- a/mi-fitness-python/src/mi_fitness/client/messages.py +++ /dev/null @@ -1,52 +0,0 @@ -"""消息(邀请通知)查询。""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from loguru import logger - -from mi_fitness.const import ( - MESSAGE_CHECK_NEW_PATH, - MESSAGE_GET_LIST_PATH, - MESSAGE_MODULE_RELATIVES, -) -from mi_fitness.models import CheckNewMsgResponse, InviteMessage, MessageListResponse - -if TYPE_CHECKING: - from mi_fitness.client.api import MiHealthClient - - -async def get_invite_messages( - client: MiHealthClient, - *, - limit: int = 30, - pending_only: bool = False, -) -> list[InviteMessage]: - """获取亲友邀请消息列表。""" - resp = await client._request( - "POST", - MESSAGE_GET_LIST_PATH, - params={"module": MESSAGE_MODULE_RELATIVES, "limit": limit}, - ) - parsed = MessageListResponse(**resp) - messages = parsed.messages - if pending_only: - messages = [m for m in messages if m.is_pending] - logger.debug( - "获取邀请消息: {}条 (待处理: {}条)", - len(parsed.messages), - sum(1 for m in parsed.messages if m.is_pending), - ) - return messages - - -async def has_new_invite(client: MiHealthClient) -> bool: - """检查是否有新的亲友邀请消息。""" - resp = await client._request( - "POST", - MESSAGE_CHECK_NEW_PATH, - params={"module": [MESSAGE_MODULE_RELATIVES], "begin_time": 0}, - ) - parsed = CheckNewMsgResponse(**resp) - return parsed.has_new(MESSAGE_MODULE_RELATIVES) diff --git a/mi-fitness-python/src/mi_fitness/client/relatives.py b/mi-fitness-python/src/mi_fitness/client/relatives.py deleted file mode 100644 index c579e41..0000000 --- a/mi-fitness-python/src/mi_fitness/client/relatives.py +++ /dev/null @@ -1,236 +0,0 @@ -"""亲友关系管理(添加 / 删除 / 设置)。""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from loguru import logger - -from mi_fitness.const import ( - ALL_SHARED_DATA_TYPES, - RELATIVES_DELETE_PATH, - RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, - RELATIVES_GET_FAMILY_MEMBER_PATH, - RELATIVES_GET_INVITE_ID_PATH, - RELATIVES_GET_SHARED_TYPES_PATH, - RELATIVES_GET_TOPIC_SUBS_PATH, - RELATIVES_LIST_PATH, - RELATIVES_OPERATE_INVITE_PATH, - RELATIVES_SEND_INVITE_PATH, - RELATIVES_VERIFY_USER_PATH, - VERIFY_TYPE_XIAOMI_ID, -) -from mi_fitness.exceptions import FamilyMemberNotFoundError -from mi_fitness.models import ( - DeleteRelativeResponse, - FamilyMember, - FamilyMemberResponse, - InviteResponse, - InviteUniqueIdResponse, - OperateInviteResponse, - RelativeListResponse, - SharedDataTypesResponse, - VerifiedUserInfo, - VerifyUserResponse, -) - -if TYPE_CHECKING: - from mi_fitness.client.api import MiHealthClient - -_DEFAULT_TOPICS = ("abnormal_event",) - - -def _build_auth_content( - shared_data_types: list[str] | None, - auth_time_range: int, -) -> dict[str, Any]: - """构造亲友邀请相关的 auth_content。""" - return { - "auth_time_range": auth_time_range, - "auth_data": shared_data_types or ALL_SHARED_DATA_TYPES, - } - - -async def get_relatives(client: MiHealthClient) -> list[FamilyMember]: - """获取亲友列表。""" - resp = await client._request("GET", RELATIVES_LIST_PATH) - parsed = RelativeListResponse(**resp) - members = parsed.relatives - logger.info("获取到 {} 位亲友", len(members)) - return members - - -async def find_relative(client: MiHealthClient, keyword: str | int) -> FamilyMember: - """按备注名或 UID 查找亲友。""" - members = await get_relatives(client) - for m in members: - if (isinstance(keyword, int) and m.relative_uid == keyword) or ( - isinstance(keyword, str) and keyword.lower() in m.relative_note.lower() - ): - return m - raise FamilyMemberNotFoundError(f"未找到亲友: {keyword}") - - -async def verify_user( - client: MiHealthClient, - verify_id: int, - *, - verify_type: int = VERIFY_TYPE_XIAOMI_ID, -) -> VerifiedUserInfo | None: - """按 UID 或扫码 ID 验证用户信息。""" - resp = await client._request( - "GET", - RELATIVES_VERIFY_USER_PATH, - params={"verify_id": verify_id, "verify_type": verify_type}, - ) - parsed = VerifyUserResponse(**resp) - return parsed.user_info - - -async def invite_relative( - client: MiHealthClient, - relative_uid: int, - *, - shared_data_types: list[str] | None = None, - auth_time_range: int = 3, - relative_note: str = "", -) -> bool: - """发送亲友邀请。""" - params: dict[str, Any] = { - "auth_content": _build_auth_content(shared_data_types, auth_time_range), - "relative_uid": relative_uid, - } - if relative_note: - params["relative_note"] = relative_note - - resp = await client._request("POST", RELATIVES_SEND_INVITE_PATH, params=params) - parsed = InviteResponse(**resp) - logger.info("邀请发送 {} (uid={})", "成功" if parsed.success else "失败", relative_uid) - return parsed.success - - -async def accept_invite( - client: MiHealthClient, - invite_id: int, - msg_id: int, - *, - shared_data_types: list[str] | None = None, - auth_time_range: int = 3, -) -> bool: - """同意亲友邀请。""" - return await _operate_invite( - client, - invite_id, - msg_id, - operate=1, - shared_data_types=shared_data_types, - auth_time_range=auth_time_range, - ) - - -async def reject_invite( - client: MiHealthClient, - invite_id: int, - msg_id: int, -) -> bool: - """拒绝亲友邀请。""" - return await _operate_invite(client, invite_id, msg_id, operate=2) - - -async def _operate_invite( - client: MiHealthClient, - invite_id: int, - msg_id: int, - *, - operate: int, - shared_data_types: list[str] | None = None, - auth_time_range: int = 3, -) -> bool: - """操作亲友邀请(内部)。""" - params: dict[str, Any] = { - "auth_content": _build_auth_content(shared_data_types, auth_time_range), - "invite_id": invite_id, - "msg_id": msg_id, - "operate": operate, - } - resp = await client._request("POST", RELATIVES_OPERATE_INVITE_PATH, params=params) - parsed = OperateInviteResponse(**resp) - op_name = "同意" if operate == 1 else "拒绝" - logger.info( - "{}邀请 {} (invite_id={})", - op_name, - "成功" if parsed.success else "失败", - invite_id, - ) - return parsed.success - - -async def delete_relative(client: MiHealthClient, relative_uid: int) -> bool: - """删除亲友关系。""" - resp = await client._request( - "POST", - RELATIVES_DELETE_PATH, - params={"relative_uid": relative_uid}, - ) - parsed = DeleteRelativeResponse(**resp) - logger.info("删除亲友 {} (uid={})", "成功" if parsed.success else "失败", relative_uid) - return parsed.success - - -async def get_invite_link_id(client: MiHealthClient) -> int: - """获取二维码邀请链接 ID。""" - resp = await client._request("GET", RELATIVES_GET_INVITE_ID_PATH) - parsed = InviteUniqueIdResponse(**resp) - logger.debug("获取邀请 ID: {}", parsed.invite_link_id) - return parsed.invite_link_id - - -async def get_shared_data_types( - client: MiHealthClient, - relative_uid: int, - *, - direction: int = 2, -) -> list[str]: - """获取亲友共享的数据类型列表。""" - resp = await client._request( - "GET", - RELATIVES_GET_SHARED_TYPES_PATH, - params={"relative_uid": relative_uid, "type": direction}, - ) - parsed = SharedDataTypesResponse(**resp) - return parsed.keys - - -async def get_applied_shared_data_types( - client: MiHealthClient, - relative_uid: int, -) -> list[str]: - """获取已申请的共享数据类型。""" - resp = await client._request( - "GET", - RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, - params={"relative_uid": relative_uid}, - ) - return resp.get("result", {}).get("keys", []) - - -async def get_family_members(client: MiHealthClient) -> list[dict[str, Any]]: - """获取家庭成员列表。""" - resp = await client._request("GET", RELATIVES_GET_FAMILY_MEMBER_PATH) - parsed = FamilyMemberResponse(**resp) - return parsed.family_user_list - - -async def get_topic_subscriptions( - client: MiHealthClient, - relative_uid: int, - topics: list[str] | None = None, -) -> dict[str, Any]: - """获取亲友的消息订阅状态。""" - topic_list = list(topics or _DEFAULT_TOPICS) - resp = await client._request( - "GET", - RELATIVES_GET_TOPIC_SUBS_PATH, - params={"relative_uid": relative_uid, "topics": topic_list}, - ) - return resp.get("result", {}) diff --git a/mi-fitness-python/src/mi_fitness/const.py b/mi-fitness-python/src/mi_fitness/const.py deleted file mode 100644 index b2fadaf..0000000 --- a/mi-fitness-python/src/mi_fitness/const.py +++ /dev/null @@ -1,95 +0,0 @@ -"""常量与配置。""" - -# region 小米账号 OAuth 端点 -XIAOMI_LOGIN_URL = "https://account.xiaomi.com/pass/serviceLogin" -XIAOMI_LOGIN_AUTH_URL = "https://account.xiaomi.com/pass/serviceLoginAuth2" -XIAOMI_PREFERENCE_URL = "https://account.xiaomi.com/pass/preference" -XIAOMI_PHONE_INFO_URL = "https://account.xiaomi.com/pass/phoneInfo" -XIAOMI_SEND_TICKET_URL = "https://account.xiaomi.com/pass/sendServiceLoginTicket" -XIAOMI_TICKET_AUTH_URL = "https://account.xiaomi.com/pass/serviceLoginTicketAuth" -XIAOMI_QR_LOGIN_URL = "https://account.xiaomi.com/longPolling/loginUrl" -# endregion - -# region 服务 SID(serviceLogin 的 sid 参数) -SERVICE_SID_HEALTH = "miothealth" -# endregion - -# region STS (安全令牌交换) 端点 -STS_HEALTH_URL = "https://sts-hlth.io.mi.com/healthapp/sts" -# endregion - -# region API 基础 URL -HEALTH_API_BASE = "https://ru.hlth.io.mi.com" -# endregion - -# region 亲友 API 路径 -RELATIVES_LIST_PATH = "/app/v1/relatives/get_relative_list" -RELATIVES_LATEST_DATA_PATH = "/app/v1/data/get_latest_fitness_data" -RELATIVES_AGGREGATED_DATA_PATH = "/app/v1/data/get_aggregated_fitness_data_by_time" -RELATIVES_FITNESS_DATA_PATH = "/app/v1/data/get_fitness_data_by_time" -RELATIVES_VERIFY_USER_PATH = "/app/v1/relatives/verify_userinfo_by_id" -RELATIVES_SEND_INVITE_PATH = "/app/v1/relatives/send_invite" -RELATIVES_OPERATE_INVITE_PATH = "/app/v1/relatives/operate_invite" -RELATIVES_DELETE_PATH = "/app/v1/relatives/delete_relative" -RELATIVES_GET_SHARED_TYPES_PATH = "/app/v1/relatives/get_shared_data_types" -RELATIVES_GET_APPLIED_SHARED_TYPES_PATH = "/app/v1/relatives/get_applied_shared_data_types" -RELATIVES_GET_FAMILY_MEMBER_PATH = "/app/v1/relatives/get_family_member" -RELATIVES_GET_INVITE_ID_PATH = "/app/v1/relatives/get_invite_unique_id" -RELATIVES_GET_TOPIC_SUBS_PATH = "/app/v1/relatives/get_topic_subscriptions" -# endregion - -# region 消息 API 路径 -MESSAGE_GET_LIST_PATH = "/app/v1/message/get_msg_list" -MESSAGE_CHECK_NEW_PATH = "/app/v1/message/check_new_msg" -MESSAGE_MODULE_RELATIVES = 1 -# endregion - -# region 业务错误码 -ERR_NOT_RELATIVES = -4002001 -ERR_NOT_SHARED_DATA_TYPE = -4002004 -ERR_DEVICE_UNTRUST = 70016 -# endregion - -# region 数据类型 key(用于 get_aggregated_data / get_fitness_data 请求) -DATA_KEY_GOAL = "goal" -DATA_KEY_HEART_RATE = "heart_rate" -DATA_KEY_SLEEP = "sleep" -DATA_KEY_BLOOD_PRESSURE = "blood_pressure" -DATA_KEY_STEPS = "steps" -DATA_KEY_CALORIES = "calories" -DATA_KEY_VALID_STAND = "valid_stand" -DATA_KEY_INTENSITY = "intensity" -DATA_KEY_WEIGHT = "weight" -DATA_KEY_SPO2 = "spo2" -DATA_TAG_DAILY_REPORT = "daily_report" -# endregion - -# region 可共享数据类型(send_invite 的 auth_data 全量) -ALL_SHARED_DATA_TYPES: tuple[str, ...] = ( - "goal", - "heart_rate", - "sleep", - "blood_pressure", - "steps", - "calories", - "valid_stand", - "intensity", - "weight", - "spo2", -) -# endregion - -# region verify_userinfo_by_id 的 verify_type 枚举 -VERIFY_TYPE_XIAOMI_ID = 1 -# endregion - -# region HTTP 公共 Header -DEFAULT_USER_AGENT = "Android-12-3.53.1-vivo-V2284A" -DEFAULT_LOGIN_USER_AGENT = ( - "Dalvik/2.1.0 (Linux; U; Android 12; V2284A Build/ab8c0d1.1) " - "APP/mi.health APPV/353001 MK/VjIyODRB " - "SDKV/5.3.0.release.68 CPN/com.mi.health PassportSDK/" -) -APP_NAME = "com.mi.health" -REGION_TAG = "ru" -# endregion diff --git a/mi-fitness-python/src/mi_fitness/crypto.py b/mi-fitness-python/src/mi_fitness/crypto.py deleted file mode 100644 index 00826d2..0000000 --- a/mi-fitness-python/src/mi_fitness/crypto.py +++ /dev/null @@ -1,289 +0,0 @@ -"""小米云服务加密模块。""" - -from __future__ import annotations - -import base64 -import hashlib -import json -import os -import struct -import time -from typing import Any - - -def _rc4_crypt(key: bytes, data: bytes, *, skip: int = 1024) -> bytes: - """RC4 加密/解密(带前 N 字节跳过,防止密钥流弱点)。 - - Args: - key: RC4 密钥。 - data: 待加密/解密的数据。 - skip: 跳过前 N 字节的密钥流(默认 1024)。 - - Returns: - 加密/解密后的字节数据。 - """ - s = list(range(256)) - j = 0 - - # KSA (Key-Scheduling Algorithm) - for i in range(256): - j = (j + s[i] + key[i % len(key)]) & 0xFF - s[i], s[j] = s[j], s[i] - - # PRGA (Pseudo-Random Generation Algorithm) - i = 0 - j = 0 - - # 跳过前 skip 字节密钥流 - for _ in range(skip): - i = (i + 1) & 0xFF - j = (j + s[i]) & 0xFF - s[i], s[j] = s[j], s[i] - - # 加密/解密 - result = bytearray(len(data)) - for idx in range(len(data)): - i = (i + 1) & 0xFF - j = (j + s[i]) & 0xFF - s[i], s[j] = s[j], s[i] - result[idx] = data[idx] ^ s[(s[i] + s[j]) & 0xFF] - - return bytes(result) - - -def generate_nonce() -> str: - """生成请求 nonce。 - - 格式: base64(random_8_bytes + minutes_since_epoch_4bytes_BE) - - Returns: - Base64 编码的 nonce 字符串。 - """ - random_part = os.urandom(8) - minutes = int(time.time() / 60) - time_part = struct.pack(">I", minutes) - return base64.b64encode(random_part + time_part).decode() - - -def compute_signed_nonce(ssecurity: str, nonce: str) -> str: - """计算签名 nonce(用于密钥派生)。 - - signed_nonce = base64(SHA256(b64decode(ssecurity) + b64decode(nonce))) - - Args: - ssecurity: 登录时获取的 ssecurity(base64 编码)。 - nonce: 请求 nonce。 - - Returns: - Base64 编码的 signed_nonce(同时作为 RC4 和 HMAC 的密钥)。 - """ - hash_val = hashlib.sha256(base64.b64decode(ssecurity) + base64.b64decode(nonce)).digest() - return base64.b64encode(hash_val).decode() - - -# endregion - - -# region 签名生成 -def _sha1_b64(message: str) -> str: - """纯 SHA1 哈希 → Base64 编码。 - - App 使用 MessageDigest("SHA1") 而非 HMAC 来生成签名。 - - Args: - message: 待哈希的 UTF-8 字符串。 - - Returns: - Base64 编码的 SHA1 摘要(28 字符)。 - """ - digest = hashlib.sha1(message.encode("utf-8")).digest() - return base64.b64encode(digest).decode() - - -def _build_sig_message( - method: str, - url_path: str, - params: dict[str, str], - signed_nonce: str, -) -> str: - """构建签名消息字符串(z94.b 格式)。 - - 格式: METHOD&/path&k1=v1&k2=v2&...&signedNonce_b64 - - method 大写 - - path 带前导 / - - params 按 key 字典序(TreeMap)排序 - - 最后追加 signedNonce 的 base64 字符串 - - Args: - method: HTTP 方法。 - url_path: URL 路径(须含前导 /)。 - params: 参数字典(已排除空 key/value)。 - signed_nonce: Base64 编码的 signed_nonce。 - - Returns: - 用 & 连接的签名消息。 - """ - parts: list[str] = [method.upper()] - if not url_path.startswith("/"): - url_path = "/" + url_path - parts.append(url_path) - for k in sorted(params.keys()): - parts.append(f"{k}={params[k]}") - parts.append(signed_nonce) - return "&".join(parts) - - -def _rc4_stream_encrypt_values( - key_bytes: bytes, - sorted_entries: list[tuple[str, str]], -) -> dict[str, str]: - """用连续 RC4 流加密多个值。 - - 模拟 App 中 d8k 的行为:构造时 drop 1024 字节, - 然后对 TreeMap 中每个 entry 的 value 按排序顺序 - 依次加密,共用同一个 RC4 密钥流。 - - Args: - key_bytes: RC4 密钥(signed_nonce 的原始字节)。 - sorted_entries: 按 key 排序的 (key, value) 对列表。 - - Returns: - {key: base64(encrypted_value)} 字典。 - """ - # 将所有 value 拼接,一次性过 RC4 流 - all_bytes = b"".join(v.encode("utf-8") for _, v in sorted_entries) - encrypted_all = _rc4_crypt(key_bytes, all_bytes, skip=1024) - - result: dict[str, str] = {} - pos = 0 - for k, v in sorted_entries: - vlen = len(v.encode("utf-8")) - result[k] = base64.b64encode(encrypted_all[pos : pos + vlen]).decode() - pos += vlen - return result - - -# endregion - - -# region 数据加密/解密 -def encrypt_data(signed_nonce: str, plaintext: str) -> str: - """用 RC4 加密数据。 - - Args: - signed_nonce: 密钥(base64 编码的 SHA256 哈希)。 - plaintext: 明文 JSON 字符串。 - - Returns: - Base64 编码的密文。 - """ - key = base64.b64decode(signed_nonce) - encrypted = _rc4_crypt(key, plaintext.encode("utf-8")) - return base64.b64encode(encrypted).decode() - - -def decrypt_data(signed_nonce: str, ciphertext_b64: str) -> str: - """用 RC4 解密数据。 - - Args: - signed_nonce: 密钥(base64 编码的 SHA256 哈希)。 - ciphertext_b64: Base64 编码的密文。 - - Returns: - 解密后的明文字符串。 - """ - key = base64.b64decode(signed_nonce) - decrypted = _rc4_crypt(key, base64.b64decode(ciphertext_b64)) - return decrypted.decode("utf-8") - - -# endregion - - -# region 封装:构建加密请求参数 -def build_encrypted_params( - method: str, - url_path: str, - ssecurity: str, - params: dict[str, Any] | None = None, -) -> dict[str, str]: - """构建完整的加密请求参数。 - - 流程(对应 App 中 ua4.c 方法): - 1. 计算 signed_nonce = base64(SHA256(ssecurity + nonce)) - 2. 构建原始参数 TreeMap(排除空 key/value) - 3. rc4_hash__ = SHA1(METHOD&/path&k=v&...&signedNonce) → base64 - 4. 将 rc4_hash__ 加入 TreeMap - 5. 用连续 RC4 流加密所有 TreeMap 值(按 key 排序,drop 1024) - 6. signature = SHA1(METHOD&/path&k=enc_v&...&signedNonce) → base64 - 7. 返回 {加密后各参数, signature, _nonce} - - Args: - method: HTTP 方法。 - url_path: API 路径(如 /app/v1/relatives/get_relative_list)。 - ssecurity: 登录时获取的 ssecurity。 - params: 要发送的参数字典(将被 JSON 序列化后加密)。 - - Returns: - 包含 data, signature, rc4_hash__, _nonce 的参数字典。 - """ - nonce = generate_nonce() - snonce = compute_signed_nonce(ssecurity, nonce) - snonce_bytes = base64.b64decode(snonce) - - # Step 1: 构建原始参数 TreeMap(排除空 key/value) - raw_tree: dict[str, str] = {} - if params: - plaintext = json.dumps(params, separators=(",", ":"), ensure_ascii=False) - raw_tree["data"] = plaintext - - # Step 2: 计算 rc4_hash__(基于原始参数) - rc4_msg = _build_sig_message(method, url_path, raw_tree, snonce) - rc4_hash_raw = _sha1_b64(rc4_msg) - - # Step 3: 将 rc4_hash__ 插入 TreeMap - raw_tree["rc4_hash__"] = rc4_hash_raw - - # Step 4: 用连续 RC4 流加密所有值 - sorted_entries = sorted(raw_tree.items()) - encrypted_values = _rc4_stream_encrypt_values(snonce_bytes, sorted_entries) - - # Step 5: 构建加密后参数 TreeMap,计算 signature - sig_msg = _build_sig_message(method, url_path, encrypted_values, snonce) - signature = _sha1_b64(sig_msg) - - # Step 6: 组装最终结果 - result: dict[str, str] = {} - for k, v in encrypted_values.items(): - result[k] = v - result["signature"] = signature - result["_nonce"] = nonce - return result - - -def decrypt_response( - ssecurity: str, - nonce: str, - ciphertext_b64: str, -) -> Any: - """解密 API 响应。 - - Args: - ssecurity: 登录时获取的 ssecurity。 - nonce: 请求时使用的 nonce。 - ciphertext_b64: Base64 编码的响应密文。 - - Returns: - 解密后的 JSON 对象。 - """ - snonce = compute_signed_nonce(ssecurity, nonce) - plaintext = decrypt_data(snonce, ciphertext_b64) - - try: - return json.loads(plaintext) - except json.JSONDecodeError: - # 可能不是 JSON,返回原始字符串 - return plaintext - - -# endregion diff --git a/mi-fitness-python/src/mi_fitness/exceptions.py b/mi-fitness-python/src/mi_fitness/exceptions.py deleted file mode 100644 index 9d60f46..0000000 --- a/mi-fitness-python/src/mi_fitness/exceptions.py +++ /dev/null @@ -1,88 +0,0 @@ -"""自定义异常。""" - - -class MiSDKError(Exception): - """MiSDK 基础异常。""" - - -class AuthError(MiSDKError): - """认证相关错误(登录失败、token 过期等)。""" - - -class APIError(MiSDKError): - """API 请求返回非预期结果。 - - Attributes: - status_code: HTTP 状态码。 - code: 业务错误码(``result["code"]``,仅业务层错误时有值)。 - response_body: 原始响应体。 - """ - - def __init__( - self, - message: str, - *, - status_code: int = 0, - code: int = 0, - response_body: str = "", - ): - super().__init__(message) - self.status_code = status_code - self.code = code - self.response_body = response_body - - def __repr__(self) -> str: - return f"APIError(status_code={self.status_code}, code={self.code}, message={str(self)!r})" - - -class DeviceUntrustedError(AuthError): - """设备未信任,需要短信验证码完成登录。 - - 新设备首次登录时触发 ``securityStatus != 0``,需要通过短信验证码 - 完成身份验证。可通过 ``login(verification_code_handler=...)`` 自动 - 处理,或手动调用 ``send_verification_code()`` + - ``login_with_verification_code()``。 - - Attributes: - security_status: 服务端返回的安全状态码。 - """ - - def __init__(self, message: str, *, security_status: int = 0): - super().__init__(message) - self.security_status = security_status - - -class CaptchaRequiredError(AuthError): - """触发图形验证码风控,需要人工识别通过。 - - 在登录流程中服务端可能要求完成图形验证码验证(错误码 87001)。 - 可通过 ``login(captcha_handler=...)`` 自动处理, - 或捕获此异常后自行下载 ``captcha_url`` 的验证码图片并重试。 - - Attributes: - captcha_url: 验证码图片完整 URL。 - """ - - def __init__(self, message: str, *, captcha_url: str = ""): - super().__init__(message) - self.captcha_url = captcha_url - - -class TokenExpiredError(AuthError): - """Token 已过期,需要重新登录。""" - - -class DataNotSharedError(MiSDKError): - """亲友未共享当前请求的数据类型。""" - - def __init__(self, message: str, *, data_type: str = ""): - super().__init__(message) - self.data_type = data_type - - -class DataOutOfSharedTimeScopeError(DataNotSharedError): - """请求日期超出亲友允许共享的时间范围。""" - - -class FamilyMemberNotFoundError(MiSDKError): - """找不到指定的亲友。""" diff --git a/mi-fitness-python/src/mi_fitness/http.py b/mi-fitness-python/src/mi_fitness/http.py deleted file mode 100644 index 3af8c22..0000000 --- a/mi-fitness-python/src/mi_fitness/http.py +++ /dev/null @@ -1,93 +0,0 @@ -"""HTTP 客户端扩展。""" - -from __future__ import annotations - -from collections.abc import Collection -from typing import Any - -import httpx -from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_exponential - -_DEFAULT_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) -_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) - - -class _RetryableStatusError(Exception): - """内部异常:用于触发 tenacity 的状态码重试。""" - - def __init__(self, response: httpx.Response): - super().__init__(f"retryable status: {response.status_code}") - self.response = response - - -def _is_retryable_error(exc: BaseException) -> bool: - """判断异常是否可重试。""" - if isinstance(exc, _RetryableStatusError): - return True - return isinstance(exc, (httpx.NetworkError, httpx.TimeoutException, httpx.RemoteProtocolError)) - - -class RetryAsyncClient(httpx.AsyncClient): - """带重试能力的异步 HTTP 客户端。 - - 继承 ``httpx.AsyncClient``,在 ``request`` 上增加 tenacity 退避重试。 - 默认仅对幂等方法启用重试,避免对发送短信/提交表单等非幂等请求重复提交。 - - Attributes: - retry_attempts: 最大重试次数(含首轮请求)。 - retry_wait_min: 指数退避最小等待秒数。 - retry_wait_max: 指数退避最大等待秒数。 - retry_wait_multiplier: 指数退避倍率。 - retry_statuses: 触发重试的 HTTP 状态码集合。 - retry_non_idempotent: 是否允许对非幂等方法重试。 - """ - - def __init__( - self, - *args: Any, - retry_attempts: int = 3, - retry_wait_min: float = 0.2, - retry_wait_max: float = 2.0, - retry_wait_multiplier: float = 0.5, - retry_statuses: Collection[int] = _DEFAULT_RETRY_STATUSES, - retry_non_idempotent: bool = False, - **kwargs: Any, - ): - super().__init__(*args, **kwargs) - self.retry_attempts = retry_attempts - self.retry_wait_min = retry_wait_min - self.retry_wait_max = retry_wait_max - self.retry_wait_multiplier = retry_wait_multiplier - self.retry_statuses = frozenset(retry_statuses) - self.retry_non_idempotent = retry_non_idempotent - - async def request( - self, method: str, url: str | httpx.URL, *args: Any, **kwargs: Any - ) -> httpx.Response: - """发送请求并按策略自动重试。""" - method_upper = method.upper() - allow_retry = self.retry_non_idempotent or method_upper in _IDEMPOTENT_METHODS - if self.retry_attempts <= 1 or not allow_retry: - return await super().request(method, url, *args, **kwargs) - - try: - async for attempt in AsyncRetrying( - stop=stop_after_attempt(self.retry_attempts), - wait=wait_exponential( - multiplier=self.retry_wait_multiplier, - min=self.retry_wait_min, - max=self.retry_wait_max, - ), - retry=retry_if_exception(_is_retryable_error), - reraise=True, - ): - with attempt: - response = await super().request(method, url, *args, **kwargs) - if response.status_code in self.retry_statuses: - raise _RetryableStatusError(response) - return response - except _RetryableStatusError as exc: - return exc.response - - # 理论上不会到达这里,保留兜底以满足类型检查。 - return await super().request(method, url, *args, **kwargs) diff --git a/mi-fitness-python/src/mi_fitness/models.py b/mi-fitness-python/src/mi_fitness/models.py deleted file mode 100644 index 28edcf7..0000000 --- a/mi-fitness-python/src/mi_fitness/models.py +++ /dev/null @@ -1,1261 +0,0 @@ -"""健康数据结构。""" - -from __future__ import annotations - -import json -from datetime import UTC, datetime -from enum import IntEnum -from functools import cached_property -from typing import Any, TypeVar - -from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator - -_ModelT = TypeVar("_ModelT", bound=BaseModel) - - -def _ts_to_datetime(ts: int) -> datetime | None: - """将秒级时间戳转为 UTC datetime,0 返回 None。""" - if ts <= 0: - return None - return datetime.fromtimestamp(ts, tz=UTC) - - -def _coerce_bool(value: Any) -> bool: - """将接口返回的 bool / int / str 统一归一化为布尔值。""" - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return value != 0 - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "y", "on"}: - return True - if normalized in {"0", "false", "no", "n", "off", ""}: - return False - return bool(value) - - -def _coerce_int(value: Any, default: int = 0) -> int: - """尽力将接口返回值转为整数。""" - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _coerce_optional_int(value: Any) -> int | None: - """尽力将接口返回值转为整数,失败时返回 None。""" - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _coerce_dict(value: Any) -> dict[str, Any]: - """仅保留 dict 结果,其余统一视为空对象。""" - return value if isinstance(value, dict) else {} - - -def _coerce_dict_list(value: Any) -> list[dict[str, Any]]: - """将单个对象或列表中的 dict 条目安全归一化。""" - if isinstance(value, dict): - candidates = [value] - elif isinstance(value, (list, tuple)): - candidates = list(value) - else: - return [] - return [item for item in candidates if isinstance(item, dict) and item] - - -def _coerce_str_list(value: Any) -> list[str]: - """仅保留非空字符串列表。""" - if isinstance(value, str): - return [value] if value else [] - if not isinstance(value, (list, tuple)): - return [] - return [item for item in value if isinstance(item, str) and item] - - -def _parse_model_list(value: Any, model: type[_ModelT]) -> list[_ModelT]: - """安全解析模型列表,跳过明显损坏的条目。""" - parsed: list[_ModelT] = [] - for item in _coerce_dict_list(value): - try: - parsed.append(model.model_validate(item)) - except ValidationError: - continue - return parsed - - -def _parse_model(value: Any, model: type[_ModelT]) -> _ModelT | None: - """安全解析单个模型,失败时返回 None。""" - try: - return model.model_validate(value) - except ValidationError: - return None - - -class _DictResultResponse(BaseModel): - """result 应为 dict 的响应基类。""" - - code: int = 0 - message: str = "" - result: dict[str, Any] = Field(default_factory=dict) - - @field_validator("result", mode="before") - @classmethod - def _normalize_result(cls, value: Any) -> dict[str, Any]: - return _coerce_dict(value) - - -class _ListResultResponse(BaseModel): - """result 应为列表的响应基类。""" - - code: int = 0 - message: str = "" - result: list[dict[str, Any]] = Field(default_factory=list) - - @field_validator("result", mode="before") - @classmethod - def _normalize_result(cls, value: Any) -> list[dict[str, Any]]: - return _coerce_dict_list(value) - - -# region Token 持久化 -class AuthToken(BaseModel): - """登录凭证,可序列化用于持久化存储。 - - Attributes: - user_id: 小米用户 ID (userId)。 - c_user_id: cUserId(cookie 认证用)。 - service_token: serviceToken(cookie 认证用)。 - ssecurity: 加密密钥(RC4 加解密用)。 - pass_token: passToken(可用于 STS 交换)。 - device_id: 设备标识符。 - """ - - user_id: str = "" - c_user_id: str = "" - service_token: str = "" - ssecurity: str = "" - pass_token: str = "" - device_id: str = "" - - -# endregion - - -# region 亲友 -class FamilyMember(BaseModel): - """亲友信息(来自 get_relative_list 响应)。 - - Attributes: - relative_uid: 亲友的小米用户 UID(整数)。 - relative_note: 备注名。 - relative_icon: 头像 URL。 - latest_data_time: 最新数据时间戳。 - latest_abnormal_record_time: 最新异常记录时间戳。 - source_tag: 来源标记。 - """ - - relative_uid: int - relative_note: str = "" - relative_icon: str = "" - latest_data_time: int = 0 - latest_abnormal_record_time: int | None = 0 - source_tag: int = 0 - - def __str__(self) -> str: - return f"{self.relative_note or '未命名'} (UID: {self.relative_uid})" - - -# endregion - - -# region 最新心率 -class LatestHeartRate(BaseModel): - """实时心率点(来自心率聚合数据或 get_latest_data 的 latest_hr 字段)。 - - Attributes: - bpm: 心率(次/分)。 - time: 采集时间戳。 - """ - - bpm: int = 0 - time: int = 0 - - -# endregion - - -# region 心率 -class HeartRateData(BaseModel): - """心率每日汇总(来自 get_aggregated_data key=heart_rate)。 - - Attributes: - time: 数据时间戳(当天 0 点)。 - avg_hr: 日均心率。 - avg_rhr: 日均静息心率。 - max_hr: 最大心率。 - min_hr: 最小心率。 - latest_hr: 最新一次心率采样。 - abnormal_hr_count: 异常心率次数。 - aerobic_hr_zone_duration: 有氧心率区间时长(分钟)。 - anaerobic_hr_zone_duration: 无氧心率区间时长(分钟)。 - extreme_hr_zone_duration: 极限心率区间时长(分钟)。 - fat_burning_hr_zone_duration: 燃脂心率区间时长(分钟)。 - warm_up_hr_zone_duration: 热身心率区间时长(分钟)。 - """ - - time: int = 0 - avg_hr: int = 0 - avg_rhr: int = 0 - max_hr: int = 0 - min_hr: int = 0 - latest_hr: LatestHeartRate | None = None - abnormal_hr_count: int = 0 - aerobic_hr_zone_duration: int = 0 - anaerobic_hr_zone_duration: int = 0 - extreme_hr_zone_duration: int = 0 - fat_burning_hr_zone_duration: int = 0 - warm_up_hr_zone_duration: int = 0 - - def __str__(self) -> str: - return ( - f"HeartRate(avg={self.avg_hr}bpm, resting={self.avg_rhr}, " - f"range={self.min_hr}-{self.max_hr})" - ) - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -# endregion - - -# region 睡眠片段 -class SleepSegment(BaseModel): - """睡眠片段(来自 sleep value 的 segment_details)。 - - Attributes: - bedtime: 入睡时间戳。 - wake_up_time: 醒来时间戳。 - duration: 持续时长(分钟)。 - sleep_deep_duration: 深睡时长(分钟)。 - sleep_light_duration: 浅睡时长(分钟)。 - timezone: 时区偏移。 - awake_count: 醒来次数。 - sleep_awake_duration: 清醒时长(分钟)。 - """ - - bedtime: int = 0 - wake_up_time: int = 0 - duration: int = 0 - sleep_deep_duration: int = 0 - sleep_light_duration: int = 0 - timezone: int = 0 - awake_count: int = 0 - sleep_awake_duration: int = 0 - - -# endregion - - -# region 睡眠 -class SleepData(BaseModel): - """睡眠每日汇总(来自 get_aggregated_data key=sleep)。 - - Attributes: - time: 数据时间戳。 - total_duration: 总睡眠时长(分钟)。 - sleep_score: 睡眠评分(0-100)。 - sleep_stage: 睡眠阶段数。 - sleep_deep_duration: 深睡时长(分钟)。 - sleep_light_duration: 浅睡时长(分钟)。 - sleep_rem_duration: REM 时长(分钟)。 - sleep_awake_duration: 清醒时长(分钟)。 - long_sleep_evaluation: 长期睡眠评估。 - day_sleep_evaluation: 日间小睡评估。 - avg_hr: 睡眠平均心率。 - max_hr: 睡眠最大心率。 - min_hr: 睡眠最小心率。 - avg_spo2: 睡眠平均血氧。 - segment_details: 睡眠片段列表。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - total_duration: int = 0 - sleep_score: int = 0 - sleep_stage: int = 0 - sleep_deep_duration: int = 0 - sleep_light_duration: int = 0 - sleep_rem_duration: int = 0 - sleep_awake_duration: int = 0 - long_sleep_evaluation: int = 0 - day_sleep_evaluation: int = 0 - avg_hr: int = 0 - max_hr: int = 0 - min_hr: int = 0 - avg_spo2: int = 0 - segment_details: list[SleepSegment] = Field(default_factory=list) - - def __str__(self) -> str: - return ( - f"Sleep({self.total_duration}min, score={self.sleep_score}/100, " - f"deep={self.sleep_deep_duration}min, light={self.sleep_light_duration}min, " - f"rem={self.sleep_rem_duration}min)" - ) - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -# endregion - - -# region 步数 -class StepData(BaseModel): - """步数每日汇总(来自 get_aggregated_data key=steps)。 - - Attributes: - time: 数据时间戳。 - steps: 步数。 - distance: 距离(米)。 - calories: 消耗卡路里。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - steps: int = 0 - distance: int = 0 - calories: int = 0 - goal: int = 0 - - def __str__(self) -> str: - return f"Steps({self.steps}步, {self.distance}m, {self.calories}cal)" - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -# endregion - - -# region 体重 -class WeightData(BaseModel): - """体重数据(来自 get_latest_data / get_fitness_data key=weight)。 - - Attributes: - time: 数据时间戳。 - weight: 体重(千克)。 - bmi: BMI 指数。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - weight: float = 0.0 - bmi: float = 0.0 - - def __str__(self) -> str: - return f"Weight({self.weight}kg, BMI={self.bmi})" - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -# endregion - - -# region 血压 -class BloodPressureData(BaseModel): - """血压数据(来自 get_latest_data / get_fitness_data key=blood_pressure)。 - - Attributes: - time: 数据时间戳。 - systolic: 收缩压(高压 mmHg)。 - diastolic: 舒张压(低压 mmHg)。 - pulse: 脉搏(bpm)。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - systolic: int = Field(default=0, validation_alias=AliasChoices("systolic", "systolic_pressure")) - diastolic: int = Field( - default=0, validation_alias=AliasChoices("diastolic", "diastolic_pressure") - ) - pulse: int | None = None - - def __str__(self) -> str: - base = f"BloodPressure({self.systolic}/{self.diastolic} mmHg)" - return f"{base}, pulse={self.pulse}" if self.pulse is not None else base - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -# endregion - - -# region 最新快照指标 -class GoalMetric(IntEnum): - """活力指标中的单项目标类型。""" - - STEPS = 1 - CALORIES = 2 - INTENSITY = 4 - - @classmethod - def from_field(cls, field: int) -> GoalMetric | None: - """将接口返回的 field 编号映射为已知目标类型。""" - try: - return cls(field) - except ValueError: - return None - - @property - def key(self) -> str: - """稳定的英文键名,适合代码分支和序列化。""" - return { - GoalMetric.STEPS: "steps", - GoalMetric.CALORIES: "calories", - GoalMetric.INTENSITY: "intensity", - }[self] - - @property - def label(self) -> str: - """与 App 文案接近的展示名称。""" - return { - GoalMetric.STEPS: "步数", - GoalMetric.CALORIES: "卡路里", - GoalMetric.INTENSITY: "中高强度", - }[self] - - -class GoalItem(BaseModel): - """单个健康目标条目。 - - Attributes: - field: 目标类型编号。 - target_value: 目标值。 - achieved_value: 已完成值。 - """ - - field: int = 0 - target_value: int | float = 0 - achieved_value: int | float = 0 - - @property - def metric(self) -> GoalMetric | None: - """已知目标类型;未知 field 返回 None。""" - return GoalMetric.from_field(self.field) - - @property - def metric_key(self) -> str: - """目标键名;未知类型保留 field 以便继续排查。""" - metric = self.metric - return metric.key if metric is not None else f"unknown:{self.field}" - - @property - def metric_label(self) -> str: - """目标展示名;未知类型保留原始编号。""" - metric = self.metric - return metric.label if metric is not None else f"未知目标({self.field})" - - -class GoalData(BaseModel): - """每日目标完成情况(来自 get_latest_data key=goal)。 - - Attributes: - time: 目标所属日期时间戳。 - goal_items: 当日所有目标项。 - - 便捷属性: - ``steps_goal`` / ``calories_goal`` / ``intensity_goal`` 会返回对应的目标条目。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - goal_items: list[GoalItem] = Field(default_factory=list) - - @field_validator("goal_items", mode="before") - @classmethod - def _normalize_goal_items(cls, value: Any) -> list[GoalItem]: - return _parse_model_list(value, GoalItem) - - def __str__(self) -> str: - return f"GoalData({len(self.goal_items)} items)" - - @cached_property - def items_by_field(self) -> dict[int, GoalItem]: - """按原始 field 编号索引目标项。""" - return {item.field: item for item in self.goal_items} - - @property - def available_metrics(self) -> list[GoalMetric]: - """当前响应中出现的已知目标类型。""" - metrics: list[GoalMetric] = [] - for item in self.goal_items: - if item.metric is not None: - metrics.append(item.metric) - return metrics - - @property - def unknown_goal_items(self) -> list[GoalItem]: - """当前响应中未识别的目标项。""" - return [item for item in self.goal_items if item.metric is None] - - def get_item(self, metric: GoalMetric | int) -> GoalItem | None: - """按目标类型读取对应条目。""" - return self.items_by_field.get(int(metric)) - - @property - def steps_goal(self) -> GoalItem | None: - """步数目标。""" - return self.get_item(GoalMetric.STEPS) - - @property - def calories_goal(self) -> GoalItem | None: - """卡路里目标。""" - return self.get_item(GoalMetric.CALORIES) - - @property - def intensity_goal(self) -> GoalItem | None: - """中高强度活动目标。""" - return self.get_item(GoalMetric.INTENSITY) - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -class CaloriesData(BaseModel): - """每日活动卡路里(来自 get_latest_data key=calories)。 - - Attributes: - time: 数据时间戳。 - calories: 已消耗活动卡路里。 - goal: 卡路里目标值。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - calories: int = 0 - goal: int = 0 - - def __str__(self) -> str: - return f"Calories({self.calories} cal, goal={self.goal})" - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -class ValidStandData(BaseModel): - """每日有效站立次数(来自 get_latest_data key=valid_stand)。 - - Attributes: - time: 数据时间戳。 - count: 有效站立次数。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - count: int = 0 - - def __str__(self) -> str: - return f"ValidStand({self.count})" - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -class IntensityData(BaseModel): - """每日中高强度活动时长(来自 get_latest_data key=intensity)。 - - Attributes: - time: 数据时间戳。 - duration: 中高强度活动时长(分钟)。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - duration: int = 0 - - def __str__(self) -> str: - return f"Intensity({self.duration} min)" - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -class Spo2Data(BaseModel): - """最新血氧数据(来自 get_latest_data key=spo2)。 - - Attributes: - time: 测量时间戳。 - spo2: 血氧百分比。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - spo2: int = 0 - - def __str__(self) -> str: - return f"Spo2({self.spo2}%)" - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -class Spo2SummaryData(BaseModel): - """每日血氧摘要(来自 get_aggregated_data key=spo2)。 - - Attributes: - time: 数据时间戳。 - avg_spo2: 平均血氧。 - max_spo2: 最高血氧。 - min_spo2: 最低血氧。 - lack_spo2_count: 低血氧次数。 - latest_spo2: 当日最近一次血氧采样。 - """ - - time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) - avg_spo2: int = 0 - max_spo2: int = 0 - min_spo2: int = 0 - lack_spo2_count: int = 0 - latest_spo2: Spo2Data | None = None - - def __str__(self) -> str: - return ( - f"Spo2Summary(avg={self.avg_spo2}%, range={self.min_spo2}-{self.max_spo2}, " - f"lack={self.lack_spo2_count})" - ) - - @property - def at(self) -> datetime | None: - """数据时间(UTC datetime)。""" - return _ts_to_datetime(self.time) - - -# endregion - - -# region 用户验证 -class VerifiedUserInfo(BaseModel): - """verify_userinfo_by_id 响应中的用户信息。 - - Attributes: - user_id: 小米用户 UID。 - nickname: 昵称。 - icon: 头像 URL。 - """ - - user_id: int = Field(alias="userId", default=0) - nickname: str = "" - icon: str = "" - - model_config = {"populate_by_name": True} - - -# endregion - - -# region 最新数据项 -class LatestDataItem(BaseModel): - """get_latest_data 响应中的单条数据项。 - - value 字段是 JSON 字符串,需要根据 key 解析为对应类型。 - - Attributes: - time: 数据时间戳。 - key: 数据类型(heart_rate / sleep / steps / weight / blood_pressure 等)。 - value: JSON 字符串或数值。 - """ - - time: int = 0 - key: str = "" - value: str | int | float = "" - - @field_validator("value", mode="before") - @classmethod - def _normalize_value(cls, value: Any) -> str | int | float: - """确保 dict/list 形式的 value 也能被统一解析。""" - if isinstance(value, (dict, list)): - return json.dumps(value, ensure_ascii=False) - if isinstance(value, (str, int, float)): - return value - return "" - - def parse_value(self) -> dict[str, Any] | int | float: - """将 value 字段从 JSON 字符串解析为字典。 - - Returns: - 解析后的字典或原始数值。 - """ - if isinstance(self.value, (int, float)): - return self.value - try: - parsed = json.loads(self.value) - except (json.JSONDecodeError, TypeError): - return {} - if isinstance(parsed, (dict, int, float)): - return parsed - return {} - - def _parse_dict_value(self) -> dict[str, Any]: - """将 value 解析为字典,失败时返回空对象。""" - parsed = self.parse_value() - return parsed if isinstance(parsed, dict) else {} - - def as_goal(self) -> GoalData | None: - """解析为目标完成数据。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, GoalData) - - def as_heart_rate(self) -> LatestHeartRate | None: - """解析为最新一次心率采样。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, LatestHeartRate) - - def as_sleep(self) -> SleepData | None: - """解析为最新睡眠摘要。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - segments = data.pop("segment_details", []) - data["segment_details"] = _parse_model_list(segments, SleepSegment) - return _parse_model(data, SleepData) - - def as_steps(self) -> StepData | None: - """解析为最新步数摘要。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, StepData) - - def as_weight(self) -> WeightData | None: - """解析为最新体重。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, WeightData) - - def as_blood_pressure(self) -> BloodPressureData | None: - """解析为最新血压。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, BloodPressureData) - - def as_calories(self) -> CaloriesData | None: - """解析为最新卡路里摘要。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, CaloriesData) - - def as_valid_stand(self) -> ValidStandData | None: - """解析为最新有效站立统计。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, ValidStandData) - - def as_intensity(self) -> IntensityData | None: - """解析为最新中高强度活动时长。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, IntensityData) - - def as_spo2(self) -> Spo2Data | None: - """解析为最新血氧。""" - data = self._parse_dict_value() - if not data: - return None - data.setdefault("time", self.time) - return _parse_model(data, Spo2Data) - - -# endregion - - -# region 聚合数据项 -class AggregatedDataItem(BaseModel): - """get_aggregated_data 响应中的单条数据项。 - - Attributes: - sid: 数据来源 SID。 - tag: 数据标签(如 daily_report)。 - key: 数据类型。 - time: 数据时间戳。 - value: JSON 字符串值。 - update_time: 更新时间戳。 - watermark: 水印(增量同步用)。 - source_sid_list: 数据来源列表。 - """ - - sid: str = "" - tag: str = "" - key: str = "" - time: int = 0 - value: str = "" - update_time: int = 0 - watermark: str = "" - source_sid_list: list[str] = Field(default_factory=list) - - @field_validator("watermark", mode="before") - @classmethod - def _stringify_watermark(cls, v: Any) -> str: - """API 有时返回 int 类型的 watermark。""" - return str(v) if v is not None else "" - - @field_validator("value", mode="before") - @classmethod - def _stringify_value(cls, v: Any) -> str: - """确保 value 始终是字符串(API 可能返回 dict)。""" - if isinstance(v, (dict, list)): - return json.dumps(v, ensure_ascii=False) - return str(v) if v is not None else "" - - def parse_value(self) -> dict[str, Any]: - """将 value 字段从 JSON 字符串解析为字典。""" - try: - parsed = json.loads(self.value) - except (json.JSONDecodeError, TypeError): - return {} - return parsed if isinstance(parsed, dict) else {} - - def as_heart_rate(self) -> HeartRateData: - """解析为心率数据。""" - data = self.parse_value() - data["time"] = self.time - latest = data.get("latest_hr") - if isinstance(latest, dict): - data["latest_hr"] = LatestHeartRate.model_validate(latest) - elif latest is not None: - data["latest_hr"] = None - return HeartRateData.model_validate(data) - - def as_sleep(self) -> SleepData: - """解析为睡眠数据。""" - data = self.parse_value() - data["time"] = self.time - segments = data.pop("segment_details", []) - data["segment_details"] = _parse_model_list(segments, SleepSegment) - return SleepData.model_validate(data) - - def as_steps(self) -> StepData: - """解析为步数数据。""" - data = self.parse_value() - data["time"] = self.time - return StepData.model_validate(data) - - def as_weight(self) -> WeightData: - """解析为体重历史数据。""" - data = self.parse_value() - data["time"] = self.time - return WeightData.model_validate(data) - - def as_blood_pressure(self) -> BloodPressureData: - """解析为血压历史数据。""" - data = self.parse_value() - data["time"] = self.time - return BloodPressureData.model_validate(data) - - def as_calories(self) -> CaloriesData: - """解析为卡路里数据。""" - data = self.parse_value() - data["time"] = self.time - return CaloriesData.model_validate(data) - - def as_valid_stand(self) -> ValidStandData: - """解析为有效站立数据。""" - data = self.parse_value() - data["time"] = self.time - return ValidStandData.model_validate(data) - - def as_intensity(self) -> IntensityData: - """解析为中高强度活动时长数据。""" - data = self.parse_value() - data["time"] = self.time - return IntensityData.model_validate(data) - - def as_spo2(self) -> Spo2SummaryData: - """解析为血氧摘要数据。""" - data = self.parse_value() - data["time"] = self.time - latest = data.get("latest_spo2") - if isinstance(latest, dict): - data["latest_spo2"] = _parse_model(latest, Spo2Data) - elif latest is not None: - data["latest_spo2"] = None - return Spo2SummaryData.model_validate(data) - - -# endregion - - -# region 每日摘要 -class DailySummary(BaseModel): - """每日健康数据摘要(由 get_daily_summary 返回)。 - - Attributes: - date: 查询日期(ISO 格式)。 - relative_uid: 亲友 UID。 - heart_rate: 心率汇总数据。 - sleep: 睡眠汇总数据。 - steps: 步数汇总数据。 - """ - - date: str = "" - relative_uid: int = 0 - heart_rate: HeartRateData | None = None - sleep: SleepData | None = None - steps: StepData | None = None - - def __str__(self) -> str: - parts = [f"DailySummary({self.date}, UID={self.relative_uid}"] - if self.heart_rate: - parts.append(f"hr={self.heart_rate.avg_hr}bpm") - if self.sleep: - parts.append(f"sleep={self.sleep.total_duration}min") - if self.steps: - parts.append(f"steps={self.steps.steps}") - return ", ".join(parts) + ")" - - -# endregion - - -# region 最新快照 -class LatestDataSnapshot(BaseModel): - """最新健康快照。 - - 将 get_latest_data 的异构 data_list 收敛为固定字段,未知 key 或解析失败的 payload - 保留到 extras,避免上层因为单条脏数据失去整份快照。 - """ - - updated_time: int = 0 - goal: GoalData | None = None - heart_rate: LatestHeartRate | None = None - sleep: SleepData | None = None - blood_pressure: BloodPressureData | None = None - steps: StepData | None = None - calories: CaloriesData | None = None - valid_stand: ValidStandData | None = None - intensity: IntensityData | None = None - weight: WeightData | None = None - spo2: Spo2Data | None = None - extras: dict[str, dict[str, Any] | int | float] = Field(default_factory=dict) - - @classmethod - def from_items( - cls, - items: list[LatestDataItem], - *, - updated_time: int = 0, - ) -> LatestDataSnapshot: - """从原始 data_list 构建类型化快照。""" - payload: dict[str, Any] = {"updated_time": updated_time} - extras: dict[str, dict[str, Any] | int | float] = {} - parsers = { - "goal": LatestDataItem.as_goal, - "heart_rate": LatestDataItem.as_heart_rate, - "sleep": LatestDataItem.as_sleep, - "blood_pressure": LatestDataItem.as_blood_pressure, - "steps": LatestDataItem.as_steps, - "calories": LatestDataItem.as_calories, - "valid_stand": LatestDataItem.as_valid_stand, - "intensity": LatestDataItem.as_intensity, - "weight": LatestDataItem.as_weight, - "spo2": LatestDataItem.as_spo2, - } - - for item in items: - parser = parsers.get(item.key) - raw_value = item.parse_value() - if parser is None: - extras[item.key] = raw_value - continue - - parsed = parser(item) - if parsed is not None: - payload[item.key] = parsed - continue - - if raw_value not in ({}, "", 0, 0.0): - extras[item.key] = raw_value - - return cls(**payload, extras=extras) - - def __str__(self) -> str: - keys = ", ".join(self.available_keys) or "empty" - return f"LatestDataSnapshot({keys})" - - @property - def at(self) -> datetime | None: - """快照更新时间(UTC datetime)。""" - return _ts_to_datetime(self.updated_time) - - @property - def available_keys(self) -> list[str]: - """当前快照中可用的数据键。""" - known_keys = [ - "goal", - "heart_rate", - "sleep", - "blood_pressure", - "steps", - "calories", - "valid_stand", - "intensity", - "weight", - "spo2", - ] - keys = [key for key in known_keys if getattr(self, key) is not None] - keys.extend(sorted(self.extras)) - return keys - - -# endregion - - -# region API 响应包装 -class RelativeListResponse(_DictResultResponse): - """get_relative_list 响应。""" - - @property - def relatives(self) -> list[FamilyMember]: - """解析亲友列表。""" - return _parse_model_list(self.result.get("relative_list"), FamilyMember) - - -class LatestDataResponse(_DictResultResponse): - """get_latest_data 响应。""" - - @property - def data_items(self) -> list[LatestDataItem]: - """解析数据项列表。""" - return _parse_model_list(self.result.get("data_list"), LatestDataItem) - - @property - def latest_data_time(self) -> int: - """最新数据更新时间。""" - return _coerce_int(self.result.get("latest_data_time"), default=0) - - @property - def snapshot(self) -> LatestDataSnapshot: - """解析为类型化快照。""" - return LatestDataSnapshot.from_items(self.data_items, updated_time=self.latest_data_time) - - -class AggregatedDataResponse(_DictResultResponse): - """get_aggregated_data / get_fitness_data 响应。""" - - @property - def data_items(self) -> list[AggregatedDataItem]: - """解析数据项列表。""" - return _parse_model_list(self.result.get("data_list"), AggregatedDataItem) - - @property - def has_more(self) -> bool: - """是否有更多数据。""" - return _coerce_bool(self.result.get("has_more", False)) - - @property - def next_key(self) -> str: - """下一页的起始 key。""" - value = self.result.get("next_key", "") - return str(value) if value is not None else "" - - -# endregion - - -# region 亲友管理 API 响应 -class VerifyUserResponse(_DictResultResponse): - """verify_userinfo_by_id 响应。""" - - @property - def user_info(self) -> VerifiedUserInfo | None: - """解析用户信息,未找到时返回 None。""" - if not self.result or not self.result.get("userId"): - return None - return VerifiedUserInfo.model_validate(self.result) - - -class InviteResponse(_DictResultResponse): - """send_invite 响应。""" - - @property - def success(self) -> bool: - """邀请是否发送成功。""" - return _coerce_int(self.result.get("send_ret"), default=0) == 1 - - -class OperateInviteResponse(_DictResultResponse): - """operate_invite 响应(同意/拒绝邀请)。""" - - @property - def success(self) -> bool: - """操作是否成功。""" - return _coerce_bool(self.result.get("operate_ret")) - - -class DeleteRelativeResponse(_DictResultResponse): - """delete_relative 响应。""" - - @property - def success(self) -> bool: - """删除是否成功。""" - return _coerce_bool(self.result.get("delete_ret")) - - -class SharedDataTypesResponse(_DictResultResponse): - """get_shared_data_types 响应。""" - - @property - def keys(self) -> list[str]: - """可共享的数据类型列表。""" - return _coerce_str_list(self.result.get("keys")) - - -class InviteUniqueIdResponse(_DictResultResponse): - """get_invite_unique_id 响应。""" - - @property - def invite_link_id(self) -> int: - """二维码邀请链接 ID。""" - return _coerce_int(self.result.get("invite_link_id"), default=0) - - -class FamilyMemberResponse(_DictResultResponse): - """get_family_member 响应。""" - - @property - def family_user_list(self) -> list[dict[str, Any]]: - """家庭成员列表(原始字典)。""" - return _coerce_dict_list(self.result.get("family_user_list")) - - -# endregion - - -# region 消息 -class InviteMessage(BaseModel): - """亲友邀请消息(来自 get_msg_list 响应)。 - - Attributes: - msg_id: 消息 ID(operate_invite 用)。 - module: 消息模块(1=亲友)。 - type: 消息类型(1=待处理邀请,5=历史通知)。 - receiver: 接收方 UID。 - sender: 发送方 UID。 - extra_data: JSON 字符串,包含 invite_id、nick_name、icon 等。 - is_new: 是否新消息。 - data_status: 数据状态(0=待处理,1=已处理)。 - create_time: 创建时间戳。 - """ - - msg_id: int = 0 - module: int = 0 - type: int = 0 - receiver: int = 0 - sender: int = 0 - extra_data: str = "" - is_new: int = 0 - data_status: int = 0 - create_time: int = 0 - last_modify: int = 0 - - @field_validator("extra_data", mode="before") - @classmethod - def _normalize_extra_data(cls, value: Any) -> str: - """确保 extra_data 在解析前始终是 JSON 字符串。""" - if isinstance(value, (dict, list)): - return json.dumps(value, ensure_ascii=False) - return str(value) if value is not None else "" - - @cached_property - def _parsed_extra(self) -> dict[str, Any]: - """解析 extra_data JSON(缓存结果)。""" - try: - parsed = json.loads(self.extra_data) - except (json.JSONDecodeError, TypeError): - return {} - return parsed if isinstance(parsed, dict) else {} - - @property - def invite_id(self) -> int | None: - """解析 extra_data 中的 invite_id(仅 type=1 时存在)。""" - return _coerce_optional_int(self._parsed_extra.get("invite_id")) - - @property - def nick_name(self) -> str: - """解析 extra_data 中的昵称。""" - return self._parsed_extra.get("nick_name", "") - - @property - def icon(self) -> str: - """解析 extra_data 中的头像 URL。""" - return self._parsed_extra.get("icon", "") - - @property - def is_pending(self) -> bool: - """是否为待处理的邀请。""" - return self.type == 1 and self.data_status == 0 - - -class MessageListResponse(_DictResultResponse): - """get_msg_list 响应。""" - - @property - def messages(self) -> list[InviteMessage]: - """消息列表。""" - return _parse_model_list(self.result.get("messages"), InviteMessage) - - @property - def msg_total(self) -> int: - """消息总数。""" - return _coerce_int(self.result.get("msg_total"), default=0) - - -class CheckNewMsgResponse(_ListResultResponse): - """check_new_msg 响应。""" - - def has_new(self, module: int = 1) -> bool: - """指定 module 是否有新消息。""" - for item in self.result: - if _coerce_int(item.get("module"), default=0) == module and _coerce_bool( - item.get("is_new") - ): - return True - return False - - -# endregion diff --git a/mi-fitness-python/tests/__init__.py b/mi-fitness-python/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/mi-fitness-python/tests/unit/__init__.py b/mi-fitness-python/tests/unit/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/mi-fitness-python/tests/unit/conftest.py b/mi-fitness-python/tests/unit/conftest.py deleted file mode 100644 index 3c25094..0000000 --- a/mi-fitness-python/tests/unit/conftest.py +++ /dev/null @@ -1,355 +0,0 @@ -"""conftest.py —— pytest 公共 fixture。""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from mi_fitness.auth import XiaomiAuth -from mi_fitness.models import AuthToken - - -@pytest.fixture -def ssecurity() -> str: - """测试用 ssecurity(base64 编码的 16 字节密钥)。""" - return "56nienlY7Ayh4VVJ0ywGGg==" - - -@pytest.fixture -def auth_token(ssecurity: str) -> AuthToken: - """构造一个已登录的 AuthToken。""" - return AuthToken( - user_id="123456", - c_user_id="c_test_user", - service_token="test_service_token", - ssecurity=ssecurity, - pass_token="test_pass_token", - device_id="an_test_device", - ) - - -@pytest.fixture -def mock_auth(auth_token: AuthToken) -> XiaomiAuth: - """构造一个已登录的 mock XiaomiAuth 实例。""" - auth = XiaomiAuth.__new__(XiaomiAuth) - auth.username = "test" - auth._password = "" - auth.token = auth_token - auth._http = MagicMock() - auth._ticket_token = "" - auth._token_path = None - return auth - - -# region 常用 API 响应模板 -RELATIVE_LIST_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "relative_list": [ - { - "relative_uid": 1452722403, - "relative_note": "妈妈", - "relative_icon": "https://example.com/avatar.jpg", - "latest_data_time": 1717488000, - "latest_abnormal_record_time": 0, - "source_tag": 1, - }, - { - "relative_uid": 9876543210, - "relative_note": "爸爸", - "relative_icon": "", - "latest_data_time": 1717484400, - "latest_abnormal_record_time": 0, - "source_tag": 1, - }, - ] - }, -} - -VERIFY_USER_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "userId": 1452722403, - "nickname": "测试用户", - "icon": "https://example.com/icon.jpg", - }, -} - -INVITE_RESPONSE = { - "code": 0, - "message": "ok", - "result": {"send_ret": 1}, -} - -DELETE_RESPONSE = { - "code": 0, - "message": "ok", - "result": {"delete_ret": True}, -} - -OPERATE_INVITE_RESPONSE = { - "code": 0, - "message": "ok", - "result": {"operate_ret": True}, -} - -INVITE_ID_RESPONSE = { - "code": 0, - "message": "ok", - "result": {"invite_link_id": 467184968352742400}, -} - -SHARED_TYPES_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "keys": ["goal", "heart_rate", "sleep", "steps"], - }, -} - -LATEST_DATA_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "data_list": [ - { - "time": 1717488000, - "key": "goal", - "value": ( - '{"date_time":1717488000,"goal_items":[' - '{"field":2,"target_value":400,"achieved_value":13},' - '{"field":1,"target_value":6000,"achieved_value":3716}' - "]}" - ), - }, - { - "time": 1717488000, - "key": "heart_rate", - "value": '{"time":1717491600,"bpm":84}', - }, - { - "time": 1717488000, - "key": "sleep", - "value": ( - '{"date_time":1717488000,"total_duration":436,' - '"sleep_stage":3,"sleep_score":86,' - '"long_sleep_evaluation":7,"day_sleep_evaluation":0}' - ), - }, - { - "time": 1717488000, - "key": "steps", - "value": '{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}', - }, - { - "time": 1717488000, - "key": "calories", - "value": '{"date_time":1717488000,"calories":230,"goal":300}', - }, - { - "time": 1717488000, - "key": "valid_stand", - "value": '{"date_time":1717488000,"count":7}', - }, - { - "time": 1717488000, - "key": "intensity", - "value": '{"date_time":1717488000,"duration":15}', - }, - { - "time": 1717488000, - "key": "weight", - "value": '{"time":1717488000,"weight":65.5,"bmi":22.1}', - }, - { - "time": 1717488000, - "key": "spo2", - "value": '{"time":1717495200,"spo2":96}', - }, - { - "key": "blood_pressure", - }, - ], - "latest_data_time": 1717495200, - }, -} - -AGGREGATED_HR_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "data_list": [ - { - "sid": "miothealth", - "tag": "daily_report", - "key": "heart_rate", - "time": 1717430400, - "value": ( - '{"avg_hr":72,"avg_rhr":62,"max_hr":120,"min_hr":55,' - '"latest_hr":{"bpm":75,"time":1717488000},' - '"abnormal_hr_count":0,' - '"aerobic_hr_zone_duration":30,' - '"anaerobic_hr_zone_duration":0,' - '"extreme_hr_zone_duration":0,' - '"fat_burning_hr_zone_duration":15,' - '"warm_up_hr_zone_duration":10}' - ), - "update_time": 1717488000, - "watermark": "w1", - "source_sid_list": [], - } - ], - "has_more": False, - }, -} - -AGGREGATED_SLEEP_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "data_list": [ - { - "sid": "miothealth", - "tag": "daily_report", - "key": "sleep", - "time": 1717430400, - "value": ( - '{"total_duration":480,"sleep_score":85,"sleep_stage":4,' - '"sleep_deep_duration":120,"sleep_light_duration":200,' - '"sleep_rem_duration":100,"sleep_awake_duration":60,' - '"long_sleep_evaluation":1,"day_sleep_evaluation":0,' - '"avg_hr":60,"max_hr":80,"min_hr":50,"avg_spo2":97,' - '"segment_details":[{"bedtime":1717365600,"wake_up_time":1717394400,' - '"duration":480,"sleep_deep_duration":120,"sleep_light_duration":200,' - '"timezone":28800,"awake_count":2,"sleep_awake_duration":60}]}' - ), - "update_time": 1717488000, - "watermark": "w2", - "source_sid_list": [], - } - ], - "has_more": False, - }, -} - -AGGREGATED_STEPS_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "data_list": [ - { - "sid": "miothealth", - "tag": "daily_report", - "key": "steps", - "time": 1717430400, - "value": '{"steps":8500,"distance":6200,"calories":320}', - "update_time": 1717488000, - "watermark": "w3", - "source_sid_list": [], - } - ], - "has_more": False, - }, -} - -FITNESS_WEIGHT_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "data_list": [ - { - "sid": "xiaomiwear_app_manually", - "tag": "", - "key": "weight", - "time": 1773753142, - "value": '{"bmi":16.97531,"time":1773753142,"weight":55.0}', - "update_time": 1773753142, - "watermark": "w5", - "source_sid_list": [], - } - ], - "has_more": False, - }, -} - -FITNESS_BLOOD_PRESSURE_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "data_list": [ - { - "sid": "xiaomiwear_app_manually", - "tag": "", - "key": "blood_pressure", - "time": 1773753098, - "value": ( - '{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}' - ), - "update_time": 1773753098, - "watermark": "w6", - "source_sid_list": [], - } - ], - "has_more": False, - }, -} - -FAMILY_MEMBER_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "family_user_list": [ - {"userId": 123, "nickname": "家人1"}, - ] - }, -} - -MESSAGE_LIST_RESPONSE = { - "code": 0, - "message": "ok", - "result": { - "messages": [ - { - "msg_id": 152824796151809, - "module": 1, - "type": 1, - "receiver": 3188565001, - "sender": 1452722403, - "extra_data": '{"invite_id":4777767,"auth_data":["heart_rate","sleep"],"nick_name":"测试用户","icon":"https://example.com/avatar.jpg"}', - "is_new": 2, - "data_status": 0, - "create_time": 1772628283, - "last_modify": 1772628283, - }, - { - "msg_id": 152821515157506, - "module": 1, - "type": 5, - "receiver": 3188565001, - "sender": 1452722403, - "extra_data": '{"nick_name":"测试用户","icon":"https://example.com/avatar.jpg"}', - "is_new": 2, - "data_status": 1, - "create_time": 1772625154, - "last_modify": 1772625154, - }, - ], - "msg_total": 2, - }, -} - -CHECK_NEW_MSG_RESPONSE = { - "code": 0, - "message": "ok", - "result": [{"module": 1, "is_new": True}], -} - -CHECK_NO_NEW_MSG_RESPONSE = { - "code": 0, - "message": "ok", - "result": [{"module": 1, "is_new": False}], -} -# endregion diff --git a/mi-fitness-python/tests/unit/test_auth.py b/mi-fitness-python/tests/unit/test_auth.py deleted file mode 100644 index 01682a9..0000000 --- a/mi-fitness-python/tests/unit/test_auth.py +++ /dev/null @@ -1,247 +0,0 @@ -"""测试认证模块的内部编排逻辑。""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest - -from mi_fitness.auth import XiaomiAuth -from mi_fitness.auth import manager as auth_manager -from mi_fitness.auth import passtoken as passtoken_module -from mi_fitness.auth import password as password_module -from mi_fitness.auth import qr as qr_module -from mi_fitness.exceptions import ( - AuthError, - CaptchaRequiredError, - DeviceUntrustedError, - TokenExpiredError, -) -from mi_fitness.models import AuthToken - -pytestmark = pytest.mark.asyncio - - -class _DummyResponse: - def __init__(self, text: str): - self.text = text - - def raise_for_status(self) -> None: - """测试响应默认视为成功。""" - - -def _http_response(text: str, status_code: int = 200) -> httpx.Response: - request = httpx.Request("GET", "https://example.com/test") - return httpx.Response(status_code=status_code, text=text, request=request) - - -def _make_auth() -> XiaomiAuth: - auth = XiaomiAuth("13800138000", "secret") - auth._http = MagicMock() - auth._http.cookies = MagicMock() - return auth - - -async def test_send_verification_code_retries_captcha_once(monkeypatch: pytest.MonkeyPatch) -> None: - auth = _make_auth() - - ensure_ready = AsyncMock() - send_ticket = AsyncMock( - side_effect=[ - CaptchaRequiredError("需要图形验证码", captcha_url="https://example.com/captcha.png"), - None, - ] - ) - get_phone_info = AsyncMock(return_value=("191******54", "ticket-token")) - fetch_captcha = AsyncMock(return_value=b"captcha-image") - captcha_handler = AsyncMock(return_value="ABCD") - - monkeypatch.setattr(auth_manager._pwd, "ensure_ticket_login_ready", ensure_ready) - monkeypatch.setattr(auth_manager._pwd, "send_ticket", send_ticket) - monkeypatch.setattr(auth_manager._pwd, "get_phone_info", get_phone_info) - monkeypatch.setattr(auth_manager._pwd, "fetch_captcha_image", fetch_captcha) - - phone = await auth.send_verification_code(captcha_handler=captcha_handler) - - assert phone == "191******54" - assert auth._ticket_token == "ticket-token" - ensure_ready.assert_awaited_once_with(auth._http) - assert [call.kwargs["captcha_code"] for call in send_ticket.await_args_list] == ["", "ABCD"] - get_phone_info.assert_awaited_once_with(auth._http, auth.username, captcha_code="") - fetch_captcha.assert_awaited_once_with(auth._http, "https://example.com/captcha.png") - captcha_handler.assert_awaited_once_with(b"captcha-image") - - -async def test_send_verification_code_stops_after_max_retries( - monkeypatch: pytest.MonkeyPatch, -) -> None: - auth = _make_auth() - - captcha_error = CaptchaRequiredError( - "需要图形验证码", - captcha_url="https://example.com/captcha.png", - ) - send_ticket = AsyncMock(side_effect=[captcha_error] * auth_manager._MAX_CAPTCHA_RETRIES) - get_phone_info = AsyncMock() - fetch_captcha = AsyncMock(return_value=b"captcha-image") - captcha_handler = AsyncMock(return_value="ABCD") - - monkeypatch.setattr(auth_manager._pwd, "ensure_ticket_login_ready", AsyncMock()) - monkeypatch.setattr(auth_manager._pwd, "send_ticket", send_ticket) - monkeypatch.setattr(auth_manager._pwd, "get_phone_info", get_phone_info) - monkeypatch.setattr(auth_manager._pwd, "fetch_captcha_image", fetch_captcha) - - with pytest.raises(AuthError, match="已连续重试 3 次"): - await auth.send_verification_code(captcha_handler=captcha_handler) - - assert send_ticket.await_count == auth_manager._MAX_CAPTCHA_RETRIES - assert fetch_captcha.await_count == auth_manager._MAX_CAPTCHA_RETRIES - assert captcha_handler.await_count == auth_manager._MAX_CAPTCHA_RETRIES - get_phone_info.assert_not_awaited() - - -async def test_login_passtoken_uses_shared_service_token_extractor( - monkeypatch: pytest.MonkeyPatch, -) -> None: - http = MagicMock() - http.cookies = MagicMock() - http.get = AsyncMock( - return_value=_DummyResponse( - '&&&START&&&{"ssecurity":"sec","location":"https://example.com/cb?foo=1","nonce":"nonce","cUserId":"cid"}' - ) - ) - extract_service_token = AsyncMock(return_value="service-token") - monkeypatch.setattr(passtoken_module, "extract_service_token", extract_service_token) - - token = AuthToken() - await passtoken_module.login_passtoken( - http, - token, - pass_token="pass-token", - user_id="user-id", - device_id="an_device", - ) - - assert token.ssecurity == "sec" - assert token.c_user_id == "cid" - assert token.service_token == "service-token" - extract_service_token.assert_awaited_once() - await_args = extract_service_token.await_args - assert await_args is not None - redirect_url = await_args.args[1] - assert redirect_url.startswith("https://example.com/cb?foo=1&clientSign=") - - -async def test_refresh_reuses_existing_token_and_persists(monkeypatch: pytest.MonkeyPatch) -> None: - auth = _make_auth() - auth.token = AuthToken( - user_id="user-id", - pass_token="pass-token", - device_id="an_device", - service_token="old-token", - ssecurity="old-sec", - ) - auth._token_path = Path("token.json") - save_token = MagicMock() - auth.save_token = save_token # type: ignore[method-assign] - - async def fake_login_passtoken(*args, **kwargs) -> None: - auth.token.service_token = "new-token" - auth.token.ssecurity = "new-sec" - - login_passtoken = AsyncMock(side_effect=fake_login_passtoken) - monkeypatch.setattr(auth_manager, "_pt", MagicMock(login_passtoken=login_passtoken)) - monkeypatch.setattr(auth_manager, "_sts", MagicMock(sts_exchange=AsyncMock())) - - token = await auth.refresh() - - assert token.service_token == "new-token" - assert token.ssecurity == "new-sec" - login_passtoken.assert_awaited_once_with( - auth._http, - auth.token, - pass_token="pass-token", - user_id="user-id", - device_id="an_device", - ) - save_token.assert_called_once_with(Path("token.json")) - - -async def test_refresh_requires_pass_token() -> None: - auth = _make_auth() - auth.token = AuthToken(user_id="user-id", pass_token="") - - with pytest.raises(TokenExpiredError, match="无法自动刷新"): - await auth.refresh() - - -async def test_password_login_wrong_password_raises_auth_error() -> None: - http = MagicMock() - http.post = AsyncMock( - return_value=_http_response('&&&START&&&{"code":70002,"desc":"invalid credential"}') - ) - - with pytest.raises(AuthError, match="登录失败"): - await password_module._raw_submit_login(http, "13800138000", "bad", "sign", "callback") - - -async def test_submit_login_requires_device_verification() -> None: - http = MagicMock() - token = AuthToken() - - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - password_module, - "_raw_submit_login", - AsyncMock(return_value={"code": 70016}), - ) - - with pytest.raises(DeviceUntrustedError, match="二次验证"): - await password_module.submit_login( - http, token, "13800138000", "secret", "sign", "callback" - ) - - -async def test_submit_login_requires_trusted_device_when_security_status_non_zero() -> None: - http = MagicMock() - token = AuthToken() - - with pytest.MonkeyPatch.context() as mp: - mp.setattr( - password_module, - "_raw_submit_login", - AsyncMock(return_value={"code": 0, "securityStatus": 8}), - ) - - with pytest.raises(DeviceUntrustedError, match="设备未受信任"): - await password_module.submit_login( - http, token, "13800138000", "secret", "sign", "callback" - ) - - -async def test_qr_login_times_out(monkeypatch: pytest.MonkeyPatch) -> None: - http = MagicMock() - http.get = AsyncMock( - return_value=_http_response( - '&&&START&&&{"qr":"https://example.com/qr.png","lp":"https://example.com/poll","timeout":0}' - ) - ) - time_values = iter([0.0, 0.0, 1.0]) - monkeypatch.setattr(qr_module.time, "time", lambda: next(time_values)) - - with pytest.raises(AuthError, match="二维码扫码超时"): - await qr_module.login_qr(http, AuthToken(), max_wait=0) - - -async def test_login_passtoken_requires_non_empty_inputs() -> None: - http = MagicMock() - http.cookies = MagicMock() - token = AuthToken() - - with pytest.raises(AuthError, match="passToken 不能为空"): - await passtoken_module.login_passtoken(http, token, pass_token="", user_id="uid") - - with pytest.raises(AuthError, match="userId 不能为空"): - await passtoken_module.login_passtoken(http, token, pass_token="pt", user_id="") diff --git a/mi-fitness-python/tests/unit/test_auth_helpers.py b/mi-fitness-python/tests/unit/test_auth_helpers.py deleted file mode 100644 index 49d3b1f..0000000 --- a/mi-fitness-python/tests/unit/test_auth_helpers.py +++ /dev/null @@ -1,193 +0,0 @@ -"""测试认证辅助函数与 STS 交换。""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest -from loguru import logger - -from mi_fitness.auth import _helpers as auth_helpers -from mi_fitness.auth import sts as auth_sts -from mi_fitness.exceptions import AuthError -from mi_fitness.models import AuthToken - - -def _response( - *, - text: str = "", - headers: dict[str, str] | None = None, -) -> httpx.Response: - request = httpx.Request("GET", "https://example.com/test") - return httpx.Response(200, text=text, headers=headers, request=request) - - -class _Cookie: - def __init__(self, name: str, domain: str, value: str): - self.name = name - self.domain = domain - self.value = value - - -def test_parse_mi_response_supports_start_prefix() -> None: - parsed = auth_helpers.parse_mi_response('&&&START&&&{"code":0,"desc":"ok"}') - assert parsed == {"code": 0, "desc": "ok"} - - -def test_parse_mi_response_raises_auth_error_for_invalid_json() -> None: - with pytest.raises(AuthError, match="响应解析失败"): - auth_helpers.parse_mi_response("&&&START&&¬-json") - - -def test_normalize_captcha_url_prepends_account_domain() -> None: - assert ( - auth_helpers.normalize_captcha_url("/pass/getCode?id=1") - == "https://account.xiaomi.com/pass/getCode?id=1" - ) - assert ( - auth_helpers.normalize_captcha_url("https://example.com/captcha") - == "https://example.com/captcha" - ) - - -def test_set_cookie_for_domains_writes_both_domains() -> None: - http = MagicMock() - http.cookies = MagicMock() - - auth_helpers.set_cookie_for_domains(http, "serviceToken", "st") - - assert http.cookies.set.call_count == 2 - assert http.cookies.set.call_args_list[0].args == ("serviceToken", "st") - assert http.cookies.set.call_args_list[0].kwargs["domain"] == "xiaomi.com" - assert http.cookies.set.call_args_list[1].kwargs["domain"] == "mi.com" - - -@pytest.mark.asyncio -async def test_extract_service_token_prefers_set_cookie_header() -> None: - http = MagicMock() - http.get = AsyncMock( - return_value=_response( - headers={ - "set-cookie": "serviceToken=header-token; Path=/; HttpOnly", - } - ) - ) - http.cookies.get.return_value = "" - - token = await auth_helpers.extract_service_token(http, "https://example.com/login") - - assert token == "header-token" - - -@pytest.mark.asyncio -async def test_extract_service_token_falls_back_to_redirect_query() -> None: - http = MagicMock() - http.get = AsyncMock( - return_value=_response( - headers={ - "location": "https://example.com/callback?serviceToken=query-token", - } - ) - ) - http.cookies.get.return_value = "" - - token = await auth_helpers.extract_service_token(http, "https://example.com/login") - - assert token == "query-token" - - -@pytest.mark.asyncio -async def test_extract_service_token_falls_back_to_cookie_jar() -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_response()) - http.cookies.get.return_value = "cookie-token" - - token = await auth_helpers.extract_service_token(http, "https://example.com/login") - - assert token == "cookie-token" - - -@pytest.mark.asyncio -async def test_extract_service_token_raises_when_missing_everywhere() -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_response()) - http.cookies.get.return_value = "" - - with pytest.raises(AuthError, match="未能获取 serviceToken"): - await auth_helpers.extract_service_token(http, "https://example.com/login") - - -@pytest.mark.asyncio -async def test_extract_credentials_populates_token_and_service_token( - monkeypatch: pytest.MonkeyPatch, -) -> None: - http = MagicMock() - token = AuthToken() - extract_service_token = AsyncMock(return_value="service-token") - monkeypatch.setattr(auth_helpers, "extract_service_token", extract_service_token) - - await auth_helpers.extract_credentials( - http, - { - "ssecurity": "sec", - "userId": "3188565001", - "passToken": "pt", - "cUserId": "cid", - "location": "https://example.com/callback", - }, - token, - ) - - assert token.ssecurity == "sec" - assert token.user_id == "3188565001" - assert token.pass_token == "pt" - assert token.c_user_id == "cid" - assert token.service_token == "service-token" - extract_service_token.assert_awaited_once_with(http, "https://example.com/callback") - - -@pytest.mark.asyncio -async def test_sts_exchange_uses_device_id_and_accepts_ok_response( - monkeypatch: pytest.MonkeyPatch, -) -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_response(text="ok")) - token = AuthToken(device_id="an_device") - monkeypatch.setattr(auth_sts.time, "time", lambda: 1.234) - - await auth_sts.sts_exchange(http, token) - - http.get.assert_awaited_once() - params = http.get.await_args.kwargs["params"] - assert params["d"] == "an_device" - assert params["p_ts"] == "1234" - - -@pytest.mark.asyncio -async def test_sts_exchange_does_not_log_cookie_values(monkeypatch: pytest.MonkeyPatch) -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_response(text="ok")) - http.cookies.jar = [_Cookie("serviceToken", "hlth.io.mi.com", "secret-service-token")] - token = AuthToken(device_id="an_device", pass_token="secret-pass-token") - messages: list[str] = [] - sink_id = logger.add(lambda message: messages.append(str(message)), level="DEBUG", format="{message}") - - try: - await auth_sts.sts_exchange(http, token) - finally: - logger.remove(sink_id) - - logs = "\n".join(messages) - assert "secret-service-token" not in logs - assert "secret-pass-token" not in logs - assert "serviceToken успешно сохранен" in logs - - -@pytest.mark.asyncio -async def test_sts_exchange_swallows_network_failure() -> None: - http = MagicMock() - http.get = AsyncMock(side_effect=RuntimeError("boom")) - token = AuthToken(device_id="an_device") - - await auth_sts.sts_exchange(http, token) diff --git a/mi-fitness-python/tests/unit/test_cli.py b/mi-fitness-python/tests/unit/test_cli.py deleted file mode 100644 index 0dad9ae..0000000 --- a/mi-fitness-python/tests/unit/test_cli.py +++ /dev/null @@ -1,74 +0,0 @@ -"""测试 CLI 入口与二维码登录。""" - -from __future__ import annotations - -import shutil -from pathlib import Path -from types import SimpleNamespace -from typing import ClassVar, Self -from uuid import uuid4 - -import pytest - -import mi_fitness.cli as cli - - -def _workspace_tmp_dir() -> Path: - root = Path(".test-tmp") / uuid4().hex - root.mkdir(parents=True, exist_ok=True) - return root - - -class _FakeAuth: - instances: ClassVar[list["_FakeAuth"]] = [] - - def __init__(self, *args: object): - self.args = args - self.token = SimpleNamespace(user_id="3188565001") - self.saved_path: Path | None = None - self.login_calls: list[tuple[object, ...]] = [] - _FakeAuth.instances.append(self) - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, exc_type, exc, tb) -> None: - return None - - async def login_qr(self, qr_callback=None) -> None: # type: ignore[no-untyped-def] - self.login_calls.append(("qr",)) - if qr_callback is not None: - await qr_callback("https://example.com/qr.png", "https://example.com/login") - - def save_token(self, path: Path | str) -> None: - self.saved_path = Path(path) - - -@pytest.mark.asyncio -async def test_qr_login_saves_token(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: - _FakeAuth.instances.clear() - tmp_dir = _workspace_tmp_dir() - monkeypatch.setattr(cli, "XiaomiAuth", _FakeAuth) - monkeypatch.setattr(cli, "TOKEN_FILE", tmp_dir / "token.json") - try: - await cli._qr_login() - auth = _FakeAuth.instances[-1] - assert auth.login_calls == [("qr",)] - assert auth.saved_path == tmp_dir / "token.json" - stdout = capsys.readouterr().out - assert "https://example.com/login" not in stdout - assert "https://example.com/qr.png" not in stdout - finally: - shutil.rmtree(tmp_dir, ignore_errors=True) - - -def test_main_runs_without_args(monkeypatch: pytest.MonkeyPatch) -> None: - called = False - - async def fake_qr_login() -> None: - nonlocal called - called = True - - monkeypatch.setattr(cli, "_qr_login", fake_qr_login) - cli.main() - assert called diff --git a/mi-fitness-python/tests/unit/test_client.py b/mi-fitness-python/tests/unit/test_client.py deleted file mode 100644 index 308f55d..0000000 --- a/mi-fitness-python/tests/unit/test_client.py +++ /dev/null @@ -1,963 +0,0 @@ -"""测试 API 客户端 (client.py)。 - -使用 mock 替换 _request 方法,验证各业务方法的参数组装和响应解析。 -""" - -from __future__ import annotations - -from datetime import date -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from mi_fitness.client import MiHealthClient -from mi_fitness.const import ( - ALL_SHARED_DATA_TYPES, - MESSAGE_GET_LIST_PATH, - MESSAGE_MODULE_RELATIVES, - RELATIVES_DELETE_PATH, - RELATIVES_GET_INVITE_ID_PATH, - RELATIVES_LIST_PATH, - RELATIVES_OPERATE_INVITE_PATH, - RELATIVES_VERIFY_USER_PATH, - VERIFY_TYPE_XIAOMI_ID, -) -from mi_fitness.exceptions import ( - DataNotSharedError, - DataOutOfSharedTimeScopeError, - FamilyMemberNotFoundError, - TokenExpiredError, -) -from mi_fitness.models import ( - CaloriesData, - GoalData, - HeartRateData, - IntensityData, - LatestDataSnapshot, - SleepData, - Spo2Data, - Spo2SummaryData, - StepData, - ValidStandData, - WeightData, -) - -from .conftest import ( - AGGREGATED_HR_RESPONSE, - AGGREGATED_SLEEP_RESPONSE, - AGGREGATED_STEPS_RESPONSE, - CHECK_NEW_MSG_RESPONSE, - CHECK_NO_NEW_MSG_RESPONSE, - DELETE_RESPONSE, - FAMILY_MEMBER_RESPONSE, - FITNESS_BLOOD_PRESSURE_RESPONSE, - FITNESS_WEIGHT_RESPONSE, - INVITE_ID_RESPONSE, - INVITE_RESPONSE, - LATEST_DATA_RESPONSE, - MESSAGE_LIST_RESPONSE, - OPERATE_INVITE_RESPONSE, - RELATIVE_LIST_RESPONSE, - SHARED_TYPES_RESPONSE, - VERIFY_USER_RESPONSE, -) - -# 使用 pytest fixture 中的 mock_auth -pytestmark = pytest.mark.asyncio - - -def _make_client(mock_auth: Any) -> MiHealthClient: - """用 mock auth 构造客户端实例。""" - return MiHealthClient(mock_auth) - - -# region 亲友列表 -class TestGetRelatives: - async def test_returns_family_members(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) - - members = await client.get_relatives() - assert len(members) == 2 - assert members[0].relative_uid == 1452722403 - assert members[0].relative_note == "妈妈" - assert members[1].relative_note == "爸爸" - - client._request.assert_called_once_with("GET", RELATIVES_LIST_PATH) - - async def test_empty_list(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {"relative_list": []}}) - members = await client.get_relatives() - assert members == [] - - -# endregion - - -# region 查找亲友 -class TestFindRelative: - async def test_find_by_uid(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) - - member = await client.find_relative(1452722403) - assert member.relative_uid == 1452722403 - - async def test_find_by_note(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) - - member = await client.find_relative("妈") - assert member.relative_note == "妈妈" - - async def test_not_found_raises(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) - - with pytest.raises(FamilyMemberNotFoundError): - await client.find_relative("不存在的人") - - -# endregion - - -# region 验证用户 -class TestVerifyUser: - async def test_verify_by_xiaomi_id(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=VERIFY_USER_RESPONSE) - - info = await client.verify_user(1452722403) - assert info is not None - assert info.user_id == 1452722403 - assert info.nickname == "测试用户" - - client._request.assert_called_once_with( - "GET", - RELATIVES_VERIFY_USER_PATH, - params={"verify_id": 1452722403, "verify_type": VERIFY_TYPE_XIAOMI_ID}, - ) - - async def test_verify_returns_none_for_missing_user(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {}}) - info = await client.verify_user(999) - assert info is None - - -# endregion - - -# region 邀请亲友 -class TestInviteRelative: - async def test_invite_success(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=INVITE_RESPONSE) - - result = await client.invite_relative(1452722403) - assert result is True - - call_args = client._request.call_args - params = call_args.kwargs["params"] - assert params["relative_uid"] == 1452722403 - assert params["auth_content"]["auth_data"] == ALL_SHARED_DATA_TYPES - assert params["auth_content"]["auth_time_range"] == 3 - - async def test_invite_with_custom_types(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=INVITE_RESPONSE) - - await client.invite_relative( - 123, - shared_data_types=["heart_rate", "sleep"], - auth_time_range=1, - ) - - params = client._request.call_args.kwargs["params"] - assert params["auth_content"]["auth_data"] == ["heart_rate", "sleep"] - assert params["auth_content"]["auth_time_range"] == 1 - - async def test_invite_with_note(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=INVITE_RESPONSE) - - await client.invite_relative(123, relative_note="好友") - - params = client._request.call_args.kwargs["params"] - assert params["relative_note"] == "好友" - - async def test_invite_failure(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {"send_ret": 0}}) - result = await client.invite_relative(123) - assert result is False - - -# endregion - - -# region 操作邀请(同意/拒绝) -class TestOperateInvite: - async def test_accept_success(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) - - result = await client.accept_invite(4777767, 152824796151809) - assert result is True - - call_args = client._request.call_args - params = call_args.kwargs["params"] - assert params["invite_id"] == 4777767 - assert params["msg_id"] == 152824796151809 - assert params["operate"] == 1 - assert params["auth_content"]["auth_data"] == ALL_SHARED_DATA_TYPES - - async def test_accept_with_custom_types(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) - - await client.accept_invite(123, 456, shared_data_types=["heart_rate", "sleep"]) - - params = client._request.call_args.kwargs["params"] - assert params["auth_content"]["auth_data"] == ["heart_rate", "sleep"] - - async def test_reject_success(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) - - result = await client.reject_invite(4777767, 152824796151809) - assert result is True - - params = client._request.call_args.kwargs["params"] - assert params["operate"] == 2 - - async def test_accept_calls_correct_endpoint(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) - - await client.accept_invite(1, 2) - - client._request.assert_called_once_with( - "POST", - RELATIVES_OPERATE_INVITE_PATH, - params={ - "auth_content": { - "auth_time_range": 3, - "auth_data": ALL_SHARED_DATA_TYPES, - }, - "invite_id": 1, - "msg_id": 2, - "operate": 1, - }, - ) - - async def test_operate_failure(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {"operate_ret": False}}) - result = await client.accept_invite(1, 2) - assert result is False - - -# endregion - - -# region 删除亲友 -class TestDeleteRelative: - async def test_delete_success(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=DELETE_RESPONSE) - - result = await client.delete_relative(1452722403) - assert result is True - - client._request.assert_called_once_with( - "POST", - RELATIVES_DELETE_PATH, - params={"relative_uid": 1452722403}, - ) - - async def test_delete_failure(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {"delete_ret": False}}) - result = await client.delete_relative(123) - assert result is False - - -# endregion - - -# region 邀请链接 ID -class TestGetInviteLinkId: - async def test_returns_snowflake_id(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=INVITE_ID_RESPONSE) - - link_id = await client.get_invite_link_id() - assert link_id == 467184968352742400 - - client._request.assert_called_once_with("GET", RELATIVES_GET_INVITE_ID_PATH) - - -# endregion - - -# region 共享数据类型 -class TestGetSharedDataTypes: - async def test_returns_keys(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=SHARED_TYPES_RESPONSE) - - keys = await client.get_shared_data_types(123) - assert "heart_rate" in keys - - async def test_direction_parameter(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=SHARED_TYPES_RESPONSE) - - await client.get_shared_data_types(123, direction=1) - params = client._request.call_args.kwargs["params"] - assert params["type"] == 1 - - -# endregion - - -# region 家庭成员 -class TestGetFamilyMembers: - async def test_returns_list(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=FAMILY_MEMBER_RESPONSE) - - members = await client.get_family_members() - assert len(members) == 1 - assert members[0]["userId"] == 123 - - -# endregion - - -# region 最新数据 -class TestGetLatestData: - async def test_returns_typed_snapshot(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) - - latest = await client.get_latest_data(123) - assert isinstance(latest, LatestDataSnapshot) - assert latest.updated_time == 1717495200 - assert latest.goal is not None - assert len(latest.goal.goal_items) == 2 - assert latest.heart_rate is not None - assert latest.heart_rate.bpm == 84 - assert latest.sleep is not None - assert latest.sleep.total_duration == 436 - assert latest.steps is not None - assert latest.steps.goal == 6000 - assert latest.calories is not None - assert latest.calories.calories == 230 - assert latest.valid_stand is not None - assert latest.valid_stand.count == 7 - assert latest.intensity is not None - assert latest.intensity.duration == 15 - assert latest.weight is not None - assert latest.weight.weight == 65.5 - assert latest.spo2 is not None - assert latest.spo2.spo2 == 96 - assert latest.blood_pressure is None - - -class TestGetLatestItems: - async def test_returns_raw_items(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) - - items = await client.get_latest_items(123) - assert len(items) == 10 - assert items[0].key == "goal" - assert items[1].key == "heart_rate" - - -# endregion - - -# region 心率数据 -class TestGetHeartRate: - async def test_returns_heart_rate_data(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=AGGREGATED_HR_RESPONSE) - - data = await client.get_heart_rate(123, date(2024, 6, 4)) - assert len(data) == 1 - assert isinstance(data[0], HeartRateData) - assert data[0].avg_hr == 72 - assert data[0].latest_hr is not None - assert data[0].latest_hr.bpm == 75 - - async def test_skips_invalid_heart_rate_item(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock( - return_value={ - "code": 0, - "result": { - "data_list": [ - { - "sid": "miothealth", - "tag": "daily_report", - "key": "heart_rate", - "time": 1717430400, - "value": '{"avg_hr":{"bad":1}}', - "update_time": 1717488000, - "watermark": "w1", - "source_sid_list": [], - }, - AGGREGATED_HR_RESPONSE["result"]["data_list"][0], - ] - }, - } - ) - - data = await client.get_heart_rate(123, date(2024, 6, 4)) - - assert len(data) == 1 - assert data[0].avg_hr == 72 - - -# endregion - - -# region 睡眠数据 -class TestGetSleep: - async def test_returns_sleep_data(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=AGGREGATED_SLEEP_RESPONSE) - - data = await client.get_sleep(123, date(2024, 6, 4)) - assert len(data) == 1 - assert isinstance(data[0], SleepData) - assert data[0].total_duration == 480 - assert data[0].sleep_score == 85 - assert len(data[0].segment_details) == 1 - - -# endregion - - -# region 步数数据 -class TestGetSteps: - async def test_returns_step_data(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=AGGREGATED_STEPS_RESPONSE) - - data = await client.get_steps(123, date(2024, 6, 4)) - assert len(data) == 1 - assert isinstance(data[0], StepData) - assert data[0].steps == 8500 - - -# endregion - - -# region 其它聚合指标 -class TestOtherAggregatedMetrics: - async def test_history_days_uses_trailing_window(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {"data_list": []}}) - - await client.get_calories_history(123, date(2024, 6, 4), days=7) - - call = client._request.call_args - assert call is not None - params = call.kwargs["params"] - assert params["key"] == "calories" - assert params["limit"] == 7 - assert params["end_time"] - params["start_time"] == 86400 * 7 - 1 - - async def test_returns_calories_valid_stand_and_intensity(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock( - side_effect=[ - { - "code": 0, - "result": { - "data_list": [ - { - "sid": "default", - "tag": "daily_report", - "key": "calories", - "time": 1717430400, - "value": '{"calories":338}', - "update_time": 1717488000, - "watermark": "w1", - "source_sid_list": [], - } - ] - }, - }, - { - "code": 0, - "result": { - "data_list": [ - { - "sid": "default", - "tag": "daily_report", - "key": "valid_stand", - "time": 1717430400, - "value": '{"count":10}', - "update_time": 1717488000, - "watermark": "w2", - "source_sid_list": [], - } - ] - }, - }, - { - "code": 0, - "result": { - "data_list": [ - { - "sid": "default", - "tag": "daily_report", - "key": "intensity", - "time": 1717430400, - "value": '{"duration":19}', - "update_time": 1717488000, - "watermark": "w3", - "source_sid_list": [], - } - ] - }, - }, - ] - ) - - calories = await client.get_calories_history(123, date(2024, 6, 4)) - stand = await client.get_valid_stand_history(123, date(2024, 6, 4)) - intensity = await client.get_intensity_history(123, date(2024, 6, 4)) - - assert isinstance(calories[0], CaloriesData) - assert calories[0].calories == 338 - assert isinstance(stand[0], ValidStandData) - assert stand[0].count == 10 - assert isinstance(intensity[0], IntensityData) - assert intensity[0].duration == 19 - - async def test_returns_spo2_history(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock( - return_value={ - "code": 0, - "result": { - "data_list": [ - { - "sid": "default", - "tag": "daily_report", - "key": "spo2", - "time": 1761091200, - "value": ( - '{"avg_spo2":96,"lack_spo2_count":0,' - '"latest_spo2":{"spo2":96,"time":1761161730},' - '"max_spo2":96,"min_spo2":96}' - ), - "update_time": 1762064917, - "watermark": "w4", - "source_sid_list": [], - } - ] - }, - } - ) - - series = await client.get_spo2_history(123, date(2025, 10, 22)) - - assert len(series) == 1 - assert isinstance(series[0], Spo2SummaryData) - assert series[0].avg_spo2 == 96 - assert series[0].latest_spo2 is not None - assert series[0].latest_spo2.spo2 == 96 - - async def test_returns_weight_history(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=FITNESS_WEIGHT_RESPONSE) - - series = await client.get_weight_history(123, date(2026, 3, 17), days=7) - - assert len(series) == 1 - assert isinstance(series[0], WeightData) - assert series[0].weight == 55.0 - assert series[0].bmi == 16.97531 - - async def test_returns_blood_pressure_history(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=FITNESS_BLOOD_PRESSURE_RESPONSE) - - series = await client.get_blood_pressure_history(123, date(2026, 3, 17), days=7) - - assert len(series) == 1 - assert series[0].systolic == 33 - assert series[0].diastolic == 30 - assert series[0].pulse == 60 - - -# endregion - - -# region 体重数据 -class TestGetWeight: - async def test_returns_weight_data(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) - - weight = await client.get_weight(123) - assert weight is not None - assert isinstance(weight, WeightData) - assert weight.weight == 65.5 - assert weight.bmi == 22.1 - - async def test_returns_none_when_no_weight(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - resp = { - "code": 0, - "result": { - "data_list": [ - {"time": 100, "key": "heart_rate", "value": "{}"}, - ] - }, - } - client._request = AsyncMock(return_value=resp) - client.get_shared_data_types = AsyncMock(return_value=["weight"]) # type: ignore[method-assign] - - weight = await client.get_weight(123) - assert weight is None - - async def test_raises_not_shared_when_weight_disabled(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value={"code": 0, "result": {"data_list": []}}) - client.get_shared_data_types = AsyncMock(return_value=["steps"]) # type: ignore[method-assign] - - with pytest.raises(DataNotSharedError, match="weight"): - await client.get_weight(123) - - -class TestGetLatestMetricHelpers: - async def test_returns_goal_and_other_latest_metric_types(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) - - goal = await client.get_goal(123) - calories = await client.get_calories(123) - stand = await client.get_valid_stand(123) - intensity = await client.get_intensity(123) - spo2 = await client.get_spo2(123) - - assert isinstance(goal, GoalData) - assert len(goal.goal_items) == 2 - assert goal.steps_goal is not None - assert goal.steps_goal.target_value == 6000 - assert goal.calories_goal is not None - assert goal.calories_goal.target_value == 400 - assert goal.intensity_goal is None - assert isinstance(calories, CaloriesData) - assert calories.calories == 230 - assert isinstance(stand, ValidStandData) - assert stand.count == 7 - assert isinstance(intensity, IntensityData) - assert intensity.duration == 15 - assert isinstance(spo2, Spo2Data) - assert spo2.spo2 == 96 - - async def test_blood_pressure_returns_none_when_shared_but_no_payload( - self, mock_auth: Any - ) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) - client.get_shared_data_types = AsyncMock(return_value=["blood_pressure"]) # type: ignore[method-assign] - - blood_pressure = await client.get_blood_pressure(123) - - assert blood_pressure is None - - async def test_blood_pressure_supports_fitness_aliases_in_latest_payload( - self, mock_auth: Any - ) -> None: - client = _make_client(mock_auth) - resp = { - "code": 0, - "result": { - "data_list": [ - { - "time": 1773753098, - "key": "blood_pressure", - "value": ( - '{"systolic_pressure":33,"diastolic_pressure":30,' - '"pulse":60,"time":1773753098}' - ), - } - ] - }, - } - client._request = AsyncMock(return_value=resp) - - blood_pressure = await client.get_blood_pressure(123) - - assert blood_pressure is not None - assert blood_pressure.systolic == 33 - assert blood_pressure.diastolic == 30 - assert blood_pressure.pulse == 60 - - -# endregion - - -# region 每日摘要 -class TestGetDailySummary: - async def test_returns_summary_dict(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - # 依次返回心率、睡眠、步数的响应 - client._request = AsyncMock( - side_effect=[ - AGGREGATED_HR_RESPONSE, - AGGREGATED_SLEEP_RESPONSE, - AGGREGATED_STEPS_RESPONSE, - ] - ) - - summary = await client.get_daily_summary(123, date(2024, 6, 4)) - assert summary.date == "2024-06-04" - assert summary.relative_uid == 123 - assert summary.heart_rate is not None - assert summary.sleep is not None - assert summary.steps is not None - - async def test_get_latest_daily_summary_uses_relative_latest_date(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client.find_relative = AsyncMock( - return_value=type( - "Member", - (), - { - "relative_uid": 123, - "relative_note": "测试", - "latest_data_time": 1717488000, - }, - )() - ) - client._request = AsyncMock( - side_effect=[ - AGGREGATED_HR_RESPONSE, - AGGREGATED_SLEEP_RESPONSE, - AGGREGATED_STEPS_RESPONSE, - ] - ) - - summary = await client.get_latest_daily_summary(123) - - assert summary.date == "2024-06-04" - assert summary.heart_rate is not None - assert summary.sleep is not None - assert summary.steps is not None - - async def test_get_latest_daily_summary_falls_back_to_latest_snapshot_time( - self, mock_auth: Any - ) -> None: - client = _make_client(mock_auth) - client.find_relative = AsyncMock( - return_value=type( - "Member", - (), - { - "relative_uid": 123, - "relative_note": "测试", - "latest_data_time": 0, - }, - )() - ) - client._request = AsyncMock( - side_effect=[ - LATEST_DATA_RESPONSE, - AGGREGATED_HR_RESPONSE, - AGGREGATED_SLEEP_RESPONSE, - AGGREGATED_STEPS_RESPONSE, - ] - ) - - summary = await client.get_latest_daily_summary(123) - - assert summary.date == "2024-06-04" - assert summary.heart_rate is not None - assert summary.sleep is not None - assert summary.steps is not None - - async def test_daily_summary_returns_partial_results_when_some_data_not_shared( - self, - mock_auth: Any, - ) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock( - side_effect=[ - DataNotSharedError("未共享该数据类型", data_type="heart_rate"), - DataNotSharedError("未共享该数据类型", data_type="sleep"), - AGGREGATED_STEPS_RESPONSE, - ] - ) - - summary = await client.get_daily_summary(123, date(2024, 6, 4)) - - assert summary.date == "2024-06-04" - assert summary.heart_rate is None - assert summary.sleep is None - assert summary.steps is not None - assert summary.steps.steps == 8500 - - async def test_daily_summary_raises_when_query_date_exceeds_shared_time_scope( - self, - mock_auth: Any, - ) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock( - side_effect=[ - DataOutOfSharedTimeScopeError("超出亲友共享时间范围", data_type="heart_rate"), - AGGREGATED_SLEEP_RESPONSE, - AGGREGATED_STEPS_RESPONSE, - ] - ) - - with pytest.raises(DataOutOfSharedTimeScopeError, match="超出亲友共享时间范围"): - await client.get_daily_summary(123, date(2024, 6, 4)) - - -# endregion - - -# region 上下文管理器 -class TestContextManager: - async def test_async_context_manager(self, mock_auth: Any) -> None: - async with MiHealthClient(mock_auth) as client: - assert client is not None - - -# endregion - - -# region 自动刷新 -class TestAutoRefresh: - async def test_request_refreshes_and_retries_once(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - - async def refresh() -> Any: - mock_auth.token.service_token = "refreshed-token" - return mock_auth.token - - mock_auth.refresh = AsyncMock(side_effect=refresh) - client._http = MagicMock() - - with pytest.MonkeyPatch.context() as mp: - import mi_fitness.client.api as client_api_module - - encrypted = AsyncMock( - side_effect=[ - TokenExpiredError("expired"), - RELATIVE_LIST_RESPONSE, - ] - ) - mp.setattr(client_api_module, "encrypted_request", encrypted) - - members = await client.get_relatives() - - assert len(members) == 2 - mock_auth.refresh.assert_awaited_once() - assert encrypted.await_count == 2 - - async def test_request_does_not_refresh_twice(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - mock_auth.refresh = AsyncMock(return_value=mock_auth.token) - client._http = MagicMock() - - with pytest.MonkeyPatch.context() as mp: - import mi_fitness.client.api as client_api_module - - encrypted = AsyncMock(side_effect=TokenExpiredError("expired")) - mp.setattr(client_api_module, "encrypted_request", encrypted) - - with pytest.raises(TokenExpiredError): - await client._request("GET", RELATIVES_LIST_PATH) - - mock_auth.refresh.assert_awaited_once() - assert encrypted.await_count == 2 - - -# endregion - - -# region 消息接口 -class TestGetInviteMessages: - async def test_returns_all_messages(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE) - - messages = await client.get_invite_messages() - assert len(messages) == 2 - assert messages[0].msg_id == 152824796151809 - assert messages[0].sender == 1452722403 - assert messages[0].invite_id == 4777767 - assert messages[0].nick_name == "测试用户" - assert messages[0].is_pending is True - - client._request.assert_called_once_with( - "POST", - MESSAGE_GET_LIST_PATH, - params={"module": MESSAGE_MODULE_RELATIVES, "limit": 30}, - ) - - async def test_pending_only(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE) - - messages = await client.get_invite_messages(pending_only=True) - assert len(messages) == 1 - assert messages[0].is_pending is True - assert messages[0].invite_id == 4777767 - - async def test_custom_limit(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE) - - await client.get_invite_messages(limit=10) - - params = client._request.call_args.kwargs["params"] - assert params["limit"] == 10 - - -class TestHasNewInvite: - async def test_has_new(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=CHECK_NEW_MSG_RESPONSE) - - result = await client.has_new_invite() - assert result is True - - async def test_no_new(self, mock_auth: Any) -> None: - client = _make_client(mock_auth) - client._request = AsyncMock(return_value=CHECK_NO_NEW_MSG_RESPONSE) - - result = await client.has_new_invite() - assert result is False - - -# endregion - - -# region 时间戳工具(同步测试,不需要 asyncio mark) -@pytest.mark.filterwarnings("ignore::pytest.PytestWarning") -class TestDateToTimestamps: - def test_specific_date(self) -> None: - start, end = MiHealthClient._date_to_timestamps(date(2024, 6, 4)) - assert end - start == 86399 # 23:59:59 - - def test_today_default(self) -> None: - start, end = MiHealthClient._date_to_timestamps() - assert end - start == 86399 - - -# endregion diff --git a/mi-fitness-python/tests/unit/test_client_base.py b/mi-fitness-python/tests/unit/test_client_base.py deleted file mode 100644 index a029cf5..0000000 --- a/mi-fitness-python/tests/unit/test_client_base.py +++ /dev/null @@ -1,138 +0,0 @@ -"""测试基础请求层的边界容错。""" - -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest - -import mi_fitness.client.base as client_base -from mi_fitness.client.base import encrypted_request -from mi_fitness.const import ERR_NOT_RELATIVES, ERR_NOT_SHARED_DATA_TYPE -from mi_fitness.exceptions import ( - AuthError, - DataNotSharedError, - DataOutOfSharedTimeScopeError, - FamilyMemberNotFoundError, -) - -pytestmark = pytest.mark.asyncio - - -def _mock_response(status_code: int = 200, text: str = "encrypted") -> httpx.Response: - request = httpx.Request("GET", "https://example.com/test") - return httpx.Response(status_code=status_code, text=text, request=request) - - -async def test_encrypted_request_accepts_string_zero_code( - monkeypatch: pytest.MonkeyPatch, - auth_token, -) -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_mock_response()) - - monkeypatch.setattr( - client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} - ) - monkeypatch.setattr( - client_base, - "decrypt_response", - lambda *args, **kwargs: {"code": "0", "result": {"ok": True}}, - ) - - result = await encrypted_request(http, auth_token, "GET", "/app/v1/test") - assert result["result"]["ok"] is True - - -async def test_encrypted_request_uses_string_code_for_not_relatives( - monkeypatch: pytest.MonkeyPatch, - auth_token, -) -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_mock_response()) - - monkeypatch.setattr( - client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} - ) - monkeypatch.setattr( - client_base, - "decrypt_response", - lambda *args, **kwargs: { - "code": str(ERR_NOT_RELATIVES), - "desc": "not relatives", - }, - ) - - with pytest.raises(FamilyMemberNotFoundError, match="not relatives"): - await encrypted_request(http, auth_token, "GET", "/app/v1/test") - - -async def test_encrypted_request_requires_authenticated_token(auth_token) -> None: - http = MagicMock() - auth_token.service_token = "" - - with pytest.raises(AuthError, match="未登录"): - await encrypted_request(http, auth_token, "GET", "/app/v1/test") - - -async def test_encrypted_request_uses_string_code_for_data_not_shared( - monkeypatch: pytest.MonkeyPatch, - auth_token, -) -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_mock_response()) - - monkeypatch.setattr( - client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} - ) - monkeypatch.setattr( - client_base, - "decrypt_response", - lambda *args, **kwargs: { - "code": str(ERR_NOT_SHARED_DATA_TYPE), - "desc": "not shared data type", - }, - ) - - with pytest.raises(DataNotSharedError, match="not shared data type") as exc_info: - await encrypted_request( - http, - auth_token, - "GET", - "/app/v1/relatives/get_aggregated_data", - params={"relative_uid": 1, "key": "heart_rate"}, - ) - - assert exc_info.value.data_type == "heart_rate" - - -async def test_encrypted_request_raises_time_scope_error_for_out_of_range_dates( - monkeypatch: pytest.MonkeyPatch, - auth_token, -) -> None: - http = MagicMock() - http.get = AsyncMock(return_value=_mock_response()) - - monkeypatch.setattr( - client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} - ) - monkeypatch.setattr( - client_base, - "decrypt_response", - lambda *args, **kwargs: { - "code": str(ERR_NOT_SHARED_DATA_TYPE), - "message": "time out of data shared time scope", - }, - ) - - with pytest.raises(DataOutOfSharedTimeScopeError, match="超出亲友共享时间范围") as exc_info: - await encrypted_request( - http, - auth_token, - "GET", - "/app/v1/relatives/get_aggregated_data", - params={"relative_uid": 1, "key": "steps"}, - ) - - assert exc_info.value.data_type == "steps" diff --git a/mi-fitness-python/tests/unit/test_const.py b/mi-fitness-python/tests/unit/test_const.py deleted file mode 100644 index 5a1d2a2..0000000 --- a/mi-fitness-python/tests/unit/test_const.py +++ /dev/null @@ -1,67 +0,0 @@ -"""测试常量 (const.py)。""" - -from __future__ import annotations - -from mi_fitness.const import ( - ALL_SHARED_DATA_TYPES, - HEALTH_API_BASE, - RELATIVES_AGGREGATED_DATA_PATH, - RELATIVES_DELETE_PATH, - RELATIVES_FITNESS_DATA_PATH, - RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, - RELATIVES_GET_FAMILY_MEMBER_PATH, - RELATIVES_GET_INVITE_ID_PATH, - RELATIVES_GET_SHARED_TYPES_PATH, - RELATIVES_GET_TOPIC_SUBS_PATH, - RELATIVES_LATEST_DATA_PATH, - RELATIVES_LIST_PATH, - RELATIVES_SEND_INVITE_PATH, - RELATIVES_VERIFY_USER_PATH, - VERIFY_TYPE_XIAOMI_ID, -) - - -class TestAPIEndpoints: - """API 端点常量测试。""" - - def test_base_url_is_https(self) -> None: - assert HEALTH_API_BASE.startswith("https://") - - def test_all_paths_start_with_slash(self) -> None: - paths = [ - RELATIVES_LIST_PATH, - RELATIVES_LATEST_DATA_PATH, - RELATIVES_AGGREGATED_DATA_PATH, - RELATIVES_FITNESS_DATA_PATH, - RELATIVES_VERIFY_USER_PATH, - RELATIVES_SEND_INVITE_PATH, - RELATIVES_DELETE_PATH, - RELATIVES_GET_SHARED_TYPES_PATH, - RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, - RELATIVES_GET_FAMILY_MEMBER_PATH, - RELATIVES_GET_INVITE_ID_PATH, - RELATIVES_GET_TOPIC_SUBS_PATH, - ] - for path in paths: - assert path.startswith(("/app/v1/relatives/", "/app/v1/data/")), f"{path} API 路径前缀不正确" - - -class TestSharedDataTypes: - """共享数据类型常量测试。""" - - def test_has_10_types(self) -> None: - assert len(ALL_SHARED_DATA_TYPES) == 10 - - def test_contains_core_types(self) -> None: - for key in ["heart_rate", "sleep", "steps", "weight", "spo2"]: - assert key in ALL_SHARED_DATA_TYPES, f"缺少 {key}" - - def test_no_duplicates(self) -> None: - assert len(ALL_SHARED_DATA_TYPES) == len(set(ALL_SHARED_DATA_TYPES)) - - -class TestVerifyTypes: - """验证类型常量测试。""" - - def test_values(self) -> None: - assert VERIFY_TYPE_XIAOMI_ID == 1 diff --git a/mi-fitness-python/tests/unit/test_crypto.py b/mi-fitness-python/tests/unit/test_crypto.py deleted file mode 100644 index aa0b028..0000000 --- a/mi-fitness-python/tests/unit/test_crypto.py +++ /dev/null @@ -1,199 +0,0 @@ -"""测试加密模块 (crypto.py)。 - -覆盖:RC4、nonce、签名、加密/解密往返。 -""" - -from __future__ import annotations - -import base64 -import json - -from mi_fitness.crypto import ( - _build_sig_message, - _rc4_crypt, - _sha1_b64, - build_encrypted_params, - compute_signed_nonce, - decrypt_data, - decrypt_response, - encrypt_data, - generate_nonce, -) - - -class TestRC4: - """RC4 加密/解密测试。""" - - def test_encrypt_decrypt_roundtrip(self) -> None: - """加密后解密应还原明文。""" - key = b"test_key_123456" - plaintext = b"Hello, MiSDK!" - encrypted = _rc4_crypt(key, plaintext) - decrypted = _rc4_crypt(key, encrypted) - assert decrypted == plaintext - - def test_different_keys_produce_different_output(self) -> None: - """不同密钥应产生不同密文。""" - data = b"same data" - enc1 = _rc4_crypt(b"key_aaa", data) - enc2 = _rc4_crypt(b"key_bbb", data) - assert enc1 != enc2 - - def test_empty_data(self) -> None: - """空数据加密应返回空。""" - result = _rc4_crypt(b"key", b"") - assert result == b"" - - def test_skip_parameter(self) -> None: - """skip=0 和 skip=1024 应产生不同结果。""" - key = b"test" - data = b"data" - r1 = _rc4_crypt(key, data, skip=0) - r2 = _rc4_crypt(key, data, skip=1024) - assert r1 != r2 - - def test_large_data(self) -> None: - """大数据块也能正确往返。""" - key = b"large_key" - data = b"x" * 100_000 - assert _rc4_crypt(key, _rc4_crypt(key, data)) == data - - -class TestNonce: - """nonce 生成测试。""" - - def test_nonce_is_base64(self) -> None: - """nonce 应为有效的 base64 字符串。""" - nonce = generate_nonce() - decoded = base64.b64decode(nonce) - assert len(decoded) == 12 # 8 random + 4 time - - def test_nonce_unique(self) -> None: - """连续生成的 nonce 应不同(随机部分不同)。""" - nonces = {generate_nonce() for _ in range(10)} - assert len(nonces) == 10 - - -class TestSignedNonce: - """compute_signed_nonce 测试。""" - - def test_deterministic(self, ssecurity: str) -> None: - """相同输入应产生相同结果。""" - nonce = generate_nonce() - r1 = compute_signed_nonce(ssecurity, nonce) - r2 = compute_signed_nonce(ssecurity, nonce) - assert r1 == r2 - - def test_output_is_base64(self, ssecurity: str) -> None: - """输出应为有效 base64。""" - nonce = generate_nonce() - snonce = compute_signed_nonce(ssecurity, nonce) - decoded = base64.b64decode(snonce) - assert len(decoded) == 32 # SHA256 - - -class TestSignature: - """签名生成测试(纯 SHA1 + Base64)。""" - - def test_sha1_b64_output(self) -> None: - """SHA1 base64 输出应为 28 字符。""" - result = _sha1_b64("test message") - decoded = base64.b64decode(result) - assert len(decoded) == 20 # SHA1 - - def test_sig_msg_format(self) -> None: - """签名消息应为 METHOD&/path&k=v&signedNonce 格式。""" - msg = _build_sig_message("GET", "/test/path", {"data": "hello"}, "snonce123") - assert msg == "GET&/test/path&data=hello&snonce123" - - def test_sig_msg_method_uppercase(self) -> None: - """方法名应转为大写。""" - msg = _build_sig_message("get", "/path", {}, "sn") - assert msg.startswith("GET&") - - def test_sig_msg_adds_leading_slash(self) -> None: - """路径没有前导 / 时应自动添加。""" - msg = _build_sig_message("GET", "path", {}, "sn") - assert "&/path&" in msg - - def test_sig_msg_sorted_params(self) -> None: - """参数应按 key 字典序排序。""" - msg = _build_sig_message("GET", "/p", {"z": "1", "a": "2"}, "sn") - assert msg == "GET&/p&a=2&z=1&sn" - - -class TestEncryptDecryptData: - """encrypt_data / decrypt_data 测试。""" - - def test_roundtrip(self, ssecurity: str) -> None: - """加密后解密应还原。""" - nonce = generate_nonce() - snonce = compute_signed_nonce(ssecurity, nonce) - plaintext = '{"key": "value", "中文": "测试"}' - encrypted = encrypt_data(snonce, plaintext) - decrypted = decrypt_data(snonce, encrypted) - assert decrypted == plaintext - - def test_encrypted_is_base64(self, ssecurity: str) -> None: - """密文应为 base64 编码。""" - nonce = generate_nonce() - snonce = compute_signed_nonce(ssecurity, nonce) - encrypted = encrypt_data(snonce, "test") - base64.b64decode(encrypted) # 不抛异常即可 - - -class TestBuildEncryptedParams: - """build_encrypted_params 集成测试。""" - - def test_has_required_keys(self, ssecurity: str) -> None: - """返回应包含 data, _nonce, signature, rc4_hash__。""" - result = build_encrypted_params( - "GET", - "/app/v1/relatives/get_relative_list", - ssecurity, - {"relative_uid": 123}, - ) - assert "data" in result - assert "_nonce" in result - assert "signature" in result - assert "rc4_hash__" in result - - def test_no_params_no_data_key(self, ssecurity: str) -> None: - """无参数时不应有 data 字段。""" - result = build_encrypted_params( - "GET", - "/app/v1/relatives/get_relative_list", - ssecurity, - ) - assert "data" not in result - assert "_nonce" in result - - def test_can_decrypt_own_params(self, ssecurity: str) -> None: - """能解密自己加密的参数。""" - params = {"test": "hello", "num": 42} - result = build_encrypted_params("POST", "/test", ssecurity, params) - snonce = compute_signed_nonce(ssecurity, result["_nonce"]) - decrypted = decrypt_data(snonce, result["data"]) - parsed = json.loads(decrypted) - assert parsed == params - - -class TestDecryptResponse: - """decrypt_response 集成测试。""" - - def test_roundtrip(self, ssecurity: str) -> None: - """构造加密响应并解密。""" - nonce = generate_nonce() - snonce = compute_signed_nonce(ssecurity, nonce) - original = {"code": 0, "result": {"data": "test"}} - encrypted = encrypt_data(snonce, json.dumps(original)) - decrypted = decrypt_response(ssecurity, nonce, encrypted) - assert decrypted == original - - def test_non_json_returns_string(self, ssecurity: str) -> None: - """非 JSON 响应应返回字符串。""" - nonce = generate_nonce() - snonce = compute_signed_nonce(ssecurity, nonce) - encrypted = encrypt_data(snonce, "not json at all") - result = decrypt_response(ssecurity, nonce, encrypted) - assert result == "not json at all" diff --git a/mi-fitness-python/tests/unit/test_exceptions.py b/mi-fitness-python/tests/unit/test_exceptions.py deleted file mode 100644 index ee6e68a..0000000 --- a/mi-fitness-python/tests/unit/test_exceptions.py +++ /dev/null @@ -1,80 +0,0 @@ -"""测试异常类 (exceptions.py)。""" - -from __future__ import annotations - -from mi_fitness.exceptions import ( - APIError, - AuthError, - CaptchaRequiredError, - DataNotSharedError, - DataOutOfSharedTimeScopeError, - FamilyMemberNotFoundError, - MiSDKError, - TokenExpiredError, -) - - -class TestExceptionHierarchy: - """异常继承关系测试。""" - - def test_base_exception(self) -> None: - e = MiSDKError("test") - assert str(e) == "test" - assert isinstance(e, Exception) - - def test_auth_error_inherits(self) -> None: - e = AuthError("auth fail") - assert isinstance(e, MiSDKError) - - def test_token_expired_inherits_auth(self) -> None: - e = TokenExpiredError("expired") - assert isinstance(e, AuthError) - assert isinstance(e, MiSDKError) - - def test_api_error_attributes(self) -> None: - e = APIError( - "api fail", - status_code=500, - code=1001, - response_body='{"error": true}', - ) - assert isinstance(e, MiSDKError) - assert e.status_code == 500 - assert e.code == 1001 - assert e.response_body == '{"error": true}' - assert str(e) == "api fail" - - def test_api_error_defaults(self) -> None: - e = APIError("msg") - assert e.status_code == 0 - assert e.code == 0 - assert e.response_body == "" - - def test_family_member_not_found(self) -> None: - e = FamilyMemberNotFoundError("未找到") - assert isinstance(e, MiSDKError) - - def test_captcha_required_inherits_auth(self) -> None: - e = CaptchaRequiredError("需要验证码") - assert isinstance(e, AuthError) - assert isinstance(e, MiSDKError) - - def test_captcha_required_url(self) -> None: - url = "https://account.xiaomi.com/pass/getCode?icodeType=login" - e = CaptchaRequiredError("验证码风控", captcha_url=url) - assert e.captcha_url == url - assert str(e) == "验证码风控" - - def test_captcha_required_default_url(self) -> None: - e = CaptchaRequiredError("msg") - assert e.captcha_url == "" - - def test_data_not_shared_error(self) -> None: - e = DataNotSharedError("未共享", data_type="heart_rate") - assert isinstance(e, MiSDKError) - assert e.data_type == "heart_rate" - - def test_time_scope_error_inherits_data_not_shared(self) -> None: - e = DataOutOfSharedTimeScopeError("超出范围", data_type="steps") - assert isinstance(e, DataNotSharedError) - assert e.data_type == "steps" diff --git a/mi-fitness-python/tests/unit/test_http.py b/mi-fitness-python/tests/unit/test_http.py deleted file mode 100644 index e3b61b7..0000000 --- a/mi-fitness-python/tests/unit/test_http.py +++ /dev/null @@ -1,83 +0,0 @@ -"""测试重试 HTTP 客户端 (http.py)。""" - -from __future__ import annotations - -import httpx -import pytest - -from mi_fitness.http import RetryAsyncClient - -pytestmark = pytest.mark.asyncio - - -async def test_retries_on_idempotent_get_status_code() -> None: - """GET 遇到可重试状态码时应自动重试。""" - call_count = 0 - - async def handler(_: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - if call_count < 3: - return httpx.Response(status_code=503, json={"message": "busy"}) - return httpx.Response(status_code=200, json={"ok": True}) - - transport = httpx.MockTransport(handler) - async with RetryAsyncClient( - transport=transport, - retry_attempts=3, - retry_wait_min=0.0, - retry_wait_max=0.0, - retry_wait_multiplier=0.0, - ) as client: - resp = await client.get("https://example.com/ping") - - assert resp.status_code == 200 - assert call_count == 3 - - -async def test_does_not_retry_non_idempotent_post_by_default() -> None: - """POST 默认不重试,避免非幂等接口重复提交。""" - call_count = 0 - - async def handler(_: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(status_code=503, json={"message": "busy"}) - - transport = httpx.MockTransport(handler) - async with RetryAsyncClient( - transport=transport, - retry_attempts=3, - retry_wait_min=0.0, - retry_wait_max=0.0, - retry_wait_multiplier=0.0, - ) as client: - resp = await client.post("https://example.com/send", data={"a": "1"}) - - assert resp.status_code == 503 - assert call_count == 1 - - -async def test_retries_on_network_error_for_get() -> None: - """GET 遇到网络异常时应自动重试。""" - call_count = 0 - - async def handler(_: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - if call_count < 2: - raise httpx.ConnectError("connect failed") - return httpx.Response(status_code=200, json={"ok": True}) - - transport = httpx.MockTransport(handler) - async with RetryAsyncClient( - transport=transport, - retry_attempts=3, - retry_wait_min=0.0, - retry_wait_max=0.0, - retry_wait_multiplier=0.0, - ) as client: - resp = await client.get("https://example.com/health") - - assert resp.status_code == 200 - assert call_count == 2 diff --git a/mi-fitness-python/tests/unit/test_models.py b/mi-fitness-python/tests/unit/test_models.py deleted file mode 100644 index f43ee6f..0000000 --- a/mi-fitness-python/tests/unit/test_models.py +++ /dev/null @@ -1,807 +0,0 @@ -"""测试数据模型 (models.py)。 - -覆盖:模型构造、字段解析、响应包装器的属性方法。 -""" - -from __future__ import annotations - -import json - -from mi_fitness.models import ( - AggregatedDataItem, - AggregatedDataResponse, - AuthToken, - BloodPressureData, - CaloriesData, - CheckNewMsgResponse, - DeleteRelativeResponse, - FamilyMember, - FamilyMemberResponse, - GoalData, - GoalMetric, - HeartRateData, - IntensityData, - InviteMessage, - InviteResponse, - InviteUniqueIdResponse, - LatestDataItem, - LatestDataResponse, - LatestDataSnapshot, - LatestHeartRate, - MessageListResponse, - OperateInviteResponse, - RelativeListResponse, - SharedDataTypesResponse, - SleepData, - SleepSegment, - Spo2Data, - Spo2SummaryData, - StepData, - ValidStandData, - VerifiedUserInfo, - VerifyUserResponse, - WeightData, -) - - -# region 基础模型测试 -class TestAuthToken: - """AuthToken 模型测试。""" - - def test_defaults(self) -> None: - """空构造应全部默认空串。""" - t = AuthToken() - assert t.user_id == "" - assert t.ssecurity == "" - assert t.service_token == "" - - def test_serialization_roundtrip(self) -> None: - """JSON 序列化往返。""" - t = AuthToken(user_id="123", ssecurity="abc") - data = t.model_dump_json() - t2 = AuthToken.model_validate_json(data) - assert t2.user_id == t.user_id - assert t2.ssecurity == t.ssecurity - - -class TestFamilyMember: - """FamilyMember 模型测试。""" - - def test_construction(self) -> None: - m = FamilyMember(relative_uid=123, relative_note="妈妈") - assert m.relative_uid == 123 - assert m.relative_note == "妈妈" - assert m.latest_data_time == 0 - - def test_from_dict(self) -> None: - data = { - "relative_uid": 9999, - "relative_note": "爸爸", - "relative_icon": "https://img.example.com/a.jpg", - "latest_data_time": 1717488000, - "latest_abnormal_record_time": 0, - "source_tag": 1, - } - m = FamilyMember(**data) - assert m.relative_uid == 9999 - assert m.source_tag == 1 - - -class TestLatestHeartRate: - def test_defaults(self) -> None: - hr = LatestHeartRate() - assert hr.bpm == 0 - assert hr.time == 0 - - -class TestHeartRateData: - def test_full_construction(self) -> None: - hr = HeartRateData( - time=1717488000, - avg_hr=72, - max_hr=120, - min_hr=55, - latest_hr=LatestHeartRate(bpm=75, time=1717488000), - ) - assert hr.avg_hr == 72 - assert hr.latest_hr is not None - assert hr.latest_hr.bpm == 75 - - -class TestSleepData: - def test_with_segments(self) -> None: - seg = SleepSegment(bedtime=100, wake_up_time=200, duration=100) - sd = SleepData( - time=1717488000, - total_duration=480, - sleep_score=85, - segment_details=[seg], - ) - assert sd.total_duration == 480 - assert len(sd.segment_details) == 1 - assert sd.segment_details[0].duration == 100 - - -class TestStepData: - def test_construction(self) -> None: - s = StepData(time=1717488000, steps=8500, distance=6200, calories=320) - assert s.steps == 8500 - - -class TestWeightData: - def test_construction(self) -> None: - w = WeightData(time=1717488000, weight=65.5, bmi=22.1) - assert w.weight == 65.5 - assert w.bmi == 22.1 - - -class TestVerifiedUserInfo: - def test_alias_user_id(self) -> None: - """userId 别名应映射到 user_id。""" - info = VerifiedUserInfo(**{"userId": 12345, "nickname": "测试", "icon": "url"}) - assert info.user_id == 12345 - assert info.nickname == "测试" - - def test_populate_by_name(self) -> None: - """也可以用 user_id 直接构造。""" - info = VerifiedUserInfo(user_id=999, nickname="直接") # type: ignore[call-arg] - assert info.user_id == 999 - - -# endregion - - -# region LatestDataItem 测试 -class TestLatestDataItem: - def test_parse_json_value(self) -> None: - item = LatestDataItem( - time=1717488000, - key="heart_rate", - value='{"avg_hr": 72}', - ) - parsed = item.parse_value() - assert isinstance(parsed, dict) - assert parsed["avg_hr"] == 72 - - def test_parse_numeric_value(self) -> None: - item = LatestDataItem(time=1717488000, key="goal", value=10000) - parsed = item.parse_value() - assert parsed == 10000 - - def test_parse_invalid_json(self) -> None: - item = LatestDataItem(time=1717488000, key="bad", value="not{json") - parsed = item.parse_value() - assert parsed == {} - - def test_parse_dict_value(self) -> None: - item = LatestDataItem( - time=1717488000, - key="weight", - value={"weight": 65.5}, # type: ignore[arg-type] - ) - parsed = item.parse_value() - assert parsed == {"weight": 65.5} - - def test_parse_json_list_returns_empty_dict(self) -> None: - item = LatestDataItem(time=1717488000, key="bad", value='["unexpected"]') - assert item.parse_value() == {} - - def test_as_goal(self) -> None: - item = LatestDataItem( - time=1717488000, - key="goal", - value={ - "date_time": 1717488000, - "goal_items": [ - {"field": 1, "target_value": 6000, "achieved_value": 3716}, - ], - }, # type: ignore[arg-type] - ) - goal = item.as_goal() - assert isinstance(goal, GoalData) - assert goal.time == 1717488000 - assert len(goal.goal_items) == 1 - assert goal.goal_items[0].target_value == 6000 - assert goal.goal_items[0].metric == GoalMetric.STEPS - assert goal.goal_items[0].metric_key == "steps" - assert goal.goal_items[0].metric_label == "步数" - - def test_goal_accessors_and_unknown_items(self) -> None: - goal = GoalData.model_validate( - { - "time": 1717488000, - "goal_items": [ - {"field": 2, "target_value": 400, "achieved_value": 347}, - {"field": 1, "target_value": 6000, "achieved_value": 6203}, - {"field": 4, "target_value": 30, "achieved_value": 27}, - {"field": 99, "target_value": 1, "achieved_value": 0}, - ], - } - ) - - assert goal.calories_goal is not None - assert goal.calories_goal.target_value == 400 - assert goal.steps_goal is not None - assert goal.steps_goal.achieved_value == 6203 - assert goal.intensity_goal is not None - assert goal.intensity_goal.target_value == 30 - assert goal.available_metrics == [ - GoalMetric.CALORIES, - GoalMetric.STEPS, - GoalMetric.INTENSITY, - ] - assert len(goal.unknown_goal_items) == 1 - assert goal.unknown_goal_items[0].metric is None - assert goal.unknown_goal_items[0].metric_key == "unknown:99" - assert goal.get_item(GoalMetric.STEPS) is goal.steps_goal - - def test_as_heart_rate(self) -> None: - item = LatestDataItem( - time=1717488000, - key="heart_rate", - value='{"time":1717491600,"bpm":84}', - ) - heart_rate = item.as_heart_rate() - assert isinstance(heart_rate, LatestHeartRate) - assert heart_rate.time == 1717491600 - assert heart_rate.bpm == 84 - - def test_as_heart_rate_returns_none_for_invalid_payload(self) -> None: - item = LatestDataItem( - time=1717488000, - key="heart_rate", - value='{"time":1717491600,"bpm":{"bad":1}}', - ) - assert item.as_heart_rate() is None - - def test_as_sleep_uses_date_time_alias(self) -> None: - item = LatestDataItem( - time=1717488000, - key="sleep", - value='{"date_time":1717488000,"total_duration":436,"sleep_score":86}', - ) - sleep = item.as_sleep() - assert isinstance(sleep, SleepData) - assert sleep.time == 1717488000 - assert sleep.total_duration == 436 - assert sleep.sleep_score == 86 - - def test_as_steps_and_latest_metrics(self) -> None: - steps_item = LatestDataItem( - time=1717488000, - key="steps", - value='{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}', - ) - calories_item = LatestDataItem( - time=1717488000, - key="calories", - value='{"date_time":1717488000,"calories":230,"goal":300}', - ) - stand_item = LatestDataItem( - time=1717488000, - key="valid_stand", - value='{"date_time":1717488000,"count":7}', - ) - intensity_item = LatestDataItem( - time=1717488000, - key="intensity", - value='{"date_time":1717488000,"duration":15}', - ) - spo2_item = LatestDataItem( - time=1717488000, - key="spo2", - value='{"time":1717495200,"spo2":96}', - ) - - steps = steps_item.as_steps() - calories = calories_item.as_calories() - stand = stand_item.as_valid_stand() - intensity = intensity_item.as_intensity() - spo2 = spo2_item.as_spo2() - - assert isinstance(steps, StepData) - assert steps.goal == 6000 - assert isinstance(calories, CaloriesData) - assert calories.goal == 300 - assert isinstance(stand, ValidStandData) - assert stand.count == 7 - assert isinstance(intensity, IntensityData) - assert intensity.duration == 15 - assert isinstance(spo2, Spo2Data) - assert spo2.spo2 == 96 - - def test_as_blood_pressure_returns_none_when_value_missing(self) -> None: - item = LatestDataItem(time=1717488000, key="blood_pressure") - assert item.as_blood_pressure() is None - - def test_as_blood_pressure_supports_fitness_aliases(self) -> None: - item = LatestDataItem( - time=1773753098, - key="blood_pressure", - value='{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}', - ) - - blood_pressure = item.as_blood_pressure() - - assert isinstance(blood_pressure, BloodPressureData) - assert blood_pressure.systolic == 33 - assert blood_pressure.diastolic == 30 - assert blood_pressure.pulse == 60 - - -# endregion - - -# region AggregatedDataItem 测试 -class TestAggregatedDataItem: - def test_stringify_dict_value(self) -> None: - """dict value 应被自动转为 JSON 字符串。""" - item = AggregatedDataItem( - time=1717488000, - key="heart_rate", - value={"avg_hr": 72}, # type: ignore[arg-type] - ) - assert isinstance(item.value, str) - parsed = json.loads(item.value) - assert parsed["avg_hr"] == 72 - - def test_as_heart_rate(self) -> None: - value = json.dumps( - { - "avg_hr": 72, - "max_hr": 120, - "min_hr": 55, - "avg_rhr": 62, - "latest_hr": {"bpm": 75, "time": 1717488000}, - } - ) - item = AggregatedDataItem(time=1717430400, key="heart_rate", value=value) - hr = item.as_heart_rate() - assert isinstance(hr, HeartRateData) - assert hr.avg_hr == 72 - assert hr.time == 1717430400 - assert hr.latest_hr is not None - assert hr.latest_hr.bpm == 75 - - def test_as_sleep(self) -> None: - value = json.dumps( - { - "total_duration": 480, - "sleep_score": 85, - "segment_details": [ - {"bedtime": 100, "wake_up_time": 200, "duration": 100}, - ], - } - ) - item = AggregatedDataItem(time=1717430400, key="sleep", value=value) - sd = item.as_sleep() - assert isinstance(sd, SleepData) - assert sd.total_duration == 480 - assert len(sd.segment_details) == 1 - - def test_as_sleep_ignores_invalid_segments(self) -> None: - value = json.dumps( - { - "total_duration": 480, - "segment_details": [ - {"bedtime": 100, "wake_up_time": 200, "duration": 100}, - "bad-segment", - 1, - ], - } - ) - item = AggregatedDataItem(time=1717430400, key="sleep", value=value) - sd = item.as_sleep() - assert len(sd.segment_details) == 1 - assert sd.segment_details[0].duration == 100 - - def test_as_steps(self) -> None: - value = json.dumps({"steps": 8500, "distance": 6200, "calories": 320}) - item = AggregatedDataItem(time=1717430400, key="steps", value=value) - st = item.as_steps() - assert isinstance(st, StepData) - assert st.steps == 8500 - assert st.time == 1717430400 - - def test_as_simple_latest_metrics(self) -> None: - calories_item = AggregatedDataItem( - time=1717430400, key="calories", value='{"calories":338}' - ) - stand_item = AggregatedDataItem(time=1717430400, key="valid_stand", value='{"count":10}') - intensity_item = AggregatedDataItem( - time=1717430400, key="intensity", value='{"duration":19}' - ) - - calories = calories_item.as_calories() - stand = stand_item.as_valid_stand() - intensity = intensity_item.as_intensity() - - assert isinstance(calories, CaloriesData) - assert calories.time == 1717430400 - assert calories.calories == 338 - assert isinstance(stand, ValidStandData) - assert stand.count == 10 - assert isinstance(intensity, IntensityData) - assert intensity.duration == 19 - - def test_as_weight_and_blood_pressure(self) -> None: - weight_item = AggregatedDataItem( - time=1773753142, - key="weight", - value='{"time":1773753142,"weight":55.0,"bmi":16.97531}', - ) - blood_pressure_item = AggregatedDataItem( - time=1773753098, - key="blood_pressure", - value='{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}', - ) - - weight = weight_item.as_weight() - blood_pressure = blood_pressure_item.as_blood_pressure() - - assert isinstance(weight, WeightData) - assert weight.weight == 55.0 - assert weight.bmi == 16.97531 - assert isinstance(blood_pressure, BloodPressureData) - assert blood_pressure.systolic == 33 - assert blood_pressure.diastolic == 30 - assert blood_pressure.pulse == 60 - - def test_as_spo2_summary(self) -> None: - value = json.dumps( - { - "avg_spo2": 96, - "lack_spo2_count": 0, - "latest_spo2": { - "spo2": 96, - "time": 1761161730, - "dbKey": "single_spo2", - }, - "max_spo2": 96, - "min_spo2": 96, - } - ) - item = AggregatedDataItem(time=1761091200, key="spo2", value=value) - spo2 = item.as_spo2() - - assert isinstance(spo2, Spo2SummaryData) - assert spo2.time == 1761091200 - assert spo2.avg_spo2 == 96 - assert spo2.latest_spo2 is not None - assert spo2.latest_spo2.spo2 == 96 - - -# endregion - - -# region 响应包装器测试 -class TestRelativeListResponse: - def test_parse_relatives(self) -> None: - resp = RelativeListResponse( - code=0, - result={ - "relative_list": [ - {"relative_uid": 111, "relative_note": "A"}, - {"relative_uid": 222, "relative_note": "B"}, - ] - }, - ) - members = resp.relatives - assert len(members) == 2 - assert members[0].relative_uid == 111 - - def test_empty_list(self) -> None: - resp = RelativeListResponse(code=0, result={"relative_list": []}) - assert resp.relatives == [] - - def test_skips_invalid_relatives(self) -> None: - resp = RelativeListResponse( - code=0, - result={ - "relative_list": [ - 123, - {"relative_note": "缺少 uid"}, - {"relative_uid": 222, "relative_note": "B"}, - ] - }, - ) - members = resp.relatives - assert len(members) == 1 - assert members[0].relative_uid == 222 - - -class TestLatestDataResponse: - def test_parse_data_items(self) -> None: - resp = LatestDataResponse( - code=0, - result={ - "data_list": [ - {"time": 100, "key": "hr", "value": "{}"}, - ] - }, - ) - items = resp.data_items - assert len(items) == 1 - assert items[0].key == "hr" - - def test_non_mapping_result_is_tolerated(self) -> None: - resp = LatestDataResponse(code=0, result=None) # type: ignore[arg-type] - assert resp.data_items == [] - - def test_snapshot_returns_typed_metrics(self) -> None: - resp = LatestDataResponse( - code=0, - result={ - "data_list": [ - { - "time": 1717488000, - "key": "heart_rate", - "value": '{"time":1717491600,"bpm":84}', - }, - { - "time": 1717488000, - "key": "steps", - "value": '{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}', - }, - {"time": 1717488000, "key": "spo2", "value": '{"time":1717495200,"spo2":96}'}, - {"time": 1717488000, "key": "mood", "value": '{"score":80}'}, - ], - "latest_data_time": 1717495200, - }, - ) - snapshot = resp.snapshot - assert isinstance(snapshot, LatestDataSnapshot) - assert snapshot.updated_time == 1717495200 - assert snapshot.heart_rate is not None - assert snapshot.heart_rate.bpm == 84 - assert snapshot.steps is not None - assert snapshot.steps.goal == 6000 - assert snapshot.spo2 is not None - assert snapshot.spo2.spo2 == 96 - assert snapshot.extras == {"mood": {"score": 80}} - assert snapshot.available_keys == ["heart_rate", "steps", "spo2", "mood"] - - def test_snapshot_preserves_invalid_known_payload_in_extras(self) -> None: - resp = LatestDataResponse( - code=0, - result={ - "data_list": [ - { - "time": 1717488000, - "key": "heart_rate", - "value": '{"time":1717491600,"bpm":{"bad":1}}', - }, - ], - "latest_data_time": 1717495200, - }, - ) - snapshot = resp.snapshot - assert snapshot.heart_rate is None - assert snapshot.extras == {"heart_rate": {"time": 1717491600, "bpm": {"bad": 1}}} - assert snapshot.available_keys == ["heart_rate"] - - -class TestAggregatedDataResponse: - def test_parse_data_items(self) -> None: - resp = AggregatedDataResponse( - code=0, - result={ - "data_list": [ - { - "sid": "test", - "tag": "daily_report", - "key": "steps", - "time": 100, - "value": "{}", - "update_time": 200, - } - ], - "has_more": True, - "next_key": "abc", - }, - ) - assert len(resp.data_items) == 1 - assert resp.has_more is True - assert resp.next_key == "abc" - - def test_string_flags_are_normalized(self) -> None: - resp = AggregatedDataResponse( - code=0, - result={ - "data_list": [], - "has_more": "false", - "next_key": 123, - }, - ) - assert resp.has_more is False - assert resp.next_key == "123" - - -class TestVerifyUserResponse: - def test_with_user(self) -> None: - resp = VerifyUserResponse( - code=0, - result={"userId": 12345, "nickname": "用户", "icon": "url"}, - ) - info = resp.user_info - assert info is not None - assert info.user_id == 12345 - - def test_no_user(self) -> None: - resp = VerifyUserResponse(code=0, result={}) - assert resp.user_info is None - - -class TestInviteResponse: - def test_success(self) -> None: - resp = InviteResponse(code=0, result={"send_ret": 1}) - assert resp.success is True - - def test_failure(self) -> None: - resp = InviteResponse(code=0, result={"send_ret": 0}) - assert resp.success is False - - def test_string_success_flag(self) -> None: - resp = InviteResponse(code=0, result={"send_ret": "1"}) - assert resp.success is True - - -class TestDeleteRelativeResponse: - def test_success(self) -> None: - resp = DeleteRelativeResponse(code=0, result={"delete_ret": True}) - assert resp.success is True - - def test_failure(self) -> None: - resp = DeleteRelativeResponse(code=0, result={"delete_ret": False}) - assert resp.success is False - - def test_string_false_is_false(self) -> None: - resp = DeleteRelativeResponse(code=0, result={"delete_ret": "false"}) - assert resp.success is False - - -class TestOperateInviteResponse: - def test_success(self) -> None: - resp = OperateInviteResponse(code=0, result={"operate_ret": True}) - assert resp.success is True - - def test_failure(self) -> None: - resp = OperateInviteResponse(code=0, result={"operate_ret": False}) - assert resp.success is False - - -class TestSharedDataTypesResponse: - def test_parse_keys(self) -> None: - resp = SharedDataTypesResponse( - code=0, - result={"keys": ["goal", "heart_rate", "sleep"]}, - ) - assert resp.keys == ["goal", "heart_rate", "sleep"] - - def test_empty(self) -> None: - resp = SharedDataTypesResponse(code=0, result={}) - assert resp.keys == [] - - def test_ignores_non_string_keys(self) -> None: - resp = SharedDataTypesResponse( - code=0, - result={"keys": ["goal", 1, None, "sleep"]}, - ) - assert resp.keys == ["goal", "sleep"] - - -class TestInviteUniqueIdResponse: - def test_parse_id(self) -> None: - resp = InviteUniqueIdResponse( - code=0, - result={"invite_link_id": 467184968352742400}, - ) - assert resp.invite_link_id == 467184968352742400 - - -class TestFamilyMemberResponse: - def test_parse_list(self) -> None: - resp = FamilyMemberResponse( - code=0, - result={"family_user_list": [{"userId": 1}, {"userId": 2}]}, - ) - assert len(resp.family_user_list) == 2 - - def test_empty(self) -> None: - resp = FamilyMemberResponse(code=0, result={}) - assert resp.family_user_list == [] - - -# endregion - - -# region 消息模型测试 -class TestInviteMessage: - def test_pending_invite(self) -> None: - msg = InviteMessage( - msg_id=152824796151809, - module=1, - type=1, - sender=1452722403, - extra_data='{"invite_id":4777767,"nick_name":"测试","icon":"https://example.com/a.jpg"}', - data_status=0, - ) - assert msg.invite_id == 4777767 - assert msg.nick_name == "测试" - assert msg.icon == "https://example.com/a.jpg" - assert msg.is_pending is True - - def test_processed_notification(self) -> None: - msg = InviteMessage(type=5, data_status=1, extra_data='{"nick_name":"用户"}') - assert msg.invite_id is None - assert msg.nick_name == "用户" - assert msg.is_pending is False - - def test_invalid_extra_data(self) -> None: - msg = InviteMessage(extra_data="not json") - assert msg.invite_id is None - assert msg.nick_name == "" - assert msg.icon == "" - - def test_empty_extra_data(self) -> None: - msg = InviteMessage(extra_data="") - assert msg.invite_id is None - assert msg.nick_name == "" - - def test_json_list_extra_data_is_ignored(self) -> None: - msg = InviteMessage(extra_data='["bad-shape"]') - assert msg.invite_id is None - assert msg.nick_name == "" - assert msg.icon == "" - - -class TestMessageListResponse: - def test_parse_messages(self) -> None: - resp = MessageListResponse( - code=0, - result={ - "messages": [ - {"msg_id": 1, "type": 1, "sender": 100, "data_status": 0}, - {"msg_id": 2, "type": 5, "sender": 200, "data_status": 1}, - ], - "msg_total": 2, - }, - ) - assert len(resp.messages) == 2 - assert resp.msg_total == 2 - assert resp.messages[0].msg_id == 1 - - def test_empty(self) -> None: - resp = MessageListResponse(code=0, result={}) - assert resp.messages == [] - assert resp.msg_total == 0 - - def test_single_message_dict_is_tolerated(self) -> None: - resp = MessageListResponse( - code=0, - result={"messages": {"msg_id": 1, "type": 1, "sender": 100}, "msg_total": "1"}, - ) - assert len(resp.messages) == 1 - assert resp.msg_total == 1 - - -class TestCheckNewMsgResponse: - def test_has_new(self) -> None: - resp = CheckNewMsgResponse(code=0, result=[{"module": 1, "is_new": True}]) - assert resp.has_new(1) is True - assert resp.has_new(2) is False - - def test_no_new(self) -> None: - resp = CheckNewMsgResponse(code=0, result=[{"module": 1, "is_new": False}]) - assert resp.has_new(1) is False - - def test_empty(self) -> None: - resp = CheckNewMsgResponse(code=0, result=[]) - assert resp.has_new(1) is False - - def test_single_dict_result_is_tolerated(self) -> None: - resp = CheckNewMsgResponse(code=0, result={"module": 1, "is_new": "true"}) # type: ignore[arg-type] - assert resp.has_new(1) is True - - -# endregion diff --git a/miband_sync.py b/miband_sync.py deleted file mode 100644 index dc47bcd..0000000 --- a/miband_sync.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import asyncio -import sys - -from miband_tracker.stdio import configure_utf8_stdio - -configure_utf8_stdio() - -from miband_tracker.config import ConfigError, Settings # noqa: E402 -from miband_tracker.sync import daemon_main, run_sync # noqa: E402 - -__all__ = ["run_sync"] - - -def main() -> None: - try: - exit_code = asyncio.run(daemon_main(Settings.from_env())) - except ConfigError as exc: - print(f"Config error: {exc}", file=sys.stderr, flush=True) - exit_code = 1 - sys.exit(exit_code) - - -if __name__ == "__main__": - main() diff --git a/miband_tracker/__init__.py b/miband_tracker/__init__.py deleted file mode 100644 index be9728c..0000000 --- a/miband_tracker/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -"""Mi Band tracker service package.""" - -from .config import Settings -from .sync import SyncResult, run_sync - -__all__ = ["Settings", "SyncResult", "run_sync"] diff --git a/miband_tracker/bot/__init__.py b/miband_tracker/bot/__init__.py deleted file mode 100644 index c6b5da9..0000000 --- a/miband_tracker/bot/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -"""Telegram bot package.""" diff --git a/miband_tracker/bot/app.py b/miband_tracker/bot/app.py deleted file mode 100644 index ebd9253..0000000 --- a/miband_tracker/bot/app.py +++ /dev/null @@ -1,2028 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import asyncio -import io -import logging -import os -import sqlite3 -import sys -from datetime import date, datetime, timedelta -from functools import wraps -from pathlib import Path - -from mi_fitness.auth import XiaomiAuth -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update -from telegram.error import RetryAfter, TelegramError -from telegram.ext import ( - Application, - CallbackQueryHandler, - CommandHandler, - ContextTypes, - MessageHandler, - filters, -) - -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 - -# --------------------------------------------------------------------------- -# Logging — console shows only WARNING+ so users don't see debug spam. -# The mi-fitness vendored library uses loguru with Chinese debug messages; -# we suppress those to ERROR as well. -# --------------------------------------------------------------------------- -logging.basicConfig( - level=logging.WARNING, - format="%(asctime)s %(levelname)s %(name)s %(message)s", -) -logging.getLogger("httpx").setLevel(logging.WARNING) -logging.getLogger("telegram").setLevel(logging.WARNING) -logging.getLogger("telegram.ext").setLevel(logging.WARNING) -logger = logging.getLogger("fitness-bot") - -try: - from loguru import logger as _loguru_logger - _loguru_logger.remove() # Remove loguru's default stderr handler - _loguru_logger.add(sys.stderr, level="ERROR", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}") -except Exception: - pass - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- -import contextvars - -SETTINGS = Settings.from_env() -BOT_TOKEN = SETTINGS.telegram_bot_token -ALLOWED_USER_ID = SETTINGS.telegram_allowed_user_id -DB_PATH = str(SETTINGS.db_path) -SYNC_LOCK = asyncio.Lock() -AUTH_LOCK = asyncio.Lock() -AUTO_MENU_REFRESH_INTERVAL = max(5, int(os.getenv("AUTO_MENU_REFRESH_INTERVAL", "30"))) - -current_user_id_var = contextvars.ContextVar("current_user_id", default=None) - -STEP_GOAL = 10_000 # можно вынести в env при желании - - -# --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Auth helpers -# --------------------------------------------------------------------------- -def get_current_user_id() -> int | None: - uid = current_user_id_var.get() - if uid is not None: - return uid - allowed_ids = SETTINGS.telegram_allowed_user_ids - if allowed_ids: - return allowed_ids[0] - return None - - -def is_allowed(update: Update) -> bool: - global SETTINGS, ALLOWED_USER_ID - uid = update.effective_user.id if update.effective_user else None - if uid is None: - return False - - allowed_ids = SETTINGS.telegram_allowed_user_ids - if not allowed_ids: - ALLOWED_USER_ID = uid - try: - allowed_user_file = SETTINGS.data_dir / "allowed_user.id" - SETTINGS.data_dir.mkdir(parents=True, exist_ok=True) - allowed_user_file.write_text(str(uid), encoding="utf-8") - logger.info("🎉 Бот успешно привязан к первому пользователю (ID: %s)!", uid) - SETTINGS = Settings.from_env() - allowed_ids = SETTINGS.telegram_allowed_user_ids - except Exception as e: - logger.error("Не удалось сохранить ID владельца в файл: %s", e) - return True - return uid in allowed_ids - - -def with_user_context(func): - @wraps(func) - async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): - uid = update.effective_user.id if update.effective_user else None - token = current_user_id_var.set(uid) - try: - return await func(update, context, *args, **kwargs) - finally: - current_user_id_var.reset(token) - return wrapper - - -def get_user_db_path() -> str: - uid = get_current_user_id() - if uid is None: - return DB_PATH - return str(SETTINGS.user_db_path(uid)) - - -def get_user_status_path() -> str: - uid = get_current_user_id() - if uid is None: - return str(SETTINGS.status_path) - return str(SETTINGS.user_status_path(uid)) - - -def get_xiaomi_token_path() -> Path | None: - uid = get_current_user_id() - if uid is None: - return None - try: - return SETTINGS.token_path(uid) - except ConfigError: - return None - - -def has_xiaomi_token() -> bool: - token_path = get_xiaomi_token_path() - return bool(token_path and token_path.exists()) - - -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", - (start_epoch, end_epoch), - ) - if not rows: - return "" - values = [row[field] for row in rows] - if len(values) > 20: - step = len(values) / 20 - values = [values[int(i * step)] for i in range(20)] - return make_sparkline(values) - - -# --------------------------------------------------------------------------- -# 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: - 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: - if is_en: - tips.append("💡 Daily step goal reached — excellent job!") - else: - tips.append("💡 Дневная норма шагов выполнена — отличный результат!") - elif s >= 7000: - 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: - 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: - if is_en: - tips.append("💡 Sleep was under 6 hours. Try to go to bed earlier tonight.") - else: - tips.append("💡 Ночной сон меньше 6 часов. Постарайтесь лечь пораньше сегодня.") - elif deep < 30: - 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: - 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: - 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: - if is_en: - tips.append("💡 Enough data, keep up the good work!") - else: - tips.append("💡 Данных достаточно, продолжайте в том же духе!") - - return tips[0] # показываем один самый актуальный совет - - -# --------------------------------------------------------------------------- -# DB: health -# --------------------------------------------------------------------------- -def health_db_exists() -> bool: - return storage.health_db_exists(SETTINGS, get_current_user_id()) - - -def health_conn() -> sqlite3.Connection: - conn = sqlite3.connect(get_user_db_path()) - conn.execute("PRAGMA busy_timeout = 5000") - conn.row_factory = sqlite3.Row - return conn - - -def fetch_one(query: str, params: tuple = ()) -> sqlite3.Row | None: - return storage.fetch_one(SETTINGS, query, params, get_current_user_id()) - - -def fetch_all(query: str, params: tuple = ()) -> list[sqlite3.Row]: - return storage.fetch_all(SETTINGS, query, params, get_current_user_id()) - - -# --------------------------------------------------------------------------- -# DB: bot state -# --------------------------------------------------------------------------- -def init_state_db() -> None: - storage.init_state_db(SETTINGS) - - -def get_user_menu_msg_id(user_id: int) -> int | None: - try: - return storage.get_user_menu_msg_id(SETTINGS, user_id) - except Exception as e: - logger.warning("Failed to read menu message id for %s: %s", user_id, e) - return None - - -def set_user_menu_msg_id(user_id: int, msg_id: int) -> None: - try: - storage.set_user_menu_msg_id(SETTINGS, user_id, msg_id) - except Exception as e: - logger.warning("Failed to save menu message id for %s: %s", user_id, e) - - -# --------------------------------------------------------------------------- -# Telegram: safe edit with exponential backoff -# --------------------------------------------------------------------------- -async def safe_delete(message: Message | None) -> None: - if not message: - return - try: - await message.delete() - except Exception as e: - logger.debug("Failed to delete message: %s", e) - - -async def send_or_update_menu( - bot, - user_id: int, - text: str, - reply_markup: InlineKeyboardMarkup | None = None, - force_new: bool = False, -) -> None: - chat_id = user_id - msg_id = get_user_menu_msg_id(user_id) - - if msg_id and not force_new: - for attempt in range(3): - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=text, - parse_mode="HTML", - reply_markup=reply_markup, - disable_web_page_preview=True, - ) - return - except RetryAfter as e: - wait = e.retry_after + attempt - logger.warning("Flood control: waiting %ss (attempt %d)", wait, attempt + 1) - await asyncio.sleep(wait) - except TelegramError as e: - msg = str(e) - if "Message is not modified" in msg: - return - if "Message to edit not found" in msg or "message can't be edited" in msg.lower(): - break # сообщение пропало — создаём новое - logger.warning("Edit failed (attempt %d): %s", attempt + 1, e) - break - - # Старое сообщение устарело или не найдено — удаляем и создаём новое - if msg_id: - try: - await bot.delete_message(chat_id=chat_id, message_id=msg_id) - except Exception as e: - logger.debug("Failed to delete old menu message %s: %s", msg_id, e) - - new_msg = await bot.send_message( - chat_id=chat_id, - text=text, - parse_mode="HTML", - reply_markup=reply_markup, - disable_web_page_preview=True, - ) - set_user_menu_msg_id(user_id, new_msg.message_id) - - -async def update_menu( - update: Update, - context: ContextTypes.DEFAULT_TYPE, - text: str, - reply_markup: InlineKeyboardMarkup | None = None, - force_new: bool = False, -) -> None: - if not update.effective_user: - return - user_id = update.effective_user.id - if update.callback_query and update.callback_query.message: - set_user_menu_msg_id(user_id, update.callback_query.message.message_id) - 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 for all allowed users after the sync daemon writes their status file.""" - last_seen_mtimes: dict[int, float] = {} - while True: - try: - allowed_ids = SETTINGS.telegram_allowed_user_ids - for uid in allowed_ids: - status_path = SETTINGS.user_status_path(uid) - if status_path.exists(): - current_mtime = status_path.stat().st_mtime - last_seen_mtime = last_seen_mtimes.get(uid) - if last_seen_mtime is None: - last_seen_mtimes[uid] = current_mtime - elif current_mtime > last_seen_mtime: - last_seen_mtimes[uid] = current_mtime - if get_user_menu_msg_id(uid): - token = current_user_id_var.set(uid) - try: - await send_or_update_menu( - app.bot, - uid, - main_menu_text(), - main_keyboard(), - ) - logger.info("Auto-refreshed main menu for user %s", uid) - finally: - current_user_id_var.reset(token) - except asyncio.CancelledError: - raise - except Exception as exc: - logger.warning("Auto menu refresh failed: %s", exc) - - await asyncio.sleep(AUTO_MENU_REFRESH_INTERVAL) - - -def format_weekday(date_str: str) -> str: - try: - dt = datetime.strptime(date_str, "%Y-%m-%d") - wd = dt.weekday() - wd_ru = ["пн", "вт", "ср", "чт", "пт", "сб", "вс"] - return wd_ru[wd] - except Exception: - return "" - - -def average_bedtime(sleep_rows) -> float | None: - offsets = [] - for r in sleep_rows: - if not r or "start_time" not in r.keys() or r["start_time"] is None: - continue - try: - dt = datetime.fromtimestamp(r["start_time"], LOCAL_TZ) - minutes = dt.hour * 60 + dt.minute - if minutes > 720: - minutes -= 1440 - offsets.append(minutes) - except Exception: - pass - if not offsets: - return None - return sum(offsets) / len(offsets) - - -def format_bedtime(avg_offset: float | None) -> str: - if avg_offset is None: - return "N/A" - minutes = int(round(avg_offset)) - if minutes < 0: - minutes += 1440 - hour = (minutes // 60) % 24 - minute = minutes % 60 - return f"{hour:02d}:{minute:02d}" - - -def weekly_summary_text() -> str: - today = datetime.now(LOCAL_TZ).date() - start_curr = today - timedelta(days=6) - end_curr = today - - start_prev = today - timedelta(days=13) - end_prev = today - timedelta(days=7) - - # 1. Запросы текущей недели - steps_curr = fetch_all( - "SELECT date, total_steps FROM steps_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", - (start_curr.isoformat(), end_curr.isoformat()), - ) - sleep_curr = fetch_all( - "SELECT date, total_duration_min, start_time FROM sleep_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", - (start_curr.isoformat(), end_curr.isoformat()), - ) - - # 2. Запросы предыдущей недели - steps_prev = fetch_all( - "SELECT date, total_steps FROM steps_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", - (start_prev.isoformat(), end_prev.isoformat()), - ) - sleep_prev = fetch_all( - "SELECT date, total_duration_min, start_time FROM sleep_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", - (start_prev.isoformat(), end_prev.isoformat()), - ) - - # 3. Расчет текущих показателей - steps_list_curr = [int(r["total_steps"]) for r in steps_curr if r["total_steps"] is not None] - avg_steps_curr = round(sum(steps_list_curr) / len(steps_list_curr)) if steps_list_curr else 0 - - sleep_list_curr = [int(r["total_duration_min"]) for r in sleep_curr if r["total_duration_min"] is not None] - avg_sleep_curr = round(sum(sleep_list_curr) / len(sleep_list_curr)) if sleep_list_curr else 0 - avg_bedtime_curr = average_bedtime(sleep_curr) - - # 4. Расчет предыдущих показателей - steps_list_prev = [int(r["total_steps"]) for r in steps_prev if r["total_steps"] is not None] - avg_steps_prev = round(sum(steps_list_prev) / len(steps_list_prev)) if steps_list_prev else 0 - - sleep_list_prev = [int(r["total_duration_min"]) for r in sleep_prev if r["total_duration_min"] is not None] - avg_sleep_prev = round(sum(sleep_list_prev) / len(sleep_list_prev)) if sleep_list_prev else 0 - avg_bedtime_prev = average_bedtime(sleep_prev) - - # 5. Разницы - diff_steps_str = "" - if avg_steps_prev > 0: - diff_steps = avg_steps_curr - avg_steps_prev - diff_steps_str = f" ({'+' if diff_steps >= 0 else ''}{diff_steps} {'📈' if diff_steps >= 0 else '📉'})" - - diff_sleep_str = "" - if avg_sleep_prev > 0: - diff_sleep = avg_sleep_curr - avg_sleep_prev - diff_sleep_str = f" ({'+' if diff_sleep >= 0 else ''}{diff_sleep} мин {'📈' if diff_sleep >= 0 else '📉'})" - - diff_bedtime_str = "" - if avg_bedtime_prev is not None and avg_bedtime_curr is not None: - diff_bedtime = avg_bedtime_curr - avg_bedtime_prev - emoji = "📈" if diff_bedtime <= 0 else "📉" - sign = "-" if diff_bedtime <= 0 else "+" - diff_bedtime_str = f" ({sign}{abs(round(diff_bedtime))} мин {emoji})" - - # 6. Рекорды - record_steps_row = max(steps_curr, key=lambda r: r["total_steps"] or 0, default=None) - record_steps_str = "N/A" - if record_steps_row and record_steps_row["total_steps"] is not None: - wd = format_weekday(record_steps_row["date"]) - record_steps_str = f"{record_steps_row['total_steps']} ({wd})" - - record_sleep_row = max(sleep_curr, key=lambda r: r["total_duration_min"] or 0, default=None) - record_sleep_str = "N/A" - if record_sleep_row and record_sleep_row["total_duration_min"] is not None: - wd = format_weekday(record_sleep_row["date"]) - dur = record_sleep_row["total_duration_min"] - record_sleep_str = f"{dur // 60} ч {dur % 60} мин ({wd})" - - # 7. Форматирование диапазона дат - start_format = start_curr.strftime("%d") - end_format = end_curr.strftime("%d") - if start_curr.month == end_curr.month: - month_name = RU_MONTHS[start_curr.month - 1] - date_range = f"{start_format}–{end_format} {month_name}" - else: - start_month = RU_MONTHS[start_curr.month - 1] - end_month = RU_MONTHS[end_curr.month - 1] - date_range = f"{start_format} {start_month} — {end_format} {end_month}" - - # 8. Сон часы и минуты - avg_sleep_h = avg_sleep_curr // 60 - avg_sleep_m = avg_sleep_curr % 60 - sleep_time_str = f"{avg_sleep_h} ч {avg_sleep_m} мин" if avg_sleep_curr > 0 else "N/A" - - # 9. Сборка текста - lines = [ - f"📊 Итоги недели: {date_range}\n", - f"🚶 Шаги: {avg_steps_curr} в день{diff_steps_str}", - f"😴 Сон: {sleep_time_str}{diff_sleep_str}", - f"⏰ Засыпание: {format_bedtime(avg_bedtime_curr)}{diff_bedtime_str}\n", - f"🏆 Рекорды: • Шаги: {record_steps_str} • Сон: {record_sleep_str}" - ] - return "\n".join(lines) - - -async def weekly_push_loop(app: Application) -> None: - """Send weekly summary push notification every Sunday at 21:00.""" - last_sent_date: str | None = None - while True: - try: - now = datetime.now(LOCAL_TZ) - if now.weekday() == 6 and now.hour == 21 and now.minute == 0: - current_date = now.strftime("%Y-%m-%d") - if last_sent_date != current_date: - last_sent_date = current_date - allowed_ids = SETTINGS.telegram_allowed_user_ids - logger.info("Starting weekly push for users: %s", allowed_ids) - for uid in allowed_ids: - token = current_user_id_var.set(uid) - try: - if health_db_exists(): - push_text = weekly_summary_text() - await app.bot.send_message( - chat_id=uid, - text=push_text, - parse_mode="HTML", - ) - logger.info("Weekly push sent to user %s", uid) - except Exception as e: - logger.error("Failed to send weekly push to user %s: %s", uid, e) - finally: - current_user_id_var.reset(token) - except asyncio.CancelledError: - raise - except Exception as exc: - logger.warning("Weekly push loop encountered error: %s", exc) - - await asyncio.sleep(30) - - -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", - ) - app.bot_data["weekly_push_task"] = asyncio.create_task( - weekly_push_loop(app), - name="weekly-push", - ) - - -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 - - task_push = app.bot_data.get("weekly_push_task") - if task_push: - task_push.cancel() - try: - await task_push - except asyncio.CancelledError: - pass - - -# --------------------------------------------------------------------------- -# Data queries -# --------------------------------------------------------------------------- -def read_status_file() -> dict: - return storage.read_status_file(SETTINGS, get_current_user_id()) - - -def latest_steps() -> sqlite3.Row | None: - return fetch_one( - """ - SELECT date, total_steps, calories, distance_m, last_sync - FROM steps_daily - ORDER BY date DESC - LIMIT 1 - """ - ) - - -def latest_sleep() -> sqlite3.Row | None: - return fetch_one( - """ - SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time, - COALESCE(rem_sleep_min, 0) AS rem_sleep_min, - COALESCE(awake_min, 0) AS awake_min, - COALESCE(total_duration_min, 0) AS total_duration_min, - COALESCE(sleep_score, 0) AS sleep_score - FROM sleep_daily - ORDER BY date DESC - LIMIT 1 - """ - ) - - -def latest_hr() -> sqlite3.Row | None: - return fetch_one("SELECT timestamp, value FROM heart_rate ORDER BY timestamp DESC LIMIT 1") - - -def latest_spo2() -> sqlite3.Row | None: - return fetch_one( - "SELECT timestamp, spo2, type FROM blood_oxygen ORDER BY timestamp DESC LIMIT 1" - ) - - -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( - """ - SELECT MIN(value) AS min_hr - FROM heart_rate - WHERE timestamp >= ? AND timestamp < ? AND value > 30 - """, - (start_epoch, end_epoch), - ) - if row and row["min_hr"]: - return int(row["min_hr"]) - return None - - -def metric_stats(table: str, field: str, start_epoch: int, end_epoch: int) -> sqlite3.Row | None: - return fetch_one( - f""" - SELECT COUNT(*) AS count, - ROUND(AVG({field}), 1) AS avg_value, - MIN({field}) AS min_value, - MAX({field}) AS max_value - FROM {table} - WHERE timestamp >= ? AND timestamp < ? - """, - (start_epoch, end_epoch), - ) - - -def sleep_window_stats(sleep: sqlite3.Row | None) -> tuple[sqlite3.Row | None, sqlite3.Row | None]: - if not sleep or not sleep["start_time"] or not sleep["end_time"]: - return None, None - hr = metric_stats("heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"])) - spo2 = metric_stats( - "blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"]) - ) - return hr, spo2 - - -def day_summary(day_str: str) -> dict: - day_value = parse_day(day_str) - start_epoch, end_epoch = day_bounds(day_value) - steps = fetch_one( - """ - SELECT date, total_steps, calories, distance_m, last_sync - FROM steps_daily - WHERE date = ? - """, - (day_str,), - ) - sleep = fetch_one( - """ - SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time, - COALESCE(rem_sleep_min, 0) AS rem_sleep_min, - COALESCE(awake_min, 0) AS awake_min, - COALESCE(total_duration_min, 0) AS total_duration_min, - COALESCE(sleep_score, 0) AS sleep_score - FROM sleep_daily - WHERE date = ? - """, - (day_str,), - ) - 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 = 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]: - rows = fetch_all( - """ - SELECT date FROM ( - SELECT date FROM steps_daily - UNION - SELECT date FROM sleep_daily - ) - ORDER BY date DESC - LIMIT ? - """, - (limit,), - ) - return [row["date"] for row in rows] - - -def period_bounds(days: int) -> tuple[date, date, int, int]: - end_day = datetime.now(LOCAL_TZ).date() - start_day = end_day - timedelta(days=days - 1) - start_epoch, _ = day_bounds(start_day) - _, end_epoch = day_bounds(end_day) - return start_day, end_day, start_epoch, end_epoch - - -def period_summary(days: int) -> dict: - start_day, end_day, start_epoch, end_epoch = period_bounds(days) - steps = fetch_all( - """ - SELECT date, total_steps, calories, distance_m - FROM steps_daily - WHERE date BETWEEN ? AND ? - ORDER BY date DESC - """, - (start_day.isoformat(), end_day.isoformat()), - ) - sleep = fetch_all( - """ - SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time, - COALESCE(rem_sleep_min, 0) AS rem_sleep_min, - COALESCE(awake_min, 0) AS awake_min, - COALESCE(total_duration_min, 0) AS total_duration_min, - COALESCE(sleep_score, 0) AS sleep_score - FROM sleep_daily - WHERE date BETWEEN ? AND ? - ORDER BY date DESC - """, - (start_day.isoformat(), end_day.isoformat()), - ) - 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, - "end": end_day, - "steps": steps, - "sleep": sleep, - "hr": hr, - "spo2": spo2, - "stress": stress, - "calories": calories_rows, - "weight": weight_rows, - } - - -# --------------------------------------------------------------------------- -# Day emoji helpers — для календаря -# --------------------------------------------------------------------------- -def day_emoji(steps_row: sqlite3.Row | None, sleep_row: sqlite3.Row | None) -> str: - """Один emoji для строки дня в календаре.""" - score = 0 - if steps_row and int(steps_row["total_steps"] or 0) >= STEP_GOAL: - score += 1 - if sleep_row: - total = sleep_total(sleep_row) - if total >= 420: - score += 1 - if score == 2: - return "🟢" - if score == 1: - return "🟡" - return "🔴" - - -# --------------------------------------------------------------------------- -# Dashboard text — «умный» с динамическим заголовком и советом дня -# --------------------------------------------------------------------------- -def main_menu_text() -> str: - steps = latest_steps() - sleep = latest_sleep() - hr = latest_hr() - spo2 = latest_spo2() - stress = latest_stress() - lines = [] - - - - # 1. Шаги - if steps: - steps_count = int(steps["total_steps"]) - 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("") - - # 2. Сон - if sleep: - total_sleep = sleep_total(sleep) - 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("") - - # 3. Пульс, Кислород, Стресс - metrics_parts = [] - if hr: - metrics_parts.append(f"❤️ {int(hr['value'])}") - if spo2: - metrics_parts.append(f"🩸 {float(spo2['spo2']):.0f}%") - if stress: - metrics_parts.append(f"🧘 {int(stress['value'])}") - - if metrics_parts: - lines.append(" · ".join(metrics_parts)) - else: - lines.append("❤️ 🩸 🧘 н/д") - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Day detail text -# --------------------------------------------------------------------------- -def day_text(data: dict) -> str: - steps = data["steps"] - sleep = data["sleep"] - hr = data["hr"] - spo2 = data["spo2"] - day_str = data["date"] - - day_label = relative_day_label(day_str) - - # Заголовок без эмодзи, затем пустая строка - lines = [f"Детали за {esc(day_str)} ({esc(day_label)})", ""] - - # Шаги - if steps: - steps_count = int(steps["total_steps"]) - 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) - light = int(sleep["light_sleep_min"] or 0) - score = int(sleep["sleep_score"] or 0) - score_part = f" · {score}/100" if score 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"😴 Сон: {format_minutes(total_sleep)} (глубокий: {format_minutes(deep)} · легкий: {format_minutes(light)}){score_part}{time_arrow}") - else: - lines.append("😴 Сон: за этот день данных нет") - - lines.append("") - - # Показатели (Пульс, SpO2, Стресс, Вес) - metrics_parts = [] - if hr and hr["count"]: - 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: - metrics_parts.append("❤️ Пульс: за этот день данных нет") - - # SpO2 - if spo2 and spo2["count"]: - 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: - 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})") - - 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} дней", - "", - ] - - if not all_days: - lines.append("Пока пусто: за этот период данных нет.") - return "\n".join(lines) - - for d in all_days: - steps_row = next((r for r in rows if r["date"] == d), None) - 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} {date_formatted} {steps_text} · {sleep_text}") - - lines.append("") - lines.append("Нажми на день ниже для деталей") - return "\n".join(lines) - - -def history_keyboard(days: int = 7) -> InlineKeyboardMarkup: - # Переключатели периода - period_row = [ - InlineKeyboardButton( - "· 7 дней ·" if days == 7 else "7 дней", - callback_data="period_cal:7", - ), - InlineKeyboardButton( - "· 30 дней ·" if days == 30 else "30 дней", - callback_data="period_cal:30", - ), - ] - buttons: list[list[InlineKeyboardButton]] = [period_row] - - day_buttons = [ - 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): - buttons.append(day_buttons[idx: idx + 3]) - - buttons.append([InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")]) - return InlineKeyboardMarkup(buttons) - - -# --------------------------------------------------------------------------- -# Day navigation keyboard -# --------------------------------------------------------------------------- -def day_keyboard(current: date) -> InlineKeyboardMarkup: - today = datetime.now(LOCAL_TZ).date() - prev_day = current - timedelta(days=1) - next_day = current + timedelta(days=1) - - prev_btn = InlineKeyboardButton(f"◀️ {prev_day.isoformat()}", callback_data=f"day:{prev_day.isoformat()}") - if current >= today: - next_btn = InlineKeyboardButton("📊 Главная", callback_data="menu:main") - else: - next_btn = InlineKeyboardButton(f"{next_day.isoformat()} ▶️", callback_data=f"day:{next_day.isoformat()}") - - return InlineKeyboardMarkup([ - [prev_btn, next_btn], - [InlineKeyboardButton("📅 Календарь", callback_data="menu:history")], - ]) - - -# --------------------------------------------------------------------------- -# Sleep detail text -# --------------------------------------------------------------------------- -def latest_sleep_text() -> str: - sleep = latest_sleep() - if not sleep: - return "😴 Ночной сон\n\nПока нет данных." - - hr, spo2 = sleep_window_stats(sleep) - total_sleep = sleep_total(sleep) - deep = int(sleep["deep_sleep_min"] or 0) - rem = int(sleep["rem_sleep_min"] or 0) - score = int(sleep["sleep_score"] or 0) - - 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: - 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("") - - 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}") - - spo2_str = "н/д" - if spo2 and spo2["count"]: - spo2_str = f"ср. {int(spo2['avg_value'])}% · мин. {int(spo2['min_value'])}%" - lines.append(f"SpO2 во сне {spo2_str}") - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Analytics / Trends -# --------------------------------------------------------------------------- -def period_text(days: int) -> str: - summary = period_summary(days) - steps = summary["steps"] - sleep_rows = summary["sleep"] - - total_steps = sum(int(row["total_steps"] or 0) for row in steps) - avg_steps = round(total_steps / len(steps)) if steps else 0 - best_steps = max(steps, key=lambda r: int(r["total_steps"] or 0), default=None) - - sleep_totals = [ - sleep_total(row) for row in sleep_rows - ] - avg_sleep = round(sum(sleep_totals) / len(sleep_totals)) if sleep_totals else None - best_sleep = max(sleep_totals) if sleep_totals else None - goal_days = sum(1 for r in steps if int(r["total_steps"] or 0) >= STEP_GOAL) - - 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"📊 Тренды · {period_label} · {date_range}", - "", - f"🚶 Шаги всего {total_steps:,}".replace(",", " "), - f" В среднем / день {avg_steps:,}".replace(",", " "), - f" Норма {STEP_GOAL // 1000}k {goal_days} из {len(steps)} дней", - ] - if best_steps: - 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("") - - 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("") - hr_str = "н/д" - if hr and hr["count"]: - 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"]: - 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) - - -def trends_keyboard(days: int) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - [ - [ - InlineKeyboardButton( - "· 7 дней ·" if days == 7 else "7 дней", - callback_data="period:7d", - ), - InlineKeyboardButton( - "· 30 дней ·" if days == 30 else "30 дней", - 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" - 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("💾 Экспорт ZIP", callback_data="menu:export"), - InlineKeyboardButton("📊 Статус БД", callback_data="menu:db_status"), - ], - [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], - ] - ) - - -def db_status_text() -> str: - status = read_status_file() - lines = ["🧰 Статус базы данных", ""] - if not health_db_exists(): - return "🧰 Статус базы данных\n\nБаза пока не создана." - - conn = health_conn() - try: - 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 = ?", - (table,), - ).fetchone() - if not exists: - lines.append(f"• {table}: таблицы нет") - continue - count = conn.execute(f"SELECT COUNT(*) AS count FROM {table}").fetchone()["count"] - lines.append(f"• {table}: {count:,} строк".replace(",", " ")) - finally: - conn.close() - - last_sync_epoch = status.get("last_sync") - last_sync = format_epoch(last_sync_epoch) if last_sync_epoch else status.get("last_sync_time", "н/д") - lines.extend( - [ - "", - f"Путь: {esc(DB_PATH)}", - f"Последний синк: {esc(last_sync)}", - ] - ) - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Export -# --------------------------------------------------------------------------- -def zip_export() -> io.BytesIO: - return storage.zip_export(SETTINGS, get_current_user_id()) - - -# --------------------------------------------------------------------------- -# Xiaomi onboarding -# --------------------------------------------------------------------------- -def onboarding_text() -> str: - return ( - "🔐 Авторизация Xiaomi\n\n" - "Для первого запуска нужен вход в Xiaomi Fitness. Нажми кнопку ниже, подтверди вход, " - "а я дождусь ответа и сразу запущу синхронизацию." - ) - - -def onboarding_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - [[InlineKeyboardButton("🔐 Войти в Xiaomi", callback_data="auth:start")]] - ) - - -def normalize_login_url(url: str | None) -> str: - if not url: - return "" - url = url.strip() - if url.startswith("//"): - return f"https:{url}" - return url - - -def xiaomi_wait_keyboard(login_url: str, qr_image_url: str) -> InlineKeyboardMarkup: - rows: list[list[InlineKeyboardButton]] = [] - login_url = normalize_login_url(login_url) - qr_image_url = normalize_login_url(qr_image_url) - if login_url: - rows.append([InlineKeyboardButton("🔐 Открыть вход Xiaomi", url=login_url)]) - if qr_image_url: - rows.append([InlineKeyboardButton("▦ Открыть QR-код", url=qr_image_url)]) - return InlineKeyboardMarkup(rows or [[InlineKeyboardButton("🔄 Повторить", callback_data="auth:start")]]) - - -def auth_retry_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - [ - [InlineKeyboardButton("🔐 Войти заново", callback_data="auth:relogin")], - [InlineKeyboardButton("⬅️ Сервис", callback_data="menu:more")], - ] - ) - - -async def show_onboarding(update: Update, context: ContextTypes.DEFAULT_TYPE, force_new: bool = False) -> None: - await update_menu(update, context, onboarding_text(), onboarding_keyboard(), force_new=force_new) - - -async def start_xiaomi_login(update: Update, context: ContextTypes.DEFAULT_TYPE, *, force: bool = False) -> None: - if has_xiaomi_token() and not force: - await show_main_menu(update, context) - return - if AUTH_LOCK.locked(): - await update_menu( - update, - context, - "🔐 Авторизация Xiaomi\n\nУже жду подтверждение входа. Открой ссылку из предыдущего сообщения.", - onboarding_keyboard(), - ) - return - - async with AUTH_LOCK: - if has_xiaomi_token() and not force: - await show_main_menu(update, context) - return - - await update_menu( - update, - context, - "🔐 Авторизация Xiaomi\n\nГотовлю ссылку входа…", - None, - ) - auth = XiaomiAuth() - - async def qr_callback(qr_image_url: str, login_url: str) -> None: - await update_menu( - update, - context, - "🔐 Авторизация Xiaomi\n\nОткрой ссылку, подтверди вход и вернись сюда. Я жду результат.", - xiaomi_wait_keyboard(login_url, qr_image_url), - ) - - try: - token = await auth.login_qr(qr_callback=qr_callback, max_wait=300) - token_path = get_xiaomi_token_path() - if token_path is None: - raise ConfigError("Не удалось определить путь для Xiaomi token") - save_auth_token(token, token_path) - await update_menu( - update, - context, - "✅ Авторизация Xiaomi\n\nВход подтверждён. Запускаю первую синхронизацию…", - None, - ) - await run_initial_sync_after_login(update, context) - except Exception as e: - logger.exception("Xiaomi login failed") - await update_menu( - update, - context, - f"⚠️ Авторизация Xiaomi\n\nНе удалось войти: {esc(e)}", - auth_retry_keyboard(), - ) - finally: - await auth.close() - - -async def run_initial_sync_after_login(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if SYNC_LOCK.locked(): - await update_menu( - update, - context, - "✅ Авторизация Xiaomi\n\nВход готов. Синхронизация уже идёт, открою меню.", - main_keyboard(), - ) - return - - async with SYNC_LOCK: - import dataclasses - initial_settings = dataclasses.replace(SETTINGS, query_duration=30) - result = await run_sync(get_current_user_id(), initial_settings) - - if result.success: - await show_main_menu(update, context) - return - - detail = f"\n\nПричина: {esc(result.error)}" if result.error else "" - await update_menu( - update, - context, - f"⚠️ Первая синхронизация\n\nАвторизация сохранена, но данные не обновились.{detail}", - auth_retry_keyboard(), - ) - - -# --------------------------------------------------------------------------- -# 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:sleep"), - InlineKeyboardButton("📊 За неделю", callback_data="menu:trends"), - ], - [ - InlineKeyboardButton("📅 История", callback_data="menu:history"), - InlineKeyboardButton("⚙️ Настройки", callback_data="menu:more"), - ], - ] - ) - - -def back_keyboard(back_to: str = "menu:main") -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data=back_to)]]) - - -# --------------------------------------------------------------------------- -# Handlers: show_* functions -# --------------------------------------------------------------------------- -async def show_main_menu( - update: Update, context: ContextTypes.DEFAULT_TYPE, force_new: bool = False -) -> None: - await update_menu(update, context, main_menu_text(), main_keyboard(), force_new=force_new) - - -async def show_history( - update: Update, context: ContextTypes.DEFAULT_TYPE, days: int = 7 -) -> None: - await update_menu(update, context, history_text(days), history_keyboard(days)) - - -async def show_day( - update: Update, context: ContextTypes.DEFAULT_TYPE, day_str: str -) -> None: - day_value = parse_day(day_str) - await update_menu( - update, - context, - day_text(day_summary(day_str)), - day_keyboard(day_value), - ) - - -async def run_manual_sync( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - if not has_xiaomi_token(): - await show_onboarding(update, context) - return - if not run_sync: - await update_menu( - update, - context, - "🔄 Синхронизация\n\nМодуль синхронизации не найден.", - back_keyboard("menu:more"), - ) - return - if SYNC_LOCK.locked(): - await update_menu( - update, - context, - "🔄 Синхронизация\n\nУже идёт обновление. Подожди завершения.", - back_keyboard("menu:more"), - ) - return - - await update_menu( - update, - context, - "🔄 Синхронизация\n\nЗабираю данные из Xiaomi Fitness…", - back_keyboard("menu:more"), - ) - async with SYNC_LOCK: - try: - result = await run_sync(get_current_user_id(), SETTINGS) - except Exception as e: - logger.exception("Manual sync failed") - await update_menu( - update, - context, - f"🔄 Синхронизация\n\nОшибка запуска: {esc(e)}", - back_keyboard("menu:more"), - ) - return - - if result.success: - await update_menu( - update, - context, - "✅ Синхронизация\n\nГотово, данные обновлены.", - InlineKeyboardMarkup( - [ - [InlineKeyboardButton("📊 На главную", callback_data="menu:main")], - [InlineKeyboardButton("⬅️ Сервис", callback_data="menu:more")], - ] - ), - ) - else: - detail = f"\n\nПричина: {esc(result.error)}" if result.error else "" - keyboard = auth_retry_keyboard() if "Token" in (result.error or "") else back_keyboard("menu:more") - await update_menu( - update, - context, - f"⚠️ Синхронизация\n\nНе вышло обновиться.{detail}", - keyboard, - ) - - -async def export_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not update.effective_chat: - return - if not health_db_exists(): - await update_menu( - update, - context, - "💾 Экспорт\n\nБаза данных пока не создана.", - back_keyboard("menu:more"), - ) - return - - await update_menu(update, context, "💾 Экспорт\n\nСобираю ZIP…", None) - try: - export_file = zip_export() - if export_file.getbuffer().nbytes == 0: - await update_menu( - update, - context, - "💾 Экспорт\n\nВ базе нет данных.", - back_keyboard("menu:more"), - ) - return - await context.bot.send_document( - chat_id=update.effective_chat.id, - document=export_file, - filename=export_file.name, - caption="💚 MiBand Health CSV Export", - ) - await update_menu( - update, - context, - "✅ Экспорт\n\nZIP с CSV-таблицами отправлен выше.", - back_keyboard("menu:more"), - ) - except Exception as e: - logger.exception("Export failed") - await update_menu( - update, - context, - f"⚠️ Экспорт\n\nОшибка: {esc(e)}", - back_keyboard("menu:more"), - ) - - -# --------------------------------------------------------------------------- -# Command handlers -# --------------------------------------------------------------------------- -@with_user_context -async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not is_allowed(update): - uid = update.effective_user.id if update.effective_user else "unknown" - logger.warning("Unauthorized access from user: %s", uid) - return - await safe_delete(update.message) - if not has_xiaomi_token(): - await show_onboarding(update, context, force_new=True) - return - await show_main_menu(update, context, force_new=True) - - -@with_user_context -async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not is_allowed(update): - return - await safe_delete(update.message) - await update_menu(update, context, db_status_text(), back_keyboard("menu:more")) - - -@with_user_context -async def cmd_sync(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not is_allowed(update): - return - await safe_delete(update.message) - if not has_xiaomi_token(): - await show_onboarding(update, context) - return - await run_manual_sync(update, context) - - -@with_user_context -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 - - 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) - - -# --------------------------------------------------------------------------- -# Callback router -# --------------------------------------------------------------------------- -@with_user_context -async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - query = update.callback_query - if query: - await query.answer() - if not is_allowed(update): - return - - data = query.data if query else "menu:main" - - if data in {"auth:start", "auth:relogin"}: - await start_xiaomi_login(update, context, force=data == "auth:relogin") - - elif not has_xiaomi_token(): - await show_onboarding(update, context) - - elif data == "menu:main": - await show_main_menu(update, context) - - elif data == "menu:history": - await show_history(update, context, 7) - - elif data.startswith("period_cal:"): - # Переключение числа дней в Календаре - days = int(data.split(":")[1]) - await show_history(update, context, days) - - elif data == "menu:sleep": - await update_menu( - update, - context, - latest_sleep_text(), - back_keyboard(), - ) - - elif data == "menu:trends": - await update_menu(update, context, period_text(7), trends_keyboard(7)) - - elif data.startswith("period:"): - 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:"): - try: - await show_day(update, context, data.split(":", 1)[1]) - except Exception as e: - logger.exception("Failed to show day") - await update_menu( - update, - context, - f"📊 День\n\nНе удалось открыть: {esc(e)}", - 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()) - - elif data == "menu:sync": - await run_manual_sync(update, context) - - elif data == "menu:export": - await export_data(update, context) - - elif data == "menu:db_status": - await update_menu(update, context, db_status_text(), back_keyboard("menu:more")) - - else: - await show_main_menu(update, context) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- -def main() -> None: - global SETTINGS, BOT_TOKEN, ALLOWED_USER_ID, DB_PATH - try: - SETTINGS = Settings.from_env(require_bot=True) - except ConfigError as exc: - logger.error("%s", exc) - sys.exit(1) - BOT_TOKEN = SETTINGS.telegram_bot_token - ALLOWED_USER_ID = SETTINGS.telegram_allowed_user_id - allowed_ids = SETTINGS.telegram_allowed_user_ids - if allowed_ids: - print(f"Запуск бота для пользователей: {allowed_ids}...") - for uid in allowed_ids: - db_p = SETTINGS.user_db_path(uid) - storage.init_health_db(db_p) - 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) - .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)) - app.add_handler(CallbackQueryHandler(handle_callback)) - app.add_handler(MessageHandler(filters.ALL, handle_message)) - app.run_polling(drop_pending_updates=True) - - -if __name__ == "__main__": - main() diff --git a/miband_tracker/bot/formatting.py b/miband_tracker/bot/formatting.py deleted file mode 100644 index e9e6767..0000000 --- a/miband_tracker/bot/formatting.py +++ /dev/null @@ -1,180 +0,0 @@ -# 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/config.py b/miband_tracker/config.py deleted file mode 100644 index 7c2d129..0000000 --- a/miband_tracker/config.py +++ /dev/null @@ -1,169 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import os -from dataclasses import dataclass, field -from pathlib import Path - - -def _load_local_env(filename: str = "secrets.env") -> None: - """Load KEY=VALUE pairs from a local .env file into os.environ. - - Only sets variables that are NOT already present in the environment - (explicit env always wins). Skips blank lines and comments (#). - """ - env_file = Path(filename) - if not env_file.exists(): - return - try: - for raw_line in env_file.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, value = line.partition("=") - key = key.strip() - if key and key not in os.environ: - os.environ[key] = value.strip() - except Exception: - pass # Never crash on env-file read failure - - -class ConfigError(ValueError): - """Invalid runtime configuration.""" - - -def _env_bool(name: str, default: bool = False) -> bool: - value = os.environ.get(name) - if value is None: - return default - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def _env_int(name: str, default: int, *, min_value: int | None = None) -> int: - raw = os.environ.get(name, str(default)).strip() - try: - value = int(raw) - except ValueError as exc: - raise ConfigError(f"{name} должен быть целым числом") from exc - if min_value is not None and value < min_value: - raise ConfigError(f"{name} должен быть не меньше {min_value}") - return value - - -@dataclass(frozen=True) -class Settings: - data_dir: Path - db_path: Path - status_path: Path - bot_state_db_path: Path - telegram_bot_token: str - telegram_allowed_user_ids: list[int] = field(default_factory=list) - sync_interval: int = 900 - query_duration: int = 2 - enable_fds_sleep_details: bool = True - telegram_allowed_user_id: int | None = None - - def __post_init__(self) -> None: - if self.telegram_allowed_user_id is not None and not self.telegram_allowed_user_ids: - object.__setattr__(self, "telegram_allowed_user_ids", [self.telegram_allowed_user_id]) - elif self.telegram_allowed_user_ids and self.telegram_allowed_user_id is None: - object.__setattr__(self, "telegram_allowed_user_id", self.telegram_allowed_user_ids[0]) - - @classmethod - def from_env(cls, *, require_bot: bool = False) -> Settings: - # Load local secrets.env (if present) before reading env vars. - # This makes Python the single source of truth on all platforms. - _load_local_env() - # Auto-detect local vs Docker mode: if DATA_DIR is not set, use ./data - # when running locally (secrets.env present or ./data already exists), - # otherwise fall back to the Docker default /opt/miband-tracker/data. - _default_data = ( - "./data" - if (Path("secrets.env").exists() or Path("data").is_dir()) - else "/opt/miband-tracker/data" - ) - data_dir = Path(os.environ.get("DATA_DIR", _default_data)) - - # Read from TELEGRAM_ALLOWED_USER_IDS or legacy TELEGRAM_ALLOWED_USER_ID - raw_ids = os.environ.get("TELEGRAM_ALLOWED_USER_IDS", os.environ.get("TELEGRAM_ALLOWED_USER_ID", "")) - allowed_user_ids = parse_user_ids(raw_ids, required=False) - - # Если ID не задан в env, пробуем загрузить из файла allowed_user.id - allowed_user_file = data_dir / "allowed_user.id" - if not allowed_user_ids and allowed_user_file.exists(): - try: - raw_file = allowed_user_file.read_text(encoding="utf-8").strip() - allowed_user_ids = parse_user_ids(raw_file) - except Exception: - pass - - bot_token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() - if require_bot and not bot_token: - raise ConfigError("TELEGRAM_BOT_TOKEN не задан") - return cls( - data_dir=data_dir, - db_path=Path(os.environ.get("DB_PATH", str(data_dir / "miband.db"))), - status_path=Path(os.environ.get("STATUS_PATH", str(data_dir / "status.json"))), - bot_state_db_path=Path( - os.environ.get( - "BOT_STATE_DB_PATH", - str(data_dir / "fitness_bot_state.db"), - ) - ), - telegram_bot_token=bot_token, - telegram_allowed_user_ids=allowed_user_ids, - sync_interval=_env_int("SYNC_INTERVAL", 900, min_value=0), - query_duration=_env_int("QUERY_DURATION", 2, min_value=1), - enable_fds_sleep_details=_env_bool("ENABLE_FDS_SLEEP_DETAILS", default=True), - ) - - def require_user_id(self, user_id: int | None = None) -> int: - resolved = user_id if user_id is not None else self.telegram_allowed_user_id - if resolved is None: - raise ConfigError("Нет доступных пользователей (TELEGRAM_ALLOWED_USER_IDS пуст)") - return int(resolved) - - def token_path(self, user_id: int | None = None) -> Path: - uid = self.require_user_id(user_id) - preferred = self.data_dir / f"token_{uid}.json" - legacy = self.data_dir / "token.json" - if preferred.exists() or not legacy.exists(): - return preferred - return legacy - - def user_db_path(self, user_id: int | None = None) -> Path: - uid = self.require_user_id(user_id) - preferred = self.data_dir / f"miband_{uid}.db" - if preferred.exists(): - return preferred - return self.db_path - - def user_status_path(self, user_id: int | None = None) -> Path: - uid = self.require_user_id(user_id) - preferred = self.data_dir / f"status_{uid}.json" - if preferred.exists(): - return preferred - return self.status_path - - def canonical_user_db_path(self, user_id: int | None = None) -> Path: - return self.data_dir / f"miband_{self.require_user_id(user_id)}.db" - - def canonical_user_status_path(self, user_id: int | None = None) -> Path: - return self.data_dir / f"status_{self.require_user_id(user_id)}.json" - - -def parse_user_ids(raw: str, *, required: bool = False) -> list[int]: - values = [item.strip() for item in raw.split(",") if item.strip()] - if not values: - if required: - raise ConfigError("TELEGRAM_ALLOWED_USER_IDS не задан или пуст") - return [] - res = [] - for val in values: - try: - res.append(int(val)) - except ValueError as exc: - raise ConfigError("Каждый ID в TELEGRAM_ALLOWED_USER_IDS должен быть целым числом") from exc - return res diff --git a/miband_tracker/fds.py b/miband_tracker/fds.py deleted file mode 100644 index 3a1e5ed..0000000 --- a/miband_tracker/fds.py +++ /dev/null @@ -1,315 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import base64 -import hashlib -import struct -from collections.abc import Callable -from typing import Any - -import httpx -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad - -FDS_SLEEP_DAILY_TYPE = 8 -FDS_ALL_DAY_FILE_TYPE = 0 -TIMEZONE_15MIN_LIMIT = 96 -SECONDS_PER_15_MINUTES = 900 -SLEEP_ASSIST_HEADER_LEN = 4 -AES_KEY_LEN = 16 -XIAOMI_FDS_AES_IV = b"1234567887654321" -GZIP_MAGIC = b"\x1f\x8b" -ZLIB_MAGICS = (b"\x78\x9c", b"\x78\x01") - -# Xiaomi FDS sleep detail payloads are reverse-engineered and parsed as best-effort. -SLEEP_VALID_TYPES = (0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5) - - -def normalize_timezone_to_15min(timezone_value: int) -> int: - # Xiaomi sleep segments already use 15-minute units; token/bootstrap paths may use seconds. - if abs(timezone_value) <= TIMEZONE_15MIN_LIMIT: - return int(timezone_value) - return int(timezone_value / SECONDS_PER_15_MINUTES) - - -def gen_data_id_key_bytes( - timestamp: int, - tz_in_15min: int, - daily_type: int, - file_type: int, - data_type: int = 0, - sport_type: int = 0, -) -> bytes: - data_type_byte = (data_type << 7) + (sport_type << 2) + (daily_type << 2) + file_type - return struct.pack(" tuple[dict[str, Any] | None, int]: - if pos + SLEEP_ASSIST_HEADER_LEN > len(payload): - return None, pos - - interval = struct.unpack_from("= 2: - actual_byte_count += 4 - - if pos + actual_byte_count > len(payload): - return None, pos - - start_time = 0 - if version >= 2: - start_time = struct.unpack_from(" dict[str, Any] | None: - if len(payload) < 9: - return None - - try: - return _parse_all_day_sleep_bytes(payload) - except (IndexError, struct.error): - return None - - -def _parse_all_day_sleep_bytes(payload: bytes) -> dict[str, Any]: - _ = struct.unpack_from(" 0 - - pos = 9 - report_data = {"sleepFinish": payload[pos] == 1} - pos += 1 - - report_data["deviceBedTime"] = struct.unpack_from(" bytes | None: - tz_in_15min = normalize_timezone_to_15min(timezone_value) - - sid = str(relative_uid) - key_bytes = gen_data_id_key_bytes( - timestamp, - tz_in_15min, - daily_type=FDS_SLEEP_DAILY_TYPE, - file_type=FDS_ALL_DAY_FILE_TYPE, - ) - suffix_b64 = base64.urlsafe_b64encode(key_bytes).decode().rstrip("=") - - sha1_sid = hashlib.sha1(sid.encode()).digest() - sha1_b64 = base64.urlsafe_b64encode(sha1_sid).decode().rstrip("=") - suffix = f"{suffix_b64}_{sha1_b64}" - - param_dict = { - "did": sid, - "relative_uid": relative_uid, - "items": [ - { - "timestamp": timestamp, - "suffix": suffix, - } - ], - } - - resp = await client._request( - "GET", - "/healthapp/service/gen_download_url", - params=param_dict, - ) - - result = resp.get("result", {}) - log_fn( - "gen_download_url returned " - f"code={resp.get('code')} message={resp.get('message')} " - f"result_keys_count={len(result)}" - ) - server_key = f"{suffix}_{timestamp}" - file_info = result.get(server_key) - if not file_info: - log_fn("No FDS info found for requested sleep segment.") - return None - - url = file_info.get("url") - obj_key_b64 = file_info.get("obj_key") - if not url: - log_fn("FDS info missing download URL.") - return None - - async with httpx.AsyncClient(timeout=30.0) as http_client: - file_resp = await http_client.get(url) - if file_resp.status_code != 200: - log_fn(f"Optional FDS sleep detail unavailable: HTTP {file_resp.status_code}; skipping.") - return None - - enc_content = file_resp.content - - log_fn(f"Downloaded FDS content length: {len(enc_content)}") - - if obj_key_b64: - try: - encrypted_bytes = android_base64_urlsafe(enc_content) - obj_key_bytes = android_base64_urlsafe(obj_key_b64) - - if len(obj_key_bytes) != AES_KEY_LEN: - log_fn(f"Invalid obj_key length: {len(obj_key_bytes)}") - return None - - cipher = AES.new(obj_key_bytes, AES.MODE_CBC, XIAOMI_FDS_AES_IV) - decrypted = cipher.decrypt(encrypted_bytes) - return unpad(decrypted, AES.block_size) - except Exception as exc: - log_fn(f"AES decryption or unpadding failed: {exc}") - return None - - log_fn("No obj_key in file_info. Checking if content is compressed (gzip/zlib) or raw...") - return decompress_or_raw_fds_content(enc_content, log_fn) - - -def android_base64_urlsafe(value: str | bytes) -> bytes: - if isinstance(value, bytes): - value = value.decode("utf-8", "ignore") - value = value.strip().replace("\n", "").replace("\r", "") - value += "=" * (-len(value) % 4) - return base64.urlsafe_b64decode(value) - - -def decompress_or_raw_fds_content(content: bytes, log_fn: Callable[[str], object] = print) -> bytes: - if content.startswith(GZIP_MAGIC): - try: - import gzip - - decompressed = gzip.decompress(content) - log_fn(f"Successfully decompressed GZIP FDS content. Length: {len(decompressed)}") - return decompressed - except Exception as exc: - log_fn(f"Failed to decompress GZIP FDS content: {exc}") - - elif content.startswith(ZLIB_MAGICS): - try: - import zlib - - decompressed = zlib.decompress(content) - log_fn(f"Successfully decompressed ZLIB FDS content. Length: {len(decompressed)}") - return decompressed - except Exception as exc: - log_fn(f"Failed to decompress ZLIB FDS content: {exc}") - - return content diff --git a/miband_tracker/lock.py b/miband_tracker/lock.py deleted file mode 100644 index a43f20b..0000000 --- a/miband_tracker/lock.py +++ /dev/null @@ -1,68 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import os -import time -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path - -# Попытка импорта fcntl (для Unix-систем) или msvcrt (для Windows) -try: - import fcntl - _HAS_FCNTL = True -except ImportError: - _HAS_FCNTL = False - try: - import msvcrt - _HAS_MSVCRT = True - except ImportError: - _HAS_MSVCRT = False - - -class LockUnavailable(RuntimeError): - """Raised when another process already owns the sync lock.""" - - -@contextmanager -def exclusive_file_lock(path: Path) -> Iterator[None]: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a+", encoding="utf-8") as lock_file: - fd = lock_file.fileno() - - if _HAS_FCNTL: - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError as exc: - raise LockUnavailable(f"Lock is already held: {path}") from exc - elif _HAS_MSVCRT: - try: - lock_file.seek(0) - # Блокируем первые 64 байта файла без ожидания (non-blocking) - msvcrt.locking(fd, msvcrt.LK_NBLCK, 64) - except OSError as exc: - raise LockUnavailable(f"Lock is already held: {path}") from exc - else: - # Резервный вариант, если блокировки недоступны на платформе - pass - - try: - lock_file.seek(0) - lock_file.truncate() - lock_file.write(f"pid={os.getpid()} time={int(time.time())}\n") - lock_file.flush() - yield - finally: - if _HAS_FCNTL: - try: - fcntl.flock(fd, fcntl.LOCK_UN) - except Exception: - pass - elif _HAS_MSVCRT: - try: - lock_file.seek(0) - msvcrt.locking(fd, msvcrt.LK_UNLCK, 64) - except Exception: - pass diff --git a/miband_tracker/secure_files.py b/miband_tracker/secure_files.py deleted file mode 100644 index 9eeb6a2..0000000 --- a/miband_tracker/secure_files.py +++ /dev/null @@ -1,68 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import json -import os -import tempfile -from pathlib import Path -from typing import Any - -SECRET_FILE_MODE = 0o600 - - -def write_text_atomic(path: Path, text: str, *, mode: int | None = None) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) - tmp_path = Path(tmp_name) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(text) - fh.flush() - os.fsync(fh.fileno()) - if mode is not None: - os.chmod(tmp_path, mode) - os.replace(tmp_path, path) - if mode is not None: - os.chmod(path, mode) - finally: - try: - tmp_path.unlink() - except FileNotFoundError: - pass - - -def write_json_atomic(path: Path, data: Any, *, mode: int | None = None) -> None: - text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" - write_text_atomic(path, text, mode=mode) - - -def write_secret_json(path: Path, data: dict[str, Any]) -> None: - write_json_atomic(path, data, mode=SECRET_FILE_MODE) - - -def save_auth_token(token: Any, path: Path) -> None: - current = _read_current_token_payload(path) - if hasattr(token, "model_dump"): - payload = token.model_dump() - elif hasattr(token, "model_dump_json"): - payload = json.loads(token.model_dump_json()) - else: - payload = dict(token) - - for key in ("target_relative_uid",): - if key in current and key not in payload: - payload[key] = current[key] - - write_secret_json(path, payload) - - -def _read_current_token_payload(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except Exception: - return {} - return payload if isinstance(payload, dict) else {} diff --git a/miband_tracker/stdio.py b/miband_tracker/stdio.py deleted file mode 100644 index 4432843..0000000 --- a/miband_tracker/stdio.py +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import os -import sys -from typing import TextIO - - -def configure_utf8_stdio() -> None: - os.environ.setdefault("PYTHONUTF8", "1") - os.environ.setdefault("PYTHONIOENCODING", "utf-8") - - for stream in (sys.stdout, sys.stderr): - reconfigure = getattr(stream, "reconfigure", None) - if reconfigure is None: - continue - try: - reconfigure(encoding="utf-8", errors="backslashreplace") - except Exception: - pass - - -def safe_print(text: str, *, file: TextIO | None = None, flush: bool = False) -> None: - stream = file or sys.stdout - try: - print(text, file=stream, flush=flush) - return - except UnicodeEncodeError: - encoding = getattr(stream, "encoding", None) or "utf-8" - fallback = text.encode(encoding, errors="backslashreplace").decode(encoding, errors="replace") - print(fallback, file=stream, flush=flush) diff --git a/miband_tracker/storage.py b/miband_tracker/storage.py deleted file mode 100644 index 1be8a6b..0000000 --- a/miband_tracker/storage.py +++ /dev/null @@ -1,250 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import csv -import io -import json -import sqlite3 -import zipfile -from contextlib import contextmanager -from datetime import datetime -from pathlib import Path -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", "calories_daily", "weight", "workouts"] - - -@contextmanager -def sqlite_conn(path: Path, *, row_factory: bool = True): - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path) - conn.execute("PRAGMA busy_timeout = 5000") - if row_factory: - conn.row_factory = sqlite3.Row - try: - yield conn - finally: - conn.close() - - -def init_health_db(db_path: Path) -> None: - with sqlite_conn(db_path, row_factory=False) as conn: - cursor = conn.cursor() - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS steps_daily ( - date TEXT PRIMARY KEY, - total_steps INTEGER, - calories REAL, - distance_m REAL, - last_sync INTEGER - ) - """ - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS steps_detail ( - timestamp INTEGER PRIMARY KEY, - steps INTEGER, - calories REAL, - distance_m REAL, - activity_type TEXT - ) - """ - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS sleep_daily ( - date TEXT PRIMARY KEY, - light_sleep_min INTEGER, - deep_sleep_min INTEGER, - start_time INTEGER, - end_time INTEGER, - rem_sleep_min INTEGER DEFAULT 0, - awake_min INTEGER DEFAULT 0, - total_duration_min INTEGER DEFAULT 0, - sleep_score INTEGER DEFAULT 0 - ) - """ - ) - _ensure_columns(cursor, "sleep_daily", { - "rem_sleep_min": "INTEGER DEFAULT 0", - "awake_min": "INTEGER DEFAULT 0", - "total_duration_min": "INTEGER DEFAULT 0", - "sleep_score": "INTEGER DEFAULT 0", - }) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS sleep_stages ( - start_time INTEGER PRIMARY KEY, - stop_time INTEGER, - stage TEXT, - duration_min INTEGER - ) - """ - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS heart_rate ( - timestamp INTEGER PRIMARY KEY, - value INTEGER - ) - """ - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS stress ( - timestamp INTEGER PRIMARY KEY, - value INTEGER - ) - """ - ) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS blood_oxygen ( - timestamp INTEGER PRIMARY KEY, - spo2 REAL, - type TEXT - ) - """ - ) - 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() - - -def _ensure_columns(cursor: sqlite3.Cursor, table: str, columns: dict[str, str]) -> None: - existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})").fetchall()} - for name, definition in columns.items(): - if name not in existing: - cursor.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") - - -def init_state_db(settings: Settings) -> None: - with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS user_menu ( - user_id INTEGER PRIMARY KEY, - menu_message_id INTEGER NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) - conn.commit() - - -def get_user_menu_msg_id(settings: Settings, user_id: int) -> int | None: - try: - with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn: - row = conn.execute( - "SELECT menu_message_id FROM user_menu WHERE user_id = ?", - (user_id,), - ).fetchone() - return int(row[0]) if row else None - except sqlite3.Error: - return None - - -def set_user_menu_msg_id(settings: Settings, user_id: int, msg_id: int) -> None: - with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn: - conn.execute( - """ - INSERT OR REPLACE INTO user_menu (user_id, menu_message_id, updated_at) - VALUES (?, ?, ?) - """, - (user_id, msg_id, datetime.now(LOCAL_TZ).isoformat(timespec="seconds")), - ) - conn.commit() - - -def health_db_exists(settings: Settings, user_id: int | None = None) -> bool: - return settings.user_db_path(user_id).exists() - - -def fetch_one(settings: Settings, query: str, params: tuple = (), user_id: int | None = None) -> sqlite3.Row | None: - if not health_db_exists(settings, user_id): - return None - with sqlite_conn(settings.user_db_path(user_id)) as conn: - return conn.execute(query, params).fetchone() - - -def fetch_all(settings: Settings, query: str, params: tuple = (), user_id: int | None = None) -> list[sqlite3.Row]: - if not health_db_exists(settings, user_id): - return [] - with sqlite_conn(settings.user_db_path(user_id)) as conn: - return conn.execute(query, params).fetchall() - - -def read_status_file(settings: Settings, user_id: int | None = None) -> dict: - try: - return json.loads(settings.user_status_path(user_id).read_text(encoding="utf-8")) - except Exception: - return {} - - -def zip_export(settings: Settings, user_id: int | None = None) -> io.BytesIO: - bio = io.BytesIO() - with sqlite_conn(settings.user_db_path(user_id)) as conn: - with zipfile.ZipFile(bio, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: - for table in EXPORT_TABLES: - exists = conn.execute( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - (table,), - ).fetchone() - if not exists: - continue - cursor = conn.execute(f"SELECT * FROM {table} ORDER BY 1") - rows = cursor.fetchall() - if not rows: - continue - csv_text = io.StringIO() - writer = csv.writer(csv_text) - writer.writerow([desc[0] for desc in cursor.description]) - writer.writerows([tuple(row) for row in rows]) - archive.writestr(f"{table}.csv", csv_text.getvalue()) - bio.seek(0) - bio.name = f"miband-health-{datetime.now(LOCAL_TZ).strftime('%Y%m%d-%H%M')}.zip" - return bio diff --git a/miband_tracker/sync.py b/miband_tracker/sync.py deleted file mode 100644 index 7c4bdb2..0000000 --- a/miband_tracker/sync.py +++ /dev/null @@ -1,630 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import asyncio -import datetime -import json -import os -import time -from dataclasses import dataclass, field -from pathlib import Path - -from mi_fitness import MiHealthClient, TokenExpiredError - -from .config import ConfigError, Settings -from .fds import download_and_decrypt_sleep_details, parse_all_day_sleep_bytes -from .lock import LockUnavailable, exclusive_file_lock -from .secure_files import save_auth_token, write_json_atomic, write_secret_json -from .stdio import safe_print -from .storage import init_health_db, sqlite_conn - - -@dataclass -class SyncResult: - success: bool - user_id: int | None = None - counters: dict[str, int] = field(default_factory=dict) - error: str | None = None - - @classmethod - def failed(cls, message: str, *, user_id: int | None = None) -> SyncResult: - return cls(False, user_id=user_id, error=message) - - -def log(message: str) -> None: - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - safe_print(f"[{now}] {message}", flush=True) - - -def format_epoch(epoch: int | float | None) -> str | None: - if not epoch: - return None - return datetime.datetime.fromtimestamp(int(epoch)).strftime("%Y-%m-%d %H:%M:%S") - - -async def run_sync( - user_id: int | None = None, - settings: Settings | None = None, -) -> SyncResult: - settings = settings or Settings.from_env() - try: - resolved_user_id = settings.require_user_id(user_id) - except ConfigError as exc: - log(str(exc)) - return SyncResult.failed(str(exc), user_id=user_id) - - lock_path = settings.data_dir / f"sync_{resolved_user_id}.lock" - try: - with exclusive_file_lock(lock_path): - return await _run_sync_locked(resolved_user_id, settings) - except LockUnavailable: - message = "Sync is already running for this user" - log(message) - return SyncResult.failed(message, user_id=resolved_user_id) - - -async def _run_sync_locked(resolved_user_id: int, settings: Settings) -> SyncResult: - token_path = settings.token_path(resolved_user_id) - if not token_path.exists() and resolved_user_id == settings.telegram_allowed_user_id and os.getenv("SSECURITY"): - _bootstrap_token_from_env(token_path) - - if not token_path.exists(): - message = f"Token file not found at: {token_path}" - log(message) - return SyncResult.failed(message, user_id=resolved_user_id) - - target_relative_uid = _target_relative_uid(token_path) - if not target_relative_uid: - message = f"No TARGET_RELATIVE_UID, target_relative_uid or user_id found in {token_path.name}" - log(message) - return SyncResult.failed(message, user_id=resolved_user_id) - - db_path = settings.canonical_user_db_path(resolved_user_id) - status_path = settings.canonical_user_status_path(resolved_user_id) - return await run_sync_for_user( - token_path=token_path, - db_path=db_path, - status_path=status_path, - target_relative_uid=target_relative_uid, - user_id=resolved_user_id, - settings=settings, - ) - - -def _bootstrap_token_from_env(token_path: Path) -> None: - log(f"Token file not found. Auto-generating {token_path} from environment variables...") - token_path.parent.mkdir(parents=True, exist_ok=True) - token_data = { - "user_id": os.getenv("USER_ID", ""), - "c_user_id": os.getenv("C_USER_ID", ""), - "service_token": os.getenv("SERVICE_TOKEN", ""), - "ssecurity": os.getenv("SSECURITY", ""), - "pass_token": os.getenv("PASS_TOKEN", ""), - "device_id": os.getenv("DEVICE_ID", f"an_{os.urandom(16).hex()}"), - "target_relative_uid": os.getenv("TARGET_RELATIVE_UID", ""), - } - write_secret_json(token_path, token_data) - - -def _target_relative_uid(token_path: Path) -> str: - try: - token_data = json.loads(token_path.read_text(encoding="utf-8")) - except Exception as exc: - log(f"Failed to read token file {token_path.name}: {exc}") - return "" - return ( - str(token_data.get("target_relative_uid", "")).strip() - or os.getenv("TARGET_RELATIVE_UID", "").strip() - or str(token_data.get("user_id", "")).strip() - ) - - -async def run_sync_for_user( - *, - token_path: Path, - db_path: Path, - status_path: Path, - target_relative_uid: str, - user_id: int, - settings: Settings, -) -> SyncResult: - try: - relative_uid = int(target_relative_uid) - except ValueError: - message = "TARGET_RELATIVE_UID must be a valid integer UID" - log(message) - return SyncResult.failed(message, user_id=user_id) - - counters = { - "steps_daily": 0, - "sleep_daily": 0, - "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. " - f"DB: {db_path}. FDS sleep details: {'enabled' if settings.enable_fds_sleep_details else 'disabled'}" - ) - init_health_db(db_path) - latest_heart_rate = None - latest_steps = None - latest_sleep = None - - try: - with sqlite_conn(db_path, row_factory=False) as conn: - cursor = conn.cursor() - async with MiHealthClient.from_token(str(token_path)) as client: - from mi_fitness.auth.sts import sts_exchange - - log("Forcing STS exchange with clientSign to obtain a full serviceToken...") - await sts_exchange(client.auth._ensure_http(), client.auth.token) - save_auth_token(client.auth.token, token_path) - - steps_list = await client.get_steps(relative_uid, days=settings.query_duration) - for item in steps_list: - if not item.at: - continue - date_str = item.at.date().isoformat() - cursor.execute( - """ - INSERT OR REPLACE INTO steps_daily (date, total_steps, calories, distance_m, last_sync) - VALUES (?, ?, ?, ?, ?) - """, - (date_str, item.steps, float(item.calories), float(item.distance), int(time.time())), - ) - counters["steps_daily"] += 1 - if not latest_steps or date_str >= latest_steps["date"]: - latest_steps = { - "date": date_str, - "total_steps": item.steps, - "calories": float(item.calories), - "distance_m": float(item.distance), - "last_sync": int(time.time()), - } - - sleep_list = await client.get_sleep(relative_uid, days=settings.query_duration) - for sleep in sleep_list: - if not sleep.at: - continue - date_str = sleep.at.date().isoformat() - start_time = 0 - end_time = 0 - if sleep.segment_details: - 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( - """ - INSERT OR REPLACE INTO sleep_stages (start_time, stop_time, stage, duration_min) - VALUES (?, ?, ?, ?) - """, - (segment.bedtime, segment.wake_up_time, "sleep_segment", segment.duration), - ) - counters["sleep_stages"] += 1 - if settings.enable_fds_sleep_details: - await _sync_fds_segment(cursor, counters, client, relative_uid, segment) - - cursor.execute( - """ - INSERT OR REPLACE INTO sleep_daily - (date, light_sleep_min, deep_sleep_min, rem_sleep_min, awake_min, - total_duration_min, sleep_score, start_time, end_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - date_str, - sleep.sleep_light_duration, - sleep.sleep_deep_duration, - getattr(sleep, "sleep_rem_duration", 0) or 0, - getattr(sleep, "sleep_awake_duration", 0) or 0, - getattr(sleep, "total_duration", 0) or 0, - getattr(sleep, "sleep_score", 0) or 0, - start_time, - end_time, - ), - ) - counters["sleep_daily"] += 1 - if not latest_sleep or date_str >= latest_sleep["date"]: - latest_sleep = { - "date": date_str, - "light_sleep_min": sleep.sleep_light_duration, - "deep_sleep_min": sleep.sleep_deep_duration, - "rem_sleep_min": getattr(sleep, "sleep_rem_duration", 0) or 0, - "awake_min": getattr(sleep, "sleep_awake_duration", 0) or 0, - "total_duration_min": getattr(sleep, "total_duration", 0) or 0, - "sleep_score": getattr(sleep, "sleep_score", 0) or 0, - "start_time": start_time, - "end_time": end_time, - } - - hr_list = await client.get_heart_rate(relative_uid, days=settings.query_duration) - for hr in hr_list: - cursor.execute( - "INSERT OR IGNORE INTO heart_rate (timestamp, value) VALUES (?, ?)", - (hr.time, hr.avg_hr), - ) - counters["heart_rate"] += cursor.rowcount > 0 - if hr.latest_hr: - cursor.execute( - "INSERT OR IGNORE INTO heart_rate (timestamp, value) VALUES (?, ?)", - (hr.latest_hr.time, hr.latest_hr.bpm), - ) - counters["heart_rate"] += cursor.rowcount > 0 - if not latest_heart_rate or hr.latest_hr.time > latest_heart_rate["timestamp"]: - latest_heart_rate = {"timestamp": hr.latest_hr.time, "value": hr.latest_hr.bpm} - - try: - spo2_list = await client.get_spo2_history(relative_uid, days=settings.query_duration) - for spo2 in spo2_list: - cursor.execute( - "INSERT OR IGNORE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)", - (spo2.time, float(spo2.avg_spo2), "daily_avg"), - ) - counters["blood_oxygen"] += cursor.rowcount > 0 - if spo2.latest_spo2: - cursor.execute( - "INSERT OR IGNORE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)", - (spo2.latest_spo2.time, float(spo2.latest_spo2.spo2), "latest"), - ) - counters["blood_oxygen"] += cursor.rowcount > 0 - 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." - log(message) - return SyncResult.failed(message, user_id=user_id) - except Exception as exc: - message = f"API request failed: {exc}" - log(message) - return SyncResult.failed(message, user_id=user_id) - - _write_status_file(status_path, latest_steps, latest_heart_rate, latest_sleep) - log("Sync completed successfully.") - return SyncResult(True, user_id=user_id, counters=counters) - - -async def _sync_fds_segment(cursor, counters: dict[str, int], client, relative_uid: int, segment) -> None: - try: - log(f"Requesting FDS sleep details for wake_up_time {segment.wake_up_time} ({format_epoch(segment.wake_up_time)})...") - bin_data = await download_and_decrypt_sleep_details( - client, - relative_uid, - segment.wake_up_time, - segment.timezone, - log_fn=log, - ) - if not bin_data: - return - parsed = parse_all_day_sleep_bytes(bin_data) - if not parsed: - return - log( - f"Parsed {len(parsed['records']['heart_rate'])} HR readings and " - f"{len(parsed['records']['spo2'])} SpO2 readings from FDS." - ) - for timestamp, value in parsed["records"]["heart_rate"]: - cursor.execute( - "INSERT OR REPLACE INTO heart_rate (timestamp, value) VALUES (?, ?)", - (timestamp, value), - ) - counters["heart_rate"] += 1 - for timestamp, value in parsed["records"]["spo2"]: - cursor.execute( - "INSERT OR REPLACE INTO blood_oxygen (timestamp, spo2, type) VALUES (?, ?, ?)", - (timestamp, float(value), "fds_detail"), - ) - counters["blood_oxygen"] += 1 - except Exception as exc: - log(f"Failed to sync details from FDS: {exc}") - - -def _write_status_file(status_path: Path, latest_steps, latest_heart_rate, latest_sleep) -> None: - now_epoch = int(time.time()) - status_data = { - "last_sync": now_epoch, - "last_sync_time": format_epoch(now_epoch), - "today": None, - "latest_heart_rate": None, - "latest_sleep": None, - } - if latest_steps: - status_data["today"] = { - "date": latest_steps["date"], - "steps": latest_steps["total_steps"], - "calories": latest_steps["calories"], - "distance_m": latest_steps["distance_m"], - } - if latest_heart_rate: - status_data["latest_heart_rate"] = { - "timestamp": latest_heart_rate["timestamp"], - "time": format_epoch(latest_heart_rate["timestamp"]), - "value": latest_heart_rate["value"], - } - if latest_sleep: - status_data["latest_sleep"] = { - "date": latest_sleep["date"], - "light_sleep_min": latest_sleep["light_sleep_min"], - "deep_sleep_min": latest_sleep["deep_sleep_min"], - "rem_sleep_min": latest_sleep["rem_sleep_min"], - "awake_min": latest_sleep["awake_min"], - "total_sleep_min": latest_sleep["total_duration_min"] - or latest_sleep["light_sleep_min"] + latest_sleep["deep_sleep_min"], - "sleep_score": latest_sleep["sleep_score"], - "start_time": format_epoch(latest_sleep["start_time"]), - "end_time": format_epoch(latest_sleep["end_time"]), - } - write_json_atomic(status_path, status_data) - 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: - allowed_ids = settings.telegram_allowed_user_ids - if not allowed_ids: - log("Нет разрешенных пользователей для синхронизации.") - return 1 - success = True - for uid in allowed_ids: - result = await run_sync(user_id=uid, settings=settings) - if not result.success: - success = False - return 0 if success else 1 - - _waiting_logged = False - while True: - try: - current_settings = Settings.from_env() - allowed_ids = current_settings.telegram_allowed_user_ids - if not allowed_ids: - if not _waiting_logged: - log("Синхронизатор ожидает привязки аккаунта через Telegram (/start)...") - _waiting_logged = True - await asyncio.sleep(5) - continue - - _waiting_logged = False # Reset so we log again if user unregisters - for uid in allowed_ids: - await run_sync(user_id=uid, settings=current_settings) - except Exception as exc: - log(f"Unhandled error in main loop: {exc}") - - interval = Settings.from_env().sync_interval - await asyncio.sleep(interval) diff --git a/package.json b/package.json new file mode 100644 index 0000000..18b037b --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "miband-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "bun@1.3.14", + "engines": { + "bun": ">=1.3.14" + }, + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun dist/src/index.js", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "biome check .", + "format": "biome check --write .", + "test": "bun test ./tests", + "check": "bun run lint && bun run typecheck && bun run test && bun run build" + }, + "dependencies": { + "grammy": "^1.45.1", + "hono": "^4.12.31", + "zod": "^4.4.3" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.7", + "@types/bun": "^1.3.14", + "typescript": "^7.0.2" + } +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index c376ab8..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,79 +0,0 @@ -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "miband-bot" -version = "0.1.0" -description = "Personal Telegram bot and Xiaomi Fitness sync service for Mi Band data" -readme = "README.md" -requires-python = ">=3.11" -license = { text = "GPL-3.0-or-later" } -authors = [ - { name = "Alexey" }, -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Environment :: Console", - "Framework :: AsyncIO", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Communications :: Chat", - "Topic :: Database", - "Topic :: Utilities", -] -dependencies = [ - "httpx==0.28.1", - "loguru==0.7.3", - "pydantic==2.12.5", - "pycryptodome==3.21.0", - "python-telegram-bot==21.6", - "qrcode==8.0", - "requests==2.32.3", - "tenacity==9.1.2", -] - -[project.optional-dependencies] -dev = [ - "pytest==8.3.4", - "pytest-asyncio==1.3.0", - "ruff==0.8.4", -] - -[project.urls] -Homepage = "https://github.com/iAlexeyRu/miband-bot" -Repository = "https://github.com/iAlexeyRu/miband-bot" -Issues = "https://github.com/iAlexeyRu/miband-bot/issues" - -[project.scripts] -miband-sync = "miband_sync:main" -miband-fitness-bot = "fitness_bot:main" - -[tool.setuptools] -py-modules = ["fitness_bot", "miband_sync"] - -[tool.setuptools.packages.find] -include = ["miband_tracker*"] -exclude = ["tests*", "mi-fitness-python*"] - -[tool.ruff] -line-length = 120 -target-version = "py311" -extend-exclude = [ - ".venv", - "data", - "scratch", - "mi-fitness-python", -] - -[tool.ruff.lint] -select = ["E", "W", "F", "I", "B", "UP", "C4", "RUF"] -ignore = [ - "E501", - "RUF001", - "RUF002", - "RUF003", -] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index dcd95cd..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths = tests -asyncio_default_fixture_loop_scope = function diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index f8c8605..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ --r requirements.txt -pytest==8.3.4 -pytest-asyncio==1.3.0 -ruff==0.8.4 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 78f57e9..0000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -httpx==0.28.1 -loguru==0.7.3 -pydantic==2.12.5 -pycryptodome==3.21.0 -python-telegram-bot==21.6 -qrcode==8.0 -requests==2.32.3 -tenacity==9.1.2 -tzdata diff --git a/run.py b/run.py deleted file mode 100644 index aaefc21..0000000 --- a/run.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey -""" -Local launcher: runs miband_sync and fitness_bot in parallel, -redirecting their output to data/sync.log and data/bot.log. -Handles Ctrl+C and window close gracefully. -""" -from __future__ import annotations - -import os -import subprocess -import sys -from contextlib import suppress -from pathlib import Path - -from miband_tracker.lock import LockUnavailable, exclusive_file_lock -from miband_tracker.stdio import configure_utf8_stdio, safe_print - -configure_utf8_stdio() - - -def python_env() -> dict[str, str]: - env = os.environ.copy() - env["PYTHONUTF8"] = "1" - env["PYTHONIOENCODING"] = "utf-8" - return env - - -def stop_processes(procs: list[subprocess.Popen[bytes]]) -> None: - for proc in procs: - if proc.poll() is None: - proc.terminate() - - for proc in procs: - if proc.poll() is None: - with suppress(subprocess.TimeoutExpired): - proc.wait(timeout=5) - - for proc in procs: - if proc.poll() is None: - proc.kill() - - -def run_processes(data_dir: Path) -> int: - procs: list[subprocess.Popen[bytes]] = [] - exit_code = 0 - child_env = python_env() - - with (data_dir / "sync.log").open("w", encoding="utf-8") as sync_log, ( - data_dir / "bot.log" - ).open("w", encoding="utf-8") as bot_log: - try: - sync_proc = subprocess.Popen( - [sys.executable, "-u", "miband_sync.py"], - stdout=sync_log, - stderr=sync_log, - env=child_env, - ) - procs.append(sync_proc) - - bot_proc = subprocess.Popen( - [sys.executable, "-u", "fitness_bot.py"], - stdout=bot_log, - stderr=bot_log, - env=child_env, - ) - procs.append(bot_proc) - - exit_code = bot_proc.wait() - except KeyboardInterrupt: - exit_code = 130 - finally: - stop_processes(procs) - - return exit_code - - -def main() -> None: - data_dir = Path("data") - data_dir.mkdir(exist_ok=True) - - try: - with exclusive_file_lock(data_dir / "run.lock"): - exit_code = run_processes(data_dir) - except LockUnavailable: - safe_print("miband-bot уже запущен. Закройте старое окно перед повторным запуском.", flush=True) - exit_code = 2 - - raise SystemExit(exit_code) - - -if __name__ == "__main__": - main() diff --git a/secrets.env.example b/secrets.env.example deleted file mode 100644 index f44370c..0000000 --- a/secrets.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Required for the Telegram bot. -TELEGRAM_BOT_TOKEN=123456:replace-me -TELEGRAM_ALLOWED_USER_ID=123456789 - -# Sync behavior. -SYNC_INTERVAL=900 -QUERY_DURATION=2 -ENABLE_FDS_SLEEP_DETAILS=true - -# Optional local runtime paths for non-Docker runs. -# Docker Compose already sets these paths. -# DATA_DIR=/opt/miband-tracker/data -# DB_PATH=/opt/miband-tracker/data/miband.db -# STATUS_PATH=/opt/miband-tracker/data/status.json -# BOT_STATE_DB_PATH=/opt/miband-tracker/data/fitness_bot_state.db - -# Optional legacy token bootstrap path. -# Prefer the Telegram Xiaomi login flow unless you know why you need this. -# USER_ID= -# C_USER_ID= -# SERVICE_TOKEN= -# SSECURITY= -# PASS_TOKEN= -# DEVICE_ID= -# TARGET_RELATIVE_UID= diff --git a/setup.bat b/setup.bat deleted file mode 100644 index 885a687..0000000 --- a/setup.bat +++ /dev/null @@ -1,249 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal EnableExtensions - -set "AUTO_START=0" -if /i "%~1"=="--start" set "AUTO_START=1" -if /i "%MIBAND_BOT_AUTO_START%"=="1" set "AUTO_START=1" -cls - -echo. -echo miband-bot -echo. - -:CHOOSE_MODE -if "%AUTO_START%"=="1" if exist secrets.env goto MODE_PYTHON -set "install_mode=1" -set /p "install_mode= [1] Python (recommended) [2] Docker Choice [1]: " -if "%install_mode%"=="1" goto MODE_PYTHON -if "%install_mode%"=="2" goto MODE_DOCKER -goto CHOOSE_MODE - -:MODE_DOCKER -where docker >nul 2>nul -if errorlevel 1 goto DOCKER_MISSING -docker info >nul 2>nul -if errorlevel 1 goto DOCKER_NOT_RUNNING -echo OK Docker is ready -set "DOCKER_ACTIVE=1" -goto SETUP_ENV - -:DOCKER_MISSING -echo ERROR Docker was not found. Install Docker Desktop. -pause -exit /b 1 - -:DOCKER_NOT_RUNNING -echo ERROR Docker is not running. Start Docker Desktop and try again. -pause -exit /b 1 - -:MODE_PYTHON -call :FIND_PYTHON -if defined PYTHON_CMD goto PYTHON_OK - -echo ERROR Python 3.11+ was not found: https://www.python.org/downloads/ -echo. -set "install_python=y" -set /p "install_python= Install Python 3.11 automatically? [Y/n]: " -if /i not "%install_python%"=="y" goto PYTHON_INSTALL_DECLINED - -echo Downloading Python 3.11.9... -powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri 'https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe' -OutFile 'python_installer.exe'" -if errorlevel 1 goto PYTHON_DOWNLOAD_FAILED - -echo Installing Python... -python_installer.exe /quiet InstallAllUsers=0 PrependPath=1 Include_test=0 -if errorlevel 1 goto PYTHON_INSTALL_FAILED -del python_installer.exe >nul 2>nul -set "PATH=%LocalAppData%\Programs\Python\Python311;%LocalAppData%\Programs\Python\Python311\Scripts;%PATH%" -set "PYTHON_CMD=python" -%PYTHON_CMD% -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul -if errorlevel 1 goto PYTHON_INSTALL_FAILED -goto PYTHON_OK - -:PYTHON_INSTALL_DECLINED -echo Install Python manually and run setup.bat again. -pause -exit /b 1 - -:PYTHON_DOWNLOAD_FAILED -echo ERROR Could not download Python installer. -pause -exit /b 1 - -:PYTHON_INSTALL_FAILED -echo ERROR Could not install Python. -pause -exit /b 1 - -:PYTHON_OK -echo OK Python is ready: %PYTHON_CMD% -set "DOCKER_ACTIVE=0" -goto SETUP_ENV - -:FIND_PYTHON -set "PYTHON_CMD=" -where py >nul 2>nul -if not errorlevel 1 ( - py -3.13 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul - if not errorlevel 1 set "PYTHON_CMD=py -3.13" & exit /b 0 - py -3.12 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul - if not errorlevel 1 set "PYTHON_CMD=py -3.12" & exit /b 0 - py -3.11 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul - if not errorlevel 1 set "PYTHON_CMD=py -3.11" & exit /b 0 -) -where python >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 set "PYTHON_CMD=python" & exit /b 0 -) -where python3 >nul 2>nul -if not errorlevel 1 ( - python3 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" >nul 2>nul - if not errorlevel 1 set "PYTHON_CMD=python3" & exit /b 0 -) -exit /b 0 - -:SETUP_ENV -echo. -if not exist secrets.env goto INPUT_ENV_VALUES -if "%AUTO_START%"=="1" goto LAUNCH_PHASE -set "overwrite_env=n" -set /p "overwrite_env= Config already exists. Reconfigure? [y/N]: " -if /i not "%overwrite_env%"=="y" goto LAUNCH_PHASE - -:INPUT_ENV_VALUES -echo. -echo Telegram bot token from @BotFather: - -:INPUT_TOKEN -set "bot_token=" -set /p "bot_token= Token: " -if "%bot_token%"=="" goto TOKEN_EMPTY -echo %bot_token% | find ":" >nul 2>nul -if errorlevel 1 goto TOKEN_INVALID -goto TOKEN_OK - -:TOKEN_EMPTY -echo Token cannot be empty. -goto INPUT_TOKEN - -:TOKEN_INVALID -echo Invalid token format. It must contain a colon. -goto INPUT_TOKEN - -:TOKEN_OK -( -echo # miband-bot config -echo TELEGRAM_BOT_TOKEN=%bot_token% -echo TELEGRAM_ALLOWED_USER_ID= -echo SYNC_INTERVAL=900 -echo QUERY_DURATION=2 -echo ENABLE_FDS_SLEEP_DETAILS=true -) > secrets.env -echo OK Config saved - -:LAUNCH_PHASE -echo. -if not "%DOCKER_ACTIVE%"=="1" goto PYTHON_LAUNCH - -:DOCKER_LAUNCH -set "launch_now=y" -set /p "launch_now= Start in Docker now? [Y/n]: " -if /i not "%launch_now%"=="y" goto DOCKER_MANUAL -docker compose up -d --build -if errorlevel 1 goto DOCKER_LAUNCH_FAILED -echo. -echo OK Bot started in Docker. Send /start in Telegram. -echo Logs: docker compose logs -f fitness-bot -goto END_LAUNCH - -:DOCKER_MANUAL -echo Manual start: docker compose up -d --build -goto END_LAUNCH - -:DOCKER_LAUNCH_FAILED -echo ERROR Docker startup failed. -goto END_LAUNCH - -:PYTHON_LAUNCH -echo Preparing Python environment... -if not exist .venv ( - %PYTHON_CMD% -m venv .venv >nul 2>nul - if errorlevel 1 goto VENV_FAILED -) -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 -del pip_install.log >nul 2>nul -echo OK Dependencies installed -call :WRITE_RUN_LOCAL -if errorlevel 1 goto RUN_LOCAL_FAILED - -if "%AUTO_START%"=="1" goto RUN_LOCAL_NOW - -echo. -set "launch_now=y" -set /p "launch_now= Start bot now? [Y/n]: " -if /i "%launch_now%"=="y" call run_local.bat -goto END_LAUNCH - -:VENV_FAILED -echo ERROR Could not create .venv. -pause -exit /b 1 - -:PIP_INSTALL_FAILED -echo ERROR Dependency installation failed: -type pip_install.log -del pip_install.log >nul 2>nul -pause -exit /b 1 - -:RUN_LOCAL_FAILED -echo ERROR Could not create run_local.bat. -pause -exit /b 1 - -:WRITE_RUN_LOCAL ->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 ^( ->>run_local.bat echo echo ERROR .venv was not found. Run setup.bat again. ->>run_local.bat echo pause ->>run_local.bat echo exit /b 1 ->>run_local.bat echo ^) ->>run_local.bat echo if not exist run.py ^( ->>run_local.bat echo echo ERROR run.py was not found. Update project and run setup.bat again. ->>run_local.bat echo pause ->>run_local.bat echo exit /b 1 ->>run_local.bat echo ^) ->>run_local.bat echo call .venv\Scripts\activate ->>run_local.bat echo set PYTHONUTF8=1 ->>run_local.bat echo set PYTHONIOENCODING=utf-8 ->>run_local.bat echo md data 2^>nul ->>run_local.bat echo cls ->>run_local.bat echo echo. ->>run_local.bat echo echo miband-bot ->>run_local.bat echo echo. ->>run_local.bat echo echo OK Bot started. Keep this window open. ->>run_local.bat echo echo Logs: data\bot.log / data\sync.log ->>run_local.bat echo echo. ->>run_local.bat echo python -u run.py ->>run_local.bat echo echo. ->>run_local.bat echo echo Bot stopped. You can close this window. ->>run_local.bat echo echo. ->>run_local.bat echo pause -exit /b 0 - -:RUN_LOCAL_NOW -call run_local.bat - -:END_LAUNCH -echo. -pause -endlocal diff --git a/setup.sh b/setup.sh deleted file mode 100755 index aac76ff..0000000 --- a/setup.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# miband-bot setup for macOS / Linux - -GREEN='\033[0;32m' -RED='\033[0;31m' -NC='\033[0m' - -ok() { echo -e " ${GREEN}✓${NC} $1"; } -err() { echo -e " ${RED}❌${NC} $1"; } - -clear -echo "" -echo " miband-bot" -echo "" - -# --- Выбор режима --- -while true; do - read -p " [1] Python (рек.) [2] Docker Выбор [1]: " install_mode - install_mode="${install_mode:-1}" - [ "$install_mode" = "1" ] || [ "$install_mode" = "2" ] && break -done -echo "" - -# --- Проверка требований --- -if [ "$install_mode" = "2" ]; then - if ! command -v docker &> /dev/null; then - err "Docker не найден. Установите Docker Desktop или Colima (brew install colima)." - exit 1 - fi - if ! docker info &> /dev/null; then - err "Docker не запущен. Запустите демон (colima start / Docker Desktop) и повторите." - exit 1 - fi - if docker compose version &> /dev/null; then - COMPOSE_CMD="docker compose" - elif command -v docker-compose &> /dev/null; then - COMPOSE_CMD="docker-compose" - else - err "Плагин docker compose не найден." - exit 1 - fi - ok "Docker готов" - DOCKER_MODE=true -else - PYTHON_CMD="" - for cmd in python3.13 python3.12 python3.11 python3 python; do - if command -v "$cmd" &> /dev/null; then - 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.11+ не найден. Установите: https://www.python.org/downloads/" - exit 1 - fi - ok "Python готов ($PYTHON_CMD)" - DOCKER_MODE=false -fi - -# --- Конфигурация --- -echo "" -if [ -f secrets.env ]; then - read -p " Конфигурация уже есть. Перенастроить? [y/N]: " overwrite < /dev/tty - if [[ ! "$overwrite" =~ ^[Yy]$ ]]; then - : # skip to launch - else - rm -f secrets.env - fi -fi - -if [ ! -f secrets.env ]; then - echo "" - echo " Токен бота от @BotFather в Telegram:" - while true; do - read -p " > " bot_token < /dev/tty - [ -z "$bot_token" ] && echo " Токен не может быть пустым." && continue - [[ "$bot_token" == *":"* ]] && break - echo " Неверный формат (должен содержать ':')." - done - - { - echo "# miband-bot" - echo "TELEGRAM_BOT_TOKEN=$bot_token" - echo "TELEGRAM_ALLOWED_USER_ID=" - echo "SYNC_INTERVAL=900" - echo "QUERY_DURATION=2" - echo "ENABLE_FDS_SLEEP_DETAILS=true" - } > secrets.env - ok "Конфигурация сохранена" -fi - -# --- Запуск --- -echo "" -if [ "$DOCKER_MODE" = true ]; then - read -p " Запустить в Docker сейчас? [Y/n]: " launch_now < /dev/tty - launch_now="${launch_now:-y}" - if [[ ! "$launch_now" =~ ^[Yy]$ ]]; then - echo " Запуск вручную: $COMPOSE_CMD up -d --build" - exit 0 - fi - $COMPOSE_CMD up -d --build - if [ $? -eq 0 ]; then - echo "" - ok "Бот запущен в Docker. Отправьте /start в Telegram." - echo " Логи: $COMPOSE_CMD logs -f fitness-bot" - else - err "Ошибка запуска Docker." - fi - exit 0 -fi - -# Python режим -echo " Подготовка..." -if [ ! -d ".venv" ]; then - if ! $PYTHON_CMD -m venv .venv; then - err "Не удалось создать .venv. На Debian/Ubuntu: sudo apt install -y python3-venv" - exit 1 - fi -fi - -source .venv/bin/activate -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 - err "Ошибка установки зависимостей:" - cat pip_install.log - rm -f pip_install.log - exit 1 -fi - -# Генерируем run_local.sh — логи Python уходят в файлы -cat << 'EOF' > run_local.sh -#!/usr/bin/env bash -# Запуск miband-bot (Python читает secrets.env автоматически) -cd "$(dirname "$0")" - -if [ ! -d ".venv" ]; then - echo " ❌ .venv не найдена. Запустите setup.sh заново." - exit 1 -fi - -if [ ! -f "run.py" ]; then - echo " ❌ run.py не найден. Обновите проект и запустите setup.sh заново." - exit 1 -fi - -source .venv/bin/activate -mkdir -p data -clear -echo "" -echo " miband-bot" -echo "" -echo " ✓ Бот запущен. Не закрывайте это окно." -echo " Логи: data/bot.log / data/sync.log" -echo "" - -python -u run.py -echo "" -echo " Бот остановлен. Можно закрыть окно." -echo "" -EOF -chmod +x run_local.sh -ok "Создан скрипт запуска: ./run_local.sh" -echo "" - -read -p " Запустить бота сейчас? [Y/n]: " launch_now < /dev/tty -launch_now="${launch_now:-y}" -if [[ "$launch_now" =~ ^[Yy]$ ]]; then - ./run_local.sh -fi diff --git a/src/bot/app.ts b/src/bot/app.ts new file mode 100644 index 0000000..b96578b --- /dev/null +++ b/src/bot/app.ts @@ -0,0 +1,1262 @@ +import type { SQLQueryBindings } from "bun:sqlite"; +import { existsSync, statSync } from "node:fs"; +import { Bot, type Context, InlineKeyboard, InputFile } from "grammy"; +import type { AppConfig } from "../config.js"; +import { canonicalUserDbPath, canonicalUserStatusPath, tokenPath, userDbPath } from "../config.js"; +import { log } from "../logger.js"; +import { + fetchAll, + fetchOne, + getUserLocale, + getUserMenuMessageId, + healthDbExists, + initHealthDb, + readStatus, + setUserLocale, + setUserMenuMessageId, + zipExport, +} from "../storage/health.js"; +import { saveAuthToken, writeTextAtomic } from "../storage/secure-files.js"; +import { runSync, type SyncResult } from "../sync.js"; +import { XiaomiAuth } from "../xiaomi/client.js"; +import { epoch, esc, minutes, relativeDay, rowNumber, rowString, sleepTotal, workoutType } from "./formatting.js"; +import { isLocale, LOCALE_NAMES, LOCALES, type Locale, t } from "./i18n.js"; + +const STEP_GOAL = 10_000; +const USER_NAMES: Record = { 7629366167: "Алексей", 1260959328: "Маша" }; +type BotContext = Context; +type TaskHandle = { stop: () => Promise }; + +function localeOf(config: AppConfig, uid: number): Locale { + return getUserLocale(config, uid); +} + +function numberLocale(locale: Locale): string { + return locale === "ru" ? "ru-RU" : locale === "es" ? "es-ES" : "en-GB"; +} + +function uidOf(context: BotContext): number | null { + const value = context.from?.id; + return value === undefined ? null : value; +} + +function allowed(context: BotContext, config: AppConfig): boolean { + const uid = uidOf(context); + if (uid === null) return false; + if (config.allowedUserIds.length > 0) return config.allowedUserIds.includes(uid); + config.allowedUserIds.push(uid); + writeTextAtomic(`${config.dataDir}/allowed_user.id`, String(uid)); + log("info", "Telegram owner bound", { userId: uid }); + return true; +} + +function userDbReady(config: AppConfig, uid: number): boolean { + const path = canonicalUserDbPath(config, uid); + if (!existsSync(path)) initHealthDb(path); + return healthDbExists(config, uid); +} + +function dbRow( + config: AppConfig, + uid: number, + query: string, + params: SQLQueryBindings[] = [], +): Record | null { + userDbReady(config, uid); + return fetchOne(config, uid, query, params); +} + +function dbRows( + config: AppConfig, + uid: number, + query: string, + params: SQLQueryBindings[] = [], +): Record[] { + userDbReady(config, uid); + return fetchAll(config, uid, query, params); +} + +function mainMenuText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + const steps = dbRow( + config, + uid, + "SELECT date,total_steps,calories,distance_m,last_sync FROM steps_daily ORDER BY date DESC LIMIT 1", + ); + const sleep = dbRow( + config, + uid, + "SELECT date,light_sleep_min,deep_sleep_min,start_time,end_time,COALESCE(rem_sleep_min,0) rem_sleep_min,COALESCE(awake_min,0) awake_min,COALESCE(total_duration_min,0) total_duration_min,COALESCE(sleep_score,0) sleep_score FROM sleep_daily ORDER BY date DESC LIMIT 1", + ); + const hr = dbRow(config, uid, "SELECT timestamp,value FROM heart_rate ORDER BY timestamp DESC LIMIT 1"); + const spo2 = dbRow(config, uid, "SELECT timestamp,spo2,type FROM blood_oxygen ORDER BY timestamp DESC LIMIT 1"); + const stress = dbRow(config, uid, "SELECT timestamp,value FROM stress ORDER BY timestamp DESC LIMIT 1"); + const lines: string[] = []; + if (steps) { + const count = rowNumber(steps, "total_steps"); + lines.push( + `🚶 ${count.toLocaleString(numberLocale(locale))} · ${(rowNumber(steps, "distance_m") / 1000).toFixed(1)} ${t(locale, "common.km")} · ${rowNumber(steps, "calories").toFixed(0)} ${t(locale, "common.kcal")}`, + ); + } else lines.push(t(locale, "main.no-steps")); + lines.push(""); + if (sleep) { + lines.push( + `😴 ${epoch(rowNumber(sleep, "start_time"), false, locale)}→${epoch(rowNumber(sleep, "end_time"), false, locale)} · ${minutes(sleepTotal(sleep), locale)}`, + ); + } else lines.push(t(locale, "main.no-sleep")); + lines.push(""); + const metrics: string[] = []; + if (hr) metrics.push(`❤️ ${rowNumber(hr, "value")}`); + if (spo2) metrics.push(`🩸 ${rowNumber(spo2, "spo2").toFixed(0)}%`); + if (stress) metrics.push(`🧘 ${rowNumber(stress, "value")}`); + lines.push(metrics.length ? metrics.join(" · ") : t(locale, "main.no-metrics")); + const status = readStatus(config, uid); + if (status.last_sync_time) lines.push("", `🕒 ${esc(status.last_sync_time)}`); + return lines.join("\n"); +} + +function workoutsText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + const rows = dbRows( + config, + uid, + "SELECT sport_type,start_time,duration_sec,calories,avg_hr FROM workouts ORDER BY start_time DESC LIMIT 10", + ); + if (!rows.length) return `🏋️ ${t(locale, "menu.workouts")}\n\n${t(locale, "workouts.empty")}`; + return [ + t(locale, "workouts.title"), + "", + ...rows.map( + (row) => + `• ${workoutType(row.sport_type, locale)} · ${epoch(rowNumber(row, "start_time"), false, locale)} · ${Math.round(rowNumber(row, "duration_sec") / 60)} ${t(locale, "common.minutes")} · ${Math.round(rowNumber(row, "calories"))} ${t(locale, "common.kcal")} · ${rowNumber(row, "avg_hr")} bpm`, + ), + ].join("\n"); +} + +function statusText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + const status = readStatus(config, uid); + const tables = [ + "steps_daily", + "sleep_daily", + "sleep_stages", + "heart_rate", + "blood_oxygen", + "stress", + "calories_daily", + "weight", + "workouts", + ]; + const lines = [t(locale, "status.title"), ""]; + for (const table of tables) { + const count = dbRow(config, uid, `SELECT COUNT(*) AS count FROM ${table}`); + lines.push( + `• ${table}: ${rowNumber(count, "count").toLocaleString(numberLocale(locale))} ${t(locale, "status.rows")}`, + ); + } + lines.push( + "", + `${t(locale, "status.path")}: ${esc(userDbPath(config, uid))}`, + `${t(locale, "status.last-sync")}: ${esc(String(status.last_sync_time ?? t(locale, "common.na")))}`, + ); + return lines.join("\n"); +} + +function localDay(offset = 0): string { + const value = new Date(Date.now() + offset * 86_400_000); + const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Moscow" }).formatToParts(value); + const year = parts.find((part) => part.type === "year")?.value ?? "1970"; + const month = parts.find((part) => part.type === "month")?.value ?? "01"; + const day = parts.find((part) => part.type === "day")?.value ?? "01"; + return `${year}-${month}-${day}`; +} + +function dayEpochBounds(day: string): [number, number] { + const start = Math.floor(new Date(`${day}T00:00:00+03:00`).getTime() / 1000); + return [start, start + 86_400]; +} + +function formatWeekday(day: string, locale: Locale): string { + const value = new Date(`${day}T12:00:00Z`); + return new Intl.DateTimeFormat(numberLocale(locale), { weekday: "short", timeZone: "Europe/Moscow" }) + .format(value) + .replace(".", ""); +} + +function averageBedtime(rows: Record[]): number | null { + const offsets = rows + .map((row) => rowNumber(row, "start_time")) + .filter(Boolean) + .map((time) => { + const hour = Number( + new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone: "Europe/Moscow" }).format( + time * 1000, + ), + ); + const minute = Number( + new Intl.DateTimeFormat("en-US", { minute: "numeric", timeZone: "Europe/Moscow" }).format(time * 1000), + ); + const offset = hour * 60 + minute; + return offset > 720 ? offset - 1440 : offset; + }); + return offsets.length ? offsets.reduce((sum, value) => sum + value, 0) / offsets.length : null; +} + +function formatBedtime(value: number | null, locale: Locale): string { + if (value === null) return t(locale, "common.na"); + const minutesValue = ((Math.round(value) % 1440) + 1440) % 1440; + return `${String(Math.floor(minutesValue / 60)).padStart(2, "0")}:${String(minutesValue % 60).padStart(2, "0")}`; +} + +function metricStats( + config: AppConfig, + uid: number, + table: string, + field: string, + start: number, + end: number, +): Record | null { + return dbRow( + config, + uid, + `SELECT COUNT(*) AS count,ROUND(AVG(${field}),1) AS avg_value,MIN(${field}) AS min_value,MAX(${field}) AS max_value FROM ${table} WHERE timestamp >= ? AND timestamp < ?`, + [start, end], + ); +} + +function daySummary(config: AppConfig, uid: number, day: string): Record { + const [start, end] = dayEpochBounds(day); + const sleep = dbRow( + config, + uid, + "SELECT date,light_sleep_min,deep_sleep_min,start_time,end_time,COALESCE(rem_sleep_min,0) rem_sleep_min,COALESCE(awake_min,0) awake_min,COALESCE(total_duration_min,0) total_duration_min,COALESCE(sleep_score,0) sleep_score FROM sleep_daily WHERE date = ?", + [day], + ); + return { + date: day, + steps: dbRow(config, uid, "SELECT date,total_steps,calories,distance_m,last_sync FROM steps_daily WHERE date = ?", [ + day, + ]), + sleep, + hr: metricStats(config, uid, "heart_rate", "value", start, end), + spo2: metricStats(config, uid, "blood_oxygen", "spo2", start, end), + stress: metricStats(config, uid, "stress", "value", start, end), + calories: dbRow( + config, + uid, + "SELECT total_cal,active_cal,valid_stand_hours,intensity_minutes FROM calories_daily WHERE date = ?", + [day], + ), + weight: dbRow( + config, + uid, + "SELECT weight_kg,bmi,body_fat_pct FROM weight WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1", + [end], + ), + workouts: dbRows( + config, + uid, + "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, end], + ), + }; +} + +function availableDays(config: AppConfig, uid: number, limit: number): string[] { + return dbRows( + config, + uid, + "SELECT date FROM (SELECT date FROM steps_daily UNION SELECT date FROM sleep_daily) ORDER BY date DESC LIMIT ?", + [limit], + ).map((row) => rowString(row, "date")); +} + +function periodSummary(config: AppConfig, uid: number, days: number): Record { + const end = localDay(); + const start = localDay(-(Math.max(1, days) - 1)); + const [startEpoch] = dayEpochBounds(start); + const [, endEpoch] = dayEpochBounds(end); + let weight = dbRows( + config, + uid, + "SELECT timestamp,weight_kg,bmi,body_fat_pct FROM weight WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp DESC", + [startEpoch, endEpoch], + ); + if (!weight.length) { + const latest = dbRow( + config, + uid, + "SELECT timestamp,weight_kg,bmi,body_fat_pct FROM weight ORDER BY timestamp DESC LIMIT 1", + ); + if (latest) weight = [latest]; + } + return { + start, + end, + steps: dbRows( + config, + uid, + "SELECT date,total_steps,calories,distance_m FROM steps_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", + [start, end], + ), + sleep: dbRows( + config, + uid, + "SELECT date,light_sleep_min,deep_sleep_min,start_time,end_time,COALESCE(rem_sleep_min,0) rem_sleep_min,COALESCE(awake_min,0) awake_min,COALESCE(total_duration_min,0) total_duration_min,COALESCE(sleep_score,0) sleep_score FROM sleep_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", + [start, end], + ), + hr: metricStats(config, uid, "heart_rate", "value", startEpoch, endEpoch), + spo2: metricStats(config, uid, "blood_oxygen", "spo2", startEpoch, endEpoch), + stress: metricStats(config, uid, "stress", "value", startEpoch, endEpoch), + calories: dbRows( + config, + uid, + "SELECT date,total_cal,active_cal,valid_stand_hours,intensity_minutes FROM calories_daily WHERE date BETWEEN ? AND ? ORDER BY date DESC", + [start, end], + ), + weight, + }; +} + +function dayEmoji(steps: Record | null, sleep: Record | null): string { + let score = 0; + if (steps && rowNumber(steps, "total_steps") >= STEP_GOAL) score += 1; + if (sleep && sleepTotal(sleep) >= 420) score += 1; + return score === 2 ? "🟢" : score === 1 ? "🟡" : "🔴"; +} + +function sleepText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + const sleep = dbRow( + config, + uid, + "SELECT date,light_sleep_min,deep_sleep_min,start_time,end_time,COALESCE(rem_sleep_min,0) rem_sleep_min,COALESCE(awake_min,0) awake_min,COALESCE(total_duration_min,0) total_duration_min,COALESCE(sleep_score,0) sleep_score FROM sleep_daily ORDER BY date DESC LIMIT 1", + ); + if (!sleep) return `😴 ${t(locale, "sleep.title")}\n\n${t(locale, "sleep.empty")}`; + const [start, end] = [rowNumber(sleep, "start_time"), rowNumber(sleep, "end_time")]; + const hr = start && end ? metricStats(config, uid, "heart_rate", "value", start, end) : null; + const spo2 = start && end ? metricStats(config, uid, "blood_oxygen", "spo2", start, end) : null; + const rest = + start && end + ? dbRow( + config, + uid, + "SELECT MIN(value) AS min_hr FROM heart_rate WHERE timestamp >= ? AND timestamp < ? AND value > 30", + [start, end], + ) + : null; + const date = new Date(`${rowString(sleep, "date")}T12:00:00Z`); + const dateLabel = new Intl.DateTimeFormat(numberLocale(locale), { + day: "numeric", + month: "long", + timeZone: "Europe/Moscow", + }).format(date); + const deep = rowNumber(sleep, "deep_sleep_min"); + const light = rowNumber(sleep, "light_sleep_min"); + const rem = rowNumber(sleep, "rem_sleep_min"); + const bar = (value: number) => { + const total = Math.max(1, sleepTotal(sleep)); + const blocks = Math.round((value / total) * 12); + return `${"█".repeat(blocks)}${"░".repeat(Math.max(0, 12 - blocks))}`; + }; + const hrText = + hr && rowNumber(hr, "count") + ? t(locale, "sleep.average", { + avg: rowNumber(hr, "avg_value").toFixed(0), + min: rowNumber(hr, "min_value").toFixed(0), + max: rowNumber(hr, "max_value").toFixed(0), + }) + : t(locale, "common.na"); + const spo2Text = + spo2 && rowNumber(spo2, "count") + ? t(locale, "sleep.minimum", { + avg: rowNumber(spo2, "avg_value").toFixed(0), + min: rowNumber(spo2, "min_value").toFixed(0), + }) + : t(locale, "common.na"); + return [ + t(locale, "sleep.night", { date: esc(dateLabel) }), + "", + `${t(locale, "sleep.duration")} ${minutes(sleepTotal(sleep), locale)}`, + `${t(locale, "sleep.quality")} ${rowNumber(sleep, "sleep_score") || t(locale, "common.na")}${rowNumber(sleep, "sleep_score") ? " / 100" : ""}`, + `${t(locale, "sleep.bed")} ${epoch(start, false, locale)} — ${epoch(end, false, locale)}`, + `${t(locale, "sleep.resting-heart-rate")} ${rest && rowNumber(rest, "min_hr") ? `${rowNumber(rest, "min_hr")} bpm` : t(locale, "common.na")}`, + "", + `${t(locale, "sleep.deep")} ${bar(deep)} ${minutes(deep, locale)}`, + `${t(locale, "sleep.light")} ${bar(light)} ${minutes(light, locale)}`, + ...(rem ? [`REM ${bar(rem)} ${minutes(rem, locale)}`] : []), + "", + `${t(locale, "sleep.in-sleep")} ${hrText}`, + `${t(locale, "sleep.in-sleep-spo2")} ${spo2Text}`, + ].join("\n"); +} + +function dayText(config: AppConfig, uid: number, day: string): string { + const locale = localeOf(config, uid); + const data = daySummary(config, uid, day); + const steps = data.steps as Record | null; + const sleep = data.sleep as Record | null; + const hr = data.hr as Record | null; + const spo2 = data.spo2 as Record | null; + const stress = data.stress as Record | null; + const calories = data.calories as Record | null; + const weight = data.weight as Record | null; + const workouts = (data.workouts as Record[]) ?? []; + const dayLabel = relativeDay(day, locale); + const lines = [t(locale, "day.details", { day: esc(day), label: esc(dayLabel) }), ""]; + if (steps) + lines.push( + `${t(locale, "day.steps")} ${rowNumber(steps, "total_steps").toLocaleString(numberLocale(locale))} · ${(rowNumber(steps, "distance_m") / 1000).toFixed(1)} ${t(locale, "common.km")}`, + ); + else lines.push(t(locale, "day.no-steps")); + if (calories) { + lines.push( + t(locale, "day.activity", { + hours: rowNumber(calories, "valid_stand_hours"), + minutes: rowNumber(calories, "intensity_minutes"), + }), + ); + lines.push( + t(locale, "day.energy", { + total: rowNumber(calories, "total_cal").toFixed(0), + active: rowNumber(calories, "active_cal").toFixed(0), + }), + ); + } else if (steps) lines.push(t(locale, "day.energy-simple", { calories: rowNumber(steps, "calories").toFixed(0) })); + lines.push(""); + if (sleep) + lines.push( + `${t(locale, "day.sleep", { total: minutes(sleepTotal(sleep), locale), deep: minutes(rowNumber(sleep, "deep_sleep_min"), locale), light: minutes(rowNumber(sleep, "light_sleep_min"), locale) })}${rowNumber(sleep, "sleep_score") ? ` · ${rowNumber(sleep, "sleep_score")}/100` : ""} · ${epoch(rowNumber(sleep, "start_time"), false, locale)}→${epoch(rowNumber(sleep, "end_time"), false, locale)}`, + ); + else lines.push(t(locale, "day.no-sleep")); + lines.push(""); + if (hr && rowNumber(hr, "count")) + lines.push( + t(locale, "day.heart-rate", { + avg: rowNumber(hr, "avg_value").toFixed(0), + min: rowNumber(hr, "min_value").toFixed(0), + max: rowNumber(hr, "max_value").toFixed(0), + }), + ); + else lines.push(t(locale, "day.no-heart-rate")); + if (spo2 && rowNumber(spo2, "count")) + lines.push( + t(locale, "day.oxygen", { + avg: rowNumber(spo2, "avg_value").toFixed(0), + min: rowNumber(spo2, "min_value").toFixed(0), + max: rowNumber(spo2, "max_value").toFixed(0), + }), + ); + else lines.push(t(locale, "day.no-oxygen")); + if (stress && rowNumber(stress, "count")) + lines.push( + t(locale, "day.stress", { + avg: rowNumber(stress, "avg_value").toFixed(0), + min: rowNumber(stress, "min_value").toFixed(0), + max: rowNumber(stress, "max_value").toFixed(0), + }), + ); + else lines.push(t(locale, "day.no-stress")); + if (weight) + lines.push( + `${t(locale, "day.weight", { weight: rowNumber(weight, "weight_kg").toFixed(1) })}${rowNumber(weight, "bmi") ? ` · BMI: ${rowNumber(weight, "bmi").toFixed(1)}` : ""}`, + ); + if (workouts.length) { + lines.push("", t(locale, "day.training")); + for (const workout of workouts) + lines.push( + `• ${esc(workoutType(rowString(workout, "sport_type"), locale))} ${epoch(rowNumber(workout, "start_time"), false, locale)} (${Math.floor(rowNumber(workout, "duration_sec") / 60)}:${String(Math.floor(rowNumber(workout, "duration_sec") % 60)).padStart(2, "0")} · 🔥 ${rowNumber(workout, "calories").toFixed(0)} ${t(locale, "common.kcal")})`, + ); + } + return lines.join("\n"); +} + +function historyText(config: AppConfig, uid: number, days = 7): string { + const locale = localeOf(config, uid); + const end = localDay(); + const start = localDay(-(days - 1)); + const steps = dbRows(config, uid, "SELECT date,total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [ + start, + end, + ]); + const sleep = dbRows( + config, + uid, + "SELECT date,light_sleep_min,deep_sleep_min,COALESCE(total_duration_min,0) total_duration_min FROM sleep_daily WHERE date BETWEEN ? AND ?", + [start, end], + ); + const sleepByDay = new Map(sleep.map((row) => [rowString(row, "date"), row])); + const allDays = [ + ...new Set([...steps.map((row) => rowString(row, "date")), ...sleep.map((row) => rowString(row, "date"))]), + ] + .sort() + .reverse(); + if (!allDays.length) return `${t(locale, "history.title", { days })}\n\n${t(locale, "history.empty")}`; + return [ + t(locale, "history.title", { days }), + "", + ...allDays.map((day) => { + const step = steps.find((row) => rowString(row, "date") === day) ?? null; + const sleepRow = sleepByDay.get(day) ?? null; + const date = new Date(`${day}T12:00:00Z`); + const label = new Intl.DateTimeFormat(numberLocale(locale), { + day: "2-digit", + month: "2-digit", + timeZone: "Europe/Moscow", + }).format(date); + return `${dayEmoji(step, sleepRow)} ${label} ${step ? `${rowNumber(step, "total_steps").toLocaleString(numberLocale(locale))} ${t(locale, "history.steps")}` : t(locale, "history.no-steps")} · ${sleepRow ? minutes(sleepTotal(sleepRow), locale) : t(locale, "history.no-sleep")}`; + }), + "", + t(locale, "history.hint"), + ].join("\n"); +} + +function weeklyText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + const summary = periodSummary(config, uid, 7); + const steps = summary.steps as Record[]; + const sleep = summary.sleep as Record[]; + const averages = (values: number[]) => + values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0; + const avgSteps = averages(steps.map((row) => rowNumber(row, "total_steps"))); + const avgSleep = averages(sleep.map((row) => sleepTotal(row))); + const avgBed = formatBedtime(averageBedtime(sleep), locale); + const previousStart = localDay(-13); + const previousEnd = localDay(-7); + const previousSteps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [ + previousStart, + previousEnd, + ]).map((row) => rowNumber(row, "total_steps")); + const previousSleep = dbRows(config, uid, "SELECT total_duration_min FROM sleep_daily WHERE date BETWEEN ? AND ?", [ + previousStart, + previousEnd, + ]).map((row) => rowNumber(row, "total_duration_min")); + const diff = (current: number, previous: number, suffix: string) => + previous + ? ` (${current - previous >= 0 ? "+" : ""}${current - previous}${suffix} ${current >= previous ? "📈" : "📉"})` + : ""; + const recordSteps = steps.length + ? steps.reduce( + (best, row) => (rowNumber(row, "total_steps") > rowNumber(best, "total_steps") ? row : best), + steps[0] as Record, + ) + : undefined; + const recordSleep = sleep.length + ? sleep.reduce( + (best, row) => (sleepTotal(row) > sleepTotal(best) ? row : best), + sleep[0] as Record, + ) + : undefined; + return [ + t(locale, "weekly.title", { start: String(summary.start), end: String(summary.end) }), + "", + t(locale, "weekly.steps", { + value: `${avgSteps.toLocaleString(numberLocale(locale))}${diff(avgSteps, averages(previousSteps), "")}`, + }), + t(locale, "weekly.sleep", { + value: `${avgSleep ? minutes(avgSleep, locale) : t(locale, "common.na")}${diff(avgSleep, averages(previousSleep), ` ${t(locale, "common.minutes")}`)}`, + }), + t(locale, "weekly.bedtime", { value: avgBed }), + "", + t(locale, "weekly.records", { + steps: recordSteps + ? `${rowNumber(recordSteps, "total_steps")} (${formatWeekday(rowString(recordSteps, "date"), locale)})` + : t(locale, "common.na"), + sleep: recordSleep + ? `${minutes(sleepTotal(recordSleep), locale)} (${formatWeekday(rowString(recordSleep, "date"), locale)})` + : t(locale, "common.na"), + }), + ].join("\n"); +} + +function trendsText(config: AppConfig, uid: number, days: number): string { + const locale = localeOf(config, uid); + const summary = periodSummary(config, uid, days >= 3650 ? 3650 : days); + const steps = summary.steps as Record[]; + const sleep = summary.sleep as Record[]; + const totalSteps = steps.reduce((sum, row) => sum + rowNumber(row, "total_steps"), 0); + const avgSteps = steps.length ? Math.round(totalSteps / steps.length) : 0; + const sleepValues = sleep.map(sleepTotal); + const avgSleep = sleepValues.length ? Math.round(sleepValues.reduce((a, b) => a + b, 0) / sleepValues.length) : 0; + const best = steps.reduce( + (bestRow, row) => (rowNumber(row, "total_steps") > rowNumber(bestRow, "total_steps") ? row : bestRow), + steps[0], + ); + const hr = summary.hr as Record | null; + const spo2 = summary.spo2 as Record | null; + const stress = summary.stress as Record | null; + const calories = summary.calories as Record[]; + const weight = (summary.weight as Record[])[0]; + const period = days >= 3650 ? t(locale, "common.all-time") : `${days} ${t(locale, "common.days")}`; + const lines = [ + t(locale, "trends.title", { period, start: String(summary.start), end: String(summary.end) }), + "", + t(locale, "trends.total-steps", { value: totalSteps.toLocaleString(numberLocale(locale)) }), + t(locale, "trends.average-steps", { value: avgSteps.toLocaleString(numberLocale(locale)) }), + t(locale, "trends.goal", { + done: steps.filter((row) => rowNumber(row, "total_steps") >= STEP_GOAL).length, + total: steps.length, + }), + ]; + if (best) + lines.push( + t(locale, "trends.best", { + date: rowString(best, "date"), + steps: rowNumber(best, "total_steps").toLocaleString(numberLocale(locale)), + }), + ); + if (calories.length) + lines.push( + "", + t(locale, "trends.activity"), + t(locale, "trends.energy", { + value: Math.round(calories.reduce((sum, row) => sum + rowNumber(row, "total_cal"), 0) / calories.length), + }), + ); + lines.push( + "", + t(locale, "trends.average-sleep", { value: avgSleep ? minutes(avgSleep, locale) : t(locale, "common.na") }), + t(locale, "trends.average-heart-rate", { + value: + hr && rowNumber(hr, "count") + ? `${rowNumber(hr, "avg_value").toFixed(0)} bpm · ${rowNumber(hr, "min_value").toFixed(0)}–${rowNumber(hr, "max_value").toFixed(0)}` + : t(locale, "common.na"), + }), + t(locale, "trends.average-oxygen", { + value: + spo2 && rowNumber(spo2, "count") + ? `${rowNumber(spo2, "avg_value").toFixed(1)}% · min. ${rowNumber(spo2, "min_value").toFixed(0)}%` + : t(locale, "common.na"), + }), + t(locale, "trends.average-stress", { + value: + stress && rowNumber(stress, "count") + ? `${rowNumber(stress, "avg_value").toFixed(0)} · ${rowNumber(stress, "min_value").toFixed(0)}–${rowNumber(stress, "max_value").toFixed(0)}` + : t(locale, "common.na"), + }), + ); + if (weight) lines.push(t(locale, "trends.latest-weight", { value: rowNumber(weight, "weight_kg").toFixed(1) })); + return lines.concat(["", `${t(locale, "common.health-care")}`]).join("\n"); +} + +function familyText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + if (config.allowedUserIds.length < 2) + return `👪 ${t(locale, "family.title")}\n\n${t(locale, "family.need-two")}`; + const [first, second] = config.allowedUserIds; + if (first === undefined || second === undefined) return t(locale, "family.no-data"); + const stats = (uid: number) => { + const start = localDay(-6); + const end = localDay(); + const steps = dbRows(config, uid, "SELECT total_steps,distance_m FROM steps_daily WHERE date BETWEEN ? AND ?", [ + start, + end, + ]); + const sleep = dbRows( + config, + uid, + "SELECT total_duration_min,start_time FROM sleep_daily WHERE date BETWEEN ? AND ?", + [start, end], + ); + return { + avgSteps: steps.length + ? Math.round(steps.reduce((sum, row) => sum + rowNumber(row, "total_steps"), 0) / steps.length) + : 0, + totalSteps: steps.reduce((sum, row) => sum + rowNumber(row, "total_steps"), 0), + distance: steps.reduce((sum, row) => sum + rowNumber(row, "distance_m"), 0), + avgSleep: sleep.length + ? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length) + : 0, + bedtime: averageBedtime(sleep), + }; + }; + const a = stats(first); + const b = stats(second); + const nameA = USER_NAMES[first] ?? `User ${first}`; + const nameB = USER_NAMES[second] ?? `User ${second}`; + const winner = (left: number, right: number, leftName: string, rightName: string, suffix: string) => + left > right + ? `${leftName} 🏆 (${left}${suffix} vs ${right}${suffix})` + : right > left + ? `${rightName} 🏆 (${right}${suffix} vs ${left}${suffix})` + : `${t(locale, "versus.tie")} (${left}${suffix})`; + const totalKm = (a.distance + b.distance) / 1000; + const route = + totalKm < 10 + ? t(locale, "common.family-route.park") + : totalKm < 30 + ? t(locale, "common.family-route.mytishchi") + : totalKm < 60 + ? t(locale, "common.family-route.podolsk") + : totalKm < 100 + ? t(locale, "common.family-route.sergiev") + : t(locale, "common.family-route.kolomna"); + return [ + `👪 ${t(locale, "family.title")}`, + "", + t(locale, "family.steps-cup", { + value: winner(a.avgSteps, b.avgSteps, nameA, nameB, ` ${t(locale, "common.day")}`), + }), + t(locale, "family.sleep-cup", { + value: winner(a.avgSleep, b.avgSleep, nameA, nameB, ` ${t(locale, "common.minutes")}`), + }), + "", + t(locale, "family.goal"), + t(locale, "family.distance", { + steps: (a.totalSteps + b.totalSteps).toLocaleString(numberLocale(locale)), + distance: totalKm.toFixed(1), + route, + }), + ].join("\n"); +} + +function versusText(config: AppConfig, uid: number, days: number): string { + const locale = localeOf(config, uid); + if (config.allowedUserIds.length < 2) return `📊 Versus\n\n${t(locale, "family.need-two")}`; + const [first, second] = config.allowedUserIds; + if (first === undefined || second === undefined) return t(locale, "versus.no-data"); + const names = [USER_NAMES[first] ?? `User ${first}`, USER_NAMES[second] ?? `User ${second}`]; + const data = (uid: number) => { + const end = localDay(); + const start = localDay(-(days - 1)); + const steps = dbRows(config, uid, "SELECT total_steps FROM steps_daily WHERE date BETWEEN ? AND ?", [ + start, + end, + ]).map((row) => rowNumber(row, "total_steps")); + const sleep = dbRows( + config, + uid, + "SELECT total_duration_min,start_time FROM sleep_daily WHERE date BETWEEN ? AND ?", + [start, end], + ); + return { + steps: steps.length ? Math.round(steps.reduce((a, b) => a + b, 0) / steps.length) : 0, + sleep: sleep.length + ? Math.round(sleep.reduce((sum, row) => sum + rowNumber(row, "total_duration_min"), 0) / sleep.length) + : 0, + bedtime: averageBedtime(sleep), + }; + }; + const a = data(first); + const b = data(second); + const stepWinner = + a.steps > b.steps + ? t(locale, "versus.ahead", { name: names[0] ?? "" }) + : b.steps > a.steps + ? t(locale, "versus.ahead", { name: names[1] ?? "" }) + : t(locale, "versus.tie"); + const sleepWinner = + a.sleep > b.sleep + ? t(locale, "versus.slept-longer", { name: names[0] ?? "" }) + : b.sleep > a.sleep + ? t(locale, "versus.slept-longer", { name: names[1] ?? "" }) + : t(locale, "versus.tie"); + const bedtimeWinner = + a.bedtime !== null && b.bedtime !== null + ? a.bedtime < b.bedtime + ? t(locale, "versus.fell-earlier", { name: names[0] ?? "" }) + : b.bedtime < a.bedtime + ? t(locale, "versus.fell-earlier", { name: names[1] ?? "" }) + : t(locale, "versus.same-time") + : ""; + return [ + t(locale, "versus.title", { + first: names[0] ?? "", + second: names[1] ?? "", + period: days === 1 ? t(locale, "common.today") : t(locale, "menu.weekly"), + }), + "", + t(locale, "versus.steps"), + t(locale, "versus.steps-line", { name: names[0] ?? "", value: a.steps }), + `${t(locale, "versus.steps-line", { name: names[1] ?? "", value: b.steps })} ${stepWinner}`, + "", + t(locale, "versus.sleep"), + t(locale, "versus.sleep-line", { + name: names[0] ?? "", + value: a.sleep ? minutes(a.sleep, locale) : t(locale, "common.na"), + }), + `${t(locale, "versus.sleep-line", { name: names[1] ?? "", value: b.sleep ? minutes(b.sleep, locale) : t(locale, "common.na") })} ${sleepWinner}`, + "", + t(locale, "versus.bedtime"), + `• ${names[0]}: ${formatBedtime(a.bedtime, locale)}`, + `• ${names[1]}: ${formatBedtime(b.bedtime, locale)} ${bedtimeWinner}`, + ].join("\n"); +} + +function keyboard(rows: Array>): InlineKeyboard { + const result = new InlineKeyboard(); + rows.forEach((row, rowIndex) => { + row.forEach(([text, data], index) => { + if (index > 0) result.text(text, data); + else result.text(text, data); + }); + if (rowIndex < rows.length - 1) result.row(); + }); + return result; +} + +function mainKb(locale: Locale): InlineKeyboard { + return keyboard([ + [ + [t(locale, "menu.sleep"), "menu:sleep"], + [t(locale, "menu.weekly"), "menu:trends"], + ], + [ + [t(locale, "menu.history"), "menu:history"], + [t(locale, "menu.settings"), "menu:more"], + ], + ]); +} + +function backKb(locale: Locale, to = "menu:main"): InlineKeyboard { + return keyboard([[[t(locale, "menu.back"), to]]]); +} +function moreText(config: AppConfig, uid: number): string { + const locale = localeOf(config, uid); + const status = readStatus(config, uid); + const tables = [ + "steps_daily", + "sleep_daily", + "sleep_stages", + "heart_rate", + "blood_oxygen", + "stress", + "calories_daily", + "weight", + "workouts", + ]; + const records = tables.reduce( + (total, table) => total + rowNumber(dbRows(config, uid, `SELECT COUNT(*) AS count FROM ${table}`)[0], "count"), + 0, + ); + return [ + `⚙️ ${t(locale, "menu.service")}`, + "", + t(locale, "service.device"), + t(locale, "service.last-sync", { value: esc(String(status.last_sync_time ?? t(locale, "common.na"))) }), + t(locale, "service.interval", { value: Math.floor(config.SYNC_INTERVAL / 60) }), + t(locale, "service.records", { value: records.toLocaleString(numberLocale(locale)) }), + ].join("\n"); +} + +function moreKb(locale: Locale): InlineKeyboard { + return keyboard([ + [ + [t(locale, "menu.sync"), "menu:sync"], + [t(locale, "menu.export"), "menu:export"], + ], + [ + [t(locale, "menu.db-status"), "menu:db_status"], + [t(locale, "menu.workouts"), "menu:workouts"], + ], + [[t(locale, "menu.family"), "menu:family:more"]], + [[t(locale, "menu.language"), "menu:language"]], + [[t(locale, "menu.home"), "menu:main"]], + ]); +} +function trendsKb(days: number, locale: Locale): InlineKeyboard { + const daysLabel = (value: number) => `${value} ${t(locale, "common.days")}`; + return keyboard([ + [ + [days === 7 ? `· ${daysLabel(7)} ·` : daysLabel(7), "period:7d"], + [days === 30 ? `· ${daysLabel(30)} ·` : daysLabel(30), "period:30d"], + ], + [ + [days >= 3650 ? `· ${t(locale, "common.all-time")} ·` : t(locale, "common.all-time"), "period:all"], + [t(locale, "menu.family"), "menu:family:trends"], + ], + [[t(locale, "menu.home"), "menu:main"]], + ]); +} + +function versusKb(locale: Locale): InlineKeyboard { + return keyboard([ + [ + [t(locale, "common.today"), "versus:1"], + [t(locale, "menu.weekly"), "versus:7"], + ], + [[t(locale, "menu.home"), "menu:main"]], + ]); +} + +function weeklyBackKb(locale: Locale): InlineKeyboard { + return keyboard([[[t(locale, "menu.family"), "menu:family:weekly"]]]); +} +function historyKb(config: AppConfig, uid: number, days = 7, locale = localeOf(config, uid)): InlineKeyboard { + const buttons: Array> = [ + [ + [days === 7 ? `· 7 ${t(locale, "common.days")} ·` : `7 ${t(locale, "common.days")}`, "period_cal:7"], + [days === 30 ? `· 30 ${t(locale, "common.days")} ·` : `30 ${t(locale, "common.days")}`, "period_cal:30"], + ], + ]; + const dates = availableDays(config, uid, days); + for (let index = 0; index < dates.length; index += 3) + buttons.push( + dates.slice(index, index + 3).map( + (day) => + [ + new Intl.DateTimeFormat(numberLocale(locale), { + day: "numeric", + month: "short", + timeZone: "Europe/Moscow", + }).format(new Date(`${day}T12:00:00Z`)), + `day:${day}`, + ] as [string, string], + ), + ); + buttons.push([[t(locale, "menu.home"), "menu:main"]]); + return keyboard(buttons); +} + +function dayKb(day: string, locale: Locale): InlineKeyboard { + const previous = new Date(`${day}T12:00:00Z`); + previous.setUTCDate(previous.getUTCDate() - 1); + const next = new Date(`${day}T12:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + const iso = (value: Date) => value.toISOString().slice(0, 10); + const nextDay = iso(next); + return keyboard([ + [ + [`◀️ ${iso(previous)}`, `day:${iso(previous)}`], + [ + nextDay > localDay() ? t(locale, "menu.home") : `${nextDay} ▶️`, + nextDay > localDay() ? "menu:main" : `day:${nextDay}`, + ], + ], + [[t(locale, "menu.calendar"), "menu:history"]], + ]); +} + +function languageKb(locale: Locale): InlineKeyboard { + return keyboard([ + ...LOCALES.map((target) => [ + [target === locale ? `· ${LOCALE_NAMES[target]} ·` : LOCALE_NAMES[target], `locale:${target}`] as [ + string, + string, + ], + ]), + [[t(locale, "menu.back"), "menu:more"]], + ]); +} + +async function showMenu( + context: BotContext, + config: AppConfig, + text: string, + replyMarkup: InlineKeyboard | undefined, + forceNew = false, +): Promise { + const uid = uidOf(context); + const chatId = context.chat?.id; + if (uid === null || chatId === undefined) return; + const stored = getUserMenuMessageId(config, uid); + const messageId = context.callbackQuery?.message?.message_id; + if (!forceNew && (messageId ?? stored)) { + try { + await context.api.editMessageText(chatId, messageId ?? stored ?? 0, text, { + parse_mode: "HTML", + ...(replyMarkup ? { reply_markup: replyMarkup } : {}), + }); + setUserMenuMessageId(config, uid, messageId ?? stored ?? 0); + return; + } catch (error) { + log("debug", "Menu edit failed, sending a new message", { error }); + } + } + const sent = await context.reply(text, { parse_mode: "HTML", ...(replyMarkup ? { reply_markup: replyMarkup } : {}) }); + setUserMenuMessageId(config, uid, sent.message_id); +} + +async function deleteIncoming(context: BotContext): Promise { + if (context.msg) + await context.api.deleteMessage(context.chat?.id ?? 0, context.msg.message_id).catch(() => undefined); +} + +function hasToken(config: AppConfig, uid: number): boolean { + return existsSync(tokenPath(config, uid)); +} +function onboardingText(locale: Locale): string { + return `${t(locale, "auth.title")}\n\n${t(locale, "auth.first-run")}`; +} +function onboardingKb(locale: Locale): InlineKeyboard { + return keyboard([[[t(locale, "auth.open-login"), "auth:start"]]]); +} + +function externalUrl(value: string): string { + return value.startsWith("//") ? `https:${value}` : value; +} + +async function startLogin(context: BotContext, config: AppConfig, force = false): Promise { + const uid = uidOf(context); + if (uid === null) return; + const locale = localeOf(config, uid); + if (hasToken(config, uid) && !force) { + await showMenu(context, config, mainMenuText(config, uid), mainKb(locale)); + return; + } + await showMenu(context, config, `${t(locale, "auth.title")}\n\n${t(locale, "auth.prepare")}`, undefined); + const auth = new XiaomiAuth(); + try { + const token = await auth.loginQr(async (qr, login) => { + const qrKeyboard = new InlineKeyboard(); + if (login) qrKeyboard.url(t(locale, "auth.open-login"), externalUrl(login)); + if (qr) qrKeyboard.row().url(t(locale, "auth.open-qr"), externalUrl(qr)); + await showMenu( + context, + config, + `${t(locale, "auth.title")}\n\n${t(locale, "auth.open")}`, + login || qr ? qrKeyboard : keyboard([[[t(locale, "auth.retry"), "auth:start"]]]), + ); + }); + saveAuthToken(tokenPath(config, uid), token); + const initial = { ...config, QUERY_DURATION: 30 }; + await showMenu(context, config, `✅ ${t(locale, "auth.title")}\n\n${t(locale, "auth.confirmed")}`, undefined); + const result = await runSync(uid, initial); + await showMenu( + context, + config, + result.success + ? mainMenuText(config, uid) + : t(locale, "sync.failed", { error: esc(result.error ?? t(locale, "common.no-data")) }), + result.success ? mainKb(locale) : backKb(locale, "menu:more"), + ); + } catch (error) { + await showMenu( + context, + config, + `${t(locale, "auth.title")}\n\n${t(locale, "auth.failed", { error: esc(error) })}`, + keyboard([ + [ + [t(locale, "auth.relogin"), "auth:relogin"], + [t(locale, "menu.service"), "menu:more"], + ], + ]), + ); + } +} + +async function manualSync(context: BotContext, config: AppConfig): Promise { + const uid = uidOf(context); + if (uid === null) return; + const locale = localeOf(config, uid); + if (!hasToken(config, uid)) { + await showMenu(context, config, onboardingText(locale), onboardingKb(locale)); + return; + } + await showMenu( + context, + config, + `${t(locale, "sync.title")}\n\n${t(locale, "sync.running")}`, + backKb(locale, "menu:more"), + ); + const result = await runSync(uid, config); + const text = result.success + ? t(locale, "sync.done") + : t(locale, "sync.failed", { error: esc(result.error ?? t(locale, "common.no-data")) }); + await showMenu(context, config, text, result.success ? mainKb(locale) : backKb(locale, "menu:more")); +} + +export function createBot(config: AppConfig): Bot { + if (!config.TELEGRAM_BOT_TOKEN) throw new Error("TELEGRAM_BOT_TOKEN is required"); + const bot = new Bot(config.TELEGRAM_BOT_TOKEN, { client: { apiRoot: config.TELEGRAM_API_ROOT } }); + bot.use(async (context, next) => { + if (allowed(context, config)) await next(); + }); + bot.command("start", async (context) => { + await deleteIncoming(context); + const uid = uidOf(context); + if (uid === null) return; + const locale = localeOf(config, uid); + if (!hasToken(config, uid)) await showMenu(context, config, onboardingText(locale), onboardingKb(locale), true); + else await showMenu(context, config, mainMenuText(config, uid), mainKb(locale), true); + }); + bot.command("status", async (context) => { + await deleteIncoming(context); + const uid = uidOf(context); + if (uid !== null) + await showMenu(context, config, statusText(config, uid), backKb(localeOf(config, uid), "menu:more")); + }); + bot.command("sync", async (context) => { + await deleteIncoming(context); + await manualSync(context, config); + }); + bot.command("versus", async (context) => { + const uid = uidOf(context); + if (uid === null) return; + const locale = localeOf(config, uid); + await showMenu(context, config, t(locale, "versus.choose"), versusKb(locale), true); + }); + bot.on("message:text", async (context) => { + const text = context.message.text; + if (text.startsWith("/")) return; + const uid = uidOf(context); + if (uid === null) return; + const locale = localeOf(config, uid); + await deleteIncoming(context); + if (!hasToken(config, uid)) return showMenu(context, config, onboardingText(locale), onboardingKb(locale)); + if (text.includes("Сон") || text.includes("Sleep") || text.includes("Sueño")) + return showMenu(context, config, sleepText(config, uid), backKb(locale)); + if (text.includes("неделю") || text.includes("Weekly") || text.includes("Semanal")) + return showMenu(context, config, trendsText(config, uid, 7), trendsKb(7, locale)); + if (text.includes("История") || text.includes("History") || text.includes("Historial")) + return showMenu(context, config, historyText(config, uid, 7), historyKb(config, uid, 7, locale)); + if (text.includes("Настройки") || text.includes("Settings") || text.includes("Ajustes")) + return showMenu(context, config, moreText(config, uid), moreKb(locale)); + return showMenu(context, config, mainMenuText(config, uid), mainKb(locale)); + }); + bot.on("callback_query:data", async (context) => { + await context.answerCallbackQuery(); + const uid = uidOf(context); + if (uid === null) return; + const locale = localeOf(config, uid); + const data = context.callbackQuery.data; + if (data === "auth:start" || data === "auth:relogin") return startLogin(context, config, data === "auth:relogin"); + if (!hasToken(config, uid) && !data.startsWith("menu:main")) + return showMenu(context, config, onboardingText(locale), onboardingKb(locale)); + if (data === "menu:main") return showMenu(context, config, mainMenuText(config, uid), mainKb(locale)); + if (data === "menu:sleep") return showMenu(context, config, sleepText(config, uid), backKb(locale)); + if (data === "menu:trends") return showMenu(context, config, trendsText(config, uid, 7), trendsKb(7, locale)); + if (data.startsWith("period:")) { + const days = data === "period:all" ? 3650 : data === "period:30d" ? 30 : 7; + return showMenu(context, config, trendsText(config, uid, days), trendsKb(days, locale)); + } + if (data === "menu:history") + return showMenu(context, config, historyText(config, uid, 7), historyKb(config, uid, 7, locale)); + if (data.startsWith("history:")) + return showMenu( + context, + config, + historyText(config, uid, Number(data.split(":")[1])), + historyKb(config, uid, Number(data.split(":")[1]), locale), + ); + if (data.startsWith("period_cal:")) { + const days = Number(data.split(":")[1]) === 30 ? 30 : 7; + return showMenu(context, config, historyText(config, uid, days), historyKb(config, uid, days, locale)); + } + if (data.startsWith("day:")) { + const day = data.slice("day:".length); + return showMenu(context, config, dayText(config, uid, day), dayKb(day, locale)); + } + if (data === "menu:more") return showMenu(context, config, moreText(config, uid), moreKb(locale)); + if (data === "menu:language") + return showMenu( + context, + config, + `${t(locale, "language.title")}\n\n${t(locale, "language.selected", { language: LOCALE_NAMES[locale] })}`, + languageKb(locale), + ); + if (data.startsWith("locale:")) { + const target = data.slice("locale:".length); + if (isLocale(target)) { + setUserLocale(config, uid, target); + return showMenu( + context, + config, + t(target, "language.title") + + "\n\n" + + t(target, "language.updated") + + "\n\n" + + t(target, "language.selected", { language: LOCALE_NAMES[target] }), + languageKb(target), + ); + } + } + if (data === "menu:sync") return manualSync(context, config); + if (data === "menu:db_status") + return showMenu(context, config, statusText(config, uid), backKb(locale, "menu:more")); + if (data === "menu:workouts") + return showMenu(context, config, workoutsText(config, uid), backKb(locale, "menu:more")); + if (data.startsWith("menu:family")) { + const source = data.split(":")[2]; + return showMenu( + context, + config, + familyText(config, uid), + backKb(locale, source === "weekly" ? "menu:weekly_back" : "menu:trends"), + ); + } + if (data === "menu:weekly_back") return showMenu(context, config, weeklyText(config, uid), weeklyBackKb(locale)); + if (data === "menu:versus") return showMenu(context, config, t(locale, "versus.choose"), versusKb(locale)); + if (data.startsWith("versus:")) + return showMenu(context, config, versusText(config, uid, Number(data.split(":")[1])), versusKb(locale)); + if (data === "menu:export") { + await showMenu(context, config, t(locale, "export.running"), undefined); + const archive = await zipExport(config, uid); + if (archive && context.chat) + await context.api.sendDocument(context.chat.id, new InputFile(archive, `miband-health-${Date.now()}.zip`), { + caption: t(locale, "export.caption"), + }); + return showMenu( + context, + config, + archive ? t(locale, "export.sent") : t(locale, "export.empty"), + backKb(locale, "menu:more"), + ); + } + return showMenu(context, config, mainMenuText(config, uid), mainKb(locale)); + }); + bot.catch((error) => + log("error", "Unhandled Telegram error", { error: error.error, update: error.ctx.update.update_id }), + ); + return bot; +} + +export async function configureBot(bot: Bot): Promise { + await bot.api.setMyCommands([ + { command: "start", description: "Open menu" }, + { command: "sync", description: "Synchronize data" }, + { command: "status", description: "Database status" }, + { command: "versus", description: "Compare activity" }, + ]); +} + +export function startBotTasks(bot: Bot, config: AppConfig): TaskHandle { + let stopped = false; + let timer: ReturnType | undefined; + let lastWeeklyDate = ""; + const seenStatus = new Map(); + const run = async (): Promise => { + if (stopped) return; + const today = new Date(); + const weeklyDate = today.toISOString().slice(0, 10); + if (today.getDay() === 0 && today.getHours() === 21 && today.getMinutes() === 0 && lastWeeklyDate !== weeklyDate) { + lastWeeklyDate = weeklyDate; + for (const uid of config.allowedUserIds) { + try { + const locale = localeOf(config, uid); + await bot.api.sendMessage(uid, weeklyText(config, uid), { + parse_mode: "HTML", + reply_markup: keyboard([[[t(locale, "menu.family"), "menu:family:weekly"]]]), + }); + } catch (error) { + log("warn", "Weekly push failed", { userId: uid, error }); + } + } + } + for (const uid of config.allowedUserIds) { + const menuMessageId = getUserMenuMessageId(config, uid); + if (!menuMessageId) continue; + try { + const modified = statSync(canonicalUserStatusPath(config, uid)).mtimeMs; + if (modified <= (seenStatus.get(uid) ?? 0)) continue; + seenStatus.set(uid, modified); + await bot.api.editMessageText(uid, menuMessageId, mainMenuText(config, uid), { + parse_mode: "HTML", + reply_markup: mainKb(localeOf(config, uid)), + }); + } catch (error) { + log("debug", "Automatic menu refresh skipped", { userId: uid, error }); + } + } + if (!stopped) timer = setTimeout(() => void run(), Math.max(5, config.AUTO_MENU_REFRESH_INTERVAL) * 1000); + }; + void run(); + return { + stop: async () => { + stopped = true; + if (timer) clearTimeout(timer); + }, + }; +} + +export type { SyncResult }; diff --git a/src/bot/formatting.ts b/src/bot/formatting.ts new file mode 100644 index 0000000..ab4dfc7 --- /dev/null +++ b/src/bot/formatting.ts @@ -0,0 +1,141 @@ +import type { Locale } from "./i18n.js"; +import { t } from "./i18n.js"; + +export const DEFAULT_STEP_GOAL = 10_000; +const SPORT_TYPES: Record> = { + en: { + free_training: "Free training", + outdoor_running: "Outdoor running", + treadmill: "Treadmill", + walking: "Walking", + cycling: "Cycling", + swimming: "Swimming", + yoga: "Yoga", + strength_training: "Strength training", + hiit: "HIIT", + jump_rope: "Jump rope", + elliptical: "Elliptical", + rowing: "Rowing", + outdoor_cycling: "Outdoor cycling", + basketball: "Basketball", + football: "Football", + table_tennis: "Table tennis", + badminton: "Badminton", + tennis: "Tennis", + volleyball: "Volleyball", + dancing: "Dancing", + martial_arts: "Martial arts", + }, + ru: { + 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: "Боевые искусства", + }, + es: { + free_training: "Entrenamiento libre", + outdoor_running: "Carrera al aire libre", + treadmill: "Cinta de correr", + walking: "Caminar", + cycling: "Ciclismo", + swimming: "Natación", + yoga: "Yoga", + strength_training: "Fuerza", + hiit: "HIIT", + jump_rope: "Saltar a la cuerda", + elliptical: "Elíptica", + rowing: "Remo", + outdoor_cycling: "Ciclismo al aire libre", + basketball: "Baloncesto", + football: "Fútbol", + table_tennis: "Tenis de mesa", + badminton: "Bádminton", + tennis: "Tenis", + volleyball: "Voleibol", + dancing: "Baile", + martial_arts: "Artes marciales", + }, +}; + +export function esc(value: unknown): string { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function epoch(value: unknown, withDate = true, locale: Locale = "en"): string { + const seconds = Number(value); + if (!seconds) return t(locale, "common.na"); + const date = new Date(seconds * 1000); + return new Intl.DateTimeFormat(locale === "ru" ? "ru-RU" : locale === "es" ? "es-ES" : "en-GB", { + timeZone: "Europe/Moscow", + ...(withDate ? { dateStyle: "short" } : {}), + timeStyle: "short", + }).format(date); +} + +export function minutes(value: unknown, locale: Locale = "en"): string { + if (value === null || value === undefined) return t(locale, "common.na"); + const total = Number(value); + return `${Math.floor(total / 60)} ${t(locale, "common.hours")} ${String(Math.floor(total % 60)).padStart(2, "0")} ${t(locale, "common.minutes")}`; +} + +export function sleepTotal(row: Record): number { + const total = Number(row.total_duration_min ?? 0); + return total > 0 ? total : Number(row.light_sleep_min ?? 0) + Number(row.deep_sleep_min ?? 0); +} + +export function sparkline(values: number[]): string { + if (!values.length) return ""; + const marks = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + const min = Math.min(...values); + const max = Math.max(...values); + if (max === min) return (marks[4] ?? "▄").repeat(Math.min(values.length, 20)); + return values.map((value) => marks[Math.floor(((value - min) / (max - min)) * 7)] ?? " ").join(""); +} + +export function stepBar(value: unknown, goal = DEFAULT_STEP_GOAL): string { + const steps = Number(value ?? 0); + const percent = goal > 0 ? Math.min(100, Math.round((steps / goal) * 100)) : 0; + const blocks = Math.floor(percent / 10); + return `[${"█".repeat(blocks)}${"░".repeat(10 - blocks)}] ${percent}%`; +} + +export function relativeDay(day: string | undefined, locale: Locale = "en"): string { + if (!day) return t(locale, "common.day"); + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + return day === today ? t(locale, "common.today") : day === yesterday ? t(locale, "common.yesterday") : day; +} + +export function workoutType(value: unknown, locale: Locale = "en"): string { + const key = String(value ?? "unknown"); + return SPORT_TYPES[locale][key] ?? key.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +export function rowNumber(row: Record | null | undefined, key: string): number { + return Number(row?.[key] ?? 0); +} + +export function rowString(row: Record | null | undefined, key: string): string { + return String(row?.[key] ?? ""); +} diff --git a/src/bot/i18n.ts b/src/bot/i18n.ts new file mode 100644 index 0000000..5f5e96c --- /dev/null +++ b/src/bot/i18n.ts @@ -0,0 +1,456 @@ +export const LOCALES = ["en", "ru", "es"] as const; +export type Locale = (typeof LOCALES)[number]; + +export const LOCALE_NAMES: Record = { + en: "English", + ru: "Русский", + es: "Español", +}; + +export const DEFAULT_LOCALE: Locale = "en"; + +const catalog = { + en: { + "main.no-steps": "🚶 Steps: N/A", + "main.no-sleep": "😴 Sleep: N/A", + "main.no-metrics": "❤️ 🩸 🧘 N/A", + "menu.sleep": "😴 Sleep", + "menu.weekly": "📊 Weekly", + "menu.history": "📅 History", + "menu.settings": "⚙️ Settings", + "menu.back": "⬅️ Back", + "menu.home": "⬅️ Home", + "menu.service": "⚙️ Service", + "menu.sync": "🔄 Sync", + "menu.export": "💾 Export", + "menu.db-status": "🗄 Database status", + "menu.workouts": "🏋️ Workouts", + "menu.family": "👪 Family", + "menu.language": "🌐 Language", + "menu.calendar": "📅 Calendar", + "language.title": "🌐 Interface language", + "language.selected": "Language: {language}", + "language.updated": "Language updated.", + "sleep.title": "😴 Night sleep", + "sleep.empty": "No sleep data yet.", + "sleep.night": "😴 Night sleep · {date}", + "sleep.duration": "Duration", + "sleep.quality": "Quality", + "sleep.bed": "Bedtime", + "sleep.resting-heart-rate": "Resting HR", + "sleep.deep": "Deep", + "sleep.light": "Light", + "sleep.average": "avg. {avg} · range {min}–{max}", + "sleep.minimum": "avg. {avg}% · min. {min}%", + "sleep.in-sleep": "HR during sleep", + "sleep.in-sleep-spo2": "SpO2 during sleep", + "workouts.title": "🏋️ Recent workouts", + "workouts.empty": "No workouts yet.", + "status.title": "🧰 Database status", + "status.rows": "rows", + "status.path": "Path", + "status.last-sync": "Last sync", + "day.details": "Details for {day} ({label})", + "day.steps": "🚶 Steps:", + "day.no-steps": "🚶 Steps: no data for this day", + "day.activity": "🧍 Activity: stand hours: {hours}h · intensity: {minutes} min", + "day.energy": "🔥 Energy: total: {total} kcal (active: {active} kcal)", + "day.energy-simple": "🔥 Energy: {calories} kcal", + "day.sleep": "😴 Sleep: {total} (deep: {deep} · light: {light})", + "day.no-sleep": "😴 Sleep: no data for this day", + "day.heart-rate": "❤️ Heart rate: avg. {avg} ({min}–{max}) bpm", + "day.no-heart-rate": "❤️ Heart rate: no data for this day", + "day.oxygen": "🩸 Oxygen: avg. {avg}% ({min}–{max}%) SpO2", + "day.no-oxygen": "🩸 Oxygen: no data for this day", + "day.stress": "🧘 Stress: avg. {avg} ({min}–{max})", + "day.no-stress": "🧘 Stress: no data for this day", + "day.weight": "⚖️ Weight: {weight} kg", + "day.training": "🏋️ Workouts:", + "history.title": "📅 Last {days} days", + "history.empty": "Nothing here: no data for this period.", + "history.hint": "Tap a day below for details", + "history.steps": "steps", + "history.no-steps": "steps N/A", + "history.no-sleep": "sleep N/A", + "weekly.title": "📊 Weekly summary: {start} — {end}", + "weekly.steps": "🚶 Steps: {value} per day", + "weekly.sleep": "😴 Sleep: {value}", + "weekly.bedtime": "⏰ Bedtime: {value}", + "weekly.records": "🏆 Records: • Steps: {steps} • Sleep: {sleep}", + "trends.title": "📊 Trends · {period} · {start} — {end}", + "trends.total-steps": "🚶 Total steps {value}", + "trends.average-steps": " Average / day {value}", + "trends.goal": " 10k goal {done} of {total} days", + "trends.best": " 🏆 Best day {date} · {steps}", + "trends.activity": "🧍 Average activity", + "trends.energy": " Energy burned {value} kcal", + "trends.average-sleep": "😴 Average sleep {value}", + "trends.average-heart-rate": "❤️ Average HR {value}", + "trends.average-oxygen": "🩸 Average SpO2 {value}", + "trends.average-stress": "🧘 Average stress {value}", + "trends.latest-weight": "⚖️ Latest weight {value} kg", + "family.title": "👪 Family statistics", + "family.need-two": "At least two users are required in TELEGRAM_ALLOWED_USER_IDS.", + "family.no-data": "👪 No data", + "family.steps-cup": "🏆 Steps cup: {value}", + "family.sleep-cup": "🏆 Sleep cup: {value}", + "family.goal": "👣 Shared goal:", + "family.distance": "Together this week: {steps} steps ({distance} km) — that is {route}", + "versus.title": "📊 Versus: {first} vs {second} ({period})", + "versus.choose": "📊 Compare activity:", + "versus.steps": "🚶 Steps:", + "versus.sleep": "😴 Sleep:", + "versus.bedtime": "⏰ Bedtime:", + "versus.steps-line": "• {name}: {value} steps", + "versus.sleep-line": "• {name}: {value}", + "versus.ahead": "({name} is ahead! 🏆)", + "versus.slept-longer": "({name} slept longer 😴)", + "versus.fell-earlier": "({name} went to bed earlier ⚡)", + "versus.tie": "(Tie 🤝)", + "versus.same-time": "(Same time 🤝)", + "versus.no-data": "📊 No data", + "service.device": "Device Mi Band", + "service.last-sync": "Last sync {value}", + "service.interval": "Interval {value} min", + "service.records": "Database rows {value}", + "auth.title": "🔐 Xiaomi authentication", + "auth.first-run": + "Sign in to Xiaomi Fitness to start. Tap the button below, confirm the login, and I will start synchronization.", + "auth.prepare": "Preparing the login link…", + "auth.open": "Open the link, confirm the login, and come back here. I am waiting for the result.", + "auth.open-login": "🔐 Open Xiaomi login", + "auth.open-qr": "▦ Open QR code", + "auth.retry": "🔄 Try again", + "auth.confirmed": "Login confirmed. Starting the first synchronization…", + "auth.failed": "Login failed: {error}", + "auth.relogin": "🔐 Log in again", + "sync.title": "🔄 Synchronization", + "sync.running": "Fetching data from Xiaomi Fitness…", + "sync.done": "✅ Synchronization\n\nDone, data updated.", + "sync.failed": "⚠️ Synchronization\n\nUpdate failed.\n\nReason: {error}", + "export.running": "💾 Export\n\nBuilding ZIP…", + "export.sent": "✅ Export\n\nZIP with CSV tables was sent above.", + "export.empty": "💾 Export\n\nThere is no data in the database.", + "export.caption": "💚 MiBand Health CSV Export", + "commands.start": "Open menu", + "commands.sync": "Synchronize data", + "commands.status": "Database status", + "commands.versus": "Compare activity", + "common.na": "N/A", + "common.today": "Today", + "common.yesterday": "Yesterday", + "common.all-time": "All time", + "common.days": "days", + "common.day": "day", + "common.hours": "h", + "common.minutes": "min", + "common.km": "km", + "common.kcal": "kcal", + "common.no-data": "No data", + "common.health-care": "Take care of your health!", + "common.family-route.park": "a walk around the park 🌳", + "common.family-route.mytishchi": "a walk from Moscow to Mytishchi 🏰", + "common.family-route.podolsk": "a walk from Moscow to Podolsk 🏭", + "common.family-route.sergiev": "a walk from Moscow to Sergiyev Posad ⛪", + "common.family-route.kolomna": "a walk from Moscow to Kolomna! 🚶‍♂️🚶‍♀️", + }, + ru: { + "main.no-steps": "🚶 Шаги н/д", + "main.no-sleep": "😴 Сон н/д", + "main.no-metrics": "❤️ 🩸 🧘 н/д", + "menu.sleep": "😴 Сон", + "menu.weekly": "📊 За неделю", + "menu.history": "📅 История", + "menu.settings": "⚙️ Настройки", + "menu.back": "⬅️ Назад", + "menu.home": "⬅️ Главная", + "menu.service": "⚙️ Сервис", + "menu.sync": "🔄 Синхронизация", + "menu.export": "💾 Экспорт", + "menu.db-status": "🗄 Статус БД", + "menu.workouts": "🏋️ Тренировки", + "menu.family": "👪 Семья", + "menu.language": "🌐 Язык", + "menu.calendar": "📅 Календарь", + "language.title": "🌐 Язык интерфейса", + "language.selected": "Язык: {language}", + "language.updated": "Язык обновлён.", + "sleep.title": "😴 Ночной сон", + "sleep.empty": "Пока нет данных.", + "sleep.night": "😴 Ночной сон · {date}", + "sleep.duration": "Длительность", + "sleep.quality": "Качество", + "sleep.bed": "Постель", + "sleep.resting-heart-rate": "Пульс покоя", + "sleep.deep": "Глубокий", + "sleep.light": "Лёгкий", + "sleep.average": "ср. {avg} · диапазон {min}–{max}", + "sleep.minimum": "ср. {avg}% · мин. {min}%", + "sleep.in-sleep": "ЧСС во сне", + "sleep.in-sleep-spo2": "SpO2 во сне", + "workouts.title": "🏋️ Последние тренировки", + "workouts.empty": "Тренировок пока нет.", + "status.title": "🧰 Статус базы данных", + "status.rows": "строк", + "status.path": "Путь", + "status.last-sync": "Последний синк", + "day.details": "Детали за {day} ({label})", + "day.steps": "🚶 Шаги:", + "day.no-steps": "🚶 Шаги: за этот день данных нет", + "day.activity": "🧍 Активность: разминки: {hours}ч · интенсивность: {minutes} мин", + "day.energy": "🔥 Энергия: всего: {total} ккал (активные: {active} ккал)", + "day.energy-simple": "🔥 Энергия: {calories} ккал", + "day.sleep": "😴 Сон: {total} (глубокий: {deep} · лёгкий: {light})", + "day.no-sleep": "😴 Сон: за этот день данных нет", + "day.heart-rate": "❤️ Пульс: ср. {avg} ({min}–{max}) bpm", + "day.no-heart-rate": "❤️ Пульс: за этот день данных нет", + "day.oxygen": "🩸 Кислород: ср. {avg}% ({min}–{max}%) SpO2", + "day.no-oxygen": "🩸 Кислород: за этот день данных нет", + "day.stress": "🧘 Стресс: ср. {avg} ({min}–{max})", + "day.no-stress": "🧘 Стресс: за этот день данных нет", + "day.weight": "⚖️ Вес: {weight} кг", + "day.training": "🏋️ Тренировки:", + "history.title": "📅 Последние {days} дней", + "history.empty": "Пока пусто: за этот период данных нет.", + "history.hint": "Нажми на день ниже для деталей", + "history.steps": "шагов", + "history.no-steps": "шаги н/д", + "history.no-sleep": "сон н/д", + "weekly.title": "📊 Итоги недели: {start} — {end}", + "weekly.steps": "🚶 Шаги: {value} в день", + "weekly.sleep": "😴 Сон: {value}", + "weekly.bedtime": "⏰ Засыпание: {value}", + "weekly.records": "🏆 Рекорды: • Шаги: {steps} • Сон: {sleep}", + "trends.title": "📊 Тренды · {period} · {start} — {end}", + "trends.total-steps": "🚶 Шаги всего {value}", + "trends.average-steps": " В среднем / день {value}", + "trends.goal": " Норма 10k {done} из {total} дней", + "trends.best": " 🏆 Лучший день {date} · {steps}", + "trends.activity": "🧍 Активность ср.", + "trends.energy": " Расход энергии {value} ккал", + "trends.average-sleep": "😴 Сон среднее {value}", + "trends.average-heart-rate": "❤️ Пульс ср. {value}", + "trends.average-oxygen": "🩸 SpO2 ср. {value}", + "trends.average-stress": "🧘 Стресс ср. {value}", + "trends.latest-weight": "⚖️ Вес (последний) {value} кг", + "family.title": "👪 Семейная статистика", + "family.need-two": "Для семейной статистики нужно как минимум два пользователя в TELEGRAM_ALLOWED_USER_IDS.", + "family.no-data": "👪 Нет данных", + "family.steps-cup": "🏆 Кубок шагов: {value}", + "family.sleep-cup": "🏆 Кубок сна: {value}", + "family.goal": "👣 Совместная цель:", + "family.distance": "Вместе за неделю вы прошли {steps} шагов ({distance} км) — это расстояние {route}", + "versus.title": "📊 Versus: {first} vs {second} ({period})", + "versus.choose": "📊 Сравнить активность:", + "versus.steps": "🚶 Шаги:", + "versus.sleep": "😴 Сон:", + "versus.bedtime": "⏰ Засыпание:", + "versus.steps-line": "• {name}: {value} шагов", + "versus.sleep-line": "• {name}: {value}", + "versus.ahead": "({name} впереди! 🏆)", + "versus.slept-longer": "({name} спал дольше 😴)", + "versus.fell-earlier": "({name} лёг раньше ⚡)", + "versus.tie": "(Ничья 🤝)", + "versus.same-time": "(В одно время 🤝)", + "versus.no-data": "📊 Нет данных", + "service.device": "Устройство Mi Band", + "service.last-sync": "Последний синк {value}", + "service.interval": "Интервал {value} мин", + "service.records": "Записей в БД {value}", + "auth.title": "🔐 Авторизация Xiaomi", + "auth.first-run": + "Для первого запуска нужен вход в Xiaomi Fitness. Нажми кнопку ниже, подтверди вход — я дождусь ответа и запущу синхронизацию.", + "auth.prepare": "Готовлю ссылку входа…", + "auth.open": "Открой ссылку, подтверди вход и вернись сюда. Я жду результат.", + "auth.open-login": "🔐 Открыть вход Xiaomi", + "auth.open-qr": "▦ Открыть QR-код", + "auth.retry": "🔄 Повторить", + "auth.confirmed": "Вход подтверждён. Запускаю первую синхронизацию…", + "auth.failed": "Не удалось войти: {error}", + "auth.relogin": "🔐 Войти заново", + "sync.title": "🔄 Синхронизация", + "sync.running": "Забираю данные из Xiaomi Fitness…", + "sync.done": "✅ Синхронизация\n\nГотово, данные обновлены.", + "sync.failed": "⚠️ Синхронизация\n\nНе вышло обновиться.\n\nПричина: {error}", + "export.running": "💾 Экспорт\n\nСобираю ZIP…", + "export.sent": "✅ Экспорт\n\nZIP с CSV-таблицами отправлен выше.", + "export.empty": "💾 Экспорт\n\nВ базе нет данных.", + "export.caption": "💚 MiBand Health CSV Export", + "commands.start": "Открыть меню", + "commands.sync": "Синхронизировать данные", + "commands.status": "Состояние базы", + "commands.versus": "Сравнить активность", + "common.na": "н/д", + "common.today": "Сегодня", + "common.yesterday": "Вчера", + "common.all-time": "Все время", + "common.days": "дней", + "common.day": "день", + "common.hours": "ч", + "common.minutes": "мин", + "common.km": "км", + "common.kcal": "ккал", + "common.no-data": "Нет данных", + "common.health-care": "Берегите здоровье!", + "common.family-route.park": "прогулялись вокруг парка 🌳", + "common.family-route.mytishchi": "дошли от Москвы до Мытищ 🏰", + "common.family-route.podolsk": "дошли от Москвы до Подольска 🏭", + "common.family-route.sergiev": "дошли от Москвы до Сергиева Посада ⛪", + "common.family-route.kolomna": "дошли от Москвы до Коломны! 🚶‍♂️🚶‍♀️", + }, + es: { + "main.no-steps": "🚶 Pasos: N/D", + "main.no-sleep": "😴 Sueño: N/D", + "main.no-metrics": "❤️ 🩸 🧘 N/D", + "menu.sleep": "😴 Sueño", + "menu.weekly": "📊 Semanal", + "menu.history": "📅 Historial", + "menu.settings": "⚙️ Ajustes", + "menu.back": "⬅️ Atrás", + "menu.home": "⬅️ Inicio", + "menu.service": "⚙️ Servicio", + "menu.sync": "🔄 Sincronizar", + "menu.export": "💾 Exportar", + "menu.db-status": "🗄 Estado de la base", + "menu.workouts": "🏋️ Entrenamientos", + "menu.family": "👪 Familia", + "menu.language": "🌐 Idioma", + "menu.calendar": "📅 Calendario", + "language.title": "🌐 Idioma de la interfaz", + "language.selected": "Idioma: {language}", + "language.updated": "Idioma actualizado.", + "sleep.title": "😴 Sueño nocturno", + "sleep.empty": "Aún no hay datos.", + "sleep.night": "😴 Sueño nocturno · {date}", + "sleep.duration": "Duración", + "sleep.quality": "Calidad", + "sleep.bed": "Hora de acostarse", + "sleep.resting-heart-rate": "Pulso en reposo", + "sleep.deep": "Profundo", + "sleep.light": "Ligero", + "sleep.average": "prom. {avg} · rango {min}–{max}", + "sleep.minimum": "prom. {avg}% · mín. {min}%", + "sleep.in-sleep": "Pulso durante el sueño", + "sleep.in-sleep-spo2": "SpO2 durante el sueño", + "workouts.title": "🏋️ Últimos entrenamientos", + "workouts.empty": "Aún no hay entrenamientos.", + "status.title": "🧰 Estado de la base de datos", + "status.rows": "filas", + "status.path": "Ruta", + "status.last-sync": "Última sincronización", + "day.details": "Detalles del {day} ({label})", + "day.steps": "🚶 Pasos:", + "day.no-steps": "🚶 Pasos: no hay datos para este día", + "day.activity": "🧍 Actividad: horas de pie: {hours}h · intensidad: {minutes} min", + "day.energy": "🔥 Energía: total: {total} kcal (activa: {active} kcal)", + "day.energy-simple": "🔥 Energía: {calories} kcal", + "day.sleep": "😴 Sueño: {total} (profundo: {deep} · ligero: {light})", + "day.no-sleep": "😴 Sueño: no hay datos para este día", + "day.heart-rate": "❤️ Pulso: prom. {avg} ({min}–{max}) bpm", + "day.no-heart-rate": "❤️ Pulso: no hay datos para este día", + "day.oxygen": "🩸 Oxígeno: prom. {avg}% ({min}–{max}%) SpO2", + "day.no-oxygen": "🩸 Oxígeno: no hay datos para este día", + "day.stress": "🧘 Estrés: prom. {avg} ({min}–{max})", + "day.no-stress": "🧘 Estrés: no hay datos para este día", + "day.weight": "⚖️ Peso: {weight} kg", + "day.training": "🏋️ Entrenamientos:", + "history.title": "📅 Últimos {days} días", + "history.empty": "No hay datos para este periodo.", + "history.hint": "Pulsa un día para ver los detalles", + "history.steps": "pasos", + "history.no-steps": "pasos N/D", + "history.no-sleep": "sueño N/D", + "weekly.title": "📊 Resumen semanal: {start} — {end}", + "weekly.steps": "🚶 Pasos: {value} al día", + "weekly.sleep": "😴 Sueño: {value}", + "weekly.bedtime": "⏰ Hora de acostarse: {value}", + "weekly.records": "🏆 Récords: • Pasos: {steps} • Sueño: {sleep}", + "trends.title": "📊 Tendencias · {period} · {start} — {end}", + "trends.total-steps": "🚶 Pasos totales {value}", + "trends.average-steps": " Media / día {value}", + "trends.goal": " Objetivo 10k {done} de {total} días", + "trends.best": " 🏆 Mejor día {date} · {steps}", + "trends.activity": "🧍 Actividad media", + "trends.energy": " Energía gastada {value} kcal", + "trends.average-sleep": "😴 Sueño medio {value}", + "trends.average-heart-rate": "❤️ Pulso medio {value}", + "trends.average-oxygen": "🩸 SpO2 media {value}", + "trends.average-stress": "🧘 Estrés medio {value}", + "trends.latest-weight": "⚖️ Último peso {value} kg", + "family.title": "👪 Estadísticas familiares", + "family.need-two": "Se necesitan al menos dos usuarios en TELEGRAM_ALLOWED_USER_IDS.", + "family.no-data": "👪 Sin datos", + "family.steps-cup": "🏆 Copa de pasos: {value}", + "family.sleep-cup": "🏆 Copa de sueño: {value}", + "family.goal": "👣 Objetivo compartido:", + "family.distance": "Esta semana juntos: {steps} pasos ({distance} km) — eso equivale a {route}", + "versus.title": "📊 Versus: {first} vs {second} ({period})", + "versus.choose": "📊 Comparar actividad:", + "versus.steps": "🚶 Pasos:", + "versus.sleep": "😴 Sueño:", + "versus.bedtime": "⏰ Hora de acostarse:", + "versus.steps-line": "• {name}: {value} pasos", + "versus.sleep-line": "• {name}: {value}", + "versus.ahead": "(¡{name} va por delante! 🏆)", + "versus.slept-longer": "({name} durmió más 😴)", + "versus.fell-earlier": "({name} se acostó antes ⚡)", + "versus.tie": "(Empate 🤝)", + "versus.same-time": "(A la misma hora 🤝)", + "versus.no-data": "📊 Sin datos", + "service.device": "Dispositivo Mi Band", + "service.last-sync": "Última sincron. {value}", + "service.interval": "Intervalo {value} min", + "service.records": "Filas en la base {value}", + "auth.title": "🔐 Autenticación de Xiaomi", + "auth.first-run": + "Para empezar, inicia sesión en Xiaomi Fitness. Pulsa el botón, confirma el acceso y comenzaré la sincronización.", + "auth.prepare": "Preparando el enlace de acceso…", + "auth.open": "Abre el enlace, confirma el acceso y vuelve aquí. Estoy esperando el resultado.", + "auth.open-login": "🔐 Abrir acceso de Xiaomi", + "auth.open-qr": "▦ Abrir código QR", + "auth.retry": "🔄 Reintentar", + "auth.confirmed": "Acceso confirmado. Inicio la primera sincronización…", + "auth.failed": "No se pudo iniciar sesión: {error}", + "auth.relogin": "🔐 Volver a iniciar sesión", + "sync.title": "🔄 Sincronización", + "sync.running": "Obteniendo datos de Xiaomi Fitness…", + "sync.done": "✅ Sincronización\n\nListo, los datos están actualizados.", + "sync.failed": "⚠️ Sincronización\n\nNo se pudo actualizar.\n\nMotivo: {error}", + "export.running": "💾 Exportación\n\nCreando ZIP…", + "export.sent": "✅ Exportación\n\nEl ZIP con las tablas CSV se envió arriba.", + "export.empty": "💾 Exportación\n\nNo hay datos en la base.", + "export.caption": "💚 Exportación CSV de MiBand", + "commands.start": "Abrir menú", + "commands.sync": "Sincronizar datos", + "commands.status": "Estado de la base", + "commands.versus": "Comparar actividad", + "common.na": "N/D", + "common.today": "Hoy", + "common.yesterday": "Ayer", + "common.all-time": "Todo el tiempo", + "common.days": "días", + "common.day": "día", + "common.hours": "h", + "common.minutes": "min", + "common.km": "km", + "common.kcal": "kcal", + "common.no-data": "Sin datos", + "common.health-care": "¡Cuida tu salud!", + "common.family-route.park": "un paseo por el parque 🌳", + "common.family-route.mytishchi": "un paseo de Moscú a Mytishchi 🏰", + "common.family-route.podolsk": "un paseo de Moscú a Podolsk 🏭", + "common.family-route.sergiev": "un paseo de Moscú a Sergiev Posad ⛪", + "common.family-route.kolomna": "un paseo de Moscú a Kolomna 🚶‍♂️🚶‍♀️", + }, +} as const; + +export type MessageKey = keyof (typeof catalog)["en"]; + +export function isLocale(value: unknown): value is Locale { + return typeof value === "string" && (LOCALES as readonly string[]).includes(value); +} + +export function t(locale: Locale, key: MessageKey, params: Record = {}): string { + const template = catalog[locale][key] ?? catalog.en[key]; + return template.replace(/\{(\w+)\}/g, (_, name: string) => (name in params ? String(params[name]) : `{${name}}`)); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..2854ed8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,156 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { z } from "zod"; + +const optionalText = z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), + z.string().min(1).optional(), +); + +const booleanText = z.preprocess((value) => { + if (typeof value !== "string") return value; + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "y", "on"].includes(normalized)) return true; + if (["false", "0", "no", "n", "off"].includes(normalized)) return false; + return value; +}, z.boolean()); + +export class ConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigurationError"; + } +} + +function parseUserIds(raw: string | undefined): number[] { + if (!raw?.trim()) return []; + const values = raw + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const ids = values.map(Number); + if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) { + throw new ConfigurationError("TELEGRAM_ALLOWED_USER_IDS must contain positive integer IDs separated by commas"); + } + return ids; +} + +function readLocalSecrets(): Record { + const path = "secrets.env"; + if (!existsSync(path)) return {}; + try { + return Object.fromEntries( + readFileSync(path, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#") && line.includes("=")) + .map((line) => { + const index = line.indexOf("="); + return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; + }), + ); + } catch { + return {}; + } +} + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + APP_NAME: z.string().min(1).default("miband-bot"), + BOT_MODE: z.enum(["polling", "webhook", "http-only"]).default("polling"), + TELEGRAM_BOT_TOKEN: optionalText, + TELEGRAM_API_ROOT: z.string().url().default("https://api.telegram.org"), + TELEGRAM_WEBHOOK_SECRET: optionalText, + PUBLIC_WEBHOOK_URL: optionalText, + TELEGRAM_ALLOWED_USER_IDS: z.string().optional(), + TELEGRAM_ALLOWED_USER_ID: z.string().optional(), + ALLOWED_USERS: z.string().optional(), + DATA_DIR: z.string().min(1).default("./data"), + DB_PATH: z.string().min(1).optional(), + STATUS_PATH: z.string().min(1).optional(), + BOT_STATE_DB_PATH: z.string().min(1).optional(), + SYNC_INTERVAL: z.coerce.number().int().min(0).default(900), + QUERY_DURATION: z.coerce.number().int().min(1).default(2), + ENABLE_FDS_SLEEP_DETAILS: booleanText.default(true), + AUTO_MENU_REFRESH_INTERVAL: z.coerce.number().positive().default(30), + PORT: z.coerce.number().int().min(1).max(65535).default(8080), + BIND_HOST: z.string().min(1).default("127.0.0.1"), + TZ: z.string().default("Europe/Moscow"), +}); + +export type AppConfig = z.infer & { + allowedUserIds: number[]; + dataDir: string; + dbPath: string; + statusPath: string; + botStateDbPath: string; +}; + +export function loadConfig(source: Record = process.env): AppConfig { + const parsed = envSchema.safeParse({ ...readLocalSecrets(), ...source }); + if (!parsed.success) { + const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "env"}: ${issue.message}`).join("; "); + throw new ConfigurationError(`Invalid environment configuration — ${details}`); + } + const value = parsed.data; + const dataDir = resolve(value.DATA_DIR); + const rawAllowed = value.TELEGRAM_ALLOWED_USER_IDS ?? value.TELEGRAM_ALLOWED_USER_ID ?? value.ALLOWED_USERS; + let allowedUserIds = parseUserIds(rawAllowed); + if (allowedUserIds.length === 0) { + const path = join(dataDir, "allowed_user.id"); + if (existsSync(path)) { + try { + allowedUserIds = parseUserIds(readFileSync(path, "utf8")); + } catch { + allowedUserIds = []; + } + } + } + if (value.BOT_MODE !== "http-only" && !value.TELEGRAM_BOT_TOKEN) { + throw new ConfigurationError("TELEGRAM_BOT_TOKEN is required unless BOT_MODE is http-only"); + } + if (value.BOT_MODE === "webhook" && (!value.TELEGRAM_WEBHOOK_SECRET || !value.PUBLIC_WEBHOOK_URL)) { + throw new ConfigurationError("TELEGRAM_WEBHOOK_SECRET and PUBLIC_WEBHOOK_URL are required in webhook mode"); + } + return { + ...value, + allowedUserIds, + dataDir, + dbPath: resolve(value.DB_PATH ?? join(dataDir, "miband.db")), + statusPath: resolve(value.STATUS_PATH ?? join(dataDir, "status.json")), + botStateDbPath: resolve(value.BOT_STATE_DB_PATH ?? join(dataDir, "fitness_bot_state.db")), + }; +} + +export function userId(config: AppConfig, candidate?: number): number { + const resolved = candidate ?? config.allowedUserIds[0]; + if (resolved === undefined) throw new ConfigurationError("No Telegram user is configured"); + return resolved; +} + +export function tokenPath(config: AppConfig, candidate?: number): string { + const uid = userId(config, candidate); + const preferred = join(config.dataDir, `token_${uid}.json`); + const legacy = join(config.dataDir, "token.json"); + return existsSync(preferred) || !existsSync(legacy) ? preferred : legacy; +} + +export function userDbPath(config: AppConfig, candidate?: number): string { + const uid = userId(config, candidate); + const preferred = join(config.dataDir, `miband_${uid}.db`); + return existsSync(preferred) ? preferred : config.dbPath; +} + +export function canonicalUserDbPath(config: AppConfig, candidate?: number): string { + return join(config.dataDir, `miband_${userId(config, candidate)}.db`); +} + +export function userStatusPath(config: AppConfig, candidate?: number): string { + const uid = userId(config, candidate); + const preferred = join(config.dataDir, `status_${uid}.json`); + return existsSync(preferred) ? preferred : config.statusPath; +} + +export function canonicalUserStatusPath(config: AppConfig, candidate?: number): string { + return join(config.dataDir, `status_${userId(config, candidate)}.json`); +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..0a48acf --- /dev/null +++ b/src/http.ts @@ -0,0 +1,33 @@ +import type { Bot } from "grammy"; +import { webhookCallback } from "grammy"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import type { AppConfig } from "./config.js"; +import { log } from "./logger.js"; +import type { RuntimeStatus } from "./runtime/status.js"; +import type { OpenDatabase } from "./storage/kv.js"; + +export function createHttpApp(config: AppConfig, bot: Bot | null, database: OpenDatabase, status: RuntimeStatus): Hono { + const app = new Hono(); + if (config.NODE_ENV !== "production") app.use("*", logger()); + app.get("/", (context) => context.json({ name: config.APP_NAME, status: "ok" })); + app.get("/healthz", (context) => context.text("ok\n")); + app.get("/readyz", (context) => { + try { + database.sqlite.query("SELECT 1").get(); + } catch (error) { + log("error", "Readiness check failed", { error }); + return context.text("error\n", 500); + } + if (config.BOT_MODE === "polling" && !status.botReady) return context.text("not ready\n", 503); + return context.text("ready\n"); + }); + if (config.BOT_MODE === "webhook" && bot && config.TELEGRAM_WEBHOOK_SECRET) { + app.post("/telegram/webhook", webhookCallback(bot, "hono", { secretToken: config.TELEGRAM_WEBHOOK_SECRET })); + } + app.onError((error, context) => { + log("error", "Unhandled HTTP error", { error, path: context.req.path }); + return context.json({ error: "Internal Server Error" }, 500); + }); + return app; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f97d531 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,82 @@ +import { configureBot, createBot, startBotTasks } from "./bot/app.js"; +import { loadConfig } from "./config.js"; +import { createHttpApp } from "./http.js"; +import { log } from "./logger.js"; +import { stopServerGracefully } from "./runtime/shutdown.js"; +import { createRuntimeStatus } from "./runtime/status.js"; +import { RuntimeSupervisor } from "./runtime/supervisor.js"; +import { startIntervalWorker } from "./runtime/worker.js"; +import { initHealthDb, initStateDb } from "./storage/health.js"; +import { migrateDatabase, openDatabase } from "./storage/kv.js"; +import { runSync } from "./sync.js"; + +async function main(): Promise { + const config = loadConfig(); + initStateDb(config.botStateDbPath); + for (const uid of config.allowedUserIds) initHealthDb(`${config.dataDir}/miband_${uid}.db`); + const database = openDatabase(config.botStateDbPath); + migrateDatabase(database); + const bot = config.BOT_MODE === "http-only" ? null : createBot(config); + const status = createRuntimeStatus(config.BOT_MODE); + const app = createHttpApp(config, bot, database, status); + const server = Bun.serve({ fetch: app.fetch, hostname: config.BIND_HOST, port: config.PORT }); + const supervisor = new RuntimeSupervisor(); + + if (bot) supervisor.register(startBotTasks(bot, config)); + if (config.SYNC_INTERVAL > 0) { + supervisor.register( + startIntervalWorker("xiaomi-sync", config.SYNC_INTERVAL * 1000, async () => { + for (const uid of config.allowedUserIds) await runSync(uid, config); + }), + ); + } + + let stopping = false; + const shutdown = async (signal: string): Promise => { + if (stopping) return; + stopping = true; + log("info", "Stopping service", { signal }); + await supervisor.stop(); + if (bot?.isRunning()) await bot.stop(); + await stopServerGracefully(server); + database.close(); + log("info", "Service stopped"); + }; + process.once("SIGINT", () => void shutdown("SIGINT")); + process.once("SIGTERM", () => void shutdown("SIGTERM")); + + if (bot) { + try { + await configureBot(bot); + } catch (error) { + log("warn", "Failed to configure Telegram commands", { error }); + } + if (config.BOT_MODE === "polling") { + void bot + .start({ + onStart: (info) => { + status.botReady = true; + status.botError = null; + log("info", "Telegram polling started", { username: info.username }); + }, + }) + .catch(async (error) => { + status.botReady = false; + status.botError = error instanceof Error ? error.message : String(error); + log("error", "Telegram polling stopped unexpectedly", { error }); + await shutdown("TELEGRAM_POLLING_FAILED"); + process.exitCode = 1; + }); + } else if (config.BOT_MODE === "webhook" && config.PUBLIC_WEBHOOK_URL && config.TELEGRAM_WEBHOOK_SECRET) { + await bot.api.setWebhook(`${config.PUBLIC_WEBHOOK_URL}/telegram/webhook`, { + secret_token: config.TELEGRAM_WEBHOOK_SECRET, + }); + } + } + log("info", "HTTP server listening", { address: `http://${config.BIND_HOST}:${config.PORT}`, mode: config.BOT_MODE }); +} + +void main().catch((error) => { + log("error", "Service startup failed", { error }); + process.exitCode = 1; +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..ae84a5f --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,29 @@ +type LogLevel = "debug" | "info" | "warn" | "error"; + +const sensitiveKey = /token|secret|password|api[_-]?key|authorization|cookie|credential|ssecurity|passtoken/i; + +export function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack }; + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, sensitiveKey.test(key) ? "[REDACTED]" : redact(nested)]), + ); + } + return value; +} + +export function log(level: LogLevel, message: string, details?: unknown): void { + const safe = details === undefined ? undefined : redact(details); + const timestamp = new Date().toISOString(); + if (process.env.NODE_ENV === "production") { + const payload: Record = { timestamp, level, message }; + if (safe !== undefined) payload.details = safe; + console.log(JSON.stringify(payload)); + return; + } + const suffix = safe === undefined ? "" : ` ${JSON.stringify(safe)}`; + const output = `[${timestamp}] [${level.toUpperCase()}] ${message}${suffix}`; + if (level === "error") console.error(output); + else console.log(output); +} diff --git a/src/runtime/shutdown.ts b/src/runtime/shutdown.ts new file mode 100644 index 0000000..8146f5d --- /dev/null +++ b/src/runtime/shutdown.ts @@ -0,0 +1,14 @@ +export async function stopServerGracefully(server: Bun.Server, timeoutMs = 10_000): Promise { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Shutdown timeout must be a positive integer"); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + server.stop(), + new Promise((resolve) => { + timeout = setTimeout(() => void server.stop(true).then(resolve, resolve), timeoutMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} diff --git a/src/runtime/status.ts b/src/runtime/status.ts new file mode 100644 index 0000000..15fb706 --- /dev/null +++ b/src/runtime/status.ts @@ -0,0 +1,7 @@ +import type { AppConfig } from "../config.js"; + +export type RuntimeStatus = { botReady: boolean; botError: string | null }; + +export function createRuntimeStatus(mode: AppConfig["BOT_MODE"]): RuntimeStatus { + return { botReady: mode !== "polling", botError: null }; +} diff --git a/src/runtime/supervisor.ts b/src/runtime/supervisor.ts new file mode 100644 index 0000000..f2eac9d --- /dev/null +++ b/src/runtime/supervisor.ts @@ -0,0 +1,22 @@ +export type Stoppable = { stop: () => void | Promise }; + +export class RuntimeSupervisor { + private readonly resources = new Set(); + private stopPromise: Promise | undefined; + + register(resource: Stoppable): () => void { + if (this.stopPromise) throw new Error("Cannot register a resource after shutdown has started"); + this.resources.add(resource); + return () => this.resources.delete(resource); + } + + async stop(): Promise { + if (this.stopPromise) return this.stopPromise; + const resources = [...this.resources].reverse(); + this.resources.clear(); + this.stopPromise = (async () => { + for (const resource of resources) await resource.stop(); + })(); + return this.stopPromise; + } +} diff --git a/src/runtime/worker.ts b/src/runtime/worker.ts new file mode 100644 index 0000000..6cb868f --- /dev/null +++ b/src/runtime/worker.ts @@ -0,0 +1,33 @@ +import { log } from "../logger.js"; + +export type WorkerHandle = { stop: () => Promise }; + +export function startIntervalWorker(name: string, intervalMs: number, task: () => void | Promise): WorkerHandle { + if (!Number.isInteger(intervalMs) || intervalMs <= 0) throw new Error("Worker interval must be a positive integer"); + let stopped = false; + let timer: ReturnType | undefined; + let currentRun: Promise = Promise.resolve(); + let stopPromise: Promise | undefined; + const startCycle = (): void => { + currentRun = (async () => { + try { + await task(); + log("debug", "Worker cycle completed", { worker: name }); + } catch (error) { + log("error", "Worker cycle failed", { worker: name, error }); + } finally { + if (!stopped) timer = setTimeout(startCycle, intervalMs); + } + })(); + }; + startCycle(); + return { + stop: () => { + if (stopPromise) return stopPromise; + stopped = true; + if (timer) clearTimeout(timer); + stopPromise = currentRun.then(() => log("info", "Worker stopped", { worker: name })); + return stopPromise; + }, + }; +} diff --git a/src/storage/health.ts b/src/storage/health.ts new file mode 100644 index 0000000..674a5a0 --- /dev/null +++ b/src/storage/health.ts @@ -0,0 +1,202 @@ +import type { Database, SQLQueryBindings } from "bun:sqlite"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { DEFAULT_LOCALE, isLocale, type Locale } from "../bot/i18n.js"; +import { type AppConfig, userDbPath, userStatusPath } from "../config.js"; +import { type OpenDatabase, openDatabase } from "./kv.js"; + +export const EXPORT_TABLES = [ + "steps_daily", + "sleep_daily", + "sleep_stages", + "heart_rate", + "blood_oxygen", + "stress", + "calories_daily", + "weight", + "workouts", +] as const; + +export type Row = Record; + +export function initHealthDb(path: string): void { + const database = openDatabase(path); + database.sqlite.exec(` + CREATE TABLE IF NOT EXISTS steps_daily ( + date TEXT PRIMARY KEY, total_steps INTEGER, calories REAL, distance_m REAL, last_sync INTEGER + ); + CREATE TABLE IF NOT EXISTS steps_detail ( + timestamp INTEGER PRIMARY KEY, steps INTEGER, calories REAL, distance_m REAL, activity_type TEXT + ); + CREATE TABLE IF NOT EXISTS sleep_daily ( + date TEXT PRIMARY KEY, light_sleep_min INTEGER, deep_sleep_min INTEGER, start_time INTEGER, end_time INTEGER, + rem_sleep_min INTEGER DEFAULT 0, awake_min INTEGER DEFAULT 0, total_duration_min INTEGER DEFAULT 0, + sleep_score INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS sleep_stages ( + start_time INTEGER PRIMARY KEY, stop_time INTEGER, stage TEXT, duration_min INTEGER + ); + CREATE TABLE IF NOT EXISTS heart_rate (timestamp INTEGER PRIMARY KEY, value INTEGER); + CREATE TABLE IF NOT EXISTS stress (timestamp INTEGER PRIMARY KEY, value INTEGER); + CREATE TABLE IF NOT EXISTS blood_oxygen (timestamp INTEGER PRIMARY KEY, spo2 REAL, type TEXT); + 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 + ); + CREATE TABLE IF NOT EXISTS weight (timestamp INTEGER PRIMARY KEY, weight_kg REAL, bmi REAL, body_fat_pct REAL); + 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 + ); + `); + const columns = new Set( + database.sqlite + .query<{ name: string }, []>("PRAGMA table_info(sleep_daily)") + .all() + .map((row) => row.name), + ); + for (const [name, definition] of [ + ["rem_sleep_min", "INTEGER DEFAULT 0"], + ["awake_min", "INTEGER DEFAULT 0"], + ["total_duration_min", "INTEGER DEFAULT 0"], + ["sleep_score", "INTEGER DEFAULT 0"], + ] as const) { + if (!columns.has(name)) database.sqlite.run(`ALTER TABLE sleep_daily ADD COLUMN ${name} ${definition}`); + } + database.close(); +} + +export function initStateDb(path: string): void { + const database = openDatabase(path); + database.sqlite.exec(` + CREATE TABLE IF NOT EXISTS user_menu ( + user_id INTEGER PRIMARY KEY, menu_message_id INTEGER NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_locale ( + user_id INTEGER PRIMARY KEY, locale TEXT NOT NULL, updated_at TEXT NOT NULL + ) + `); + database.close(); +} + +export function getUserLocale(config: AppConfig, uid: number): Locale { + try { + const database = openDatabase(config.botStateDbPath); + const row = database.sqlite + .query<{ locale: string }, [number]>("SELECT locale FROM user_locale WHERE user_id = ?") + .get(uid); + database.close(); + return isLocale(row?.locale) ? row.locale : DEFAULT_LOCALE; + } catch { + return DEFAULT_LOCALE; + } +} + +export function setUserLocale(config: AppConfig, uid: number, locale: Locale): void { + const database = openDatabase(config.botStateDbPath); + database.sqlite + .query( + "INSERT INTO user_locale (user_id, locale, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET locale = excluded.locale, updated_at = excluded.updated_at", + ) + .run(uid, locale, new Date().toISOString()); + database.close(); +} + +export function healthDbExists(config: AppConfig, uid: number): boolean { + return existsSync(userDbPath(config, uid)); +} + +export function openHealthDb(config: AppConfig, uid: number): OpenDatabase { + const path = userDbPath(config, uid); + mkdirSync(dirname(path), { recursive: true }); + return openDatabase(path); +} + +export function fetchOne(config: AppConfig, uid: number, query: string, params: SQLQueryBindings[] = []): Row | null { + if (!healthDbExists(config, uid)) return null; + const database = openHealthDb(config, uid); + try { + return (database.sqlite.query(query).get(...params) as Row | null) ?? null; + } finally { + database.close(); + } +} + +export function fetchAll(config: AppConfig, uid: number, query: string, params: SQLQueryBindings[] = []): Row[] { + if (!healthDbExists(config, uid)) return []; + const database = openHealthDb(config, uid); + try { + return database.sqlite.query(query).all(...params) as Row[]; + } finally { + database.close(); + } +} + +export function withHealthDb(config: AppConfig, uid: number, action: (database: Database) => T): T { + const database = openHealthDb(config, uid); + try { + return action(database.sqlite); + } finally { + database.close(); + } +} + +export function getUserMenuMessageId(config: AppConfig, uid: number): number | null { + try { + const database = openDatabase(config.botStateDbPath); + const row = database.sqlite + .query<{ menu_message_id: number }, [number]>("SELECT menu_message_id FROM user_menu WHERE user_id = ?") + .get(uid); + database.close(); + return row?.menu_message_id ?? null; + } catch { + return null; + } +} + +export function setUserMenuMessageId(config: AppConfig, uid: number, messageId: number): void { + const database = openDatabase(config.botStateDbPath); + database.sqlite + .query("INSERT OR REPLACE INTO user_menu (user_id, menu_message_id, updated_at) VALUES (?, ?, ?)") + .run(uid, messageId, new Date().toISOString()); + database.close(); +} + +export function readStatus(config: AppConfig, uid: number): Row { + try { + return JSON.parse(readFileSync(userStatusPath(config, uid), "utf8")) as Row; + } catch { + return {}; + } +} + +function csvCell(value: unknown): string { + const text = value === null || value === undefined ? "" : String(value); + return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export async function zipExport(config: AppConfig, uid: number): Promise { + if (!healthDbExists(config, uid)) return null; + const files: Record = {}; + withHealthDb(config, uid, (sqlite) => { + for (const table of EXPORT_TABLES) { + const exists = sqlite + .query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table); + if (!exists) continue; + const query = sqlite.query(`SELECT * FROM ${table} ORDER BY 1`); + const rows = query.all() as Row[]; + if (!rows.length) continue; + const headers = Object.keys(rows[0] ?? {}); + const lines = [headers.map(csvCell).join(",")]; + for (const row of rows) lines.push(headers.map((header) => csvCell(row[header])).join(",")); + files[`${table}.csv`] = `${lines.join("\n")}\n`; + } + }); + if (Object.keys(files).length === 0) return null; + return await new Bun.Archive(files).bytes(); +} + +export function ensureUserDataDir(config: AppConfig): void { + mkdirSync(join(config.dataDir), { recursive: true }); +} diff --git a/src/storage/kv.ts b/src/storage/kv.ts new file mode 100644 index 0000000..b6cb583 --- /dev/null +++ b/src/storage/kv.ts @@ -0,0 +1,38 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +export type OpenDatabase = { sqlite: Database; path: string; close: () => void }; + +export function openDatabase(url: string): OpenDatabase { + const path = url === ":memory:" ? url : resolve(url); + if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); + const sqlite = new Database(path, { create: true, strict: true }); + sqlite.run("PRAGMA busy_timeout = 30000"); + if (path !== ":memory:") sqlite.run("PRAGMA journal_mode = WAL"); + sqlite.run("PRAGMA foreign_keys = ON"); + return { sqlite, path, close: () => sqlite.close() }; +} + +export function migrateDatabase(database: OpenDatabase): void { + database.sqlite.exec(` + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); +} + +export function getState(database: OpenDatabase, key: string): string | null { + const row = database.sqlite.query<{ value: string }, [string]>("SELECT value FROM app_state WHERE key = ?").get(key); + return row?.value ?? null; +} + +export function setState(database: OpenDatabase, key: string, value: string): void { + database.sqlite + .query( + "INSERT INTO app_state (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ) + .run(key, value, new Date().toISOString()); +} diff --git a/src/storage/secure-files.ts b/src/storage/secure-files.ts new file mode 100644 index 0000000..f80a727 --- /dev/null +++ b/src/storage/secure-files.ts @@ -0,0 +1,128 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; + +const SECRET_MODE = 0o600; + +export function writeTextAtomic(path: string, text: string, mode?: number): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = join(dirname(path), `.${path.split("/").pop() ?? "file"}.${process.pid}.tmp`); + try { + writeFileSync(temporary, text, { encoding: "utf8", mode: mode ?? 0o644 }); + if (mode !== undefined) chmodSync(temporary, mode); + renameSync(temporary, path); + if (mode !== undefined) chmodSync(path, mode); + } finally { + try { + unlinkSync(temporary); + } catch { + // The rename already removed it. + } + } +} + +export function writeJsonAtomic(path: string, data: unknown, mode?: number): void { + writeTextAtomic(path, `${JSON.stringify(data, null, 2)}\n`, mode); +} + +export function writeSecretJson(path: string, data: unknown): void { + writeJsonAtomic(path, data, SECRET_MODE); +} + +export type AuthToken = { + user_id: string; + c_user_id: string; + service_token: string; + ssecurity: string; + pass_token: string; + device_id: string; + target_relative_uid?: string | number; +}; + +export function readToken(path: string): AuthToken { + const payload = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + user_id: String(payload.user_id ?? ""), + c_user_id: String(payload.c_user_id ?? ""), + service_token: String(payload.service_token ?? ""), + ssecurity: String(payload.ssecurity ?? ""), + pass_token: String(payload.pass_token ?? ""), + device_id: String(payload.device_id ?? ""), + ...(payload.target_relative_uid === undefined ? {} : { target_relative_uid: payload.target_relative_uid }), + }; +} + +export function saveAuthToken(path: string, token: AuthToken): void { + let current: Partial = {}; + try { + current = JSON.parse(readFileSync(path, "utf8")) as Partial; + } catch { + // New token. + } + const payload = { ...token }; + if (current.target_relative_uid !== undefined && payload.target_relative_uid === undefined) { + payload.target_relative_uid = current.target_relative_uid; + } + writeSecretJson(path, payload); +} + +export class LockUnavailable extends Error { + constructor(message: string) { + super(message); + this.name = "LockUnavailable"; + } +} + +export async function withExclusiveFileLock(path: string, action: () => Promise): Promise { + const lockDir = `${path}.d`; + mkdirSync(dirname(path), { recursive: true }); + try { + mkdirSync(lockDir); + } catch { + let stale = false; + try { + const lockText = readFileSync(path, "utf8"); + const pid = Number(lockText.match(/pid=(\d+)/)?.[1] ?? 0); + if (pid > 0) { + try { + process.kill(pid, 0); + } catch { + stale = true; + } + } + } catch { + stale = false; + } + if (!stale) throw new LockUnavailable(`Lock is already held: ${path}`); + try { + rmdirSync(lockDir); + if (existsSync(path)) unlinkSync(path); + mkdirSync(lockDir); + } catch { + throw new LockUnavailable(`Lock is already held: ${path}`); + } + } + try { + writeFileSync(path, `pid=${process.pid} time=${Math.floor(Date.now() / 1000)}\n`, "utf8"); + return await action(); + } finally { + try { + unlinkSync(path); + } catch { + // Best effort cleanup. + } + try { + rmdirSync(lockDir); + } catch { + // Best effort cleanup. + } + } +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..0ad76ab --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,490 @@ +import type { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; +import { type AppConfig, canonicalUserDbPath, canonicalUserStatusPath, tokenPath, userId } from "./config.js"; +import { log } from "./logger.js"; +import { initHealthDb, withHealthDb } from "./storage/health.js"; +import { + type AuthToken, + readToken, + saveAuthToken, + withExclusiveFileLock, + writeJsonAtomic, +} from "./storage/secure-files.js"; +import { + type AggregatedDataItem, + formatXiaomiError, + MiHealthClient, + type SleepData, + type StepData, + TokenExpiredError, +} from "./xiaomi/client.js"; +import { downloadAndDecryptSleepDetails, parseAllDaySleepBytes } from "./xiaomi/fds.js"; + +export type SyncResult = { success: boolean; userId: number; counters: Record; error?: string }; + +function increment(counters: Record, key: string, amount = 1): void { + counters[key] = (counters[key] ?? 0) + amount; +} + +function timestamp(): number { + return Math.floor(Date.now() / 1000); +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function formatEpoch(value: number): string | null { + return value ? new Date(value * 1000).toISOString().replace("T", " ").slice(0, 19) : null; +} + +function targetRelativeUid(token: AuthToken): number | null { + const value = token.target_relative_uid ?? token.user_id; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function bootstrapToken(): AuthToken { + return { + user_id: process.env.USER_ID ?? "", + c_user_id: process.env.C_USER_ID ?? "", + service_token: process.env.SERVICE_TOKEN ?? "", + ssecurity: process.env.SSECURITY ?? "", + pass_token: process.env.PASS_TOKEN ?? "", + device_id: process.env.DEVICE_ID ?? `an_${crypto.randomUUID().replaceAll("-", "")}`, + ...(process.env.TARGET_RELATIVE_UID ? { target_relative_uid: process.env.TARGET_RELATIVE_UID } : {}), + }; +} + +export async function runSync(candidateUserId: number | undefined, config: AppConfig): Promise { + const uid = userId(config, candidateUserId); + const lockPath = `${config.dataDir}/sync_${uid}.lock`; + try { + return await withExclusiveFileLock(lockPath, () => runSyncLocked(uid, config)); + } catch (error) { + const message = + error instanceof Error && error.name === "LockUnavailable" + ? "Sync is already running for this user" + : formatXiaomiError(error); + log("warn", message, { userId: uid }); + return { success: false, userId: uid, counters: {}, error: message }; + } +} + +async function runSyncLocked(uid: number, config: AppConfig): Promise { + const path = tokenPath(config, uid); + if (!existsSync(path) && uid === config.allowedUserIds[0] && process.env.SSECURITY) { + saveAuthToken(path, bootstrapToken()); + } + if (!existsSync(path)) + return { success: false, userId: uid, counters: {}, error: `Token file not found at: ${path}` }; + const token = (() => { + try { + return readToken(path); + } catch { + return null; + } + })(); + if (!token) return { success: false, userId: uid, counters: {}, error: `Token file cannot be read: ${path}` }; + const relativeUid = targetRelativeUid(token); + if (!relativeUid) + return { success: false, userId: uid, counters: {}, error: `No target_relative_uid or user_id found in ${path}` }; + const dbPath = canonicalUserDbPath(config, uid); + const statusPath = canonicalUserStatusPath(config, uid); + initHealthDb(dbPath); + const counters: Record = {}; + let latestSteps: StepData | undefined; + let latestSleep: SleepData | undefined; + let latestHeartRate: { timestamp: number; value: number } | undefined; + const client = MiHealthClient.fromToken(path); + try { + const steps = await client.getSteps(relativeUid, config.QUERY_DURATION); + withHealthDb(config, uid, (database) => { + for (const item of steps) { + const date = new Date(item.time * 1000).toISOString().slice(0, 10); + database + .query( + "INSERT OR REPLACE INTO steps_daily (date,total_steps,calories,distance_m,last_sync) VALUES (?,?,?,?,?)", + ) + .run(date, item.steps, item.calories, item.distance, timestamp()); + increment(counters, "steps_daily"); + if (!latestSteps || date >= new Date(latestSteps.time * 1000).toISOString().slice(0, 10)) latestSteps = item; + } + }); + + const sleep = await client.getSleep(relativeUid, config.QUERY_DURATION); + for (const item of sleep) { + const date = new Date(item.time * 1000).toISOString().slice(0, 10); + withHealthDb(config, uid, (database) => { + const segments = item.segment_details; + const start = segments.length ? Math.min(...segments.map((segment) => segment.bedtime)) : 0; + const end = segments.length ? Math.max(...segments.map((segment) => segment.wake_up_time)) : 0; + for (const segment of segments) { + database + .query("INSERT OR REPLACE INTO sleep_stages (start_time,stop_time,stage,duration_min) VALUES (?,?,?,?)") + .run(segment.bedtime, segment.wake_up_time, "sleep_segment", segment.duration); + increment(counters, "sleep_stages"); + } + database + .query( + "INSERT OR REPLACE INTO sleep_daily (date,light_sleep_min,deep_sleep_min,rem_sleep_min,awake_min,total_duration_min,sleep_score,start_time,end_time) VALUES (?,?,?,?,?,?,?,?,?)", + ) + .run( + date, + item.sleep_light_duration, + item.sleep_deep_duration, + item.sleep_rem_duration, + item.sleep_awake_duration, + item.total_duration, + item.sleep_score, + start, + end, + ); + increment(counters, "sleep_daily"); + }); + if (!latestSleep || item.time > latestSleep.time) latestSleep = item; + } + + const heartRate = await client.getHeartRate(relativeUid, config.QUERY_DURATION); + withHealthDb(config, uid, (database) => { + for (const item of heartRate) { + insertHeartRate(database, item.avg_hr, item.time, counters); + if (item.latest_hr) { + insertHeartRate(database, item.latest_hr.bpm, item.latest_hr.time, counters); + if (!latestHeartRate || item.latest_hr.time > latestHeartRate.timestamp) + latestHeartRate = { timestamp: item.latest_hr.time, value: item.latest_hr.bpm }; + } + } + }); + + try { + const spo2 = await client.getSpo2History(relativeUid, config.QUERY_DURATION); + withHealthDb(config, uid, (database) => { + for (const item of spo2) { + insertBloodOxygen(database, item.avg_spo2, item.time, "daily_avg", counters); + if (item.latest_spo2) + insertBloodOxygen(database, item.latest_spo2.spo2, item.latest_spo2.time, "latest", counters); + } + }); + } catch (error) { + log("warn", "Failed to fetch blood oxygen", { error }); + } + + await syncPointMetrics(client, relativeUid, config, uid, counters); + await syncCalories(client, relativeUid, config, uid, counters); + await syncWeight(client, relativeUid, config, uid, counters); + await syncWorkouts(client, relativeUid, config, uid, counters); + if (config.ENABLE_FDS_SLEEP_DETAILS) await syncFds(client, relativeUid, config, uid, sleep, counters); + saveAuthToken(path, client.auth.token); + writeStatus(statusPath, latestSteps, latestHeartRate, latestSleep); + return { success: true, userId: uid, counters }; + } catch (error) { + const message = + error instanceof TokenExpiredError + ? "Token has expired and auto-refresh failed. Action required: re-login." + : `API request failed: ${formatXiaomiError(error)}`; + log("error", message, { userId: uid }); + return { success: false, userId: uid, counters, error: message }; + } +} + +function insertHeartRate(database: Database, value: number, time: number, counters: Record): void { + const result = database.query("INSERT OR IGNORE INTO heart_rate (timestamp,value) VALUES (?,?)").run(time, value); + if (result.changes > 0) increment(counters, "heart_rate"); +} + +function insertBloodOxygen( + database: Database, + value: number, + time: number, + type: string, + counters: Record, +): void { + const result = database + .query("INSERT OR IGNORE INTO blood_oxygen (timestamp,spo2,type) VALUES (?,?,?)") + .run(time, value, type); + if (result.changes > 0) increment(counters, "blood_oxygen"); +} + +async function syncPointMetrics( + client: MiHealthClient, + relativeUid: number, + config: AppConfig, + uid: number, + counters: Record, +): Promise { + const start = Math.floor(Date.now() / 1000) - config.QUERY_DURATION * 86_400; + const end = Math.floor(Date.now() / 1000); + for (const [key, table, field] of [ + ["heart_rate", "heart_rate", "bpm"], + ["spo2", "blood_oxygen", "spo2"], + ["stress", "stress", "stress"], + ] as const) { + try { + const items = await client.getFitnessData(relativeUid, key, start, end, 1440 * config.QUERY_DURATION); + withHealthDb(config, uid, (database) => { + for (const item of items) { + const value = parseMetricValue(item, field); + if (!value) continue; + const query = + table === "blood_oxygen" + ? "INSERT OR IGNORE INTO blood_oxygen (timestamp,spo2,type) VALUES (?,?,?)" + : `INSERT OR IGNORE INTO ${table} (timestamp,value) VALUES (?,?)`; + const result = + table === "blood_oxygen" + ? database.query(query).run(item.time, value, "point") + : database.query(query).run(item.time, value); + if (result.changes > 0) increment(counters, table); + } + }); + } catch (error) { + log("warn", `Failed to fetch ${key}`, { error }); + } + } +} + +function parseMetricValue(item: AggregatedDataItem, field: string): number { + const raw = (() => { + try { + return JSON.parse(item.value) as unknown; + } catch { + return item.value; + } + })(); + if (raw && typeof raw === "object" && !Array.isArray(raw)) + return Number((raw as Record)[field] ?? 0); + return Number(raw); +} + +async function syncCalories( + client: MiHealthClient, + relativeUid: number, + config: AppConfig, + uid: number, + counters: Record, +): Promise { + const start = Math.floor(Date.now() / 1000) - config.QUERY_DURATION * 86_400; + const end = Math.floor(Date.now() / 1000); + const values = new Map>(); + for (const [key, field, valueKey] of [ + ["calories", "total_cal", "calories"], + ["intensity", "intensity_minutes", "duration"], + ["valid_stand", "valid_stand_hours", "count"], + ] as const) { + try { + for (const item of await client.getAggregatedData(relativeUid, key, start, end, config.QUERY_DURATION)) { + const value = parseMetricValue(item, valueKey); + if (!value) continue; + const date = new Date(item.time * 1000).toISOString().slice(0, 10); + const entry = values.get(date) ?? {}; + entry[field] = field === "total_cal" ? (entry[field] ?? 0) + value : Math.max(entry[field] ?? 0, value); + values.set(date, entry); + } + } catch (error) { + log("warn", `Failed to fetch ${key}`, { error }); + } + } + withHealthDb(config, uid, (database) => { + for (const [date, value] of values) { + const result = database + .query( + "INSERT OR REPLACE INTO calories_daily (date,total_cal,active_cal,valid_stand_hours,intensity_minutes,last_sync) VALUES (?,?,?,?,?,?)", + ) + .run( + date, + value.total_cal ?? null, + value.active_cal ?? null, + value.valid_stand_hours ?? null, + value.intensity_minutes ?? null, + timestamp(), + ); + if (result.changes > 0) increment(counters, "calories_daily"); + } + }); +} + +async function syncWeight( + client: MiHealthClient, + relativeUid: number, + config: AppConfig, + uid: number, + counters: Record, +): Promise { + try { + const values = await client.getWeightHistory(relativeUid, Math.max(config.QUERY_DURATION, 180)); + withHealthDb(config, uid, (database) => { + for (const item of values) { + if (!item.weight || item.weight <= 0) continue; + const result = database + .query("INSERT OR IGNORE INTO weight (timestamp,weight_kg,bmi) VALUES (?,?,?)") + .run(item.time, item.weight, item.bmi); + if (result.changes > 0) increment(counters, "weight"); + } + }); + } catch (error) { + log("warn", "Failed to fetch weight", { error }); + } +} + +async function syncWorkouts( + client: MiHealthClient, + relativeUid: number, + config: AppConfig, + uid: number, + counters: Record, +): Promise { + try { + let watermark = 0; + let hasMore = true; + while (hasMore) { + const response = await client.request("GET", "/app/v1/data/get_sport_records_by_watermark", { + relative_uid: relativeUid, + watermark, + limit: 50, + }); + const result = record(response.result); + const records = Array.isArray(result.sport_records) ? result.sport_records : []; + hasMore = result.has_more === true; + if (!records.length) break; + withHealthDb(config, uid, (database) => { + for (const rawRecord of records) { + const entry = record(rawRecord); + const value = (() => { + const raw = entry.value; + if (typeof raw === "string") { + try { + return record(JSON.parse(raw)); + } catch { + return {}; + } + } + return record(raw); + })(); + const recordWatermark = Number(entry.watermark ?? 0); + watermark = Math.max(watermark, recordWatermark); + const workoutId = String(entry.sid ?? entry.did ?? ""); + const start = Number(value.start_time ?? entry.time ?? 0); + const duration = Number(value.duration ?? 0); + const resultRow = database + .query( + "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 (?,?,?,?,?,?,?,?,?,?,?)", + ) + .run( + workoutId, + String(entry.key ?? entry.category ?? "unknown"), + start, + Number(value.end_time ?? start + duration), + duration, + Number(value.calories ?? value.total_cal ?? 0), + Number(value.avg_hrm ?? 0), + Number(value.max_hrm ?? 0), + Number(value.min_hrm ?? 0), + recordWatermark, + JSON.stringify(value), + ); + if (resultRow.changes > 0) increment(counters, "workouts"); + } + }); + } + log("debug", "Workout watermark sync completed", { userId: uid }); + } catch (error) { + log("warn", "Failed to fetch workouts", { error }); + } +} + +async function syncFds( + client: MiHealthClient, + relativeUid: number, + config: AppConfig, + uid: number, + sleep: SleepData[], + counters: Record, +): Promise { + for (const item of sleep) + for (const segment of item.segment_details) { + try { + const content = await downloadAndDecryptSleepDetails( + client, + relativeUid, + segment.wake_up_time, + segment.timezone, + (message) => log("debug", message), + ); + const parsed = content ? parseAllDaySleepBytes(content) : null; + if (!parsed) continue; + withHealthDb(config, uid, (database) => { + for (const [timestampValue, value] of parsed.records.heart_rate) { + database + .query("INSERT OR REPLACE INTO heart_rate (timestamp,value) VALUES (?,?)") + .run(timestampValue, value); + increment(counters, "heart_rate"); + } + for (const [timestampValue, value] of parsed.records.spo2) { + database + .query("INSERT OR REPLACE INTO blood_oxygen (timestamp,spo2,type) VALUES (?,?,?)") + .run(timestampValue, value, "fds_detail"); + increment(counters, "blood_oxygen"); + } + }); + increment(counters, "fds_segments"); + } catch (error) { + log("debug", "FDS sleep detail unavailable", { error }); + } + } +} + +function writeStatus( + path: string, + steps: StepData | undefined, + heartRate: { timestamp: number; value: number } | undefined, + sleep: SleepData | undefined, +): void { + writeJsonAtomic(path, { + last_sync: timestamp(), + last_sync_time: formatEpoch(timestamp()), + today: steps + ? { + date: new Date(steps.time * 1000).toISOString().slice(0, 10), + steps: steps.steps, + calories: steps.calories, + distance_m: steps.distance, + } + : null, + latest_heart_rate: heartRate + ? { timestamp: heartRate.timestamp, time: formatEpoch(heartRate.timestamp), value: heartRate.value } + : null, + latest_sleep: sleep + ? { + date: new Date(sleep.time * 1000).toISOString().slice(0, 10), + light_sleep_min: sleep.sleep_light_duration, + deep_sleep_min: sleep.sleep_deep_duration, + rem_sleep_min: sleep.sleep_rem_duration, + awake_min: sleep.sleep_awake_duration, + total_sleep_min: sleep.total_duration || sleep.sleep_light_duration + sleep.sleep_deep_duration, + sleep_score: sleep.sleep_score, + start_time: formatEpoch(Math.min(...sleep.segment_details.map((segment) => segment.bedtime), 0)), + end_time: formatEpoch(Math.max(...sleep.segment_details.map((segment) => segment.wake_up_time), 0)), + } + : null, + }); +} + +export async function runSyncDaemon(config: AppConfig, signal: AbortSignal): Promise { + while (!signal.aborted) { + const current = (() => { + try { + return config.allowedUserIds; + } catch { + return []; + } + })(); + if (current.length === 0) { + log("info", "Sync daemon is waiting for Telegram /start binding"); + await Bun.sleep(5000); + continue; + } + for (const uid of current) await runSync(uid, config); + if (config.SYNC_INTERVAL <= 0) return; + await Bun.sleep(config.SYNC_INTERVAL * 1000); + } +} diff --git a/src/xiaomi/client.ts b/src/xiaomi/client.ts new file mode 100644 index 0000000..1711b7a --- /dev/null +++ b/src/xiaomi/client.ts @@ -0,0 +1,1292 @@ +import { createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { log } from "../logger.js"; +import { type AuthToken, readToken, saveAuthToken } from "../storage/secure-files.js"; + +export type { AuthToken } from "../storage/secure-files.js"; + +const API_BASE = "https://ru.hlth.io.mi.com"; +const STS_URL = "https://sts-hlth.io.mi.com/healthapp/sts"; +const QR_URL = "https://account.xiaomi.com/longPolling/loginUrl"; +const SERVICE_LOGIN_URL = "https://account.xiaomi.com/pass/serviceLogin"; +const DEFAULT_UA = "Android-12-3.53.1-vivo-V2284A"; +const LOGIN_UA = + "Dalvik/2.1.0 (Linux; U; Android 12; V2284A Build/ab8c0d1.1) APP/mi.health APPV/353001 MK/VjIyODRB SDKV/5.3.0.release.68 CPN/com.mi.health PassportSDK/"; + +export class XiaomiError extends Error {} +export class TokenExpiredError extends XiaomiError {} +export class AuthError extends XiaomiError {} +export class APIError extends XiaomiError { + constructor( + message: string, + readonly details: { statusCode?: number; code?: number; responseBody?: string } = {}, + ) { + super(message); + this.name = "APIError"; + } +} +export class DataNotSharedError extends XiaomiError { + constructor( + message: string, + readonly dataType = "", + ) { + super(message); + this.name = "DataNotSharedError"; + } +} +export class DataOutOfSharedTimeScopeError extends DataNotSharedError {} +export class FamilyMemberNotFoundError extends XiaomiError {} +export class DeviceUntrustedError extends AuthError {} +export class CaptchaRequiredError extends AuthError { + constructor( + message: string, + readonly captchaUrl = "", + ) { + super(message); + this.name = "CaptchaRequiredError"; + } +} + +export type LatestHeartRate = { bpm: number; time: number }; +export type SleepSegment = { + bedtime: number; + wake_up_time: number; + duration: number; + sleep_deep_duration: number; + sleep_light_duration: number; + timezone: number; + awake_count?: number; + sleep_awake_duration: number; +}; +export type HeartRateData = { + time: number; + avg_hr: number; + avg_rhr: number; + max_hr: number; + min_hr: number; + latest_hr: LatestHeartRate | null; + abnormal_hr_count?: number; + aerobic_hr_zone_duration?: number; + anaerobic_hr_zone_duration?: number; + extreme_hr_zone_duration?: number; + fat_burning_hr_zone_duration?: number; + warm_up_hr_zone_duration?: number; +}; +export type SleepData = { + time: number; + total_duration: number; + sleep_score: number; + sleep_deep_duration: number; + sleep_light_duration: number; + sleep_rem_duration: number; + sleep_awake_duration: number; + sleep_stage?: number; + long_sleep_evaluation?: number; + day_sleep_evaluation?: number; + avg_hr?: number; + max_hr?: number; + min_hr?: number; + avg_spo2?: number; + segment_details: SleepSegment[]; +}; +export type StepData = { time: number; steps: number; distance: number; calories: number; goal?: number }; +export type WeightData = { time: number; weight: number; bmi: number }; +export type BloodPressureData = { time: number; systolic: number; diastolic: number; pulse: number | null }; +export type GoalItem = { field: number; target_value: number; achieved_value: number }; +export type GoalData = { time: number; goal_items: GoalItem[] }; +export type CaloriesData = { time: number; calories: number; goal: number }; +export type ValidStandData = { time: number; count: number }; +export type IntensityData = { time: number; duration: number }; +export type Spo2Data = { time: number; spo2: number }; +export type Spo2SummaryData = { + time: number; + avg_spo2: number; + max_spo2?: number; + min_spo2?: number; + lack_spo2_count?: number; + latest_spo2: Spo2Data | null; +}; +export type AggregatedDataItem = { + sid: string; + tag: string; + key: string; + time: number; + value: string; + update_time: number; + watermark: string; + source_sid_list?: string[]; +}; +export type FamilyMember = { + relative_uid: number; + relative_note: string; + relative_icon?: string; + latest_data_time: number; + latest_abnormal_record_time?: number | null; + source_tag?: number; +}; +export type VerifiedUserInfo = { user_id: number; nickname: string; icon: string }; +export type LatestDataItem = { time: number; key: string; value: string | number }; +export type LatestDataSnapshot = { + updated_time: number; + goal: GoalData | null; + heart_rate: LatestHeartRate | null; + sleep: SleepData | null; + blood_pressure: BloodPressureData | null; + steps: StepData | null; + calories: CaloriesData | null; + valid_stand: ValidStandData | null; + intensity: IntensityData | null; + weight: WeightData | null; + spo2: Spo2Data | null; + extras: Record; +}; +export type DailySummary = { + date: string; + relative_uid: number; + heart_rate: HeartRateData | null; + sleep: SleepData | null; + steps: StepData | null; +}; +export type InviteMessage = { + msg_id: number; + module: number; + type: number; + receiver: number; + sender: number; + extra_data: string; + is_new: number; + data_status: number; + create_time: number; + last_modify: number; + invite_id: number | null; + nick_name: string; + icon: string; + is_pending: boolean; +}; + +type JsonObject = Record; +type ApiResponse = { code?: unknown; message?: unknown; result?: unknown } & JsonObject; + +function base64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +function bytes(value: string): Uint8Array { + const normalized = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - (value.length % 4)) % 4); + return new Uint8Array(Buffer.from(normalized, "base64")); +} + +function urlSafeBase64(value: Uint8Array): string { + return base64(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function asObject(value: unknown): JsonObject { + return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; +} + +function numberValue(value: unknown, fallback = 0): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : value === undefined || value === null ? fallback : String(value); +} + +function parseValue(value: unknown): JsonObject { + if (value !== null && typeof value === "object" && !Array.isArray(value)) return value as JsonObject; + if (typeof value !== "string") return {}; + try { + return asObject(JSON.parse(value)); + } catch { + return {}; + } +} + +function rc4(key: Uint8Array, input: Uint8Array, skip = 1024): Uint8Array { + if (!key.length) throw new XiaomiError("RC4 key is empty"); + const s = Array.from({ length: 256 }, (_, index) => index); + let j = 0; + for (let i = 0; i < 256; i += 1) { + j = (j + (s[i] ?? 0) + (key[i % key.length] ?? 0)) & 255; + [s[i], s[j]] = [s[j] ?? 0, s[i] ?? 0]; + } + let i = 0; + j = 0; + const next = (): number => { + i = (i + 1) & 255; + j = (j + (s[i] ?? 0)) & 255; + [s[i], s[j]] = [s[j] ?? 0, s[i] ?? 0]; + return s[((s[i] ?? 0) + (s[j] ?? 0)) & 255] ?? 0; + }; + for (let n = 0; n < skip; n += 1) next(); + return Uint8Array.from(input, (value) => value ^ next()); +} + +export function rc4Crypt(key: Uint8Array, input: Uint8Array, skip = 1024): Uint8Array { + return rc4(key, input, skip); +} + +function signedNonce(ssecurity: string, nonce: string): string { + const hash = createHash("sha256") + .update(Buffer.concat([Buffer.from(ssecurity, "base64"), Buffer.from(nonce, "base64")])) + .digest(); + return base64(hash); +} + +export function computeSignedNonce(ssecurity: string, nonce: string): string { + return signedNonce(ssecurity, nonce); +} + +function sha1Base64(value: string): string { + return createHash("sha1").update(value, "utf8").digest("base64"); +} + +function signatureMessage(method: string, path: string, params: Record, nonce: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return [ + method.toUpperCase(), + normalized, + ...Object.keys(params) + .sort() + .map((key) => `${key}=${params[key]}`), + nonce, + ].join("&"); +} + +function encryptedParams( + method: string, + path: string, + ssecurity: string, + params: JsonObject | undefined, +): Record { + const nonce = base64( + Buffer.concat([ + randomBytes(8), + (() => { + const result = Buffer.alloc(4); + result.writeUInt32BE(Math.floor(Date.now() / 60_000), 0); + return result; + })(), + ]), + ); + const snonce = signedNonce(ssecurity, nonce); + const raw: Record = {}; + if (params) raw.data = JSON.stringify(params); + const rc4Hash = sha1Base64(signatureMessage(method, path, raw, snonce)); + raw.rc4_hash__ = rc4Hash; + const entries = Object.entries(raw).sort(([a], [b]) => a.localeCompare(b)); + const plaintext = Buffer.concat(entries.map(([, value]) => Buffer.from(value, "utf8"))); + const encrypted = rc4(bytes(snonce), plaintext); + const output: Record = {}; + let position = 0; + for (const [key, value] of entries) { + const length = Buffer.byteLength(value, "utf8"); + output[key] = base64(encrypted.slice(position, position + length)); + position += length; + } + output.signature = sha1Base64(signatureMessage(method, path, output, snonce)); + output._nonce = nonce; + return output; +} + +export function buildEncryptedParams( + method: string, + path: string, + ssecurity: string, + params?: JsonObject, +): Record { + return encryptedParams(method, path, ssecurity, params); +} + +export function decryptResponse(ssecurity: string, nonce: string, ciphertext: string): unknown { + const snonce = signedNonce(ssecurity, nonce); + const plaintext = Buffer.from(rc4(bytes(snonce), bytes(ciphertext))).toString("utf8"); + try { + return JSON.parse(plaintext); + } catch { + return plaintext; + } +} + +export function encryptData(snonce: string, plaintext: string): string { + return base64(rc4(bytes(snonce), new TextEncoder().encode(plaintext))); +} + +export function decryptData(snonce: string, ciphertext: string): string { + return new TextDecoder().decode(rc4(bytes(snonce), bytes(ciphertext))); +} + +function dataItem(item: unknown): AggregatedDataItem { + const value = asObject(item); + const raw = value.value; + return { + sid: stringValue(value.sid), + tag: stringValue(value.tag), + key: stringValue(value.key), + time: numberValue(value.time), + value: typeof raw === "string" ? raw : JSON.stringify(raw ?? {}), + update_time: numberValue(value.update_time), + watermark: stringValue(value.watermark), + source_sid_list: Array.isArray(value.source_sid_list) + ? value.source_sid_list.filter((entry): entry is string => typeof entry === "string") + : [], + }; +} + +function parseSegments(value: unknown): SleepSegment[] { + if (!Array.isArray(value)) return []; + return value + .filter((item) => item && typeof item === "object") + .map((item) => { + const entry = asObject(item); + return { + bedtime: numberValue(entry.bedtime), + wake_up_time: numberValue(entry.wake_up_time), + duration: numberValue(entry.duration), + sleep_deep_duration: numberValue(entry.sleep_deep_duration), + sleep_light_duration: numberValue(entry.sleep_light_duration), + timezone: numberValue(entry.timezone), + awake_count: numberValue(entry.awake_count), + sleep_awake_duration: numberValue(entry.sleep_awake_duration), + }; + }); +} + +function parseAggregated(item: AggregatedDataItem, key: string): JsonObject { + const value = parseValue(item.value); + value.time = item.time; + if (key === "sleep") value.segment_details = parseSegments(value.segment_details); + return value; +} + +function parseHeartRate(item: AggregatedDataItem): HeartRateData { + const value = parseAggregated(item, "heart_rate"); + const latest = asObject(value.latest_hr); + return { + time: numberValue(value.time), + avg_hr: numberValue(value.avg_hr), + avg_rhr: numberValue(value.avg_rhr), + max_hr: numberValue(value.max_hr), + min_hr: numberValue(value.min_hr), + latest_hr: Object.keys(latest).length ? { bpm: numberValue(latest.bpm), time: numberValue(latest.time) } : null, + abnormal_hr_count: numberValue(value.abnormal_hr_count), + aerobic_hr_zone_duration: numberValue(value.aerobic_hr_zone_duration), + anaerobic_hr_zone_duration: numberValue(value.anaerobic_hr_zone_duration), + extreme_hr_zone_duration: numberValue(value.extreme_hr_zone_duration), + fat_burning_hr_zone_duration: numberValue(value.fat_burning_hr_zone_duration), + warm_up_hr_zone_duration: numberValue(value.warm_up_hr_zone_duration), + }; +} + +function parseSleep(item: AggregatedDataItem): SleepData { + const value = parseAggregated(item, "sleep"); + return { + time: numberValue(value.time), + total_duration: numberValue(value.total_duration), + sleep_score: numberValue(value.sleep_score), + sleep_deep_duration: numberValue(value.sleep_deep_duration), + sleep_light_duration: numberValue(value.sleep_light_duration), + sleep_rem_duration: numberValue(value.sleep_rem_duration), + sleep_awake_duration: numberValue(value.sleep_awake_duration), + sleep_stage: numberValue(value.sleep_stage), + long_sleep_evaluation: numberValue(value.long_sleep_evaluation), + day_sleep_evaluation: numberValue(value.day_sleep_evaluation), + avg_hr: numberValue(value.avg_hr), + max_hr: numberValue(value.max_hr), + min_hr: numberValue(value.min_hr), + avg_spo2: numberValue(value.avg_spo2), + segment_details: parseSegments(value.segment_details), + }; +} + +function parseSteps(item: AggregatedDataItem): StepData { + const value = parseAggregated(item, "steps"); + return { + time: numberValue(value.time), + steps: numberValue(value.steps), + distance: numberValue(value.distance), + calories: numberValue(value.calories), + goal: numberValue(value.goal), + }; +} + +function parseSpo2(item: AggregatedDataItem): Spo2SummaryData { + const value = parseAggregated(item, "spo2"); + const latest = asObject(value.latest_spo2); + return { + time: numberValue(value.time), + avg_spo2: numberValue(value.avg_spo2), + max_spo2: numberValue(value.max_spo2), + min_spo2: numberValue(value.min_spo2), + lack_spo2_count: numberValue(value.lack_spo2_count), + latest_spo2: Object.keys(latest).length ? { time: numberValue(latest.time), spo2: numberValue(latest.spo2) } : null, + }; +} + +function parseWeight(item: AggregatedDataItem): WeightData { + const value = parseAggregated(item, "weight"); + return { time: numberValue(value.time), weight: numberValue(value.weight), bmi: numberValue(value.bmi) }; +} + +function parseBloodPressure(item: AggregatedDataItem): BloodPressureData { + const value = parseAggregated(item, "blood_pressure"); + return { + time: numberValue(value.time), + systolic: numberValue(value.systolic ?? value.systolic_pressure), + diastolic: numberValue(value.diastolic ?? value.diastolic_pressure), + pulse: value.pulse === undefined || value.pulse === null ? null : numberValue(value.pulse), + }; +} + +function parseLatestValue(item: LatestDataItem): JsonObject { + const value = typeof item.value === "string" ? parseValue(item.value) : asObject(item.value); + value.time = item.time; + return value; +} + +function parseLatestItem(item: LatestDataItem): unknown { + const value = parseLatestValue(item); + switch (item.key) { + case "goal": + return { + time: numberValue(value.time), + goal_items: Array.isArray(value.goal_items) + ? value.goal_items.map((entry) => { + const goal = asObject(entry); + return { + field: numberValue(goal.field), + target_value: numberValue(goal.target_value), + achieved_value: numberValue(goal.achieved_value), + }; + }) + : [], + } satisfies GoalData; + case "heart_rate": + return { bpm: numberValue(value.bpm), time: numberValue(value.time) } satisfies LatestHeartRate; + case "sleep": + return { + ...parseSleep({ ...dataItem({ key: item.key, time: item.time, value: JSON.stringify(value) }) }), + } satisfies SleepData; + case "steps": + return { + time: numberValue(value.time), + steps: numberValue(value.steps), + distance: numberValue(value.distance), + calories: numberValue(value.calories), + goal: numberValue(value.goal), + } satisfies StepData; + case "weight": + return { + time: numberValue(value.time), + weight: numberValue(value.weight), + bmi: numberValue(value.bmi), + } satisfies WeightData; + case "blood_pressure": + return { + time: numberValue(value.time), + systolic: numberValue(value.systolic ?? value.systolic_pressure), + diastolic: numberValue(value.diastolic ?? value.diastolic_pressure), + pulse: value.pulse === undefined || value.pulse === null ? null : numberValue(value.pulse), + } satisfies BloodPressureData; + case "calories": + return { + time: numberValue(value.time), + calories: numberValue(value.calories), + goal: numberValue(value.goal), + } satisfies CaloriesData; + case "valid_stand": + return { time: numberValue(value.time), count: numberValue(value.count) } satisfies ValidStandData; + case "intensity": + return { time: numberValue(value.time), duration: numberValue(value.duration) } satisfies IntensityData; + case "spo2": + return { time: numberValue(value.time), spo2: numberValue(value.spo2) } satisfies Spo2Data; + default: + return value; + } +} + +function parseLatestDataItems(value: unknown): LatestDataItem[] { + if (!Array.isArray(value)) return []; + return value + .filter((item) => item && typeof item === "object") + .map((item) => { + const entry = asObject(item); + const raw = entry.value; + return { + time: numberValue(entry.time), + key: stringValue(entry.key), + value: typeof raw === "string" || typeof raw === "number" ? raw : JSON.stringify(raw ?? {}), + }; + }); +} + +function latestSnapshot(items: LatestDataItem[], updatedTime: number): LatestDataSnapshot { + const snapshot: LatestDataSnapshot = { + updated_time: updatedTime, + goal: null, + heart_rate: null, + sleep: null, + blood_pressure: null, + steps: null, + calories: null, + valid_stand: null, + intensity: null, + weight: null, + spo2: null, + extras: {}, + }; + for (const item of items) { + const parsed = parseLatestItem(item); + if (item.key in snapshot && item.key !== "updated_time" && item.key !== "extras") { + (snapshot as unknown as Record)[item.key] = parsed; + } else { + snapshot.extras[item.key] = parsed; + } + } + return snapshot; +} + +function windowArguments(queryDateOrDays: Date | number | undefined, days = 1): [Date, number] { + if (typeof queryDateOrDays === "number") return [new Date(), Math.max(1, queryDateOrDays)]; + return [queryDateOrDays ?? new Date(), Math.max(1, days)]; +} + +function dateWindow(days: number, queryDate = new Date()): [number, number, number] { + const endDate = new Date( + Date.UTC(queryDate.getUTCFullYear(), queryDate.getUTCMonth(), queryDate.getUTCDate() + 1, 0, 0, 0), + ); + const end = Math.floor(endDate.getTime() / 1000) - 1; + const windowDays = Math.max(1, days); + return [end - 86_400 * windowDays + 1, end, windowDays]; +} + +class XiaomiHttp { + private readonly cookies = new Map(); + constructor(private readonly headers: Record) {} + + setCookie(name: string, value: string): void { + this.cookies.set(name, value); + } + + async request(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(this.headers); + for (const [key, value] of Object.entries(init.headers ?? {})) headers.set(key, value); + if (this.cookies.size) headers.set("cookie", [...this.cookies].map(([key, value]) => `${key}=${value}`).join("; ")); + const response = await fetch(url, { ...init, headers, redirect: "manual" }); + for (const cookie of response.headers.getSetCookie?.() ?? []) { + const pair = cookie.split(";", 1)[0] ?? ""; + const index = pair.indexOf("="); + if (index > 0) this.cookies.set(pair.slice(0, index), pair.slice(index + 1)); + } + return response; + } + + get cookiesSnapshot(): Record { + return Object.fromEntries(this.cookies); + } +} + +function parseMiResponse(text: string): JsonObject { + const body = text.startsWith("&&&START&&&") ? text.slice("&&&START&&&".length) : text; + try { + return asObject(JSON.parse(body)); + } catch { + throw new XiaomiError(`Xiaomi response is not JSON: ${body.slice(0, 200)}`); + } +} + +export class XiaomiAuth { + token: AuthToken; + private tokenFilePath: string | undefined; + private readonly http = new XiaomiHttp({ + "user-agent": LOGIN_UA, + "content-type": "application/x-www-form-urlencoded", + }); + + constructor( + token: AuthToken = { + user_id: "", + c_user_id: "", + service_token: "", + ssecurity: "", + pass_token: "", + device_id: `an_${randomBytes(16).toString("hex")}`, + }, + ) { + this.token = token; + } + + static fromToken(path: string): XiaomiAuth { + const auth = new XiaomiAuth(readToken(path)); + auth.tokenFilePath = path; + return auth; + } + + get isAuthenticated(): boolean { + return Boolean(this.token.service_token && this.token.ssecurity); + } + + get canRefresh(): boolean { + return Boolean(this.token.pass_token && this.token.user_id); + } + + saveToken(path = this.tokenFilePath): void { + if (!path) throw new AuthError("Token path is not configured"); + saveAuthToken(path, this.token); + this.tokenFilePath = path; + } + + loadToken(path: string): AuthToken { + this.token = readToken(path); + this.tokenFilePath = path; + return this.token; + } + + async refresh(): Promise { + if (!this.canRefresh) throw new TokenExpiredError("Token cannot be refreshed without passToken and userId"); + await this.refreshWithPassToken(); + if (this.tokenFilePath) this.saveToken(this.tokenFilePath); + return this.token; + } + + toString(): string { + return `XiaomiAuth(user=${this.token.user_id || "N/A"}, ${this.isAuthenticated ? "authenticated" : "unauthenticated"})`; + } + + async loginQr(callback?: (qrImageUrl: string, loginUrl: string) => Promise, maxWait = 300): Promise { + this.http.setCookie("deviceId", this.token.device_id); + const query = new URLSearchParams({ + _qrsize: "480", + qs: "%3Fsid%3Dmiothealth%26_json%3Dtrue", + callback: STS_URL, + _hasLogo: "false", + sid: "miothealth", + serviceParam: "", + _locale: "zh_CN", + _dc: String(Date.now()), + }); + const qrResponse = await this.http.request(`${QR_URL}?${query}`); + if (!qrResponse.ok) throw new XiaomiError(`QR request failed: ${qrResponse.status}`); + const qr = parseMiResponse(await qrResponse.text()); + const image = stringValue(qr.qr); + const loginUrl = stringValue(qr.loginUrl); + const pollingUrl = stringValue(qr.lp); + if (!image || !pollingUrl) throw new XiaomiError("Xiaomi did not return a QR polling URL"); + await callback?.(image, loginUrl); + const timeout = Math.min(numberValue(qr.timeout, maxWait), maxWait) * 1000; + const started = Date.now(); + let response: Response | undefined; + while (Date.now() - started < timeout) { + try { + response = await this.http.request(pollingUrl); + if (response.status === 200) break; + await Bun.sleep(2000); + } catch (error) { + log("warn", "Xiaomi QR polling failed", { error }); + await Bun.sleep(2000); + } + } + if (response?.status !== 200) throw new XiaomiError("Xiaomi QR login timed out"); + const data = parseMiResponse(await response.text()); + this.token.ssecurity = stringValue(data.ssecurity); + this.token.user_id = stringValue(data.userId); + this.token.pass_token = stringValue(data.passToken); + this.token.c_user_id = stringValue(data.cUserId); + const location = stringValue(data.location); + if (location) { + const redirect = await this.http.request(location); + const locationHeader = redirect.headers.get("location") ?? location; + this.token.service_token = + stringValue(this.http.cookiesSnapshot.serviceToken) || + stringValue(new URL(locationHeader).searchParams.get("serviceToken")); + } + if (!this.token.service_token) throw new XiaomiError("Xiaomi login did not return serviceToken"); + await this.stsExchange(); + return this.token; + } + + async stsExchange(): Promise { + const query = new URLSearchParams({ + d: this.token.device_id, + ticket: "0", + pwd: "0", + p_ts: String(Date.now()), + fid: "0", + p_lm: "2", + p_ur: "CN", + sid: "hlth.io.mi.com", + }); + const clientSign = process.env.MI_CLIENT_SIGN; + if (clientSign) query.set("clientSign", clientSign); + const response = await this.http.request(`${STS_URL}?${query}`); + if (response.ok && (await response.text()).trim() === "ok") { + const serviceToken = this.http.cookiesSnapshot.serviceToken; + if (serviceToken) this.token.service_token = serviceToken; + } + } + + async refreshWithPassToken(): Promise { + if (!this.token.pass_token || !this.token.user_id) { + throw new TokenExpiredError("Xiaomi passToken credentials are missing"); + } + + this.http.setCookie("passToken", this.token.pass_token); + this.http.setCookie("deviceId", this.token.device_id); + this.http.setCookie("userId", this.token.user_id); + + const query = new URLSearchParams({ _json: "true", sid: "miothealth" }); + const response = await this.http.request(`${SERVICE_LOGIN_URL}?${query}`); + if (!response.ok) throw new TokenExpiredError(`Xiaomi token refresh failed: ${response.status}`); + const data = parseMiResponse(await response.text()); + const ssecurity = stringValue(data.ssecurity); + if (!ssecurity) throw new TokenExpiredError("Xiaomi serviceLogin did not return ssecurity"); + + const previous = { + c_user_id: this.token.c_user_id, + service_token: this.token.service_token, + ssecurity: this.token.ssecurity, + }; + const nextCUserId = stringValue(data.cUserId, this.token.c_user_id); + if (nextCUserId) this.http.setCookie("cUserId", nextCUserId); + let nextServiceToken = ""; + const location = stringValue(data.location); + if (location) { + const nonce = stringValue(data.nonce); + const clientSign = encodeURIComponent(sha1Base64(`nonce=${nonce}&${ssecurity}`)).replaceAll("%2F", "/"); + const redirect = await this.http.request(`${location}&clientSign=${clientSign}`); + const locationHeader = redirect.headers.get("location") ?? location; + const serviceToken = + stringValue(this.http.cookiesSnapshot.serviceToken) || + stringValue(new URL(locationHeader).searchParams.get("serviceToken")); + if (serviceToken) nextServiceToken = serviceToken; + } + await this.stsExchange(); + nextServiceToken ||= stringValue(this.http.cookiesSnapshot.serviceToken); + if (!nextServiceToken || nextServiceToken === previous.service_token) { + this.token.c_user_id = previous.c_user_id; + this.token.service_token = previous.service_token; + this.token.ssecurity = previous.ssecurity; + throw new TokenExpiredError("Xiaomi token refresh did not return a new serviceToken"); + } + this.token.c_user_id = nextCUserId; + this.token.service_token = nextServiceToken; + this.token.ssecurity = ssecurity; + return this.token; + } +} + +export class MiHealthClient { + private readonly http = new XiaomiHttp({ "user-agent": DEFAULT_UA, region_tag: "ru", handleparams: "true" }); + private refreshPromise: Promise | undefined; + constructor( + readonly auth: XiaomiAuth, + private readonly baseUrl = API_BASE, + ) {} + + static fromToken(path: string, baseUrl = API_BASE): MiHealthClient { + return new MiHealthClient(XiaomiAuth.fromToken(path), baseUrl); + } + + toString(): string { + return `MiHealthClient(user_id=${this.auth.token.user_id || "N/A"}, base_url=${this.baseUrl})`; + } + + async request(method: string, path: string, params?: JsonObject, retry = true): Promise { + if (!this.auth.token.service_token || !this.auth.token.ssecurity) + throw new XiaomiError("Xiaomi token is not authenticated"); + this.http.setCookie("cUserId", this.auth.token.c_user_id); + this.http.setCookie("serviceToken", this.auth.token.service_token); + const signingPath = path === "/healthapp/service/gen_download_url" ? "/service/gen_download_url" : path; + const encoded = encryptedParams(method, signingPath, this.auth.token.ssecurity, params); + const url = new URL(`${this.baseUrl}${path}`); + if (method.toUpperCase() === "GET") + for (const [key, value] of Object.entries(encoded)) url.searchParams.set(key, value); + const response = await this.http.request(url.toString(), { + method, + ...(method.toUpperCase() === "GET" ? {} : { body: new URLSearchParams(encoded) }), + headers: method.toUpperCase() === "GET" ? undefined : { "content-type": "application/x-www-form-urlencoded" }, + }); + if (response.status === 401) { + if (!retry || !this.auth.token.pass_token || !this.auth.token.user_id) + throw new TokenExpiredError("Xiaomi token expired"); + await this.refresh(); + return this.request(method, path, params, false); + } + if (!response.ok) + throw new APIError(`Xiaomi API ${method} ${path} failed: ${response.status}`, { statusCode: response.status }); + const nonce = encoded._nonce; + if (!nonce) throw new XiaomiError("Xiaomi request nonce is missing"); + const decrypted = decryptResponse(this.auth.token.ssecurity, nonce, await response.text()); + const result = asObject(decrypted); + const code = numberValue(result.code, -1); + if (code !== 0) { + const message = stringValue(result.message ?? result.msg ?? result.desc ?? result.description, "unknown error"); + const requestedKey = stringValue(params?.key); + if (code === -4002001) throw new FamilyMemberNotFoundError(`Not a family member: ${message}`); + if (code === -4002004) { + if (message.toLowerCase().includes("time out of data shared time scope")) + throw new DataOutOfSharedTimeScopeError(message, requestedKey); + throw new DataNotSharedError(message, requestedKey); + } + throw new APIError(`Xiaomi API error ${code}: ${message}`, { code }); + } + return result as ApiResponse; + } + + private async refresh(): Promise { + if (this.refreshPromise) return this.refreshPromise; + this.refreshPromise = (async () => { + await this.auth.refreshWithPassToken(); + })().finally(() => { + this.refreshPromise = undefined; + }); + return this.refreshPromise; + } + + async getAggregatedData( + uid: number, + key: string, + start: number, + end: number, + limitOrOptions: number | { tag?: string; limit?: number } = 30, + ): Promise { + const options = typeof limitOrOptions === "number" ? { limit: limitOrOptions } : limitOrOptions; + const response = await this.request("GET", "/app/v1/data/get_aggregated_fitness_data_by_time", { + relative_uid: uid, + key, + tag: options.tag ?? "daily_report", + start_time: start, + end_time: end, + limit: options.limit ?? 30, + }); + const result = asObject(response.result); + return (Array.isArray(result.data_list) ? result.data_list : []).map(dataItem); + } + + async getFitnessData( + uid: number, + key: string, + start: number, + end: number, + limitOrOptions: number | { limit?: number } = 30, + ): Promise { + const limit = typeof limitOrOptions === "number" ? limitOrOptions : (limitOrOptions.limit ?? 30); + const response = await this.request("GET", "/app/v1/data/get_fitness_data_by_time", { + relative_uid: uid, + key, + start_time: start, + end_time: end, + limit, + }); + const result = asObject(response.result); + return (Array.isArray(result.data_list) ? result.data_list : []).map(dataItem); + } + + async getSteps( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "steps", start, end, limit)).map(parseSteps); + } + + async getSleep( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "sleep", start, end, limit)).map(parseSleep); + } + + async getHeartRate( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "heart_rate", start, end, limit)).map(parseHeartRate); + } + + async getSpo2History( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "spo2", start, end, limit)).map(parseSpo2); + } + + async getWeightHistory( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end] = dateWindow(days, queryDate); + return (await this.getFitnessData(uid, "weight", start, end, Math.max(days, 30))).map(parseWeight); + } + + async getBloodPressureHistory( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end] = dateWindow(days, queryDate); + return (await this.getFitnessData(uid, "blood_pressure", start, end, Math.max(days, 30))).map(parseBloodPressure); + } + + async getLatestItems(uid: number): Promise { + const response = await this.request("GET", "/app/v1/data/get_latest_fitness_data", { relative_uid: uid }); + return parseLatestDataItems(asObject(response.result).data_list); + } + + async getLatestData(uid: number): Promise { + const response = await this.request("GET", "/app/v1/data/get_latest_fitness_data", { relative_uid: uid }); + const result = asObject(response.result); + return latestSnapshot(parseLatestDataItems(result.data_list), numberValue(result.latest_data_time)); + } + + private async latestMetric(uid: number, key: string, value: T | null): Promise { + if (value !== null) return value; + const shared = await this.getSharedDataTypes(uid); + if (!shared.includes(key)) throw new DataNotSharedError(`Data type is not shared: ${key}`, key); + return null; + } + + async getWeight(uid: number): Promise { + return this.latestMetric(uid, "weight", (await this.getLatestData(uid)).weight); + } + + async getGoal(uid: number): Promise { + return this.latestMetric(uid, "goal", (await this.getLatestData(uid)).goal); + } + + async getBloodPressure(uid: number): Promise { + return this.latestMetric(uid, "blood_pressure", (await this.getLatestData(uid)).blood_pressure); + } + + async getCalories(uid: number): Promise { + return this.latestMetric(uid, "calories", (await this.getLatestData(uid)).calories); + } + + async getValidStand(uid: number): Promise { + return this.latestMetric(uid, "valid_stand", (await this.getLatestData(uid)).valid_stand); + } + + async getIntensity(uid: number): Promise { + return this.latestMetric(uid, "intensity", (await this.getLatestData(uid)).intensity); + } + + async getSpo2(uid: number): Promise { + return this.latestMetric(uid, "spo2", (await this.getLatestData(uid)).spo2); + } + + async getCaloriesHistory( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "calories", start, end, limit)).map((item) => { + const value = parseAggregated(item, "calories"); + return { time: numberValue(value.time), calories: numberValue(value.calories), goal: numberValue(value.goal) }; + }); + } + + async getValidStandHistory( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "valid_stand", start, end, limit)).map((item) => { + const value = parseAggregated(item, "valid_stand"); + return { time: numberValue(value.time), count: numberValue(value.count) }; + }); + } + + async getIntensityHistory( + uid: number, + queryDateOrDays: Date | number = 1, + options: { days?: number } = {}, + ): Promise { + const [queryDate, days] = windowArguments(queryDateOrDays, options.days); + const [start, end, limit] = dateWindow(days, queryDate); + return (await this.getAggregatedData(uid, "intensity", start, end, limit)).map((item) => { + const value = parseAggregated(item, "intensity"); + return { time: numberValue(value.time), duration: numberValue(value.duration) }; + }); + } + + async getDailySummary(uid: number, queryDate = new Date()): Promise { + const [heartRate, sleep, steps] = await Promise.all([ + this.getHeartRate(uid, queryDate) + .then((items) => items[0] ?? null) + .catch((error) => { + if (error instanceof DataNotSharedError) return null; + throw error; + }), + this.getSleep(uid, queryDate) + .then((items) => items[0] ?? null) + .catch((error) => { + if (error instanceof DataNotSharedError) return null; + throw error; + }), + this.getSteps(uid, queryDate) + .then((items) => items[0] ?? null) + .catch((error) => { + if (error instanceof DataNotSharedError) return null; + throw error; + }), + ]); + return { date: queryDate.toISOString().slice(0, 10), relative_uid: uid, heart_rate: heartRate, sleep, steps }; + } + + async getLatestDailySummary(uid: number): Promise { + const member = await this.findRelative(uid); + const queryDate = member.latest_data_time ? new Date(member.latest_data_time * 1000) : new Date(); + return this.getDailySummary(uid, queryDate); + } + + async getRelatives(): Promise { + const response = await this.request("GET", "/app/v1/relatives/get_relative_list"); + const result = asObject(response.result); + return (Array.isArray(result.relative_list) ? result.relative_list : []).map((item) => { + const value = asObject(item); + return { + relative_uid: numberValue(value.relative_uid), + relative_note: stringValue(value.relative_note), + relative_icon: stringValue(value.relative_icon), + latest_data_time: numberValue(value.latest_data_time), + latest_abnormal_record_time: numberValue(value.latest_abnormal_record_time), + source_tag: numberValue(value.source_tag), + }; + }); + } + + async findRelative(keyword: string | number): Promise { + const relatives = await this.getRelatives(); + const match = relatives.find((member) => + typeof keyword === "number" + ? member.relative_uid === keyword + : member.relative_note.toLowerCase().includes(keyword.toLowerCase()), + ); + if (!match) throw new FamilyMemberNotFoundError(`Relative not found: ${keyword}`); + return match; + } + + async verifyUser(verifyId: number, verifyType = 1): Promise { + const response = await this.request("GET", "/app/v1/relatives/verify_userinfo_by_id", { + verify_id: verifyId, + verify_type: verifyType, + }); + const value = asObject(response.result); + if (!value.userId) return null; + return { user_id: numberValue(value.userId), nickname: stringValue(value.nickname), icon: stringValue(value.icon) }; + } + + private authContent(sharedDataTypes: string[] | undefined, authTimeRange: number): JsonObject { + return { + auth_time_range: authTimeRange, + auth_data: sharedDataTypes ?? [ + "goal", + "heart_rate", + "sleep", + "blood_pressure", + "steps", + "calories", + "valid_stand", + "intensity", + "weight", + "spo2", + ], + }; + } + + async inviteRelative( + relativeUid: number, + options: { sharedDataTypes?: string[]; authTimeRange?: number; relativeNote?: string } = {}, + ): Promise { + const params: JsonObject = { + auth_content: this.authContent(options.sharedDataTypes, options.authTimeRange ?? 3), + relative_uid: relativeUid, + }; + if (options.relativeNote) params.relative_note = options.relativeNote; + const result = asObject((await this.request("POST", "/app/v1/relatives/send_invite", params)).result); + return numberValue(result.send_ret) === 1; + } + + async acceptInvite( + inviteId: number, + msgId: number, + options: { sharedDataTypes?: string[]; authTimeRange?: number } = {}, + ): Promise { + return this.operateInvite(inviteId, msgId, 1, options); + } + + async rejectInvite(inviteId: number, msgId: number): Promise { + return this.operateInvite(inviteId, msgId, 2); + } + + private async operateInvite( + inviteId: number, + msgId: number, + operate: number, + options: { sharedDataTypes?: string[]; authTimeRange?: number } = {}, + ): Promise { + const result = asObject( + ( + await this.request("POST", "/app/v1/relatives/operate_invite", { + auth_content: this.authContent(options.sharedDataTypes, options.authTimeRange ?? 3), + invite_id: inviteId, + msg_id: msgId, + operate, + }) + ).result, + ); + return Boolean(numberValue(result.operate_ret)); + } + + async deleteRelative(relativeUid: number): Promise { + const result = asObject( + (await this.request("POST", "/app/v1/relatives/delete_relative", { relative_uid: relativeUid })).result, + ); + return Boolean(numberValue(result.delete_ret)); + } + + async getInviteLinkId(): Promise { + const result = asObject((await this.request("GET", "/app/v1/relatives/get_invite_unique_id")).result); + return numberValue(result.invite_link_id); + } + + async getSharedDataTypes(uid: number, direction = 2): Promise { + const response = await this.request("GET", "/app/v1/relatives/get_shared_data_types", { + relative_uid: uid, + type: direction, + }); + const result = asObject(response.result); + return Array.isArray(result.keys) ? result.keys.filter((value): value is string => typeof value === "string") : []; + } + + async getAppliedSharedDataTypes(uid: number): Promise { + const result = asObject( + (await this.request("GET", "/app/v1/relatives/get_applied_shared_data_types", { relative_uid: uid })).result, + ); + return Array.isArray(result.keys) ? result.keys.filter((value): value is string => typeof value === "string") : []; + } + + async getFamilyMembers(): Promise { + const result = asObject((await this.request("GET", "/app/v1/relatives/get_family_member")).result); + return Array.isArray(result.family_user_list) ? result.family_user_list.map(asObject) : []; + } + + async getTopicSubscriptions(uid: number, topics = ["abnormal_event"]): Promise { + return asObject( + (await this.request("GET", "/app/v1/relatives/get_topic_subscriptions", { relative_uid: uid, topics })).result, + ); + } + + async getInviteMessages(options: { limit?: number; pendingOnly?: boolean } = {}): Promise { + const result = asObject( + ( + await this.request("POST", "/app/v1/message/get_msg_list", { + module: 1, + limit: options.limit ?? 30, + }) + ).result, + ); + const messages = Array.isArray(result.messages) ? result.messages : []; + return messages + .map((item) => { + const value = asObject(item); + const rawExtra = value.extra_data; + const extra = typeof rawExtra === "string" ? parseValue(rawExtra) : asObject(rawExtra); + const type = numberValue(value.type); + const dataStatus = numberValue(value.data_status); + return { + msg_id: numberValue(value.msg_id), + module: numberValue(value.module), + type, + receiver: numberValue(value.receiver), + sender: numberValue(value.sender), + extra_data: typeof rawExtra === "string" ? rawExtra : JSON.stringify(rawExtra ?? {}), + is_new: numberValue(value.is_new), + data_status: dataStatus, + create_time: numberValue(value.create_time), + last_modify: numberValue(value.last_modify), + invite_id: extra.invite_id === undefined ? null : numberValue(extra.invite_id), + nick_name: stringValue(extra.nick_name), + icon: stringValue(extra.icon), + is_pending: type === 1 && dataStatus === 0, + }; + }) + .filter((message) => !options.pendingOnly || message.is_pending); + } + + async hasNewInvite(): Promise { + const result = ( + await this.request("POST", "/app/v1/message/check_new_msg", { + module: [1], + begin_time: 0, + }) + ).result; + return ( + Array.isArray(result) && + result.some((item) => { + const value = asObject(item); + return numberValue(value.module) === 1 && Boolean(numberValue(value.is_new)); + }) + ); + } + + async getFdsDownloadInfo(uid: number, timestamp: number, timezone: number): Promise { + const tz = Math.abs(timezone) <= 96 ? Math.trunc(timezone) : Math.trunc(timezone / 900); + const key = Buffer.alloc(6); + key.writeUInt32LE(timestamp, 0); + key.writeInt8(tz, 4); + key.writeUInt8((8 << 2) + 0, 5); + const suffix = `${urlSafeBase64(key)}_${urlSafeBase64(createHash("sha1").update(String(uid)).digest())}`; + const response = await this.request("GET", "/healthapp/service/gen_download_url", { + did: String(uid), + relative_uid: uid, + items: [{ timestamp, suffix }], + }); + const result = asObject(response.result); + return asObject(result[`${suffix}_${timestamp}`]); + } + + async close(): Promise {} + + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +export function parseMetric(item: AggregatedDataItem, key: string): number { + return numberValue(parseAggregated(item, key)[key === "stress" ? "stress" : key]); +} + +export function parseMetricObject(item: AggregatedDataItem): JsonObject { + return parseValue(item.value); +} + +export function formatXiaomiError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function decodeFdsAes(content: Uint8Array, objectKey: string): Uint8Array { + const key = bytes(objectKey); + const encoded = Buffer.from(content).toString("utf8").trim(); + const encrypted = encoded && /^[A-Za-z0-9_+/=-]+$/.test(encoded) ? bytes(encoded) : content; + const decipher = createDecipheriv("aes-128-cbc", key, Buffer.from("1234567887654321", "utf8")); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +} diff --git a/src/xiaomi/fds.ts b/src/xiaomi/fds.ts new file mode 100644 index 0000000..440f741 --- /dev/null +++ b/src/xiaomi/fds.ts @@ -0,0 +1,192 @@ +import { gunzipSync, inflateSync } from "node:zlib"; +import { decodeFdsAes, type MiHealthClient } from "./client.js"; + +const VALID_TYPES = [0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5]; +export const FDS_SLEEP_DAILY_TYPE = 8; +export const FDS_ALL_DAY_FILE_TYPE = 0; +export const TIMEZONE_15MIN_LIMIT = 96; +export const SECONDS_PER_15_MINUTES = 900; + +export function normalizeTimezoneTo15Min(value: number): number { + return Math.abs(value) <= TIMEZONE_15MIN_LIMIT ? Math.trunc(value) : Math.trunc(value / SECONDS_PER_15_MINUTES); +} + +export function genDataIdKeyBytes( + timestamp: number, + timezoneIn15Min: number, + dailyType: number, + fileType: number, + dataType = 0, + sportType = 0, +): Uint8Array { + const key = Buffer.alloc(6); + key.writeUInt32LE(timestamp, 0); + key.writeInt8(timezoneIn15Min, 4); + key.writeUInt8((dataType << 7) + (sportType << 2) + (dailyType << 2) + fileType, 5); + return key; +} + +function view(payload: Uint8Array): DataView { + return new DataView(payload.buffer, payload.byteOffset, payload.byteLength); +} + +function readSleepAssistInfo( + payload: Uint8Array, + position: number, + byteCount: number, + float: boolean, + unsigned: boolean, + version: number, +): [{ start_time: number; interval: number; record_count: number; values: Array } | null, number] { + if (position + 4 > payload.length) return [null, position]; + const data = view(payload); + const interval = data.getInt16(position, true); + const recordCount = data.getInt16(position + 2, true); + position += 4; + if (recordCount <= 0) return [null, position]; + const total = byteCount * recordCount + (version >= 2 ? 4 : 0); + if (position + total > payload.length) return [null, position]; + let startTime = 0; + if (version >= 2) { + startTime = data.getUint32(position, true); + position += 4; + } + const values: Array = []; + for (let index = 0; index < recordCount; index += 1) { + if (byteCount === 1) values.push(payload[position] ?? 0); + else if (byteCount === 2) values.push(unsigned ? data.getUint16(position, true) : data.getInt16(position, true)); + else if (byteCount === 4) + values.push( + float + ? data.getFloat32(position, true) + : unsigned + ? data.getUint32(position, true) + : data.getInt32(position, true), + ); + else values.push(payload.slice(position, position + byteCount)); + position += byteCount; + } + return [{ start_time: startTime, interval, record_count: recordCount, values }, position]; +} + +export type ParsedSleepDetails = { + report: Record; + records: { heart_rate: Array<[number, number]>; spo2: Array<[number, number]> }; +}; + +export function parseAllDaySleepBytes(payload: Uint8Array): ParsedSleepDetails | null { + if (payload.length < 9) return null; + try { + const version = payload[5] ?? 0; + const validBytes = payload.slice(7, 9); + const valid = new Map(); + for (const [index, type] of VALID_TYPES.entries()) { + valid.set(type, ((validBytes[Math.floor(index / 8)] ?? 0) & (1 << (7 - (index % 8)))) !== 0); + } + let position = 9; + const report: Record = { + sleepFinish: payload[position] === 1, + deviceBedTime: view(payload).getUint32(position + 1, true), + deviceWakeupTime: view(payload).getUint32(position + 5, true), + }; + position += 9; + const quality = payload[position] ?? 0; + if (valid.get(2)) report.sleepQuality = quality; + position += 1; + const efficiency = payload[position] ?? 0; + if (valid.get(6)) report.sleepEfficiency = efficiency; + position += 1; + const data = view(payload); + const entrySleepDuration = data.getUint32(position, true); + if (valid.get(7)) report.entrySleepDuration = entrySleepDuration; + position += 4; + const linBedDuration = data.getUint32(position, true); + if (valid.get(8)) report.linBedDuration = linBedDuration; + position += 4; + const goBedTime = data.getUint32(position, true); + if (valid.get(9)) report.goBedTime = goBedTime; + position += 4; + const leaveBedTime = data.getUint32(position, true); + if (valid.get(10)) report.leaveBedTime = leaveBedTime; + position += 4; + const records: ParsedSleepDetails["records"] = { heart_rate: [], spo2: [] }; + if (valid.get(3)) { + const [heartRate, next] = readSleepAssistInfo(payload, position, 1, false, false, version); + position = next; + if (heartRate) + heartRate.values.forEach((value, index) => { + if (typeof value === "number" && value > 0 && value < 255) + records.heart_rate.push([heartRate.start_time + index * heartRate.interval, value]); + }); + } + if (valid.get(4)) { + const [spo2] = readSleepAssistInfo(payload, position, 1, false, false, version); + if (spo2) + spo2.values.forEach((value, index) => { + if (typeof value === "number" && value > 0 && value <= 100) + records.spo2.push([spo2.start_time + index * spo2.interval, value]); + }); + } + return { report, records }; + } catch { + return null; + } +} + +export async function downloadAndDecryptSleepDetails( + client: MiHealthClient, + uid: number, + timestamp: number, + timezone: number, + logFn: (message: string) => void, +): Promise { + const fileInfo = await client.getFdsDownloadInfo(uid, timestamp, timezone); + if (!fileInfo) return null; + const url = typeof fileInfo.url === "string" ? fileInfo.url : ""; + if (!url) return null; + const response = await fetch(url); + if (!response.ok) { + logFn(`FDS download returned HTTP ${response.status}`); + return null; + } + const content = new Uint8Array(await response.arrayBuffer()); + const objectKey = typeof fileInfo.obj_key === "string" ? fileInfo.obj_key : ""; + if (objectKey) { + try { + return decodeFdsAes(content, objectKey); + } catch (error) { + logFn(`FDS AES decryption failed: ${String(error)}`); + return null; + } + } + if (content[0] === 0x1f && content[1] === 0x8b) return new Uint8Array(gunzipSync(content)); + if (content[0] === 0x78 && (content[1] === 0x9c || content[1] === 0x01)) return new Uint8Array(inflateSync(content)); + return content; +} + +export function androidBase64UrlSafe(value: string | Uint8Array): Uint8Array { + const text = typeof value === "string" ? value : new TextDecoder().decode(value); + const normalized = text.trim().replaceAll("\n", "").replaceAll("\r", ""); + return new Uint8Array(Buffer.from(normalized + "=".repeat((4 - (normalized.length % 4)) % 4), "base64url")); +} + +export function decompressOrRawFdsContent( + content: Uint8Array, + logFn: (message: string) => void = () => undefined, +): Uint8Array { + try { + if (content[0] === 0x1f && content[1] === 0x8b) { + const decompressed = new Uint8Array(gunzipSync(content)); + logFn(`Successfully decompressed GZIP FDS content. Length: ${decompressed.length}`); + return decompressed; + } + if (content[0] === 0x78 && (content[1] === 0x9c || content[1] === 0x01)) { + const decompressed = new Uint8Array(inflateSync(content)); + logFn(`Successfully decompressed ZLIB FDS content. Length: ${decompressed.length}`); + return decompressed; + } + } catch (error) { + logFn(`FDS decompression failed: ${String(error)}`); + } + return content; +} diff --git a/tests/bot-ui.test.ts b/tests/bot-ui.test.ts new file mode 100644 index 0000000..86198b8 --- /dev/null +++ b/tests/bot-ui.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createBot } from "../src/bot/app.js"; +import type { Locale } from "../src/bot/i18n.js"; +import { type AppConfig, loadConfig } from "../src/config.js"; +import { initHealthDb, initStateDb, setUserLocale, withHealthDb } from "../src/storage/health.js"; + +type ApiCall = { method: string; payload: Record }; +type KeyboardButton = { text: string; callback_data?: string }; + +function callbackUpdate(data: string): Parameters["handleUpdate"]>[0] { + return { + update_id: Date.now(), + callback_query: { + id: `query-${Date.now()}`, + data, + from: { id: 42, is_bot: false, first_name: "Test" }, + chat_instance: "test", + message: { + message_id: 99, + date: Math.floor(Date.now() / 1000), + chat: { id: 42, type: "private", first_name: "Test" }, + text: "menu", + }, + }, + }; +} + +function configFor(dir: string): AppConfig { + return loadConfig({ + TELEGRAM_BOT_TOKEN: "123:abc", + TELEGRAM_ALLOWED_USER_IDS: "42,43", + DATA_DIR: dir, + }); +} + +async function runCallback( + data: string, + language: Locale = "en", + seed?: (config: AppConfig) => void, +): Promise { + const dir = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-ui-")); + try { + const config = configFor(dir); + writeFileSync(join(dir, "token_42.json"), "{}"); + initStateDb(config.botStateDbPath); + setUserLocale(config, 42, language); + initHealthDb(join(dir, "miband_42.db")); + initHealthDb(join(dir, "miband_43.db")); + seed?.(config); + const calls: ApiCall[] = []; + const bot = createBot(config); + bot.botInfo = { id: 999, is_bot: true, first_name: "Test", username: "test_bot" } as never; + bot.api.config.use(async (_previous, method, payload) => { + calls.push({ method: String(method), payload: payload as Record }); + return { ok: true, result: true } as never; + }); + await bot.handleUpdate(callbackUpdate(data)); + return calls; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function editCall(calls: ApiCall[]): ApiCall { + const call = calls.find((item) => item.method === "editMessageText"); + if (!call) throw new Error("editMessageText call was not made"); + return call; +} + +function rows(call: ApiCall): KeyboardButton[][] { + const markup = call.payload.reply_markup as { inline_keyboard: KeyboardButton[][] }; + return markup.inline_keyboard; +} + +function button(rowsToRead: KeyboardButton[][], text: string): KeyboardButton { + const found = rowsToRead.flat().find((item) => item.text === text); + if (!found) throw new Error(`Button not found: ${text}`); + return found; +} + +describe("bot UI callback parity", () => { + test("opens versus from its legacy menu callback", async () => { + const call = editCall(await runCallback("menu:versus")); + expect(call.payload.text).toBe("📊 Compare activity:"); + expect(button(rows(call), "Today").callback_data).toBe("versus:1"); + expect(button(rows(call), "📊 Weekly").callback_data).toBe("versus:7"); + }); + + test("preserves the family return destination", async () => { + const weekly = editCall(await runCallback("menu:family:weekly")); + expect(button(rows(weekly), "⬅️ Back").callback_data).toBe("menu:weekly_back"); + + const trends = editCall(await runCallback("menu:family:trends")); + expect(button(rows(trends), "⬅️ Back").callback_data).toBe("menu:trends"); + + const weeklyBack = editCall(await runCallback("menu:weekly_back")); + expect(String(weeklyBack.payload.text)).toContain("📊 Weekly summary:"); + expect(button(rows(weeklyBack), "👪 Family").callback_data).toBe("menu:family:weekly"); + }); + + test("marks the selected trends period", async () => { + const call = editCall(await runCallback("period:30d")); + expect(button(rows(call), "· 30 days ·").callback_data).toBe("period:30d"); + expect(button(rows(call), "All time").callback_data).toBe("period:all"); + }); + + test("uses English as the default main keyboard", async () => { + const call = editCall(await runCallback("menu:main")); + expect(button(rows(call), "😴 Sleep").callback_data).toBe("menu:sleep"); + expect(button(rows(call), "📊 Weekly").callback_data).toBe("menu:trends"); + expect(button(rows(call), "⚙️ Settings").callback_data).toBe("menu:more"); + }); + + test("offers Russian and Spanish in the language settings", async () => { + const languageMenu = editCall(await runCallback("menu:language")); + expect(button(rows(languageMenu), "Русский").callback_data).toBe("locale:ru"); + expect(button(rows(languageMenu), "Español").callback_data).toBe("locale:es"); + + const spanish = editCall(await runCallback("locale:es")); + expect(button(rows(spanish), "· Español ·").callback_data).toBe("locale:es"); + const spanishMain = editCall(await runCallback("menu:main", "es")); + expect(button(rows(spanishMain), "😴 Sueño").callback_data).toBe("menu:sleep"); + }); + + test("renders Spanish trend labels", async () => { + const call = editCall(await runCallback("menu:trends", "es")); + expect(String(call.payload.text)).toContain("📊 Tendencias"); + expect(button(rows(call), "30 días").callback_data).toBe("period:30d"); + expect(button(rows(call), "👪 Familia").callback_data).toBe("menu:family:trends"); + }); + + test("does not append /100 to an unavailable sleep score", async () => { + const call = editCall( + await runCallback("menu:sleep", "ru", (config) => { + withHealthDb(config, 42, (database) => { + database + .prepare( + "INSERT INTO sleep_daily (date,light_sleep_min,deep_sleep_min,start_time,end_time,total_duration_min,sleep_score) VALUES (?,?,?,?,?,?,?)", + ) + .run("2026-08-12", 120, 120, 1_755_000_000, 1_755_025_200, 240, 0); + }); + }), + ); + expect(String(call.payload.text)).toContain("Качество н/д"); + expect(String(call.payload.text)).not.toContain("Качество н/д / 100"); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..ca343ef --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { ConfigurationError, loadConfig } from "../src/config.js"; + +describe("loadConfig", () => { + test("keeps http-only mode usable without Telegram credentials", () => { + const config = loadConfig({ BOT_MODE: "http-only", DATABASE_URL: ":memory:", QUERY_DURATION: "2" }); + expect(config.BOT_MODE).toBe("http-only"); + expect(config.QUERY_DURATION).toBe(2); + expect(config.dataDir).toContain("data"); + }); + + test("parses the personal allowlist", () => { + const config = loadConfig({ TELEGRAM_BOT_TOKEN: "123:abc", TELEGRAM_ALLOWED_USER_IDS: "42, 7" }); + expect(config.allowedUserIds).toEqual([42, 7]); + }); + + test("rejects malformed IDs and incomplete webhook settings", () => { + expect(() => loadConfig({ TELEGRAM_BOT_TOKEN: "123:abc", TELEGRAM_ALLOWED_USER_IDS: "42,nope" })).toThrow( + ConfigurationError, + ); + expect(() => + loadConfig({ TELEGRAM_BOT_TOKEN: "123:abc", BOT_MODE: "webhook", TELEGRAM_ALLOWED_USER_IDS: "42" }), + ).toThrow(ConfigurationError); + }); + + test("treats empty strings as unset", () => { + expect(loadConfig({ BOT_MODE: "http-only", TELEGRAM_BOT_TOKEN: "" }).TELEGRAM_BOT_TOKEN).toBeUndefined(); + }); + + test("parses textual booleans instead of coercing false to true", () => { + const config = loadConfig({ BOT_MODE: "http-only", ENABLE_FDS_SLEEP_DETAILS: "false" }); + expect(config.ENABLE_FDS_SLEEP_DETAILS).toBe(false); + }); +}); diff --git a/tests/fds.test.ts b/tests/fds.test.ts new file mode 100644 index 0000000..ee4264b --- /dev/null +++ b/tests/fds.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test"; +import { parseAllDaySleepBytes } from "../src/xiaomi/fds.js"; + +describe("FDS parser", () => { + test("rejects truncated sleep payloads", () => { + expect(parseAllDaySleepBytes(new Uint8Array([0, 0, 0, 0, 0, 1, 0, 0, 0]))).toBeNull(); + }); +}); diff --git a/tests/formatting.test.ts b/tests/formatting.test.ts new file mode 100644 index 0000000..3ce228b --- /dev/null +++ b/tests/formatting.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { esc, minutes, stepBar } from "../src/bot/formatting.js"; + +describe("bot formatting", () => { + test("escapes Telegram HTML and formats health values", () => { + expect(esc(' & "value"')).toBe("<secret> & "value""); + expect(minutes(396)).toBe("6 h 36 min"); + expect(stepBar(5000)).toContain("50%"); + }); +}); diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..c2ef62b --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { initHealthDb, zipExport } from "../src/storage/health.js"; +import { openDatabase } from "../src/storage/kv.js"; + +function tempDir(): string { + return mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-ts-")); +} + +describe("health storage", () => { + test("creates the health schema and exports CSV files as ZIP", async () => { + const dir = tempDir(); + const dbPath = join(dir, "miband_42.db"); + initHealthDb(dbPath); + const database = openDatabase(dbPath); + database.sqlite.query("INSERT INTO steps_daily (date,total_steps) VALUES (?,?)").run("2026-08-11", 1234); + database.close(); + const config = { + dataDir: dir, + dbPath, + statusPath: join(dir, "status.json"), + botStateDbPath: join(dir, "state.db"), + allowedUserIds: [42], + BOT_MODE: "http-only", + QUERY_DURATION: 2, + } as never; + const archive = await zipExport(config, 42); + expect(archive).not.toBeNull(); + expect(new Uint8Array(archive ?? []).length).toBeGreaterThan(0); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/secure-files.test.ts b/tests/secure-files.test.ts new file mode 100644 index 0000000..f897a63 --- /dev/null +++ b/tests/secure-files.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { withExclusiveFileLock } from "../src/storage/secure-files.js"; + +describe("file locks", () => { + test("releases the lock directory after the action", async () => { + const directory = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "miband-lock-")); + const path = join(directory, "sync.lock"); + await withExclusiveFileLock(path, async () => "first"); + await expect(withExclusiveFileLock(path, async () => "second")).resolves.toBe("second"); + rmSync(directory, { recursive: true, force: true }); + }); +}); diff --git a/tests/test_bot_ui.py b/tests/test_bot_ui.py deleted file mode 100644 index 9aaeca4..0000000 --- a/tests/test_bot_ui.py +++ /dev/null @@ -1,286 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -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 - - -class FakeUser: - id = 123 - - -class FakeUpdate: - effective_user = FakeUser() - message = object() - 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( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - settings = _settings(tmp_path) - - 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())) - - keyboard = bot_app.more_keyboard() - labels = [ - button.text - for row in keyboard.inline_keyboard - for button in row - ] - rendered = "\n".join([*labels, bot_app.more_text()]) - - assert "Принять приглашения" not in rendered - assert "Приглашения" not in rendered - assert "втор" not in rendered.lower() - - -@pytest.mark.asyncio -async def test_cmd_start_without_token_shows_onboarding(monkeypatch: pytest.MonkeyPatch) -> None: - update = FakeUpdate() - context = object() - show_onboarding = AsyncMock() - show_main_menu = AsyncMock() - - monkeypatch.setattr(bot_app, "is_allowed", lambda _: True) - monkeypatch.setattr(bot_app, "has_xiaomi_token", lambda: False) - monkeypatch.setattr(bot_app, "safe_delete", AsyncMock()) - monkeypatch.setattr(bot_app, "show_onboarding", show_onboarding) - monkeypatch.setattr(bot_app, "show_main_menu", show_main_menu) - - await bot_app.cmd_start(update, context) - - show_onboarding.assert_awaited_once_with(update, context, force_new=True) - show_main_menu.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - update = FakeUpdate() - context = object() - token_path = tmp_path / "token_123.json" - - class FakeToken: - def model_dump(self): - return { - "user_id": "456", - "c_user_id": "", - "service_token": "service", - "ssecurity": "sec", - "pass_token": "pass", - "device_id": "device", - } - - class FakeAuth: - async def login_qr(self, *, qr_callback, max_wait): - await qr_callback("https://example.test/qr.png", "https://example.test/login") - return FakeToken() - - async def close(self): - return None - - settings = _settings(tmp_path) - run_sync = AsyncMock(return_value=SyncResult(True, user_id=123)) - show_main_menu = AsyncMock() - - monkeypatch.setattr(bot_app, "SETTINGS", settings) - monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123) - monkeypatch.setattr(bot_app, "AUTH_LOCK", asyncio.Lock()) - monkeypatch.setattr(bot_app, "SYNC_LOCK", asyncio.Lock()) - monkeypatch.setattr(bot_app, "XiaomiAuth", FakeAuth) - monkeypatch.setattr(bot_app, "update_menu", AsyncMock()) - monkeypatch.setattr(bot_app, "run_sync", run_sync) - monkeypatch.setattr(bot_app, "show_main_menu", show_main_menu) - - await bot_app.start_xiaomi_login(update, context) - - data = json.loads(token_path.read_text(encoding="utf-8")) - assert data["user_id"] == "456" - assert data["service_token"] == "service" - run_sync.assert_awaited_once() - call_args = run_sync.await_args[0] - assert call_args[0] == 123 - assert call_args[1].query_duration == 30 - 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(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - settings = _settings(tmp_path) - - 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())) - - 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_config.py b/tests/test_config.py deleted file mode 100644 index 047c8ec..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,76 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from pathlib import Path - -import pytest - -from miband_tracker.config import ConfigError, Settings, parse_user_ids - - -def test_parse_user_ids_accepts_multiple_values() -> None: - assert parse_user_ids("1,2", required=True) == [1, 2] - assert parse_user_ids("1", required=True) == [1] - - -def test_parse_user_ids_rejects_invalid_values() -> None: - with pytest.raises(ConfigError): - parse_user_ids("1,invalid", required=True) - - -def test_settings_user_paths_prefer_existing_user_files(tmp_path: Path) -> None: - user_id = 123 - (tmp_path / f"miband_{user_id}.db").write_text("", encoding="utf-8") - (tmp_path / f"status_{user_id}.json").write_text("{}", encoding="utf-8") - (tmp_path / f"token_{user_id}.json").write_text("{}", encoding="utf-8") - - 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_ids=[user_id], - sync_interval=900, - query_duration=2, - enable_fds_sleep_details=True, - ) - - assert settings.telegram_allowed_user_id == user_id - assert settings.user_db_path() == tmp_path / f"miband_{user_id}.db" - assert settings.user_status_path() == tmp_path / f"status_{user_id}.json" - assert settings.token_path() == tmp_path / f"token_{user_id}.json" - - -def test_settings_falls_back_to_legacy_db_and_status(tmp_path: Path) -> 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_ids=[123], - sync_interval=900, - query_duration=2, - enable_fds_sleep_details=True, - ) - - assert settings.user_db_path() == tmp_path / "miband.db" - assert settings.user_status_path() == tmp_path / "status.json" - assert settings.canonical_user_db_path() == tmp_path / "miband_123.db" - - -def test_settings_from_env_rejects_invalid_interval(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("TELEGRAM_ALLOWED_USER_IDS", "123") - monkeypatch.setenv("SYNC_INTERVAL", "soon") - - with pytest.raises(ConfigError, match="SYNC_INTERVAL"): - Settings.from_env() - - -def test_settings_from_env_rejects_zero_query_duration(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("TELEGRAM_ALLOWED_USER_IDS", "123") - monkeypatch.setenv("QUERY_DURATION", "0") - - with pytest.raises(ConfigError, match="QUERY_DURATION"): - Settings.from_env() diff --git a/tests/test_dynamic_whitelist.py b/tests/test_dynamic_whitelist.py deleted file mode 100644 index 1dd3460..0000000 --- a/tests/test_dynamic_whitelist.py +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import os -from pathlib import Path -from unittest.mock import MagicMock - -from telegram import Update, User - -from miband_tracker.bot import app -from miband_tracker.config import Settings - - -def test_dynamic_whitelisting(tmp_path: Path) -> None: - # Setup temporary directories and config - data_dir = tmp_path / "data" - data_dir.mkdir() - - # Override settings in app and os.environ - os.environ["DATA_DIR"] = str(data_dir) - os.environ["TELEGRAM_ALLOWED_USER_ID"] = "" - - app.SETTINGS = Settings.from_env() - app.ALLOWED_USER_ID = None - - # Verify initial state - assert app.ALLOWED_USER_ID is None - assert not (data_dir / "allowed_user.id").exists() - - # 1. First user tries to access - user_1 = MagicMock(spec=User) - user_1.id = 999999 - update_1 = MagicMock(spec=Update) - update_1.effective_user = user_1 - - # First access should be allowed and should bind user_1 - assert app.is_allowed(update_1) is True - assert app.ALLOWED_USER_ID == 999999 - assert (data_dir / "allowed_user.id").exists() - assert (data_dir / "allowed_user.id").read_text(encoding="utf-8").strip() == "999999" - - # 2. Second user tries to access - user_2 = MagicMock(spec=User) - user_2.id = 888888 - update_2 = MagicMock(spec=Update) - update_2.effective_user = user_2 - - # Access for user_2 must be blocked - assert app.is_allowed(update_2) is False - - # 3. First user accesses again - assert app.is_allowed(update_1) is True - - # 4. Verify settings reloading - reloaded_settings = Settings.from_env() - assert reloaded_settings.telegram_allowed_user_id == 999999 diff --git a/tests/test_fds.py b/tests/test_fds.py deleted file mode 100644 index ae0e49e..0000000 --- a/tests/test_fds.py +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -import struct - -from miband_tracker.fds import parse_all_day_sleep_bytes - - -def test_parse_all_day_sleep_bytes_reads_hr_and_spo2_records() -> None: - blob = bytearray() - blob += struct.pack(" None: - for blob in (b"", bytes(9), bytes(10), bytes(20)): - assert parse_all_day_sleep_bytes(blob) is None diff --git a/tests/test_lock.py b/tests/test_lock.py deleted file mode 100644 index a7424d2..0000000 --- a/tests/test_lock.py +++ /dev/null @@ -1,38 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -import pytest - -from miband_tracker.lock import LockUnavailable, exclusive_file_lock - - -def test_exclusive_file_lock_rejects_second_process(tmp_path: Path) -> None: - lock_path = tmp_path / "sync.lock" - script = ( - "import sys, time\n" - "from pathlib import Path\n" - "from miband_tracker.lock import exclusive_file_lock\n" - "with exclusive_file_lock(Path(sys.argv[1])):\n" - " print('ready', flush=True)\n" - " time.sleep(5)\n" - ) - proc = subprocess.Popen( - [sys.executable, "-c", script, str(lock_path)], - stdout=subprocess.PIPE, - text=True, - ) - try: - assert proc.stdout is not None - assert proc.stdout.readline().strip() == "ready" - with pytest.raises(LockUnavailable): - with exclusive_file_lock(lock_path): - pass - finally: - proc.terminate() - proc.wait(timeout=5) diff --git a/tests/test_secure_files.py b/tests/test_secure_files.py deleted file mode 100644 index 3395598..0000000 --- a/tests/test_secure_files.py +++ /dev/null @@ -1,35 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -import json -import os -from pathlib import Path - -from mi_fitness.models import AuthToken - -from miband_tracker.secure_files import save_auth_token, write_secret_json - - -def test_write_secret_json_uses_private_file_mode(tmp_path: Path) -> None: - path = tmp_path / "token.json" - - write_secret_json(path, {"service_token": "secret"}) - - assert json.loads(path.read_text(encoding="utf-8")) == {"service_token": "secret"} - assert stat_mode(path) == 0o600 - - -def test_save_auth_token_preserves_target_relative_uid(tmp_path: Path) -> None: - path = tmp_path / "token.json" - write_secret_json(path, {"target_relative_uid": "42"}) - - save_auth_token(AuthToken(user_id="11", service_token="svc", ssecurity="sec"), path) - - data = json.loads(path.read_text(encoding="utf-8")) - assert data["user_id"] == "11" - assert data["target_relative_uid"] == "42" - assert stat_mode(path) == 0o600 - - -def stat_mode(path: Path) -> int: - return os.stat(path).st_mode & 0o777 diff --git a/tests/test_stdio.py b/tests/test_stdio.py deleted file mode 100644 index 0fcdeec..0000000 --- a/tests/test_stdio.py +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from __future__ import annotations - -import io - -from miband_tracker.stdio import safe_print - - -def test_safe_print_does_not_crash_on_non_ascii_with_charmap_stream() -> None: - raw = io.BytesIO() - stream = io.TextIOWrapper(raw, encoding="cp1251", errors="strict") - - safe_print("API 业务错误", file=stream, flush=True) - stream.flush() - - assert b"API " in raw.getvalue() - assert b"\\u4e1a" in raw.getvalue() diff --git a/tests/test_storage.py b/tests/test_storage.py deleted file mode 100644 index 9bf0be5..0000000 --- a/tests/test_storage.py +++ /dev/null @@ -1,114 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from pathlib import Path - -from miband_tracker import storage -from miband_tracker.config import Settings - - -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_init_health_db_is_idempotent(tmp_path: Path) -> None: - db_path = tmp_path / "miband_123.db" - - storage.init_health_db(db_path) - storage.init_health_db(db_path) - - with storage.sqlite_conn(db_path) as conn: - tables = { - 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", - "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: - 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-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 deleted file mode 100644 index 0a3f32a..0000000 --- a/tests/test_sync.py +++ /dev/null @@ -1,77 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# Copyright (C) 2026 Alexey - -from pathlib import Path - -import pytest - -from miband_tracker.config import Settings -from miband_tracker.storage import init_health_db, sqlite_conn -from miband_tracker.sync import _sync_workouts, run_sync - - -@pytest.mark.asyncio -async def test_run_sync_missing_token_returns_failed_result(tmp_path: Path) -> 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=0, - query_duration=2, - enable_fds_sleep_details=True, - ) - - result = await run_sync(settings=settings) - - 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 diff --git a/tests/xiaomi-crypto.test.ts b/tests/xiaomi-crypto.test.ts new file mode 100644 index 0000000..6464f24 --- /dev/null +++ b/tests/xiaomi-crypto.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { buildEncryptedParams, computeSignedNonce, decryptData, encryptData } from "../src/xiaomi/client.js"; + +describe("Xiaomi crypto", () => { + test("round-trips encrypted payloads and request params", () => { + const ssecurity = Buffer.from("test-ssecurity-key").toString("base64"); + const nonce = Buffer.from("123456789012").toString("base64"); + const signed = computeSignedNonce(ssecurity, nonce); + expect(decryptData(signed, encryptData(signed, '{"中文":"тест"}'))).toBe('{"中文":"тест"}'); + const params = buildEncryptedParams("POST", "/test", ssecurity, { test: "hello", num: 42 }); + expect(JSON.parse(decryptData(computeSignedNonce(ssecurity, params._nonce ?? ""), params.data ?? ""))).toEqual({ + test: "hello", + num: 42, + }); + expect(params.signature).toBeString(); + expect(params.rc4_hash__).toBeString(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4e445b6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "types": ["bun"], + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +}