From 7962c423b4e2b4001e5b37212b85a04f0dda55a8 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 24 May 2026 21:53:04 +0300 Subject: [PATCH] feat: add miband bot application --- .dockerignore | 25 + .gitignore | 21 + Dockerfile | 32 + README.md | 56 + compose.yaml | 32 + fitness_bot.py | 8 + mi-fitness-python/.github/workflows/ci.yml | 100 ++ .../.github/workflows/release.yml | 64 + mi-fitness-python/.gitignore | 238 +++ mi-fitness-python/.pre-commit-config.yaml | 23 + mi-fitness-python/.python-version | 1 + mi-fitness-python/LICENSE | 674 ++++++++ mi-fitness-python/README.md | 179 ++ mi-fitness-python/cliff.toml | 35 + mi-fitness-python/examples/basic_usage.py | 69 + mi-fitness-python/justfile | 38 + mi-fitness-python/pyproject.toml | 125 ++ mi-fitness-python/src/mi_fitness/__init__.py | 99 ++ .../src/mi_fitness/auth/__init__.py | 8 + .../src/mi_fitness/auth/_helpers.py | 128 ++ .../src/mi_fitness/auth/manager.py | 451 +++++ .../src/mi_fitness/auth/passtoken.py | 92 + .../src/mi_fitness/auth/password.py | 328 ++++ mi-fitness-python/src/mi_fitness/auth/qr.py | 116 ++ mi-fitness-python/src/mi_fitness/auth/sts.py | 67 + mi-fitness-python/src/mi_fitness/cli.py | 74 + .../src/mi_fitness/client/__init__.py | 5 + .../src/mi_fitness/client/api.py | 438 +++++ .../src/mi_fitness/client/base.py | 205 +++ .../src/mi_fitness/client/data.py | 522 ++++++ .../src/mi_fitness/client/messages.py | 52 + .../src/mi_fitness/client/relatives.py | 236 +++ mi-fitness-python/src/mi_fitness/const.py | 95 ++ mi-fitness-python/src/mi_fitness/crypto.py | 289 ++++ .../src/mi_fitness/exceptions.py | 88 + mi-fitness-python/src/mi_fitness/http.py | 93 + mi-fitness-python/src/mi_fitness/models.py | 1261 ++++++++++++++ mi-fitness-python/tests/__init__.py | 0 mi-fitness-python/tests/unit/__init__.py | 0 mi-fitness-python/tests/unit/conftest.py | 355 ++++ mi-fitness-python/tests/unit/test_auth.py | 247 +++ .../tests/unit/test_auth_helpers.py | 165 ++ mi-fitness-python/tests/unit/test_cli.py | 71 + mi-fitness-python/tests/unit/test_client.py | 963 +++++++++++ .../tests/unit/test_client_base.py | 138 ++ mi-fitness-python/tests/unit/test_const.py | 67 + mi-fitness-python/tests/unit/test_crypto.py | 199 +++ .../tests/unit/test_exceptions.py | 80 + mi-fitness-python/tests/unit/test_http.py | 83 + mi-fitness-python/tests/unit/test_models.py | 807 +++++++++ miband_sync.py | 23 + miband_tracker/__init__.py | 6 + miband_tracker/bot/__init__.py | 1 + miband_tracker/bot/app.py | 1499 +++++++++++++++++ miband_tracker/config.py | 114 ++ miband_tracker/fds.py | 286 ++++ miband_tracker/lock.py | 31 + miband_tracker/secure_files.py | 66 + miband_tracker/storage.py | 208 +++ miband_tracker/sync.py | 375 +++++ pyproject.toml | 11 + pytest.ini | 3 + requirements-dev.txt | 4 + requirements.txt | 8 + tests/test_bot_ui.py | 113 ++ tests/test_config.py | 67 + tests/test_fds.py | 33 + tests/test_lock.py | 35 + tests/test_secure_files.py | 32 + tests/test_storage.py | 49 + tests/test_sync.py | 27 + 71 files changed, 12533 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 fitness_bot.py create mode 100644 mi-fitness-python/.github/workflows/ci.yml create mode 100644 mi-fitness-python/.github/workflows/release.yml create mode 100644 mi-fitness-python/.gitignore create mode 100644 mi-fitness-python/.pre-commit-config.yaml create mode 100644 mi-fitness-python/.python-version create mode 100644 mi-fitness-python/LICENSE create mode 100644 mi-fitness-python/README.md create mode 100644 mi-fitness-python/cliff.toml create mode 100644 mi-fitness-python/examples/basic_usage.py create mode 100644 mi-fitness-python/justfile create mode 100644 mi-fitness-python/pyproject.toml create mode 100644 mi-fitness-python/src/mi_fitness/__init__.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/__init__.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/_helpers.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/manager.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/passtoken.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/password.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/qr.py create mode 100644 mi-fitness-python/src/mi_fitness/auth/sts.py create mode 100644 mi-fitness-python/src/mi_fitness/cli.py create mode 100644 mi-fitness-python/src/mi_fitness/client/__init__.py create mode 100644 mi-fitness-python/src/mi_fitness/client/api.py create mode 100644 mi-fitness-python/src/mi_fitness/client/base.py create mode 100644 mi-fitness-python/src/mi_fitness/client/data.py create mode 100644 mi-fitness-python/src/mi_fitness/client/messages.py create mode 100644 mi-fitness-python/src/mi_fitness/client/relatives.py create mode 100644 mi-fitness-python/src/mi_fitness/const.py create mode 100644 mi-fitness-python/src/mi_fitness/crypto.py create mode 100644 mi-fitness-python/src/mi_fitness/exceptions.py create mode 100644 mi-fitness-python/src/mi_fitness/http.py create mode 100644 mi-fitness-python/src/mi_fitness/models.py create mode 100644 mi-fitness-python/tests/__init__.py create mode 100644 mi-fitness-python/tests/unit/__init__.py create mode 100644 mi-fitness-python/tests/unit/conftest.py create mode 100644 mi-fitness-python/tests/unit/test_auth.py create mode 100644 mi-fitness-python/tests/unit/test_auth_helpers.py create mode 100644 mi-fitness-python/tests/unit/test_cli.py create mode 100644 mi-fitness-python/tests/unit/test_client.py create mode 100644 mi-fitness-python/tests/unit/test_client_base.py create mode 100644 mi-fitness-python/tests/unit/test_const.py create mode 100644 mi-fitness-python/tests/unit/test_crypto.py create mode 100644 mi-fitness-python/tests/unit/test_exceptions.py create mode 100644 mi-fitness-python/tests/unit/test_http.py create mode 100644 mi-fitness-python/tests/unit/test_models.py create mode 100644 miband_sync.py create mode 100644 miband_tracker/__init__.py create mode 100644 miband_tracker/bot/__init__.py create mode 100644 miband_tracker/bot/app.py create mode 100644 miband_tracker/config.py create mode 100644 miband_tracker/fds.py create mode 100644 miband_tracker/lock.py create mode 100644 miband_tracker/secure_files.py create mode 100644 miband_tracker/storage.py create mode 100644 miband_tracker/sync.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/test_bot_ui.py create mode 100644 tests/test_config.py create mode 100644 tests/test_fds.py create mode 100644 tests/test_lock.py create mode 100644 tests/test_secure_files.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_sync.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b17f72 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +.gitignore +.dockerignore +.venv +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +.mypy_cache +.test-tmp +.coverage +htmlcov +.DS_Store +data +secrets.env +*.env +*.db +*.sqlite +*.sqlite3 +token*.json +status*.json +*.log +*.zip +*.tar +*.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94ea91e --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.test-tmp/ +.coverage +htmlcov/ +.DS_Store +data/ +secrets.env +*.env +*.db +*.sqlite +*.sqlite3 +token*.json +status*.json +*.log +*.zip +*.tar +*.tar.gz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c8092e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.11-slim + +ARG APP_UID=1000 +ARG APP_GID=1000 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements.txt /app/ +RUN pip install -r /app/requirements.txt + +COPY mi-fitness-python /app/mi-fitness-python +RUN pip install -e /app/mi-fitness-python + +COPY miband_tracker /app/miband_tracker +COPY miband_sync.py /app/ +COPY fitness_bot.py /app/ + +RUN groupadd --gid "${APP_GID}" app \ + && useradd --uid "${APP_UID}" --gid app --create-home --shell /usr/sbin/nologin app \ + && mkdir -p /opt/miband-tracker/data \ + && chown -R app:app /app /opt/miband-tracker + +USER app + +HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 \ + CMD python -c "import os; from pathlib import Path; p = Path(os.environ.get('DATA_DIR', '/opt/miband-tracker/data')); p.mkdir(parents=True, exist_ok=True); raise SystemExit(0 if os.access(p, os.W_OK) else 1)" + +CMD ["python", "-u", "miband_sync.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e09187a --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# miband-bot + +Single-user Xiaomi Fitness sync service and Telegram bot. + +The bot stores Xiaomi Fitness health data in SQLite and exposes a Telegram menu for recent steps, sleep, heart rate, SpO2, trends, manual sync and CSV export. + +## First Run + +1. Create `secrets.env` from the variables below. +2. Start Docker Compose. +3. Open the bot in Telegram and send `/start`. +4. The bot will show a Xiaomi login button. Confirm the login, then the bot saves `data/token_.json`, runs the first sync and opens the main menu. + +```env +TELEGRAM_BOT_TOKEN=123456:telegram-token +TELEGRAM_ALLOWED_USER_ID=123456789 +SYNC_INTERVAL=900 +QUERY_DURATION=2 +ENABLE_FDS_SLEEP_DETAILS=true +``` + +## Docker + +```sh +docker compose up -d --build +docker compose logs -f fitness-bot +``` + +Both services share `./data`. Sync runs are guarded by a file lock in that directory, so manual sync from Telegram and the daemon do not write SQLite/token files at the same time. + +## Local Checks + +```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 +``` + +## Runtime + +Entrypoints kept for Docker compatibility: + +```sh +python -u miband_sync.py +python -u fitness_bot.py +``` + +Secrets live in `secrets.env` and `data/token_.json`; do not commit them. Token files are written atomically with mode `0600`. + +## License + +This project is licensed under GNU GPL v3.0. The vendored `mi-fitness-python` SDK is kept under its own GPL v3.0 license in `mi-fitness-python/LICENSE`. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..d1527f4 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,32 @@ +services: + tracker: + container_name: miband-tracker + build: . + 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"] + 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 diff --git a/fitness_bot.py b/fitness_bot.py new file mode 100644 index 0000000..d4222cb --- /dev/null +++ b/fitness_bot.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from miband_tracker.bot.app import main + + +if __name__ == "__main__": + main() diff --git a/mi-fitness-python/.github/workflows/ci.yml b/mi-fitness-python/.github/workflows/ci.yml new file mode 100644 index 0000000..afb6c8d --- /dev/null +++ b/mi-fitness-python/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +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 diff --git a/mi-fitness-python/.github/workflows/release.yml b/mi-fitness-python/.github/workflows/release.yml new file mode 100644 index 0000000..236dc2f --- /dev/null +++ b/mi-fitness-python/.github/workflows/release.yml @@ -0,0 +1,64 @@ +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 }} diff --git a/mi-fitness-python/.gitignore b/mi-fitness-python/.gitignore new file mode 100644 index 0000000..0bf70f5 --- /dev/null +++ b/mi-fitness-python/.gitignore @@ -0,0 +1,238 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.env.dev +.env.prod +.env.e2e +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +.DS_Store +junit.xml + +# AI +instructions/ +AGENT.md +.agent/rules/ + +# Local +token.json diff --git a/mi-fitness-python/.pre-commit-config.yaml b/mi-fitness-python/.pre-commit-config.yaml new file mode 100644 index 0000000..b80259c --- /dev/null +++ b/mi-fitness-python/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +default_install_hook_types: [pre-commit, commit-msg] +repos: + - repo: builtin + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.13 + hooks: + - id: ruff-check + args: [--fix] + stages: [pre-commit] + - id: ruff-format + stages: [pre-commit] + + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.12.0 + hooks: + - id: commitizen + stages: [commit-msg] diff --git a/mi-fitness-python/.python-version b/mi-fitness-python/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/mi-fitness-python/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/mi-fitness-python/LICENSE b/mi-fitness-python/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/mi-fitness-python/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/mi-fitness-python/README.md b/mi-fitness-python/README.md new file mode 100644 index 0000000..1a1bcc1 --- /dev/null +++ b/mi-fitness-python/README.md @@ -0,0 +1,179 @@ +--- +обновлено: 2026-05-23 +--- +# Mi Fitness + + +小米运动健康 SDK, 通过亲友列表获取其他账号的的心率、睡眠、步数等健康数据。 + +> **⚠️ 仅供学习与测试使用。** API 端点可能随版本更新而变化。 + +## 安装 + +```bash +pip install mi-fitness +# 或使用 uv +uv add mi-fitness +``` + +从源码安装: + +```bash +git clone https://github.com/MistEO/MiSDK.git && cd MiSDK +uv sync +``` + +## 快速开始 + +### 登录(二维码扫码) + +使用小米账号二维码扫码方式登录: + +```python +import asyncio +from mi_fitness import XiaomiAuth + +async def login(): + async with XiaomiAuth() as auth: + await auth.login_qr() + auth.save_token("token.json") + print(f"登录成功!user_id = {auth.token.user_id}") + +asyncio.run(login()) +``` + +自定义二维码展示回调: + +```python +async def login_with_callback(): + async def on_qr(qr_image_url: str, login_url: str) -> None: + # qr_image_url 是二维码图片 URL + print(f"请扫描: {qr_image_url}") + + async with XiaomiAuth() as auth: + await auth.login_qr(qr_callback=on_qr) + auth.save_token("token.json") +``` + +CLI 一行命令登录: + +```bash +uv run python -m mi_fitness.cli qr-login +``` + +### 查询数据 + +```python +import asyncio +from mi_fitness import MiHealthClient + +async def main(): + async with MiHealthClient.from_token("token.json") as client: + # 亲友列表 + relatives = await client.get_relatives() + for r in relatives: + print(f"[{r.relative_uid}] {r.relative_note}") + + uid = relatives[0].relative_uid + + # 最新快照(强类型) + latest = await client.get_latest_data(uid) + print(latest.available_keys) + print(latest.heart_rate) # LatestHeartRate(bpm=84, ...) + print(latest.steps) # StepData(steps=3716, ...) + + # 最近同步日摘要(心率+睡眠+步数并发获取) + summary = await client.get_latest_daily_summary(uid) + print(f"步数: {summary.steps}, 睡眠: {summary.sleep}, 心率: {summary.heart_rate}") + +asyncio.run(main()) +``` + +### 亲友管理 + +```python +async def manage(): + async with MiHealthClient.from_token("token.json") as client: + # 验证用户 + info = await client.verify_user(小米ID) + print(f"找到: {info.nickname} (UID: {info.user_id})") + + # 发送邀请(默认共享全部数据类型) + await client.invite_relative(info.user_id) + + # 删除亲友 + await client.delete_relative(亲友UID) +``` + +### 异常处理 + +```python +from mi_fitness import MiHealthClient, TokenExpiredError, APIError + +async def safe_query(): + try: + async with MiHealthClient.from_token("token.json") as client: + relatives = await client.get_relatives() + except TokenExpiredError: + print("Token 已过期,请重新登录") + except APIError as e: + print(f"API 错误: {e} (HTTP {e.status_code})") +``` + +## API 一览 + +### 数据查询 + +| 方法 | 返回类型 | 说明 | +|------|---------|------| +| `get_heart_rate(uid, date)` | `list[HeartRateData]` | 日均/静息/最大/最小心率、最新采样 | +| `get_sleep(uid, date)` | `list[SleepData]` | 时长/评分/深睡/浅睡/REM/片段详情 | +| `get_steps(uid, date)` | `list[StepData]` | 步数/距离/卡路里 | +| `get_calories_history(uid, date, days=1)` | `list[CaloriesData]` | 按天/按周获取活动卡路里 | +| `get_valid_stand_history(uid, date, days=1)` | `list[ValidStandData]` | 按天/按周获取有效站立次数 | +| `get_intensity_history(uid, date, days=1)` | `list[IntensityData]` | 按天/按周获取中高强度活动时长 | +| `get_spo2_history(uid, date, days=1)` | `list[Spo2SummaryData]` | 按天/按周获取血氧摘要 | +| `get_weight_history(uid, date, days=1)` | `list[WeightData]` | 获取时间窗口内的体重测量记录 | +| `get_blood_pressure_history(uid, date, days=1)` | `list[BloodPressureData]` | 获取时间窗口内的血压测量记录 | +| `get_weight(uid)` | `WeightData \| None` | 体重/BMI | +| `get_goal(uid)` | `GoalData \| None` | 最新活力目标集合,支持 `steps_goal / calories_goal / intensity_goal` 便捷访问;不提供历史目标值 | +| `get_blood_pressure(uid)` | `BloodPressureData \| None` | 最新血压 | +| `get_calories(uid)` | `CaloriesData \| None` | 最新活动卡路里 | +| `get_valid_stand(uid)` | `ValidStandData \| None` | 最新有效站立次数 | +| `get_intensity(uid)` | `IntensityData \| None` | 最新中高强度活动时长 | +| `get_spo2(uid)` | `Spo2Data \| None` | 最新血氧 | +| `get_latest_data(uid)` | `LatestDataSnapshot` | 强类型最新快照(goal/heart_rate/sleep/steps/weight/...) | +| `get_latest_items(uid)` | `list[LatestDataItem]` | 原始 `data_list`,适合调试 | +| `get_daily_summary(uid, date)` | `DailySummary` | 心率+睡眠+步数并发获取 | +| `get_latest_daily_summary(uid)` | `DailySummary` | 自动使用最近一次同步日,减少空结果 | +| `get_aggregated_data(uid, key, start, end)` | `AggregatedDataResponse` | 自定义时间范围和数据类型 | +| `get_fitness_data(uid, key, start, end)` | `AggregatedDataResponse` | 原始测量/事件数据(如体重、血压、异常心率) | + +### 亲友管理 + +| 方法 | 说明 | +|------|------| +| `get_relatives()` | 获取所有已绑定亲友 | +| `find_relative(keyword)` | 按备注名或 UID 查找 | +| `verify_user(xiaomi_id)` | 添加亲友前验证用户信息 | +| `invite_relative(uid)` | 邀请用户成为亲友 | +| `delete_relative(uid)` | 解除亲友关系 | +| `accept_invite(msg) / reject_invite(msg)` | 接受/拒绝邀请 | +| `has_new_invite()` | 是否有新邀请 | +| `get_invite_link_id()` | 获取二维码邀请链接 ID | +| `get_shared_data_types(uid)` | 查看对方共享了哪些数据类型 | +| `get_family_members()` | 家庭组成员列表 | + +### 异常体系 + +| 异常 | 说明 | +|------|------| +| `MiSDKError` | 基础异常 | +| `AuthError` | 认证相关(登录失败等) | +| `TokenExpiredError` | Token 过期且自动刷新失败 | +| `DataNotSharedError` | 亲友未共享当前请求的数据类型 | +| `DataOutOfSharedTimeScopeError` | 查询日期超出亲友允许共享的时间范围 | +| `APIError` | API 非预期响应(含 `status_code` 和 `response_body`) | +| `DeviceUntrustedError` | 新设备需要短信验证 | +| `CaptchaRequiredError` | 触发图形验证码风控 | +| `FamilyMemberNotFoundError` | 找不到指定亲友 | diff --git a/mi-fitness-python/cliff.toml b/mi-fitness-python/cliff.toml new file mode 100644 index 0000000..530dc02 --- /dev/null +++ b/mi-fitness-python/cliff.toml @@ -0,0 +1,35 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# 头部模板 +header = """ +# Changelog\n +""" +# 提交信息模板 +body = """ +{% for group, commits in commits | group_by(attribute="group") -%} +### {{ group | striptags | trim | upper_first }} +{% for commit in commits -%} +- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | split(pat="\n") | first | trim | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/commit/{{ commit.id }})){% if commit.remote.username %} by @{{ commit.remote.username }}{% endif %}{% if commit.remote.pr_number %} in [#{{ commit.remote.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ commit.remote.pr_number }}){% endif %} +{% endfor %} + +{% endfor -%} +""" +# 移除尾部空白 +trim = true +# 底部模板 +footer = "" +# 后处理器 +postprocessors = [] + +[git] +# 提交分组 +commit_parsers = [ + { message = "^feat", group = "✨ Features" }, + { message = "^fix", group = "🐛 Fixes" }, +] +# 保护不匹配的破坏性提交 +protect_breaking_commits = true +# 只保留能被 commit_parsers 匹配到的提交 +filter_commits = true diff --git a/mi-fitness-python/examples/basic_usage.py b/mi-fitness-python/examples/basic_usage.py new file mode 100644 index 0000000..48cedb6 --- /dev/null +++ b/mi-fitness-python/examples/basic_usage.py @@ -0,0 +1,69 @@ +"""使用示例 —— 登录并查询亲友健康数据。 + +运行前请确保: +1. 已在小米运动健康 App 中添加了亲友关系 +2. 亲友已在 App 中授权共享数据 + +用法: + uv run python examples/basic_usage.py +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from mi_fitness import MiHealthClient, XiaomiAuth + +TOKEN_PATH = Path("token.json") + + +async def main() -> None: + # region 登录(首次运行需要) + if not TOKEN_PATH.exists(): + print("首次使用,请扫码登录:") + async with XiaomiAuth() as auth: + await auth.login_qr() + auth.save_token(TOKEN_PATH) + print(f"登录成功!Token 已保存至 {TOKEN_PATH}") + # endregion + + # region 查询数据(一步创建客户端) + async with MiHealthClient.from_token(TOKEN_PATH) as client: + # 1. 获取亲友列表 + relatives = await client.get_relatives() + print(f"\n已绑定 {len(relatives)} 位亲友:") + for r in relatives: + print(f" - [{r.relative_uid}] {r.relative_note or '(未设置备注)'}") + + if not relatives: + print("未找到亲友,请先在 App 中添加亲友关系") + return + + # 2. 查询第一位亲友的最近同步数据 + target = relatives[0] + uid = target.relative_uid + print(f"\n查询 [{target.relative_note}] (UID: {uid}) 的最近同步健康数据:") + + latest = await client.get_latest_data(uid) + print(f" 可用指标: {', '.join(latest.available_keys)}") + + if latest.heart_rate: + print(f" 最新心率: {latest.heart_rate.bpm} bpm") + if latest.sleep: + print(f" 最新睡眠: {latest.sleep.total_duration}分钟 评分{latest.sleep.sleep_score}/100") + if latest.steps: + print(f" 最新步数: {latest.steps.steps}步 / {latest.steps.distance}米 / {latest.steps.calories}卡") + if latest.weight: + print(f" 最新体重: {latest.weight.weight}kg BMI {latest.weight.bmi}") + + summary = await client.get_latest_daily_summary(uid) + print(f"\n最近同步日摘要 ({summary.date}):") + print(f" 心率: {summary.heart_rate or '暂无数据'}") + print(f" 睡眠: {summary.sleep or '暂无数据'}") + print(f" 步数: {summary.steps or '暂无数据'}") + # endregion + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mi-fitness-python/justfile b/mi-fitness-python/justfile new file mode 100644 index 0000000..bb53454 --- /dev/null +++ b/mi-fitness-python/justfile @@ -0,0 +1,38 @@ +set windows-shell := ["powershell", "-NoProfile", "-Command"] + +# 默认任务列表 +default: + @just --list + +# 运行测试 +test: + uv run pytest + +# 版本发布(更新版本号、更新 lock 文件) +bump: + uv run cz bump + uv lock + +# 生成 changelog +changelog: + uv run git-cliff --latest + +# 安装 pre-commit hooks +hooks: + uv run prek install + +# 代码检查 +lint: + uv run ruff check . --fix + +# 代码格式化 +format: + uv run ruff format . + +# 类型检查 +check: + uv run basedpyright + +# 更新 pre-commit hooks +update: + uv run prek auto-update diff --git a/mi-fitness-python/pyproject.toml b/mi-fitness-python/pyproject.toml new file mode 100644 index 0000000..aad5e24 --- /dev/null +++ b/mi-fitness-python/pyproject.toml @@ -0,0 +1,125 @@ +[project] +name = "mi-fitness" +version = "0.2.0" +description = "小米运动健康亲友数据 SDK" +readme = "README.md" +requires-python = ">=3.11" +authors = [{ name = "Misty02600", email = "xiao02600@gmail.com" }] +dependencies = [ + "httpx>=0.28.1", + "loguru>=0.7.3", + "pydantic>=2.12.5", + "qrcode>=8.0", + "tenacity>=9.0.0", +] + +[project.urls] +Homepage = "https://github.com/MistEO/MiSDK" +Issues = "https://github.com/MistEO/MiSDK/issues" +Repository = "https://github.com/MistEO/MiSDK.git" + +[project.scripts] +mi-fitness-login = "mi_fitness.cli:main" + +[dependency-groups] +dev = [ + "basedpyright>=1.16.0", + "commitizen>=4.1.0", + "git-cliff>=2.11.0,<3.0.0", + "prek>=0.2.0", + "ruff>=0.14.13,<1.0.0", + { include-group = "test" }, +] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=1.3.0,<1.4.0", + "pytest-cov>=7.0.0", + "pytest-xdist>=3.8.0,<4.0.0", +] + +[build-system] +requires = ["uv_build>=0.9.2,<0.10.0"] +build-backend = "uv_build" + +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.2.0" +tag_format = "v$version" +version_files = ["pyproject.toml:^version"] +major_version_zero = true + +[tool.coverage.report] +exclude_lines = [ + "raise NotImplementedError", + "if TYPE_CHECKING:", + "@overload", + "except ImportError:", +] + +[tool.pyright] +include = ["src", "tests/unit"] +pythonVersion = "3.11" +pythonPlatform = "All" +typeCheckingMode = "standard" + +[[tool.pyright.executionEnvironments]] +root = "tests" +reportPrivateUsage = "none" +reportUnknownMemberType = "none" + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", + "--strict-markers", + "--tb=short", + "-ra", +] +testpaths = ["tests/unit"] +pythonpath = ["src", "tests/unit"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 100 +src = ["src", "tests/unit"] + +[tool.ruff.format] +line-ending = "lf" + +[tool.ruff.lint] +select = [ + "F", # Pyflakes + "W", # pycodestyle warnings + "E", # pycodestyle errors + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "ASYNC", # flake8-async + "C4", # flake8-comprehensions + "T10", # flake8-debugger + "T20", # flake8-print + "PYI", # flake8-pyi + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "TID", # flake8-tidy-imports + "RUF", # Ruff-specific +] +ignore = [ + "E501", # 行长度由 formatter 控制 + "E402", # 允许模块导入不在文件顶部 + "UP037", # 允许引号类型注解 + "RUF001", # 允许字符串中的中文字符 + "RUF002", # 允许文档字符串中的中文字符 + "RUF003", # 允许注释中的中文字符 + "W191", # 允许制表符缩进 + "TID252", # 允许相对导入 + "B008", # 允许函数参数默认值中使用函数调用 +] + +[tool.ruff.lint.isort] +extra-standard-library = ["typing_extensions"] + +[tool.ruff.lint.per-file-ignores] +"examples/*" = ["T201", "ASYNC240", "ASYNC250"] +"src/mi_fitness/cli.py" = ["T201", "ASYNC240"] +"tests/e2e/*" = ["T201", "ASYNC230", "ASYNC240", "ASYNC250", "F541"] diff --git a/mi-fitness-python/src/mi_fitness/__init__.py b/mi-fitness-python/src/mi_fitness/__init__.py new file mode 100644 index 0000000..6f00e1c --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/__init__.py @@ -0,0 +1,99 @@ +"""Mi Fitness —— 小米运动健康亲友数据 SDK。""" + +from mi_fitness.auth import XiaomiAuth +from mi_fitness.client import MiHealthClient +from mi_fitness.exceptions import ( + APIError, + AuthError, + CaptchaRequiredError, + DataNotSharedError, + DataOutOfSharedTimeScopeError, + DeviceUntrustedError, + FamilyMemberNotFoundError, + MiSDKError, + TokenExpiredError, +) +from mi_fitness.models import ( + AggregatedDataItem, + AggregatedDataResponse, + AuthToken, + BloodPressureData, + CaloriesData, + CheckNewMsgResponse, + DailySummary, + DeleteRelativeResponse, + FamilyMember, + GoalData, + GoalItem, + GoalMetric, + HeartRateData, + IntensityData, + InviteMessage, + InviteResponse, + InviteUniqueIdResponse, + LatestDataItem, + LatestDataResponse, + LatestDataSnapshot, + LatestHeartRate, + MessageListResponse, + OperateInviteResponse, + RelativeListResponse, + SharedDataTypesResponse, + SleepData, + SleepSegment, + Spo2Data, + Spo2SummaryData, + StepData, + ValidStandData, + VerifiedUserInfo, + VerifyUserResponse, + WeightData, +) + +__all__ = [ + "APIError", + "AggregatedDataItem", + "AggregatedDataResponse", + "AuthError", + "AuthToken", + "BloodPressureData", + "CaloriesData", + "CaptchaRequiredError", + "CheckNewMsgResponse", + "DailySummary", + "DataNotSharedError", + "DataOutOfSharedTimeScopeError", + "DeleteRelativeResponse", + "DeviceUntrustedError", + "FamilyMember", + "FamilyMemberNotFoundError", + "GoalData", + "GoalItem", + "GoalMetric", + "HeartRateData", + "IntensityData", + "InviteMessage", + "InviteResponse", + "InviteUniqueIdResponse", + "LatestDataItem", + "LatestDataResponse", + "LatestDataSnapshot", + "LatestHeartRate", + "MessageListResponse", + "MiHealthClient", + "MiSDKError", + "OperateInviteResponse", + "RelativeListResponse", + "SharedDataTypesResponse", + "SleepData", + "SleepSegment", + "Spo2Data", + "Spo2SummaryData", + "StepData", + "TokenExpiredError", + "ValidStandData", + "VerifiedUserInfo", + "VerifyUserResponse", + "WeightData", + "XiaomiAuth", +] diff --git a/mi-fitness-python/src/mi_fitness/auth/__init__.py b/mi-fitness-python/src/mi_fitness/auth/__init__.py new file mode 100644 index 0000000..72d604c --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/__init__.py @@ -0,0 +1,8 @@ +"""小米账号认证子包。 + +对外只暴露 ``XiaomiAuth``,内部按登录方式拆分为独立模块。 +""" + +from mi_fitness.auth.manager import XiaomiAuth + +__all__ = ["XiaomiAuth"] diff --git a/mi-fitness-python/src/mi_fitness/auth/_helpers.py b/mi-fitness-python/src/mi_fitness/auth/_helpers.py new file mode 100644 index 0000000..7168308 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/_helpers.py @@ -0,0 +1,128 @@ +"""认证模块内部工具函数。""" + +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( + "凭证提取完成, ssecurity={}, user_id={}", + token.ssecurity[:8] + "...", + token.user_id, + ) + + +async def async_sleep(seconds: float) -> None: + """异步等待,方便测试时 mock。""" + await asyncio.sleep(seconds) diff --git a/mi-fitness-python/src/mi_fitness/auth/manager.py b/mi-fitness-python/src/mi_fitness/auth/manager.py new file mode 100644 index 0000000..fc27a47 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/manager.py @@ -0,0 +1,451 @@ +"""小米账号认证管理器。 + +负责编排登录流程、token 持久化。具体登录实现委托给 +``password``、``qr``、``passtoken`` 等子模块。 +""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Self, TypeVar + +from loguru import logger + +from mi_fitness.exceptions import ( + AuthError, + CaptchaRequiredError, + DeviceUntrustedError, + TokenExpiredError, +) +from mi_fitness.http import RetryAsyncClient +from mi_fitness.models import AuthToken + +from . import passtoken as _pt +from . import password as _pwd +from . import qr as _qr +from . import sts as _sts +from ._helpers import create_login_http + +_MAX_CAPTCHA_RETRIES = 3 +_CaptchaStepT = TypeVar("_CaptchaStepT") + + +def _write_secret_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + fh.flush() + os.fsync(fh.fileno()) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) + os.chmod(path, 0o600) + finally: + try: + tmp_path.unlink() + except FileNotFoundError: + pass + + +class XiaomiAuth: + """小米账号认证管理器。 + + 负责登录流程、token 持久化。通过 serviceLogin 获取 ssecurity + 和 serviceToken,后续 API 请求使用 RC4 加密。 + + Attributes: + username: 小米账号(手机号或邮箱)。 + token: 当前认证凭证。 + """ + + def __init__( + self, + username: str = "", + password: str = "", + *, + device_id: str = "", + ): + """ + Args: + username: 小米账号。 + password: 密码(仅登录时需要,不会被存储)。 + device_id: 设备标识符。留空则自动生成随机值。新设备首次登录 + 会触发短信验证码验证,可通过 ``login()`` 的 + ``verification_code_handler`` 回调自动处理。 + """ + self.username = username + self._password = password + self.token = AuthToken() + if device_id: + self.token.device_id = device_id + self._http: RetryAsyncClient | None = None + self._ticket_token: str = "" + self._token_path: Path | None = None + + @classmethod + def from_token(cls, path: Path | str) -> Self: + """从文件加载已有 token,一步完成初始化。 + + Args: + path: token 文件路径。 + + Returns: + 已加载 token 的认证管理器。 + + Raises: + AuthError: 文件不存在或格式错误。 + """ + instance = cls() + instance.load_token(path) + return instance + + def _ensure_http(self) -> RetryAsyncClient: + """确保 HTTP 客户端已初始化(惰性创建)。""" + if self._http is None: + self._http = create_login_http() + return self._http + + def _ensure_device_cookie(self) -> RetryAsyncClient: + """确保 deviceId 已生成并写入登录 cookie。""" + http = self._ensure_http() + if not self.token.device_id: + self.token.device_id = f"an_{os.urandom(16).hex()}" + http.cookies.set("deviceId", self.token.device_id) + return http + + async def _run_with_captcha_retries( + self, + http: RetryAsyncClient, + action: Callable[[str], Awaitable[_CaptchaStepT]], + *, + captcha_handler: Callable[[bytes], Awaitable[str]] | None = None, + ) -> _CaptchaStepT: + """统一处理图形验证码重试。""" + captcha_code = "" + for _ in range(_MAX_CAPTCHA_RETRIES): + try: + return await action(captcha_code) + except CaptchaRequiredError as e: + if captcha_handler is None: + raise + image = await _pwd.fetch_captcha_image(http, e.captcha_url) + captcha_code = await captcha_handler(image) + raise AuthError(f"图形验证码验证失败:已连续重试 {_MAX_CAPTCHA_RETRIES} 次") + + # region 公共方法 + async def login( + self, + *, + verification_code_handler: Callable[[str], Awaitable[str]] | None = None, + captcha_handler: Callable[[bytes], Awaitable[str]] | None = None, + ) -> AuthToken: + """执行完整登录流程。 + + Args: + verification_code_handler: 短信验证码回调。接收脱敏手机号 + (如 ``"191******54"``),返回用户输入的 6 位验证码。 + 新设备首次登录需要短信验证时自动调用。 + 若未提供且需要验证,将抛出 ``DeviceUntrustedError``。 + captcha_handler: 图形验证码回调。接收验证码图片字节 + (PNG/JPEG),返回用户识别的验证码文本。 + 当登录流程触发图形验证码风控时自动调用。 + 若未提供且需要验证码,将抛出 ``CaptchaRequiredError``。 + + Returns: + 登录成功后的 AuthToken。 + + Raises: + AuthError: 登录失败(密码错误等)。 + DeviceUntrustedError: 需要短信验证但未提供回调。 + CaptchaRequiredError: 需要图形验证码但未提供回调。 + """ + if not self.username or not self._password: + raise AuthError("用户名和密码不能为空") + + http = self._ensure_device_cookie() + + logger.info("开始小米账号登录: {}", self.username) + + sign, callback = await _pwd.get_login_page(http) + + try: + await _pwd.submit_login( + http, + self.token, + self.username, + self._password, + sign, + callback, + ) + except DeviceUntrustedError: + if verification_code_handler is None: + raise + phone = await self.send_verification_code( + captcha_handler=captcha_handler, + ) + code = await verification_code_handler(phone) + await self.login_with_verification_code(code) + return self.token + + await _sts.sts_exchange(http, self.token) + + self._password = "" + logger.info("登录成功, user_id={}", self.token.user_id) + return self.token + + def save_token(self, path: Path | str) -> None: + """将 token 保存到 JSON 文件,便于下次免登录恢复。 + + Args: + path: 保存路径。 + """ + path = Path(path) + _write_secret_text(path, self.token.model_dump_json(indent=2) + "\n") + self._token_path = path + logger.info("Token 已保存至 {}", path) + + def load_token(self, path: Path | str) -> AuthToken: + """从文件加载 token。 + + Args: + path: token 文件路径。 + + Returns: + 加载的 AuthToken。 + + Raises: + AuthError: 文件不存在或格式错误。 + """ + path = Path(path) + if not path.exists(): + raise AuthError(f"Token 文件不存在: {path}") + try: + data = path.read_text(encoding="utf-8") + self.token = AuthToken.model_validate_json(data) + self._token_path = path + logger.info("Token 已从 {} 加载, user_id={}", path, self.token.user_id) + return self.token + except Exception as e: + raise AuthError(f"Token 文件解析失败: {e}") from e + + @property + def is_authenticated(self) -> bool: + """检查是否已登录。""" + return bool(self.token.service_token and self.token.ssecurity) + + @property + def can_refresh(self) -> bool: + """当前 token 是否具备自动刷新条件。""" + return bool(self.token.pass_token and self.token.user_id) + + async def close(self) -> None: + """关闭 HTTP 客户端(如有)。""" + if self._http is not None: + await self._http.aclose() + self._http = None + + async def refresh(self) -> AuthToken: + """用已有 passToken 刷新 serviceToken / ssecurity。 + + Returns: + 刷新后的 AuthToken。 + + Raises: + TokenExpiredError: 当前 token 无法刷新,或刷新失败。 + """ + if not self.can_refresh: + raise TokenExpiredError("Token 已过期,且缺少 passToken 或 user_id,无法自动刷新") + + logger.info("开始刷新登录凭证, user_id={}", self.token.user_id) + try: + token = await self.login_passtoken( + pass_token=self.token.pass_token, + user_id=self.token.user_id, + device_id=self.token.device_id, + ) + except AuthError as e: + raise TokenExpiredError(f"Token 已过期,自动刷新失败: {e}") from e + + if self._token_path is not None: + self.save_token(self._token_path) + + logger.info("登录凭证刷新成功, user_id={}", token.user_id) + return token + + async def send_verification_code( + self, + *, + captcha_handler: Callable[[bytes], Awaitable[str]] | None = None, + ) -> str: + """发送短信验证码到用户手机。 + + 在 ``login()`` 抛出 ``DeviceUntrustedError`` 后调用此方法 + 手动发起短信验证流程。 + + Args: + captcha_handler: 图形验证码回调。接收验证码图片字节, + 返回用户识别的验证码文本。未提供时触发验证码将直接抛出 + ``CaptchaRequiredError``。 + + Returns: + 脱敏手机号(如 ``"191******54"``)。 + + Raises: + AuthError: 获取手机信息或发送验证码失败。 + CaptchaRequiredError: 需要图形验证码但未提供回调。 + """ + http = self._ensure_http() + await _pwd.ensure_ticket_login_ready(http) + + await self._run_with_captcha_retries( + http, + lambda captcha_code: _pwd.send_ticket( + http, + self.username, + captcha_code=captcha_code, + ), + captcha_handler=captcha_handler, + ) + phone, ticket_token = await self._run_with_captcha_retries( + http, + lambda captcha_code: _pwd.get_phone_info( + http, + self.username, + captcha_code=captcha_code, + ), + captcha_handler=captcha_handler, + ) + + self._ticket_token = ticket_token + logger.info("验证码已发送至 {}", phone) + return phone + + async def login_with_verification_code(self, code: str) -> AuthToken: + """使用短信验证码完成登录。 + + 在 ``send_verification_code()`` 之后调用,提交用户收到的验证码。 + + Args: + code: 6 位短信验证码。 + + Returns: + 登录成功后的 AuthToken。 + + Raises: + AuthError: 验证码错误或登录失败。 + """ + if not self._ticket_token: + raise AuthError("请先调用 send_verification_code() 发送验证码") + + http = self._ensure_http() + http.cookies.set("ticketToken", self._ticket_token) + + sign, callback = await _pwd.get_login_page(http, login_sign="ticket") + await _pwd.submit_ticket_auth(http, self.token, code, sign, callback) + await _sts.sts_exchange(http, self.token) + + self._password = "" + self._ticket_token = "" + logger.info("短信验证码登录成功, user_id={}", self.token.user_id) + return self.token + + def __repr__(self) -> str: + status = "已认证" if self.is_authenticated else "未认证" + uid = self.token.user_id or "N/A" + return f"XiaomiAuth(user={self.username or uid!r}, {status})" + + async def login_qr( + self, + *, + qr_callback: Callable[[str, str], Awaitable[None]] | None = None, + poll_interval: float = 2.0, + max_wait: float = 300.0, + ) -> AuthToken: + """二维码扫码登录(无需密码,绕过验证码风控)。 + + 用户用小米账号 APP 扫描二维码完成登录,SDK 通过长轮询 + 检测扫码结果并自动提取凭证。 + + Args: + qr_callback: 二维码展示回调。接收 ``(qr_image_url, login_url)``, + 其中 ``qr_image_url`` 是二维码图片 URL(可下载显示), + ``login_url`` 是备选的浏览器登录链接。 + 默认将信息打印到控制台。 + poll_interval: 长轮询间隔(秒)。 + max_wait: 扫码超时时间(秒)。 + + Returns: + 登录成功后的 AuthToken。 + + Raises: + AuthError: 获取二维码失败或扫码超时。 + """ + http = self._ensure_device_cookie() + + await _qr.login_qr( + http, + self.token, + qr_callback=qr_callback, + poll_interval=poll_interval, + max_wait=max_wait, + ) + + await _sts.sts_exchange(http, self.token) + + logger.info("二维码登录成功, user_id={}", self.token.user_id) + return self.token + + async def login_passtoken( + self, + *, + pass_token: str = "", + user_id: str = "", + device_id: str = "", + ) -> AuthToken: + """使用 passToken 换取完整登录凭证(无需密码)。 + + passToken 可通过 ``migate.get_passtoken()`` 或浏览器登录小米账号 + 后从 Cookie 中提取获取。此方法用 passToken 调用 ``serviceLogin`` + 换取 ``ssecurity`` 和 ``serviceToken``。 + + Args: + pass_token: 小米账号 passToken。 + user_id: 小米账号 userId。 + device_id: 设备标识符(可选)。 + + Returns: + 登录成功后的 AuthToken。 + + Raises: + AuthError: passToken 无效或换取凭证失败。 + """ + http = self._ensure_http() + + await _pt.login_passtoken( + http, + self.token, + pass_token=pass_token, + user_id=user_id, + device_id=device_id, + ) + + await _sts.sts_exchange(http, self.token) + + logger.info("passToken 登录成功, user_id={}", self.token.user_id) + return self.token + + # endregion + + # region 上下文管理器 + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *args: object) -> None: + await self.close() + + # endregion diff --git a/mi-fitness-python/src/mi_fitness/auth/passtoken.py b/mi-fitness-python/src/mi_fitness/auth/passtoken.py new file mode 100644 index 0000000..f3423d8 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/passtoken.py @@ -0,0 +1,92 @@ +"""passToken 交换登录。""" + +from __future__ import annotations + +import base64 +import hashlib +import os +from urllib.parse import quote + +from loguru import logger + +from mi_fitness.const import SERVICE_SID_HEALTH, XIAOMI_LOGIN_URL +from mi_fitness.exceptions import AuthError +from mi_fitness.http import RetryAsyncClient +from mi_fitness.models import AuthToken + +from ._helpers import extract_service_token, parse_mi_response, set_cookie_for_domains + + +async def login_passtoken( + http: RetryAsyncClient, + token: AuthToken, + *, + pass_token: str, + user_id: str, + device_id: str = "", +) -> None: + """使用 passToken 换取完整登录凭证。 + + Args: + http: HTTP 客户端。 + token: 要写入的 AuthToken。 + pass_token: 小米账号 passToken。 + user_id: 小米账号 userId。 + device_id: 设备标识符(可选)。 + + Raises: + AuthError: passToken 无效或换取凭证失败。 + """ + if not pass_token: + raise AuthError("passToken 不能为空") + if not user_id: + raise AuthError("userId 不能为空") + + token.pass_token = pass_token + token.user_id = user_id + if device_id: + token.device_id = device_id + elif not token.device_id: + token.device_id = f"an_{os.urandom(16).hex()}" + + # 设置 cookies(passToken + deviceId + userId) + for name, value in { + "passToken": pass_token, + "deviceId": token.device_id, + "userId": user_id, + }.items(): + set_cookie_for_domains(http, name, value) + + logger.info("使用 passToken 换取凭证, userId={}", user_id) + + # 调用 serviceLogin,带上 passToken cookie 会让服务端直接返回凭证 + resp = await http.get( + XIAOMI_LOGIN_URL, + params={"_json": "true", "sid": SERVICE_SID_HEALTH}, + ) + resp.raise_for_status() + data = parse_mi_response(resp.text) + + ssecurity = data.get("ssecurity", "") + location = data.get("location", "") + nonce_val = data.get("nonce", "") + c_user_id = data.get("cUserId", "") + + if not ssecurity: + raise AuthError( + "passToken 换取凭证失败:serviceLogin 未返回 ssecurity。" + f"响应字段: {', '.join(sorted(data.keys()))}" + ) + + token.ssecurity = ssecurity + token.c_user_id = c_user_id + + # 跟随 location 重定向获取 serviceToken + if location: + sign_text = f"nonce={nonce_val}&{ssecurity}" + sha1_digest = hashlib.sha1(sign_text.encode()).digest() + client_sign = quote(base64.b64encode(sha1_digest).decode()) + full_url = f"{location}&clientSign={client_sign}" + token.service_token = await extract_service_token(http, full_url) + + logger.info("passToken 凭证交换完成, user_id={}", token.user_id) diff --git a/mi-fitness-python/src/mi_fitness/auth/password.py b/mi-fitness-python/src/mi_fitness/auth/password.py new file mode 100644 index 0000000..224287d --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/password.py @@ -0,0 +1,328 @@ +"""密码登录 + 短信验证码流程。""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from loguru import logger + +from mi_fitness.const import ( + APP_NAME, + ERR_DEVICE_UNTRUST, + SERVICE_SID_HEALTH, + XIAOMI_LOGIN_AUTH_URL, + XIAOMI_LOGIN_URL, + XIAOMI_PHONE_INFO_URL, + XIAOMI_PREFERENCE_URL, + XIAOMI_SEND_TICKET_URL, + XIAOMI_TICKET_AUTH_URL, +) +from mi_fitness.exceptions import AuthError, CaptchaRequiredError, DeviceUntrustedError +from mi_fitness.http import RetryAsyncClient +from mi_fitness.models import AuthToken + +from ._helpers import extract_credentials, normalize_captcha_url, parse_mi_response + + +# region 登录页 +async def get_login_page( + http: RetryAsyncClient, + *, + login_sign: str = "", +) -> tuple[str, str]: + """请求登录页,获取 _sign 和 callback。 + + Args: + http: HTTP 客户端。 + login_sign: 登录签名类型。``""`` 为密码登录, + ``"ticket"`` 为短信验证码登录。 + + Returns: + (sign, callback) 元组。 + """ + import re + + params: dict[str, str] = { + "_json": "true", + "appName": APP_NAME, + "sid": SERVICE_SID_HEALTH, + "_locale": "zh_CN", + } + if login_sign: + params["_loginSign"] = login_sign + + resp = await http.get(XIAOMI_LOGIN_URL, params=params) + resp.raise_for_status() + + body = resp.text + if body.startswith("&&&START&&&"): + body = body[len("&&&START&&&") :] + + try: + data = json.loads(body) + except json.JSONDecodeError: + sign_match = re.search(r'"_sign"\s*:\s*"([^"]+)"', resp.text) + callback_match = re.search(r'"callback"\s*:\s*"([^"]+)"', resp.text) + sign = sign_match.group(1) if sign_match else "" + callback = callback_match.group(1) if callback_match else "" + return sign, callback + + sign = data.get("_sign", "") + callback = data.get("callback", "") + return sign, callback + + +# endregion + + +# region 密码提交 +async def submit_login( + http: RetryAsyncClient, + token: AuthToken, + username: str, + password: str, + sign: str, + callback: str, +) -> None: + """提交密码并处理登录响应。 + + 成功时直接写入 token;设备未信任时抛出 DeviceUntrustedError。 + + Raises: + AuthError: 密码错误。 + DeviceUntrustedError: 设备未信任,需短信验证。 + """ + data = await _raw_submit_login(http, username, password, sign, callback) + + if data.get("ssecurity"): + await extract_credentials(http, data, token) + return + + if data.get("code") == ERR_DEVICE_UNTRUST: + raise DeviceUntrustedError( + f"登录需要二次验证(code={ERR_DEVICE_UNTRUST})。将自动进入短信验证码流程。", + security_status=16, + ) + + security_status = data.get("securityStatus", 0) + if security_status != 0: + raise DeviceUntrustedError( + f"设备未受信任 (securityStatus={security_status})," + f"需要短信验证码完成登录。\n" + f"请传入 verification_code_handler 回调," + f"或手动调用 send_verification_code() + " + f"login_with_verification_code()。", + security_status=security_status, + ) + + keys = ", ".join(sorted(data.keys())) + raise AuthError(f"登录异常:密码正确但未返回凭证。响应字段: {keys}") + + +async def _raw_submit_login( + http: RetryAsyncClient, + username: str, + password: str, + sign: str, + callback: str, +) -> dict: + """提交密码到 serviceLoginAuth2 并返回解析后的响应。""" + pwd_hash = hashlib.md5(password.encode()).hexdigest().upper() + + form_data = { + "sid": SERVICE_SID_HEALTH, + "_json": "true", + "_sign": sign, + "callback": callback, + "user": username, + "hash": pwd_hash, + "qs": f"%3Fsid%3D{SERVICE_SID_HEALTH}", + "_locale": "zh_CN", + } + + resp = await http.post( + XIAOMI_LOGIN_AUTH_URL, + data=form_data, + headers={"Referer": XIAOMI_LOGIN_URL}, + ) + resp.raise_for_status() + + data = parse_mi_response(resp.text) + + code = data.get("code", -1) + if code == ERR_DEVICE_UNTRUST: + return data + if code != 0: + desc = data.get("desc", "未知错误") + raise AuthError(f"登录失败 (code={code}): {desc}") + + return data + + +# endregion + + +# region 短信验证码 +async def ensure_ticket_login_ready(http: RetryAsyncClient) -> None: + """请求 preference 页准备 ticket 登录上下文。""" + resp = await http.get(XIAOMI_PREFERENCE_URL, params={"_locale": "zh_CN"}) + resp.raise_for_status() + data = parse_mi_response(resp.text) + if data.get("code", -1) != 0: + raise AuthError(f"登录偏好初始化失败: {data.get('description', '未知错误')}") + + +def _build_ticket_form_data(username: str, *, captcha_code: str = "") -> dict[str, str]: + """构造短信验证相关接口的公共表单。""" + form_data: dict[str, str] = { + "sid": SERVICE_SID_HEALTH, + "_json": "true", + "_locale": "zh_CN", + "user": username, + } + if captcha_code: + form_data["captCode"] = captcha_code + return form_data + + +async def _post_ticket_request( + http: RetryAsyncClient, + url: str, + username: str, + *, + captcha_code: str = "", + error_prefix: str, +) -> dict[str, Any]: + """提交短信验证相关请求并统一处理验证码风控。""" + resp = await http.post( + url, + data=_build_ticket_form_data(username, captcha_code=captcha_code), + ) + resp.raise_for_status() + data = parse_mi_response(resp.text) + + if data.get("code", -1) == 0: + return data + + desc = data.get("description", "未知错误") + captcha_url = normalize_captcha_url(data.get("captchaUrl", "")) + if captcha_url: + raise CaptchaRequiredError( + f"{error_prefix}:触发了图形验证码风控 (code={data.get('code')})", + captcha_url=captcha_url, + ) + raise AuthError(f"{error_prefix}: {desc}") + + +async def send_ticket( + http: RetryAsyncClient, + username: str, + *, + captcha_code: str = "", +) -> None: + """发送短信验证码到用户手机。 + + Raises: + CaptchaRequiredError: 触发图形验证码风控。 + AuthError: 发送失败。 + """ + data = await _post_ticket_request( + http, + XIAOMI_SEND_TICKET_URL, + username, + captcha_code=captcha_code, + error_prefix="验证码发送失败", + ) + + logger.debug("验证码已发送, vCodeLen={}", data.get("data", {}).get("vCodeLen")) + + +async def get_phone_info( + http: RetryAsyncClient, + username: str, + *, + captcha_code: str = "", +) -> tuple[str, str]: + """获取手机号信息和 ticketToken。 + + Returns: + (脱敏手机号, ticketToken) 元组。 + + Raises: + CaptchaRequiredError: 触发图形验证码风控。 + AuthError: 获取失败。 + """ + data = await _post_ticket_request( + http, + XIAOMI_PHONE_INFO_URL, + username, + captcha_code=captcha_code, + error_prefix="获取手机信息失败", + ) + + info = data.get("data", {}) + phone = info.get("phone", "未知号码") + ticket_token = info.get("ticketToken", "") + if not ticket_token: + raise AuthError("服务端未返回 ticketToken,无法发送验证码") + return phone, ticket_token + + +async def fetch_captcha_image(http: RetryAsyncClient, captcha_url: str) -> bytes: + """下载图形验证码图片。""" + logger.debug("下载图形验证码: {}", captcha_url) + resp = await http.get(captcha_url) + resp.raise_for_status() + return resp.content + + +async def submit_ticket_auth( + http: RetryAsyncClient, + token: AuthToken, + code: str, + sign: str, + callback: str, +) -> None: + """使用验证码完成 serviceLoginTicketAuth 登录。 + + Args: + http: HTTP 客户端。 + token: 要写入的 AuthToken。 + code: 用户输入的 6 位短信验证码。 + sign: 登录页获取的 _sign。 + callback: 回调 URL。 + """ + form_data = { + "sid": SERVICE_SID_HEALTH, + "_json": "true", + "_sign": sign, + "callback": callback, + "ticket": code, + "qs": ( + f"%3F_loginSign%3Dticket%26_json%3Dtrue%26sid%3D{SERVICE_SID_HEALTH}%26_locale%3Dzh_CN" + ), + "_locale": "zh_CN", + } + + resp = await http.post( + XIAOMI_TICKET_AUTH_URL, + data=form_data, + headers={"Referer": XIAOMI_LOGIN_URL}, + ) + resp.raise_for_status() + data = parse_mi_response(resp.text) + + code_val = data.get("code", -1) + if code_val != 0: + desc = data.get("desc", "未知错误") + raise AuthError(f"验证码验证失败 (code={code_val}): {desc}") + + if not data.get("ssecurity"): + raise AuthError("验证码验证成功但未返回凭证") + + await extract_credentials(http, data, token) + + +# endregion diff --git a/mi-fitness-python/src/mi_fitness/auth/qr.py b/mi-fitness-python/src/mi_fitness/auth/qr.py new file mode 100644 index 0000000..205a2f4 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/qr.py @@ -0,0 +1,116 @@ +"""二维码扫码登录。""" + +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("二维码图片: {}", qr_image_url) + if login_url: + logger.info("或在浏览器打开: {}", login_url) + + # Step 2: 长轮询等待扫码 + effective_timeout = min(float(qr_timeout), max_wait) + poll_request_timeout = 60.0 + logger.debug( + "二维码长轮询开始: effective_timeout={}s, request_timeout={}s", + f"{effective_timeout:.0f}", + f"{poll_request_timeout:.0f}", + ) + start_time = time.time() + + while True: + elapsed = time.time() - start_time + if elapsed > effective_timeout: + raise AuthError(f"二维码扫码超时({effective_timeout:.0f}s),请重新获取") + + try: + # 直接调用 httpx.AsyncClient.get 绕过 RetryAsyncClient 的重试 + resp = await httpx.AsyncClient.request( + http, "GET", long_polling_url, timeout=poll_request_timeout + ) + except (asyncio.CancelledError, KeyboardInterrupt): + logger.warning("二维码登录轮询被中断") + raise + except httpx.TimeoutException: + logger.debug("长轮询超时,继续等待...") + continue + except httpx.RequestError as e: + logger.warning("长轮询请求失败: {}", e) + await async_sleep(poll_interval) + continue + + if resp.status_code != 200: + logger.debug("长轮询返回 {},继续等待...", resp.status_code) + await async_sleep(poll_interval) + continue + + break + + data = parse_mi_response(resp.text) + logger.info("扫码成功, userId={}", data.get("userId")) + + # Step 3: 提取凭证 + await extract_credentials(http, data, token) diff --git a/mi-fitness-python/src/mi_fitness/auth/sts.py b/mi-fitness-python/src/mi_fitness/auth/sts.py new file mode 100644 index 0000000..415c028 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/auth/sts.py @@ -0,0 +1,67 @@ +"""STS 安全令牌交换。""" + +from __future__ import annotations + +import time +import os + +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 交换成功") + # Extract STS serviceToken from cookies and save it to AuthToken + logger.debug("Куки в клиенте во время STS:") + for cookie in http.cookies.jar: + logger.debug(f" Cookie: {cookie.name}, Domain: {cookie.domain}, Value: {cookie.value[:15]}...") + 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: {}", sts_token[:15] + "...") + else: + logger.warning("STS 交换响应: {}", resp.text[:100]) + except Exception as e: + logger.warning("STS 交换失败(非致命): {}", e) diff --git a/mi-fitness-python/src/mi_fitness/cli.py b/mi-fitness-python/src/mi_fitness/cli.py new file mode 100644 index 0000000..65f820b --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/cli.py @@ -0,0 +1,74 @@ +"""二维码扫码登录,获取 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) + print(f"\n 二维码图片: {qr_image_url}") + if login_url: + print(f" 浏览器打开: {login_url}") + print("\n⏳ 等待扫码...\n") + + async with XiaomiAuth() as auth: + try: + await auth.login_qr(qr_callback=show_qr) + except AuthError as e: + print(f"❌ 扫码登录失败: {e}") + raise + + auth.save_token(TOKEN_FILE) + print(f"✅ 扫码登录成功!user_id = {auth.token.user_id}") + print(f" Token 已保存至 {TOKEN_FILE.resolve()}") + + +def main() -> None: + """CLI 入口。""" + asyncio.run(_qr_login()) + + +if __name__ == "__main__": + main() diff --git a/mi-fitness-python/src/mi_fitness/client/__init__.py b/mi-fitness-python/src/mi_fitness/client/__init__.py new file mode 100644 index 0000000..5181632 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/client/__init__.py @@ -0,0 +1,5 @@ +"""小米运动健康 API 客户端子包。""" + +from mi_fitness.client.api import MiHealthClient + +__all__ = ["MiHealthClient"] diff --git a/mi-fitness-python/src/mi_fitness/client/api.py b/mi-fitness-python/src/mi_fitness/client/api.py new file mode 100644 index 0000000..ce10980 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/client/api.py @@ -0,0 +1,438 @@ +"""MiHealthClient —— 小米运动健康 API 客户端。 + +薄编排层:持有 HTTP 客户端与认证状态, +所有业务逻辑委托给 relatives / data / messages 子模块。 +""" + +from __future__ import annotations + +import asyncio +from datetime import date +from pathlib import Path +from typing import Any, Self + +from mi_fitness.auth import XiaomiAuth +from mi_fitness.client import data as _data +from mi_fitness.client import messages as _msg +from mi_fitness.client import relatives as _rel +from mi_fitness.client.base import create_api_http, encrypted_request +from mi_fitness.const import HEALTH_API_BASE +from mi_fitness.exceptions import TokenExpiredError +from mi_fitness.models import ( + AggregatedDataResponse, + BloodPressureData, + CaloriesData, + DailySummary, + FamilyMember, + GoalData, + HeartRateData, + IntensityData, + InviteMessage, + LatestDataItem, + LatestDataSnapshot, + SleepData, + Spo2Data, + Spo2SummaryData, + StepData, + ValidStandData, + VerifiedUserInfo, + WeightData, +) + + +class MiHealthClient: + """小米运动健康 API 客户端。 + + 通过已登录的 XiaomiAuth 实例访问亲友健康数据 API。 + 所有请求使用 RC4 加密,通过 cookie 认证。 + + Attributes: + auth: 认证管理器。 + base_url: API 基础 URL。 + """ + + def __init__( + self, + auth: XiaomiAuth, + base_url: str = HEALTH_API_BASE, + ): + """ + Args: + auth: 已通过登录的认证管理器。 + base_url: API 基础 URL(默认国内节点)。 + """ + self.auth = auth + self.base_url = base_url.rstrip("/") + self._http = create_api_http() + self._refresh_lock = asyncio.Lock() + + @classmethod + def from_token(cls, path: Path | str, **kwargs: Any) -> Self: + """从 token 文件一步创建客户端。 + + Args: + path: token 文件路径。 + **kwargs: 传递给 MiHealthClient 的额外参数(如 base_url)。 + + Returns: + 已就绪的 MiHealthClient 实例。 + + Raises: + AuthError: 文件不存在或格式错误。 + """ + auth = XiaomiAuth.from_token(path) + return cls(auth, **kwargs) + + def __repr__(self) -> str: + uid = self.auth.token.user_id or "N/A" + return f"MiHealthClient(user_id={uid!r}, base_url={self.base_url!r})" + + # region 内部请求 + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + _allow_refresh: bool = True, + ) -> dict[str, Any]: + """发送 RC4 加密的 API 请求。""" + expired_service_token = self.auth.token.service_token + try: + return await encrypted_request( + self._http, + self.auth.token, + method, + path, + self.base_url, + params=params, + ) + except TokenExpiredError: + if not _allow_refresh: + raise + await self._refresh_auth(expired_service_token) + return await self._request(method, path, params=params, _allow_refresh=False) + + async def _refresh_auth(self, expired_service_token: str) -> None: + """串行化自动刷新,避免并发请求重复刷新 token。""" + async with self._refresh_lock: + current_service_token = self.auth.token.service_token + if ( + expired_service_token + and current_service_token + and current_service_token != expired_service_token + and self.auth.is_authenticated + ): + return + await self.auth.refresh() + + # endregion + + # region 亲友管理 + async def get_relatives(self) -> list[FamilyMember]: + """获取亲友列表。""" + return await _rel.get_relatives(self) + + async def find_relative(self, keyword: str | int) -> FamilyMember: + """按备注名或 UID 查找亲友。""" + return await _rel.find_relative(self, keyword) + + async def verify_user( + self, + verify_id: int, + *, + verify_type: int = 1, + ) -> VerifiedUserInfo | None: + """按 UID 或扫码 ID 验证用户信息。""" + return await _rel.verify_user(self, verify_id, verify_type=verify_type) + + async def invite_relative( + self, + relative_uid: int, + *, + shared_data_types: list[str] | None = None, + auth_time_range: int = 3, + relative_note: str = "", + ) -> bool: + """发送亲友邀请。""" + return await _rel.invite_relative( + self, + relative_uid, + shared_data_types=shared_data_types, + auth_time_range=auth_time_range, + relative_note=relative_note, + ) + + async def accept_invite( + self, + invite_id: int, + msg_id: int, + *, + shared_data_types: list[str] | None = None, + auth_time_range: int = 3, + ) -> bool: + """同意亲友邀请。""" + return await _rel.accept_invite( + self, + invite_id, + msg_id, + shared_data_types=shared_data_types, + auth_time_range=auth_time_range, + ) + + async def reject_invite(self, invite_id: int, msg_id: int) -> bool: + """拒绝亲友邀请。""" + return await _rel.reject_invite(self, invite_id, msg_id) + + async def delete_relative(self, relative_uid: int) -> bool: + """删除亲友关系。""" + return await _rel.delete_relative(self, relative_uid) + + async def get_invite_link_id(self) -> int: + """获取二维码邀请链接 ID。""" + return await _rel.get_invite_link_id(self) + + async def get_shared_data_types( + self, + relative_uid: int, + *, + direction: int = 2, + ) -> list[str]: + """获取亲友共享的数据类型列表。""" + return await _rel.get_shared_data_types(self, relative_uid, direction=direction) + + async def get_applied_shared_data_types(self, relative_uid: int) -> list[str]: + """获取已申请的共享数据类型。""" + return await _rel.get_applied_shared_data_types(self, relative_uid) + + async def get_family_members(self) -> list[dict[str, Any]]: + """获取家庭成员列表。""" + return await _rel.get_family_members(self) + + async def get_topic_subscriptions( + self, + relative_uid: int, + topics: list[str] | None = None, + ) -> dict[str, Any]: + """获取亲友的消息订阅状态。""" + return await _rel.get_topic_subscriptions(self, relative_uid, topics) + + # endregion + + # region 数据查询 + async def get_latest_items(self, relative_uid: int) -> list[LatestDataItem]: + """获取亲友的原始最新数据项列表。""" + return await _data.get_latest_items(self, relative_uid) + + async def get_latest_data(self, relative_uid: int) -> LatestDataSnapshot: + """获取亲友的最新数据快照(强类型聚合视图)。""" + return await _data.get_latest_data(self, relative_uid) + + async def get_aggregated_data( + self, + relative_uid: int, + key: str, + start_time: int, + end_time: int, + *, + tag: str = "daily_report", + limit: int = 30, + ) -> AggregatedDataResponse: + """获取亲友的聚合数据。""" + return await _data.get_aggregated_data( + self, + relative_uid, + key, + start_time, + end_time, + tag=tag, + limit=limit, + ) + + async def get_fitness_data( + self, + relative_uid: int, + key: str, + start_time: int, + end_time: int, + *, + limit: int = 30, + ) -> AggregatedDataResponse: + """获取亲友的原始测量/事件数据(如体重、血压、异常心率等)。""" + return await _data.get_fitness_data( + self, + relative_uid, + key, + start_time, + end_time, + limit=limit, + ) + + async def get_heart_rate( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[HeartRateData]: + """获取亲友的心率数据。""" + return await _data.get_heart_rate(self, relative_uid, query_date, days=days) + + async def get_sleep( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[SleepData]: + """获取亲友的睡眠数据。""" + return await _data.get_sleep(self, relative_uid, query_date, days=days) + + async def get_steps( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[StepData]: + """获取亲友的步数数据。""" + return await _data.get_steps(self, relative_uid, query_date, days=days) + + async def get_weight_history( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[WeightData]: + """获取亲友在指定窗口内的体重测量记录。""" + return await _data.get_weight_history(self, relative_uid, query_date, days=days) + + async def get_blood_pressure_history( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[BloodPressureData]: + """获取亲友在指定窗口内的血压测量记录。""" + return await _data.get_blood_pressure_history(self, relative_uid, query_date, days=days) + + async def get_weight(self, relative_uid: int) -> WeightData | None: + """获取亲友最新体重数据。""" + return await _data.get_weight(self, relative_uid) + + async def get_calories_history( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[CaloriesData]: + """获取亲友按天聚合的活动卡路里数据。""" + return await _data.get_calories_history(self, relative_uid, query_date, days=days) + + async def get_valid_stand_history( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[ValidStandData]: + """获取亲友按天聚合的有效站立次数。""" + return await _data.get_valid_stand_history(self, relative_uid, query_date, days=days) + + async def get_intensity_history( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[IntensityData]: + """获取亲友按天聚合的中高强度活动时长。""" + return await _data.get_intensity_history(self, relative_uid, query_date, days=days) + + async def get_spo2_history( + self, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, + ) -> list[Spo2SummaryData]: + """获取亲友按天聚合的血氧摘要。""" + return await _data.get_spo2_history(self, relative_uid, query_date, days=days) + + async def get_goal(self, relative_uid: int) -> GoalData | None: + """获取亲友最新目标完成情况。""" + return await _data.get_goal(self, relative_uid) + + async def get_blood_pressure(self, relative_uid: int) -> BloodPressureData | None: + """获取亲友最新血压数据。""" + return await _data.get_blood_pressure(self, relative_uid) + + async def get_calories(self, relative_uid: int) -> CaloriesData | None: + """获取亲友最新活动卡路里。""" + return await _data.get_calories(self, relative_uid) + + async def get_valid_stand(self, relative_uid: int) -> ValidStandData | None: + """获取亲友最新有效站立次数。""" + return await _data.get_valid_stand(self, relative_uid) + + async def get_intensity(self, relative_uid: int) -> IntensityData | None: + """获取亲友最新中高强度活动时长。""" + return await _data.get_intensity(self, relative_uid) + + async def get_spo2(self, relative_uid: int) -> Spo2Data | None: + """获取亲友最新血氧数据。""" + return await _data.get_spo2(self, relative_uid) + + async def get_daily_summary( + self, + relative_uid: int, + query_date: date | None = None, + ) -> DailySummary: + """获取亲友的每日综合健康摘要。""" + return await _data.get_daily_summary(self, relative_uid, query_date) + + async def get_latest_daily_summary(self, relative_uid: int) -> DailySummary: + """获取亲友最近一次同步数据的每日综合健康摘要。""" + return await _data.get_latest_daily_summary(self, relative_uid) + + # endregion + + # region 消息 + async def get_invite_messages( + self, + *, + limit: int = 30, + pending_only: bool = False, + ) -> list[InviteMessage]: + """获取亲友邀请消息列表。""" + return await _msg.get_invite_messages(self, limit=limit, pending_only=pending_only) + + async def has_new_invite(self) -> bool: + """检查是否有新的亲友邀请消息。""" + return await _msg.has_new_invite(self) + + # endregion + + # region 静态工具 + @staticmethod + def _date_to_timestamps(query_date: date | None = None) -> tuple[int, int]: + """将日期转换为当日 00:00 ~ 23:59:59 的时间戳。""" + return _data._date_to_timestamps(query_date) + + # endregion + + # region 生命周期 + async def close(self) -> None: + """关闭 HTTP 客户端。""" + await self._http.aclose() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *args: object) -> None: + await self.close() + + # endregion diff --git a/mi-fitness-python/src/mi_fitness/client/base.py b/mi-fitness-python/src/mi_fitness/client/base.py new file mode 100644 index 0000000..9f9fa8c --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/client/base.py @@ -0,0 +1,205 @@ +"""RC4 加密请求基础层。""" + +from __future__ import annotations + +import json +from typing import Any, NoReturn + +from mi_fitness.const import ( + DEFAULT_USER_AGENT, + ERR_NOT_RELATIVES, + ERR_NOT_SHARED_DATA_TYPE, + HEALTH_API_BASE, + REGION_TAG, +) +from mi_fitness.crypto import build_encrypted_params, decrypt_response +from mi_fitness.exceptions import ( + APIError, + AuthError, + DataNotSharedError, + DataOutOfSharedTimeScopeError, + FamilyMemberNotFoundError, + TokenExpiredError, +) +from mi_fitness.http import RetryAsyncClient +from mi_fitness.models import AuthToken + + +def _coerce_api_code(value: Any, default: int = -1) -> int: + """尽力兼容 int / str 形式的业务码。""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _extract_api_message(result: dict[str, Any]) -> str: + """兼容不同字段名的错误消息。""" + for key in ("message", "msg", "desc", "description"): + value = result.get(key) + if isinstance(value, str) and value.strip(): + return value + return "未知错误" + + +def _is_time_scope_error(message: str) -> bool: + """识别“超出亲友共享时间范围”类错误。""" + normalized = message.strip().lower() + return "time out of data shared time scope" in normalized + + +def _extract_requested_data_type(params: dict[str, Any] | None) -> str: + """从业务参数中提取当前请求的数据类型 key。""" + if not isinstance(params, dict): + return "" + key = params.get("key") + return str(key) if key is not None else "" + + +def _build_auth_cookies(token: AuthToken) -> dict[str, str]: + """构造健康接口请求所需的认证 cookie。""" + return { + "cUserId": token.c_user_id, + "serviceToken": token.service_token, + } + + +async def _send_encrypted_http_request( + http: RetryAsyncClient, + method: str, + url: str, + enc_params: dict[str, Any], + cookies: dict[str, str], +): + """按 HTTP 方法发送已加密请求。""" + if method.upper() == "GET": + return await http.get(url, params=enc_params, cookies=cookies) + return await http.post(url, data=enc_params, cookies=cookies) + + +def _raise_for_http_status(resp: Any, method: str, path: str) -> None: + """将 HTTP 状态码转换为 SDK 异常。""" + if resp.status_code == 401: + raise TokenExpiredError(f"认证已过期: {method} {path} -> 401") + if resp.status_code != 200: + raise APIError( + f"API 请求失败: {method} {path} -> {resp.status_code}", + status_code=resp.status_code, + response_body=resp.text, + ) + + +def _decrypt_result(ssecurity: str, nonce: str, resp: Any) -> dict[str, Any]: + """解密并校验响应体。""" + try: + result = decrypt_response(ssecurity, nonce, resp.text) + except Exception as e: + raise APIError( + f"响应解密失败: {e}", + status_code=resp.status_code, + response_body=resp.text[:200], + ) from e + + if not isinstance(result, dict): + raise APIError( + f"解密后非 JSON 对象: {type(result)}", + response_body=str(result)[:200], + ) + return result + + +def _raise_for_business_code( + code: int, + result: dict[str, Any], + *, + params: dict[str, Any] | None = None, +) -> NoReturn: + """将业务错误码映射为 SDK 异常。""" + msg = _extract_api_message(result) + body = json.dumps(result, ensure_ascii=False) + + if code == ERR_NOT_RELATIVES: + raise FamilyMemberNotFoundError(f"非亲友关系 (code={code}): {msg}") + + if code == ERR_NOT_SHARED_DATA_TYPE: + data_type = _extract_requested_data_type(params) + suffix = f", key={data_type}" if data_type else "" + if _is_time_scope_error(msg): + raise DataOutOfSharedTimeScopeError( + f"超出亲友共享时间范围 (code={code}{suffix}): {msg}", + data_type=data_type, + ) + raise DataNotSharedError( + f"未共享该数据类型 (code={code}{suffix}): {msg}", + data_type=data_type, + ) + + raise APIError( + f"API 业务错误 (code={code}): {msg}", + code=code, + response_body=body, + ) + + +def create_api_http() -> RetryAsyncClient: + """创建 API 请求专用的 HTTP 客户端。""" + return RetryAsyncClient( + timeout=30.0, + headers={ + "User-Agent": DEFAULT_USER_AGENT, + "region_tag": REGION_TAG, + "handleparams": "true", + }, + ) + + +async def encrypted_request( + http: RetryAsyncClient, + token: AuthToken, + method: str, + path: str, + base_url: str = HEALTH_API_BASE, + *, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """发送 RC4 加密的 API 请求并解密响应。 + + Args: + http: HTTP 客户端。 + token: 已登录的 AuthToken。 + method: HTTP 方法(GET / POST)。 + path: API 路径。 + base_url: API 基础 URL。 + params: 业务参数。 + + Returns: + 解密后的响应 JSON dict。 + + Raises: + APIError: 请求或解密失败。 + AuthError: 当前未登录。 + TokenExpiredError: 401 认证过期。 + FamilyMemberNotFoundError: 非亲友关系。 + DataNotSharedError: 亲友未共享当前请求的数据类型。 + DataOutOfSharedTimeScopeError: 请求日期超出亲友共享时间范围。 + """ + if not token.service_token or not token.ssecurity: + raise AuthError("未登录,请先调用 auth.login()") + + ssecurity = token.ssecurity + signing_path = path + if path == "/healthapp/service/gen_download_url": + signing_path = "/service/gen_download_url" + enc_params = build_encrypted_params(method, signing_path, ssecurity, params) + nonce = enc_params["_nonce"] + cookies = _build_auth_cookies(token) + url = base_url.rstrip("/") + path + resp = await _send_encrypted_http_request(http, method, url, enc_params, cookies) + _raise_for_http_status(resp, method, path) + result = _decrypt_result(ssecurity, nonce, resp) + + code = _coerce_api_code(result.get("code"), default=-1) + if code != 0: + _raise_for_business_code(code, result, params=params) + + return result diff --git a/mi-fitness-python/src/mi_fitness/client/data.py b/mi-fitness-python/src/mi_fitness/client/data.py new file mode 100644 index 0000000..55f7f0e --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/client/data.py @@ -0,0 +1,522 @@ +"""健康数据查询(心率、睡眠、步数等)。""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import UTC, date, datetime, timedelta, timezone +from typing import TYPE_CHECKING, TypeVar + +from pydantic import ValidationError + +from mi_fitness.const import ( + DATA_KEY_BLOOD_PRESSURE, + DATA_KEY_CALORIES, + DATA_KEY_GOAL, + DATA_KEY_HEART_RATE, + DATA_KEY_INTENSITY, + DATA_KEY_SLEEP, + DATA_KEY_SPO2, + DATA_KEY_STEPS, + DATA_KEY_VALID_STAND, + DATA_KEY_WEIGHT, + DATA_TAG_DAILY_REPORT, + RELATIVES_AGGREGATED_DATA_PATH, + RELATIVES_FITNESS_DATA_PATH, + RELATIVES_LATEST_DATA_PATH, +) +from mi_fitness.exceptions import DataNotSharedError, DataOutOfSharedTimeScopeError +from mi_fitness.models import ( + AggregatedDataItem, + AggregatedDataResponse, + BloodPressureData, + CaloriesData, + DailySummary, + GoalData, + HeartRateData, + IntensityData, + LatestDataItem, + LatestDataResponse, + LatestDataSnapshot, + SleepData, + Spo2Data, + Spo2SummaryData, + StepData, + ValidStandData, + WeightData, +) + +if TYPE_CHECKING: + from mi_fitness.client.api import MiHealthClient + +_SeriesDataT = TypeVar("_SeriesDataT") +_LatestMetricT = TypeVar( + "_LatestMetricT", + GoalData, + BloodPressureData, + CaloriesData, + IntensityData, + Spo2Data, + ValidStandData, + WeightData, +) + + +async def _get_first_shared_or_none( + fetcher: Callable[[], Awaitable[list[_SeriesDataT]]], +) -> _SeriesDataT | None: + """摘要接口专用:未共享时返回 None,其余异常继续向上抛。""" + try: + result = await fetcher() + except DataOutOfSharedTimeScopeError: + raise + except DataNotSharedError: + return None + return result[0] if result else None + + +# region 最新数据 +async def _get_latest_response(client: MiHealthClient, relative_uid: int) -> LatestDataResponse: + """获取并解析最新数据响应。""" + resp = await client._request( + "GET", + RELATIVES_LATEST_DATA_PATH, + params={"relative_uid": relative_uid}, + ) + return LatestDataResponse(**resp) + + +async def get_latest_items(client: MiHealthClient, relative_uid: int) -> list[LatestDataItem]: + """获取亲友的原始最新数据项列表。""" + return (await _get_latest_response(client, relative_uid)).data_items + + +async def get_latest_data(client: MiHealthClient, relative_uid: int) -> LatestDataSnapshot: + """获取亲友的最新数据快照(强类型聚合视图)。""" + return (await _get_latest_response(client, relative_uid)).snapshot + + +async def _get_latest_metric( + client: MiHealthClient, + relative_uid: int, + *, + attr_name: str, + shared_key: str, +) -> _LatestMetricT | None: + """读取最新快照中的单项数据,并在未共享时抛出明确异常。""" + latest = await get_latest_data(client, relative_uid) + value = getattr(latest, attr_name) + if value is not None: + return value + + shared_types = await client.get_shared_data_types(relative_uid) + if shared_key not in shared_types: + raise DataNotSharedError(f"未共享该数据类型: {shared_key}", data_type=shared_key) + return None + + +# endregion + + +# region 聚合数据 +async def get_aggregated_data( + client: MiHealthClient, + relative_uid: int, + key: str, + start_time: int, + end_time: int, + *, + tag: str = DATA_TAG_DAILY_REPORT, + limit: int = 30, +) -> AggregatedDataResponse: + """获取亲友的聚合数据。""" + resp = await client._request( + "GET", + RELATIVES_AGGREGATED_DATA_PATH, + params={ + "relative_uid": relative_uid, + "key": key, + "tag": tag, + "start_time": start_time, + "end_time": end_time, + "limit": limit, + }, + ) + return AggregatedDataResponse(**resp) + + +async def get_fitness_data( + client: MiHealthClient, + relative_uid: int, + key: str, + start_time: int, + end_time: int, + *, + limit: int = 30, +) -> AggregatedDataResponse: + """获取亲友的原始测量/事件数据(如体重、血压、异常心率等)。""" + resp = await client._request( + "GET", + RELATIVES_FITNESS_DATA_PATH, + params={ + "relative_uid": relative_uid, + "key": key, + "start_time": start_time, + "end_time": end_time, + "limit": limit, + }, + ) + return AggregatedDataResponse(**resp) + + +# endregion + + +# region 便捷方法 +def _date_to_timestamps(query_date: date | None = None) -> tuple[int, int]: + """将日期转换为当日 00:00 ~ 23:59:59 的时间戳。""" + d = query_date or date.today() + tz = timezone(timedelta(hours=8)) + start = int(datetime(d.year, d.month, d.day, tzinfo=tz).timestamp()) + end = start + 86400 - 1 + return start, end + + +def _build_window_timestamps(query_date: date | None, days: int) -> tuple[int, int, int]: + """构造以 ``query_date`` 为结束日的查询窗口。""" + window_days = max(days, 1) + _, end = _date_to_timestamps(query_date) + start = end - 86400 * window_days + 1 + return start, end, window_days + + +def _parse_series_items( + items: list[AggregatedDataItem], + parser: Callable[[AggregatedDataItem], _SeriesDataT], +) -> list[_SeriesDataT]: + """逐条解析数据项,跳过单条脏数据。""" + series: list[_SeriesDataT] = [] + for item in items: + try: + series.append(parser(item)) + except ValidationError: + continue + return series + + +async def _get_aggregated_series( + client: MiHealthClient, + relative_uid: int, + key: str, + parser: Callable[[AggregatedDataItem], _SeriesDataT], + query_date: date | None = None, + *, + days: int = 1, +) -> list[_SeriesDataT]: + """按日期范围拉取聚合数据并转换为目标模型。 + + ``query_date`` 视为窗口结束日;``days=7`` 表示获取该日及之前 6 天的聚合数据。 + """ + start, end, window_days = _build_window_timestamps(query_date, days) + resp = await get_aggregated_data(client, relative_uid, key, start, end, limit=window_days) + return _parse_series_items(resp.data_items, parser) + + +async def _get_fitness_series( + client: MiHealthClient, + relative_uid: int, + key: str, + parser: Callable[[AggregatedDataItem], _SeriesDataT], + query_date: date | None = None, + *, + days: int = 1, +) -> list[_SeriesDataT]: + """按时间窗口拉取原始测量记录。 + + ``get_fitness_data`` 不是按天一条的聚合接口,因此固定使用较宽松的 ``limit=30``, + 以避免一周内存在多次测量时被 ``days`` 误伤截断。 + """ + start, end, window_days = _build_window_timestamps(query_date, days) + resp = await get_fitness_data( + client, + relative_uid, + key, + start, + end, + limit=max(window_days, 30), + ) + return _parse_series_items(resp.data_items, parser) + + +async def get_heart_rate( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[HeartRateData]: + """获取亲友的心率数据。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_HEART_RATE, + AggregatedDataItem.as_heart_rate, + query_date, + days=days, + ) + + +async def get_sleep( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[SleepData]: + """获取亲友的睡眠数据。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_SLEEP, + AggregatedDataItem.as_sleep, + query_date, + days=days, + ) + + +async def get_steps( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[StepData]: + """获取亲友的步数数据。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_STEPS, + AggregatedDataItem.as_steps, + query_date, + days=days, + ) + + +async def get_calories_history( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[CaloriesData]: + """获取亲友按天聚合的活动卡路里数据。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_CALORIES, + AggregatedDataItem.as_calories, + query_date, + days=days, + ) + + +async def get_valid_stand_history( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[ValidStandData]: + """获取亲友按天聚合的有效站立次数。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_VALID_STAND, + AggregatedDataItem.as_valid_stand, + query_date, + days=days, + ) + + +async def get_intensity_history( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[IntensityData]: + """获取亲友按天聚合的中高强度活动时长。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_INTENSITY, + AggregatedDataItem.as_intensity, + query_date, + days=days, + ) + + +async def get_spo2_history( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[Spo2SummaryData]: + """获取亲友按天聚合的血氧摘要。""" + return await _get_aggregated_series( + client, + relative_uid, + DATA_KEY_SPO2, + AggregatedDataItem.as_spo2, + query_date, + days=days, + ) + + +async def get_weight_history( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[WeightData]: + """获取亲友在指定窗口内的体重测量记录。""" + return await _get_fitness_series( + client, + relative_uid, + DATA_KEY_WEIGHT, + AggregatedDataItem.as_weight, + query_date, + days=days, + ) + + +async def get_blood_pressure_history( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, + *, + days: int = 1, +) -> list[BloodPressureData]: + """获取亲友在指定窗口内的血压测量记录。""" + return await _get_fitness_series( + client, + relative_uid, + DATA_KEY_BLOOD_PRESSURE, + AggregatedDataItem.as_blood_pressure, + query_date, + days=days, + ) + + +async def get_weight(client: MiHealthClient, relative_uid: int) -> WeightData | None: + """获取亲友最新体重数据。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="weight", + shared_key=DATA_KEY_WEIGHT, + ) + + +async def get_goal(client: MiHealthClient, relative_uid: int) -> GoalData | None: + """获取亲友最新目标完成情况。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="goal", + shared_key=DATA_KEY_GOAL, + ) + + +async def get_blood_pressure(client: MiHealthClient, relative_uid: int) -> BloodPressureData | None: + """获取亲友最新血压数据。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="blood_pressure", + shared_key=DATA_KEY_BLOOD_PRESSURE, + ) + + +async def get_calories(client: MiHealthClient, relative_uid: int) -> CaloriesData | None: + """获取亲友最新活动卡路里。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="calories", + shared_key=DATA_KEY_CALORIES, + ) + + +async def get_valid_stand(client: MiHealthClient, relative_uid: int) -> ValidStandData | None: + """获取亲友最新有效站立次数。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="valid_stand", + shared_key=DATA_KEY_VALID_STAND, + ) + + +async def get_intensity(client: MiHealthClient, relative_uid: int) -> IntensityData | None: + """获取亲友最新中高强度活动时长。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="intensity", + shared_key=DATA_KEY_INTENSITY, + ) + + +async def get_spo2(client: MiHealthClient, relative_uid: int) -> Spo2Data | None: + """获取亲友最新血氧数据。""" + return await _get_latest_metric( + client, + relative_uid, + attr_name="spo2", + shared_key=DATA_KEY_SPO2, + ) + + +async def get_daily_summary( + client: MiHealthClient, + relative_uid: int, + query_date: date | None = None, +) -> DailySummary: + """获取亲友的每日综合健康摘要(并发请求心率、睡眠、步数)。""" + d = query_date or date.today() + + heart_rate, sleep, steps = await asyncio.gather( + _get_first_shared_or_none(lambda: get_heart_rate(client, relative_uid, d)), + _get_first_shared_or_none(lambda: get_sleep(client, relative_uid, d)), + _get_first_shared_or_none(lambda: get_steps(client, relative_uid, d)), + ) + + return DailySummary( + date=d.isoformat(), + relative_uid=relative_uid, + heart_rate=heart_rate, + sleep=sleep, + steps=steps, + ) + + +async def get_latest_daily_summary( + client: MiHealthClient, + relative_uid: int, +) -> DailySummary: + """获取亲友最近一次有同步数据的每日综合健康摘要。""" + member = await client.find_relative(relative_uid) + query_date = date.today() + if member.latest_data_time > 0: + query_date = datetime.fromtimestamp(member.latest_data_time, tz=UTC).date() + else: + latest = await get_latest_data(client, relative_uid) + if latest.updated_time > 0: + query_date = datetime.fromtimestamp(latest.updated_time, tz=UTC).date() + return await get_daily_summary(client, relative_uid, query_date) + + +# endregion diff --git a/mi-fitness-python/src/mi_fitness/client/messages.py b/mi-fitness-python/src/mi_fitness/client/messages.py new file mode 100644 index 0000000..cc2f6c2 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/client/messages.py @@ -0,0 +1,52 @@ +"""消息(邀请通知)查询。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from loguru import logger + +from mi_fitness.const import ( + MESSAGE_CHECK_NEW_PATH, + MESSAGE_GET_LIST_PATH, + MESSAGE_MODULE_RELATIVES, +) +from mi_fitness.models import CheckNewMsgResponse, InviteMessage, MessageListResponse + +if TYPE_CHECKING: + from mi_fitness.client.api import MiHealthClient + + +async def get_invite_messages( + client: MiHealthClient, + *, + limit: int = 30, + pending_only: bool = False, +) -> list[InviteMessage]: + """获取亲友邀请消息列表。""" + resp = await client._request( + "POST", + MESSAGE_GET_LIST_PATH, + params={"module": MESSAGE_MODULE_RELATIVES, "limit": limit}, + ) + parsed = MessageListResponse(**resp) + messages = parsed.messages + if pending_only: + messages = [m for m in messages if m.is_pending] + logger.debug( + "获取邀请消息: {}条 (待处理: {}条)", + len(parsed.messages), + sum(1 for m in parsed.messages if m.is_pending), + ) + return messages + + +async def has_new_invite(client: MiHealthClient) -> bool: + """检查是否有新的亲友邀请消息。""" + resp = await client._request( + "POST", + MESSAGE_CHECK_NEW_PATH, + params={"module": [MESSAGE_MODULE_RELATIVES], "begin_time": 0}, + ) + parsed = CheckNewMsgResponse(**resp) + return parsed.has_new(MESSAGE_MODULE_RELATIVES) diff --git a/mi-fitness-python/src/mi_fitness/client/relatives.py b/mi-fitness-python/src/mi_fitness/client/relatives.py new file mode 100644 index 0000000..c579e41 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/client/relatives.py @@ -0,0 +1,236 @@ +"""亲友关系管理(添加 / 删除 / 设置)。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from loguru import logger + +from mi_fitness.const import ( + ALL_SHARED_DATA_TYPES, + RELATIVES_DELETE_PATH, + RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, + RELATIVES_GET_FAMILY_MEMBER_PATH, + RELATIVES_GET_INVITE_ID_PATH, + RELATIVES_GET_SHARED_TYPES_PATH, + RELATIVES_GET_TOPIC_SUBS_PATH, + RELATIVES_LIST_PATH, + RELATIVES_OPERATE_INVITE_PATH, + RELATIVES_SEND_INVITE_PATH, + RELATIVES_VERIFY_USER_PATH, + VERIFY_TYPE_XIAOMI_ID, +) +from mi_fitness.exceptions import FamilyMemberNotFoundError +from mi_fitness.models import ( + DeleteRelativeResponse, + FamilyMember, + FamilyMemberResponse, + InviteResponse, + InviteUniqueIdResponse, + OperateInviteResponse, + RelativeListResponse, + SharedDataTypesResponse, + VerifiedUserInfo, + VerifyUserResponse, +) + +if TYPE_CHECKING: + from mi_fitness.client.api import MiHealthClient + +_DEFAULT_TOPICS = ("abnormal_event",) + + +def _build_auth_content( + shared_data_types: list[str] | None, + auth_time_range: int, +) -> dict[str, Any]: + """构造亲友邀请相关的 auth_content。""" + return { + "auth_time_range": auth_time_range, + "auth_data": shared_data_types or ALL_SHARED_DATA_TYPES, + } + + +async def get_relatives(client: MiHealthClient) -> list[FamilyMember]: + """获取亲友列表。""" + resp = await client._request("GET", RELATIVES_LIST_PATH) + parsed = RelativeListResponse(**resp) + members = parsed.relatives + logger.info("获取到 {} 位亲友", len(members)) + return members + + +async def find_relative(client: MiHealthClient, keyword: str | int) -> FamilyMember: + """按备注名或 UID 查找亲友。""" + members = await get_relatives(client) + for m in members: + if (isinstance(keyword, int) and m.relative_uid == keyword) or ( + isinstance(keyword, str) and keyword.lower() in m.relative_note.lower() + ): + return m + raise FamilyMemberNotFoundError(f"未找到亲友: {keyword}") + + +async def verify_user( + client: MiHealthClient, + verify_id: int, + *, + verify_type: int = VERIFY_TYPE_XIAOMI_ID, +) -> VerifiedUserInfo | None: + """按 UID 或扫码 ID 验证用户信息。""" + resp = await client._request( + "GET", + RELATIVES_VERIFY_USER_PATH, + params={"verify_id": verify_id, "verify_type": verify_type}, + ) + parsed = VerifyUserResponse(**resp) + return parsed.user_info + + +async def invite_relative( + client: MiHealthClient, + relative_uid: int, + *, + shared_data_types: list[str] | None = None, + auth_time_range: int = 3, + relative_note: str = "", +) -> bool: + """发送亲友邀请。""" + params: dict[str, Any] = { + "auth_content": _build_auth_content(shared_data_types, auth_time_range), + "relative_uid": relative_uid, + } + if relative_note: + params["relative_note"] = relative_note + + resp = await client._request("POST", RELATIVES_SEND_INVITE_PATH, params=params) + parsed = InviteResponse(**resp) + logger.info("邀请发送 {} (uid={})", "成功" if parsed.success else "失败", relative_uid) + return parsed.success + + +async def accept_invite( + client: MiHealthClient, + invite_id: int, + msg_id: int, + *, + shared_data_types: list[str] | None = None, + auth_time_range: int = 3, +) -> bool: + """同意亲友邀请。""" + return await _operate_invite( + client, + invite_id, + msg_id, + operate=1, + shared_data_types=shared_data_types, + auth_time_range=auth_time_range, + ) + + +async def reject_invite( + client: MiHealthClient, + invite_id: int, + msg_id: int, +) -> bool: + """拒绝亲友邀请。""" + return await _operate_invite(client, invite_id, msg_id, operate=2) + + +async def _operate_invite( + client: MiHealthClient, + invite_id: int, + msg_id: int, + *, + operate: int, + shared_data_types: list[str] | None = None, + auth_time_range: int = 3, +) -> bool: + """操作亲友邀请(内部)。""" + params: dict[str, Any] = { + "auth_content": _build_auth_content(shared_data_types, auth_time_range), + "invite_id": invite_id, + "msg_id": msg_id, + "operate": operate, + } + resp = await client._request("POST", RELATIVES_OPERATE_INVITE_PATH, params=params) + parsed = OperateInviteResponse(**resp) + op_name = "同意" if operate == 1 else "拒绝" + logger.info( + "{}邀请 {} (invite_id={})", + op_name, + "成功" if parsed.success else "失败", + invite_id, + ) + return parsed.success + + +async def delete_relative(client: MiHealthClient, relative_uid: int) -> bool: + """删除亲友关系。""" + resp = await client._request( + "POST", + RELATIVES_DELETE_PATH, + params={"relative_uid": relative_uid}, + ) + parsed = DeleteRelativeResponse(**resp) + logger.info("删除亲友 {} (uid={})", "成功" if parsed.success else "失败", relative_uid) + return parsed.success + + +async def get_invite_link_id(client: MiHealthClient) -> int: + """获取二维码邀请链接 ID。""" + resp = await client._request("GET", RELATIVES_GET_INVITE_ID_PATH) + parsed = InviteUniqueIdResponse(**resp) + logger.debug("获取邀请 ID: {}", parsed.invite_link_id) + return parsed.invite_link_id + + +async def get_shared_data_types( + client: MiHealthClient, + relative_uid: int, + *, + direction: int = 2, +) -> list[str]: + """获取亲友共享的数据类型列表。""" + resp = await client._request( + "GET", + RELATIVES_GET_SHARED_TYPES_PATH, + params={"relative_uid": relative_uid, "type": direction}, + ) + parsed = SharedDataTypesResponse(**resp) + return parsed.keys + + +async def get_applied_shared_data_types( + client: MiHealthClient, + relative_uid: int, +) -> list[str]: + """获取已申请的共享数据类型。""" + resp = await client._request( + "GET", + RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, + params={"relative_uid": relative_uid}, + ) + return resp.get("result", {}).get("keys", []) + + +async def get_family_members(client: MiHealthClient) -> list[dict[str, Any]]: + """获取家庭成员列表。""" + resp = await client._request("GET", RELATIVES_GET_FAMILY_MEMBER_PATH) + parsed = FamilyMemberResponse(**resp) + return parsed.family_user_list + + +async def get_topic_subscriptions( + client: MiHealthClient, + relative_uid: int, + topics: list[str] | None = None, +) -> dict[str, Any]: + """获取亲友的消息订阅状态。""" + topic_list = list(topics or _DEFAULT_TOPICS) + resp = await client._request( + "GET", + RELATIVES_GET_TOPIC_SUBS_PATH, + params={"relative_uid": relative_uid, "topics": topic_list}, + ) + return resp.get("result", {}) diff --git a/mi-fitness-python/src/mi_fitness/const.py b/mi-fitness-python/src/mi_fitness/const.py new file mode 100644 index 0000000..b2fadaf --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/const.py @@ -0,0 +1,95 @@ +"""常量与配置。""" + +# region 小米账号 OAuth 端点 +XIAOMI_LOGIN_URL = "https://account.xiaomi.com/pass/serviceLogin" +XIAOMI_LOGIN_AUTH_URL = "https://account.xiaomi.com/pass/serviceLoginAuth2" +XIAOMI_PREFERENCE_URL = "https://account.xiaomi.com/pass/preference" +XIAOMI_PHONE_INFO_URL = "https://account.xiaomi.com/pass/phoneInfo" +XIAOMI_SEND_TICKET_URL = "https://account.xiaomi.com/pass/sendServiceLoginTicket" +XIAOMI_TICKET_AUTH_URL = "https://account.xiaomi.com/pass/serviceLoginTicketAuth" +XIAOMI_QR_LOGIN_URL = "https://account.xiaomi.com/longPolling/loginUrl" +# endregion + +# region 服务 SID(serviceLogin 的 sid 参数) +SERVICE_SID_HEALTH = "miothealth" +# endregion + +# region STS (安全令牌交换) 端点 +STS_HEALTH_URL = "https://sts-hlth.io.mi.com/healthapp/sts" +# endregion + +# region API 基础 URL +HEALTH_API_BASE = "https://ru.hlth.io.mi.com" +# endregion + +# region 亲友 API 路径 +RELATIVES_LIST_PATH = "/app/v1/relatives/get_relative_list" +RELATIVES_LATEST_DATA_PATH = "/app/v1/data/get_latest_fitness_data" +RELATIVES_AGGREGATED_DATA_PATH = "/app/v1/data/get_aggregated_fitness_data_by_time" +RELATIVES_FITNESS_DATA_PATH = "/app/v1/data/get_fitness_data_by_time" +RELATIVES_VERIFY_USER_PATH = "/app/v1/relatives/verify_userinfo_by_id" +RELATIVES_SEND_INVITE_PATH = "/app/v1/relatives/send_invite" +RELATIVES_OPERATE_INVITE_PATH = "/app/v1/relatives/operate_invite" +RELATIVES_DELETE_PATH = "/app/v1/relatives/delete_relative" +RELATIVES_GET_SHARED_TYPES_PATH = "/app/v1/relatives/get_shared_data_types" +RELATIVES_GET_APPLIED_SHARED_TYPES_PATH = "/app/v1/relatives/get_applied_shared_data_types" +RELATIVES_GET_FAMILY_MEMBER_PATH = "/app/v1/relatives/get_family_member" +RELATIVES_GET_INVITE_ID_PATH = "/app/v1/relatives/get_invite_unique_id" +RELATIVES_GET_TOPIC_SUBS_PATH = "/app/v1/relatives/get_topic_subscriptions" +# endregion + +# region 消息 API 路径 +MESSAGE_GET_LIST_PATH = "/app/v1/message/get_msg_list" +MESSAGE_CHECK_NEW_PATH = "/app/v1/message/check_new_msg" +MESSAGE_MODULE_RELATIVES = 1 +# endregion + +# region 业务错误码 +ERR_NOT_RELATIVES = -4002001 +ERR_NOT_SHARED_DATA_TYPE = -4002004 +ERR_DEVICE_UNTRUST = 70016 +# endregion + +# region 数据类型 key(用于 get_aggregated_data / get_fitness_data 请求) +DATA_KEY_GOAL = "goal" +DATA_KEY_HEART_RATE = "heart_rate" +DATA_KEY_SLEEP = "sleep" +DATA_KEY_BLOOD_PRESSURE = "blood_pressure" +DATA_KEY_STEPS = "steps" +DATA_KEY_CALORIES = "calories" +DATA_KEY_VALID_STAND = "valid_stand" +DATA_KEY_INTENSITY = "intensity" +DATA_KEY_WEIGHT = "weight" +DATA_KEY_SPO2 = "spo2" +DATA_TAG_DAILY_REPORT = "daily_report" +# endregion + +# region 可共享数据类型(send_invite 的 auth_data 全量) +ALL_SHARED_DATA_TYPES: tuple[str, ...] = ( + "goal", + "heart_rate", + "sleep", + "blood_pressure", + "steps", + "calories", + "valid_stand", + "intensity", + "weight", + "spo2", +) +# endregion + +# region verify_userinfo_by_id 的 verify_type 枚举 +VERIFY_TYPE_XIAOMI_ID = 1 +# endregion + +# region HTTP 公共 Header +DEFAULT_USER_AGENT = "Android-12-3.53.1-vivo-V2284A" +DEFAULT_LOGIN_USER_AGENT = ( + "Dalvik/2.1.0 (Linux; U; Android 12; V2284A Build/ab8c0d1.1) " + "APP/mi.health APPV/353001 MK/VjIyODRB " + "SDKV/5.3.0.release.68 CPN/com.mi.health PassportSDK/" +) +APP_NAME = "com.mi.health" +REGION_TAG = "ru" +# endregion diff --git a/mi-fitness-python/src/mi_fitness/crypto.py b/mi-fitness-python/src/mi_fitness/crypto.py new file mode 100644 index 0000000..00826d2 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/crypto.py @@ -0,0 +1,289 @@ +"""小米云服务加密模块。""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import struct +import time +from typing import Any + + +def _rc4_crypt(key: bytes, data: bytes, *, skip: int = 1024) -> bytes: + """RC4 加密/解密(带前 N 字节跳过,防止密钥流弱点)。 + + Args: + key: RC4 密钥。 + data: 待加密/解密的数据。 + skip: 跳过前 N 字节的密钥流(默认 1024)。 + + Returns: + 加密/解密后的字节数据。 + """ + s = list(range(256)) + j = 0 + + # KSA (Key-Scheduling Algorithm) + for i in range(256): + j = (j + s[i] + key[i % len(key)]) & 0xFF + s[i], s[j] = s[j], s[i] + + # PRGA (Pseudo-Random Generation Algorithm) + i = 0 + j = 0 + + # 跳过前 skip 字节密钥流 + for _ in range(skip): + i = (i + 1) & 0xFF + j = (j + s[i]) & 0xFF + s[i], s[j] = s[j], s[i] + + # 加密/解密 + result = bytearray(len(data)) + for idx in range(len(data)): + i = (i + 1) & 0xFF + j = (j + s[i]) & 0xFF + s[i], s[j] = s[j], s[i] + result[idx] = data[idx] ^ s[(s[i] + s[j]) & 0xFF] + + return bytes(result) + + +def generate_nonce() -> str: + """生成请求 nonce。 + + 格式: base64(random_8_bytes + minutes_since_epoch_4bytes_BE) + + Returns: + Base64 编码的 nonce 字符串。 + """ + random_part = os.urandom(8) + minutes = int(time.time() / 60) + time_part = struct.pack(">I", minutes) + return base64.b64encode(random_part + time_part).decode() + + +def compute_signed_nonce(ssecurity: str, nonce: str) -> str: + """计算签名 nonce(用于密钥派生)。 + + signed_nonce = base64(SHA256(b64decode(ssecurity) + b64decode(nonce))) + + Args: + ssecurity: 登录时获取的 ssecurity(base64 编码)。 + nonce: 请求 nonce。 + + Returns: + Base64 编码的 signed_nonce(同时作为 RC4 和 HMAC 的密钥)。 + """ + hash_val = hashlib.sha256(base64.b64decode(ssecurity) + base64.b64decode(nonce)).digest() + return base64.b64encode(hash_val).decode() + + +# endregion + + +# region 签名生成 +def _sha1_b64(message: str) -> str: + """纯 SHA1 哈希 → Base64 编码。 + + App 使用 MessageDigest("SHA1") 而非 HMAC 来生成签名。 + + Args: + message: 待哈希的 UTF-8 字符串。 + + Returns: + Base64 编码的 SHA1 摘要(28 字符)。 + """ + digest = hashlib.sha1(message.encode("utf-8")).digest() + return base64.b64encode(digest).decode() + + +def _build_sig_message( + method: str, + url_path: str, + params: dict[str, str], + signed_nonce: str, +) -> str: + """构建签名消息字符串(z94.b 格式)。 + + 格式: METHOD&/path&k1=v1&k2=v2&...&signedNonce_b64 + - method 大写 + - path 带前导 / + - params 按 key 字典序(TreeMap)排序 + - 最后追加 signedNonce 的 base64 字符串 + + Args: + method: HTTP 方法。 + url_path: URL 路径(须含前导 /)。 + params: 参数字典(已排除空 key/value)。 + signed_nonce: Base64 编码的 signed_nonce。 + + Returns: + 用 & 连接的签名消息。 + """ + parts: list[str] = [method.upper()] + if not url_path.startswith("/"): + url_path = "/" + url_path + parts.append(url_path) + for k in sorted(params.keys()): + parts.append(f"{k}={params[k]}") + parts.append(signed_nonce) + return "&".join(parts) + + +def _rc4_stream_encrypt_values( + key_bytes: bytes, + sorted_entries: list[tuple[str, str]], +) -> dict[str, str]: + """用连续 RC4 流加密多个值。 + + 模拟 App 中 d8k 的行为:构造时 drop 1024 字节, + 然后对 TreeMap 中每个 entry 的 value 按排序顺序 + 依次加密,共用同一个 RC4 密钥流。 + + Args: + key_bytes: RC4 密钥(signed_nonce 的原始字节)。 + sorted_entries: 按 key 排序的 (key, value) 对列表。 + + Returns: + {key: base64(encrypted_value)} 字典。 + """ + # 将所有 value 拼接,一次性过 RC4 流 + all_bytes = b"".join(v.encode("utf-8") for _, v in sorted_entries) + encrypted_all = _rc4_crypt(key_bytes, all_bytes, skip=1024) + + result: dict[str, str] = {} + pos = 0 + for k, v in sorted_entries: + vlen = len(v.encode("utf-8")) + result[k] = base64.b64encode(encrypted_all[pos : pos + vlen]).decode() + pos += vlen + return result + + +# endregion + + +# region 数据加密/解密 +def encrypt_data(signed_nonce: str, plaintext: str) -> str: + """用 RC4 加密数据。 + + Args: + signed_nonce: 密钥(base64 编码的 SHA256 哈希)。 + plaintext: 明文 JSON 字符串。 + + Returns: + Base64 编码的密文。 + """ + key = base64.b64decode(signed_nonce) + encrypted = _rc4_crypt(key, plaintext.encode("utf-8")) + return base64.b64encode(encrypted).decode() + + +def decrypt_data(signed_nonce: str, ciphertext_b64: str) -> str: + """用 RC4 解密数据。 + + Args: + signed_nonce: 密钥(base64 编码的 SHA256 哈希)。 + ciphertext_b64: Base64 编码的密文。 + + Returns: + 解密后的明文字符串。 + """ + key = base64.b64decode(signed_nonce) + decrypted = _rc4_crypt(key, base64.b64decode(ciphertext_b64)) + return decrypted.decode("utf-8") + + +# endregion + + +# region 封装:构建加密请求参数 +def build_encrypted_params( + method: str, + url_path: str, + ssecurity: str, + params: dict[str, Any] | None = None, +) -> dict[str, str]: + """构建完整的加密请求参数。 + + 流程(对应 App 中 ua4.c 方法): + 1. 计算 signed_nonce = base64(SHA256(ssecurity + nonce)) + 2. 构建原始参数 TreeMap(排除空 key/value) + 3. rc4_hash__ = SHA1(METHOD&/path&k=v&...&signedNonce) → base64 + 4. 将 rc4_hash__ 加入 TreeMap + 5. 用连续 RC4 流加密所有 TreeMap 值(按 key 排序,drop 1024) + 6. signature = SHA1(METHOD&/path&k=enc_v&...&signedNonce) → base64 + 7. 返回 {加密后各参数, signature, _nonce} + + Args: + method: HTTP 方法。 + url_path: API 路径(如 /app/v1/relatives/get_relative_list)。 + ssecurity: 登录时获取的 ssecurity。 + params: 要发送的参数字典(将被 JSON 序列化后加密)。 + + Returns: + 包含 data, signature, rc4_hash__, _nonce 的参数字典。 + """ + nonce = generate_nonce() + snonce = compute_signed_nonce(ssecurity, nonce) + snonce_bytes = base64.b64decode(snonce) + + # Step 1: 构建原始参数 TreeMap(排除空 key/value) + raw_tree: dict[str, str] = {} + if params: + plaintext = json.dumps(params, separators=(",", ":"), ensure_ascii=False) + raw_tree["data"] = plaintext + + # Step 2: 计算 rc4_hash__(基于原始参数) + rc4_msg = _build_sig_message(method, url_path, raw_tree, snonce) + rc4_hash_raw = _sha1_b64(rc4_msg) + + # Step 3: 将 rc4_hash__ 插入 TreeMap + raw_tree["rc4_hash__"] = rc4_hash_raw + + # Step 4: 用连续 RC4 流加密所有值 + sorted_entries = sorted(raw_tree.items()) + encrypted_values = _rc4_stream_encrypt_values(snonce_bytes, sorted_entries) + + # Step 5: 构建加密后参数 TreeMap,计算 signature + sig_msg = _build_sig_message(method, url_path, encrypted_values, snonce) + signature = _sha1_b64(sig_msg) + + # Step 6: 组装最终结果 + result: dict[str, str] = {} + for k, v in encrypted_values.items(): + result[k] = v + result["signature"] = signature + result["_nonce"] = nonce + return result + + +def decrypt_response( + ssecurity: str, + nonce: str, + ciphertext_b64: str, +) -> Any: + """解密 API 响应。 + + Args: + ssecurity: 登录时获取的 ssecurity。 + nonce: 请求时使用的 nonce。 + ciphertext_b64: Base64 编码的响应密文。 + + Returns: + 解密后的 JSON 对象。 + """ + snonce = compute_signed_nonce(ssecurity, nonce) + plaintext = decrypt_data(snonce, ciphertext_b64) + + try: + return json.loads(plaintext) + except json.JSONDecodeError: + # 可能不是 JSON,返回原始字符串 + return plaintext + + +# endregion diff --git a/mi-fitness-python/src/mi_fitness/exceptions.py b/mi-fitness-python/src/mi_fitness/exceptions.py new file mode 100644 index 0000000..9d60f46 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/exceptions.py @@ -0,0 +1,88 @@ +"""自定义异常。""" + + +class MiSDKError(Exception): + """MiSDK 基础异常。""" + + +class AuthError(MiSDKError): + """认证相关错误(登录失败、token 过期等)。""" + + +class APIError(MiSDKError): + """API 请求返回非预期结果。 + + Attributes: + status_code: HTTP 状态码。 + code: 业务错误码(``result["code"]``,仅业务层错误时有值)。 + response_body: 原始响应体。 + """ + + def __init__( + self, + message: str, + *, + status_code: int = 0, + code: int = 0, + response_body: str = "", + ): + super().__init__(message) + self.status_code = status_code + self.code = code + self.response_body = response_body + + def __repr__(self) -> str: + return f"APIError(status_code={self.status_code}, code={self.code}, message={str(self)!r})" + + +class DeviceUntrustedError(AuthError): + """设备未信任,需要短信验证码完成登录。 + + 新设备首次登录时触发 ``securityStatus != 0``,需要通过短信验证码 + 完成身份验证。可通过 ``login(verification_code_handler=...)`` 自动 + 处理,或手动调用 ``send_verification_code()`` + + ``login_with_verification_code()``。 + + Attributes: + security_status: 服务端返回的安全状态码。 + """ + + def __init__(self, message: str, *, security_status: int = 0): + super().__init__(message) + self.security_status = security_status + + +class CaptchaRequiredError(AuthError): + """触发图形验证码风控,需要人工识别通过。 + + 在登录流程中服务端可能要求完成图形验证码验证(错误码 87001)。 + 可通过 ``login(captcha_handler=...)`` 自动处理, + 或捕获此异常后自行下载 ``captcha_url`` 的验证码图片并重试。 + + Attributes: + captcha_url: 验证码图片完整 URL。 + """ + + def __init__(self, message: str, *, captcha_url: str = ""): + super().__init__(message) + self.captcha_url = captcha_url + + +class TokenExpiredError(AuthError): + """Token 已过期,需要重新登录。""" + + +class DataNotSharedError(MiSDKError): + """亲友未共享当前请求的数据类型。""" + + def __init__(self, message: str, *, data_type: str = ""): + super().__init__(message) + self.data_type = data_type + + +class DataOutOfSharedTimeScopeError(DataNotSharedError): + """请求日期超出亲友允许共享的时间范围。""" + + +class FamilyMemberNotFoundError(MiSDKError): + """找不到指定的亲友。""" diff --git a/mi-fitness-python/src/mi_fitness/http.py b/mi-fitness-python/src/mi_fitness/http.py new file mode 100644 index 0000000..3af8c22 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/http.py @@ -0,0 +1,93 @@ +"""HTTP 客户端扩展。""" + +from __future__ import annotations + +from collections.abc import Collection +from typing import Any + +import httpx +from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_exponential + +_DEFAULT_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) +_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) + + +class _RetryableStatusError(Exception): + """内部异常:用于触发 tenacity 的状态码重试。""" + + def __init__(self, response: httpx.Response): + super().__init__(f"retryable status: {response.status_code}") + self.response = response + + +def _is_retryable_error(exc: BaseException) -> bool: + """判断异常是否可重试。""" + if isinstance(exc, _RetryableStatusError): + return True + return isinstance(exc, (httpx.NetworkError, httpx.TimeoutException, httpx.RemoteProtocolError)) + + +class RetryAsyncClient(httpx.AsyncClient): + """带重试能力的异步 HTTP 客户端。 + + 继承 ``httpx.AsyncClient``,在 ``request`` 上增加 tenacity 退避重试。 + 默认仅对幂等方法启用重试,避免对发送短信/提交表单等非幂等请求重复提交。 + + Attributes: + retry_attempts: 最大重试次数(含首轮请求)。 + retry_wait_min: 指数退避最小等待秒数。 + retry_wait_max: 指数退避最大等待秒数。 + retry_wait_multiplier: 指数退避倍率。 + retry_statuses: 触发重试的 HTTP 状态码集合。 + retry_non_idempotent: 是否允许对非幂等方法重试。 + """ + + def __init__( + self, + *args: Any, + retry_attempts: int = 3, + retry_wait_min: float = 0.2, + retry_wait_max: float = 2.0, + retry_wait_multiplier: float = 0.5, + retry_statuses: Collection[int] = _DEFAULT_RETRY_STATUSES, + retry_non_idempotent: bool = False, + **kwargs: Any, + ): + super().__init__(*args, **kwargs) + self.retry_attempts = retry_attempts + self.retry_wait_min = retry_wait_min + self.retry_wait_max = retry_wait_max + self.retry_wait_multiplier = retry_wait_multiplier + self.retry_statuses = frozenset(retry_statuses) + self.retry_non_idempotent = retry_non_idempotent + + async def request( + self, method: str, url: str | httpx.URL, *args: Any, **kwargs: Any + ) -> httpx.Response: + """发送请求并按策略自动重试。""" + method_upper = method.upper() + allow_retry = self.retry_non_idempotent or method_upper in _IDEMPOTENT_METHODS + if self.retry_attempts <= 1 or not allow_retry: + return await super().request(method, url, *args, **kwargs) + + try: + async for attempt in AsyncRetrying( + stop=stop_after_attempt(self.retry_attempts), + wait=wait_exponential( + multiplier=self.retry_wait_multiplier, + min=self.retry_wait_min, + max=self.retry_wait_max, + ), + retry=retry_if_exception(_is_retryable_error), + reraise=True, + ): + with attempt: + response = await super().request(method, url, *args, **kwargs) + if response.status_code in self.retry_statuses: + raise _RetryableStatusError(response) + return response + except _RetryableStatusError as exc: + return exc.response + + # 理论上不会到达这里,保留兜底以满足类型检查。 + return await super().request(method, url, *args, **kwargs) diff --git a/mi-fitness-python/src/mi_fitness/models.py b/mi-fitness-python/src/mi_fitness/models.py new file mode 100644 index 0000000..28edcf7 --- /dev/null +++ b/mi-fitness-python/src/mi_fitness/models.py @@ -0,0 +1,1261 @@ +"""健康数据结构。""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from enum import IntEnum +from functools import cached_property +from typing import Any, TypeVar + +from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator + +_ModelT = TypeVar("_ModelT", bound=BaseModel) + + +def _ts_to_datetime(ts: int) -> datetime | None: + """将秒级时间戳转为 UTC datetime,0 返回 None。""" + if ts <= 0: + return None + return datetime.fromtimestamp(ts, tz=UTC) + + +def _coerce_bool(value: Any) -> bool: + """将接口返回的 bool / int / str 统一归一化为布尔值。""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off", ""}: + return False + return bool(value) + + +def _coerce_int(value: Any, default: int = 0) -> int: + """尽力将接口返回值转为整数。""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _coerce_optional_int(value: Any) -> int | None: + """尽力将接口返回值转为整数,失败时返回 None。""" + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _coerce_dict(value: Any) -> dict[str, Any]: + """仅保留 dict 结果,其余统一视为空对象。""" + return value if isinstance(value, dict) else {} + + +def _coerce_dict_list(value: Any) -> list[dict[str, Any]]: + """将单个对象或列表中的 dict 条目安全归一化。""" + if isinstance(value, dict): + candidates = [value] + elif isinstance(value, (list, tuple)): + candidates = list(value) + else: + return [] + return [item for item in candidates if isinstance(item, dict) and item] + + +def _coerce_str_list(value: Any) -> list[str]: + """仅保留非空字符串列表。""" + if isinstance(value, str): + return [value] if value else [] + if not isinstance(value, (list, tuple)): + return [] + return [item for item in value if isinstance(item, str) and item] + + +def _parse_model_list(value: Any, model: type[_ModelT]) -> list[_ModelT]: + """安全解析模型列表,跳过明显损坏的条目。""" + parsed: list[_ModelT] = [] + for item in _coerce_dict_list(value): + try: + parsed.append(model.model_validate(item)) + except ValidationError: + continue + return parsed + + +def _parse_model(value: Any, model: type[_ModelT]) -> _ModelT | None: + """安全解析单个模型,失败时返回 None。""" + try: + return model.model_validate(value) + except ValidationError: + return None + + +class _DictResultResponse(BaseModel): + """result 应为 dict 的响应基类。""" + + code: int = 0 + message: str = "" + result: dict[str, Any] = Field(default_factory=dict) + + @field_validator("result", mode="before") + @classmethod + def _normalize_result(cls, value: Any) -> dict[str, Any]: + return _coerce_dict(value) + + +class _ListResultResponse(BaseModel): + """result 应为列表的响应基类。""" + + code: int = 0 + message: str = "" + result: list[dict[str, Any]] = Field(default_factory=list) + + @field_validator("result", mode="before") + @classmethod + def _normalize_result(cls, value: Any) -> list[dict[str, Any]]: + return _coerce_dict_list(value) + + +# region Token 持久化 +class AuthToken(BaseModel): + """登录凭证,可序列化用于持久化存储。 + + Attributes: + user_id: 小米用户 ID (userId)。 + c_user_id: cUserId(cookie 认证用)。 + service_token: serviceToken(cookie 认证用)。 + ssecurity: 加密密钥(RC4 加解密用)。 + pass_token: passToken(可用于 STS 交换)。 + device_id: 设备标识符。 + """ + + user_id: str = "" + c_user_id: str = "" + service_token: str = "" + ssecurity: str = "" + pass_token: str = "" + device_id: str = "" + + +# endregion + + +# region 亲友 +class FamilyMember(BaseModel): + """亲友信息(来自 get_relative_list 响应)。 + + Attributes: + relative_uid: 亲友的小米用户 UID(整数)。 + relative_note: 备注名。 + relative_icon: 头像 URL。 + latest_data_time: 最新数据时间戳。 + latest_abnormal_record_time: 最新异常记录时间戳。 + source_tag: 来源标记。 + """ + + relative_uid: int + relative_note: str = "" + relative_icon: str = "" + latest_data_time: int = 0 + latest_abnormal_record_time: int | None = 0 + source_tag: int = 0 + + def __str__(self) -> str: + return f"{self.relative_note or '未命名'} (UID: {self.relative_uid})" + + +# endregion + + +# region 最新心率 +class LatestHeartRate(BaseModel): + """实时心率点(来自心率聚合数据或 get_latest_data 的 latest_hr 字段)。 + + Attributes: + bpm: 心率(次/分)。 + time: 采集时间戳。 + """ + + bpm: int = 0 + time: int = 0 + + +# endregion + + +# region 心率 +class HeartRateData(BaseModel): + """心率每日汇总(来自 get_aggregated_data key=heart_rate)。 + + Attributes: + time: 数据时间戳(当天 0 点)。 + avg_hr: 日均心率。 + avg_rhr: 日均静息心率。 + max_hr: 最大心率。 + min_hr: 最小心率。 + latest_hr: 最新一次心率采样。 + abnormal_hr_count: 异常心率次数。 + aerobic_hr_zone_duration: 有氧心率区间时长(分钟)。 + anaerobic_hr_zone_duration: 无氧心率区间时长(分钟)。 + extreme_hr_zone_duration: 极限心率区间时长(分钟)。 + fat_burning_hr_zone_duration: 燃脂心率区间时长(分钟)。 + warm_up_hr_zone_duration: 热身心率区间时长(分钟)。 + """ + + time: int = 0 + avg_hr: int = 0 + avg_rhr: int = 0 + max_hr: int = 0 + min_hr: int = 0 + latest_hr: LatestHeartRate | None = None + abnormal_hr_count: int = 0 + aerobic_hr_zone_duration: int = 0 + anaerobic_hr_zone_duration: int = 0 + extreme_hr_zone_duration: int = 0 + fat_burning_hr_zone_duration: int = 0 + warm_up_hr_zone_duration: int = 0 + + def __str__(self) -> str: + return ( + f"HeartRate(avg={self.avg_hr}bpm, resting={self.avg_rhr}, " + f"range={self.min_hr}-{self.max_hr})" + ) + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +# endregion + + +# region 睡眠片段 +class SleepSegment(BaseModel): + """睡眠片段(来自 sleep value 的 segment_details)。 + + Attributes: + bedtime: 入睡时间戳。 + wake_up_time: 醒来时间戳。 + duration: 持续时长(分钟)。 + sleep_deep_duration: 深睡时长(分钟)。 + sleep_light_duration: 浅睡时长(分钟)。 + timezone: 时区偏移。 + awake_count: 醒来次数。 + sleep_awake_duration: 清醒时长(分钟)。 + """ + + bedtime: int = 0 + wake_up_time: int = 0 + duration: int = 0 + sleep_deep_duration: int = 0 + sleep_light_duration: int = 0 + timezone: int = 0 + awake_count: int = 0 + sleep_awake_duration: int = 0 + + +# endregion + + +# region 睡眠 +class SleepData(BaseModel): + """睡眠每日汇总(来自 get_aggregated_data key=sleep)。 + + Attributes: + time: 数据时间戳。 + total_duration: 总睡眠时长(分钟)。 + sleep_score: 睡眠评分(0-100)。 + sleep_stage: 睡眠阶段数。 + sleep_deep_duration: 深睡时长(分钟)。 + sleep_light_duration: 浅睡时长(分钟)。 + sleep_rem_duration: REM 时长(分钟)。 + sleep_awake_duration: 清醒时长(分钟)。 + long_sleep_evaluation: 长期睡眠评估。 + day_sleep_evaluation: 日间小睡评估。 + avg_hr: 睡眠平均心率。 + max_hr: 睡眠最大心率。 + min_hr: 睡眠最小心率。 + avg_spo2: 睡眠平均血氧。 + segment_details: 睡眠片段列表。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + total_duration: int = 0 + sleep_score: int = 0 + sleep_stage: int = 0 + sleep_deep_duration: int = 0 + sleep_light_duration: int = 0 + sleep_rem_duration: int = 0 + sleep_awake_duration: int = 0 + long_sleep_evaluation: int = 0 + day_sleep_evaluation: int = 0 + avg_hr: int = 0 + max_hr: int = 0 + min_hr: int = 0 + avg_spo2: int = 0 + segment_details: list[SleepSegment] = Field(default_factory=list) + + def __str__(self) -> str: + return ( + f"Sleep({self.total_duration}min, score={self.sleep_score}/100, " + f"deep={self.sleep_deep_duration}min, light={self.sleep_light_duration}min, " + f"rem={self.sleep_rem_duration}min)" + ) + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +# endregion + + +# region 步数 +class StepData(BaseModel): + """步数每日汇总(来自 get_aggregated_data key=steps)。 + + Attributes: + time: 数据时间戳。 + steps: 步数。 + distance: 距离(米)。 + calories: 消耗卡路里。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + steps: int = 0 + distance: int = 0 + calories: int = 0 + goal: int = 0 + + def __str__(self) -> str: + return f"Steps({self.steps}步, {self.distance}m, {self.calories}cal)" + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +# endregion + + +# region 体重 +class WeightData(BaseModel): + """体重数据(来自 get_latest_data / get_fitness_data key=weight)。 + + Attributes: + time: 数据时间戳。 + weight: 体重(千克)。 + bmi: BMI 指数。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + weight: float = 0.0 + bmi: float = 0.0 + + def __str__(self) -> str: + return f"Weight({self.weight}kg, BMI={self.bmi})" + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +# endregion + + +# region 血压 +class BloodPressureData(BaseModel): + """血压数据(来自 get_latest_data / get_fitness_data key=blood_pressure)。 + + Attributes: + time: 数据时间戳。 + systolic: 收缩压(高压 mmHg)。 + diastolic: 舒张压(低压 mmHg)。 + pulse: 脉搏(bpm)。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + systolic: int = Field(default=0, validation_alias=AliasChoices("systolic", "systolic_pressure")) + diastolic: int = Field( + default=0, validation_alias=AliasChoices("diastolic", "diastolic_pressure") + ) + pulse: int | None = None + + def __str__(self) -> str: + base = f"BloodPressure({self.systolic}/{self.diastolic} mmHg)" + return f"{base}, pulse={self.pulse}" if self.pulse is not None else base + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +# endregion + + +# region 最新快照指标 +class GoalMetric(IntEnum): + """活力指标中的单项目标类型。""" + + STEPS = 1 + CALORIES = 2 + INTENSITY = 4 + + @classmethod + def from_field(cls, field: int) -> GoalMetric | None: + """将接口返回的 field 编号映射为已知目标类型。""" + try: + return cls(field) + except ValueError: + return None + + @property + def key(self) -> str: + """稳定的英文键名,适合代码分支和序列化。""" + return { + GoalMetric.STEPS: "steps", + GoalMetric.CALORIES: "calories", + GoalMetric.INTENSITY: "intensity", + }[self] + + @property + def label(self) -> str: + """与 App 文案接近的展示名称。""" + return { + GoalMetric.STEPS: "步数", + GoalMetric.CALORIES: "卡路里", + GoalMetric.INTENSITY: "中高强度", + }[self] + + +class GoalItem(BaseModel): + """单个健康目标条目。 + + Attributes: + field: 目标类型编号。 + target_value: 目标值。 + achieved_value: 已完成值。 + """ + + field: int = 0 + target_value: int | float = 0 + achieved_value: int | float = 0 + + @property + def metric(self) -> GoalMetric | None: + """已知目标类型;未知 field 返回 None。""" + return GoalMetric.from_field(self.field) + + @property + def metric_key(self) -> str: + """目标键名;未知类型保留 field 以便继续排查。""" + metric = self.metric + return metric.key if metric is not None else f"unknown:{self.field}" + + @property + def metric_label(self) -> str: + """目标展示名;未知类型保留原始编号。""" + metric = self.metric + return metric.label if metric is not None else f"未知目标({self.field})" + + +class GoalData(BaseModel): + """每日目标完成情况(来自 get_latest_data key=goal)。 + + Attributes: + time: 目标所属日期时间戳。 + goal_items: 当日所有目标项。 + + 便捷属性: + ``steps_goal`` / ``calories_goal`` / ``intensity_goal`` 会返回对应的目标条目。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + goal_items: list[GoalItem] = Field(default_factory=list) + + @field_validator("goal_items", mode="before") + @classmethod + def _normalize_goal_items(cls, value: Any) -> list[GoalItem]: + return _parse_model_list(value, GoalItem) + + def __str__(self) -> str: + return f"GoalData({len(self.goal_items)} items)" + + @cached_property + def items_by_field(self) -> dict[int, GoalItem]: + """按原始 field 编号索引目标项。""" + return {item.field: item for item in self.goal_items} + + @property + def available_metrics(self) -> list[GoalMetric]: + """当前响应中出现的已知目标类型。""" + metrics: list[GoalMetric] = [] + for item in self.goal_items: + if item.metric is not None: + metrics.append(item.metric) + return metrics + + @property + def unknown_goal_items(self) -> list[GoalItem]: + """当前响应中未识别的目标项。""" + return [item for item in self.goal_items if item.metric is None] + + def get_item(self, metric: GoalMetric | int) -> GoalItem | None: + """按目标类型读取对应条目。""" + return self.items_by_field.get(int(metric)) + + @property + def steps_goal(self) -> GoalItem | None: + """步数目标。""" + return self.get_item(GoalMetric.STEPS) + + @property + def calories_goal(self) -> GoalItem | None: + """卡路里目标。""" + return self.get_item(GoalMetric.CALORIES) + + @property + def intensity_goal(self) -> GoalItem | None: + """中高强度活动目标。""" + return self.get_item(GoalMetric.INTENSITY) + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +class CaloriesData(BaseModel): + """每日活动卡路里(来自 get_latest_data key=calories)。 + + Attributes: + time: 数据时间戳。 + calories: 已消耗活动卡路里。 + goal: 卡路里目标值。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + calories: int = 0 + goal: int = 0 + + def __str__(self) -> str: + return f"Calories({self.calories} cal, goal={self.goal})" + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +class ValidStandData(BaseModel): + """每日有效站立次数(来自 get_latest_data key=valid_stand)。 + + Attributes: + time: 数据时间戳。 + count: 有效站立次数。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + count: int = 0 + + def __str__(self) -> str: + return f"ValidStand({self.count})" + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +class IntensityData(BaseModel): + """每日中高强度活动时长(来自 get_latest_data key=intensity)。 + + Attributes: + time: 数据时间戳。 + duration: 中高强度活动时长(分钟)。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + duration: int = 0 + + def __str__(self) -> str: + return f"Intensity({self.duration} min)" + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +class Spo2Data(BaseModel): + """最新血氧数据(来自 get_latest_data key=spo2)。 + + Attributes: + time: 测量时间戳。 + spo2: 血氧百分比。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + spo2: int = 0 + + def __str__(self) -> str: + return f"Spo2({self.spo2}%)" + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +class Spo2SummaryData(BaseModel): + """每日血氧摘要(来自 get_aggregated_data key=spo2)。 + + Attributes: + time: 数据时间戳。 + avg_spo2: 平均血氧。 + max_spo2: 最高血氧。 + min_spo2: 最低血氧。 + lack_spo2_count: 低血氧次数。 + latest_spo2: 当日最近一次血氧采样。 + """ + + time: int = Field(default=0, validation_alias=AliasChoices("time", "date_time")) + avg_spo2: int = 0 + max_spo2: int = 0 + min_spo2: int = 0 + lack_spo2_count: int = 0 + latest_spo2: Spo2Data | None = None + + def __str__(self) -> str: + return ( + f"Spo2Summary(avg={self.avg_spo2}%, range={self.min_spo2}-{self.max_spo2}, " + f"lack={self.lack_spo2_count})" + ) + + @property + def at(self) -> datetime | None: + """数据时间(UTC datetime)。""" + return _ts_to_datetime(self.time) + + +# endregion + + +# region 用户验证 +class VerifiedUserInfo(BaseModel): + """verify_userinfo_by_id 响应中的用户信息。 + + Attributes: + user_id: 小米用户 UID。 + nickname: 昵称。 + icon: 头像 URL。 + """ + + user_id: int = Field(alias="userId", default=0) + nickname: str = "" + icon: str = "" + + model_config = {"populate_by_name": True} + + +# endregion + + +# region 最新数据项 +class LatestDataItem(BaseModel): + """get_latest_data 响应中的单条数据项。 + + value 字段是 JSON 字符串,需要根据 key 解析为对应类型。 + + Attributes: + time: 数据时间戳。 + key: 数据类型(heart_rate / sleep / steps / weight / blood_pressure 等)。 + value: JSON 字符串或数值。 + """ + + time: int = 0 + key: str = "" + value: str | int | float = "" + + @field_validator("value", mode="before") + @classmethod + def _normalize_value(cls, value: Any) -> str | int | float: + """确保 dict/list 形式的 value 也能被统一解析。""" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, (str, int, float)): + return value + return "" + + def parse_value(self) -> dict[str, Any] | int | float: + """将 value 字段从 JSON 字符串解析为字典。 + + Returns: + 解析后的字典或原始数值。 + """ + if isinstance(self.value, (int, float)): + return self.value + try: + parsed = json.loads(self.value) + except (json.JSONDecodeError, TypeError): + return {} + if isinstance(parsed, (dict, int, float)): + return parsed + return {} + + def _parse_dict_value(self) -> dict[str, Any]: + """将 value 解析为字典,失败时返回空对象。""" + parsed = self.parse_value() + return parsed if isinstance(parsed, dict) else {} + + def as_goal(self) -> GoalData | None: + """解析为目标完成数据。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, GoalData) + + def as_heart_rate(self) -> LatestHeartRate | None: + """解析为最新一次心率采样。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, LatestHeartRate) + + def as_sleep(self) -> SleepData | None: + """解析为最新睡眠摘要。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + segments = data.pop("segment_details", []) + data["segment_details"] = _parse_model_list(segments, SleepSegment) + return _parse_model(data, SleepData) + + def as_steps(self) -> StepData | None: + """解析为最新步数摘要。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, StepData) + + def as_weight(self) -> WeightData | None: + """解析为最新体重。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, WeightData) + + def as_blood_pressure(self) -> BloodPressureData | None: + """解析为最新血压。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, BloodPressureData) + + def as_calories(self) -> CaloriesData | None: + """解析为最新卡路里摘要。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, CaloriesData) + + def as_valid_stand(self) -> ValidStandData | None: + """解析为最新有效站立统计。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, ValidStandData) + + def as_intensity(self) -> IntensityData | None: + """解析为最新中高强度活动时长。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, IntensityData) + + def as_spo2(self) -> Spo2Data | None: + """解析为最新血氧。""" + data = self._parse_dict_value() + if not data: + return None + data.setdefault("time", self.time) + return _parse_model(data, Spo2Data) + + +# endregion + + +# region 聚合数据项 +class AggregatedDataItem(BaseModel): + """get_aggregated_data 响应中的单条数据项。 + + Attributes: + sid: 数据来源 SID。 + tag: 数据标签(如 daily_report)。 + key: 数据类型。 + time: 数据时间戳。 + value: JSON 字符串值。 + update_time: 更新时间戳。 + watermark: 水印(增量同步用)。 + source_sid_list: 数据来源列表。 + """ + + sid: str = "" + tag: str = "" + key: str = "" + time: int = 0 + value: str = "" + update_time: int = 0 + watermark: str = "" + source_sid_list: list[str] = Field(default_factory=list) + + @field_validator("watermark", mode="before") + @classmethod + def _stringify_watermark(cls, v: Any) -> str: + """API 有时返回 int 类型的 watermark。""" + return str(v) if v is not None else "" + + @field_validator("value", mode="before") + @classmethod + def _stringify_value(cls, v: Any) -> str: + """确保 value 始终是字符串(API 可能返回 dict)。""" + if isinstance(v, (dict, list)): + return json.dumps(v, ensure_ascii=False) + return str(v) if v is not None else "" + + def parse_value(self) -> dict[str, Any]: + """将 value 字段从 JSON 字符串解析为字典。""" + try: + parsed = json.loads(self.value) + except (json.JSONDecodeError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + def as_heart_rate(self) -> HeartRateData: + """解析为心率数据。""" + data = self.parse_value() + data["time"] = self.time + latest = data.get("latest_hr") + if isinstance(latest, dict): + data["latest_hr"] = LatestHeartRate.model_validate(latest) + elif latest is not None: + data["latest_hr"] = None + return HeartRateData.model_validate(data) + + def as_sleep(self) -> SleepData: + """解析为睡眠数据。""" + data = self.parse_value() + data["time"] = self.time + segments = data.pop("segment_details", []) + data["segment_details"] = _parse_model_list(segments, SleepSegment) + return SleepData.model_validate(data) + + def as_steps(self) -> StepData: + """解析为步数数据。""" + data = self.parse_value() + data["time"] = self.time + return StepData.model_validate(data) + + def as_weight(self) -> WeightData: + """解析为体重历史数据。""" + data = self.parse_value() + data["time"] = self.time + return WeightData.model_validate(data) + + def as_blood_pressure(self) -> BloodPressureData: + """解析为血压历史数据。""" + data = self.parse_value() + data["time"] = self.time + return BloodPressureData.model_validate(data) + + def as_calories(self) -> CaloriesData: + """解析为卡路里数据。""" + data = self.parse_value() + data["time"] = self.time + return CaloriesData.model_validate(data) + + def as_valid_stand(self) -> ValidStandData: + """解析为有效站立数据。""" + data = self.parse_value() + data["time"] = self.time + return ValidStandData.model_validate(data) + + def as_intensity(self) -> IntensityData: + """解析为中高强度活动时长数据。""" + data = self.parse_value() + data["time"] = self.time + return IntensityData.model_validate(data) + + def as_spo2(self) -> Spo2SummaryData: + """解析为血氧摘要数据。""" + data = self.parse_value() + data["time"] = self.time + latest = data.get("latest_spo2") + if isinstance(latest, dict): + data["latest_spo2"] = _parse_model(latest, Spo2Data) + elif latest is not None: + data["latest_spo2"] = None + return Spo2SummaryData.model_validate(data) + + +# endregion + + +# region 每日摘要 +class DailySummary(BaseModel): + """每日健康数据摘要(由 get_daily_summary 返回)。 + + Attributes: + date: 查询日期(ISO 格式)。 + relative_uid: 亲友 UID。 + heart_rate: 心率汇总数据。 + sleep: 睡眠汇总数据。 + steps: 步数汇总数据。 + """ + + date: str = "" + relative_uid: int = 0 + heart_rate: HeartRateData | None = None + sleep: SleepData | None = None + steps: StepData | None = None + + def __str__(self) -> str: + parts = [f"DailySummary({self.date}, UID={self.relative_uid}"] + if self.heart_rate: + parts.append(f"hr={self.heart_rate.avg_hr}bpm") + if self.sleep: + parts.append(f"sleep={self.sleep.total_duration}min") + if self.steps: + parts.append(f"steps={self.steps.steps}") + return ", ".join(parts) + ")" + + +# endregion + + +# region 最新快照 +class LatestDataSnapshot(BaseModel): + """最新健康快照。 + + 将 get_latest_data 的异构 data_list 收敛为固定字段,未知 key 或解析失败的 payload + 保留到 extras,避免上层因为单条脏数据失去整份快照。 + """ + + updated_time: int = 0 + goal: GoalData | None = None + heart_rate: LatestHeartRate | None = None + sleep: SleepData | None = None + blood_pressure: BloodPressureData | None = None + steps: StepData | None = None + calories: CaloriesData | None = None + valid_stand: ValidStandData | None = None + intensity: IntensityData | None = None + weight: WeightData | None = None + spo2: Spo2Data | None = None + extras: dict[str, dict[str, Any] | int | float] = Field(default_factory=dict) + + @classmethod + def from_items( + cls, + items: list[LatestDataItem], + *, + updated_time: int = 0, + ) -> LatestDataSnapshot: + """从原始 data_list 构建类型化快照。""" + payload: dict[str, Any] = {"updated_time": updated_time} + extras: dict[str, dict[str, Any] | int | float] = {} + parsers = { + "goal": LatestDataItem.as_goal, + "heart_rate": LatestDataItem.as_heart_rate, + "sleep": LatestDataItem.as_sleep, + "blood_pressure": LatestDataItem.as_blood_pressure, + "steps": LatestDataItem.as_steps, + "calories": LatestDataItem.as_calories, + "valid_stand": LatestDataItem.as_valid_stand, + "intensity": LatestDataItem.as_intensity, + "weight": LatestDataItem.as_weight, + "spo2": LatestDataItem.as_spo2, + } + + for item in items: + parser = parsers.get(item.key) + raw_value = item.parse_value() + if parser is None: + extras[item.key] = raw_value + continue + + parsed = parser(item) + if parsed is not None: + payload[item.key] = parsed + continue + + if raw_value not in ({}, "", 0, 0.0): + extras[item.key] = raw_value + + return cls(**payload, extras=extras) + + def __str__(self) -> str: + keys = ", ".join(self.available_keys) or "empty" + return f"LatestDataSnapshot({keys})" + + @property + def at(self) -> datetime | None: + """快照更新时间(UTC datetime)。""" + return _ts_to_datetime(self.updated_time) + + @property + def available_keys(self) -> list[str]: + """当前快照中可用的数据键。""" + known_keys = [ + "goal", + "heart_rate", + "sleep", + "blood_pressure", + "steps", + "calories", + "valid_stand", + "intensity", + "weight", + "spo2", + ] + keys = [key for key in known_keys if getattr(self, key) is not None] + keys.extend(sorted(self.extras)) + return keys + + +# endregion + + +# region API 响应包装 +class RelativeListResponse(_DictResultResponse): + """get_relative_list 响应。""" + + @property + def relatives(self) -> list[FamilyMember]: + """解析亲友列表。""" + return _parse_model_list(self.result.get("relative_list"), FamilyMember) + + +class LatestDataResponse(_DictResultResponse): + """get_latest_data 响应。""" + + @property + def data_items(self) -> list[LatestDataItem]: + """解析数据项列表。""" + return _parse_model_list(self.result.get("data_list"), LatestDataItem) + + @property + def latest_data_time(self) -> int: + """最新数据更新时间。""" + return _coerce_int(self.result.get("latest_data_time"), default=0) + + @property + def snapshot(self) -> LatestDataSnapshot: + """解析为类型化快照。""" + return LatestDataSnapshot.from_items(self.data_items, updated_time=self.latest_data_time) + + +class AggregatedDataResponse(_DictResultResponse): + """get_aggregated_data / get_fitness_data 响应。""" + + @property + def data_items(self) -> list[AggregatedDataItem]: + """解析数据项列表。""" + return _parse_model_list(self.result.get("data_list"), AggregatedDataItem) + + @property + def has_more(self) -> bool: + """是否有更多数据。""" + return _coerce_bool(self.result.get("has_more", False)) + + @property + def next_key(self) -> str: + """下一页的起始 key。""" + value = self.result.get("next_key", "") + return str(value) if value is not None else "" + + +# endregion + + +# region 亲友管理 API 响应 +class VerifyUserResponse(_DictResultResponse): + """verify_userinfo_by_id 响应。""" + + @property + def user_info(self) -> VerifiedUserInfo | None: + """解析用户信息,未找到时返回 None。""" + if not self.result or not self.result.get("userId"): + return None + return VerifiedUserInfo.model_validate(self.result) + + +class InviteResponse(_DictResultResponse): + """send_invite 响应。""" + + @property + def success(self) -> bool: + """邀请是否发送成功。""" + return _coerce_int(self.result.get("send_ret"), default=0) == 1 + + +class OperateInviteResponse(_DictResultResponse): + """operate_invite 响应(同意/拒绝邀请)。""" + + @property + def success(self) -> bool: + """操作是否成功。""" + return _coerce_bool(self.result.get("operate_ret")) + + +class DeleteRelativeResponse(_DictResultResponse): + """delete_relative 响应。""" + + @property + def success(self) -> bool: + """删除是否成功。""" + return _coerce_bool(self.result.get("delete_ret")) + + +class SharedDataTypesResponse(_DictResultResponse): + """get_shared_data_types 响应。""" + + @property + def keys(self) -> list[str]: + """可共享的数据类型列表。""" + return _coerce_str_list(self.result.get("keys")) + + +class InviteUniqueIdResponse(_DictResultResponse): + """get_invite_unique_id 响应。""" + + @property + def invite_link_id(self) -> int: + """二维码邀请链接 ID。""" + return _coerce_int(self.result.get("invite_link_id"), default=0) + + +class FamilyMemberResponse(_DictResultResponse): + """get_family_member 响应。""" + + @property + def family_user_list(self) -> list[dict[str, Any]]: + """家庭成员列表(原始字典)。""" + return _coerce_dict_list(self.result.get("family_user_list")) + + +# endregion + + +# region 消息 +class InviteMessage(BaseModel): + """亲友邀请消息(来自 get_msg_list 响应)。 + + Attributes: + msg_id: 消息 ID(operate_invite 用)。 + module: 消息模块(1=亲友)。 + type: 消息类型(1=待处理邀请,5=历史通知)。 + receiver: 接收方 UID。 + sender: 发送方 UID。 + extra_data: JSON 字符串,包含 invite_id、nick_name、icon 等。 + is_new: 是否新消息。 + data_status: 数据状态(0=待处理,1=已处理)。 + create_time: 创建时间戳。 + """ + + msg_id: int = 0 + module: int = 0 + type: int = 0 + receiver: int = 0 + sender: int = 0 + extra_data: str = "" + is_new: int = 0 + data_status: int = 0 + create_time: int = 0 + last_modify: int = 0 + + @field_validator("extra_data", mode="before") + @classmethod + def _normalize_extra_data(cls, value: Any) -> str: + """确保 extra_data 在解析前始终是 JSON 字符串。""" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) if value is not None else "" + + @cached_property + def _parsed_extra(self) -> dict[str, Any]: + """解析 extra_data JSON(缓存结果)。""" + try: + parsed = json.loads(self.extra_data) + except (json.JSONDecodeError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + @property + def invite_id(self) -> int | None: + """解析 extra_data 中的 invite_id(仅 type=1 时存在)。""" + return _coerce_optional_int(self._parsed_extra.get("invite_id")) + + @property + def nick_name(self) -> str: + """解析 extra_data 中的昵称。""" + return self._parsed_extra.get("nick_name", "") + + @property + def icon(self) -> str: + """解析 extra_data 中的头像 URL。""" + return self._parsed_extra.get("icon", "") + + @property + def is_pending(self) -> bool: + """是否为待处理的邀请。""" + return self.type == 1 and self.data_status == 0 + + +class MessageListResponse(_DictResultResponse): + """get_msg_list 响应。""" + + @property + def messages(self) -> list[InviteMessage]: + """消息列表。""" + return _parse_model_list(self.result.get("messages"), InviteMessage) + + @property + def msg_total(self) -> int: + """消息总数。""" + return _coerce_int(self.result.get("msg_total"), default=0) + + +class CheckNewMsgResponse(_ListResultResponse): + """check_new_msg 响应。""" + + def has_new(self, module: int = 1) -> bool: + """指定 module 是否有新消息。""" + for item in self.result: + if _coerce_int(item.get("module"), default=0) == module and _coerce_bool( + item.get("is_new") + ): + return True + return False + + +# endregion diff --git a/mi-fitness-python/tests/__init__.py b/mi-fitness-python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mi-fitness-python/tests/unit/__init__.py b/mi-fitness-python/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mi-fitness-python/tests/unit/conftest.py b/mi-fitness-python/tests/unit/conftest.py new file mode 100644 index 0000000..3c25094 --- /dev/null +++ b/mi-fitness-python/tests/unit/conftest.py @@ -0,0 +1,355 @@ +"""conftest.py —— pytest 公共 fixture。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from mi_fitness.auth import XiaomiAuth +from mi_fitness.models import AuthToken + + +@pytest.fixture +def ssecurity() -> str: + """测试用 ssecurity(base64 编码的 16 字节密钥)。""" + return "56nienlY7Ayh4VVJ0ywGGg==" + + +@pytest.fixture +def auth_token(ssecurity: str) -> AuthToken: + """构造一个已登录的 AuthToken。""" + return AuthToken( + user_id="123456", + c_user_id="c_test_user", + service_token="test_service_token", + ssecurity=ssecurity, + pass_token="test_pass_token", + device_id="an_test_device", + ) + + +@pytest.fixture +def mock_auth(auth_token: AuthToken) -> XiaomiAuth: + """构造一个已登录的 mock XiaomiAuth 实例。""" + auth = XiaomiAuth.__new__(XiaomiAuth) + auth.username = "test" + auth._password = "" + auth.token = auth_token + auth._http = MagicMock() + auth._ticket_token = "" + auth._token_path = None + return auth + + +# region 常用 API 响应模板 +RELATIVE_LIST_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "relative_list": [ + { + "relative_uid": 1452722403, + "relative_note": "妈妈", + "relative_icon": "https://example.com/avatar.jpg", + "latest_data_time": 1717488000, + "latest_abnormal_record_time": 0, + "source_tag": 1, + }, + { + "relative_uid": 9876543210, + "relative_note": "爸爸", + "relative_icon": "", + "latest_data_time": 1717484400, + "latest_abnormal_record_time": 0, + "source_tag": 1, + }, + ] + }, +} + +VERIFY_USER_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "userId": 1452722403, + "nickname": "测试用户", + "icon": "https://example.com/icon.jpg", + }, +} + +INVITE_RESPONSE = { + "code": 0, + "message": "ok", + "result": {"send_ret": 1}, +} + +DELETE_RESPONSE = { + "code": 0, + "message": "ok", + "result": {"delete_ret": True}, +} + +OPERATE_INVITE_RESPONSE = { + "code": 0, + "message": "ok", + "result": {"operate_ret": True}, +} + +INVITE_ID_RESPONSE = { + "code": 0, + "message": "ok", + "result": {"invite_link_id": 467184968352742400}, +} + +SHARED_TYPES_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "keys": ["goal", "heart_rate", "sleep", "steps"], + }, +} + +LATEST_DATA_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "data_list": [ + { + "time": 1717488000, + "key": "goal", + "value": ( + '{"date_time":1717488000,"goal_items":[' + '{"field":2,"target_value":400,"achieved_value":13},' + '{"field":1,"target_value":6000,"achieved_value":3716}' + "]}" + ), + }, + { + "time": 1717488000, + "key": "heart_rate", + "value": '{"time":1717491600,"bpm":84}', + }, + { + "time": 1717488000, + "key": "sleep", + "value": ( + '{"date_time":1717488000,"total_duration":436,' + '"sleep_stage":3,"sleep_score":86,' + '"long_sleep_evaluation":7,"day_sleep_evaluation":0}' + ), + }, + { + "time": 1717488000, + "key": "steps", + "value": '{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}', + }, + { + "time": 1717488000, + "key": "calories", + "value": '{"date_time":1717488000,"calories":230,"goal":300}', + }, + { + "time": 1717488000, + "key": "valid_stand", + "value": '{"date_time":1717488000,"count":7}', + }, + { + "time": 1717488000, + "key": "intensity", + "value": '{"date_time":1717488000,"duration":15}', + }, + { + "time": 1717488000, + "key": "weight", + "value": '{"time":1717488000,"weight":65.5,"bmi":22.1}', + }, + { + "time": 1717488000, + "key": "spo2", + "value": '{"time":1717495200,"spo2":96}', + }, + { + "key": "blood_pressure", + }, + ], + "latest_data_time": 1717495200, + }, +} + +AGGREGATED_HR_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "data_list": [ + { + "sid": "miothealth", + "tag": "daily_report", + "key": "heart_rate", + "time": 1717430400, + "value": ( + '{"avg_hr":72,"avg_rhr":62,"max_hr":120,"min_hr":55,' + '"latest_hr":{"bpm":75,"time":1717488000},' + '"abnormal_hr_count":0,' + '"aerobic_hr_zone_duration":30,' + '"anaerobic_hr_zone_duration":0,' + '"extreme_hr_zone_duration":0,' + '"fat_burning_hr_zone_duration":15,' + '"warm_up_hr_zone_duration":10}' + ), + "update_time": 1717488000, + "watermark": "w1", + "source_sid_list": [], + } + ], + "has_more": False, + }, +} + +AGGREGATED_SLEEP_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "data_list": [ + { + "sid": "miothealth", + "tag": "daily_report", + "key": "sleep", + "time": 1717430400, + "value": ( + '{"total_duration":480,"sleep_score":85,"sleep_stage":4,' + '"sleep_deep_duration":120,"sleep_light_duration":200,' + '"sleep_rem_duration":100,"sleep_awake_duration":60,' + '"long_sleep_evaluation":1,"day_sleep_evaluation":0,' + '"avg_hr":60,"max_hr":80,"min_hr":50,"avg_spo2":97,' + '"segment_details":[{"bedtime":1717365600,"wake_up_time":1717394400,' + '"duration":480,"sleep_deep_duration":120,"sleep_light_duration":200,' + '"timezone":28800,"awake_count":2,"sleep_awake_duration":60}]}' + ), + "update_time": 1717488000, + "watermark": "w2", + "source_sid_list": [], + } + ], + "has_more": False, + }, +} + +AGGREGATED_STEPS_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "data_list": [ + { + "sid": "miothealth", + "tag": "daily_report", + "key": "steps", + "time": 1717430400, + "value": '{"steps":8500,"distance":6200,"calories":320}', + "update_time": 1717488000, + "watermark": "w3", + "source_sid_list": [], + } + ], + "has_more": False, + }, +} + +FITNESS_WEIGHT_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "data_list": [ + { + "sid": "xiaomiwear_app_manually", + "tag": "", + "key": "weight", + "time": 1773753142, + "value": '{"bmi":16.97531,"time":1773753142,"weight":55.0}', + "update_time": 1773753142, + "watermark": "w5", + "source_sid_list": [], + } + ], + "has_more": False, + }, +} + +FITNESS_BLOOD_PRESSURE_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "data_list": [ + { + "sid": "xiaomiwear_app_manually", + "tag": "", + "key": "blood_pressure", + "time": 1773753098, + "value": ( + '{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}' + ), + "update_time": 1773753098, + "watermark": "w6", + "source_sid_list": [], + } + ], + "has_more": False, + }, +} + +FAMILY_MEMBER_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "family_user_list": [ + {"userId": 123, "nickname": "家人1"}, + ] + }, +} + +MESSAGE_LIST_RESPONSE = { + "code": 0, + "message": "ok", + "result": { + "messages": [ + { + "msg_id": 152824796151809, + "module": 1, + "type": 1, + "receiver": 3188565001, + "sender": 1452722403, + "extra_data": '{"invite_id":4777767,"auth_data":["heart_rate","sleep"],"nick_name":"测试用户","icon":"https://example.com/avatar.jpg"}', + "is_new": 2, + "data_status": 0, + "create_time": 1772628283, + "last_modify": 1772628283, + }, + { + "msg_id": 152821515157506, + "module": 1, + "type": 5, + "receiver": 3188565001, + "sender": 1452722403, + "extra_data": '{"nick_name":"测试用户","icon":"https://example.com/avatar.jpg"}', + "is_new": 2, + "data_status": 1, + "create_time": 1772625154, + "last_modify": 1772625154, + }, + ], + "msg_total": 2, + }, +} + +CHECK_NEW_MSG_RESPONSE = { + "code": 0, + "message": "ok", + "result": [{"module": 1, "is_new": True}], +} + +CHECK_NO_NEW_MSG_RESPONSE = { + "code": 0, + "message": "ok", + "result": [{"module": 1, "is_new": False}], +} +# endregion diff --git a/mi-fitness-python/tests/unit/test_auth.py b/mi-fitness-python/tests/unit/test_auth.py new file mode 100644 index 0000000..01682a9 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_auth.py @@ -0,0 +1,247 @@ +"""测试认证模块的内部编排逻辑。""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from mi_fitness.auth import XiaomiAuth +from mi_fitness.auth import manager as auth_manager +from mi_fitness.auth import passtoken as passtoken_module +from mi_fitness.auth import password as password_module +from mi_fitness.auth import qr as qr_module +from mi_fitness.exceptions import ( + AuthError, + CaptchaRequiredError, + DeviceUntrustedError, + TokenExpiredError, +) +from mi_fitness.models import AuthToken + +pytestmark = pytest.mark.asyncio + + +class _DummyResponse: + def __init__(self, text: str): + self.text = text + + def raise_for_status(self) -> None: + """测试响应默认视为成功。""" + + +def _http_response(text: str, status_code: int = 200) -> httpx.Response: + request = httpx.Request("GET", "https://example.com/test") + return httpx.Response(status_code=status_code, text=text, request=request) + + +def _make_auth() -> XiaomiAuth: + auth = XiaomiAuth("13800138000", "secret") + auth._http = MagicMock() + auth._http.cookies = MagicMock() + return auth + + +async def test_send_verification_code_retries_captcha_once(monkeypatch: pytest.MonkeyPatch) -> None: + auth = _make_auth() + + ensure_ready = AsyncMock() + send_ticket = AsyncMock( + side_effect=[ + CaptchaRequiredError("需要图形验证码", captcha_url="https://example.com/captcha.png"), + None, + ] + ) + get_phone_info = AsyncMock(return_value=("191******54", "ticket-token")) + fetch_captcha = AsyncMock(return_value=b"captcha-image") + captcha_handler = AsyncMock(return_value="ABCD") + + monkeypatch.setattr(auth_manager._pwd, "ensure_ticket_login_ready", ensure_ready) + monkeypatch.setattr(auth_manager._pwd, "send_ticket", send_ticket) + monkeypatch.setattr(auth_manager._pwd, "get_phone_info", get_phone_info) + monkeypatch.setattr(auth_manager._pwd, "fetch_captcha_image", fetch_captcha) + + phone = await auth.send_verification_code(captcha_handler=captcha_handler) + + assert phone == "191******54" + assert auth._ticket_token == "ticket-token" + ensure_ready.assert_awaited_once_with(auth._http) + assert [call.kwargs["captcha_code"] for call in send_ticket.await_args_list] == ["", "ABCD"] + get_phone_info.assert_awaited_once_with(auth._http, auth.username, captcha_code="") + fetch_captcha.assert_awaited_once_with(auth._http, "https://example.com/captcha.png") + captcha_handler.assert_awaited_once_with(b"captcha-image") + + +async def test_send_verification_code_stops_after_max_retries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + auth = _make_auth() + + captcha_error = CaptchaRequiredError( + "需要图形验证码", + captcha_url="https://example.com/captcha.png", + ) + send_ticket = AsyncMock(side_effect=[captcha_error] * auth_manager._MAX_CAPTCHA_RETRIES) + get_phone_info = AsyncMock() + fetch_captcha = AsyncMock(return_value=b"captcha-image") + captcha_handler = AsyncMock(return_value="ABCD") + + monkeypatch.setattr(auth_manager._pwd, "ensure_ticket_login_ready", AsyncMock()) + monkeypatch.setattr(auth_manager._pwd, "send_ticket", send_ticket) + monkeypatch.setattr(auth_manager._pwd, "get_phone_info", get_phone_info) + monkeypatch.setattr(auth_manager._pwd, "fetch_captcha_image", fetch_captcha) + + with pytest.raises(AuthError, match="已连续重试 3 次"): + await auth.send_verification_code(captcha_handler=captcha_handler) + + assert send_ticket.await_count == auth_manager._MAX_CAPTCHA_RETRIES + assert fetch_captcha.await_count == auth_manager._MAX_CAPTCHA_RETRIES + assert captcha_handler.await_count == auth_manager._MAX_CAPTCHA_RETRIES + get_phone_info.assert_not_awaited() + + +async def test_login_passtoken_uses_shared_service_token_extractor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + http = MagicMock() + http.cookies = MagicMock() + http.get = AsyncMock( + return_value=_DummyResponse( + '&&&START&&&{"ssecurity":"sec","location":"https://example.com/cb?foo=1","nonce":"nonce","cUserId":"cid"}' + ) + ) + extract_service_token = AsyncMock(return_value="service-token") + monkeypatch.setattr(passtoken_module, "extract_service_token", extract_service_token) + + token = AuthToken() + await passtoken_module.login_passtoken( + http, + token, + pass_token="pass-token", + user_id="user-id", + device_id="an_device", + ) + + assert token.ssecurity == "sec" + assert token.c_user_id == "cid" + assert token.service_token == "service-token" + extract_service_token.assert_awaited_once() + await_args = extract_service_token.await_args + assert await_args is not None + redirect_url = await_args.args[1] + assert redirect_url.startswith("https://example.com/cb?foo=1&clientSign=") + + +async def test_refresh_reuses_existing_token_and_persists(monkeypatch: pytest.MonkeyPatch) -> None: + auth = _make_auth() + auth.token = AuthToken( + user_id="user-id", + pass_token="pass-token", + device_id="an_device", + service_token="old-token", + ssecurity="old-sec", + ) + auth._token_path = Path("token.json") + save_token = MagicMock() + auth.save_token = save_token # type: ignore[method-assign] + + async def fake_login_passtoken(*args, **kwargs) -> None: + auth.token.service_token = "new-token" + auth.token.ssecurity = "new-sec" + + login_passtoken = AsyncMock(side_effect=fake_login_passtoken) + monkeypatch.setattr(auth_manager, "_pt", MagicMock(login_passtoken=login_passtoken)) + monkeypatch.setattr(auth_manager, "_sts", MagicMock(sts_exchange=AsyncMock())) + + token = await auth.refresh() + + assert token.service_token == "new-token" + assert token.ssecurity == "new-sec" + login_passtoken.assert_awaited_once_with( + auth._http, + auth.token, + pass_token="pass-token", + user_id="user-id", + device_id="an_device", + ) + save_token.assert_called_once_with(Path("token.json")) + + +async def test_refresh_requires_pass_token() -> None: + auth = _make_auth() + auth.token = AuthToken(user_id="user-id", pass_token="") + + with pytest.raises(TokenExpiredError, match="无法自动刷新"): + await auth.refresh() + + +async def test_password_login_wrong_password_raises_auth_error() -> None: + http = MagicMock() + http.post = AsyncMock( + return_value=_http_response('&&&START&&&{"code":70002,"desc":"invalid credential"}') + ) + + with pytest.raises(AuthError, match="登录失败"): + await password_module._raw_submit_login(http, "13800138000", "bad", "sign", "callback") + + +async def test_submit_login_requires_device_verification() -> None: + http = MagicMock() + token = AuthToken() + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + password_module, + "_raw_submit_login", + AsyncMock(return_value={"code": 70016}), + ) + + with pytest.raises(DeviceUntrustedError, match="二次验证"): + await password_module.submit_login( + http, token, "13800138000", "secret", "sign", "callback" + ) + + +async def test_submit_login_requires_trusted_device_when_security_status_non_zero() -> None: + http = MagicMock() + token = AuthToken() + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + password_module, + "_raw_submit_login", + AsyncMock(return_value={"code": 0, "securityStatus": 8}), + ) + + with pytest.raises(DeviceUntrustedError, match="设备未受信任"): + await password_module.submit_login( + http, token, "13800138000", "secret", "sign", "callback" + ) + + +async def test_qr_login_times_out(monkeypatch: pytest.MonkeyPatch) -> None: + http = MagicMock() + http.get = AsyncMock( + return_value=_http_response( + '&&&START&&&{"qr":"https://example.com/qr.png","lp":"https://example.com/poll","timeout":0}' + ) + ) + time_values = iter([0.0, 0.0, 1.0]) + monkeypatch.setattr(qr_module.time, "time", lambda: next(time_values)) + + with pytest.raises(AuthError, match="二维码扫码超时"): + await qr_module.login_qr(http, AuthToken(), max_wait=0) + + +async def test_login_passtoken_requires_non_empty_inputs() -> None: + http = MagicMock() + http.cookies = MagicMock() + token = AuthToken() + + with pytest.raises(AuthError, match="passToken 不能为空"): + await passtoken_module.login_passtoken(http, token, pass_token="", user_id="uid") + + with pytest.raises(AuthError, match="userId 不能为空"): + await passtoken_module.login_passtoken(http, token, pass_token="pt", user_id="") diff --git a/mi-fitness-python/tests/unit/test_auth_helpers.py b/mi-fitness-python/tests/unit/test_auth_helpers.py new file mode 100644 index 0000000..9747a57 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_auth_helpers.py @@ -0,0 +1,165 @@ +"""测试认证辅助函数与 STS 交换。""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +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) + + +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_swallows_network_failure() -> None: + http = MagicMock() + http.get = AsyncMock(side_effect=RuntimeError("boom")) + token = AuthToken(device_id="an_device") + + await auth_sts.sts_exchange(http, token) diff --git a/mi-fitness-python/tests/unit/test_cli.py b/mi-fitness-python/tests/unit/test_cli.py new file mode 100644 index 0000000..d28e3c0 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_cli.py @@ -0,0 +1,71 @@ +"""测试 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) -> 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" + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def test_main_runs_without_args(monkeypatch: pytest.MonkeyPatch) -> None: + called = False + + async def fake_qr_login() -> None: + nonlocal called + called = True + + monkeypatch.setattr(cli, "_qr_login", fake_qr_login) + cli.main() + assert called diff --git a/mi-fitness-python/tests/unit/test_client.py b/mi-fitness-python/tests/unit/test_client.py new file mode 100644 index 0000000..308f55d --- /dev/null +++ b/mi-fitness-python/tests/unit/test_client.py @@ -0,0 +1,963 @@ +"""测试 API 客户端 (client.py)。 + +使用 mock 替换 _request 方法,验证各业务方法的参数组装和响应解析。 +""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mi_fitness.client import MiHealthClient +from mi_fitness.const import ( + ALL_SHARED_DATA_TYPES, + MESSAGE_GET_LIST_PATH, + MESSAGE_MODULE_RELATIVES, + RELATIVES_DELETE_PATH, + RELATIVES_GET_INVITE_ID_PATH, + RELATIVES_LIST_PATH, + RELATIVES_OPERATE_INVITE_PATH, + RELATIVES_VERIFY_USER_PATH, + VERIFY_TYPE_XIAOMI_ID, +) +from mi_fitness.exceptions import ( + DataNotSharedError, + DataOutOfSharedTimeScopeError, + FamilyMemberNotFoundError, + TokenExpiredError, +) +from mi_fitness.models import ( + CaloriesData, + GoalData, + HeartRateData, + IntensityData, + LatestDataSnapshot, + SleepData, + Spo2Data, + Spo2SummaryData, + StepData, + ValidStandData, + WeightData, +) + +from .conftest import ( + AGGREGATED_HR_RESPONSE, + AGGREGATED_SLEEP_RESPONSE, + AGGREGATED_STEPS_RESPONSE, + CHECK_NEW_MSG_RESPONSE, + CHECK_NO_NEW_MSG_RESPONSE, + DELETE_RESPONSE, + FAMILY_MEMBER_RESPONSE, + FITNESS_BLOOD_PRESSURE_RESPONSE, + FITNESS_WEIGHT_RESPONSE, + INVITE_ID_RESPONSE, + INVITE_RESPONSE, + LATEST_DATA_RESPONSE, + MESSAGE_LIST_RESPONSE, + OPERATE_INVITE_RESPONSE, + RELATIVE_LIST_RESPONSE, + SHARED_TYPES_RESPONSE, + VERIFY_USER_RESPONSE, +) + +# 使用 pytest fixture 中的 mock_auth +pytestmark = pytest.mark.asyncio + + +def _make_client(mock_auth: Any) -> MiHealthClient: + """用 mock auth 构造客户端实例。""" + return MiHealthClient(mock_auth) + + +# region 亲友列表 +class TestGetRelatives: + async def test_returns_family_members(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) + + members = await client.get_relatives() + assert len(members) == 2 + assert members[0].relative_uid == 1452722403 + assert members[0].relative_note == "妈妈" + assert members[1].relative_note == "爸爸" + + client._request.assert_called_once_with("GET", RELATIVES_LIST_PATH) + + async def test_empty_list(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {"relative_list": []}}) + members = await client.get_relatives() + assert members == [] + + +# endregion + + +# region 查找亲友 +class TestFindRelative: + async def test_find_by_uid(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) + + member = await client.find_relative(1452722403) + assert member.relative_uid == 1452722403 + + async def test_find_by_note(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) + + member = await client.find_relative("妈") + assert member.relative_note == "妈妈" + + async def test_not_found_raises(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=RELATIVE_LIST_RESPONSE) + + with pytest.raises(FamilyMemberNotFoundError): + await client.find_relative("不存在的人") + + +# endregion + + +# region 验证用户 +class TestVerifyUser: + async def test_verify_by_xiaomi_id(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=VERIFY_USER_RESPONSE) + + info = await client.verify_user(1452722403) + assert info is not None + assert info.user_id == 1452722403 + assert info.nickname == "测试用户" + + client._request.assert_called_once_with( + "GET", + RELATIVES_VERIFY_USER_PATH, + params={"verify_id": 1452722403, "verify_type": VERIFY_TYPE_XIAOMI_ID}, + ) + + async def test_verify_returns_none_for_missing_user(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {}}) + info = await client.verify_user(999) + assert info is None + + +# endregion + + +# region 邀请亲友 +class TestInviteRelative: + async def test_invite_success(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=INVITE_RESPONSE) + + result = await client.invite_relative(1452722403) + assert result is True + + call_args = client._request.call_args + params = call_args.kwargs["params"] + assert params["relative_uid"] == 1452722403 + assert params["auth_content"]["auth_data"] == ALL_SHARED_DATA_TYPES + assert params["auth_content"]["auth_time_range"] == 3 + + async def test_invite_with_custom_types(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=INVITE_RESPONSE) + + await client.invite_relative( + 123, + shared_data_types=["heart_rate", "sleep"], + auth_time_range=1, + ) + + params = client._request.call_args.kwargs["params"] + assert params["auth_content"]["auth_data"] == ["heart_rate", "sleep"] + assert params["auth_content"]["auth_time_range"] == 1 + + async def test_invite_with_note(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=INVITE_RESPONSE) + + await client.invite_relative(123, relative_note="好友") + + params = client._request.call_args.kwargs["params"] + assert params["relative_note"] == "好友" + + async def test_invite_failure(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {"send_ret": 0}}) + result = await client.invite_relative(123) + assert result is False + + +# endregion + + +# region 操作邀请(同意/拒绝) +class TestOperateInvite: + async def test_accept_success(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) + + result = await client.accept_invite(4777767, 152824796151809) + assert result is True + + call_args = client._request.call_args + params = call_args.kwargs["params"] + assert params["invite_id"] == 4777767 + assert params["msg_id"] == 152824796151809 + assert params["operate"] == 1 + assert params["auth_content"]["auth_data"] == ALL_SHARED_DATA_TYPES + + async def test_accept_with_custom_types(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) + + await client.accept_invite(123, 456, shared_data_types=["heart_rate", "sleep"]) + + params = client._request.call_args.kwargs["params"] + assert params["auth_content"]["auth_data"] == ["heart_rate", "sleep"] + + async def test_reject_success(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) + + result = await client.reject_invite(4777767, 152824796151809) + assert result is True + + params = client._request.call_args.kwargs["params"] + assert params["operate"] == 2 + + async def test_accept_calls_correct_endpoint(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=OPERATE_INVITE_RESPONSE) + + await client.accept_invite(1, 2) + + client._request.assert_called_once_with( + "POST", + RELATIVES_OPERATE_INVITE_PATH, + params={ + "auth_content": { + "auth_time_range": 3, + "auth_data": ALL_SHARED_DATA_TYPES, + }, + "invite_id": 1, + "msg_id": 2, + "operate": 1, + }, + ) + + async def test_operate_failure(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {"operate_ret": False}}) + result = await client.accept_invite(1, 2) + assert result is False + + +# endregion + + +# region 删除亲友 +class TestDeleteRelative: + async def test_delete_success(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=DELETE_RESPONSE) + + result = await client.delete_relative(1452722403) + assert result is True + + client._request.assert_called_once_with( + "POST", + RELATIVES_DELETE_PATH, + params={"relative_uid": 1452722403}, + ) + + async def test_delete_failure(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {"delete_ret": False}}) + result = await client.delete_relative(123) + assert result is False + + +# endregion + + +# region 邀请链接 ID +class TestGetInviteLinkId: + async def test_returns_snowflake_id(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=INVITE_ID_RESPONSE) + + link_id = await client.get_invite_link_id() + assert link_id == 467184968352742400 + + client._request.assert_called_once_with("GET", RELATIVES_GET_INVITE_ID_PATH) + + +# endregion + + +# region 共享数据类型 +class TestGetSharedDataTypes: + async def test_returns_keys(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=SHARED_TYPES_RESPONSE) + + keys = await client.get_shared_data_types(123) + assert "heart_rate" in keys + + async def test_direction_parameter(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=SHARED_TYPES_RESPONSE) + + await client.get_shared_data_types(123, direction=1) + params = client._request.call_args.kwargs["params"] + assert params["type"] == 1 + + +# endregion + + +# region 家庭成员 +class TestGetFamilyMembers: + async def test_returns_list(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=FAMILY_MEMBER_RESPONSE) + + members = await client.get_family_members() + assert len(members) == 1 + assert members[0]["userId"] == 123 + + +# endregion + + +# region 最新数据 +class TestGetLatestData: + async def test_returns_typed_snapshot(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) + + latest = await client.get_latest_data(123) + assert isinstance(latest, LatestDataSnapshot) + assert latest.updated_time == 1717495200 + assert latest.goal is not None + assert len(latest.goal.goal_items) == 2 + assert latest.heart_rate is not None + assert latest.heart_rate.bpm == 84 + assert latest.sleep is not None + assert latest.sleep.total_duration == 436 + assert latest.steps is not None + assert latest.steps.goal == 6000 + assert latest.calories is not None + assert latest.calories.calories == 230 + assert latest.valid_stand is not None + assert latest.valid_stand.count == 7 + assert latest.intensity is not None + assert latest.intensity.duration == 15 + assert latest.weight is not None + assert latest.weight.weight == 65.5 + assert latest.spo2 is not None + assert latest.spo2.spo2 == 96 + assert latest.blood_pressure is None + + +class TestGetLatestItems: + async def test_returns_raw_items(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) + + items = await client.get_latest_items(123) + assert len(items) == 10 + assert items[0].key == "goal" + assert items[1].key == "heart_rate" + + +# endregion + + +# region 心率数据 +class TestGetHeartRate: + async def test_returns_heart_rate_data(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=AGGREGATED_HR_RESPONSE) + + data = await client.get_heart_rate(123, date(2024, 6, 4)) + assert len(data) == 1 + assert isinstance(data[0], HeartRateData) + assert data[0].avg_hr == 72 + assert data[0].latest_hr is not None + assert data[0].latest_hr.bpm == 75 + + async def test_skips_invalid_heart_rate_item(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock( + return_value={ + "code": 0, + "result": { + "data_list": [ + { + "sid": "miothealth", + "tag": "daily_report", + "key": "heart_rate", + "time": 1717430400, + "value": '{"avg_hr":{"bad":1}}', + "update_time": 1717488000, + "watermark": "w1", + "source_sid_list": [], + }, + AGGREGATED_HR_RESPONSE["result"]["data_list"][0], + ] + }, + } + ) + + data = await client.get_heart_rate(123, date(2024, 6, 4)) + + assert len(data) == 1 + assert data[0].avg_hr == 72 + + +# endregion + + +# region 睡眠数据 +class TestGetSleep: + async def test_returns_sleep_data(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=AGGREGATED_SLEEP_RESPONSE) + + data = await client.get_sleep(123, date(2024, 6, 4)) + assert len(data) == 1 + assert isinstance(data[0], SleepData) + assert data[0].total_duration == 480 + assert data[0].sleep_score == 85 + assert len(data[0].segment_details) == 1 + + +# endregion + + +# region 步数数据 +class TestGetSteps: + async def test_returns_step_data(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=AGGREGATED_STEPS_RESPONSE) + + data = await client.get_steps(123, date(2024, 6, 4)) + assert len(data) == 1 + assert isinstance(data[0], StepData) + assert data[0].steps == 8500 + + +# endregion + + +# region 其它聚合指标 +class TestOtherAggregatedMetrics: + async def test_history_days_uses_trailing_window(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {"data_list": []}}) + + await client.get_calories_history(123, date(2024, 6, 4), days=7) + + call = client._request.call_args + assert call is not None + params = call.kwargs["params"] + assert params["key"] == "calories" + assert params["limit"] == 7 + assert params["end_time"] - params["start_time"] == 86400 * 7 - 1 + + async def test_returns_calories_valid_stand_and_intensity(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock( + side_effect=[ + { + "code": 0, + "result": { + "data_list": [ + { + "sid": "default", + "tag": "daily_report", + "key": "calories", + "time": 1717430400, + "value": '{"calories":338}', + "update_time": 1717488000, + "watermark": "w1", + "source_sid_list": [], + } + ] + }, + }, + { + "code": 0, + "result": { + "data_list": [ + { + "sid": "default", + "tag": "daily_report", + "key": "valid_stand", + "time": 1717430400, + "value": '{"count":10}', + "update_time": 1717488000, + "watermark": "w2", + "source_sid_list": [], + } + ] + }, + }, + { + "code": 0, + "result": { + "data_list": [ + { + "sid": "default", + "tag": "daily_report", + "key": "intensity", + "time": 1717430400, + "value": '{"duration":19}', + "update_time": 1717488000, + "watermark": "w3", + "source_sid_list": [], + } + ] + }, + }, + ] + ) + + calories = await client.get_calories_history(123, date(2024, 6, 4)) + stand = await client.get_valid_stand_history(123, date(2024, 6, 4)) + intensity = await client.get_intensity_history(123, date(2024, 6, 4)) + + assert isinstance(calories[0], CaloriesData) + assert calories[0].calories == 338 + assert isinstance(stand[0], ValidStandData) + assert stand[0].count == 10 + assert isinstance(intensity[0], IntensityData) + assert intensity[0].duration == 19 + + async def test_returns_spo2_history(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock( + return_value={ + "code": 0, + "result": { + "data_list": [ + { + "sid": "default", + "tag": "daily_report", + "key": "spo2", + "time": 1761091200, + "value": ( + '{"avg_spo2":96,"lack_spo2_count":0,' + '"latest_spo2":{"spo2":96,"time":1761161730},' + '"max_spo2":96,"min_spo2":96}' + ), + "update_time": 1762064917, + "watermark": "w4", + "source_sid_list": [], + } + ] + }, + } + ) + + series = await client.get_spo2_history(123, date(2025, 10, 22)) + + assert len(series) == 1 + assert isinstance(series[0], Spo2SummaryData) + assert series[0].avg_spo2 == 96 + assert series[0].latest_spo2 is not None + assert series[0].latest_spo2.spo2 == 96 + + async def test_returns_weight_history(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=FITNESS_WEIGHT_RESPONSE) + + series = await client.get_weight_history(123, date(2026, 3, 17), days=7) + + assert len(series) == 1 + assert isinstance(series[0], WeightData) + assert series[0].weight == 55.0 + assert series[0].bmi == 16.97531 + + async def test_returns_blood_pressure_history(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=FITNESS_BLOOD_PRESSURE_RESPONSE) + + series = await client.get_blood_pressure_history(123, date(2026, 3, 17), days=7) + + assert len(series) == 1 + assert series[0].systolic == 33 + assert series[0].diastolic == 30 + assert series[0].pulse == 60 + + +# endregion + + +# region 体重数据 +class TestGetWeight: + async def test_returns_weight_data(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) + + weight = await client.get_weight(123) + assert weight is not None + assert isinstance(weight, WeightData) + assert weight.weight == 65.5 + assert weight.bmi == 22.1 + + async def test_returns_none_when_no_weight(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + resp = { + "code": 0, + "result": { + "data_list": [ + {"time": 100, "key": "heart_rate", "value": "{}"}, + ] + }, + } + client._request = AsyncMock(return_value=resp) + client.get_shared_data_types = AsyncMock(return_value=["weight"]) # type: ignore[method-assign] + + weight = await client.get_weight(123) + assert weight is None + + async def test_raises_not_shared_when_weight_disabled(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value={"code": 0, "result": {"data_list": []}}) + client.get_shared_data_types = AsyncMock(return_value=["steps"]) # type: ignore[method-assign] + + with pytest.raises(DataNotSharedError, match="weight"): + await client.get_weight(123) + + +class TestGetLatestMetricHelpers: + async def test_returns_goal_and_other_latest_metric_types(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) + + goal = await client.get_goal(123) + calories = await client.get_calories(123) + stand = await client.get_valid_stand(123) + intensity = await client.get_intensity(123) + spo2 = await client.get_spo2(123) + + assert isinstance(goal, GoalData) + assert len(goal.goal_items) == 2 + assert goal.steps_goal is not None + assert goal.steps_goal.target_value == 6000 + assert goal.calories_goal is not None + assert goal.calories_goal.target_value == 400 + assert goal.intensity_goal is None + assert isinstance(calories, CaloriesData) + assert calories.calories == 230 + assert isinstance(stand, ValidStandData) + assert stand.count == 7 + assert isinstance(intensity, IntensityData) + assert intensity.duration == 15 + assert isinstance(spo2, Spo2Data) + assert spo2.spo2 == 96 + + async def test_blood_pressure_returns_none_when_shared_but_no_payload( + self, mock_auth: Any + ) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=LATEST_DATA_RESPONSE) + client.get_shared_data_types = AsyncMock(return_value=["blood_pressure"]) # type: ignore[method-assign] + + blood_pressure = await client.get_blood_pressure(123) + + assert blood_pressure is None + + async def test_blood_pressure_supports_fitness_aliases_in_latest_payload( + self, mock_auth: Any + ) -> None: + client = _make_client(mock_auth) + resp = { + "code": 0, + "result": { + "data_list": [ + { + "time": 1773753098, + "key": "blood_pressure", + "value": ( + '{"systolic_pressure":33,"diastolic_pressure":30,' + '"pulse":60,"time":1773753098}' + ), + } + ] + }, + } + client._request = AsyncMock(return_value=resp) + + blood_pressure = await client.get_blood_pressure(123) + + assert blood_pressure is not None + assert blood_pressure.systolic == 33 + assert blood_pressure.diastolic == 30 + assert blood_pressure.pulse == 60 + + +# endregion + + +# region 每日摘要 +class TestGetDailySummary: + async def test_returns_summary_dict(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + # 依次返回心率、睡眠、步数的响应 + client._request = AsyncMock( + side_effect=[ + AGGREGATED_HR_RESPONSE, + AGGREGATED_SLEEP_RESPONSE, + AGGREGATED_STEPS_RESPONSE, + ] + ) + + summary = await client.get_daily_summary(123, date(2024, 6, 4)) + assert summary.date == "2024-06-04" + assert summary.relative_uid == 123 + assert summary.heart_rate is not None + assert summary.sleep is not None + assert summary.steps is not None + + async def test_get_latest_daily_summary_uses_relative_latest_date(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client.find_relative = AsyncMock( + return_value=type( + "Member", + (), + { + "relative_uid": 123, + "relative_note": "测试", + "latest_data_time": 1717488000, + }, + )() + ) + client._request = AsyncMock( + side_effect=[ + AGGREGATED_HR_RESPONSE, + AGGREGATED_SLEEP_RESPONSE, + AGGREGATED_STEPS_RESPONSE, + ] + ) + + summary = await client.get_latest_daily_summary(123) + + assert summary.date == "2024-06-04" + assert summary.heart_rate is not None + assert summary.sleep is not None + assert summary.steps is not None + + async def test_get_latest_daily_summary_falls_back_to_latest_snapshot_time( + self, mock_auth: Any + ) -> None: + client = _make_client(mock_auth) + client.find_relative = AsyncMock( + return_value=type( + "Member", + (), + { + "relative_uid": 123, + "relative_note": "测试", + "latest_data_time": 0, + }, + )() + ) + client._request = AsyncMock( + side_effect=[ + LATEST_DATA_RESPONSE, + AGGREGATED_HR_RESPONSE, + AGGREGATED_SLEEP_RESPONSE, + AGGREGATED_STEPS_RESPONSE, + ] + ) + + summary = await client.get_latest_daily_summary(123) + + assert summary.date == "2024-06-04" + assert summary.heart_rate is not None + assert summary.sleep is not None + assert summary.steps is not None + + async def test_daily_summary_returns_partial_results_when_some_data_not_shared( + self, + mock_auth: Any, + ) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock( + side_effect=[ + DataNotSharedError("未共享该数据类型", data_type="heart_rate"), + DataNotSharedError("未共享该数据类型", data_type="sleep"), + AGGREGATED_STEPS_RESPONSE, + ] + ) + + summary = await client.get_daily_summary(123, date(2024, 6, 4)) + + assert summary.date == "2024-06-04" + assert summary.heart_rate is None + assert summary.sleep is None + assert summary.steps is not None + assert summary.steps.steps == 8500 + + async def test_daily_summary_raises_when_query_date_exceeds_shared_time_scope( + self, + mock_auth: Any, + ) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock( + side_effect=[ + DataOutOfSharedTimeScopeError("超出亲友共享时间范围", data_type="heart_rate"), + AGGREGATED_SLEEP_RESPONSE, + AGGREGATED_STEPS_RESPONSE, + ] + ) + + with pytest.raises(DataOutOfSharedTimeScopeError, match="超出亲友共享时间范围"): + await client.get_daily_summary(123, date(2024, 6, 4)) + + +# endregion + + +# region 上下文管理器 +class TestContextManager: + async def test_async_context_manager(self, mock_auth: Any) -> None: + async with MiHealthClient(mock_auth) as client: + assert client is not None + + +# endregion + + +# region 自动刷新 +class TestAutoRefresh: + async def test_request_refreshes_and_retries_once(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + + async def refresh() -> Any: + mock_auth.token.service_token = "refreshed-token" + return mock_auth.token + + mock_auth.refresh = AsyncMock(side_effect=refresh) + client._http = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + import mi_fitness.client.api as client_api_module + + encrypted = AsyncMock( + side_effect=[ + TokenExpiredError("expired"), + RELATIVE_LIST_RESPONSE, + ] + ) + mp.setattr(client_api_module, "encrypted_request", encrypted) + + members = await client.get_relatives() + + assert len(members) == 2 + mock_auth.refresh.assert_awaited_once() + assert encrypted.await_count == 2 + + async def test_request_does_not_refresh_twice(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + mock_auth.refresh = AsyncMock(return_value=mock_auth.token) + client._http = MagicMock() + + with pytest.MonkeyPatch.context() as mp: + import mi_fitness.client.api as client_api_module + + encrypted = AsyncMock(side_effect=TokenExpiredError("expired")) + mp.setattr(client_api_module, "encrypted_request", encrypted) + + with pytest.raises(TokenExpiredError): + await client._request("GET", RELATIVES_LIST_PATH) + + mock_auth.refresh.assert_awaited_once() + assert encrypted.await_count == 2 + + +# endregion + + +# region 消息接口 +class TestGetInviteMessages: + async def test_returns_all_messages(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE) + + messages = await client.get_invite_messages() + assert len(messages) == 2 + assert messages[0].msg_id == 152824796151809 + assert messages[0].sender == 1452722403 + assert messages[0].invite_id == 4777767 + assert messages[0].nick_name == "测试用户" + assert messages[0].is_pending is True + + client._request.assert_called_once_with( + "POST", + MESSAGE_GET_LIST_PATH, + params={"module": MESSAGE_MODULE_RELATIVES, "limit": 30}, + ) + + async def test_pending_only(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE) + + messages = await client.get_invite_messages(pending_only=True) + assert len(messages) == 1 + assert messages[0].is_pending is True + assert messages[0].invite_id == 4777767 + + async def test_custom_limit(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=MESSAGE_LIST_RESPONSE) + + await client.get_invite_messages(limit=10) + + params = client._request.call_args.kwargs["params"] + assert params["limit"] == 10 + + +class TestHasNewInvite: + async def test_has_new(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=CHECK_NEW_MSG_RESPONSE) + + result = await client.has_new_invite() + assert result is True + + async def test_no_new(self, mock_auth: Any) -> None: + client = _make_client(mock_auth) + client._request = AsyncMock(return_value=CHECK_NO_NEW_MSG_RESPONSE) + + result = await client.has_new_invite() + assert result is False + + +# endregion + + +# region 时间戳工具(同步测试,不需要 asyncio mark) +@pytest.mark.filterwarnings("ignore::pytest.PytestWarning") +class TestDateToTimestamps: + def test_specific_date(self) -> None: + start, end = MiHealthClient._date_to_timestamps(date(2024, 6, 4)) + assert end - start == 86399 # 23:59:59 + + def test_today_default(self) -> None: + start, end = MiHealthClient._date_to_timestamps() + assert end - start == 86399 + + +# endregion diff --git a/mi-fitness-python/tests/unit/test_client_base.py b/mi-fitness-python/tests/unit/test_client_base.py new file mode 100644 index 0000000..a029cf5 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_client_base.py @@ -0,0 +1,138 @@ +"""测试基础请求层的边界容错。""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +import mi_fitness.client.base as client_base +from mi_fitness.client.base import encrypted_request +from mi_fitness.const import ERR_NOT_RELATIVES, ERR_NOT_SHARED_DATA_TYPE +from mi_fitness.exceptions import ( + AuthError, + DataNotSharedError, + DataOutOfSharedTimeScopeError, + FamilyMemberNotFoundError, +) + +pytestmark = pytest.mark.asyncio + + +def _mock_response(status_code: int = 200, text: str = "encrypted") -> httpx.Response: + request = httpx.Request("GET", "https://example.com/test") + return httpx.Response(status_code=status_code, text=text, request=request) + + +async def test_encrypted_request_accepts_string_zero_code( + monkeypatch: pytest.MonkeyPatch, + auth_token, +) -> None: + http = MagicMock() + http.get = AsyncMock(return_value=_mock_response()) + + monkeypatch.setattr( + client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} + ) + monkeypatch.setattr( + client_base, + "decrypt_response", + lambda *args, **kwargs: {"code": "0", "result": {"ok": True}}, + ) + + result = await encrypted_request(http, auth_token, "GET", "/app/v1/test") + assert result["result"]["ok"] is True + + +async def test_encrypted_request_uses_string_code_for_not_relatives( + monkeypatch: pytest.MonkeyPatch, + auth_token, +) -> None: + http = MagicMock() + http.get = AsyncMock(return_value=_mock_response()) + + monkeypatch.setattr( + client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} + ) + monkeypatch.setattr( + client_base, + "decrypt_response", + lambda *args, **kwargs: { + "code": str(ERR_NOT_RELATIVES), + "desc": "not relatives", + }, + ) + + with pytest.raises(FamilyMemberNotFoundError, match="not relatives"): + await encrypted_request(http, auth_token, "GET", "/app/v1/test") + + +async def test_encrypted_request_requires_authenticated_token(auth_token) -> None: + http = MagicMock() + auth_token.service_token = "" + + with pytest.raises(AuthError, match="未登录"): + await encrypted_request(http, auth_token, "GET", "/app/v1/test") + + +async def test_encrypted_request_uses_string_code_for_data_not_shared( + monkeypatch: pytest.MonkeyPatch, + auth_token, +) -> None: + http = MagicMock() + http.get = AsyncMock(return_value=_mock_response()) + + monkeypatch.setattr( + client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} + ) + monkeypatch.setattr( + client_base, + "decrypt_response", + lambda *args, **kwargs: { + "code": str(ERR_NOT_SHARED_DATA_TYPE), + "desc": "not shared data type", + }, + ) + + with pytest.raises(DataNotSharedError, match="not shared data type") as exc_info: + await encrypted_request( + http, + auth_token, + "GET", + "/app/v1/relatives/get_aggregated_data", + params={"relative_uid": 1, "key": "heart_rate"}, + ) + + assert exc_info.value.data_type == "heart_rate" + + +async def test_encrypted_request_raises_time_scope_error_for_out_of_range_dates( + monkeypatch: pytest.MonkeyPatch, + auth_token, +) -> None: + http = MagicMock() + http.get = AsyncMock(return_value=_mock_response()) + + monkeypatch.setattr( + client_base, "build_encrypted_params", lambda *args, **kwargs: {"_nonce": "nonce"} + ) + monkeypatch.setattr( + client_base, + "decrypt_response", + lambda *args, **kwargs: { + "code": str(ERR_NOT_SHARED_DATA_TYPE), + "message": "time out of data shared time scope", + }, + ) + + with pytest.raises(DataOutOfSharedTimeScopeError, match="超出亲友共享时间范围") as exc_info: + await encrypted_request( + http, + auth_token, + "GET", + "/app/v1/relatives/get_aggregated_data", + params={"relative_uid": 1, "key": "steps"}, + ) + + assert exc_info.value.data_type == "steps" diff --git a/mi-fitness-python/tests/unit/test_const.py b/mi-fitness-python/tests/unit/test_const.py new file mode 100644 index 0000000..5a1d2a2 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_const.py @@ -0,0 +1,67 @@ +"""测试常量 (const.py)。""" + +from __future__ import annotations + +from mi_fitness.const import ( + ALL_SHARED_DATA_TYPES, + HEALTH_API_BASE, + RELATIVES_AGGREGATED_DATA_PATH, + RELATIVES_DELETE_PATH, + RELATIVES_FITNESS_DATA_PATH, + RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, + RELATIVES_GET_FAMILY_MEMBER_PATH, + RELATIVES_GET_INVITE_ID_PATH, + RELATIVES_GET_SHARED_TYPES_PATH, + RELATIVES_GET_TOPIC_SUBS_PATH, + RELATIVES_LATEST_DATA_PATH, + RELATIVES_LIST_PATH, + RELATIVES_SEND_INVITE_PATH, + RELATIVES_VERIFY_USER_PATH, + VERIFY_TYPE_XIAOMI_ID, +) + + +class TestAPIEndpoints: + """API 端点常量测试。""" + + def test_base_url_is_https(self) -> None: + assert HEALTH_API_BASE.startswith("https://") + + def test_all_paths_start_with_slash(self) -> None: + paths = [ + RELATIVES_LIST_PATH, + RELATIVES_LATEST_DATA_PATH, + RELATIVES_AGGREGATED_DATA_PATH, + RELATIVES_FITNESS_DATA_PATH, + RELATIVES_VERIFY_USER_PATH, + RELATIVES_SEND_INVITE_PATH, + RELATIVES_DELETE_PATH, + RELATIVES_GET_SHARED_TYPES_PATH, + RELATIVES_GET_APPLIED_SHARED_TYPES_PATH, + RELATIVES_GET_FAMILY_MEMBER_PATH, + RELATIVES_GET_INVITE_ID_PATH, + RELATIVES_GET_TOPIC_SUBS_PATH, + ] + for path in paths: + assert path.startswith(("/app/v1/relatives/", "/app/v1/data/")), f"{path} API 路径前缀不正确" + + +class TestSharedDataTypes: + """共享数据类型常量测试。""" + + def test_has_10_types(self) -> None: + assert len(ALL_SHARED_DATA_TYPES) == 10 + + def test_contains_core_types(self) -> None: + for key in ["heart_rate", "sleep", "steps", "weight", "spo2"]: + assert key in ALL_SHARED_DATA_TYPES, f"缺少 {key}" + + def test_no_duplicates(self) -> None: + assert len(ALL_SHARED_DATA_TYPES) == len(set(ALL_SHARED_DATA_TYPES)) + + +class TestVerifyTypes: + """验证类型常量测试。""" + + def test_values(self) -> None: + assert VERIFY_TYPE_XIAOMI_ID == 1 diff --git a/mi-fitness-python/tests/unit/test_crypto.py b/mi-fitness-python/tests/unit/test_crypto.py new file mode 100644 index 0000000..aa0b028 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_crypto.py @@ -0,0 +1,199 @@ +"""测试加密模块 (crypto.py)。 + +覆盖:RC4、nonce、签名、加密/解密往返。 +""" + +from __future__ import annotations + +import base64 +import json + +from mi_fitness.crypto import ( + _build_sig_message, + _rc4_crypt, + _sha1_b64, + build_encrypted_params, + compute_signed_nonce, + decrypt_data, + decrypt_response, + encrypt_data, + generate_nonce, +) + + +class TestRC4: + """RC4 加密/解密测试。""" + + def test_encrypt_decrypt_roundtrip(self) -> None: + """加密后解密应还原明文。""" + key = b"test_key_123456" + plaintext = b"Hello, MiSDK!" + encrypted = _rc4_crypt(key, plaintext) + decrypted = _rc4_crypt(key, encrypted) + assert decrypted == plaintext + + def test_different_keys_produce_different_output(self) -> None: + """不同密钥应产生不同密文。""" + data = b"same data" + enc1 = _rc4_crypt(b"key_aaa", data) + enc2 = _rc4_crypt(b"key_bbb", data) + assert enc1 != enc2 + + def test_empty_data(self) -> None: + """空数据加密应返回空。""" + result = _rc4_crypt(b"key", b"") + assert result == b"" + + def test_skip_parameter(self) -> None: + """skip=0 和 skip=1024 应产生不同结果。""" + key = b"test" + data = b"data" + r1 = _rc4_crypt(key, data, skip=0) + r2 = _rc4_crypt(key, data, skip=1024) + assert r1 != r2 + + def test_large_data(self) -> None: + """大数据块也能正确往返。""" + key = b"large_key" + data = b"x" * 100_000 + assert _rc4_crypt(key, _rc4_crypt(key, data)) == data + + +class TestNonce: + """nonce 生成测试。""" + + def test_nonce_is_base64(self) -> None: + """nonce 应为有效的 base64 字符串。""" + nonce = generate_nonce() + decoded = base64.b64decode(nonce) + assert len(decoded) == 12 # 8 random + 4 time + + def test_nonce_unique(self) -> None: + """连续生成的 nonce 应不同(随机部分不同)。""" + nonces = {generate_nonce() for _ in range(10)} + assert len(nonces) == 10 + + +class TestSignedNonce: + """compute_signed_nonce 测试。""" + + def test_deterministic(self, ssecurity: str) -> None: + """相同输入应产生相同结果。""" + nonce = generate_nonce() + r1 = compute_signed_nonce(ssecurity, nonce) + r2 = compute_signed_nonce(ssecurity, nonce) + assert r1 == r2 + + def test_output_is_base64(self, ssecurity: str) -> None: + """输出应为有效 base64。""" + nonce = generate_nonce() + snonce = compute_signed_nonce(ssecurity, nonce) + decoded = base64.b64decode(snonce) + assert len(decoded) == 32 # SHA256 + + +class TestSignature: + """签名生成测试(纯 SHA1 + Base64)。""" + + def test_sha1_b64_output(self) -> None: + """SHA1 base64 输出应为 28 字符。""" + result = _sha1_b64("test message") + decoded = base64.b64decode(result) + assert len(decoded) == 20 # SHA1 + + def test_sig_msg_format(self) -> None: + """签名消息应为 METHOD&/path&k=v&signedNonce 格式。""" + msg = _build_sig_message("GET", "/test/path", {"data": "hello"}, "snonce123") + assert msg == "GET&/test/path&data=hello&snonce123" + + def test_sig_msg_method_uppercase(self) -> None: + """方法名应转为大写。""" + msg = _build_sig_message("get", "/path", {}, "sn") + assert msg.startswith("GET&") + + def test_sig_msg_adds_leading_slash(self) -> None: + """路径没有前导 / 时应自动添加。""" + msg = _build_sig_message("GET", "path", {}, "sn") + assert "&/path&" in msg + + def test_sig_msg_sorted_params(self) -> None: + """参数应按 key 字典序排序。""" + msg = _build_sig_message("GET", "/p", {"z": "1", "a": "2"}, "sn") + assert msg == "GET&/p&a=2&z=1&sn" + + +class TestEncryptDecryptData: + """encrypt_data / decrypt_data 测试。""" + + def test_roundtrip(self, ssecurity: str) -> None: + """加密后解密应还原。""" + nonce = generate_nonce() + snonce = compute_signed_nonce(ssecurity, nonce) + plaintext = '{"key": "value", "中文": "测试"}' + encrypted = encrypt_data(snonce, plaintext) + decrypted = decrypt_data(snonce, encrypted) + assert decrypted == plaintext + + def test_encrypted_is_base64(self, ssecurity: str) -> None: + """密文应为 base64 编码。""" + nonce = generate_nonce() + snonce = compute_signed_nonce(ssecurity, nonce) + encrypted = encrypt_data(snonce, "test") + base64.b64decode(encrypted) # 不抛异常即可 + + +class TestBuildEncryptedParams: + """build_encrypted_params 集成测试。""" + + def test_has_required_keys(self, ssecurity: str) -> None: + """返回应包含 data, _nonce, signature, rc4_hash__。""" + result = build_encrypted_params( + "GET", + "/app/v1/relatives/get_relative_list", + ssecurity, + {"relative_uid": 123}, + ) + assert "data" in result + assert "_nonce" in result + assert "signature" in result + assert "rc4_hash__" in result + + def test_no_params_no_data_key(self, ssecurity: str) -> None: + """无参数时不应有 data 字段。""" + result = build_encrypted_params( + "GET", + "/app/v1/relatives/get_relative_list", + ssecurity, + ) + assert "data" not in result + assert "_nonce" in result + + def test_can_decrypt_own_params(self, ssecurity: str) -> None: + """能解密自己加密的参数。""" + params = {"test": "hello", "num": 42} + result = build_encrypted_params("POST", "/test", ssecurity, params) + snonce = compute_signed_nonce(ssecurity, result["_nonce"]) + decrypted = decrypt_data(snonce, result["data"]) + parsed = json.loads(decrypted) + assert parsed == params + + +class TestDecryptResponse: + """decrypt_response 集成测试。""" + + def test_roundtrip(self, ssecurity: str) -> None: + """构造加密响应并解密。""" + nonce = generate_nonce() + snonce = compute_signed_nonce(ssecurity, nonce) + original = {"code": 0, "result": {"data": "test"}} + encrypted = encrypt_data(snonce, json.dumps(original)) + decrypted = decrypt_response(ssecurity, nonce, encrypted) + assert decrypted == original + + def test_non_json_returns_string(self, ssecurity: str) -> None: + """非 JSON 响应应返回字符串。""" + nonce = generate_nonce() + snonce = compute_signed_nonce(ssecurity, nonce) + encrypted = encrypt_data(snonce, "not json at all") + result = decrypt_response(ssecurity, nonce, encrypted) + assert result == "not json at all" diff --git a/mi-fitness-python/tests/unit/test_exceptions.py b/mi-fitness-python/tests/unit/test_exceptions.py new file mode 100644 index 0000000..ee6e68a --- /dev/null +++ b/mi-fitness-python/tests/unit/test_exceptions.py @@ -0,0 +1,80 @@ +"""测试异常类 (exceptions.py)。""" + +from __future__ import annotations + +from mi_fitness.exceptions import ( + APIError, + AuthError, + CaptchaRequiredError, + DataNotSharedError, + DataOutOfSharedTimeScopeError, + FamilyMemberNotFoundError, + MiSDKError, + TokenExpiredError, +) + + +class TestExceptionHierarchy: + """异常继承关系测试。""" + + def test_base_exception(self) -> None: + e = MiSDKError("test") + assert str(e) == "test" + assert isinstance(e, Exception) + + def test_auth_error_inherits(self) -> None: + e = AuthError("auth fail") + assert isinstance(e, MiSDKError) + + def test_token_expired_inherits_auth(self) -> None: + e = TokenExpiredError("expired") + assert isinstance(e, AuthError) + assert isinstance(e, MiSDKError) + + def test_api_error_attributes(self) -> None: + e = APIError( + "api fail", + status_code=500, + code=1001, + response_body='{"error": true}', + ) + assert isinstance(e, MiSDKError) + assert e.status_code == 500 + assert e.code == 1001 + assert e.response_body == '{"error": true}' + assert str(e) == "api fail" + + def test_api_error_defaults(self) -> None: + e = APIError("msg") + assert e.status_code == 0 + assert e.code == 0 + assert e.response_body == "" + + def test_family_member_not_found(self) -> None: + e = FamilyMemberNotFoundError("未找到") + assert isinstance(e, MiSDKError) + + def test_captcha_required_inherits_auth(self) -> None: + e = CaptchaRequiredError("需要验证码") + assert isinstance(e, AuthError) + assert isinstance(e, MiSDKError) + + def test_captcha_required_url(self) -> None: + url = "https://account.xiaomi.com/pass/getCode?icodeType=login" + e = CaptchaRequiredError("验证码风控", captcha_url=url) + assert e.captcha_url == url + assert str(e) == "验证码风控" + + def test_captcha_required_default_url(self) -> None: + e = CaptchaRequiredError("msg") + assert e.captcha_url == "" + + def test_data_not_shared_error(self) -> None: + e = DataNotSharedError("未共享", data_type="heart_rate") + assert isinstance(e, MiSDKError) + assert e.data_type == "heart_rate" + + def test_time_scope_error_inherits_data_not_shared(self) -> None: + e = DataOutOfSharedTimeScopeError("超出范围", data_type="steps") + assert isinstance(e, DataNotSharedError) + assert e.data_type == "steps" diff --git a/mi-fitness-python/tests/unit/test_http.py b/mi-fitness-python/tests/unit/test_http.py new file mode 100644 index 0000000..e3b61b7 --- /dev/null +++ b/mi-fitness-python/tests/unit/test_http.py @@ -0,0 +1,83 @@ +"""测试重试 HTTP 客户端 (http.py)。""" + +from __future__ import annotations + +import httpx +import pytest + +from mi_fitness.http import RetryAsyncClient + +pytestmark = pytest.mark.asyncio + + +async def test_retries_on_idempotent_get_status_code() -> None: + """GET 遇到可重试状态码时应自动重试。""" + call_count = 0 + + async def handler(_: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + if call_count < 3: + return httpx.Response(status_code=503, json={"message": "busy"}) + return httpx.Response(status_code=200, json={"ok": True}) + + transport = httpx.MockTransport(handler) + async with RetryAsyncClient( + transport=transport, + retry_attempts=3, + retry_wait_min=0.0, + retry_wait_max=0.0, + retry_wait_multiplier=0.0, + ) as client: + resp = await client.get("https://example.com/ping") + + assert resp.status_code == 200 + assert call_count == 3 + + +async def test_does_not_retry_non_idempotent_post_by_default() -> None: + """POST 默认不重试,避免非幂等接口重复提交。""" + call_count = 0 + + async def handler(_: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + return httpx.Response(status_code=503, json={"message": "busy"}) + + transport = httpx.MockTransport(handler) + async with RetryAsyncClient( + transport=transport, + retry_attempts=3, + retry_wait_min=0.0, + retry_wait_max=0.0, + retry_wait_multiplier=0.0, + ) as client: + resp = await client.post("https://example.com/send", data={"a": "1"}) + + assert resp.status_code == 503 + assert call_count == 1 + + +async def test_retries_on_network_error_for_get() -> None: + """GET 遇到网络异常时应自动重试。""" + call_count = 0 + + async def handler(_: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + if call_count < 2: + raise httpx.ConnectError("connect failed") + return httpx.Response(status_code=200, json={"ok": True}) + + transport = httpx.MockTransport(handler) + async with RetryAsyncClient( + transport=transport, + retry_attempts=3, + retry_wait_min=0.0, + retry_wait_max=0.0, + retry_wait_multiplier=0.0, + ) as client: + resp = await client.get("https://example.com/health") + + assert resp.status_code == 200 + assert call_count == 2 diff --git a/mi-fitness-python/tests/unit/test_models.py b/mi-fitness-python/tests/unit/test_models.py new file mode 100644 index 0000000..f43ee6f --- /dev/null +++ b/mi-fitness-python/tests/unit/test_models.py @@ -0,0 +1,807 @@ +"""测试数据模型 (models.py)。 + +覆盖:模型构造、字段解析、响应包装器的属性方法。 +""" + +from __future__ import annotations + +import json + +from mi_fitness.models import ( + AggregatedDataItem, + AggregatedDataResponse, + AuthToken, + BloodPressureData, + CaloriesData, + CheckNewMsgResponse, + DeleteRelativeResponse, + FamilyMember, + FamilyMemberResponse, + GoalData, + GoalMetric, + HeartRateData, + IntensityData, + InviteMessage, + InviteResponse, + InviteUniqueIdResponse, + LatestDataItem, + LatestDataResponse, + LatestDataSnapshot, + LatestHeartRate, + MessageListResponse, + OperateInviteResponse, + RelativeListResponse, + SharedDataTypesResponse, + SleepData, + SleepSegment, + Spo2Data, + Spo2SummaryData, + StepData, + ValidStandData, + VerifiedUserInfo, + VerifyUserResponse, + WeightData, +) + + +# region 基础模型测试 +class TestAuthToken: + """AuthToken 模型测试。""" + + def test_defaults(self) -> None: + """空构造应全部默认空串。""" + t = AuthToken() + assert t.user_id == "" + assert t.ssecurity == "" + assert t.service_token == "" + + def test_serialization_roundtrip(self) -> None: + """JSON 序列化往返。""" + t = AuthToken(user_id="123", ssecurity="abc") + data = t.model_dump_json() + t2 = AuthToken.model_validate_json(data) + assert t2.user_id == t.user_id + assert t2.ssecurity == t.ssecurity + + +class TestFamilyMember: + """FamilyMember 模型测试。""" + + def test_construction(self) -> None: + m = FamilyMember(relative_uid=123, relative_note="妈妈") + assert m.relative_uid == 123 + assert m.relative_note == "妈妈" + assert m.latest_data_time == 0 + + def test_from_dict(self) -> None: + data = { + "relative_uid": 9999, + "relative_note": "爸爸", + "relative_icon": "https://img.example.com/a.jpg", + "latest_data_time": 1717488000, + "latest_abnormal_record_time": 0, + "source_tag": 1, + } + m = FamilyMember(**data) + assert m.relative_uid == 9999 + assert m.source_tag == 1 + + +class TestLatestHeartRate: + def test_defaults(self) -> None: + hr = LatestHeartRate() + assert hr.bpm == 0 + assert hr.time == 0 + + +class TestHeartRateData: + def test_full_construction(self) -> None: + hr = HeartRateData( + time=1717488000, + avg_hr=72, + max_hr=120, + min_hr=55, + latest_hr=LatestHeartRate(bpm=75, time=1717488000), + ) + assert hr.avg_hr == 72 + assert hr.latest_hr is not None + assert hr.latest_hr.bpm == 75 + + +class TestSleepData: + def test_with_segments(self) -> None: + seg = SleepSegment(bedtime=100, wake_up_time=200, duration=100) + sd = SleepData( + time=1717488000, + total_duration=480, + sleep_score=85, + segment_details=[seg], + ) + assert sd.total_duration == 480 + assert len(sd.segment_details) == 1 + assert sd.segment_details[0].duration == 100 + + +class TestStepData: + def test_construction(self) -> None: + s = StepData(time=1717488000, steps=8500, distance=6200, calories=320) + assert s.steps == 8500 + + +class TestWeightData: + def test_construction(self) -> None: + w = WeightData(time=1717488000, weight=65.5, bmi=22.1) + assert w.weight == 65.5 + assert w.bmi == 22.1 + + +class TestVerifiedUserInfo: + def test_alias_user_id(self) -> None: + """userId 别名应映射到 user_id。""" + info = VerifiedUserInfo(**{"userId": 12345, "nickname": "测试", "icon": "url"}) + assert info.user_id == 12345 + assert info.nickname == "测试" + + def test_populate_by_name(self) -> None: + """也可以用 user_id 直接构造。""" + info = VerifiedUserInfo(user_id=999, nickname="直接") # type: ignore[call-arg] + assert info.user_id == 999 + + +# endregion + + +# region LatestDataItem 测试 +class TestLatestDataItem: + def test_parse_json_value(self) -> None: + item = LatestDataItem( + time=1717488000, + key="heart_rate", + value='{"avg_hr": 72}', + ) + parsed = item.parse_value() + assert isinstance(parsed, dict) + assert parsed["avg_hr"] == 72 + + def test_parse_numeric_value(self) -> None: + item = LatestDataItem(time=1717488000, key="goal", value=10000) + parsed = item.parse_value() + assert parsed == 10000 + + def test_parse_invalid_json(self) -> None: + item = LatestDataItem(time=1717488000, key="bad", value="not{json") + parsed = item.parse_value() + assert parsed == {} + + def test_parse_dict_value(self) -> None: + item = LatestDataItem( + time=1717488000, + key="weight", + value={"weight": 65.5}, # type: ignore[arg-type] + ) + parsed = item.parse_value() + assert parsed == {"weight": 65.5} + + def test_parse_json_list_returns_empty_dict(self) -> None: + item = LatestDataItem(time=1717488000, key="bad", value='["unexpected"]') + assert item.parse_value() == {} + + def test_as_goal(self) -> None: + item = LatestDataItem( + time=1717488000, + key="goal", + value={ + "date_time": 1717488000, + "goal_items": [ + {"field": 1, "target_value": 6000, "achieved_value": 3716}, + ], + }, # type: ignore[arg-type] + ) + goal = item.as_goal() + assert isinstance(goal, GoalData) + assert goal.time == 1717488000 + assert len(goal.goal_items) == 1 + assert goal.goal_items[0].target_value == 6000 + assert goal.goal_items[0].metric == GoalMetric.STEPS + assert goal.goal_items[0].metric_key == "steps" + assert goal.goal_items[0].metric_label == "步数" + + def test_goal_accessors_and_unknown_items(self) -> None: + goal = GoalData.model_validate( + { + "time": 1717488000, + "goal_items": [ + {"field": 2, "target_value": 400, "achieved_value": 347}, + {"field": 1, "target_value": 6000, "achieved_value": 6203}, + {"field": 4, "target_value": 30, "achieved_value": 27}, + {"field": 99, "target_value": 1, "achieved_value": 0}, + ], + } + ) + + assert goal.calories_goal is not None + assert goal.calories_goal.target_value == 400 + assert goal.steps_goal is not None + assert goal.steps_goal.achieved_value == 6203 + assert goal.intensity_goal is not None + assert goal.intensity_goal.target_value == 30 + assert goal.available_metrics == [ + GoalMetric.CALORIES, + GoalMetric.STEPS, + GoalMetric.INTENSITY, + ] + assert len(goal.unknown_goal_items) == 1 + assert goal.unknown_goal_items[0].metric is None + assert goal.unknown_goal_items[0].metric_key == "unknown:99" + assert goal.get_item(GoalMetric.STEPS) is goal.steps_goal + + def test_as_heart_rate(self) -> None: + item = LatestDataItem( + time=1717488000, + key="heart_rate", + value='{"time":1717491600,"bpm":84}', + ) + heart_rate = item.as_heart_rate() + assert isinstance(heart_rate, LatestHeartRate) + assert heart_rate.time == 1717491600 + assert heart_rate.bpm == 84 + + def test_as_heart_rate_returns_none_for_invalid_payload(self) -> None: + item = LatestDataItem( + time=1717488000, + key="heart_rate", + value='{"time":1717491600,"bpm":{"bad":1}}', + ) + assert item.as_heart_rate() is None + + def test_as_sleep_uses_date_time_alias(self) -> None: + item = LatestDataItem( + time=1717488000, + key="sleep", + value='{"date_time":1717488000,"total_duration":436,"sleep_score":86}', + ) + sleep = item.as_sleep() + assert isinstance(sleep, SleepData) + assert sleep.time == 1717488000 + assert sleep.total_duration == 436 + assert sleep.sleep_score == 86 + + def test_as_steps_and_latest_metrics(self) -> None: + steps_item = LatestDataItem( + time=1717488000, + key="steps", + value='{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}', + ) + calories_item = LatestDataItem( + time=1717488000, + key="calories", + value='{"date_time":1717488000,"calories":230,"goal":300}', + ) + stand_item = LatestDataItem( + time=1717488000, + key="valid_stand", + value='{"date_time":1717488000,"count":7}', + ) + intensity_item = LatestDataItem( + time=1717488000, + key="intensity", + value='{"date_time":1717488000,"duration":15}', + ) + spo2_item = LatestDataItem( + time=1717488000, + key="spo2", + value='{"time":1717495200,"spo2":96}', + ) + + steps = steps_item.as_steps() + calories = calories_item.as_calories() + stand = stand_item.as_valid_stand() + intensity = intensity_item.as_intensity() + spo2 = spo2_item.as_spo2() + + assert isinstance(steps, StepData) + assert steps.goal == 6000 + assert isinstance(calories, CaloriesData) + assert calories.goal == 300 + assert isinstance(stand, ValidStandData) + assert stand.count == 7 + assert isinstance(intensity, IntensityData) + assert intensity.duration == 15 + assert isinstance(spo2, Spo2Data) + assert spo2.spo2 == 96 + + def test_as_blood_pressure_returns_none_when_value_missing(self) -> None: + item = LatestDataItem(time=1717488000, key="blood_pressure") + assert item.as_blood_pressure() is None + + def test_as_blood_pressure_supports_fitness_aliases(self) -> None: + item = LatestDataItem( + time=1773753098, + key="blood_pressure", + value='{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}', + ) + + blood_pressure = item.as_blood_pressure() + + assert isinstance(blood_pressure, BloodPressureData) + assert blood_pressure.systolic == 33 + assert blood_pressure.diastolic == 30 + assert blood_pressure.pulse == 60 + + +# endregion + + +# region AggregatedDataItem 测试 +class TestAggregatedDataItem: + def test_stringify_dict_value(self) -> None: + """dict value 应被自动转为 JSON 字符串。""" + item = AggregatedDataItem( + time=1717488000, + key="heart_rate", + value={"avg_hr": 72}, # type: ignore[arg-type] + ) + assert isinstance(item.value, str) + parsed = json.loads(item.value) + assert parsed["avg_hr"] == 72 + + def test_as_heart_rate(self) -> None: + value = json.dumps( + { + "avg_hr": 72, + "max_hr": 120, + "min_hr": 55, + "avg_rhr": 62, + "latest_hr": {"bpm": 75, "time": 1717488000}, + } + ) + item = AggregatedDataItem(time=1717430400, key="heart_rate", value=value) + hr = item.as_heart_rate() + assert isinstance(hr, HeartRateData) + assert hr.avg_hr == 72 + assert hr.time == 1717430400 + assert hr.latest_hr is not None + assert hr.latest_hr.bpm == 75 + + def test_as_sleep(self) -> None: + value = json.dumps( + { + "total_duration": 480, + "sleep_score": 85, + "segment_details": [ + {"bedtime": 100, "wake_up_time": 200, "duration": 100}, + ], + } + ) + item = AggregatedDataItem(time=1717430400, key="sleep", value=value) + sd = item.as_sleep() + assert isinstance(sd, SleepData) + assert sd.total_duration == 480 + assert len(sd.segment_details) == 1 + + def test_as_sleep_ignores_invalid_segments(self) -> None: + value = json.dumps( + { + "total_duration": 480, + "segment_details": [ + {"bedtime": 100, "wake_up_time": 200, "duration": 100}, + "bad-segment", + 1, + ], + } + ) + item = AggregatedDataItem(time=1717430400, key="sleep", value=value) + sd = item.as_sleep() + assert len(sd.segment_details) == 1 + assert sd.segment_details[0].duration == 100 + + def test_as_steps(self) -> None: + value = json.dumps({"steps": 8500, "distance": 6200, "calories": 320}) + item = AggregatedDataItem(time=1717430400, key="steps", value=value) + st = item.as_steps() + assert isinstance(st, StepData) + assert st.steps == 8500 + assert st.time == 1717430400 + + def test_as_simple_latest_metrics(self) -> None: + calories_item = AggregatedDataItem( + time=1717430400, key="calories", value='{"calories":338}' + ) + stand_item = AggregatedDataItem(time=1717430400, key="valid_stand", value='{"count":10}') + intensity_item = AggregatedDataItem( + time=1717430400, key="intensity", value='{"duration":19}' + ) + + calories = calories_item.as_calories() + stand = stand_item.as_valid_stand() + intensity = intensity_item.as_intensity() + + assert isinstance(calories, CaloriesData) + assert calories.time == 1717430400 + assert calories.calories == 338 + assert isinstance(stand, ValidStandData) + assert stand.count == 10 + assert isinstance(intensity, IntensityData) + assert intensity.duration == 19 + + def test_as_weight_and_blood_pressure(self) -> None: + weight_item = AggregatedDataItem( + time=1773753142, + key="weight", + value='{"time":1773753142,"weight":55.0,"bmi":16.97531}', + ) + blood_pressure_item = AggregatedDataItem( + time=1773753098, + key="blood_pressure", + value='{"systolic_pressure":33,"diastolic_pressure":30,"pulse":60,"time":1773753098}', + ) + + weight = weight_item.as_weight() + blood_pressure = blood_pressure_item.as_blood_pressure() + + assert isinstance(weight, WeightData) + assert weight.weight == 55.0 + assert weight.bmi == 16.97531 + assert isinstance(blood_pressure, BloodPressureData) + assert blood_pressure.systolic == 33 + assert blood_pressure.diastolic == 30 + assert blood_pressure.pulse == 60 + + def test_as_spo2_summary(self) -> None: + value = json.dumps( + { + "avg_spo2": 96, + "lack_spo2_count": 0, + "latest_spo2": { + "spo2": 96, + "time": 1761161730, + "dbKey": "single_spo2", + }, + "max_spo2": 96, + "min_spo2": 96, + } + ) + item = AggregatedDataItem(time=1761091200, key="spo2", value=value) + spo2 = item.as_spo2() + + assert isinstance(spo2, Spo2SummaryData) + assert spo2.time == 1761091200 + assert spo2.avg_spo2 == 96 + assert spo2.latest_spo2 is not None + assert spo2.latest_spo2.spo2 == 96 + + +# endregion + + +# region 响应包装器测试 +class TestRelativeListResponse: + def test_parse_relatives(self) -> None: + resp = RelativeListResponse( + code=0, + result={ + "relative_list": [ + {"relative_uid": 111, "relative_note": "A"}, + {"relative_uid": 222, "relative_note": "B"}, + ] + }, + ) + members = resp.relatives + assert len(members) == 2 + assert members[0].relative_uid == 111 + + def test_empty_list(self) -> None: + resp = RelativeListResponse(code=0, result={"relative_list": []}) + assert resp.relatives == [] + + def test_skips_invalid_relatives(self) -> None: + resp = RelativeListResponse( + code=0, + result={ + "relative_list": [ + 123, + {"relative_note": "缺少 uid"}, + {"relative_uid": 222, "relative_note": "B"}, + ] + }, + ) + members = resp.relatives + assert len(members) == 1 + assert members[0].relative_uid == 222 + + +class TestLatestDataResponse: + def test_parse_data_items(self) -> None: + resp = LatestDataResponse( + code=0, + result={ + "data_list": [ + {"time": 100, "key": "hr", "value": "{}"}, + ] + }, + ) + items = resp.data_items + assert len(items) == 1 + assert items[0].key == "hr" + + def test_non_mapping_result_is_tolerated(self) -> None: + resp = LatestDataResponse(code=0, result=None) # type: ignore[arg-type] + assert resp.data_items == [] + + def test_snapshot_returns_typed_metrics(self) -> None: + resp = LatestDataResponse( + code=0, + result={ + "data_list": [ + { + "time": 1717488000, + "key": "heart_rate", + "value": '{"time":1717491600,"bpm":84}', + }, + { + "time": 1717488000, + "key": "steps", + "value": '{"date_time":1717488000,"steps":3716,"distance":2193,"calories":143,"goal":6000}', + }, + {"time": 1717488000, "key": "spo2", "value": '{"time":1717495200,"spo2":96}'}, + {"time": 1717488000, "key": "mood", "value": '{"score":80}'}, + ], + "latest_data_time": 1717495200, + }, + ) + snapshot = resp.snapshot + assert isinstance(snapshot, LatestDataSnapshot) + assert snapshot.updated_time == 1717495200 + assert snapshot.heart_rate is not None + assert snapshot.heart_rate.bpm == 84 + assert snapshot.steps is not None + assert snapshot.steps.goal == 6000 + assert snapshot.spo2 is not None + assert snapshot.spo2.spo2 == 96 + assert snapshot.extras == {"mood": {"score": 80}} + assert snapshot.available_keys == ["heart_rate", "steps", "spo2", "mood"] + + def test_snapshot_preserves_invalid_known_payload_in_extras(self) -> None: + resp = LatestDataResponse( + code=0, + result={ + "data_list": [ + { + "time": 1717488000, + "key": "heart_rate", + "value": '{"time":1717491600,"bpm":{"bad":1}}', + }, + ], + "latest_data_time": 1717495200, + }, + ) + snapshot = resp.snapshot + assert snapshot.heart_rate is None + assert snapshot.extras == {"heart_rate": {"time": 1717491600, "bpm": {"bad": 1}}} + assert snapshot.available_keys == ["heart_rate"] + + +class TestAggregatedDataResponse: + def test_parse_data_items(self) -> None: + resp = AggregatedDataResponse( + code=0, + result={ + "data_list": [ + { + "sid": "test", + "tag": "daily_report", + "key": "steps", + "time": 100, + "value": "{}", + "update_time": 200, + } + ], + "has_more": True, + "next_key": "abc", + }, + ) + assert len(resp.data_items) == 1 + assert resp.has_more is True + assert resp.next_key == "abc" + + def test_string_flags_are_normalized(self) -> None: + resp = AggregatedDataResponse( + code=0, + result={ + "data_list": [], + "has_more": "false", + "next_key": 123, + }, + ) + assert resp.has_more is False + assert resp.next_key == "123" + + +class TestVerifyUserResponse: + def test_with_user(self) -> None: + resp = VerifyUserResponse( + code=0, + result={"userId": 12345, "nickname": "用户", "icon": "url"}, + ) + info = resp.user_info + assert info is not None + assert info.user_id == 12345 + + def test_no_user(self) -> None: + resp = VerifyUserResponse(code=0, result={}) + assert resp.user_info is None + + +class TestInviteResponse: + def test_success(self) -> None: + resp = InviteResponse(code=0, result={"send_ret": 1}) + assert resp.success is True + + def test_failure(self) -> None: + resp = InviteResponse(code=0, result={"send_ret": 0}) + assert resp.success is False + + def test_string_success_flag(self) -> None: + resp = InviteResponse(code=0, result={"send_ret": "1"}) + assert resp.success is True + + +class TestDeleteRelativeResponse: + def test_success(self) -> None: + resp = DeleteRelativeResponse(code=0, result={"delete_ret": True}) + assert resp.success is True + + def test_failure(self) -> None: + resp = DeleteRelativeResponse(code=0, result={"delete_ret": False}) + assert resp.success is False + + def test_string_false_is_false(self) -> None: + resp = DeleteRelativeResponse(code=0, result={"delete_ret": "false"}) + assert resp.success is False + + +class TestOperateInviteResponse: + def test_success(self) -> None: + resp = OperateInviteResponse(code=0, result={"operate_ret": True}) + assert resp.success is True + + def test_failure(self) -> None: + resp = OperateInviteResponse(code=0, result={"operate_ret": False}) + assert resp.success is False + + +class TestSharedDataTypesResponse: + def test_parse_keys(self) -> None: + resp = SharedDataTypesResponse( + code=0, + result={"keys": ["goal", "heart_rate", "sleep"]}, + ) + assert resp.keys == ["goal", "heart_rate", "sleep"] + + def test_empty(self) -> None: + resp = SharedDataTypesResponse(code=0, result={}) + assert resp.keys == [] + + def test_ignores_non_string_keys(self) -> None: + resp = SharedDataTypesResponse( + code=0, + result={"keys": ["goal", 1, None, "sleep"]}, + ) + assert resp.keys == ["goal", "sleep"] + + +class TestInviteUniqueIdResponse: + def test_parse_id(self) -> None: + resp = InviteUniqueIdResponse( + code=0, + result={"invite_link_id": 467184968352742400}, + ) + assert resp.invite_link_id == 467184968352742400 + + +class TestFamilyMemberResponse: + def test_parse_list(self) -> None: + resp = FamilyMemberResponse( + code=0, + result={"family_user_list": [{"userId": 1}, {"userId": 2}]}, + ) + assert len(resp.family_user_list) == 2 + + def test_empty(self) -> None: + resp = FamilyMemberResponse(code=0, result={}) + assert resp.family_user_list == [] + + +# endregion + + +# region 消息模型测试 +class TestInviteMessage: + def test_pending_invite(self) -> None: + msg = InviteMessage( + msg_id=152824796151809, + module=1, + type=1, + sender=1452722403, + extra_data='{"invite_id":4777767,"nick_name":"测试","icon":"https://example.com/a.jpg"}', + data_status=0, + ) + assert msg.invite_id == 4777767 + assert msg.nick_name == "测试" + assert msg.icon == "https://example.com/a.jpg" + assert msg.is_pending is True + + def test_processed_notification(self) -> None: + msg = InviteMessage(type=5, data_status=1, extra_data='{"nick_name":"用户"}') + assert msg.invite_id is None + assert msg.nick_name == "用户" + assert msg.is_pending is False + + def test_invalid_extra_data(self) -> None: + msg = InviteMessage(extra_data="not json") + assert msg.invite_id is None + assert msg.nick_name == "" + assert msg.icon == "" + + def test_empty_extra_data(self) -> None: + msg = InviteMessage(extra_data="") + assert msg.invite_id is None + assert msg.nick_name == "" + + def test_json_list_extra_data_is_ignored(self) -> None: + msg = InviteMessage(extra_data='["bad-shape"]') + assert msg.invite_id is None + assert msg.nick_name == "" + assert msg.icon == "" + + +class TestMessageListResponse: + def test_parse_messages(self) -> None: + resp = MessageListResponse( + code=0, + result={ + "messages": [ + {"msg_id": 1, "type": 1, "sender": 100, "data_status": 0}, + {"msg_id": 2, "type": 5, "sender": 200, "data_status": 1}, + ], + "msg_total": 2, + }, + ) + assert len(resp.messages) == 2 + assert resp.msg_total == 2 + assert resp.messages[0].msg_id == 1 + + def test_empty(self) -> None: + resp = MessageListResponse(code=0, result={}) + assert resp.messages == [] + assert resp.msg_total == 0 + + def test_single_message_dict_is_tolerated(self) -> None: + resp = MessageListResponse( + code=0, + result={"messages": {"msg_id": 1, "type": 1, "sender": 100}, "msg_total": "1"}, + ) + assert len(resp.messages) == 1 + assert resp.msg_total == 1 + + +class TestCheckNewMsgResponse: + def test_has_new(self) -> None: + resp = CheckNewMsgResponse(code=0, result=[{"module": 1, "is_new": True}]) + assert resp.has_new(1) is True + assert resp.has_new(2) is False + + def test_no_new(self) -> None: + resp = CheckNewMsgResponse(code=0, result=[{"module": 1, "is_new": False}]) + assert resp.has_new(1) is False + + def test_empty(self) -> None: + resp = CheckNewMsgResponse(code=0, result=[]) + assert resp.has_new(1) is False + + def test_single_dict_result_is_tolerated(self) -> None: + resp = CheckNewMsgResponse(code=0, result={"module": 1, "is_new": "true"}) # type: ignore[arg-type] + assert resp.has_new(1) is True + + +# endregion diff --git a/miband_sync.py b/miband_sync.py new file mode 100644 index 0000000..a73e9ff --- /dev/null +++ b/miband_sync.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import asyncio +import sys + +from miband_tracker.config import ConfigError, Settings +from miband_tracker.sync import daemon_main, run_sync + +__all__ = ["run_sync"] + + +def main() -> None: + try: + exit_code = asyncio.run(daemon_main(Settings.from_env())) + except ConfigError as exc: + print(f"Config error: {exc}", file=sys.stderr, flush=True) + exit_code = 1 + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/miband_tracker/__init__.py b/miband_tracker/__init__.py new file mode 100644 index 0000000..23bfc5a --- /dev/null +++ b/miband_tracker/__init__.py @@ -0,0 +1,6 @@ +"""Mi Band tracker service package.""" + +from .config import Settings +from .sync import SyncResult, run_sync + +__all__ = ["Settings", "SyncResult", "run_sync"] diff --git a/miband_tracker/bot/__init__.py b/miband_tracker/bot/__init__.py new file mode 100644 index 0000000..dbf7deb --- /dev/null +++ b/miband_tracker/bot/__init__.py @@ -0,0 +1 @@ +"""Telegram bot package.""" diff --git a/miband_tracker/bot/app.py b/miband_tracker/bot/app.py new file mode 100644 index 0000000..e8cdb42 --- /dev/null +++ b/miband_tracker/bot/app.py @@ -0,0 +1,1499 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from functools import wraps + +import asyncio +import html +import io +import logging +import sqlite3 +import sys +import time +from datetime import date, datetime, time as dt_time, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Message, Update +from telegram.ext import ( + Application, + CallbackQueryHandler, + CommandHandler, + ContextTypes, + MessageHandler, + filters, +) +from telegram.error import RetryAfter, TelegramError + +from mi_fitness.auth import XiaomiAuth + +from miband_tracker import storage +from miband_tracker.config import ConfigError, Settings +from miband_tracker.secure_files import save_auth_token +from miband_tracker.sync import run_sync + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("telegram").setLevel(logging.WARNING) +logging.getLogger("telegram.ext").setLevel(logging.WARNING) +logger = logging.getLogger("fitness-bot") + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +SETTINGS = Settings.from_env() +BOT_TOKEN = SETTINGS.telegram_bot_token +ALLOWED_USER_ID = SETTINGS.telegram_allowed_user_id +DB_PATH = str(SETTINGS.db_path) +LOCAL_TZ = ZoneInfo("Europe/Moscow") +SYNC_LOCK = asyncio.Lock() +AUTH_LOCK = asyncio.Lock() + +STEP_GOAL = 10_000 # можно вынести в env при желании + + +# --------------------------------------------------------------------------- +# Auth helpers +# --------------------------------------------------------------------------- +def is_allowed(update: Update) -> bool: + uid = update.effective_user.id if update.effective_user else None + return uid == ALLOWED_USER_ID + + +def with_user_context(func): + @wraps(func) + async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): + return await func(update, context, *args, **kwargs) + return wrapper + + +def get_user_db_path() -> str: + if ALLOWED_USER_ID is None: + return DB_PATH + return str(SETTINGS.user_db_path(ALLOWED_USER_ID)) + + +def get_user_status_path() -> str: + if ALLOWED_USER_ID is None: + return str(SETTINGS.status_path) + return str(SETTINGS.user_status_path(ALLOWED_USER_ID)) + + +def get_xiaomi_token_path() -> Path | None: + if ALLOWED_USER_ID is None: + return None + try: + return SETTINGS.token_path(ALLOWED_USER_ID) + except ConfigError: + return None + + +def has_xiaomi_token() -> bool: + token_path = get_xiaomi_token_path() + return bool(token_path and token_path.exists()) + + +def esc(value: object) -> str: + return html.escape(str(value), quote=False) + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- +def format_epoch(epoch: int | float | None, with_date: bool = True) -> str: + if not epoch: + return "н/д" + 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: + if not epoch: + return "н/д" + diff = int(time.time() - int(epoch)) + if diff < 60: + return "только что" + diff_min = diff // 60 + if diff_min < 60: + return f"{diff_min} мин. назад" + diff_hours = diff_min // 60 + if diff_hours < 24: + return f"{diff_hours} ч. назад" + diff_days = diff_hours // 24 + if diff_days == 1: + return "вчера" + return f"{diff_days} дн. назад" + + +def format_minutes(minutes: int | float | None) -> str: + if minutes is None: + return "н/д" + minutes = int(minutes) + return f"{minutes // 60} ч {minutes % 60:02d} мин" + + +def step_goal_bar(steps: int | float | None, goal: int = STEP_GOAL) -> str: + if steps is None: + steps = 0 + steps_int = int(steps) + percent = min(100, round(steps_int / goal * 100)) if goal > 0 else 0 + filled = percent // 10 + bar = "█" * filled + "░" * (10 - filled) + return f"[{bar}] {percent}%" + + +def step_goal_text(steps: int | float | None, goal: int = STEP_GOAL) -> str: + if steps is None: + return f"Цель {goal:,} шагов".replace(",", " ") + steps_int = int(steps) + left = max(0, goal - steps_int) + if left: + text = f"Цель {goal:,} · осталось {left:,} шагов" + else: + text = 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 get_sleep_sparkline(table: str, field: str, start_epoch: int, end_epoch: int) -> str: + rows = fetch_all( + f"SELECT {field} FROM {table} WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC", + (start_epoch, end_epoch), + ) + if not rows: + return "" + values = [row[field] for row in rows] + if len(values) > 20: + step = len(values) / 20 + values = [values[int(i * step)] for i in range(20)] + return make_sparkline(values) + + +def relative_day_label(day_str: str | None) -> str: + if not day_str: + return "Последний день" + try: + day_value = parse_day(day_str) + except ValueError: + return f"День: {day_str}" + today = datetime.now(LOCAL_TZ).date() + if day_value == today: + return "Сегодня" + if day_value == today - timedelta(days=1): + return "Вчера" + 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() + + +# --------------------------------------------------------------------------- +# Sleep quality label +# --------------------------------------------------------------------------- +def sleep_total(sleep: sqlite3.Row) -> int: + """Реальное время сна: total_duration_min если есть, иначе light+deep.""" + 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: + """Оценка качества сна по общей длительности и доле глубокого сна.""" + if total_min >= 420 and deep_min >= 60: + return "🟢 Отличный" + if total_min >= 360 and deep_min >= 40: + return "🟡 Хороший" + if total_min >= 300: + return "🟠 Средний" + return "🔴 Недостаточный" + + +# --------------------------------------------------------------------------- +# Daily tip engine +# --------------------------------------------------------------------------- +def daily_tip(steps: sqlite3.Row | None, sleep: sqlite3.Row | None, hr: sqlite3.Row | None) -> str: + """Генерирует один персональный инсайт на основе последних данных.""" + tips = [] + + if steps: + s = int(steps["total_steps"] or 0) + if s < 5000: + tips.append("💡 Сегодня совсем мало шагов. Прогулка в 20 минут добавит ~2 000 шагов и заметно поднимет настроение.") + elif s >= STEP_GOAL: + tips.append("💡 Дневная норма шагов выполнена — отличный результат!") + elif s >= 7000: + tips.append(f"💡 До цели {STEP_GOAL:,} шагов осталось {STEP_GOAL - s:,} — почти дошли!".replace(",", " ")) + + if sleep: + total = sleep_total(sleep) + deep = int(sleep["deep_sleep_min"] or 0) + if total < 300: + tips.append("💡 Ночной сон меньше 5 часов — это мало. Постарайтесь лечь пораньше сегодня.") + elif total < 360: + tips.append("💡 Ночной сон меньше 6 часов. Постарайтесь лечь пораньше сегодня.") + elif deep < 30: + tips.append("💡 Глубокого сна было совсем мало. Попробуйте проветрить комнату и ограничить экраны за час до сна.") + + if hr: + bpm = int(hr["value"]) + if bpm > 90: + tips.append("💡 Пульс в покое выше нормы — возможно, организм устал или есть стресс. Следите за самочувствием.") + elif bpm < 50: + tips.append("💡 Очень низкий пульс — если это спортивная норма, всё хорошо. Если нет — стоит обратить внимание.") + + if not tips: + tips.append("💡 Данных достаточно, продолжайте в том же духе!") + + return tips[0] # показываем один самый актуальный совет + + +# --------------------------------------------------------------------------- +# DB: health +# --------------------------------------------------------------------------- +def health_db_exists() -> bool: + return storage.health_db_exists(SETTINGS, ALLOWED_USER_ID) + + +def health_conn() -> sqlite3.Connection: + conn = sqlite3.connect(get_user_db_path()) + conn.execute("PRAGMA busy_timeout = 5000") + conn.row_factory = sqlite3.Row + return conn + + +def fetch_one(query: str, params: tuple = ()) -> sqlite3.Row | None: + return storage.fetch_one(SETTINGS, query, params, ALLOWED_USER_ID) + + +def fetch_all(query: str, params: tuple = ()) -> list[sqlite3.Row]: + return storage.fetch_all(SETTINGS, query, params, ALLOWED_USER_ID) + + +# --------------------------------------------------------------------------- +# DB: bot state +# --------------------------------------------------------------------------- +def init_state_db() -> None: + storage.init_state_db(SETTINGS) + + +def get_user_menu_msg_id(user_id: int) -> int | None: + try: + return storage.get_user_menu_msg_id(SETTINGS, user_id) + except Exception as e: + logger.warning("Failed to read menu message id for %s: %s", user_id, e) + return None + + +def set_user_menu_msg_id(user_id: int, msg_id: int) -> None: + try: + storage.set_user_menu_msg_id(SETTINGS, user_id, msg_id) + except Exception as e: + logger.warning("Failed to save menu message id for %s: %s", user_id, e) + + +# --------------------------------------------------------------------------- +# Telegram: safe edit with exponential backoff +# --------------------------------------------------------------------------- +async def safe_delete(message: Message | None) -> None: + if not message: + return + try: + await message.delete() + except Exception as e: + logger.debug("Failed to delete message: %s", e) + + +async def send_or_update_menu( + bot, + user_id: int, + text: str, + reply_markup: InlineKeyboardMarkup | None = None, + force_new: bool = False, +) -> None: + chat_id = user_id + msg_id = get_user_menu_msg_id(user_id) + + if msg_id and not force_new: + for attempt in range(3): + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=msg_id, + text=text, + parse_mode="HTML", + reply_markup=reply_markup, + disable_web_page_preview=True, + ) + return + except RetryAfter as e: + wait = e.retry_after + attempt + logger.warning("Flood control: waiting %ss (attempt %d)", wait, attempt + 1) + await asyncio.sleep(wait) + except TelegramError as e: + msg = str(e) + if "Message is not modified" in msg: + return + if "Message to edit not found" in msg or "message can't be edited" in msg.lower(): + break # сообщение пропало — создаём новое + logger.warning("Edit failed (attempt %d): %s", attempt + 1, e) + break + + # Старое сообщение устарело или не найдено — удаляем и создаём новое + if msg_id: + try: + await bot.delete_message(chat_id=chat_id, message_id=msg_id) + except Exception as e: + logger.debug("Failed to delete old menu message %s: %s", msg_id, e) + + new_msg = await bot.send_message( + chat_id=chat_id, + text=text, + parse_mode="HTML", + reply_markup=reply_markup, + disable_web_page_preview=True, + ) + set_user_menu_msg_id(user_id, new_msg.message_id) + + +async def update_menu( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + text: str, + reply_markup: InlineKeyboardMarkup | None = None, + force_new: bool = False, +) -> None: + if not update.effective_user: + return + user_id = update.effective_user.id + if update.callback_query and update.callback_query.message: + set_user_menu_msg_id(user_id, update.callback_query.message.message_id) + await send_or_update_menu(context.bot, user_id, text, reply_markup, force_new) + + +# --------------------------------------------------------------------------- +# Data queries +# --------------------------------------------------------------------------- +def read_status_file() -> dict: + return storage.read_status_file(SETTINGS, ALLOWED_USER_ID) + + +def latest_steps() -> sqlite3.Row | None: + return fetch_one( + """ + SELECT date, total_steps, calories, distance_m, last_sync + FROM steps_daily + ORDER BY date DESC + LIMIT 1 + """ + ) + + +def latest_sleep() -> sqlite3.Row | None: + return fetch_one( + """ + SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time, + COALESCE(rem_sleep_min, 0) AS rem_sleep_min, + COALESCE(awake_min, 0) AS awake_min, + COALESCE(total_duration_min, 0) AS total_duration_min, + COALESCE(sleep_score, 0) AS sleep_score + FROM sleep_daily + ORDER BY date DESC + LIMIT 1 + """ + ) + + +def latest_hr() -> sqlite3.Row | None: + return fetch_one("SELECT timestamp, value FROM heart_rate ORDER BY timestamp DESC LIMIT 1") + + +def latest_spo2() -> sqlite3.Row | None: + return fetch_one( + "SELECT timestamp, spo2, type FROM blood_oxygen ORDER BY timestamp DESC LIMIT 1" + ) + + +def resting_hr(start_epoch: int, end_epoch: int) -> int | None: + """Пульс покоя = минимальный за окно сна (игнорируем нули и аномалии < 30).""" + row = fetch_one( + """ + SELECT MIN(value) AS min_hr + FROM heart_rate + WHERE timestamp >= ? AND timestamp < ? AND value > 30 + """, + (start_epoch, end_epoch), + ) + if row and row["min_hr"]: + return int(row["min_hr"]) + return None + + +def metric_stats(table: str, field: str, start_epoch: int, end_epoch: int) -> sqlite3.Row | None: + return fetch_one( + f""" + SELECT COUNT(*) AS count, + ROUND(AVG({field}), 1) AS avg_value, + MIN({field}) AS min_value, + MAX({field}) AS max_value + FROM {table} + WHERE timestamp >= ? AND timestamp < ? + """, + (start_epoch, end_epoch), + ) + + +def sleep_window_stats(sleep: sqlite3.Row | None) -> tuple[sqlite3.Row | None, sqlite3.Row | None]: + if not sleep or not sleep["start_time"] or not sleep["end_time"]: + return None, None + hr = metric_stats("heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"])) + spo2 = metric_stats( + "blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"]) + ) + return hr, spo2 + + +def day_summary(day_str: str) -> dict: + day_value = parse_day(day_str) + start_epoch, end_epoch = day_bounds(day_value) + steps = fetch_one( + """ + SELECT date, total_steps, calories, distance_m, last_sync + FROM steps_daily + WHERE date = ? + """, + (day_str,), + ) + sleep = fetch_one( + """ + SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time, + COALESCE(rem_sleep_min, 0) AS rem_sleep_min, + COALESCE(awake_min, 0) AS awake_min, + COALESCE(total_duration_min, 0) AS total_duration_min, + COALESCE(sleep_score, 0) AS sleep_score + FROM sleep_daily + WHERE date = ? + """, + (day_str,), + ) + hr = metric_stats("heart_rate", "value", start_epoch, end_epoch) + spo2 = metric_stats("blood_oxygen", "spo2", start_epoch, end_epoch) + return {"date": day_str, "steps": steps, "sleep": sleep, "hr": hr, "spo2": spo2} + + +def available_days(limit: int = 14) -> list[str]: + rows = fetch_all( + """ + SELECT date FROM ( + SELECT date FROM steps_daily + UNION + SELECT date FROM sleep_daily + ) + ORDER BY date DESC + LIMIT ? + """, + (limit,), + ) + return [row["date"] for row in rows] + + +def period_bounds(days: int) -> tuple[date, date, int, int]: + end_day = datetime.now(LOCAL_TZ).date() + start_day = end_day - timedelta(days=days - 1) + start_epoch, _ = day_bounds(start_day) + _, end_epoch = day_bounds(end_day) + return start_day, end_day, start_epoch, end_epoch + + +def period_summary(days: int) -> dict: + start_day, end_day, start_epoch, end_epoch = period_bounds(days) + steps = fetch_all( + """ + SELECT date, total_steps, calories, distance_m + FROM steps_daily + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + """, + (start_day.isoformat(), end_day.isoformat()), + ) + sleep = fetch_all( + """ + SELECT date, light_sleep_min, deep_sleep_min, start_time, end_time, + COALESCE(rem_sleep_min, 0) AS rem_sleep_min, + COALESCE(awake_min, 0) AS awake_min, + COALESCE(total_duration_min, 0) AS total_duration_min, + COALESCE(sleep_score, 0) AS sleep_score + FROM sleep_daily + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + """, + (start_day.isoformat(), end_day.isoformat()), + ) + hr = metric_stats("heart_rate", "value", start_epoch, end_epoch) + spo2 = metric_stats("blood_oxygen", "spo2", start_epoch, end_epoch) + return { + "days": days, + "start": start_day, + "end": end_day, + "steps": steps, + "sleep": sleep, + "hr": hr, + "spo2": spo2, + } + + +# --------------------------------------------------------------------------- +# Day emoji helpers — для календаря +# --------------------------------------------------------------------------- +def day_emoji(steps_row: sqlite3.Row | None, sleep_row: sqlite3.Row | None) -> str: + """Один emoji для строки дня в календаре.""" + score = 0 + if steps_row and int(steps_row["total_steps"] or 0) >= STEP_GOAL: + score += 1 + if sleep_row: + total = sleep_total(sleep_row) + if total >= 420: + score += 1 + if score == 2: + return "🟢" + if score == 1: + return "🟡" + return "🔴" + + +# --------------------------------------------------------------------------- +# Dashboard text — «умный» с динамическим заголовком и советом дня +# --------------------------------------------------------------------------- +def main_menu_text() -> str: + steps = latest_steps() + sleep = latest_sleep() + hr = latest_hr() + spo2 = latest_spo2() + lines = [] + + # Шаги + if steps: + label = relative_day_label(steps["date"]) + steps_count = int(steps["total_steps"]) + lines.append(f"🚶 {esc(label)}: {steps_count:,} шагов".replace(",", " ")) + lines.append(f" {step_goal_bar(steps_count)}") + lines.append(f" {step_goal_text(steps_count)}") + lines.append( + f" {float(steps['distance_m']) / 1000:.1f} км · {float(steps['calories']):.0f} ккал" + ) + else: + lines.append("🚶 Шаги: жду первую синхронизацию") + + lines.append("") + + # Сон — краткая сводка на дашборде + if sleep: + total_sleep = sleep_total(sleep) + deep = int(sleep["deep_sleep_min"] or 0) + quality = sleep_quality_label(total_sleep, deep) + lines.append(f"😴 Сон: {format_minutes(total_sleep)} · {quality}") + lines.append( + f" {esc(relative_day_label(sleep['date']))} · " + f"глубокий: {deep} мин · лёгкий: {sleep['light_sleep_min']} мин" + + (f" · REM: {sleep['rem_sleep_min']} мин" if sleep['rem_sleep_min'] else "") + ) + else: + lines.append("😴 Сон: данных пока нет") + + lines.append("") + + # Пульс + if hr: + bpm = int(hr["value"]) + lines.append(f"❤️ Пульс: {bpm} bpm · {format_relative_time(hr['timestamp'])}") + else: + lines.append("❤️ Пульс: данных пока нет") + + # SpO2 + if spo2: + spo2_val = float(spo2["spo2"]) + color = "🟢" if spo2_val >= 95 else ("🟡" if spo2_val >= 90 else "🔴") + lines.append(f"🩸 SpO2: {color} {spo2_val:.1f}% · {format_relative_time(spo2['timestamp'])}") + else: + lines.append("🩸 SpO2: данных пока нет") + + # Совет дня + tip = daily_tip(steps, sleep, hr) + lines.extend(["", tip]) + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Day detail text +# --------------------------------------------------------------------------- +def day_text(data: dict) -> str: + steps = data["steps"] + sleep = data["sleep"] + hr = data["hr"] + spo2 = data["spo2"] + day_str = data["date"] + + day_value = parse_day(day_str) + start_epoch, end_epoch = day_bounds(day_value) + + day_label = relative_day_label(day_str) + lines = [f"📊 {esc(day_label)} · {esc(day_str)}", ""] + + # Шаги + if steps: + steps_count = int(steps["total_steps"]) + lines.extend( + [ + "🚶 Активность:", + f" Шаги: {steps_count:,}".replace(",", " "), + f" {step_goal_bar(steps_count)}", + f" {step_goal_text(steps_count)}", + f" Дистанция: {float(steps['distance_m']) / 1000:.1f} км · {float(steps['calories']):.0f} ккал", + ] + ) + else: + lines.append("🚶 Шаги: за этот день данных нет") + + lines.append("") + + # Сон + if sleep: + total_sleep = sleep_total(sleep) + deep = int(sleep["deep_sleep_min"] or 0) + quality = sleep_quality_label(total_sleep, deep) + lines.extend( + [ + f"😴 Сон: {format_minutes(total_sleep)} · {quality}", + f" Глубокий: {format_minutes(deep)} · Лёгкий: {format_minutes(sleep['light_sleep_min'])}" + + (f" · REM: {format_minutes(sleep['rem_sleep_min'])}" if sleep['rem_sleep_min'] else ""), + f" Окно: {format_epoch(sleep['start_time'], False)} — {format_epoch(sleep['end_time'], False)}", + ] + ) + # Пульс покоя + rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"])) + if rest_hr: + lines.append(f" Пульс покоя во сне: {rest_hr} bpm") + + # Sparklines ЧСС и SpO2 + sleep_hr_spark = get_sleep_sparkline( + "heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"]) + ) + sleep_spo2_spark = get_sleep_sparkline( + "blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"]) + ) + if sleep_hr_spark: + lines.append(f" 📈 ЧСС: [{sleep_hr_spark}]") + if sleep_spo2_spark: + lines.append(f" 📉 SpO2: [{sleep_spo2_spark}]") + else: + lines.append("😴 Сон: за этот день данных нет") + + lines.append("") + lines.append("❤️ За сутки:") + if hr and hr["count"]: + lines.append( + f" Пульс: ср. {hr['avg_value']} · диапазон {hr['min_value']}–{hr['max_value']} bpm" + ) + else: + lines.append(" Пульс: точек нет") + + if spo2 and spo2["count"]: + lines.append( + f" SpO2: ср. {spo2['avg_value']}% · диапазон {spo2['min_value']}–{spo2['max_value']}%" + ) + else: + lines.append(" SpO2: точек нет") + + lines.extend( + ["", "Статистика с датчиков браслета. Не является медицинским заключением."] + ) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# History / Calendar +# --------------------------------------------------------------------------- +def history_text(days: int = 7) -> str: + summary = period_summary(days) + rows = summary["steps"] + sleep_by_day = {row["date"]: row for row in summary["sleep"]} + # Все уникальные дни из обоих источников + all_days = sorted( + set([r["date"] for r in rows] + list(sleep_by_day.keys())), + reverse=True, + )[:days] + + lines = [ + f"📅 Календарь · последние {days} дней", + f"{summary['start'].isoformat()} — {summary['end'].isoformat()}", + "", + ] + + if not all_days: + lines.append("Пока пусто: за этот период данных нет.") + return "\n".join(lines) + + for d in all_days: + steps_row = next((r for r in rows if r["date"] == d), None) + sleep_row = sleep_by_day.get(d) + icon = day_emoji(steps_row, sleep_row) + + steps_text = f"{int(steps_row['total_steps']):,} шагов".replace(",", " ") if steps_row else "шаги н/д" + sleep_text = "сон н/д" + if sleep_row: + total = sleep_total(sleep_row) + sleep_text = format_minutes(total) + lines.append(f"{icon} {d}: {steps_text} · {sleep_text}") + + lines.append("") + lines.append("Нажми на кнопку дня ниже, чтобы открыть детальный разрез.") + return "\n".join(lines) + + +def history_keyboard(days: int = 7) -> InlineKeyboardMarkup: + # Переключатели периода + period_row = [ + InlineKeyboardButton( + "· 7 дней ·" if days == 7 else "7 дней", + callback_data="period_cal:7", + ), + InlineKeyboardButton( + "· 30 дней ·" if days == 30 else "30 дней", + callback_data="period_cal:30", + ), + ] + buttons: list[list[InlineKeyboardButton]] = [period_row] + + day_buttons = [ + InlineKeyboardButton(day, callback_data=f"day:{day}") + for day in available_days(days) + ] + for idx in range(0, len(day_buttons), 3): + buttons.append(day_buttons[idx: idx + 3]) + + buttons.append([InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")]) + return InlineKeyboardMarkup(buttons) + + +# --------------------------------------------------------------------------- +# Day navigation keyboard +# --------------------------------------------------------------------------- +def day_keyboard(current: date) -> InlineKeyboardMarkup: + today = datetime.now(LOCAL_TZ).date() + prev_day = current - timedelta(days=1) + next_day = current + timedelta(days=1) + + prev_btn = InlineKeyboardButton(f"◀️ {prev_day.isoformat()}", callback_data=f"day:{prev_day.isoformat()}") + if current >= today: + next_btn = InlineKeyboardButton("📊 Главная", callback_data="menu:main") + else: + next_btn = InlineKeyboardButton(f"{next_day.isoformat()} ▶️", callback_data=f"day:{next_day.isoformat()}") + + return InlineKeyboardMarkup([ + [prev_btn, next_btn], + [InlineKeyboardButton("📅 Календарь", callback_data="menu:history")], + ]) + + +# --------------------------------------------------------------------------- +# Sleep detail text +# --------------------------------------------------------------------------- +def latest_sleep_text() -> str: + sleep = latest_sleep() + if not sleep: + return "😴 Ночной сон\n\nПока нет данных." + + hr, spo2 = sleep_window_stats(sleep) + total_sleep = sleep_total(sleep) + deep = int(sleep["deep_sleep_min"] or 0) + rem = int(sleep["rem_sleep_min"] or 0) + awake = int(sleep["awake_min"] or 0) + score = int(sleep["sleep_score"] or 0) + quality = sleep_quality_label(total_sleep, deep) + + lines = [ + f"😴 Ночной сон · {esc(relative_day_label(sleep['date']))}", + f"Дата: {esc(sleep['date'])}", + "", + f"⏳ Длительность: {format_minutes(total_sleep)} · {quality}" + + (f" · Счёт: {score}/100" if score else ""), + f" • Глубокий: {format_minutes(deep)}", + f" • Лёгкий: {format_minutes(sleep['light_sleep_min'])}", + ] + if rem: + lines.append(f" • REM: {format_minutes(rem)}") + if awake: + lines.append(f" • Пробуждений: {awake} мин") + lines.append(f" • Постель: {format_epoch(sleep['start_time'], False)} — {format_epoch(sleep['end_time'], False)}") + + # Пульс покоя + if sleep["start_time"] and sleep["end_time"]: + rest_hr = resting_hr(int(sleep["start_time"]), int(sleep["end_time"])) + if rest_hr: + lines.append(f" • Пульс покоя: {rest_hr} bpm") + + lines.append("") + + # Sparklines + if sleep["start_time"] and sleep["end_time"]: + sleep_hr_spark = get_sleep_sparkline( + "heart_rate", "value", int(sleep["start_time"]), int(sleep["end_time"]) + ) + sleep_spo2_spark = get_sleep_sparkline( + "blood_oxygen", "spo2", int(sleep["start_time"]), int(sleep["end_time"]) + ) + + if sleep_hr_spark: + lines.append("📈 ЧСС во сне:") + lines.append(f" [{sleep_hr_spark}]") + if hr and hr["count"]: + lines.append( + f" Ср. {hr['avg_value']} bpm · диапазон {hr['min_value']}–{hr['max_value']} bpm" + ) + lines.append("") + else: + lines.append("📈 ЧСС: детальных точек нет\n") + + if sleep_spo2_spark: + lines.append("📉 SpO2 во сне:") + lines.append(f" [{sleep_spo2_spark}]") + if spo2 and spo2["count"]: + lines.append( + f" Ср. {spo2['avg_value']}% · мин. {spo2['min_value']}%" + ) + lines.append("") + else: + lines.append("📉 SpO2: детальных точек нет\n") + + lines.append("Детали подтягиваются автоматически при фоновой синхронизации с FDS.") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Analytics / Trends +# --------------------------------------------------------------------------- +def period_text(days: int) -> str: + summary = period_summary(days) + steps = summary["steps"] + sleep_rows = summary["sleep"] + + total_steps = sum(int(row["total_steps"] or 0) for row in steps) + avg_steps = round(total_steps / len(steps)) if steps else 0 + total_distance = sum(float(row["distance_m"] or 0) for row in steps) / 1000 + best_steps = max(steps, key=lambda r: int(r["total_steps"] or 0), default=None) + worst_steps = min(steps, key=lambda r: int(r["total_steps"] or 0), default=None) + + sleep_totals = [ + sleep_total(row) for row in sleep_rows + ] + avg_sleep = round(sum(sleep_totals) / len(sleep_totals)) if sleep_totals else None + best_sleep = max(sleep_totals) if sleep_totals else None + goal_days = sum(1 for r in steps if int(r["total_steps"] or 0) >= STEP_GOAL) + + hr = summary["hr"] + spo2 = summary["spo2"] + + lines = [ + f"📊 Аналитика · {days} дней", + f"{summary['start'].isoformat()} — {summary['end'].isoformat()}", + "", + "🚶 Активность:", + f" Всего шагов: {total_steps:,}".replace(",", " "), + f" В среднем: {avg_steps:,} / день".replace(",", " "), + f" Дистанция: {total_distance:.1f} км", + f" Норма {STEP_GOAL:,} выполнена: {goal_days} из {len(steps)} дней".replace(",", " "), + ] + if best_steps: + lines.append( + f" 🏆 Лучший день: {best_steps['date']} · {int(best_steps['total_steps']):,} шагов".replace(",", " ") + ) + if worst_steps and len(steps) > 1: + lines.append( + f" 📉 Слабейший: {worst_steps['date']} · {int(worst_steps['total_steps']):,} шагов".replace(",", " ") + ) + + lines.append("") + lines.append("😴 Сон:") + if avg_sleep is not None: + lines.append(f" Среднее: {format_minutes(avg_sleep)}") + else: + lines.append(" Среднее: н/д") + if best_sleep is not None: + lines.append(f" Лучшая ночь: {format_minutes(best_sleep)}") + + lines.append("") + lines.append("❤️ ЧСС и SpO2:") + if hr and hr["count"]: + lines.append( + f" Пульс: ср. {hr['avg_value']} bpm · диапазон {hr['min_value']}–{hr['max_value']} bpm" + ) + else: + lines.append(" Пульс: данных нет") + + if spo2 and spo2["count"]: + lines.append( + f" SpO2: ср. {spo2['avg_value']}% · мин. {spo2['min_value']}%" + ) + else: + lines.append(" SpO2: данных нет") + + lines.extend(["", "Берегите здоровье!"]) + return "\n".join(lines) + + +def trends_keyboard(days: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "· 7 дней ·" if days == 7 else "7 дней", + callback_data="period:7d", + ), + InlineKeyboardButton( + "· 30 дней ·" if days == 30 else "30 дней", + callback_data="period:30d", + ), + ], + [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], + ] + ) + + +# --------------------------------------------------------------------------- +# Service menu +# --------------------------------------------------------------------------- +def more_text() -> str: + return ( + "⚙️ Сервис\n\n" + "Технические функции: принудительная синхронизация, статус базы данных и выгрузка CSV." + ) + + +def more_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [InlineKeyboardButton("🔄 Принудительный синк", callback_data="menu:sync")], + [ + InlineKeyboardButton("💾 Экспорт ZIP", callback_data="menu:export"), + InlineKeyboardButton("🧰 Статус БД", callback_data="menu:db_status"), + ], + [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], + ] + ) + + +def db_status_text() -> str: + status = read_status_file() + lines = ["🧰 Статус базы данных", ""] + if not health_db_exists(): + return "🧰 Статус базы данных\n\nБаза пока не создана." + + conn = health_conn() + try: + tables = ["steps_daily", "sleep_daily", "sleep_stages", "heart_rate", "blood_oxygen"] + for table in tables: + exists = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ).fetchone() + if not exists: + lines.append(f"• {table}: таблицы нет") + continue + count = conn.execute(f"SELECT COUNT(*) AS count FROM {table}").fetchone()["count"] + lines.append(f"• {table}: {count:,} строк".replace(",", " ")) + finally: + conn.close() + + last_sync_epoch = status.get("last_sync") + last_sync = format_epoch(last_sync_epoch) if last_sync_epoch else status.get("last_sync_time", "н/д") + lines.extend( + [ + "", + f"Путь: {esc(DB_PATH)}", + f"Последний синк: {esc(last_sync)}", + ] + ) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- +def zip_export() -> io.BytesIO: + return storage.zip_export(SETTINGS, ALLOWED_USER_ID) + + +# --------------------------------------------------------------------------- +# Xiaomi onboarding +# --------------------------------------------------------------------------- +def onboarding_text() -> str: + return ( + "🔐 Авторизация Xiaomi\n\n" + "Для первого запуска нужен вход в Xiaomi Fitness. Нажми кнопку ниже, подтверди вход, " + "а я дождусь ответа и сразу запущу синхронизацию." + ) + + +def onboarding_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [[InlineKeyboardButton("🔐 Войти в Xiaomi", callback_data="auth:start")]] + ) + + +def normalize_login_url(url: str | None) -> str: + if not url: + return "" + url = url.strip() + if url.startswith("//"): + return f"https:{url}" + return url + + +def xiaomi_wait_keyboard(login_url: str, qr_image_url: str) -> InlineKeyboardMarkup: + rows: list[list[InlineKeyboardButton]] = [] + login_url = normalize_login_url(login_url) + qr_image_url = normalize_login_url(qr_image_url) + if login_url: + rows.append([InlineKeyboardButton("🔐 Открыть вход Xiaomi", url=login_url)]) + if qr_image_url: + rows.append([InlineKeyboardButton("▦ Открыть QR-код", url=qr_image_url)]) + return InlineKeyboardMarkup(rows or [[InlineKeyboardButton("🔄 Повторить", callback_data="auth:start")]]) + + +def auth_retry_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [InlineKeyboardButton("🔐 Войти заново", callback_data="auth:relogin")], + [InlineKeyboardButton("⬅️ Сервис", callback_data="menu:more")], + ] + ) + + +async def show_onboarding(update: Update, context: ContextTypes.DEFAULT_TYPE, force_new: bool = False) -> None: + await update_menu(update, context, onboarding_text(), onboarding_keyboard(), force_new=force_new) + + +async def start_xiaomi_login(update: Update, context: ContextTypes.DEFAULT_TYPE, *, force: bool = False) -> None: + if has_xiaomi_token() and not force: + await show_main_menu(update, context) + return + if AUTH_LOCK.locked(): + await update_menu( + update, + context, + "🔐 Авторизация Xiaomi\n\nУже жду подтверждение входа. Открой ссылку из предыдущего сообщения.", + onboarding_keyboard(), + ) + return + + async with AUTH_LOCK: + if has_xiaomi_token() and not force: + await show_main_menu(update, context) + return + + await update_menu( + update, + context, + "🔐 Авторизация Xiaomi\n\nГотовлю ссылку входа…", + None, + ) + auth = XiaomiAuth() + + async def qr_callback(qr_image_url: str, login_url: str) -> None: + await update_menu( + update, + context, + "🔐 Авторизация Xiaomi\n\nОткрой ссылку, подтверди вход и вернись сюда. Я жду результат.", + xiaomi_wait_keyboard(login_url, qr_image_url), + ) + + try: + token = await auth.login_qr(qr_callback=qr_callback, max_wait=300) + token_path = get_xiaomi_token_path() + if token_path is None: + raise ConfigError("Не удалось определить путь для Xiaomi token") + save_auth_token(token, token_path) + await update_menu( + update, + context, + "✅ Авторизация Xiaomi\n\nВход подтверждён. Запускаю первую синхронизацию…", + None, + ) + await run_initial_sync_after_login(update, context) + except Exception as e: + logger.exception("Xiaomi login failed") + await update_menu( + update, + context, + f"⚠️ Авторизация Xiaomi\n\nНе удалось войти: {esc(e)}", + auth_retry_keyboard(), + ) + finally: + await auth.close() + + +async def run_initial_sync_after_login(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if SYNC_LOCK.locked(): + await update_menu( + update, + context, + "✅ Авторизация Xiaomi\n\nВход готов. Синхронизация уже идёт, открою меню.", + main_keyboard(), + ) + return + + async with SYNC_LOCK: + result = await run_sync(ALLOWED_USER_ID, SETTINGS) + + if result.success: + await show_main_menu(update, context) + return + + detail = f"\n\nПричина: {esc(result.error)}" if result.error else "" + await update_menu( + update, + context, + f"⚠️ Первая синхронизация\n\nАвторизация сохранена, но данные не обновились.{detail}", + auth_retry_keyboard(), + ) + + +# --------------------------------------------------------------------------- +# Menu keyboards +# --------------------------------------------------------------------------- +def main_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("📅 Календарь", callback_data="menu:history"), + InlineKeyboardButton("📊 Аналитика", callback_data="menu:trends"), + ], + [ + InlineKeyboardButton("😴 Детали сна", callback_data="menu:sleep"), + InlineKeyboardButton("⚙️ Сервис", callback_data="menu:more"), + ], + ] + ) + + +def back_keyboard(back_to: str = "menu:main") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data=back_to)]]) + + +# --------------------------------------------------------------------------- +# Handlers: show_* functions +# --------------------------------------------------------------------------- +async def show_main_menu( + update: Update, context: ContextTypes.DEFAULT_TYPE, force_new: bool = False +) -> None: + await update_menu(update, context, main_menu_text(), main_keyboard(), force_new=force_new) + + +async def show_history( + update: Update, context: ContextTypes.DEFAULT_TYPE, days: int = 7 +) -> None: + await update_menu(update, context, history_text(days), history_keyboard(days)) + + +async def show_day( + update: Update, context: ContextTypes.DEFAULT_TYPE, day_str: str +) -> None: + day_value = parse_day(day_str) + await update_menu( + update, + context, + day_text(day_summary(day_str)), + day_keyboard(day_value), + ) + + +async def run_manual_sync( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + if not has_xiaomi_token(): + await show_onboarding(update, context) + return + if not run_sync: + await update_menu( + update, + context, + "🔄 Синхронизация\n\nМодуль синхронизации не найден.", + back_keyboard("menu:more"), + ) + return + if SYNC_LOCK.locked(): + await update_menu( + update, + context, + "🔄 Синхронизация\n\nУже идёт обновление. Подожди завершения.", + back_keyboard("menu:more"), + ) + return + + await update_menu( + update, + context, + "🔄 Синхронизация\n\nЗабираю данные из Xiaomi Fitness…", + back_keyboard("menu:more"), + ) + async with SYNC_LOCK: + try: + result = await run_sync(ALLOWED_USER_ID, SETTINGS) + except Exception as e: + logger.exception("Manual sync failed") + await update_menu( + update, + context, + f"🔄 Синхронизация\n\nОшибка запуска: {esc(e)}", + back_keyboard("menu:more"), + ) + return + + if result.success: + await update_menu( + update, + context, + "✅ Синхронизация\n\nГотово, данные обновлены.", + InlineKeyboardMarkup( + [ + [InlineKeyboardButton("📊 На главную", callback_data="menu:main")], + [InlineKeyboardButton("⬅️ Сервис", callback_data="menu:more")], + ] + ), + ) + else: + detail = f"\n\nПричина: {esc(result.error)}" if result.error else "" + keyboard = auth_retry_keyboard() if "Token" in (result.error or "") else back_keyboard("menu:more") + await update_menu( + update, + context, + f"⚠️ Синхронизация\n\nНе вышло обновиться.{detail}", + keyboard, + ) + + +async def export_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not update.effective_chat: + return + if not health_db_exists(): + await update_menu( + update, + context, + "💾 Экспорт\n\nБаза данных пока не создана.", + back_keyboard("menu:more"), + ) + return + + await update_menu(update, context, "💾 Экспорт\n\nСобираю ZIP…", None) + try: + export_file = zip_export() + if export_file.getbuffer().nbytes == 0: + await update_menu( + update, + context, + "💾 Экспорт\n\nВ базе нет данных.", + back_keyboard("menu:more"), + ) + return + await context.bot.send_document( + chat_id=update.effective_chat.id, + document=export_file, + filename=export_file.name, + caption="💚 MiBand Health CSV Export", + ) + await update_menu( + update, + context, + "✅ Экспорт\n\nZIP с CSV-таблицами отправлен выше.", + back_keyboard("menu:more"), + ) + except Exception as e: + logger.exception("Export failed") + await update_menu( + update, + context, + f"⚠️ Экспорт\n\nОшибка: {esc(e)}", + back_keyboard("menu:more"), + ) + + +# --------------------------------------------------------------------------- +# Command handlers +# --------------------------------------------------------------------------- +@with_user_context +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not is_allowed(update): + uid = update.effective_user.id if update.effective_user else "unknown" + logger.warning("Unauthorized access from user: %s", uid) + return + await safe_delete(update.message) + if not has_xiaomi_token(): + await show_onboarding(update, context, force_new=True) + return + await show_main_menu(update, context, force_new=True) + + +@with_user_context +async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not is_allowed(update): + return + await safe_delete(update.message) + await update_menu(update, context, db_status_text(), back_keyboard("menu:more")) + + +@with_user_context +async def cmd_sync(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not is_allowed(update): + return + await safe_delete(update.message) + if not has_xiaomi_token(): + await show_onboarding(update, context) + return + await run_manual_sync(update, context) + + +@with_user_context +async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not is_allowed(update): + return + await safe_delete(update.message) + if not has_xiaomi_token(): + await show_onboarding(update, context) + return + await show_main_menu(update, context) + + +# --------------------------------------------------------------------------- +# Callback router +# --------------------------------------------------------------------------- +@with_user_context +async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query: + await query.answer() + if not is_allowed(update): + return + + data = query.data if query else "menu:main" + + if data in {"auth:start", "auth:relogin"}: + await start_xiaomi_login(update, context, force=data == "auth:relogin") + + elif not has_xiaomi_token(): + await show_onboarding(update, context) + + elif data == "menu:main": + await show_main_menu(update, context) + + elif data == "menu:history": + await show_history(update, context, 7) + + elif data.startswith("period_cal:"): + # Переключение числа дней в Календаре + days = int(data.split(":")[1]) + await show_history(update, context, days) + + elif data == "menu:sleep": + await update_menu( + update, + context, + latest_sleep_text(), + InlineKeyboardMarkup( + [ + [InlineKeyboardButton("📅 Календарь", callback_data="menu:history")], + [InlineKeyboardButton("⬅️ Главная", callback_data="menu:main")], + ] + ), + ) + + elif data == "menu:trends": + await update_menu(update, context, period_text(30), trends_keyboard(30)) + + elif data.startswith("period:"): + days = 7 if data == "period:7d" else 30 + await update_menu(update, context, period_text(days), trends_keyboard(days)) + + elif data.startswith("day:"): + try: + await show_day(update, context, data.split(":", 1)[1]) + except Exception as e: + logger.exception("Failed to show day") + await update_menu( + update, + context, + f"📊 День\n\nНе удалось открыть: {esc(e)}", + back_keyboard("menu:history"), + ) + + elif data == "menu:more": + await update_menu(update, context, more_text(), more_keyboard()) + + elif data == "menu:sync": + await run_manual_sync(update, context) + + elif data == "menu:export": + await export_data(update, context) + + elif data == "menu:db_status": + await update_menu(update, context, db_status_text(), back_keyboard("menu:more")) + + else: + await show_main_menu(update, context) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def main() -> None: + global SETTINGS, BOT_TOKEN, ALLOWED_USER_ID, DB_PATH + try: + SETTINGS = Settings.from_env(require_bot=True) + except ConfigError as exc: + logger.error("%s", exc) + sys.exit(1) + BOT_TOKEN = SETTINGS.telegram_bot_token + ALLOWED_USER_ID = SETTINGS.require_user_id() + DB_PATH = str(SETTINGS.user_db_path(ALLOWED_USER_ID)) + + init_state_db() + logger.info("Стартую fitness-bot для пользователя: %s", ALLOWED_USER_ID) + app = Application.builder().token(BOT_TOKEN).build() + app.add_handler(CommandHandler("start", cmd_start)) + app.add_handler(CommandHandler("status", cmd_status)) + app.add_handler(CommandHandler("sync", cmd_sync)) + app.add_handler(CallbackQueryHandler(handle_callback)) + app.add_handler(MessageHandler(filters.ALL, handle_message)) + app.run_polling(drop_pending_updates=True) + + +if __name__ == "__main__": + main() diff --git a/miband_tracker/config.py b/miband_tracker/config.py new file mode 100644 index 0000000..5baf8e6 --- /dev/null +++ b/miband_tracker/config.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +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_id: int | None + sync_interval: int + query_duration: int + enable_fds_sleep_details: bool + + @classmethod + def from_env(cls, *, require_bot: bool = False) -> "Settings": + data_dir = Path(os.environ.get("DATA_DIR", "/opt/miband-tracker/data")) + allowed_user_id = parse_single_user_id( + os.environ.get("TELEGRAM_ALLOWED_USER_ID", ""), required=require_bot + ) + 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_id=allowed_user_id, + 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_ID должен содержать ровно один user id") + 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_single_user_id(raw: str, *, required: bool = False) -> int | None: + values = [item.strip() for item in raw.split(",") if item.strip()] + if not values: + if required: + raise ConfigError("TELEGRAM_ALLOWED_USER_ID не задан или пуст") + return None + if len(values) > 1: + raise ConfigError("TELEGRAM_ALLOWED_USER_ID должен содержать ровно один user id") + try: + return int(values[0]) + except ValueError as exc: + raise ConfigError("TELEGRAM_ALLOWED_USER_ID должен быть целым числом") from exc diff --git a/miband_tracker/fds.py b/miband_tracker/fds.py new file mode 100644 index 0000000..66ce2e6 --- /dev/null +++ b/miband_tracker/fds.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import base64 +import hashlib +import struct + +import httpx +from Crypto.Cipher import AES +from Crypto.Util.Padding import unpad + + +def normalize_timezone_to_15min(timezone_value: int) -> int: + # Xiaomi sleep segments already use 15-minute units; token/bootstrap paths may use seconds. + if abs(timezone_value) <= 96: + return int(timezone_value) + return int(timezone_value / 900) + +def gen_data_id_key_bytes(timestamp: int, tz_in_15min: int, daily_type: int, file_type: int, data_type: int = 0, sport_type: int = 0) -> bytes: + data_type_byte = (data_type << 7) + (sport_type << 2) + (daily_type << 2) + file_type + return struct.pack(" len(b): + return None, pos + + interval = struct.unpack_from("= 2: + actual_byte_count += 4 + + if pos + actual_byte_count > len(b): + return None, pos + + start_time = 0 + if version >= 2: + start_time = struct.unpack_from(" 0 + valid_map[t] = val + i += 1 + + pos = 9 + report_data = { + "sleepFinish": bool(b[pos] == 1) + } + pos += 1 + + # type=0 (deviceBedTime) + report_data["deviceBedTime"] = struct.unpack_from(" 0 and val < 255: + records["heart_rate"].append((start_t + idx * interval, val)) + + # type 4: spo2 + if valid_map[4]: + spo2_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version) + if spo2_data: + start_t = spo2_data["start_time"] + interval = spo2_data["interval"] + for idx, val in enumerate(spo2_data["values"]): + if val > 0 and val <= 100: + records["spo2"].append((start_t + idx * interval, val)) + + return { + "report": report_data, + "records": records + } + +async def download_and_decrypt_sleep_details(client, relative_uid: int, timestamp: int, timezone_value: int, log_fn=print): + tz_in_15min = normalize_timezone_to_15min(timezone_value) + + # 1. Generate FDSItem + sid = str(relative_uid) + key_bytes = gen_data_id_key_bytes(timestamp, tz_in_15min, daily_type=8, file_type=0) + suffix_b64 = base64.urlsafe_b64encode(key_bytes).decode().rstrip("=") + + sha1_sid = hashlib.sha1(sid.encode()).digest() + sha1_b64 = base64.urlsafe_b64encode(sha1_sid).decode().rstrip("=") + + suffix = f"{suffix_b64}_{sha1_b64}" + + # 2. Prepare request params + param_dict = { + "did": sid, + "relative_uid": relative_uid, + "items": [ + { + "timestamp": timestamp, + "suffix": suffix + } + ] + } + + # 3. Call service/gen_download_url + 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 + + # 4. Download file + async with httpx.AsyncClient(timeout=30.0) as http_client: + file_resp = await http_client.get(url) + if file_resp.status_code != 200: + log_fn(f"Optional FDS sleep detail unavailable: HTTP {file_resp.status_code}; skipping.") + return None + + enc_content = file_resp.content + + log_fn(f"Downloaded FDS content length: {len(enc_content)}") + + # 5. Decrypt or decompress content + def android_base64_urlsafe(s): + if isinstance(s, bytes): + s = s.decode("utf-8", "ignore") + s = s.strip().replace("\n", "").replace("\r", "") + s += "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s) + + if obj_key_b64: + try: + encrypted_bytes = android_base64_urlsafe(enc_content) + obj_key_bytes = android_base64_urlsafe(obj_key_b64) + + if len(obj_key_bytes) != 16: + log_fn(f"Invalid obj_key length: {len(obj_key_bytes)}") + return None + + cipher = AES.new(obj_key_bytes, AES.MODE_CBC, b"1234567887654321") + decrypted = cipher.decrypt(encrypted_bytes) + decrypted = unpad(decrypted, 16) + return decrypted + except Exception as e: + log_fn(f"AES decryption or unpadding failed: {e}") + return None + else: + log_fn("No obj_key in file_info. Checking if content is compressed (gzip/zlib) or raw...") + # Check for GZIP header (1f 8b) + if enc_content.startswith(b"\x1f\x8b"): + try: + import gzip + decompressed = gzip.decompress(enc_content) + log_fn(f"Successfully decompressed GZIP FDS content. Length: {len(decompressed)}") + return decompressed + except Exception as e: + log_fn(f"Failed to decompress GZIP FDS content: {e}") + # Check for ZLIB header (78 9c or 78 01 or 78 5e etc.) + elif enc_content.startswith(b"\x78\x9c") or enc_content.startswith(b"\x78\x01"): + try: + import zlib + decompressed = zlib.decompress(enc_content) + log_fn(f"Successfully decompressed ZLIB FDS content. Length: {len(decompressed)}") + return decompressed + except Exception as e: + log_fn(f"Failed to decompress ZLIB FDS content: {e}") + + # If not compressed, return as is + return enc_content diff --git a/miband_tracker/lock.py b/miband_tracker/lock.py new file mode 100644 index 0000000..80235ae --- /dev/null +++ b/miband_tracker/lock.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import fcntl +import os +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + + +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: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise LockUnavailable(f"Lock is already held: {path}") from exc + + 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: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) diff --git a/miband_tracker/secure_files.py b/miband_tracker/secure_files.py new file mode 100644 index 0000000..44c8bb5 --- /dev/null +++ b/miband_tracker/secure_files.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +SECRET_FILE_MODE = 0o600 + + +def write_text_atomic(path: Path, text: str, *, mode: int | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + fh.flush() + os.fsync(fh.fileno()) + if mode is not None: + os.chmod(tmp_path, mode) + os.replace(tmp_path, path) + if mode is not None: + os.chmod(path, mode) + finally: + try: + tmp_path.unlink() + except FileNotFoundError: + pass + + +def write_json_atomic(path: Path, data: Any, *, mode: int | None = None) -> None: + text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + write_text_atomic(path, text, mode=mode) + + +def write_secret_json(path: Path, data: dict[str, Any]) -> None: + write_json_atomic(path, data, mode=SECRET_FILE_MODE) + + +def save_auth_token(token: Any, path: Path) -> None: + current = _read_current_token_payload(path) + if hasattr(token, "model_dump"): + payload = token.model_dump() + elif hasattr(token, "model_dump_json"): + payload = json.loads(token.model_dump_json()) + else: + payload = dict(token) + + for key in ("target_relative_uid",): + if key in current and key not in payload: + payload[key] = current[key] + + write_secret_json(path, payload) + + +def _read_current_token_payload(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} diff --git a/miband_tracker/storage.py b/miband_tracker/storage.py new file mode 100644 index 0000000..90aa5f6 --- /dev/null +++ b/miband_tracker/storage.py @@ -0,0 +1,208 @@ +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"] + + +@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 + ) + """ + ) + conn.commit() + + +def _ensure_columns(cursor: sqlite3.Cursor, table: str, columns: dict[str, str]) -> None: + existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})").fetchall()} + for name, definition in columns.items(): + if name not in existing: + cursor.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") + + +def init_state_db(settings: Settings) -> None: + with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_menu ( + user_id INTEGER PRIMARY KEY, + menu_message_id INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.commit() + + +def get_user_menu_msg_id(settings: Settings, user_id: int) -> int | None: + try: + with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn: + row = conn.execute( + "SELECT menu_message_id FROM user_menu WHERE user_id = ?", + (user_id,), + ).fetchone() + return int(row[0]) if row else None + except sqlite3.Error: + return None + + +def set_user_menu_msg_id(settings: Settings, user_id: int, msg_id: int) -> None: + with sqlite_conn(settings.bot_state_db_path, row_factory=False) as conn: + conn.execute( + """ + INSERT OR REPLACE INTO user_menu (user_id, menu_message_id, updated_at) + VALUES (?, ?, ?) + """, + (user_id, msg_id, datetime.now(LOCAL_TZ).isoformat(timespec="seconds")), + ) + conn.commit() + + +def health_db_exists(settings: Settings, user_id: int | None = None) -> bool: + return settings.user_db_path(user_id).exists() + + +def fetch_one(settings: Settings, query: str, params: tuple = (), user_id: int | None = None) -> sqlite3.Row | None: + if not health_db_exists(settings, user_id): + return None + with sqlite_conn(settings.user_db_path(user_id)) as conn: + return conn.execute(query, params).fetchone() + + +def fetch_all(settings: Settings, query: str, params: tuple = (), user_id: int | None = None) -> list[sqlite3.Row]: + if not health_db_exists(settings, user_id): + return [] + with sqlite_conn(settings.user_db_path(user_id)) as conn: + return conn.execute(query, params).fetchall() + + +def read_status_file(settings: Settings, user_id: int | None = None) -> dict: + try: + return json.loads(settings.user_status_path(user_id).read_text(encoding="utf-8")) + except Exception: + return {} + + +def zip_export(settings: Settings, user_id: int | None = None) -> io.BytesIO: + bio = io.BytesIO() + with sqlite_conn(settings.user_db_path(user_id)) as conn: + with zipfile.ZipFile(bio, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + for table in EXPORT_TABLES: + exists = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table,), + ).fetchone() + if not exists: + continue + cursor = conn.execute(f"SELECT * FROM {table} ORDER BY 1") + rows = cursor.fetchall() + if not rows: + continue + csv_text = io.StringIO() + writer = csv.writer(csv_text) + writer.writerow([desc[0] for desc in cursor.description]) + writer.writerows([tuple(row) for row in rows]) + archive.writestr(f"{table}.csv", csv_text.getvalue()) + bio.seek(0) + bio.name = f"miband-health-{datetime.now(LOCAL_TZ).strftime('%Y%m%d-%H%M')}.zip" + return bio diff --git a/miband_tracker/sync.py b/miband_tracker/sync.py new file mode 100644 index 0000000..dd0822f --- /dev/null +++ b/miband_tracker/sync.py @@ -0,0 +1,375 @@ +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 .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") + 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 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, + } + 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 = sleep.segment_details[0].bedtime + end_time = sleep.segment_details[-1].wake_up_time + 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}") + + 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 daemon_main(settings: Settings | None = None) -> int: + settings = settings or Settings.from_env() + if settings.sync_interval <= 0: + log("Running in one-shot mode.") + result = await run_sync(settings=settings) + return 0 if result.success else 1 + + log(f"Running in daemon mode. Sync interval: {settings.sync_interval} seconds.") + while True: + try: + await run_sync(settings=settings) + except Exception as exc: + log(f"Unhandled error in main loop: {exc}") + log(f"Sleeping for {settings.sync_interval} seconds...") + await asyncio.sleep(settings.sync_interval) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..62e6a50 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.ruff] +line-length = 120 +target-version = "py311" +extend-exclude = [ + ".venv", + "data", + "mi-fitness-python", +] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..dcd95cd --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_default_fixture_loop_scope = function diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f8c8605 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.4 +pytest-asyncio==1.3.0 +ruff==0.8.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1ec4c07 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +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 diff --git a/tests/test_bot_ui.py b/tests/test_bot_ui.py new file mode 100644 index 0000000..8f47044 --- /dev/null +++ b/tests/test_bot_ui.py @@ -0,0 +1,113 @@ +import asyncio +import json +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +import miband_tracker.bot.app as bot_app +from miband_tracker.config import Settings +from miband_tracker.sync import SyncResult + + +class FakeUser: + id = 123 + + +class FakeUpdate: + effective_user = FakeUser() + message = object() + callback_query = None + + +def test_service_menu_does_not_expose_invites_or_second_user() -> None: + keyboard = bot_app.more_keyboard() + labels = [ + button.text + for row in keyboard.inline_keyboard + for button in row + ] + rendered = "\n".join(labels + [bot_app.more_text()]) + + assert "Принять приглашения" not in rendered + assert "Приглашения" not in rendered + assert "втор" not in rendered.lower() + + +@pytest.mark.asyncio +async def test_cmd_start_without_token_shows_onboarding(monkeypatch: pytest.MonkeyPatch) -> None: + update = FakeUpdate() + context = object() + show_onboarding = AsyncMock() + show_main_menu = AsyncMock() + + monkeypatch.setattr(bot_app, "is_allowed", lambda _: True) + monkeypatch.setattr(bot_app, "has_xiaomi_token", lambda: False) + monkeypatch.setattr(bot_app, "safe_delete", AsyncMock()) + monkeypatch.setattr(bot_app, "show_onboarding", show_onboarding) + monkeypatch.setattr(bot_app, "show_main_menu", show_main_menu) + + await bot_app.cmd_start(update, context) + + show_onboarding.assert_awaited_once_with(update, context, force_new=True) + show_main_menu.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_start_xiaomi_login_saves_token_syncs_and_opens_menu( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + update = FakeUpdate() + context = object() + token_path = tmp_path / "token_123.json" + + class FakeToken: + def model_dump(self): + return { + "user_id": "456", + "c_user_id": "", + "service_token": "service", + "ssecurity": "sec", + "pass_token": "pass", + "device_id": "device", + } + + class FakeAuth: + async def login_qr(self, *, qr_callback, max_wait): + await qr_callback("https://example.test/qr.png", "https://example.test/login") + return FakeToken() + + async def close(self): + return None + + settings = Settings( + data_dir=tmp_path, + db_path=tmp_path / "miband.db", + status_path=tmp_path / "status.json", + bot_state_db_path=tmp_path / "fitness_bot_state.db", + telegram_bot_token="token", + telegram_allowed_user_id=123, + sync_interval=900, + query_duration=2, + enable_fds_sleep_details=True, + ) + run_sync = AsyncMock(return_value=SyncResult(True, user_id=123)) + show_main_menu = AsyncMock() + + monkeypatch.setattr(bot_app, "SETTINGS", settings) + monkeypatch.setattr(bot_app, "ALLOWED_USER_ID", 123) + monkeypatch.setattr(bot_app, "AUTH_LOCK", asyncio.Lock()) + monkeypatch.setattr(bot_app, "SYNC_LOCK", asyncio.Lock()) + monkeypatch.setattr(bot_app, "XiaomiAuth", FakeAuth) + monkeypatch.setattr(bot_app, "update_menu", AsyncMock()) + monkeypatch.setattr(bot_app, "run_sync", run_sync) + monkeypatch.setattr(bot_app, "show_main_menu", show_main_menu) + + await bot_app.start_xiaomi_login(update, context) + + data = json.loads(token_path.read_text(encoding="utf-8")) + assert data["user_id"] == "456" + assert data["service_token"] == "service" + run_sync.assert_awaited_once_with(123, settings) + show_main_menu.assert_awaited_once_with(update, context) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7a604e0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import pytest + +from miband_tracker.config import ConfigError, Settings, parse_single_user_id + + +def test_parse_single_user_id_rejects_multiple_values() -> None: + with pytest.raises(ConfigError): + parse_single_user_id("1,2", required=True) + + +def test_settings_user_paths_prefer_existing_user_files(tmp_path: Path) -> None: + user_id = 123 + (tmp_path / f"miband_{user_id}.db").write_text("", encoding="utf-8") + (tmp_path / f"status_{user_id}.json").write_text("{}", encoding="utf-8") + (tmp_path / f"token_{user_id}.json").write_text("{}", encoding="utf-8") + + settings = Settings( + data_dir=tmp_path, + db_path=tmp_path / "miband.db", + status_path=tmp_path / "status.json", + bot_state_db_path=tmp_path / "fitness_bot_state.db", + telegram_bot_token="token", + telegram_allowed_user_id=user_id, + sync_interval=900, + query_duration=2, + enable_fds_sleep_details=True, + ) + + assert settings.user_db_path() == tmp_path / f"miband_{user_id}.db" + assert settings.user_status_path() == tmp_path / f"status_{user_id}.json" + assert settings.token_path() == tmp_path / f"token_{user_id}.json" + + +def test_settings_falls_back_to_legacy_db_and_status(tmp_path: Path) -> None: + settings = Settings( + data_dir=tmp_path, + db_path=tmp_path / "miband.db", + status_path=tmp_path / "status.json", + bot_state_db_path=tmp_path / "fitness_bot_state.db", + telegram_bot_token="token", + telegram_allowed_user_id=123, + sync_interval=900, + query_duration=2, + enable_fds_sleep_details=True, + ) + + assert settings.user_db_path() == tmp_path / "miband.db" + assert settings.user_status_path() == tmp_path / "status.json" + assert settings.canonical_user_db_path() == tmp_path / "miband_123.db" + + +def test_settings_from_env_rejects_invalid_interval(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TELEGRAM_ALLOWED_USER_ID", "123") + monkeypatch.setenv("SYNC_INTERVAL", "soon") + + with pytest.raises(ConfigError, match="SYNC_INTERVAL"): + Settings.from_env() + + +def test_settings_from_env_rejects_zero_query_duration(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TELEGRAM_ALLOWED_USER_ID", "123") + monkeypatch.setenv("QUERY_DURATION", "0") + + with pytest.raises(ConfigError, match="QUERY_DURATION"): + Settings.from_env() diff --git a/tests/test_fds.py b/tests/test_fds.py new file mode 100644 index 0000000..50da182 --- /dev/null +++ b/tests/test_fds.py @@ -0,0 +1,33 @@ +import struct + +from miband_tracker.fds import parse_all_day_sleep_bytes + + +def test_parse_all_day_sleep_bytes_reads_hr_and_spo2_records() -> None: + blob = bytearray() + blob += struct.pack(" None: + for blob in (b"", bytes(9), bytes(10), bytes(20)): + assert parse_all_day_sleep_bytes(blob) is None diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..73474b3 --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from miband_tracker.lock import LockUnavailable, exclusive_file_lock + + +def test_exclusive_file_lock_rejects_second_process(tmp_path: Path) -> None: + lock_path = tmp_path / "sync.lock" + script = ( + "import sys, time\n" + "from pathlib import Path\n" + "from miband_tracker.lock import exclusive_file_lock\n" + "with exclusive_file_lock(Path(sys.argv[1])):\n" + " print('ready', flush=True)\n" + " time.sleep(5)\n" + ) + proc = subprocess.Popen( + [sys.executable, "-c", script, str(lock_path)], + stdout=subprocess.PIPE, + text=True, + ) + try: + assert proc.stdout is not None + assert proc.stdout.readline().strip() == "ready" + with pytest.raises(LockUnavailable): + with exclusive_file_lock(lock_path): + pass + finally: + proc.terminate() + proc.wait(timeout=5) diff --git a/tests/test_secure_files.py b/tests/test_secure_files.py new file mode 100644 index 0000000..2c0c67c --- /dev/null +++ b/tests/test_secure_files.py @@ -0,0 +1,32 @@ +import json +import os +from pathlib import Path + +from mi_fitness.models import AuthToken + +from miband_tracker.secure_files import save_auth_token, write_secret_json + + +def test_write_secret_json_uses_private_file_mode(tmp_path: Path) -> None: + path = tmp_path / "token.json" + + write_secret_json(path, {"service_token": "secret"}) + + assert json.loads(path.read_text(encoding="utf-8")) == {"service_token": "secret"} + assert stat_mode(path) == 0o600 + + +def test_save_auth_token_preserves_target_relative_uid(tmp_path: Path) -> None: + path = tmp_path / "token.json" + write_secret_json(path, {"target_relative_uid": "42"}) + + save_auth_token(AuthToken(user_id="11", service_token="svc", ssecurity="sec"), path) + + data = json.loads(path.read_text(encoding="utf-8")) + assert data["user_id"] == "11" + assert data["target_relative_uid"] == "42" + assert stat_mode(path) == 0o600 + + +def stat_mode(path: Path) -> int: + return os.stat(path).st_mode & 0o777 diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..1e0d868 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from miband_tracker import storage +from miband_tracker.config import Settings + + +def _settings(tmp_path: Path) -> Settings: + return Settings( + data_dir=tmp_path, + db_path=tmp_path / "miband.db", + status_path=tmp_path / "status.json", + bot_state_db_path=tmp_path / "fitness_bot_state.db", + telegram_bot_token="token", + telegram_allowed_user_id=123, + sync_interval=900, + query_duration=2, + enable_fds_sleep_details=True, + ) + + +def test_init_health_db_is_idempotent(tmp_path: Path) -> None: + db_path = tmp_path / "miband_123.db" + + storage.init_health_db(db_path) + storage.init_health_db(db_path) + + with storage.sqlite_conn(db_path) as conn: + tables = { + row["name"] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() + } + assert {"steps_daily", "sleep_daily", "heart_rate", "blood_oxygen"}.issubset(tables) + + +def test_zip_export_includes_non_empty_tables_only(tmp_path: Path) -> None: + settings = _settings(tmp_path) + db_path = settings.canonical_user_db_path() + storage.init_health_db(db_path) + with storage.sqlite_conn(db_path, row_factory=False) as conn: + conn.execute( + "INSERT INTO steps_daily (date, total_steps, calories, distance_m, last_sync) VALUES (?, ?, ?, ?, ?)", + ("2026-05-24", 1000, 10.0, 800.0, 1), + ) + conn.commit() + + archive = storage.zip_export(settings) + + assert archive.getbuffer().nbytes > 0 + assert b"steps_daily.csv" in archive.getvalue() diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..6b090f0 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,27 @@ +from pathlib import Path + +import pytest + +from miband_tracker.config import Settings +from miband_tracker.sync import run_sync + + +@pytest.mark.asyncio +async def test_run_sync_missing_token_returns_failed_result(tmp_path: Path) -> None: + settings = Settings( + data_dir=tmp_path, + db_path=tmp_path / "miband.db", + status_path=tmp_path / "status.json", + bot_state_db_path=tmp_path / "fitness_bot_state.db", + telegram_bot_token="token", + telegram_allowed_user_id=123, + sync_interval=0, + query_duration=2, + enable_fds_sleep_details=True, + ) + + result = await run_sync(settings=settings) + + assert not result.success + assert result.user_id == 123 + assert "Token file not found" in (result.error or "")