From df914ea56ac0529191e4da412c67044b3552da8f Mon Sep 17 00:00:00 2001 From: Aleksej Kubulashvili Date: Thu, 12 Mar 2026 14:12:49 +0300 Subject: [PATCH] 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. --- .env.example | 3 + .github/workflows/ci.yml | 30 +++ .gitignore | 13 + CHANGELOG.md | 8 + CONTRIBUTING.md | 25 ++ LICENSE | 21 ++ README.md | 109 ++++++++- SECURITY.md | 13 + pyproject.toml | 7 + src/mi_fitness_mcp/adapters/base.py | 17 +- .../adapters/mi_fitness_cloud.py | 107 ++++++--- src/mi_fitness_mcp/auth/__init__.py | 19 +- src/mi_fitness_mcp/config.py | 7 +- src/mi_fitness_mcp/main.py | 17 +- src/mi_fitness_mcp/models/__init__.py | 12 +- src/mi_fitness_mcp/server.py | 193 +++++++++++++-- src/mi_fitness_mcp/services/autosync.py | 223 ------------------ src/mi_fitness_mcp/services/query_service.py | 130 +++++----- src/mi_fitness_mcp/services/sync_service.py | 20 +- src/mi_fitness_mcp/storage/__init__.py | 80 ++++--- tests/test_config.py | 14 ++ tests/test_mi_fitness_cloud.py | 65 +++++ tests/test_storage_query.py | 47 ++++ 23 files changed, 754 insertions(+), 426 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md delete mode 100644 src/mi_fitness_mcp/services/autosync.py create mode 100644 tests/test_config.py create mode 100644 tests/test_mi_fitness_cloud.py create mode 100644 tests/test_storage_query.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..887d7f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +MI_FITNESS_USER_ID=replace_me +MI_FITNESS_PASS_TOKEN=replace_me +MI_FITNESS_REGION=ru diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..846db78 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 8dd5c5f..2243596 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d2f9d18 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7bbacdf --- /dev/null +++ b/CONTRIBUTING.md @@ -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 +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e0f5376 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 60a6439..c4ff8b5 100644 --- a/README.md +++ b/README.md @@ -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 "" --pass-token "" --region ru mi-fitness-mcp doctor +``` + +For local endpoint exploration there is also a probe script: + +```bash +python probe_mifitness.py --user-id "" --pass-token "" +``` + +## 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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..26aa896 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index d3973b9..4a9f112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/mi_fitness_mcp/adapters/base.py b/src/mi_fitness_mcp/adapters/base.py index ef4cbed..267a8fc 100644 --- a/src/mi_fitness_mcp/adapters/base.py +++ b/src/mi_fitness_mcp/adapters/base.py @@ -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, @@ -20,7 +19,7 @@ class DataAdapter(ABC): @abstractmethod def connect(self) -> Union[bool, "Coroutine[Any, Any, bool]"]: """Connect to data source. - + Returns: True if connection successful """ @@ -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 diff --git a/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py b/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py index 8890c26..5e514b5 100644 --- a/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py +++ b/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py @@ -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,38 +142,57 @@ class MiFitnessCloudAdapter(DataAdapter): if not self._client: raise RuntimeError("client not initialized") - form = {"data": json.dumps(payload, separators=(",", ":"))} - nonce = _gen_nonce() - signed_nonce = _gen_signed_nonce(self._ssecurity, nonce) - form["rc4_hash__"] = _gen_signature("POST", api_path, form, signed_nonce) + 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) + form["rc4_hash__"] = _gen_signature("POST", api_path, form, signed_nonce) - encrypted: dict[str, str] = {} - for key, value in form.items(): - encrypted[key] = base64.b64encode(_rc4_crypt(signed_nonce, value.encode())).decode() + encrypted: dict[str, str] = {} + for key, value in form.items(): + 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() + encrypted["signature"] = _gen_signature("POST", api_path, encrypted, signed_nonce) + encrypted["_nonce"] = base64.b64encode(nonce).decode() - response = await self._client.post( - base_url + api_path, - headers={ - "Cookie": self._cookies, - "Content-Type": "application/x-www-form-urlencoded", - }, - content=urlencode(encrypted), - ) - response.raise_for_status() - plaintext = _rc4_crypt(signed_nonce, base64.b64decode(response.text)) - body = json.loads(plaintext) - if body.get("code") != 0: - raise RuntimeError(body.get("message", "unknown mi fitness error")) - return body.get("result", {}) + response = await self._client.post( + base_url + api_path, + headers={ + "Cookie": self._cookies, + "Content-Type": "application/x-www-form-urlencoded", + }, + content=urlencode(encrypted), + ) + response.raise_for_status() + plaintext = _rc4_crypt(signed_nonce, base64.b64decode(response.text)) + body = json.loads(plaintext) + 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 - self._connected = False + await self._close_client() + self._connected = False diff --git a/src/mi_fitness_mcp/auth/__init__.py b/src/mi_fitness_mcp/auth/__init__.py index 399665e..0f30fe0 100644 --- a/src/mi_fitness_mcp/auth/__init__.py +++ b/src/mi_fitness_mcp/auth/__init__.py @@ -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: - keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_user_id", user_id) - keyring.set_password(SERVICE_NAME, f"{ACCOUNT_NAME}_pass_token", pass_token) + 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) diff --git a/src/mi_fitness_mcp/config.py b/src/mi_fitness_mcp/config.py index 190fa90..b24c48f 100644 --- a/src/mi_fitness_mcp/config.py +++ b/src/mi_fitness_mcp/config.py @@ -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): diff --git a/src/mi_fitness_mcp/main.py b/src/mi_fitness_mcp/main.py index 94b9ca6..e1a43db 100644 --- a/src/mi_fitness_mcp/main.py +++ b/src/mi_fitness_mcp/main.py @@ -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)") diff --git a/src/mi_fitness_mcp/models/__init__.py b/src/mi_fitness_mcp/models/__init__.py index f923af3..2345c3c 100644 --- a/src/mi_fitness_mcp/models/__init__.py +++ b/src/mi_fitness_mcp/models/__init__.py @@ -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 diff --git a/src/mi_fitness_mcp/server.py b/src/mi_fitness_mcp/server.py index b0fb8e4..d0ef28e 100644 --- a/src/mi_fitness_mcp/server.py +++ b/src/mi_fitness_mcp/server.py @@ -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: diff --git a/src/mi_fitness_mcp/services/autosync.py b/src/mi_fitness_mcp/services/autosync.py deleted file mode 100644 index 4ad656e..0000000 --- a/src/mi_fitness_mcp/services/autosync.py +++ /dev/null @@ -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() diff --git a/src/mi_fitness_mcp/services/query_service.py b/src/mi_fitness_mcp/services/query_service.py index 564b0a3..0d69189 100644 --- a/src/mi_fitness_mcp/services/query_service.py +++ b/src/mi_fitness_mcp/services/query_service.py @@ -1,5 +1,6 @@ """Query service for retrieving data from database.""" +from contextlib import suppress from datetime import datetime, timedelta from typing import Any @@ -11,7 +12,7 @@ class QueryService: def __init__(self, db: Database, user_id: str): """Initialize query service. - + Args: db: Database instance user_id: User identifier @@ -22,7 +23,7 @@ class QueryService: def get_daily_summaries(self, start_date: str, end_date: str) -> list[dict[str, Any]]: """Get daily activity summaries for date range.""" records = self.db.query_daily_activity(self.user_id, start_date, end_date) - + # Group by date and aggregate summaries = {} for record in records: @@ -37,7 +38,7 @@ class QueryService: "floors": 0, "active_minutes": 0, } - + summaries[date]["steps"] += record.get("steps", 0) summaries[date]["distance_m"] += record.get("distance_m", 0) summaries[date]["active_kcal"] += record.get("active_kcal", 0) @@ -47,7 +48,7 @@ class QueryService: summaries[date]["floors"] += record["floors"] if record.get("active_minutes"): summaries[date]["active_minutes"] += record["active_minutes"] - + return list(summaries.values()) def get_metric_series( @@ -60,22 +61,24 @@ class QueryService: ) -> list[dict[str, Any]]: """Get time series for a metric.""" summaries = self.get_daily_summaries(start_date, end_date) - + series = [] for summary in summaries: value = summary.get(metric) if value is not None: - series.append({ - "date": summary["date"], - "value": value, - }) - + series.append( + { + "date": summary["date"], + "value": value, + } + ) + # Apply aggregation if needed if granularity == "week": series = self._aggregate_by_week(series, aggregation) elif granularity == "month": series = self._aggregate_by_month(series, aggregation) - + return series def _aggregate_by_week( @@ -85,17 +88,17 @@ class QueryService: ) -> list[dict]: """Aggregate daily series by week.""" weeks = {} - + for item in series: date = datetime.strptime(item["date"], "%Y-%m-%d") # Get week start (Monday) week_start = date - timedelta(days=date.weekday()) week_key = week_start.strftime("%Y-%m-%d") - + if week_key not in weeks: weeks[week_key] = [] weeks[week_key].append(item["value"]) - + result = [] for week_key, values in sorted(weeks.items()): if aggregation == "sum": @@ -108,9 +111,9 @@ class QueryService: value = max(values) else: value = sum(values) - + result.append({"date": week_key, "value": value}) - + return result def _aggregate_by_month( @@ -120,15 +123,15 @@ class QueryService: ) -> list[dict]: """Aggregate daily series by month.""" months = {} - + for item in series: date = datetime.strptime(item["date"], "%Y-%m-%d") month_key = date.strftime("%Y-%m") - + if month_key not in months: months[month_key] = [] months[month_key].append(item["value"]) - + result = [] for month_key, values in sorted(months.items()): if aggregation == "sum": @@ -141,9 +144,9 @@ class QueryService: value = max(values) else: value = sum(values) - + result.append({"date": month_key + "-01", "value": value}) - + return result def get_sleep_sessions( @@ -154,13 +157,13 @@ class QueryService: ) -> list[dict[str, Any]]: """Get sleep sessions for date range.""" records = self.db.query_sleep_sessions(self.user_id, start_date, end_date) - + sessions = [] for record in records: # Skip naps if not included if not include_naps and record.get("is_nap"): continue - + session = { "sleep_id": record["sleep_id"], "start_at": record["start_at"], @@ -171,17 +174,16 @@ class QueryService: "sleep_score": record.get("sleep_score"), "is_nap": record.get("is_nap", False), } - + # 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) - + return sessions def get_workouts( @@ -194,37 +196,40 @@ class QueryService: ) -> list[dict[str, Any]]: """Get workouts for date range with optional filters.""" records = self.db.query_workouts(self.user_id, start_date, end_date) - + workouts = [] for record in records: # Apply filters - if activity_types: - if record["activity_type"].lower() not in [t.lower() for t in activity_types]: - continue - + 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: continue - + if min_distance_km: distance_km = (record.get("distance_m") or 0) / 1000 if distance_km < min_distance_km: continue - - workouts.append({ - "workout_id": record["workout_id"], - "activity_type": record["activity_type"], - "start_at": record["start_at"], - "end_at": record["end_at"], - "duration_minutes": record["duration_minutes"], - "distance_m": record.get("distance_m"), - "calories_kcal": record.get("calories_kcal"), - "avg_heart_rate_bpm": record.get("avg_heart_rate_bpm"), - "max_heart_rate_bpm": record.get("max_heart_rate_bpm"), - "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"), - }) - + + workouts.append( + { + "workout_id": record["workout_id"], + "activity_type": record["activity_type"], + "start_at": record["start_at"], + "end_at": record["end_at"], + "duration_minutes": record["duration_minutes"], + "distance_m": record.get("distance_m"), + "calories_kcal": record.get("calories_kcal"), + "avg_heart_rate_bpm": record.get("avg_heart_rate_bpm"), + "max_heart_rate_bpm": record.get("max_heart_rate_bpm"), + "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 def get_body_measurements( @@ -235,25 +240,30 @@ class QueryService: ) -> list[dict[str, Any]]: """Get body measurements for date range.""" records = self.db.query_body_measurements(self.user_id, start_date, end_date) - + measurements = [] for record in records: measurement = { "timestamp": record["timestamp"], "weight_kg": record["weight_kg"], } - + # 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", ] - + for field in optional_fields: if record.get(field) is not None: measurement[field] = record[field] - + # Filter metrics if specified if metrics: filtered = {"timestamp": measurement["timestamp"]} @@ -261,7 +271,7 @@ class QueryService: if metric in measurement: filtered[metric] = measurement[metric] measurement = filtered - + measurements.append(measurement) return measurements @@ -294,8 +304,8 @@ class QueryService: def get_data_coverage(self, data_types: list[str] | None = None) -> list[dict[str, Any]]: """Get data coverage information.""" coverage = self.db.get_data_coverage(self.user_id) - + if data_types: coverage = [c for c in coverage if c["data_type"] in data_types] - + return coverage diff --git a/src/mi_fitness_mcp/services/sync_service.py b/src/mi_fitness_mcp/services/sync_service.py index ed7f31c..355b537 100644 --- a/src/mi_fitness_mcp/services/sync_service.py +++ b/src/mi_fitness_mcp/services/sync_service.py @@ -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 @@ -17,7 +17,7 @@ class SyncService: def __init__(self, adapter: DataAdapter, db: Database): """Initialize sync service. - + Args: adapter: Data source adapter db: Database instance @@ -42,13 +42,13 @@ class SyncService: force_full: bool = False, ) -> dict: """Synchronize a specific data type. - + Args: data_type: Type of data to sync (daily_activity, sleep, workouts, body_measurements) start_date: Start date (YYYY-MM-DD), defaults to 30 days ago end_date: End date (YYYY-MM-DD), defaults to today force_full: Force full sync ignoring last sync state - + Returns: Dict with sync statistics """ @@ -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 @@ -158,7 +152,7 @@ class SyncService: force_full: bool = False, ) -> dict: """Synchronous wrapper for sync_data_type. - + Use this when calling from synchronous code. """ return asyncio.run(self.sync_data_type(data_type, start_date, end_date, force_full)) diff --git a/src/mi_fitness_mcp/storage/__init__.py b/src/mi_fitness_mcp/storage/__init__.py index 2cbcd41..fc89447 100644 --- a/src/mi_fitness_mcp/storage/__init__.py +++ b/src/mi_fitness_mcp/storage/__init__.py @@ -21,7 +21,7 @@ class Database: def __init__(self, db_path: Path | str): """Initialize database. - + Args: db_path: Path to SQLite database file """ @@ -31,7 +31,7 @@ class Database: def _init_db(self) -> None: """Initialize database schema.""" self.db_path.parent.mkdir(parents=True, exist_ok=True) - + with self._get_connection() as conn: # Daily activity table conn.execute(""" @@ -171,23 +171,23 @@ class Database: # Create indexes for better query performance conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_activity_user_date + CREATE INDEX IF NOT EXISTS idx_activity_user_date ON daily_activity(user_id, date) """) conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_sleep_user_start + CREATE INDEX IF NOT EXISTS idx_sleep_user_start ON sleep_sessions(user_id, start_at) """) conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_workouts_user_start + CREATE INDEX IF NOT EXISTS idx_workouts_user_start ON workouts(user_id, start_at) """) conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_measurements_user_ts + CREATE INDEX IF NOT EXISTS idx_measurements_user_ts ON body_measurements(user_id, timestamp) """) conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_heart_rate_user_ts + CREATE INDEX IF NOT EXISTS idx_heart_rate_user_ts ON heart_rate_samples(user_id, timestamp) """) @@ -205,7 +205,7 @@ class Database: def insert_daily_activity(self, activity: DailyActivity) -> bool: """Insert or update daily activity record. - + Returns: True if record was inserted, False if updated """ @@ -458,7 +458,7 @@ class Database: rows = conn.execute( """ SELECT * FROM sleep_sessions - WHERE user_id = ? + WHERE user_id = ? AND date(start_at) >= ? AND date(start_at) <= ? ORDER BY start_at """, @@ -526,11 +526,11 @@ class Database: """Get data coverage statistics.""" with self._get_connection() as conn: results = [] - + # Activity coverage row = conn.execute( """ - SELECT + SELECT MIN(date) as first_date, MAX(date) as last_date, COUNT(DISTINCT date) as days_with_data @@ -540,15 +540,17 @@ class Database: (user_id,), ).fetchone() if row and row["first_date"]: - results.append({ - "data_type": "daily_activity", - **dict(row), - }) + results.append( + { + "data_type": "daily_activity", + **dict(row), + } + ) # Sleep coverage row = conn.execute( """ - SELECT + SELECT MIN(date(start_at)) as first_date, MAX(date(start_at)) as last_date, COUNT(DISTINCT date(start_at)) as days_with_data @@ -558,15 +560,17 @@ class Database: (user_id,), ).fetchone() if row and row["first_date"]: - results.append({ - "data_type": "sleep", - **dict(row), - }) + results.append( + { + "data_type": "sleep", + **dict(row), + } + ) # Workouts coverage row = conn.execute( """ - SELECT + SELECT MIN(date(start_at)) as first_date, MAX(date(start_at)) as last_date, COUNT(DISTINCT date(start_at)) as days_with_data @@ -576,15 +580,17 @@ class Database: (user_id,), ).fetchone() if row and row["first_date"]: - results.append({ - "data_type": "workouts", - **dict(row), - }) + results.append( + { + "data_type": "workouts", + **dict(row), + } + ) # Body measurements coverage row = conn.execute( """ - SELECT + SELECT MIN(date(timestamp)) as first_date, MAX(date(timestamp)) as last_date, COUNT(DISTINCT date(timestamp)) as days_with_data @@ -594,14 +600,16 @@ class Database: (user_id,), ).fetchone() if row and row["first_date"]: - results.append({ - "data_type": "body_measurements", - **dict(row), - }) + results.append( + { + "data_type": "body_measurements", + **dict(row), + } + ) row = conn.execute( """ - SELECT + SELECT MIN(date(timestamp)) as first_date, MAX(date(timestamp)) as last_date, COUNT(DISTINCT date(timestamp)) as days_with_data @@ -611,9 +619,11 @@ class Database: (user_id,), ).fetchone() if row and row["first_date"]: - results.append({ - "data_type": "heart_rate", - **dict(row), - }) + results.append( + { + "data_type": "heart_rate", + **dict(row), + } + ) return results diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d18787a --- /dev/null +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_mi_fitness_cloud.py b/tests/test_mi_fitness_cloud.py new file mode 100644 index 0000000..0d9f2e4 --- /dev/null +++ b/tests/test_mi_fitness_cloud.py @@ -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 diff --git a/tests/test_storage_query.py b/tests/test_storage_query.py new file mode 100644 index 0000000..80bd16b --- /dev/null +++ b/tests/test_storage_query.py @@ -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