docs: add OSS project docs and CI

This commit is contained in:
Alex
2026-05-24 21:53:17 +03:00
parent c56d9f7e41
commit a36abd7fdd
29 changed files with 872 additions and 221 deletions
+60
View File
@@ -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
+1
View File
@@ -1,5 +1,6 @@
.venv/
__pycache__/
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
+11
View File
@@ -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.
+23
View File
@@ -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.
+54
View File
@@ -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.
+151 -22
View File
@@ -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_<telegram_user_id>.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_<telegram_user_id>.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_<telegram_user_id>.json` - Xiaomi auth token, секретный файл;
- `miband_<telegram_user_id>.db` - SQLite-база с health-данными;
- `status_<telegram_user_id>.json` - последний статус синхронизации;
- `fitness_bot_state.db` - служебное состояние Telegram-меню;
- `sync_<telegram_user_id>.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_<telegram_user_id>.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).
+151 -22
View File
@@ -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_<telegram_user_id>.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_<telegram_user_id>.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_<telegram_user_id>.json` - Xiaomi auth token, secret file;
- `miband_<telegram_user_id>.db` - SQLite database with health data;
- `status_<telegram_user_id>.json` - latest sync status;
- `fitness_bot_state.db` - Telegram menu state;
- `sync_<telegram_user_id>.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_<telegram_user_id>.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).
+44
View File
@@ -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_<telegram_user_id>.json` contains Xiaomi auth credentials.
- `data/miband_<telegram_user_id>.db` contains health history.
- `data/status_<telegram_user_id>.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_<telegram_user_id>.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.
+31
View File
@@ -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 <xiao02600@gmail.com>` 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.
+3 -1
View File
@@ -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()
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -1 +1,4 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
"""Telegram bot package."""
+8 -6
View File
@@ -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
+4 -1
View File
@@ -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
+194 -165
View File
@@ -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("<IbB", timestamp, tz_in_15min, data_type_byte)
def parse_sleep_assist_info(b, pos, byte_count, is_float, is_unsigned, version):
if pos + 4 > 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("<h", b, pos)[0]
record_count = struct.unpack_from("<h", b, pos + 2)[0]
pos += 4
interval = struct.unpack_from("<h", payload, pos)[0]
record_count = struct.unpack_from("<h", payload, pos + 2)[0]
pos += SLEEP_ASSIST_HEADER_LEN
if record_count <= 0:
return None, pos
actual_byte_count = byte_count * record_count
if version >= 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("<I", b, pos)[0]
start_time = struct.unpack_from("<I", payload, pos)[0]
pos += 4
values = []
values: list[int | float | bytes] = []
for _ in range(record_count):
if byte_count == 1:
val = b[pos]
value = payload[pos]
pos += 1
elif byte_count == 2:
val = struct.unpack_from("<H" if is_unsigned else "<h", b, pos)[0]
value = struct.unpack_from("<H" if is_unsigned else "<h", payload, pos)[0]
pos += 2
elif byte_count == 4:
if is_float:
val = struct.unpack_from("<f", b, pos)[0]
value = struct.unpack_from("<f", payload, pos)[0]
else:
val = struct.unpack_from("<I" if is_unsigned else "<i", b, pos)[0]
value = struct.unpack_from("<I" if is_unsigned else "<i", payload, pos)[0]
pos += 4
else:
val = b[pos:pos+byte_count]
value = payload[pos : pos + byte_count]
pos += byte_count
values.append(val)
values.append(value)
return {
"start_time": start_time,
"interval": interval,
"record_count": record_count,
"values": values
"values": values,
}, pos
def parse_all_day_sleep_bytes(b: bytes):
if len(b) < 9:
def parse_all_day_sleep_bytes(payload: bytes) -> 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("<I", b, 0)[0]
_ = b[4]
version = b[5]
_ = b[6]
data_valid = b[7:9]
def _parse_all_day_sleep_bytes(payload: bytes) -> dict[str, Any]:
_ = struct.unpack_from("<I", payload, 0)[0]
_ = payload[4]
version = payload[5]
_ = payload[6]
data_valid = payload[7:9]
valid_map = {}
i = 0
types_to_check = [0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5]
for t in types_to_check:
byte_idx = i // 8
bit_idx = i % 8
val = (data_valid[byte_idx] & (1 << (7 - bit_idx))) > 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("<I", b, pos)[0]
report_data["deviceBedTime"] = struct.unpack_from("<I", payload, pos)[0]
pos += 4
# type=1 (deviceWakeupTime)
report_data["deviceWakeupTime"] = struct.unpack_from("<I", b, pos)[0]
report_data["deviceWakeupTime"] = struct.unpack_from("<I", payload, pos)[0]
pos += 4
# type=2 (sleepQuality)
val = b[pos]
value = payload[pos]
if valid_map[2]:
report_data["sleepQuality"] = val
report_data["sleepQuality"] = value
pos += 1
# type=6 (sleepEfficiency)
val = b[pos]
value = payload[pos]
if valid_map[6]:
report_data["sleepEfficiency"] = val
report_data["sleepEfficiency"] = value
pos += 1
# type=7 (entrySleepDuration)
val = struct.unpack_from("<I", b, pos)[0]
value = struct.unpack_from("<I", payload, pos)[0]
if valid_map[7]:
report_data["entrySleepDuration"] = val
report_data["entrySleepDuration"] = value
pos += 4
# type=8 (linBedDuration)
val = struct.unpack_from("<I", b, pos)[0]
value = struct.unpack_from("<I", payload, pos)[0]
if valid_map[8]:
report_data["linBedDuration"] = val
report_data["linBedDuration"] = value
pos += 4
# type=9 (goBedTime)
val = struct.unpack_from("<I", b, pos)[0]
value = struct.unpack_from("<I", payload, pos)[0]
if valid_map[9]:
report_data["goBedTime"] = val
report_data["goBedTime"] = value
pos += 4
# type=10 (leaveBedTime)
val = struct.unpack_from("<I", b, pos)[0]
value = struct.unpack_from("<I", payload, pos)[0]
if valid_map[10]:
report_data["leaveBedTime"] = val
report_data["leaveBedTime"] = value
pos += 4
records = {
records: dict[str, list[tuple[int, int | float | bytes]]] = {
"heart_rate": [],
"spo2": []
}
# type 3: hr
if valid_map[3]:
hr_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version)
if hr_data:
start_t = hr_data["start_time"]
interval = hr_data["interval"]
for idx, val in enumerate(hr_data["values"]):
if val > 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
+3
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import fcntl
+3 -1
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import csv
+4 -1
View File
@@ -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)
+68 -1
View File
@@ -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",
]
+25
View File
@@ -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=
+4 -1
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from pathlib import Path
import pytest
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from __future__ import annotations
import subprocess
+3
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
import json
import os
from pathlib import Path
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Alexey
from pathlib import Path
import pytest