mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
feat: prepare mi-fitness-mcp for first public release
Package the standalone Mi Fitness MCP with clearer public docs, release metadata, CI, and safer local repository hygiene so it is ready to publish without secrets or work identity leakage.
This commit is contained in:
1 parent
6771fc841c
commit
df914ea56a
23 files changed
+650
-322
No files matched your search
@@ -0,0 +1,3 @@
|
||||
MI_FITNESS_USER_ID=replace_me
|
||||
MI_FITNESS_PASS_TOKEN=replace_me
|
||||
MI_FITNESS_REGION=ru
|
||||
@@ -0,0 +1,30 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
- name: Ruff
|
||||
run: |
|
||||
python -m ruff check src tests
|
||||
- name: Pytest
|
||||
run: |
|
||||
pytest -q
|
||||
- name: Build
|
||||
run: |
|
||||
python -m build
|
||||
+13
@@ -20,6 +20,7 @@ build/
|
||||
htmlcov/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.dmypy.json
|
||||
*.iml
|
||||
.idea/
|
||||
@@ -27,3 +28,15 @@ htmlcov/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Local runtime data
|
||||
logs/
|
||||
data/
|
||||
exports/
|
||||
tmp/
|
||||
config.json
|
||||
*.local.json
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
*.db-*
|
||||
*.zip
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- initial standalone `mi-fitness-mcp` repository
|
||||
- Xiaomi auth via `userId + passToken`
|
||||
- Mi Fitness cloud sync for steps, heart rate, calories, body measurements
|
||||
- MCP tools for sync, summaries, heart rate, body measurements, coverage
|
||||
@@ -0,0 +1,25 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for contributing.
|
||||
|
||||
## Local setup
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
## Before opening a change
|
||||
|
||||
- keep local config, SQLite data, and captured cloud payloads out of git
|
||||
- never commit real `userId` or `passToken` values
|
||||
- keep README and examples aligned with actual supported data types
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
ruff check src tests
|
||||
pytest
|
||||
python -m build
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aleksej Kubulashvili
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,32 +1,76 @@
|
||||
# Mi Fitness MCP
|
||||
|
||||
Отдельный MCP-сервер для `Mi Fitness`.
|
||||
MCP server for Mi Fitness data.
|
||||
|
||||
Подтверждённые данные:
|
||||
This project provides a local SQLite-backed MCP server for Mi Fitness cloud data.
|
||||
|
||||
- шаги
|
||||
- калории
|
||||
- пульс
|
||||
- вес и состав тела
|
||||
## Current data coverage
|
||||
|
||||
## Установка
|
||||
Confirmed with the current cloud flow:
|
||||
|
||||
- daily activity
|
||||
- steps
|
||||
- distance
|
||||
- active calories
|
||||
- heart rate
|
||||
- body measurements
|
||||
- weight
|
||||
- BMI
|
||||
- fat, water, bone, and muscle metrics
|
||||
- visceral fat
|
||||
- basal metabolism
|
||||
|
||||
Not yet confirmed with the current Xiaomi cloud endpoint:
|
||||
|
||||
- sleep
|
||||
- workouts
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e "."
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
## Команды
|
||||
## Setup
|
||||
|
||||
You need:
|
||||
|
||||
- `userId`
|
||||
- `passToken`
|
||||
|
||||
Typical flow:
|
||||
|
||||
1. Open `https://account.xiaomi.com`
|
||||
2. Sign in to your Xiaomi account
|
||||
3. Open browser DevTools
|
||||
4. Inspect cookies for `account.xiaomi.com`
|
||||
5. Copy `userId` and `passToken`
|
||||
|
||||
Configure the server:
|
||||
|
||||
```bash
|
||||
mi-fitness-mcp setup --mode mi_fitness_cloud --user-id "<userId>" --pass-token "<passToken>" --region ru
|
||||
mi-fitness-mcp doctor
|
||||
```
|
||||
|
||||
For local endpoint exploration there is also a probe script:
|
||||
|
||||
```bash
|
||||
python probe_mifitness.py --user-id "<userId>" --pass-token "<passToken>"
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
mi-fitness-mcp sync --start-date 2025-04-01 --end-date 2025-05-31
|
||||
mi-fitness-mcp serve
|
||||
```
|
||||
|
||||
## Настройка Claude Desktop
|
||||
## MCP client config
|
||||
|
||||
Example `Claude Desktop` config:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -38,3 +82,48 @@ mi-fitness-mcp serve
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example prompts
|
||||
|
||||
- `Show my daily activity for the last 14 days`
|
||||
- `How has my resting heart rate changed this month?`
|
||||
- `Summarize my latest body measurements`
|
||||
- `Sync my latest Mi Fitness data`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
mi-fitness-mcp --help
|
||||
mi-fitness-mcp setup --help
|
||||
mi-fitness-mcp doctor
|
||||
mi-fitness-mcp sync --help
|
||||
mi-fitness-mcp serve
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pytest
|
||||
python -m build
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Connection: failed`
|
||||
- verify `userId` and `passToken`
|
||||
- verify region, usually `ru`
|
||||
- `Credentials not found`
|
||||
- run `setup` again
|
||||
- `sync` returns no data
|
||||
- try another date range
|
||||
- verify that the data actually exists in Mi Fitness cloud
|
||||
|
||||
## Security
|
||||
|
||||
- `passToken` is stored via the system keyring
|
||||
- do not commit `.env`, local config files, or real credentials
|
||||
- rotate tokens if they were pasted into chats or shell history
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an unofficial project and is not affiliated with Xiaomi.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Security
|
||||
|
||||
This project stores credentials in the operating system keychain via `keyring`.
|
||||
|
||||
## Safe usage
|
||||
|
||||
- do not commit local config files, databases, or captured payloads
|
||||
- avoid pasting `passToken` values into shell history when possible
|
||||
- treat leaked `passToken` values as compromised and rotate them
|
||||
|
||||
## Reporting issues
|
||||
|
||||
If you find a vulnerability or a secret leak, do not post the secret in a public GitHub issue. Share only the minimum details needed to reproduce the issue.
|
||||
@@ -9,6 +9,10 @@ description = "MCP server for Mi Fitness data access"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{name = "Aleksej Kubulashvili", email = "kubulashvili@users.noreply.github.com"},
|
||||
]
|
||||
keywords = ["mcp", "model-context-protocol", "mi-fitness", "xiaomi", "fitness", "health"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
@@ -30,6 +34,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"build>=1.2.2",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"respx>=0.21.0",
|
||||
@@ -45,6 +50,8 @@ packages = ["src/mi_fitness_mcp"]
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Base adapter interface for data sources."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, Coroutine, Union
|
||||
from collections.abc import AsyncIterator, Coroutine, Iterator
|
||||
from typing import Any, Union
|
||||
|
||||
from mi_fitness_mcp.models import (
|
||||
BodyMeasurement,
|
||||
@@ -41,7 +40,7 @@ class DataAdapter(ABC):
|
||||
self,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Union[Iterator[DailyActivity], AsyncIterator[DailyActivity]]:
|
||||
) -> Iterator[DailyActivity] | AsyncIterator[DailyActivity]:
|
||||
"""Iterate over daily activity records."""
|
||||
pass
|
||||
|
||||
@@ -50,7 +49,7 @@ class DataAdapter(ABC):
|
||||
self,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Union[Iterator[SleepSession], AsyncIterator[SleepSession]]:
|
||||
) -> Iterator[SleepSession] | AsyncIterator[SleepSession]:
|
||||
"""Iterate over sleep session records."""
|
||||
pass
|
||||
|
||||
@@ -59,7 +58,7 @@ class DataAdapter(ABC):
|
||||
self,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Union[Iterator[Workout], AsyncIterator[Workout]]:
|
||||
) -> Iterator[Workout] | AsyncIterator[Workout]:
|
||||
"""Iterate over workout records."""
|
||||
pass
|
||||
|
||||
@@ -68,7 +67,7 @@ class DataAdapter(ABC):
|
||||
self,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Union[Iterator[BodyMeasurement], AsyncIterator[BodyMeasurement]]:
|
||||
) -> Iterator[BodyMeasurement] | AsyncIterator[BodyMeasurement]:
|
||||
"""Iterate over body measurement records."""
|
||||
pass
|
||||
|
||||
@@ -77,7 +76,7 @@ class DataAdapter(ABC):
|
||||
self,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Union[Iterator[HeartRateSample], AsyncIterator[HeartRateSample]]:
|
||||
) -> Iterator[HeartRateSample] | AsyncIterator[HeartRateSample]:
|
||||
"""Iterate over heart rate records."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -13,8 +13,13 @@ from urllib.parse import urlencode
|
||||
import httpx
|
||||
|
||||
from mi_fitness_mcp.adapters.base import DataAdapter
|
||||
from mi_fitness_mcp.models import BodyMeasurement, DailyActivity, HeartRateSample, SleepSession, Workout
|
||||
|
||||
from mi_fitness_mcp.models import (
|
||||
BodyMeasurement,
|
||||
DailyActivity,
|
||||
HeartRateSample,
|
||||
SleepSession,
|
||||
Workout,
|
||||
)
|
||||
|
||||
LOGIN_PREFIX = b"&&&START&&&"
|
||||
KNOWN_REGIONS = ["ru", "cn", "de", "i2", "sg", "us"]
|
||||
@@ -24,7 +29,7 @@ def _read_login_payload(text: str) -> dict:
|
||||
payload = text.encode()
|
||||
if not payload.startswith(LOGIN_PREFIX):
|
||||
raise RuntimeError("unexpected Xiaomi login response")
|
||||
return json.loads(payload[len(LOGIN_PREFIX):].decode())
|
||||
return json.loads(payload[len(LOGIN_PREFIX) :].decode())
|
||||
|
||||
|
||||
def _rc4_crypt(key: bytes, payload: bytes) -> bytes:
|
||||
@@ -72,7 +77,9 @@ def _gen_signature(method: str, path: str, values: dict[str, str], signed_nonce:
|
||||
|
||||
|
||||
class MiFitnessCloudAdapter(DataAdapter):
|
||||
def __init__(self, user_id: str | None = None, pass_token: str | None = None, region: str = "ru"):
|
||||
def __init__(
|
||||
self, user_id: str | None = None, pass_token: str | None = None, region: str = "ru"
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.pass_token = pass_token
|
||||
self.region = region
|
||||
@@ -107,6 +114,11 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
def get_available_data_types(self) -> list[str]:
|
||||
return self._available_types.copy()
|
||||
|
||||
async def _close_client(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _login_with_token(self, user_id: str, pass_token: str) -> None:
|
||||
if not self._client:
|
||||
raise RuntimeError("client not initialized")
|
||||
@@ -130,6 +142,9 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
if not self._client:
|
||||
raise RuntimeError("client not initialized")
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
form = {"data": json.dumps(payload, separators=(",", ":"))}
|
||||
nonce = _gen_nonce()
|
||||
signed_nonce = _gen_signed_nonce(self._ssecurity, nonce)
|
||||
@@ -137,7 +152,9 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
|
||||
encrypted: dict[str, str] = {}
|
||||
for key, value in form.items():
|
||||
encrypted[key] = base64.b64encode(_rc4_crypt(signed_nonce, value.encode())).decode()
|
||||
encrypted[key] = base64.b64encode(
|
||||
_rc4_crypt(signed_nonce, value.encode())
|
||||
).decode()
|
||||
|
||||
encrypted["signature"] = _gen_signature("POST", api_path, encrypted, signed_nonce)
|
||||
encrypted["_nonce"] = base64.b64encode(nonce).decode()
|
||||
@@ -156,12 +173,26 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
if body.get("code") != 0:
|
||||
raise RuntimeError(body.get("message", "unknown mi fitness error"))
|
||||
return body.get("result", {})
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if attempt == 2:
|
||||
break
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
raise RuntimeError(f"Mi Fitness request failed: {last_error}")
|
||||
|
||||
async def _fetch_key(self, key: str, start_date: str, end_date: str, region: str | None = None) -> list[dict]:
|
||||
async def _fetch_key(
|
||||
self, key: str, start_date: str, end_date: str, region: str | None = None
|
||||
) -> list[dict]:
|
||||
region_name = region or self.region
|
||||
base_url = "https://hlth.io.mi.com" if region_name in ("", "cn") else f"https://{region_name}.hlth.io.mi.com"
|
||||
base_url = (
|
||||
"https://hlth.io.mi.com"
|
||||
if region_name in ("", "cn")
|
||||
else f"https://{region_name}.hlth.io.mi.com"
|
||||
)
|
||||
start_time = int(datetime.fromisoformat(start_date).replace(tzinfo=UTC).timestamp())
|
||||
end_time = int(datetime.fromisoformat(end_date + "T23:59:59").replace(tzinfo=UTC).timestamp())
|
||||
end_time = int(
|
||||
datetime.fromisoformat(end_date + "T23:59:59").replace(tzinfo=UTC).timestamp()
|
||||
)
|
||||
next_key = None
|
||||
items: list[dict] = []
|
||||
|
||||
@@ -183,7 +214,9 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
return items
|
||||
|
||||
async def _discover_region(self, preferred_region: str) -> str:
|
||||
candidates = [preferred_region] + [region for region in KNOWN_REGIONS if region != preferred_region]
|
||||
candidates = [preferred_region] + [
|
||||
region for region in KNOWN_REGIONS if region != preferred_region
|
||||
]
|
||||
for region in candidates:
|
||||
for key in ("weight", "steps", "heart_rate"):
|
||||
try:
|
||||
@@ -243,7 +276,9 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
yield
|
||||
|
||||
records = await self._fetch_key("steps", start_date, end_date)
|
||||
daily: dict[str, dict[str, float]] = defaultdict(lambda: {"steps": 0, "distance_m": 0.0, "active_kcal": 0.0})
|
||||
daily: dict[str, dict[str, float]] = defaultdict(
|
||||
lambda: {"steps": 0, "distance_m": 0.0, "active_kcal": 0.0}
|
||||
)
|
||||
for item in records:
|
||||
payload = self._parse_value(item)
|
||||
date_str = self._record_datetime(item).strftime("%Y-%m-%d")
|
||||
@@ -343,7 +378,5 @@ class MiFitnessCloudAdapter(DataAdapter):
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
await self._close_client()
|
||||
self._connected = False
|
||||
@@ -1,15 +1,23 @@
|
||||
"""Authentication helpers for Mi Fitness cloud API."""
|
||||
|
||||
import logging
|
||||
|
||||
import keyring
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_NAME = "mi-fitness-mcp"
|
||||
ACCOUNT_NAME = "mi_fitness_auth"
|
||||
|
||||
|
||||
def save_mi_fitness_token(user_id: str, pass_token: str) -> None:
|
||||
try:
|
||||
keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id", user_id)
|
||||
keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token", pass_token)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to save Mi Fitness credentials: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
def load_mi_fitness_token() -> tuple[str | None, str | None]:
|
||||
@@ -17,7 +25,8 @@ def load_mi_fitness_token() -> tuple[str | None, str | None]:
|
||||
user_id = keyring.get_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id")
|
||||
pass_token = keyring.get_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token")
|
||||
return user_id, pass_token
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load Mi Fitness credentials: %s", exc)
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -26,7 +35,11 @@ def delete_mi_fitness_token() -> None:
|
||||
keyring.delete_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id")
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete Mi Fitness user_id: %s", exc)
|
||||
try:
|
||||
keyring.delete_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token")
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Failed to delete Mi Fitness passToken: %s", exc)
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from platformdirs import user_config_dir, user_data_dir, user_log_dir
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def _default_database_path() -> Path:
|
||||
@@ -27,8 +27,7 @@ class Config(BaseModel):
|
||||
store_raw_payloads: bool = True
|
||||
default_lookback_days: int = 30
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
@@ -48,7 +47,7 @@ def load_config() -> Config:
|
||||
save_config(config)
|
||||
return config
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if "database_path" in data and isinstance(data["database_path"], str):
|
||||
|
||||
@@ -8,9 +8,8 @@ from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
from mi_fitness_mcp.auth import load_mi_fitness_token, save_mi_fitness_token
|
||||
from mi_fitness_mcp.config import Config, get_config_path, load_config, save_config
|
||||
from mi_fitness_mcp.server import main as server_main
|
||||
from mi_fitness_mcp.storage import Database
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
PROGRAM_NAME = "mi-fitness-mcp"
|
||||
|
||||
@@ -70,7 +69,9 @@ def cmd_doctor(args):
|
||||
print("✅ Mi Fitness credentials найдены")
|
||||
print(f" User ID: {user_id}")
|
||||
print(f" Region: {config.region}")
|
||||
adapter = MiFitnessCloudAdapter(user_id=user_id, pass_token=pass_token, region=config.region)
|
||||
adapter = MiFitnessCloudAdapter(
|
||||
user_id=user_id, pass_token=pass_token, region=config.region
|
||||
)
|
||||
connected, data_types, region = asyncio.run(_check_adapter_health(adapter))
|
||||
print(f" Подключение: {'✅' if connected else '❌'}")
|
||||
if connected:
|
||||
@@ -144,7 +145,9 @@ def cmd_sync(args):
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog=PROGRAM_NAME, description="MCP server for Mi Fitness data")
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=PROGRAM_NAME, description="MCP server for Mi Fitness data"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
subparsers.add_parser("serve", help="Run MCP server")
|
||||
|
||||
@@ -157,7 +160,11 @@ def main():
|
||||
subparsers.add_parser("doctor", help="Check configuration and diagnose issues")
|
||||
|
||||
sync_parser = subparsers.add_parser("sync", help="Sync data from source")
|
||||
sync_parser.add_argument("--type", choices=["daily_activity", "body_measurements", "heart_rate"], help="Type of data to sync")
|
||||
sync_parser.add_argument(
|
||||
"--type",
|
||||
choices=["daily_activity", "body_measurements", "heart_rate"],
|
||||
help="Type of data to sync",
|
||||
)
|
||||
sync_parser.add_argument("--start-date", help="Start date (YYYY-MM-DD)")
|
||||
sync_parser.add_argument("--end-date", help="End date (YYYY-MM-DD)")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pydantic models for Mi Fitness MCP."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -11,16 +11,14 @@ class BaseEntity(BaseModel):
|
||||
|
||||
id: str = Field(description="Internal UUID")
|
||||
provider: Literal["mi_fitness"] = Field(description="Data provider")
|
||||
source_type: Literal["cloud_session"] = Field(
|
||||
description="Source of the data"
|
||||
)
|
||||
source_type: Literal["cloud_session"] = Field(description="Source of the data")
|
||||
source_record_id: str | None = Field(None, description="Provider's native ID")
|
||||
user_id: str = Field(description="User identifier")
|
||||
device_id: str | None = Field(None, description="Device that recorded the data")
|
||||
timezone: str = Field(default="UTC", description="Timezone for the data")
|
||||
collected_at: datetime | None = Field(None, description="When data was recorded")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None))
|
||||
|
||||
|
||||
class DailyActivity(BaseEntity):
|
||||
@@ -177,7 +175,7 @@ class QueryResponse(BaseModel):
|
||||
|
||||
status: Literal["ok", "error"] = "ok"
|
||||
source: Literal["mi_fitness_cloud", "cloud_session", "cache", "unknown"]
|
||||
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None))
|
||||
timezone: str = "UTC"
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
+173
-20
@@ -33,14 +33,113 @@ query_service = None
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(name="get_connection_status", description="Check connection status", inputSchema={"type": "object", "properties": {}}),
|
||||
Tool(name="sync_data", description="Synchronize Mi Fitness data", inputSchema={"type": "object", "properties": {"data_types": {"type": "array", "items": {"type": "string"}}, "start_date": {"type": "string"}, "end_date": {"type": "string"}, "force_full_sync": {"type": "boolean"}}}),
|
||||
Tool(name="get_profile", description="Get user profile information", inputSchema={"type": "object", "properties": {}}),
|
||||
Tool(name="get_daily_summary", description="Get daily activity summary", inputSchema={"type": "object", "properties": {"date": {"type": "string"}, "start_date": {"type": "string"}, "end_date": {"type": "string"}}}),
|
||||
Tool(name="query_metric_series", description="Query metric series", inputSchema={"type": "object", "properties": {"metric": {"type": "string", "enum": ["steps", "distance_m", "active_kcal", "weight_kg"]}, "start_date": {"type": "string"}, "end_date": {"type": "string"}, "granularity": {"type": "string", "enum": ["day", "week", "month"]}, "aggregation": {"type": "string", "enum": ["sum", "avg", "min", "max", "latest"]}}, "required": ["metric", "start_date", "end_date"]}),
|
||||
Tool(name="query_heart_rate", description="Query heart rate samples", inputSchema={"type": "object", "properties": {"start_date": {"type": "string"}, "end_date": {"type": "string"}, "sample_type": {"type": "string", "enum": ["resting", "active", "passive", "workout"]}, "limit": {"type": "integer"}}, "required": ["start_date", "end_date"]}),
|
||||
Tool(name="query_body_measurements", description="Query body measurements", inputSchema={"type": "object", "properties": {"start_date": {"type": "string"}, "end_date": {"type": "string"}, "metrics": {"type": "array", "items": {"type": "string", "enum": ["weight_kg", "bmi", "body_fat_pct", "muscle_mass_kg", "water_pct"]}}, "latest_only": {"type": "boolean"}}, "required": ["start_date", "end_date"]}),
|
||||
Tool(name="get_data_coverage", description="Get data coverage", inputSchema={"type": "object", "properties": {"data_types": {"type": "array", "items": {"type": "string"}}}}),
|
||||
Tool(
|
||||
name="get_connection_status",
|
||||
description="Check connection status",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="sync_data",
|
||||
description="Synchronize Mi Fitness data",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_types": {"type": "array", "items": {"type": "string"}},
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"force_full_sync": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_profile",
|
||||
description="Get user profile information",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
Tool(
|
||||
name="get_daily_summary",
|
||||
description="Get daily activity summary",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string"},
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="query_metric_series",
|
||||
description="Query metric series",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metric": {
|
||||
"type": "string",
|
||||
"enum": ["steps", "distance_m", "active_kcal", "weight_kg"],
|
||||
},
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"granularity": {"type": "string", "enum": ["day", "week", "month"]},
|
||||
"aggregation": {
|
||||
"type": "string",
|
||||
"enum": ["sum", "avg", "min", "max", "latest"],
|
||||
},
|
||||
},
|
||||
"required": ["metric", "start_date", "end_date"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="query_heart_rate",
|
||||
description="Query heart rate samples",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"sample_type": {
|
||||
"type": "string",
|
||||
"enum": ["resting", "active", "passive", "workout"],
|
||||
},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="query_body_measurements",
|
||||
description="Query body measurements",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_date": {"type": "string"},
|
||||
"end_date": {"type": "string"},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"weight_kg",
|
||||
"bmi",
|
||||
"body_fat_pct",
|
||||
"muscle_mass_kg",
|
||||
"water_pct",
|
||||
],
|
||||
},
|
||||
},
|
||||
"latest_only": {"type": "boolean"},
|
||||
},
|
||||
"required": ["start_date", "end_date"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="get_data_coverage",
|
||||
description="Get data coverage",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {"data_types": {"type": "array", "items": {"type": "string"}}},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -74,7 +173,9 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
||||
async def _handle_get_connection_status() -> dict:
|
||||
global adapter, config
|
||||
if not config or config.mode == "not_configured":
|
||||
return ConnectionStatus(mode="not_configured", connected=False, message="Server not configured.").model_dump()
|
||||
return ConnectionStatus(
|
||||
mode="not_configured", connected=False, message="Server not configured."
|
||||
).model_dump()
|
||||
|
||||
connected = adapter is not None and adapter.is_connected()
|
||||
last_sync = None
|
||||
@@ -87,7 +188,12 @@ async def _handle_get_connection_status() -> dict:
|
||||
sync_time = datetime.fromisoformat(state["last_sync_at"])
|
||||
if last_sync is None or sync_time > last_sync:
|
||||
last_sync = sync_time
|
||||
return ConnectionStatus(mode=config.mode, connected=connected, last_sync_at=last_sync, available_data_types=available_types).model_dump()
|
||||
return ConnectionStatus(
|
||||
mode=config.mode,
|
||||
connected=connected,
|
||||
last_sync_at=last_sync,
|
||||
available_data_types=available_types,
|
||||
).model_dump()
|
||||
|
||||
|
||||
async def _handle_sync_data(arguments: dict) -> dict:
|
||||
@@ -102,7 +208,12 @@ async def _handle_sync_data(arguments: dict) -> dict:
|
||||
types_synced = []
|
||||
for data_type in data_types:
|
||||
try:
|
||||
result = await sync_service.sync_data_type(data_type=data_type, start_date=arguments.get("start_date"), end_date=arguments.get("end_date"), force_full=arguments.get("force_full_sync", False))
|
||||
result = await sync_service.sync_data_type(
|
||||
data_type=data_type,
|
||||
start_date=arguments.get("start_date"),
|
||||
end_date=arguments.get("end_date"),
|
||||
force_full=arguments.get("force_full_sync", False),
|
||||
)
|
||||
total_added += result.get("added", 0)
|
||||
total_updated += result.get("updated", 0)
|
||||
total_skipped += result.get("skipped", 0)
|
||||
@@ -110,13 +221,32 @@ async def _handle_sync_data(arguments: dict) -> dict:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync {data_type}: {e}")
|
||||
finished_at = datetime.utcnow()
|
||||
return {"status": "ok", "sync_id": sync_id, "started_at": started_at.isoformat(), "finished_at": finished_at.isoformat(), "records_added": total_added, "records_updated": total_updated, "records_skipped": total_skipped, "data_types_synced": types_synced}
|
||||
return {
|
||||
"status": "ok",
|
||||
"sync_id": sync_id,
|
||||
"started_at": started_at.isoformat(),
|
||||
"finished_at": finished_at.isoformat(),
|
||||
"records_added": total_added,
|
||||
"records_updated": total_updated,
|
||||
"records_skipped": total_skipped,
|
||||
"data_types_synced": types_synced,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_get_profile() -> dict:
|
||||
if not adapter or not adapter.is_connected():
|
||||
return {"status": "error", "error": "Not connected to data source"}
|
||||
return QueryResponse(status="ok", source=config.mode if config else "unknown", data={"profile": {"user_id": adapter.get_user_id() or "unknown", "timezone": config.timezone if config else "UTC", "devices": []}}).model_dump()
|
||||
return QueryResponse(
|
||||
status="ok",
|
||||
source=config.mode if config else "unknown",
|
||||
data={
|
||||
"profile": {
|
||||
"user_id": adapter.get_user_id() or "unknown",
|
||||
"timezone": config.timezone if config else "UTC",
|
||||
"devices": [],
|
||||
}
|
||||
},
|
||||
).model_dump()
|
||||
|
||||
|
||||
async def _handle_get_daily_summary(arguments: dict) -> dict:
|
||||
@@ -133,24 +263,45 @@ async def _handle_get_daily_summary(arguments: dict) -> dict:
|
||||
async def _handle_query_metric_series(arguments: dict) -> dict:
|
||||
if not query_service:
|
||||
return {"status": "error", "error": "Query service not initialized"}
|
||||
series = query_service.get_metric_series(metric=arguments["metric"], start_date=arguments["start_date"], end_date=arguments["end_date"], granularity=arguments.get("granularity", "day"), aggregation=arguments.get("aggregation", "sum"))
|
||||
return QueryResponse(status="ok", source="cache", data={"metric": arguments["metric"], "series": series}).model_dump()
|
||||
series = query_service.get_metric_series(
|
||||
metric=arguments["metric"],
|
||||
start_date=arguments["start_date"],
|
||||
end_date=arguments["end_date"],
|
||||
granularity=arguments.get("granularity", "day"),
|
||||
aggregation=arguments.get("aggregation", "sum"),
|
||||
)
|
||||
return QueryResponse(
|
||||
status="ok", source="cache", data={"metric": arguments["metric"], "series": series}
|
||||
).model_dump()
|
||||
|
||||
|
||||
async def _handle_query_heart_rate(arguments: dict) -> dict:
|
||||
if not query_service:
|
||||
return {"status": "error", "error": "Query service not initialized"}
|
||||
samples = query_service.get_heart_rate_samples(start_date=arguments["start_date"], end_date=arguments["end_date"], sample_type=arguments.get("sample_type"), limit=arguments.get("limit"))
|
||||
return QueryResponse(status="ok", source="cache", data={"samples": samples, "count": len(samples)}).model_dump()
|
||||
samples = query_service.get_heart_rate_samples(
|
||||
start_date=arguments["start_date"],
|
||||
end_date=arguments["end_date"],
|
||||
sample_type=arguments.get("sample_type"),
|
||||
limit=arguments.get("limit"),
|
||||
)
|
||||
return QueryResponse(
|
||||
status="ok", source="cache", data={"samples": samples, "count": len(samples)}
|
||||
).model_dump()
|
||||
|
||||
|
||||
async def _handle_query_body_measurements(arguments: dict) -> dict:
|
||||
if not query_service:
|
||||
return {"status": "error", "error": "Query service not initialized"}
|
||||
measurements = query_service.get_body_measurements(start_date=arguments["start_date"], end_date=arguments["end_date"], metrics=arguments.get("metrics"))
|
||||
measurements = query_service.get_body_measurements(
|
||||
start_date=arguments["start_date"],
|
||||
end_date=arguments["end_date"],
|
||||
metrics=arguments.get("metrics"),
|
||||
)
|
||||
if arguments.get("latest_only") and measurements:
|
||||
measurements = [measurements[-1]]
|
||||
return QueryResponse(status="ok", source="cache", data={"measurements": measurements, "count": len(measurements)}).model_dump()
|
||||
return QueryResponse(
|
||||
status="ok", source="cache", data={"measurements": measurements, "count": len(measurements)}
|
||||
).model_dump()
|
||||
|
||||
|
||||
async def _handle_get_data_coverage(arguments: dict) -> dict:
|
||||
@@ -167,7 +318,9 @@ async def main():
|
||||
if config.mode == "mi_fitness_cloud":
|
||||
user_id, pass_token = load_mi_fitness_token()
|
||||
if user_id and pass_token:
|
||||
adapter = MiFitnessCloudAdapter(user_id=user_id, pass_token=pass_token, region=config.region)
|
||||
adapter = MiFitnessCloudAdapter(
|
||||
user_id=user_id, pass_token=pass_token, region=config.region
|
||||
)
|
||||
if await adapter.connect():
|
||||
logger.info("Connected to Mi Fitness cloud API")
|
||||
else:
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Background sync scheduler for Mi Fitness MCP."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable
|
||||
|
||||
from mi_fitness_mcp.adapters.base import DataAdapter
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncScheduler:
|
||||
"""Scheduler for automatic background synchronization."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: DataAdapter,
|
||||
db: Database,
|
||||
sync_interval_minutes: int = 60,
|
||||
):
|
||||
"""Initialize sync scheduler.
|
||||
|
||||
Args:
|
||||
adapter: Data source adapter
|
||||
db: Database instance
|
||||
sync_interval_minutes: How often to sync (default: 1 hour)
|
||||
"""
|
||||
self.adapter = adapter
|
||||
self.db = db
|
||||
self.sync_interval = timedelta(minutes=sync_interval_minutes)
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
self._last_sync: datetime | None = None
|
||||
self._sync_callback: Callable | None = None
|
||||
|
||||
def set_sync_callback(self, callback: Callable) -> None:
|
||||
"""Set callback to be called after each sync.
|
||||
|
||||
Args:
|
||||
callback: Function to call with sync results
|
||||
"""
|
||||
self._sync_callback = callback
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the sync scheduler."""
|
||||
if self._running:
|
||||
logger.warning("Scheduler already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_scheduler())
|
||||
logger.info(f"Sync scheduler started (interval: {self.sync_interval})")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the sync scheduler."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Sync scheduler stopped")
|
||||
|
||||
async def _run_scheduler(self) -> None:
|
||||
"""Main scheduler loop."""
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
|
||||
sync_service = SyncService(self.adapter, self.db)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Check if it's time to sync
|
||||
if self._should_sync():
|
||||
await self._perform_sync(sync_service)
|
||||
|
||||
# Wait for next check
|
||||
await asyncio.sleep(60) # Check every minute
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync scheduler: {e}")
|
||||
await asyncio.sleep(60) # Wait before retrying
|
||||
|
||||
def _should_sync(self) -> bool:
|
||||
"""Check if sync should be performed."""
|
||||
if not self._last_sync:
|
||||
return True
|
||||
|
||||
time_since_last = datetime.utcnow() - self._last_sync
|
||||
return time_since_last >= self.sync_interval
|
||||
|
||||
async def _perform_sync(self, sync_service) -> None:
|
||||
"""Perform synchronization of all data types."""
|
||||
logger.info("Starting automatic sync")
|
||||
|
||||
results = {}
|
||||
data_types = self.adapter.get_available_data_types()
|
||||
|
||||
for data_type in data_types:
|
||||
try:
|
||||
result = sync_service.sync_data_type(data_type)
|
||||
results[data_type] = result
|
||||
logger.info(
|
||||
f"Synced {data_type}: {result['added']} added, "
|
||||
f"{result['updated']} updated"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync {data_type}: {e}")
|
||||
results[data_type] = {"error": str(e)}
|
||||
|
||||
self._last_sync = datetime.utcnow()
|
||||
|
||||
# Call callback if set
|
||||
if self._sync_callback:
|
||||
try:
|
||||
self._sync_callback(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Sync callback failed: {e}")
|
||||
|
||||
logger.info("Automatic sync completed")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get scheduler status."""
|
||||
return {
|
||||
"running": self._running,
|
||||
"last_sync": self._last_sync.isoformat() if self._last_sync else None,
|
||||
"sync_interval_minutes": self.sync_interval.total_seconds() / 60,
|
||||
"next_sync": (
|
||||
(self._last_sync + self.sync_interval).isoformat()
|
||||
if self._last_sync
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AutoSyncManager:
|
||||
"""Manager for automatic synchronization configuration."""
|
||||
|
||||
def __init__(self, config_path: str = "config/autosync.json"):
|
||||
"""Initialize auto-sync manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to autosync configuration file
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self._scheduler: SyncScheduler | None = None
|
||||
|
||||
def load_config(self) -> dict:
|
||||
"""Load auto-sync configuration."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(self.config_path)
|
||||
if not path.exists():
|
||||
return {
|
||||
"enabled": False,
|
||||
"sync_interval_minutes": 60,
|
||||
"data_types": ["daily_activity", "sleep", "workouts"],
|
||||
}
|
||||
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_config(self, config: dict) -> None:
|
||||
"""Save auto-sync configuration."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(self.config_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
async def start_scheduler(
|
||||
self,
|
||||
adapter: DataAdapter,
|
||||
db: Database,
|
||||
) -> SyncScheduler | None:
|
||||
"""Start scheduler with loaded configuration.
|
||||
|
||||
Args:
|
||||
adapter: Data source adapter
|
||||
db: Database instance
|
||||
|
||||
Returns:
|
||||
SyncScheduler instance or None if not enabled
|
||||
"""
|
||||
config = self.load_config()
|
||||
|
||||
if not config.get("enabled", False):
|
||||
logger.info("Auto-sync is disabled")
|
||||
return None
|
||||
|
||||
interval = config.get("sync_interval_minutes", 60)
|
||||
|
||||
self._scheduler = SyncScheduler(
|
||||
adapter=adapter,
|
||||
db=db,
|
||||
sync_interval_minutes=interval,
|
||||
)
|
||||
|
||||
await self._scheduler.start()
|
||||
return self._scheduler
|
||||
|
||||
async def stop_scheduler(self) -> None:
|
||||
"""Stop the running scheduler."""
|
||||
if self._scheduler:
|
||||
await self._scheduler.stop()
|
||||
self._scheduler = None
|
||||
|
||||
def get_scheduler_status(self) -> dict | None:
|
||||
"""Get current scheduler status."""
|
||||
if not self._scheduler:
|
||||
return None
|
||||
return self._scheduler.get_status()
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Query service for retrieving data from database."""
|
||||
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -65,10 +66,12 @@ class QueryService:
|
||||
for summary in summaries:
|
||||
value = summary.get(metric)
|
||||
if value is not None:
|
||||
series.append({
|
||||
series.append(
|
||||
{
|
||||
"date": summary["date"],
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Apply aggregation if needed
|
||||
if granularity == "week":
|
||||
@@ -175,10 +178,9 @@ class QueryService:
|
||||
# Parse stages if available
|
||||
if record.get("stages"):
|
||||
import json
|
||||
try:
|
||||
|
||||
with suppress(json.JSONDecodeError):
|
||||
session["stages"] = json.loads(record["stages"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
sessions.append(session)
|
||||
|
||||
@@ -198,8 +200,9 @@ class QueryService:
|
||||
workouts = []
|
||||
for record in records:
|
||||
# Apply filters
|
||||
if activity_types:
|
||||
if record["activity_type"].lower() not in [t.lower() for t in activity_types]:
|
||||
if activity_types and record["activity_type"].lower() not in [
|
||||
t.lower() for t in activity_types
|
||||
]:
|
||||
continue
|
||||
|
||||
if min_duration and record.get("duration_minutes", 0) < min_duration:
|
||||
@@ -210,7 +213,8 @@ class QueryService:
|
||||
if distance_km < min_distance_km:
|
||||
continue
|
||||
|
||||
workouts.append({
|
||||
workouts.append(
|
||||
{
|
||||
"workout_id": record["workout_id"],
|
||||
"activity_type": record["activity_type"],
|
||||
"start_at": record["start_at"],
|
||||
@@ -223,7 +227,8 @@ class QueryService:
|
||||
"avg_pace_sec_per_km": record.get("avg_pace_sec_per_km"),
|
||||
"max_pace_sec_per_km": record.get("max_pace_sec_per_km"),
|
||||
"total_steps": record.get("total_steps"),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return workouts
|
||||
|
||||
@@ -245,8 +250,13 @@ class QueryService:
|
||||
|
||||
# Add optional metrics
|
||||
optional_fields = [
|
||||
"bmi", "body_fat_pct", "muscle_mass_kg", "water_pct",
|
||||
"bone_mass_kg", "visceral_fat_score", "basal_metabolism_kcal",
|
||||
"bmi",
|
||||
"body_fat_pct",
|
||||
"muscle_mass_kg",
|
||||
"water_pct",
|
||||
"bone_mass_kg",
|
||||
"visceral_fat_score",
|
||||
"basal_metabolism_kcal",
|
||||
"metabolic_age",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from mi_fitness_mcp.adapters.base import DataAdapter
|
||||
@@ -65,13 +65,7 @@ class SyncService:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
if not start_date:
|
||||
if last_record_ts:
|
||||
start_date = last_record_ts.strftime("%Y-%m-%d")
|
||||
elif self.adapter.__class__.__name__ == "MiFitnessCloudAdapter":
|
||||
start_date = "2020-01-01"
|
||||
else:
|
||||
start = datetime.strptime(end_date, "%Y-%m-%d") - timedelta(days=30)
|
||||
start_date = start.strftime("%Y-%m-%d")
|
||||
start_date = last_record_ts.strftime("%Y-%m-%d") if last_record_ts else "2020-01-01"
|
||||
|
||||
added = 0
|
||||
updated = 0
|
||||
|
||||
@@ -540,10 +540,12 @@ class Database:
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row and row["first_date"]:
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
"data_type": "daily_activity",
|
||||
**dict(row),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Sleep coverage
|
||||
row = conn.execute(
|
||||
@@ -558,10 +560,12 @@ class Database:
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row and row["first_date"]:
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
"data_type": "sleep",
|
||||
**dict(row),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Workouts coverage
|
||||
row = conn.execute(
|
||||
@@ -576,10 +580,12 @@ class Database:
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row and row["first_date"]:
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
"data_type": "workouts",
|
||||
**dict(row),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
# Body measurements coverage
|
||||
row = conn.execute(
|
||||
@@ -594,10 +600,12 @@ class Database:
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row and row["first_date"]:
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
"data_type": "body_measurements",
|
||||
**dict(row),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
row = conn.execute(
|
||||
"""
|
||||
@@ -611,9 +619,11 @@ class Database:
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row and row["first_date"]:
|
||||
results.append({
|
||||
results.append(
|
||||
{
|
||||
"data_type": "heart_rate",
|
||||
**dict(row),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,14 @@
|
||||
from mi_fitness_mcp.config import Config, load_config, save_config
|
||||
|
||||
|
||||
def test_config_roundtrip(tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
monkeypatch.setattr("mi_fitness_mcp.config.get_config_path", lambda: config_file)
|
||||
|
||||
config = Config(mode="mi_fitness_cloud", region="ru")
|
||||
save_config(config)
|
||||
loaded = load_config()
|
||||
|
||||
assert loaded.mode == "mi_fitness_cloud"
|
||||
assert loaded.region == "ru"
|
||||
assert loaded.database_path.name == "mi_fitness.db"
|
||||
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
|
||||
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
||||
|
||||
|
||||
async def _collect(async_iterable):
|
||||
items = []
|
||||
async for item in async_iterable:
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def test_optional_number_helpers():
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
|
||||
assert adapter._optional_float(0) is None
|
||||
assert adapter._optional_float("1.5") == 1.5
|
||||
assert adapter._optional_int(0) is None
|
||||
assert adapter._optional_int("7") == 7
|
||||
|
||||
|
||||
def test_parse_value_dict_and_json():
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
|
||||
assert adapter._parse_value({"value": {"steps": 1}}) == {"steps": 1}
|
||||
assert adapter._parse_value({"value": '{"steps": 2}'}) == {"steps": 2}
|
||||
|
||||
|
||||
def test_record_datetime_uses_zone_offset():
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
|
||||
dt = adapter._record_datetime({"time": 0, "zone_offset": 10800})
|
||||
assert dt.isoformat().startswith("1970-01-01T03:00:00")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iter_daily_activity_aggregates_steps_and_calories(monkeypatch):
|
||||
adapter = MiFitnessCloudAdapter(user_id="u1", pass_token="p1")
|
||||
adapter._connected = True
|
||||
adapter._client = object()
|
||||
|
||||
async def fake_fetch(key, start_date, end_date, region=None):
|
||||
if key == "steps":
|
||||
return [
|
||||
{
|
||||
"time": 1743467400,
|
||||
"zone_offset": 0,
|
||||
"value": '{"steps": 10, "distance": 8, "calories": 1}',
|
||||
},
|
||||
{
|
||||
"time": 1743467460,
|
||||
"zone_offset": 0,
|
||||
"value": '{"steps": 20, "distance": 16, "calories": 2}',
|
||||
},
|
||||
]
|
||||
if key == "calories":
|
||||
return [
|
||||
{"time": 1743467400, "zone_offset": 0, "value": '{"calories": 5}'},
|
||||
{"time": 1743467460, "zone_offset": 0, "value": '{"calories": 7}'},
|
||||
]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(adapter, "_fetch_key", fake_fetch)
|
||||
items = await _collect(adapter.iter_daily_activity("2025-04-01", "2025-04-01"))
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].steps == 30
|
||||
assert items[0].distance_m == 24
|
||||
assert items[0].active_kcal == 12
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
|
||||
from mi_fitness_mcp.models import BodyMeasurement, DailyActivity, HeartRateSample
|
||||
from mi_fitness_mcp.services.query_service import QueryService
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
|
||||
def test_storage_and_query_roundtrip(tmp_path):
|
||||
db = Database(tmp_path / "test.db")
|
||||
|
||||
activity = DailyActivity(
|
||||
id="a1",
|
||||
provider="mi_fitness",
|
||||
source_type="cloud_session",
|
||||
user_id="u1",
|
||||
date="2025-04-01",
|
||||
steps=1000,
|
||||
distance_m=800,
|
||||
active_kcal=50,
|
||||
)
|
||||
hr = HeartRateSample(
|
||||
id="hr1",
|
||||
provider="mi_fitness",
|
||||
source_type="cloud_session",
|
||||
user_id="u1",
|
||||
timestamp=datetime(2025, 4, 1, 12, 0, 0),
|
||||
bpm=70,
|
||||
sample_type="passive",
|
||||
)
|
||||
body = BodyMeasurement(
|
||||
id="w1",
|
||||
provider="mi_fitness",
|
||||
source_type="cloud_session",
|
||||
user_id="u1",
|
||||
timestamp=datetime(2025, 4, 1, 7, 0, 0),
|
||||
weight_kg=101.5,
|
||||
bmi=28.0,
|
||||
)
|
||||
|
||||
db.insert_daily_activity(activity)
|
||||
db.insert_heart_rate_sample(hr)
|
||||
db.insert_body_measurement(body)
|
||||
|
||||
query = QueryService(db, "u1")
|
||||
assert len(query.get_daily_summaries("2025-04-01", "2025-04-01")) == 1
|
||||
assert len(query.get_heart_rate_samples("2025-04-01", "2025-04-01")) == 1
|
||||
assert len(query.get_body_measurements("2025-04-01", "2025-04-01")) == 1
|
||||
Reference in new issue
Block a user