Initialize standalone Mi Fitness MCP repository

This commit is contained in:
Aleksej Kubulashvili committed 2026-03-12 10:03:58 +03:00
commit 6771fc841c
19 files changed
+2805

No files matched your search

+619
View File
@@ -0,0 +1,619 @@
"""SQLite storage layer for Mi Fitness MCP."""
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
from mi_fitness_mcp.models import (
BodyMeasurement,
DailyActivity,
HeartRateSample,
SleepSession,
Workout,
)
class Database:
"""SQLite database manager."""
def __init__(self, db_path: Path | str):
"""Initialize database.
Args:
db_path: Path to SQLite database file
"""
self.db_path = Path(db_path)
self._init_db()
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("""
CREATE TABLE IF NOT EXISTS daily_activity (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_type TEXT NOT NULL,
source_record_id TEXT,
user_id TEXT NOT NULL,
device_id TEXT,
timezone TEXT DEFAULT 'UTC',
collected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date TEXT NOT NULL,
steps INTEGER NOT NULL DEFAULT 0,
distance_m REAL NOT NULL DEFAULT 0,
active_kcal REAL NOT NULL DEFAULT 0,
total_kcal REAL,
floors INTEGER,
active_minutes INTEGER,
UNIQUE(user_id, date, device_id)
)
""")
# Sleep sessions table
conn.execute("""
CREATE TABLE IF NOT EXISTS sleep_sessions (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_type TEXT NOT NULL,
source_record_id TEXT,
user_id TEXT NOT NULL,
device_id TEXT,
timezone TEXT DEFAULT 'UTC',
collected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sleep_id TEXT NOT NULL,
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
duration_minutes INTEGER NOT NULL,
time_asleep_minutes INTEGER NOT NULL,
time_awake_minutes INTEGER NOT NULL,
sleep_score INTEGER,
is_nap BOOLEAN DEFAULT FALSE,
stages TEXT, -- JSON array of sleep stages
UNIQUE(user_id, sleep_id)
)
""")
# Workouts table
conn.execute("""
CREATE TABLE IF NOT EXISTS workouts (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_type TEXT NOT NULL,
source_record_id TEXT,
user_id TEXT NOT NULL,
device_id TEXT,
timezone TEXT DEFAULT 'UTC',
collected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
workout_id TEXT NOT NULL,
activity_type TEXT NOT NULL,
start_at TIMESTAMP NOT NULL,
end_at TIMESTAMP NOT NULL,
duration_minutes INTEGER NOT NULL,
distance_m REAL,
calories_kcal REAL,
avg_heart_rate_bpm INTEGER,
max_heart_rate_bpm INTEGER,
avg_pace_sec_per_km REAL,
max_pace_sec_per_km REAL,
total_steps INTEGER,
UNIQUE(user_id, workout_id)
)
""")
# Body measurements table
conn.execute("""
CREATE TABLE IF NOT EXISTS body_measurements (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_type TEXT NOT NULL,
source_record_id TEXT,
user_id TEXT NOT NULL,
device_id TEXT,
timezone TEXT DEFAULT 'UTC',
collected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
timestamp TIMESTAMP NOT NULL,
weight_kg REAL NOT NULL,
bmi REAL,
body_fat_pct REAL,
muscle_mass_kg REAL,
water_pct REAL,
bone_mass_kg REAL,
visceral_fat_score INTEGER,
basal_metabolism_kcal INTEGER,
metabolic_age INTEGER,
UNIQUE(user_id, timestamp, device_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS heart_rate_samples (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
source_type TEXT NOT NULL,
source_record_id TEXT,
user_id TEXT NOT NULL,
device_id TEXT,
timezone TEXT DEFAULT 'UTC',
collected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
timestamp TIMESTAMP NOT NULL,
bpm INTEGER NOT NULL,
sample_type TEXT NOT NULL,
UNIQUE(user_id, timestamp, sample_type)
)
""")
# Sync state table
conn.execute("""
CREATE TABLE IF NOT EXISTS sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data_type TEXT NOT NULL UNIQUE,
last_sync_at TIMESTAMP,
last_record_timestamp TIMESTAMP,
records_count INTEGER DEFAULT 0
)
""")
# Create indexes for better query performance
conn.execute("""
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
ON sleep_sessions(user_id, start_at)
""")
conn.execute("""
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
ON body_measurements(user_id, timestamp)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_heart_rate_user_ts
ON heart_rate_samples(user_id, timestamp)
""")
conn.commit()
@contextmanager
def _get_connection(self):
"""Get database connection with row factory."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def insert_daily_activity(self, activity: DailyActivity) -> bool:
"""Insert or update daily activity record.
Returns:
True if record was inserted, False if updated
"""
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO daily_activity (
id, provider, source_type, source_record_id, user_id, device_id,
timezone, collected_at, date, steps, distance_m, active_kcal,
total_kcal, floors, active_minutes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
steps = excluded.steps,
distance_m = excluded.distance_m,
active_kcal = excluded.active_kcal,
total_kcal = excluded.total_kcal,
floors = excluded.floors,
active_minutes = excluded.active_minutes,
updated_at = CURRENT_TIMESTAMP
""",
(
activity.id,
activity.provider,
activity.source_type,
activity.source_record_id,
activity.user_id,
activity.device_id,
activity.timezone,
activity.collected_at.isoformat() if activity.collected_at else None,
activity.date,
activity.steps,
activity.distance_m,
activity.active_kcal,
activity.total_kcal,
activity.floors,
activity.active_minutes,
),
)
conn.commit()
return cursor.rowcount > 0
def insert_sleep_session(self, sleep: SleepSession) -> bool:
"""Insert or update sleep session record."""
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO sleep_sessions (
id, provider, source_type, source_record_id, user_id, device_id,
timezone, collected_at, sleep_id, start_at, end_at, duration_minutes,
time_asleep_minutes, time_awake_minutes, sleep_score, is_nap, stages
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, sleep_id) DO UPDATE SET
duration_minutes = excluded.duration_minutes,
time_asleep_minutes = excluded.time_asleep_minutes,
time_awake_minutes = excluded.time_awake_minutes,
sleep_score = excluded.sleep_score,
stages = excluded.stages,
updated_at = CURRENT_TIMESTAMP
""",
(
sleep.id,
sleep.provider,
sleep.source_type,
sleep.source_record_id,
sleep.user_id,
sleep.device_id,
sleep.timezone,
sleep.collected_at.isoformat() if sleep.collected_at else None,
sleep.sleep_id,
sleep.start_at.isoformat(),
sleep.end_at.isoformat(),
sleep.duration_minutes,
sleep.time_asleep_minutes,
sleep.time_awake_minutes,
sleep.sleep_score,
sleep.is_nap,
json.dumps([s.model_dump() for s in sleep.stages]),
),
)
conn.commit()
return cursor.rowcount > 0
def insert_workout(self, workout: Workout) -> bool:
"""Insert or update workout record."""
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO workouts (
id, provider, source_type, source_record_id, user_id, device_id,
timezone, collected_at, workout_id, activity_type, start_at, end_at,
duration_minutes, distance_m, calories_kcal, avg_heart_rate_bpm,
max_heart_rate_bpm, avg_pace_sec_per_km, max_pace_sec_per_km, total_steps
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, workout_id) DO UPDATE SET
duration_minutes = excluded.duration_minutes,
distance_m = excluded.distance_m,
calories_kcal = excluded.calories_kcal,
avg_heart_rate_bpm = excluded.avg_heart_rate_bpm,
max_heart_rate_bpm = excluded.max_heart_rate_bpm,
updated_at = CURRENT_TIMESTAMP
""",
(
workout.id,
workout.provider,
workout.source_type,
workout.source_record_id,
workout.user_id,
workout.device_id,
workout.timezone,
workout.collected_at.isoformat() if workout.collected_at else None,
workout.workout_id,
workout.activity_type,
workout.start_at.isoformat(),
workout.end_at.isoformat(),
workout.duration_minutes,
workout.distance_m,
workout.calories_kcal,
workout.avg_heart_rate_bpm,
workout.max_heart_rate_bpm,
workout.avg_pace_sec_per_km,
workout.max_pace_sec_per_km,
workout.total_steps,
),
)
conn.commit()
return cursor.rowcount > 0
def insert_body_measurement(self, measurement: BodyMeasurement) -> bool:
"""Insert or update body measurement record."""
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO body_measurements (
id, provider, source_type, source_record_id, user_id, device_id,
timezone, collected_at, timestamp, weight_kg, bmi, body_fat_pct,
muscle_mass_kg, water_pct, bone_mass_kg, visceral_fat_score,
basal_metabolism_kcal, metabolic_age
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
weight_kg = excluded.weight_kg,
bmi = excluded.bmi,
body_fat_pct = excluded.body_fat_pct,
muscle_mass_kg = excluded.muscle_mass_kg,
water_pct = excluded.water_pct,
updated_at = CURRENT_TIMESTAMP
""",
(
measurement.id,
measurement.provider,
measurement.source_type,
measurement.source_record_id,
measurement.user_id,
measurement.device_id,
measurement.timezone,
measurement.collected_at.isoformat() if measurement.collected_at else None,
measurement.timestamp.isoformat(),
measurement.weight_kg,
measurement.bmi,
measurement.body_fat_pct,
measurement.muscle_mass_kg,
measurement.water_pct,
measurement.bone_mass_kg,
measurement.visceral_fat_score,
measurement.basal_metabolism_kcal,
measurement.metabolic_age,
),
)
conn.commit()
return cursor.rowcount > 0
def insert_heart_rate_sample(self, sample: HeartRateSample) -> bool:
with self._get_connection() as conn:
cursor = conn.execute(
"""
INSERT INTO heart_rate_samples (
id, provider, source_type, source_record_id, user_id, device_id,
timezone, collected_at, timestamp, bpm, sample_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
bpm = excluded.bpm,
updated_at = CURRENT_TIMESTAMP
""",
(
sample.id,
sample.provider,
sample.source_type,
sample.source_record_id,
sample.user_id,
sample.device_id,
sample.timezone,
sample.collected_at.isoformat() if sample.collected_at else None,
sample.timestamp.isoformat(),
sample.bpm,
sample.sample_type,
),
)
conn.commit()
return cursor.rowcount > 0
def update_sync_state(self, data_type: str, last_record_ts: datetime | None = None) -> None:
"""Update sync state for a data type."""
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO sync_state (data_type, last_sync_at, last_record_timestamp)
VALUES (?, CURRENT_TIMESTAMP, ?)
ON CONFLICT(data_type) DO UPDATE SET
last_sync_at = CURRENT_TIMESTAMP,
last_record_timestamp = excluded.last_record_timestamp
""",
(data_type, last_record_ts.isoformat() if last_record_ts else None),
)
conn.commit()
def get_sync_state(self, data_type: str) -> dict[str, Any] | None:
"""Get sync state for a data type."""
with self._get_connection() as conn:
row = conn.execute(
"SELECT * FROM sync_state WHERE data_type = ?",
(data_type,),
).fetchone()
return dict(row) if row else None
def query_daily_activity(
self,
user_id: str,
start_date: str,
end_date: str,
) -> list[dict[str, Any]]:
"""Query daily activity records."""
with self._get_connection() as conn:
rows = conn.execute(
"""
SELECT * FROM daily_activity
WHERE user_id = ? AND date >= ? AND date <= ?
ORDER BY date
""",
(user_id, start_date, end_date),
).fetchall()
return [dict(row) for row in rows]
def query_sleep_sessions(
self,
user_id: str,
start_date: str,
end_date: str,
) -> list[dict[str, Any]]:
"""Query sleep session records."""
with self._get_connection() as conn:
rows = conn.execute(
"""
SELECT * FROM sleep_sessions
WHERE user_id = ?
AND date(start_at) >= ? AND date(start_at) <= ?
ORDER BY start_at
""",
(user_id, start_date, end_date),
).fetchall()
return [dict(row) for row in rows]
def query_workouts(
self,
user_id: str,
start_date: str,
end_date: str,
) -> list[dict[str, Any]]:
"""Query workout records."""
with self._get_connection() as conn:
rows = conn.execute(
"""
SELECT * FROM workouts
WHERE user_id = ?
AND date(start_at) >= ? AND date(start_at) <= ?
ORDER BY start_at
""",
(user_id, start_date, end_date),
).fetchall()
return [dict(row) for row in rows]
def query_body_measurements(
self,
user_id: str,
start_date: str,
end_date: str,
) -> list[dict[str, Any]]:
"""Query body measurement records."""
with self._get_connection() as conn:
rows = conn.execute(
"""
SELECT * FROM body_measurements
WHERE user_id = ?
AND date(timestamp) >= ? AND date(timestamp) <= ?
ORDER BY timestamp
""",
(user_id, start_date, end_date),
).fetchall()
return [dict(row) for row in rows]
def query_heart_rate_samples(
self,
user_id: str,
start_date: str,
end_date: str,
) -> list[dict[str, Any]]:
with self._get_connection() as conn:
rows = conn.execute(
"""
SELECT * FROM heart_rate_samples
WHERE user_id = ?
AND date(timestamp) >= ? AND date(timestamp) <= ?
ORDER BY timestamp
""",
(user_id, start_date, end_date),
).fetchall()
return [dict(row) for row in rows]
def get_data_coverage(self, user_id: str) -> list[dict[str, Any]]:
"""Get data coverage statistics."""
with self._get_connection() as conn:
results = []
# Activity coverage
row = conn.execute(
"""
SELECT
MIN(date) as first_date,
MAX(date) as last_date,
COUNT(DISTINCT date) as days_with_data
FROM daily_activity
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "daily_activity",
**dict(row),
})
# Sleep coverage
row = conn.execute(
"""
SELECT
MIN(date(start_at)) as first_date,
MAX(date(start_at)) as last_date,
COUNT(DISTINCT date(start_at)) as days_with_data
FROM sleep_sessions
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "sleep",
**dict(row),
})
# Workouts coverage
row = conn.execute(
"""
SELECT
MIN(date(start_at)) as first_date,
MAX(date(start_at)) as last_date,
COUNT(DISTINCT date(start_at)) as days_with_data
FROM workouts
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "workouts",
**dict(row),
})
# Body measurements coverage
row = conn.execute(
"""
SELECT
MIN(date(timestamp)) as first_date,
MAX(date(timestamp)) as last_date,
COUNT(DISTINCT date(timestamp)) as days_with_data
FROM body_measurements
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "body_measurements",
**dict(row),
})
row = conn.execute(
"""
SELECT
MIN(date(timestamp)) as first_date,
MAX(date(timestamp)) as last_date,
COUNT(DISTINCT date(timestamp)) as days_with_data
FROM heart_rate_samples
WHERE user_id = ?
""",
(user_id,),
).fetchone()
if row and row["first_date"]:
results.append({
"data_type": "heart_rate",
**dict(row),
})
return results