mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-09-05 18:16:18 +03:00
rewrite bot in TypeScript and add locale switching
This commit is contained in:
+2
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+2
-7
@@ -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/
|
||||
|
||||
-11
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
-32
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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_<id>.json` | Xiaomi auth token (**секретный**) |
|
||||
| `miband_<id>.db` | SQLite-база с health-данными |
|
||||
| `status_<id>.json` | Последний статус синхронизации |
|
||||
| `allowed_user.id` | ID привязанного владельца |
|
||||
| `fitness_bot_state.db` | Служебное состояние Telegram-меню |
|
||||
| `sync_<id>.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).
|
||||
|
||||
@@ -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).
|
||||
-196
@@ -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_<id>.json` | Xiaomi auth token (**secret**) |
|
||||
| `miband_<id>.db` | SQLite database with health data |
|
||||
| `status_<id>.json` | Last sync status |
|
||||
| `allowed_user.id` | ID of the bound owner |
|
||||
| `fitness_bot_state.db` | Telegram menu internal state |
|
||||
| `sync_<id>.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).
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
-31
@@ -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 <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.
|
||||
@@ -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" } }
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+20
-26
@@ -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
|
||||
|
||||
@@ -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()
|
||||
-92
@@ -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
|
||||
-70
@@ -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/tty
|
||||
if [ -z "$overwrite_confirm" ]; then
|
||||
overwrite_confirm="y"
|
||||
fi
|
||||
if [[ ! "$overwrite_confirm" =~ ^[Yy]$ ]]; then
|
||||
echo -e "${RED}Установка отменена.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# 3. Скачиваем ZIP-архив с GitHub
|
||||
echo "Загрузка последней версии проекта с GitHub..."
|
||||
ZIP_URL="https://github.com/iAlexeyRu/miband-bot/archive/refs/heads/main.zip"
|
||||
TEMP_ZIP="miband-bot-temp.zip"
|
||||
|
||||
if command -v curl &> /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 </dev/tty
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/**"
|
||||
|
||||
jobs:
|
||||
ruff:
|
||||
name: Ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
version: latest
|
||||
|
||||
- name: Ruff check
|
||||
run: uvx ruff check --output-format=github src/ tests/unit/
|
||||
|
||||
- name: Ruff format check
|
||||
run: uvx ruff format --check src/ tests/unit/
|
||||
|
||||
basedpyright:
|
||||
name: BasedPyright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Static typing check
|
||||
run: uvx basedpyright src/ tests/unit/
|
||||
|
||||
test:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
concurrency:
|
||||
group: coverage-${{ github.ref }}-${{ matrix.python-version }}
|
||||
cancel-in-progress: true
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
env:
|
||||
PYTHON: ${{ matrix.python-version }}
|
||||
UV_NO_SYNC: 1
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Run Pytest
|
||||
run: uv run pytest tests/unit/ --cov=src --cov-report xml --junitxml=./junit.xml -n auto
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
env_vars: PYTHON
|
||||
use_oidc: true
|
||||
report_type: test_results
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
env_vars: PYTHON
|
||||
use_oidc: true
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
version: latest
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(uv version --short)" >> $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 }}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -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` | 找不到指定亲友 |
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
"""小米账号认证子包。
|
||||
|
||||
对外只暴露 ``XiaomiAuth``,内部按登录方式拆分为独立模块。
|
||||
"""
|
||||
|
||||
from mi_fitness.auth.manager import XiaomiAuth
|
||||
|
||||
__all__ = ["XiaomiAuth"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -1,5 +0,0 @@
|
||||
"""小米运动健康 API 客户端子包。"""
|
||||
|
||||
from mi_fitness.client.api import MiHealthClient
|
||||
|
||||
__all__ = ["MiHealthClient"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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", {})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
"""找不到指定的亲友。"""
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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="")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -1,4 +0,0 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Alexey
|
||||
|
||||
"""Telegram bot package."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"<code>[{bar}]</code> {percent}%"
|
||||
|
||||
|
||||
def step_goal_text(steps: int | float | None, goal: int = DEFAULT_STEP_GOAL) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if steps is None:
|
||||
return f"Goal {goal:,} steps".replace(",", " ") if is_en else f"Цель {goal:,} шагов".replace(",", " ")
|
||||
steps_int = int(steps)
|
||||
left = max(0, goal - steps_int)
|
||||
if left:
|
||||
text = f"Goal {goal:,} · {left:,} steps left" if is_en else f"Цель {goal:,} · осталось {left:,} шагов"
|
||||
else:
|
||||
text = f"Goal {goal:,} · daily goal achieved! 🎉" if is_en else f"Цель {goal:,} · дневная цель выполнена! 🎉"
|
||||
return text.replace(",", " ")
|
||||
|
||||
|
||||
def make_sparkline(values: list[float | int]) -> str:
|
||||
if not values:
|
||||
return ""
|
||||
sparks = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
val_range = max_val - min_val
|
||||
if val_range == 0:
|
||||
return sparks[4] * min(len(values), 20)
|
||||
sparkline = []
|
||||
for val in values:
|
||||
idx = int((val - min_val) / val_range * (len(sparks) - 1))
|
||||
sparkline.append(sparks[idx])
|
||||
return "".join(sparkline)
|
||||
|
||||
|
||||
def relative_day_label(day_str: str | None) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if not day_str:
|
||||
return "Last day" if is_en else "Последний день"
|
||||
try:
|
||||
day_value = parse_day(day_str)
|
||||
except ValueError:
|
||||
return f"Day: {day_str}" if is_en else f"День: {day_str}"
|
||||
today = datetime.now(LOCAL_TZ).date()
|
||||
if day_value == today:
|
||||
return "Today" if is_en else "Сегодня"
|
||||
if day_value == today - timedelta(days=1):
|
||||
return "Yesterday" if is_en else "Вчера"
|
||||
return day_str
|
||||
|
||||
|
||||
def day_bounds(day_value: date) -> tuple[int, int]:
|
||||
start = datetime.combine(day_value, dt_time.min, tzinfo=LOCAL_TZ)
|
||||
end = start + timedelta(days=1)
|
||||
return int(start.timestamp()), int(end.timestamp())
|
||||
|
||||
|
||||
def parse_day(day_str: str) -> date:
|
||||
return datetime.strptime(day_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def sleep_total(sleep) -> int:
|
||||
total = int(sleep["total_duration_min"]) if sleep["total_duration_min"] else 0
|
||||
if total > 0:
|
||||
return total
|
||||
return int(sleep["light_sleep_min"] or 0) + int(sleep["deep_sleep_min"] or 0)
|
||||
|
||||
|
||||
def sleep_quality_label(total_min: int, deep_min: int) -> str:
|
||||
is_en = os.getenv("BOT_LANG") == "en"
|
||||
if total_min >= 420 and deep_min >= 60:
|
||||
return "🟢 Excellent" if is_en else "🟢 Отличный"
|
||||
if total_min >= 360 and deep_min >= 40:
|
||||
return "🟡 Good" if is_en else "🟡 Хороший"
|
||||
if total_min >= 300:
|
||||
return "🟠 Average" if is_en else "🟠 Средний"
|
||||
return "🔴 Poor" if is_en else "🔴 Недостаточный"
|
||||
|
||||
|
||||
def workout_type_label(sport_type: str) -> str:
|
||||
return SPORT_TYPE_LABELS.get(sport_type, sport_type.replace("_", " ").title())
|
||||
@@ -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
|
||||
@@ -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("<IbB", timestamp, tz_in_15min, data_type_byte)
|
||||
|
||||
|
||||
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", 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(payload):
|
||||
return None, pos
|
||||
|
||||
start_time = 0
|
||||
if version >= 2:
|
||||
start_time = struct.unpack_from("<I", payload, pos)[0]
|
||||
pos += 4
|
||||
|
||||
values: list[int | float | bytes] = []
|
||||
for _ in range(record_count):
|
||||
if byte_count == 1:
|
||||
value = payload[pos]
|
||||
pos += 1
|
||||
elif byte_count == 2:
|
||||
value = struct.unpack_from("<H" if is_unsigned else "<h", payload, pos)[0]
|
||||
pos += 2
|
||||
elif byte_count == 4:
|
||||
if is_float:
|
||||
value = struct.unpack_from("<f", payload, pos)[0]
|
||||
else:
|
||||
value = struct.unpack_from("<I" if is_unsigned else "<i", payload, pos)[0]
|
||||
pos += 4
|
||||
else:
|
||||
value = payload[pos : pos + byte_count]
|
||||
pos += byte_count
|
||||
values.append(value)
|
||||
|
||||
return {
|
||||
"start_time": start_time,
|
||||
"interval": interval,
|
||||
"record_count": record_count,
|
||||
"values": values,
|
||||
}, pos
|
||||
|
||||
|
||||
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(payload)
|
||||
except (IndexError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
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 = {}
|
||||
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": payload[pos] == 1}
|
||||
pos += 1
|
||||
|
||||
report_data["deviceBedTime"] = struct.unpack_from("<I", payload, pos)[0]
|
||||
pos += 4
|
||||
|
||||
report_data["deviceWakeupTime"] = struct.unpack_from("<I", payload, pos)[0]
|
||||
pos += 4
|
||||
|
||||
value = payload[pos]
|
||||
if valid_map[2]:
|
||||
report_data["sleepQuality"] = value
|
||||
pos += 1
|
||||
|
||||
value = payload[pos]
|
||||
if valid_map[6]:
|
||||
report_data["sleepEfficiency"] = value
|
||||
pos += 1
|
||||
|
||||
value = struct.unpack_from("<I", payload, pos)[0]
|
||||
if valid_map[7]:
|
||||
report_data["entrySleepDuration"] = value
|
||||
pos += 4
|
||||
|
||||
value = struct.unpack_from("<I", payload, pos)[0]
|
||||
if valid_map[8]:
|
||||
report_data["linBedDuration"] = value
|
||||
pos += 4
|
||||
|
||||
value = struct.unpack_from("<I", payload, pos)[0]
|
||||
if valid_map[9]:
|
||||
report_data["goBedTime"] = value
|
||||
pos += 4
|
||||
|
||||
value = struct.unpack_from("<I", payload, pos)[0]
|
||||
if valid_map[10]:
|
||||
report_data["leaveBedTime"] = value
|
||||
pos += 4
|
||||
|
||||
records: dict[str, list[tuple[int, int | float | bytes]]] = {
|
||||
"heart_rate": [],
|
||||
"spo2": [],
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
@@ -1,4 +0,0 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==1.3.0
|
||||
ruff==0.8.4
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
+1262
File diff suppressed because it is too large
Load Diff
@@ -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<Locale, Record<string, string>> = {
|
||||
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<string, unknown>): 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 `<code>[${"█".repeat(blocks)}${"░".repeat(10 - blocks)}]</code> ${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<string, unknown> | null | undefined, key: string): number {
|
||||
return Number(row?.[key] ?? 0);
|
||||
}
|
||||
|
||||
export function rowString(row: Record<string, unknown> | null | undefined, key: string): string {
|
||||
return String(row?.[key] ?? "");
|
||||
}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
export const LOCALES = ["en", "ru", "es"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const LOCALE_NAMES: Record<Locale, string> = {
|
||||
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": "🧍 <b>Average activity</b>",
|
||||
"trends.energy": " Energy burned {value} kcal",
|
||||
"trends.average-sleep": "😴 <b>Average sleep</b> {value}",
|
||||
"trends.average-heart-rate": "❤️ <b>Average HR</b> {value}",
|
||||
"trends.average-oxygen": "🩸 <b>Average SpO2</b> {value}",
|
||||
"trends.average-stress": "🧘 <b>Average stress</b> {value}",
|
||||
"trends.latest-weight": "⚖️ <b>Latest weight</b> {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": "🏆 <b>Steps cup:</b> {value}",
|
||||
"family.sleep-cup": "🏆 <b>Sleep cup:</b> {value}",
|
||||
"family.goal": "👣 <b>Shared goal:</b>",
|
||||
"family.distance": "Together this week: {steps} steps ({distance} km) — that is {route}",
|
||||
"versus.title": "📊 Versus: {first} vs {second} ({period})",
|
||||
"versus.choose": "📊 <b>Compare activity:</b>",
|
||||
"versus.steps": "🚶 <b>Steps:</b>",
|
||||
"versus.sleep": "😴 <b>Sleep:</b>",
|
||||
"versus.bedtime": "⏰ <b>Bedtime:</b>",
|
||||
"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 <b>Mi Band</b>",
|
||||
"service.last-sync": "Last sync <b>{value}</b>",
|
||||
"service.interval": "Interval <b>{value} min</b>",
|
||||
"service.records": "Database rows <b>{value}</b>",
|
||||
"auth.title": "🔐 <b>Xiaomi authentication</b>",
|
||||
"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": "🔄 <b>Synchronization</b>",
|
||||
"sync.running": "Fetching data from Xiaomi Fitness…",
|
||||
"sync.done": "✅ <b>Synchronization</b>\n\nDone, data updated.",
|
||||
"sync.failed": "⚠️ <b>Synchronization</b>\n\nUpdate failed.\n\nReason: {error}",
|
||||
"export.running": "💾 <b>Export</b>\n\nBuilding ZIP…",
|
||||
"export.sent": "✅ <b>Export</b>\n\nZIP with CSV tables was sent above.",
|
||||
"export.empty": "💾 <b>Export</b>\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": "🚶 <b>Шаги всего</b> {value}",
|
||||
"trends.average-steps": " В среднем / день {value}",
|
||||
"trends.goal": " Норма 10k {done} из {total} дней",
|
||||
"trends.best": " 🏆 Лучший день {date} · {steps}",
|
||||
"trends.activity": "🧍 <b>Активность ср.</b>",
|
||||
"trends.energy": " Расход энергии {value} ккал",
|
||||
"trends.average-sleep": "😴 <b>Сон среднее</b> {value}",
|
||||
"trends.average-heart-rate": "❤️ <b>Пульс ср.</b> {value}",
|
||||
"trends.average-oxygen": "🩸 <b>SpO2 ср.</b> {value}",
|
||||
"trends.average-stress": "🧘 <b>Стресс ср.</b> {value}",
|
||||
"trends.latest-weight": "⚖️ <b>Вес (последний)</b> {value} кг",
|
||||
"family.title": "👪 Семейная статистика",
|
||||
"family.need-two": "Для семейной статистики нужно как минимум два пользователя в TELEGRAM_ALLOWED_USER_IDS.",
|
||||
"family.no-data": "👪 Нет данных",
|
||||
"family.steps-cup": "🏆 <b>Кубок шагов:</b> {value}",
|
||||
"family.sleep-cup": "🏆 <b>Кубок сна:</b> {value}",
|
||||
"family.goal": "👣 <b>Совместная цель:</b>",
|
||||
"family.distance": "Вместе за неделю вы прошли {steps} шагов ({distance} км) — это расстояние {route}",
|
||||
"versus.title": "📊 <b>Versus: {first} vs {second} ({period})</b>",
|
||||
"versus.choose": "📊 <b>Сравнить активность:</b>",
|
||||
"versus.steps": "🚶 <b>Шаги:</b>",
|
||||
"versus.sleep": "😴 <b>Сон:</b>",
|
||||
"versus.bedtime": "⏰ <b>Засыпание:</b>",
|
||||
"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": "Устройство <b>Mi Band</b>",
|
||||
"service.last-sync": "Последний синк <b>{value}</b>",
|
||||
"service.interval": "Интервал <b>{value} мин</b>",
|
||||
"service.records": "Записей в БД <b>{value}</b>",
|
||||
"auth.title": "🔐 <b>Авторизация Xiaomi</b>",
|
||||
"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": "🔄 <b>Синхронизация</b>",
|
||||
"sync.running": "Забираю данные из Xiaomi Fitness…",
|
||||
"sync.done": "✅ <b>Синхронизация</b>\n\nГотово, данные обновлены.",
|
||||
"sync.failed": "⚠️ <b>Синхронизация</b>\n\nНе вышло обновиться.\n\nПричина: {error}",
|
||||
"export.running": "💾 <b>Экспорт</b>\n\nСобираю ZIP…",
|
||||
"export.sent": "✅ <b>Экспорт</b>\n\nZIP с CSV-таблицами отправлен выше.",
|
||||
"export.empty": "💾 <b>Экспорт</b>\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": "🚶 <b>Pasos totales</b> {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": "🧍 <b>Actividad media</b>",
|
||||
"trends.energy": " Energía gastada {value} kcal",
|
||||
"trends.average-sleep": "😴 <b>Sueño medio</b> {value}",
|
||||
"trends.average-heart-rate": "❤️ <b>Pulso medio</b> {value}",
|
||||
"trends.average-oxygen": "🩸 <b>SpO2 media</b> {value}",
|
||||
"trends.average-stress": "🧘 <b>Estrés medio</b> {value}",
|
||||
"trends.latest-weight": "⚖️ <b>Último peso</b> {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": "🏆 <b>Copa de pasos:</b> {value}",
|
||||
"family.sleep-cup": "🏆 <b>Copa de sueño:</b> {value}",
|
||||
"family.goal": "👣 <b>Objetivo compartido:</b>",
|
||||
"family.distance": "Esta semana juntos: {steps} pasos ({distance} km) — eso equivale a {route}",
|
||||
"versus.title": "📊 <b>Versus: {first} vs {second} ({period})</b>",
|
||||
"versus.choose": "📊 <b>Comparar actividad:</b>",
|
||||
"versus.steps": "🚶 <b>Pasos:</b>",
|
||||
"versus.sleep": "😴 <b>Sueño:</b>",
|
||||
"versus.bedtime": "⏰ <b>Hora de acostarse:</b>",
|
||||
"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 <b>Mi Band</b>",
|
||||
"service.last-sync": "Última sincron. <b>{value}</b>",
|
||||
"service.interval": "Intervalo <b>{value} min</b>",
|
||||
"service.records": "Filas en la base <b>{value}</b>",
|
||||
"auth.title": "🔐 <b>Autenticación de Xiaomi</b>",
|
||||
"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": "🔄 <b>Sincronización</b>",
|
||||
"sync.running": "Obteniendo datos de Xiaomi Fitness…",
|
||||
"sync.done": "✅ <b>Sincronización</b>\n\nListo, los datos están actualizados.",
|
||||
"sync.failed": "⚠️ <b>Sincronización</b>\n\nNo se pudo actualizar.\n\nMotivo: {error}",
|
||||
"export.running": "💾 <b>Exportación</b>\n\nCreando ZIP…",
|
||||
"export.sent": "✅ <b>Exportación</b>\n\nEl ZIP con las tablas CSV se envió arriba.",
|
||||
"export.empty": "💾 <b>Exportación</b>\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, string | number> = {}): string {
|
||||
const template = catalog[locale][key] ?? catalog.en[key];
|
||||
return template.replace(/\{(\w+)\}/g, (_, name: string) => (name in params ? String(params[name]) : `{${name}}`));
|
||||
}
|
||||
+156
@@ -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<string, string> {
|
||||
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<typeof envSchema> & {
|
||||
allowedUserIds: number[];
|
||||
dataDir: string;
|
||||
dbPath: string;
|
||||
statusPath: string;
|
||||
botStateDbPath: string;
|
||||
};
|
||||
|
||||
export function loadConfig(source: Record<string, string | undefined> = 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`);
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> => {
|
||||
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;
|
||||
});
|
||||
@@ -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<string, unknown> = { 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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export async function stopServerGracefully(server: Bun.Server<undefined>, timeoutMs = 10_000): Promise<void> {
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Shutdown timeout must be a positive integer");
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
server.stop(),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => void server.stop(true).then(resolve, resolve), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type Stoppable = { stop: () => void | Promise<void> };
|
||||
|
||||
export class RuntimeSupervisor {
|
||||
private readonly resources = new Set<Stoppable>();
|
||||
private stopPromise: Promise<void> | 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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { log } from "../logger.js";
|
||||
|
||||
export type WorkerHandle = { stop: () => Promise<void> };
|
||||
|
||||
export function startIntervalWorker(name: string, intervalMs: number, task: () => void | Promise<void>): WorkerHandle {
|
||||
if (!Number.isInteger(intervalMs) || intervalMs <= 0) throw new Error("Worker interval must be a positive integer");
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let currentRun: Promise<void> = Promise.resolve();
|
||||
let stopPromise: Promise<void> | 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<Row, SQLQueryBindings[]>(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<Row, SQLQueryBindings[]>(query).all(...params) as Row[];
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function withHealthDb<T>(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<Uint8Array | null> {
|
||||
if (!healthDbExists(config, uid)) return null;
|
||||
const files: Record<string, string> = {};
|
||||
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<Row, []>(`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 });
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user