From a36abd7fdd788d3c624f415343c3b6bc84a680da Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 24 May 2026 21:53:17 +0300 Subject: [PATCH] docs: add OSS project docs and CI --- .github/workflows/ci.yml | 60 ++++++ .gitignore | 1 + AUTHORS.md | 11 + CHANGELOG.md | 23 +++ CONTRIBUTING.md | 54 +++++ README.md | 173 ++++++++++++++-- README_EN.md | 173 ++++++++++++++-- SECURITY.md | 44 ++++ VENDORED.md | 31 +++ fitness_bot.py | 4 +- miband_sync.py | 3 + miband_tracker/__init__.py | 3 + miband_tracker/bot/__init__.py | 3 + miband_tracker/bot/app.py | 14 +- miband_tracker/config.py | 5 +- miband_tracker/fds.py | 359 ++++++++++++++++++--------------- miband_tracker/lock.py | 3 + miband_tracker/secure_files.py | 4 +- miband_tracker/storage.py | 3 + miband_tracker/sync.py | 5 +- pyproject.toml | 69 ++++++- secrets.env.example | 25 +++ tests/test_bot_ui.py | 5 +- tests/test_config.py | 3 + tests/test_fds.py | 3 + tests/test_lock.py | 3 + tests/test_secure_files.py | 3 + tests/test_storage.py | 3 + tests/test_sync.py | 3 + 29 files changed, 872 insertions(+), 221 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 AUTHORS.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 VENDORED.md create mode 100644 secrets.env.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6a1600 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} + 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 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + mi-fitness-python/pyproject.toml + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.txt -e mi-fitness-python + + - 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 + + docker: + name: Docker build + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build image + run: docker compose build diff --git a/.gitignore b/.gitignore index 94ea91e..e4fab16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .venv/ __pycache__/ +*.egg-info/ .pytest_cache/ .ruff_cache/ .mypy_cache/ diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..902423d --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,11 @@ +# 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 new file mode 100644 index 0000000..14c9330 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# 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 new file mode 100644 index 0000000..b9d0c1d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# 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/README.md b/README.md index 9930138..7a42fbe 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,138 @@ Русский | [English](README_EN.md) -Однопользовательский сервис синхронизации Xiaomi Fitness и Telegram-бот. +Личный Telegram-бот для данных Xiaomi Fitness / Mi Band. -Бот сохраняет данные здоровья из Xiaomi Fitness в SQLite и показывает в Telegram меню с шагами, сном, пульсом, SpO2, аналитикой, ручной синхронизацией и экспортом CSV. +Он сам забирает шаги, сон, пульс и SpO2 из Xiaomi Fitness, складывает их в локальную SQLite-базу и показывает понятное меню в Telegram. Идея простая: данные с браслета остаются у вас на сервере, а Telegram становится удобной кнопкой «посмотреть здоровье за сегодня», «обновить вручную» или «выгрузить CSV». -## Первый запуск +Проект рассчитан на одного владельца. Это не публичный бот для многих пользователей и не медицинский сервис. -1. Создайте `secrets.env` с переменными ниже. -2. Запустите Docker Compose. -3. Откройте бота в Telegram и отправьте `/start`. -4. Бот покажет кнопку входа в Xiaomi. Подтвердите вход, после этого бот сохранит `data/token_.json`, запустит первую синхронизацию и откроет главное меню. +## Что умеет -```env -TELEGRAM_BOT_TOKEN=123456:telegram-token -TELEGRAM_ALLOWED_USER_ID=123456789 -SYNC_INTERVAL=900 -QUERY_DURATION=2 -ENABLE_FDS_SLEEP_DETAILS=true +- Показывает последние шаги, сон, пульс и SpO2 в Telegram. +- Запускает ручную синхронизацию кнопкой в меню. +- Автоматически синхронизирует данные по расписанию. +- Хранит историю в SQLite в папке `data/`. +- Экспортирует накопленные таблицы в ZIP с CSV-файлами. +- Работает через Docker Compose. +- Пишет Xiaomi token атомарно с правами `0600`. + +## Кому подходит + +Проект подойдет, если вы: + +- пользуетесь Xiaomi Fitness / Mi Band; +- хотите видеть свои данные в Telegram; +- готовы запустить маленький self-hosted сервис; +- понимаете, что неофициальные API могут сломаться после изменений Xiaomi. + +Проект не подойдет, если нужен многопользовательский SaaS, медицинская точность, гарантия совместимости со всеми браслетами или официальный Xiaomi API. + +## Важно про reverse engineering + +`miband-bot` - неофициальный проект. Он не связан с Xiaomi, Zepp, Huami, Telegram или их партнерами. + +Доступ к данным Xiaomi Fitness сделан через reverse engineering неофициальных API. Это значит: + +- Xiaomi может изменить API без предупреждения; +- вход или синхронизация могут временно перестать работать; +- используйте проект только для своих аккаунтов и своих данных; +- соблюдайте применимые законы и условия сервисов в вашей стране; +- данные браслета не являются медицинским заключением. + +## Как это работает + +```text +Mi Band -> Xiaomi Fitness cloud -> miband-bot -> SQLite -> Telegram menu / CSV export ``` -## Docker +В Docker Compose запускаются два процесса: + +- `tracker` - периодически синхронизирует данные из Xiaomi Fitness; +- `fitness-bot` - отвечает в Telegram, показывает меню, запускает ручной sync и экспорт. + +Оба процесса используют одну папку `./data`. Запись защищена файловым lock, поэтому фоновая и ручная синхронизация не пишут в SQLite/token одновременно. + +## Что понадобится + +- Сервер или домашняя машина с Docker и Docker Compose. +- Telegram bot token от [@BotFather](https://t.me/BotFather). +- Ваш Telegram user id. +- Xiaomi аккаунт, в котором видны данные Xiaomi Fitness. + +## Быстрый запуск + +1. Скопируйте пример секретов: + +```sh +cp secrets.env.example secrets.env +``` + +2. Заполните минимум эти переменные: + +```env +TELEGRAM_BOT_TOKEN=123456:replace-me +TELEGRAM_ALLOWED_USER_ID=123456789 +``` + +3. Запустите сервис: ```sh docker compose up -d --build docker compose logs -f fitness-bot ``` -Оба сервиса используют общую папку `./data`. Синхронизация защищена файловым lock в этой папке, поэтому ручной sync из Telegram и daemon не пишут в SQLite/token одновременно. +4. Откройте своего Telegram-бота и отправьте `/start`. -## Локальные проверки +5. Бот покажет кнопку входа в Xiaomi. Подтвердите вход по ссылке/QR. После этого бот сохранит `data/token_.json`, запустит первую синхронизацию и откроет главное меню. + +## Настройки + +Основные переменные лежат в `secrets.env`: + +```env +TELEGRAM_BOT_TOKEN=123456:replace-me +TELEGRAM_ALLOWED_USER_ID=123456789 +SYNC_INTERVAL=900 +QUERY_DURATION=2 +ENABLE_FDS_SLEEP_DETAILS=true +``` + +- `TELEGRAM_BOT_TOKEN` - token вашего Telegram-бота. +- `TELEGRAM_ALLOWED_USER_ID` - единственный Telegram user id, которому разрешен доступ. +- `SYNC_INTERVAL` - интервал фоновой синхронизации в секундах. `900` = 15 минут. +- `QUERY_DURATION` - сколько последних дней запрашивать при sync. +- `ENABLE_FDS_SLEEP_DETAILS` - пробовать ли загружать детальные ночные данные FDS. + +Пути к базе и статусу уже заданы в `compose.yaml`. Если запускаете без Docker, смотрите `secrets.env.example`. + +## Где лежат данные + +Runtime-файлы создаются в `./data`: + +- `token_.json` - Xiaomi auth token, секретный файл; +- `miband_.db` - SQLite-база с health-данными; +- `status_.json` - последний статус синхронизации; +- `fitness_bot_state.db` - служебное состояние Telegram-меню; +- `sync_.lock` - lock-файл синхронизации. + +Не коммитьте `secrets.env`, `data/`, `*.db`, `token*.json` и `status*.json`. Эти файлы уже добавлены в `.gitignore`. + +## Команды бота + +- `/start` - открыть меню или начать вход в Xiaomi. +- `/sync` - запустить ручную синхронизацию. +- `/status` - показать состояние локальной базы. + +Основное управление происходит кнопками в Telegram-меню. + +## Экспорт CSV + +В меню есть экспорт данных. Бот собирает ZIP с CSV-таблицами и отправляет его в Telegram. + +Помните: ZIP с health-данными уходит через инфраструктуру Telegram. Не отправляйте экспорт в чужие чаты и не храните его там, где доступ есть у других людей. + +## Локальная разработка ```sh python3 -m venv .venv @@ -42,17 +145,43 @@ python3 -m venv .venv .venv/bin/python -m pip check ``` -## Runtime - -Entrypoints сохранены для совместимости с Docker: +Entrypoints сохранены для Docker и локального запуска: ```sh python -u miband_sync.py python -u fitness_bot.py ``` -Секреты лежат в `secrets.env` и `data/token_.json`; не коммитьте их. Token-файлы записываются атомарно с правами `0600`. +Если проект установлен как Python package, доступны console scripts: -## Лицензия +```sh +miband-sync +miband-fitness-bot +``` -Проект распространяется под GNU GPL v3.0. Vendored SDK `mi-fitness-python` сохранён со своей GPL v3.0 лицензией в `mi-fitness-python/LICENSE`. +## Troubleshooting + +**Бот не отвечает.** +Проверьте `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_ID` и логи: + +```sh +docker compose logs -f fitness-bot +``` + +**Синхронизация пишет, что token не найден.** +Откройте бота в Telegram, отправьте `/start` и пройдите Xiaomi login flow. + +**Token истек.** +В меню запустите повторный вход в Xiaomi. Старый token можно удалить из `data/`. + +**Данных мало или нет SpO2/деталей сна.** +Проверьте, что Xiaomi Fitness реально показывает эти данные. Часть данных зависит от модели браслета, настроек шаринга и доступности неофициального API. + +**После обновления Xiaomi все сломалось.** +Это ожидаемый риск reverse-engineering проекта. Проверьте issues/README и логи, затем обновите код или временно отключите проблемную часть. + +## Лицензия и vendored SDK + +Проект распространяется под GNU GPL v3.0 or later. Полный текст лицензии лежит в [LICENSE](LICENSE). + +SDK `mi-fitness-python` хранится в репозитории как vendored source copy и остается под своей GNU GPL v3.0 лицензией: [mi-fitness-python/LICENSE](mi-fitness-python/LICENSE). Подробности о происхождении и политике обновления описаны в [VENDORED.md](VENDORED.md). diff --git a/README_EN.md b/README_EN.md index 351b174..deddd62 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,35 +2,138 @@ [Русский](README.md) | English -Single-user Xiaomi Fitness sync service and Telegram bot. +A personal Telegram bot for Xiaomi Fitness / Mi Band data. -The bot stores Xiaomi Fitness health data in SQLite and exposes a Telegram menu for recent steps, sleep, heart rate, SpO2, trends, manual sync and CSV export. +It fetches steps, sleep, heart rate and SpO2 from Xiaomi Fitness, stores the history in a local SQLite database, and shows a practical Telegram menu. The goal is simple: your band data stays on your own server, while Telegram becomes a convenient place to check today's health snapshot, run a manual sync or export CSV files. -## First Run +The project is designed for one owner. It is not a public multi-user bot and not a medical service. -1. Create `secrets.env` from the variables below. -2. Start Docker Compose. -3. Open the bot in Telegram and send `/start`. -4. The bot will show a Xiaomi login button. Confirm the login, then the bot saves `data/token_.json`, runs the first sync and opens the main menu. +## What It Does -```env -TELEGRAM_BOT_TOKEN=123456:telegram-token -TELEGRAM_ALLOWED_USER_ID=123456789 -SYNC_INTERVAL=900 -QUERY_DURATION=2 -ENABLE_FDS_SLEEP_DETAILS=true +- Shows recent steps, sleep, heart rate and SpO2 in Telegram. +- Runs manual sync from a Telegram button. +- Syncs data automatically on a schedule. +- Stores history in SQLite under `data/`. +- Exports accumulated tables as a ZIP with CSV files. +- Runs with Docker Compose. +- Writes the Xiaomi token atomically with mode `0600`. + +## Who It Is For + +This project is useful if you: + +- use Xiaomi Fitness / Mi Band; +- want to see your own data in Telegram; +- are comfortable running a small self-hosted service; +- understand that unofficial APIs can break when Xiaomi changes something. + +It is not a good fit if you need a multi-user SaaS, medical-grade accuracy, guaranteed compatibility with every band, or an official Xiaomi API. + +## Reverse Engineering Notice + +`miband-bot` is an unofficial project. It is not affiliated with Xiaomi, Zepp, Huami, Telegram or their partners. + +Xiaomi Fitness access is based on reverse engineering of unofficial APIs. This means: + +- Xiaomi can change the API without notice; +- login or sync can temporarily stop working; +- use the project only with your own accounts and your own data; +- follow applicable laws and service terms in your jurisdiction; +- wearable data is not a medical diagnosis. + +## How It Works + +```text +Mi Band -> Xiaomi Fitness cloud -> miband-bot -> SQLite -> Telegram menu / CSV export ``` -## Docker +Docker Compose starts two processes: + +- `tracker` - periodically syncs data from Xiaomi Fitness; +- `fitness-bot` - responds in Telegram, shows the menu, starts manual sync and exports data. + +Both processes share `./data`. Writes are protected by a file lock, so background sync and manual sync do not write SQLite/token files at the same time. + +## Requirements + +- A server or home machine with Docker and Docker Compose. +- A Telegram bot token from [@BotFather](https://t.me/BotFather). +- Your Telegram user id. +- A Xiaomi account with Xiaomi Fitness data. + +## Quick Start + +1. Copy the secrets template: + +```sh +cp secrets.env.example secrets.env +``` + +2. Fill at least these variables: + +```env +TELEGRAM_BOT_TOKEN=123456:replace-me +TELEGRAM_ALLOWED_USER_ID=123456789 +``` + +3. Start the service: ```sh docker compose up -d --build docker compose logs -f fitness-bot ``` -Both services share `./data`. Sync runs are guarded by a file lock in that directory, so manual sync from Telegram and the daemon do not write SQLite/token files at the same time. +4. Open your Telegram bot and send `/start`. -## Local Checks +5. The bot will show a Xiaomi login button. Confirm login through the link/QR flow. After that the bot saves `data/token_.json`, runs the first sync and opens the main menu. + +## Configuration + +Main variables live in `secrets.env`: + +```env +TELEGRAM_BOT_TOKEN=123456:replace-me +TELEGRAM_ALLOWED_USER_ID=123456789 +SYNC_INTERVAL=900 +QUERY_DURATION=2 +ENABLE_FDS_SLEEP_DETAILS=true +``` + +- `TELEGRAM_BOT_TOKEN` - your Telegram bot token. +- `TELEGRAM_ALLOWED_USER_ID` - the single Telegram user id allowed to access the bot. +- `SYNC_INTERVAL` - background sync interval in seconds. `900` = 15 minutes. +- `QUERY_DURATION` - how many recent days to query on each sync. +- `ENABLE_FDS_SLEEP_DETAILS` - whether to try fetching detailed FDS sleep data. + +Database and status paths are already set in `compose.yaml`. If you run without Docker, see `secrets.env.example`. + +## Where Data Lives + +Runtime files are created under `./data`: + +- `token_.json` - Xiaomi auth token, secret file; +- `miband_.db` - SQLite database with health data; +- `status_.json` - latest sync status; +- `fitness_bot_state.db` - Telegram menu state; +- `sync_.lock` - sync lock file. + +Do not commit `secrets.env`, `data/`, `*.db`, `token*.json` or `status*.json`. These files are already listed in `.gitignore`. + +## Bot Commands + +- `/start` - open the menu or start Xiaomi login. +- `/sync` - run manual sync. +- `/status` - show local database status. + +Most actions are done with buttons in the Telegram menu. + +## CSV Export + +The menu includes data export. The bot builds a ZIP with CSV tables and sends it through Telegram. + +Remember: the ZIP contains health data and travels through Telegram infrastructure. Do not send it to shared chats or store it where other people can access it. + +## Local Development ```sh python3 -m venv .venv @@ -42,17 +145,43 @@ python3 -m venv .venv .venv/bin/python -m pip check ``` -## Runtime - -Entrypoints kept for Docker compatibility: +Entrypoints are kept for Docker and local compatibility: ```sh python -u miband_sync.py python -u fitness_bot.py ``` -Secrets live in `secrets.env` and `data/token_.json`; do not commit them. Token files are written atomically with mode `0600`. +If installed as a Python package, console scripts are available: -## License +```sh +miband-sync +miband-fitness-bot +``` -This project is licensed under GNU GPL v3.0. The vendored `mi-fitness-python` SDK is kept under its own GPL v3.0 license in `mi-fitness-python/LICENSE`. +## Troubleshooting + +**The bot does not respond.** +Check `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_ID` and logs: + +```sh +docker compose logs -f fitness-bot +``` + +**Sync says token is missing.** +Open the bot in Telegram, send `/start` and complete the Xiaomi login flow. + +**The token expired.** +Run Xiaomi login again from the menu. You can remove the old token from `data/`. + +**Some data is missing or there is no SpO2/sleep detail.** +Check that Xiaomi Fitness itself shows that data. Some data depends on the band model, sharing settings and unofficial API availability. + +**Everything broke after a Xiaomi update.** +That is an expected risk for a reverse-engineering project. Check issues/README and logs, then update the code or temporarily disable the affected feature. + +## License And Vendored SDK + +This project is licensed under GNU GPL v3.0 or later. The full license text is in [LICENSE](LICENSE). + +The `mi-fitness-python` SDK is kept in this repository as a vendored source copy and remains under its own GNU GPL v3.0 license: [mi-fitness-python/LICENSE](mi-fitness-python/LICENSE). Origin and update policy are documented in [VENDORED.md](VENDORED.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..84efe8c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +`miband-bot` handles Telegram credentials, Xiaomi auth tokens and health data. Treat every runtime file as private. + +## Supported Scope + +Security reports are accepted for the current `main` branch. This is a small self-hosted project, so there are no formal long-term support branches. + +## Sensitive Files + +By default, Docker Compose stores runtime data under `./data`: + +- `secrets.env` contains the Telegram bot token and access configuration. +- `data/token_.json` contains Xiaomi auth credentials. +- `data/miband_.db` contains health history. +- `data/status_.json` contains latest sync state. +- `data/fitness_bot_state.db` contains Telegram menu state. + +These files must not be committed, uploaded to public issue trackers, or pasted into logs. Token files are written atomically with mode `0600`, but filesystem permissions are not a substitute for keeping the host private. + +## Telegram Export Privacy + +The CSV export feature sends a ZIP with health data through Telegram. Use it only in your own private chat with your bot. Do not forward exports to shared chats unless you intentionally want other people to access that data. + +## Rotating Secrets + +- Telegram bot token: revoke or regenerate it with [@BotFather](https://t.me/BotFather), then update `secrets.env` and restart Compose. +- Xiaomi token: delete `data/token_.json`, then open the bot and run the Xiaomi login flow again. +- Local health data: stop Compose and remove the relevant `data/*.db`, `data/status*.json` and `data/token*.json` files. + +## Reporting A Vulnerability + +Open a private report if the hosting platform supports it, or contact the maintainer directly before publishing details. Do not include real tokens, Telegram user ids, Xiaomi account ids, database dumps or health exports in the report. + +Useful safe context includes: + +- project commit; +- Python and Docker versions; +- sanitized logs with tokens removed; +- exact steps to reproduce using placeholder credentials. + +## Reverse Engineering Notice + +This project uses unofficial Xiaomi Fitness APIs discovered through reverse engineering. Do not use it to access accounts or data that you do not own or have explicit permission to use. diff --git a/VENDORED.md b/VENDORED.md new file mode 100644 index 0000000..3285803 --- /dev/null +++ b/VENDORED.md @@ -0,0 +1,31 @@ +# 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/fitness_bot.py b/fitness_bot.py index d4222cb..f56e0f0 100644 --- a/fitness_bot.py +++ b/fitness_bot.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations from miband_tracker.bot.app import main - if __name__ == "__main__": main() diff --git a/miband_sync.py b/miband_sync.py index a73e9ff..2c05f4b 100644 --- a/miband_sync.py +++ b/miband_sync.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import asyncio diff --git a/miband_tracker/__init__.py b/miband_tracker/__init__.py index 23bfc5a..be9728c 100644 --- a/miband_tracker/__init__.py +++ b/miband_tracker/__init__.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + """Mi Band tracker service package.""" from .config import Settings diff --git a/miband_tracker/bot/__init__.py b/miband_tracker/bot/__init__.py index dbf7deb..c6b5da9 100644 --- a/miband_tracker/bot/__init__.py +++ b/miband_tracker/bot/__init__.py @@ -1 +1,4 @@ +# 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 index e8cdb42..bbf51c6 100644 --- a/miband_tracker/bot/app.py +++ b/miband_tracker/bot/app.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -from __future__ import annotations +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey -from functools import wraps +from __future__ import annotations import asyncio import html @@ -10,11 +11,15 @@ import logging import sqlite3 import sys import time -from datetime import date, datetime, time as dt_time, timedelta +from datetime import date, datetime, timedelta +from datetime import time as dt_time +from functools import wraps from pathlib import Path from zoneinfo import ZoneInfo +from mi_fitness.auth import XiaomiAuth from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update +from telegram.error import RetryAfter, TelegramError from telegram.ext import ( Application, CallbackQueryHandler, @@ -23,9 +28,6 @@ from telegram.ext import ( MessageHandler, filters, ) -from telegram.error import RetryAfter, TelegramError - -from mi_fitness.auth import XiaomiAuth from miband_tracker import storage from miband_tracker.config import ConfigError, Settings diff --git a/miband_tracker/config.py b/miband_tracker/config.py index 5baf8e6..de384e5 100644 --- a/miband_tracker/config.py +++ b/miband_tracker/config.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import os @@ -40,7 +43,7 @@ class Settings: enable_fds_sleep_details: bool @classmethod - def from_env(cls, *, require_bot: bool = False) -> "Settings": + def from_env(cls, *, require_bot: bool = False) -> Settings: data_dir = Path(os.environ.get("DATA_DIR", "/opt/miband-tracker/data")) allowed_user_id = parse_single_user_id( os.environ.get("TELEGRAM_ALLOWED_USER_ID", ""), required=require_bot diff --git a/miband_tracker/fds.py b/miband_tracker/fds.py index 66ce2e6..3a1e5ed 100644 --- a/miband_tracker/fds.py +++ b/miband_tracker/fds.py @@ -1,213 +1,240 @@ +# 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) <= 96: + if abs(timezone_value) <= TIMEZONE_15MIN_LIMIT: return int(timezone_value) - return int(timezone_value / 900) + 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: + +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(" len(b): + +def parse_sleep_assist_info( + payload: bytes, + pos: int, + byte_count: int, + is_float: bool, + is_unsigned: bool, + version: int, +) -> 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(b): + + 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(b) + return _parse_all_day_sleep_bytes(payload) except (IndexError, struct.error): return None -def _parse_all_day_sleep_bytes(b: bytes): - _ = struct.unpack_from(" dict[str, Any]: + _ = struct.unpack_from(" 0 - valid_map[t] = val - i += 1 - + for index, valid_type in enumerate(SLEEP_VALID_TYPES): + byte_idx = index // 8 + bit_idx = index % 8 + valid_map[valid_type] = (data_valid[byte_idx] & (1 << (7 - bit_idx))) > 0 + pos = 9 - report_data = { - "sleepFinish": bool(b[pos] == 1) - } + report_data = {"sleepFinish": payload[pos] == 1} pos += 1 - - # type=0 (deviceBedTime) - report_data["deviceBedTime"] = struct.unpack_from(" 0 and val < 255: - records["heart_rate"].append((start_t + idx * interval, val)) - - # type 4: spo2 - if valid_map[4]: - spo2_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version) - if spo2_data: - start_t = spo2_data["start_time"] - interval = spo2_data["interval"] - for idx, val in enumerate(spo2_data["values"]): - if val > 0 and val <= 100: - records["spo2"].append((start_t + idx * interval, val)) - - return { - "report": report_data, - "records": records + "spo2": [], } -async def download_and_decrypt_sleep_details(client, relative_uid: int, timestamp: int, timezone_value: int, log_fn=print): + if valid_map[3]: + hr_data, pos = parse_sleep_assist_info(payload, pos, 1, False, False, version) + if hr_data: + start_time = int(hr_data["start_time"]) + interval = int(hr_data["interval"]) + for index, value in enumerate(hr_data["values"]): + if isinstance(value, int) and 0 < value < 255: + records["heart_rate"].append((start_time + index * interval, value)) + + if valid_map[4]: + spo2_data, pos = parse_sleep_assist_info(payload, pos, 1, False, False, version) + if spo2_data: + start_time = int(spo2_data["start_time"]) + interval = int(spo2_data["interval"]) + for index, value in enumerate(spo2_data["values"]): + if isinstance(value, int) and 0 < value <= 100: + records["spo2"].append((start_time + index * interval, value)) + + return { + "report": report_data, + "records": records, + } + + +async def download_and_decrypt_sleep_details( + client: Any, + relative_uid: int, + timestamp: int, + timezone_value: int, + log_fn: Callable[[str], object] = print, +) -> bytes | None: tz_in_15min = normalize_timezone_to_15min(timezone_value) - - # 1. Generate FDSItem + sid = str(relative_uid) - key_bytes = gen_data_id_key_bytes(timestamp, tz_in_15min, daily_type=8, file_type=0) + 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}" - - # 2. Prepare request params + param_dict = { "did": sid, "relative_uid": relative_uid, "items": [ { "timestamp": timestamp, - "suffix": suffix + "suffix": suffix, } - ] + ], } - - # 3. Call service/gen_download_url + resp = await client._request( "GET", "/healthapp/service/gen_download_url", - params=param_dict + params=param_dict, ) - + result = resp.get("result", {}) log_fn( "gen_download_url returned " @@ -219,68 +246,70 @@ async def download_and_decrypt_sleep_details(client, relative_uid: int, timestam 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 - - # 4. Download file + 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)}") - - # 5. Decrypt or decompress content - def android_base64_urlsafe(s): - if isinstance(s, bytes): - s = s.decode("utf-8", "ignore") - s = s.strip().replace("\n", "").replace("\r", "") - s += "=" * (-len(s) % 4) - return base64.urlsafe_b64decode(s) - + 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) != 16: + + 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, b"1234567887654321") + + cipher = AES.new(obj_key_bytes, AES.MODE_CBC, XIAOMI_FDS_AES_IV) decrypted = cipher.decrypt(encrypted_bytes) - decrypted = unpad(decrypted, 16) - return decrypted - except Exception as e: - log_fn(f"AES decryption or unpadding failed: {e}") + return unpad(decrypted, AES.block_size) + except Exception as exc: + log_fn(f"AES decryption or unpadding failed: {exc}") return None - else: - log_fn("No obj_key in file_info. Checking if content is compressed (gzip/zlib) or raw...") - # Check for GZIP header (1f 8b) - if enc_content.startswith(b"\x1f\x8b"): - try: - import gzip - decompressed = gzip.decompress(enc_content) - log_fn(f"Successfully decompressed GZIP FDS content. Length: {len(decompressed)}") - return decompressed - except Exception as e: - log_fn(f"Failed to decompress GZIP FDS content: {e}") - # Check for ZLIB header (78 9c or 78 01 or 78 5e etc.) - elif enc_content.startswith(b"\x78\x9c") or enc_content.startswith(b"\x78\x01"): - try: - import zlib - decompressed = zlib.decompress(enc_content) - log_fn(f"Successfully decompressed ZLIB FDS content. Length: {len(decompressed)}") - return decompressed - except Exception as e: - log_fn(f"Failed to decompress ZLIB FDS content: {e}") - - # If not compressed, return as is - return enc_content + + 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 index 80235ae..c7d6f6b 100644 --- a/miband_tracker/lock.py +++ b/miband_tracker/lock.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import fcntl diff --git a/miband_tracker/secure_files.py b/miband_tracker/secure_files.py index 44c8bb5..9eeb6a2 100644 --- a/miband_tracker/secure_files.py +++ b/miband_tracker/secure_files.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import json @@ -6,7 +9,6 @@ import tempfile from pathlib import Path from typing import Any - SECRET_FILE_MODE = 0o600 diff --git a/miband_tracker/storage.py b/miband_tracker/storage.py index 90aa5f6..5cc3af3 100644 --- a/miband_tracker/storage.py +++ b/miband_tracker/storage.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import csv diff --git a/miband_tracker/sync.py b/miband_tracker/sync.py index dd0822f..250b0df 100644 --- a/miband_tracker/sync.py +++ b/miband_tracker/sync.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import asyncio @@ -25,7 +28,7 @@ class SyncResult: error: str | None = None @classmethod - def failed(cls, message: str, *, user_id: int | None = None) -> "SyncResult": + def failed(cls, message: str, *, user_id: int | None = None) -> SyncResult: return cls(False, user_id=user_id, error=message) diff --git a/pyproject.toml b/pyproject.toml index 62e6a50..ee2a1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,64 @@ +[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" @@ -8,4 +69,10 @@ extend-exclude = [ ] [tool.ruff.lint] -select = ["E4", "E7", "E9", "F"] +select = ["E", "W", "F", "I", "B", "UP", "C4", "RUF"] +ignore = [ + "E501", + "RUF001", + "RUF002", + "RUF003", +] diff --git a/secrets.env.example b/secrets.env.example new file mode 100644 index 0000000..f44370c --- /dev/null +++ b/secrets.env.example @@ -0,0 +1,25 @@ +# 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/tests/test_bot_ui.py b/tests/test_bot_ui.py index 8f47044..a845900 100644 --- a/tests/test_bot_ui.py +++ b/tests/test_bot_ui.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + import asyncio import json from pathlib import Path @@ -27,7 +30,7 @@ def test_service_menu_does_not_expose_invites_or_second_user() -> None: for row in keyboard.inline_keyboard for button in row ] - rendered = "\n".join(labels + [bot_app.more_text()]) + rendered = "\n".join([*labels, bot_app.more_text()]) assert "Принять приглашения" not in rendered assert "Приглашения" not in rendered diff --git a/tests/test_config.py b/tests/test_config.py index 7a604e0..c0d9c4e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from pathlib import Path import pytest diff --git a/tests/test_fds.py b/tests/test_fds.py index 50da182..ae0e49e 100644 --- a/tests/test_fds.py +++ b/tests/test_fds.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + import struct from miband_tracker.fds import parse_all_day_sleep_bytes diff --git a/tests/test_lock.py b/tests/test_lock.py index 73474b3..a7424d2 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from __future__ import annotations import subprocess diff --git a/tests/test_secure_files.py b/tests/test_secure_files.py index 2c0c67c..3395598 100644 --- a/tests/test_secure_files.py +++ b/tests/test_secure_files.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + import json import os from pathlib import Path diff --git a/tests/test_storage.py b/tests/test_storage.py index 1e0d868..12cca70 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from pathlib import Path from miband_tracker import storage diff --git a/tests/test_sync.py b/tests/test_sync.py index 6b090f0..ce6eee0 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Alexey + from pathlib import Path import pytest