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