mirror of
https://github.com/alexgetmancom/miband-bot.git
synced 2026-07-18 19:50:14 +03:00
feat: add miband bot application
This commit is contained in:
@@ -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
|
||||
+21
@@ -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
|
||||
+32
@@ -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"]
|
||||
@@ -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_<telegram_user_id>.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_<telegram_user_id>.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`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from miband_tracker.bot.app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+100
@@ -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
|
||||
+64
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -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` | 找不到指定亲友 |
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""小米账号认证子包。
|
||||
|
||||
对外只暴露 ``XiaomiAuth``,内部按登录方式拆分为独立模块。
|
||||
"""
|
||||
|
||||
from mi_fitness.auth.manager import XiaomiAuth
|
||||
|
||||
__all__ = ["XiaomiAuth"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""小米运动健康 API 客户端子包。"""
|
||||
|
||||
from mi_fitness.client.api import MiHealthClient
|
||||
|
||||
__all__ = ["MiHealthClient"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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", {})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
"""找不到指定的亲友。"""
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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="")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Mi Band tracker service package."""
|
||||
|
||||
from .config import Settings
|
||||
from .sync import SyncResult, run_sync
|
||||
|
||||
__all__ = ["Settings", "SyncResult", "run_sync"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Telegram bot package."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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("<IbB", timestamp, tz_in_15min, data_type_byte)
|
||||
|
||||
def parse_sleep_assist_info(b, pos, byte_count, is_float, is_unsigned, version):
|
||||
if pos + 4 > len(b):
|
||||
return None, pos
|
||||
|
||||
interval = struct.unpack_from("<h", b, pos)[0]
|
||||
record_count = struct.unpack_from("<h", b, pos + 2)[0]
|
||||
pos += 4
|
||||
|
||||
if record_count <= 0:
|
||||
return None, pos
|
||||
|
||||
actual_byte_count = byte_count * record_count
|
||||
if version >= 2:
|
||||
actual_byte_count += 4
|
||||
|
||||
if pos + actual_byte_count > len(b):
|
||||
return None, pos
|
||||
|
||||
start_time = 0
|
||||
if version >= 2:
|
||||
start_time = struct.unpack_from("<I", b, pos)[0]
|
||||
pos += 4
|
||||
|
||||
values = []
|
||||
for _ in range(record_count):
|
||||
if byte_count == 1:
|
||||
val = b[pos]
|
||||
pos += 1
|
||||
elif byte_count == 2:
|
||||
val = struct.unpack_from("<H" if is_unsigned else "<h", b, pos)[0]
|
||||
pos += 2
|
||||
elif byte_count == 4:
|
||||
if is_float:
|
||||
val = struct.unpack_from("<f", b, pos)[0]
|
||||
else:
|
||||
val = struct.unpack_from("<I" if is_unsigned else "<i", b, pos)[0]
|
||||
pos += 4
|
||||
else:
|
||||
val = b[pos:pos+byte_count]
|
||||
pos += byte_count
|
||||
values.append(val)
|
||||
|
||||
return {
|
||||
"start_time": start_time,
|
||||
"interval": interval,
|
||||
"record_count": record_count,
|
||||
"values": values
|
||||
}, pos
|
||||
|
||||
def parse_all_day_sleep_bytes(b: bytes):
|
||||
if len(b) < 9:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _parse_all_day_sleep_bytes(b)
|
||||
except (IndexError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_all_day_sleep_bytes(b: bytes):
|
||||
_ = struct.unpack_from("<I", b, 0)[0]
|
||||
_ = b[4]
|
||||
version = b[5]
|
||||
_ = b[6]
|
||||
|
||||
data_valid = b[7:9]
|
||||
|
||||
valid_map = {}
|
||||
i = 0
|
||||
types_to_check = [0, 1, 2, 6, 7, 8, 9, 10, 3, 4, 5]
|
||||
for t in types_to_check:
|
||||
byte_idx = i // 8
|
||||
bit_idx = i % 8
|
||||
val = (data_valid[byte_idx] & (1 << (7 - bit_idx))) > 0
|
||||
valid_map[t] = val
|
||||
i += 1
|
||||
|
||||
pos = 9
|
||||
report_data = {
|
||||
"sleepFinish": bool(b[pos] == 1)
|
||||
}
|
||||
pos += 1
|
||||
|
||||
# type=0 (deviceBedTime)
|
||||
report_data["deviceBedTime"] = struct.unpack_from("<I", b, pos)[0]
|
||||
pos += 4
|
||||
|
||||
# type=1 (deviceWakeupTime)
|
||||
report_data["deviceWakeupTime"] = struct.unpack_from("<I", b, pos)[0]
|
||||
pos += 4
|
||||
|
||||
# type=2 (sleepQuality)
|
||||
val = b[pos]
|
||||
if valid_map[2]:
|
||||
report_data["sleepQuality"] = val
|
||||
pos += 1
|
||||
|
||||
# type=6 (sleepEfficiency)
|
||||
val = b[pos]
|
||||
if valid_map[6]:
|
||||
report_data["sleepEfficiency"] = val
|
||||
pos += 1
|
||||
|
||||
# type=7 (entrySleepDuration)
|
||||
val = struct.unpack_from("<I", b, pos)[0]
|
||||
if valid_map[7]:
|
||||
report_data["entrySleepDuration"] = val
|
||||
pos += 4
|
||||
|
||||
# type=8 (linBedDuration)
|
||||
val = struct.unpack_from("<I", b, pos)[0]
|
||||
if valid_map[8]:
|
||||
report_data["linBedDuration"] = val
|
||||
pos += 4
|
||||
|
||||
# type=9 (goBedTime)
|
||||
val = struct.unpack_from("<I", b, pos)[0]
|
||||
if valid_map[9]:
|
||||
report_data["goBedTime"] = val
|
||||
pos += 4
|
||||
|
||||
# type=10 (leaveBedTime)
|
||||
val = struct.unpack_from("<I", b, pos)[0]
|
||||
if valid_map[10]:
|
||||
report_data["leaveBedTime"] = val
|
||||
pos += 4
|
||||
|
||||
records = {
|
||||
"heart_rate": [],
|
||||
"spo2": []
|
||||
}
|
||||
|
||||
# type 3: hr
|
||||
if valid_map[3]:
|
||||
hr_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version)
|
||||
if hr_data:
|
||||
start_t = hr_data["start_time"]
|
||||
interval = hr_data["interval"]
|
||||
for idx, val in enumerate(hr_data["values"]):
|
||||
if val > 0 and val < 255:
|
||||
records["heart_rate"].append((start_t + idx * interval, val))
|
||||
|
||||
# type 4: spo2
|
||||
if valid_map[4]:
|
||||
spo2_data, pos = parse_sleep_assist_info(b, pos, 1, False, False, version)
|
||||
if spo2_data:
|
||||
start_t = spo2_data["start_time"]
|
||||
interval = spo2_data["interval"]
|
||||
for idx, val in enumerate(spo2_data["values"]):
|
||||
if val > 0 and val <= 100:
|
||||
records["spo2"].append((start_t + idx * interval, val))
|
||||
|
||||
return {
|
||||
"report": report_data,
|
||||
"records": records
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==1.3.0
|
||||
ruff==0.8.4
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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("<I", 1_700_000_000)
|
||||
blob += bytes([12, 2, 0])
|
||||
blob += bytes([0b00000000, 0b11000000])
|
||||
blob += bytes([1])
|
||||
blob += struct.pack("<I", 1_700_000_100)
|
||||
blob += struct.pack("<I", 1_700_000_500)
|
||||
blob += bytes([80, 90])
|
||||
blob += struct.pack("<I", 120)
|
||||
blob += struct.pack("<I", 400)
|
||||
blob += struct.pack("<I", 1_700_000_090)
|
||||
blob += struct.pack("<I", 1_700_000_520)
|
||||
blob += struct.pack("<hhI", 60, 2, 1_700_000_100)
|
||||
blob += bytes([61, 62])
|
||||
blob += struct.pack("<hhI", 60, 2, 1_700_000_100)
|
||||
blob += bytes([97, 98])
|
||||
|
||||
parsed = parse_all_day_sleep_bytes(bytes(blob))
|
||||
|
||||
assert parsed is not None
|
||||
assert parsed["records"]["heart_rate"] == [(1_700_000_100, 61), (1_700_000_160, 62)]
|
||||
assert parsed["records"]["spo2"] == [(1_700_000_100, 97), (1_700_000_160, 98)]
|
||||
|
||||
|
||||
def test_parse_all_day_sleep_bytes_rejects_malformed_payloads() -> None:
|
||||
for blob in (b"", bytes(9), bytes(10), bytes(20)):
|
||||
assert parse_all_day_sleep_bytes(blob) is None
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 "")
|
||||
Reference in New Issue
Block a user