feat: add miband bot application

This commit is contained in:
Alex
2026-05-24 21:53:04 +03:00
parent 23876f70e2
commit 7962c423b4
71 changed files with 12533 additions and 0 deletions
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+56
View File
@@ -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`.
+32
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from __future__ import annotations
from miband_tracker.bot.app import main
if __name__ == "__main__":
main()
+100
View File
@@ -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
View File
@@ -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 }}
+238
View File
@@ -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
+23
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
3.12
+674
View File
@@ -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>.
+179
View File
@@ -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` | 找不到指定亲友 |
+35
View File
@@ -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
+69
View File
@@ -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())
+38
View File
@@ -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
+125
View File
@@ -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()}"
# 设置 cookiespassToken + 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
+116
View File
@@ -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)
+74
View File
@@ -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", {})
+95
View File
@@ -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 服务 SIDserviceLogin 的 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
+289
View File
@@ -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: 登录时获取的 ssecuritybase64 编码)。
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):
"""找不到指定的亲友。"""
+93
View File
@@ -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
View File
+355
View File
@@ -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:
"""测试用 ssecuritybase64 编码的 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
+247
View File
@@ -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&&&not-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)
+71
View File
@@ -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
+963
View File
@@ -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
+199
View File
@@ -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"
+83
View File
@@ -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
+807
View File
@@ -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
+23
View File
@@ -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()
+6
View File
@@ -0,0 +1,6 @@
"""Mi Band tracker service package."""
from .config import Settings
from .sync import SyncResult, run_sync
__all__ = ["Settings", "SyncResult", "run_sync"]
+1
View File
@@ -0,0 +1 @@
"""Telegram bot package."""
File diff suppressed because it is too large Load Diff
+114
View File
@@ -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
+286
View File
@@ -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
+31
View File
@@ -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)
+66
View File
@@ -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 {}
+208
View File
@@ -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
+375
View File
@@ -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)
+11
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
asyncio_default_fixture_loop_scope = function
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==1.3.0
ruff==0.8.4
+8
View File
@@ -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
+113
View File
@@ -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)
+67
View File
@@ -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()
+33
View File
@@ -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
+35
View File
@@ -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)
+32
View File
@@ -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
+49
View File
@@ -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()
+27
View File
@@ -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 "")