mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
659 lines
24 KiB
Python
659 lines
24 KiB
Python
"""MCP server implementation for Mi Fitness."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import TextContent, Tool
|
|
|
|
from mi_fitness_mcp.adapters.mi_fitness_cloud import MiFitnessCloudAdapter
|
|
from mi_fitness_mcp.auth import load_mi_fitness_token
|
|
from mi_fitness_mcp.config import load_config
|
|
from mi_fitness_mcp.models import ConnectionStatus, QueryResponse
|
|
from mi_fitness_mcp.services.query_service import QueryService
|
|
from mi_fitness_mcp.services.sync_service import SyncService
|
|
from mi_fitness_mcp.storage import Database
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = Server("mi-fitness-mcp")
|
|
|
|
config = None
|
|
db = None
|
|
adapter = None
|
|
sync_service = None
|
|
query_service = None
|
|
sync_tasks: dict[str, dict[str, Any]] = {}
|
|
sync_active = False
|
|
MAX_SYNC_TASKS = 100
|
|
|
|
|
|
def _prune_sync_tasks() -> None:
|
|
completed = [
|
|
sync_id
|
|
for sync_id, state in sync_tasks.items()
|
|
if state.get("status") not in {"queued", "running"}
|
|
]
|
|
excess = max(0, len(sync_tasks) - MAX_SYNC_TASKS + 1)
|
|
for sync_id in completed[:excess]:
|
|
sync_tasks.pop(sync_id, 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"},
|
|
"background": {"type": "boolean", "default": False},
|
|
},
|
|
},
|
|
),
|
|
Tool(
|
|
name="get_sync_status",
|
|
description="Get background synchronization status",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"sync_id": {"type": "string"}},
|
|
"required": ["sync_id"],
|
|
},
|
|
),
|
|
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="query_sleep",
|
|
description="Query sleep sessions",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"include_naps": {"type": "boolean"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_workouts",
|
|
description="Query workouts",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"activity_types": {"type": "array", "items": {"type": "string"}},
|
|
"min_duration": {"type": "integer"},
|
|
"min_distance_km": {"type": "number"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_spo2",
|
|
description="Query blood oxygen saturation samples",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_stress",
|
|
description="Query stress samples",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"level": {"type": "string", "enum": ["low", "medium", "high"]},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["start_date", "end_date"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="query_abnormal_heart_beat",
|
|
description="Query abnormal heart beat events",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"start_date": {"type": "string"},
|
|
"end_date": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"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"}}},
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@app.call_tool()
|
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
try:
|
|
if name == "get_connection_status":
|
|
result = await _handle_get_connection_status()
|
|
elif name == "sync_data":
|
|
result = await _handle_sync_data(arguments)
|
|
elif name == "get_sync_status":
|
|
result = _handle_get_sync_status(arguments)
|
|
elif name == "get_profile":
|
|
result = await _handle_get_profile()
|
|
elif name == "get_daily_summary":
|
|
result = await _handle_get_daily_summary(arguments)
|
|
elif name == "query_metric_series":
|
|
result = await _handle_query_metric_series(arguments)
|
|
elif name == "query_heart_rate":
|
|
result = await _handle_query_heart_rate(arguments)
|
|
elif name == "query_body_measurements":
|
|
result = await _handle_query_body_measurements(arguments)
|
|
elif name == "query_sleep":
|
|
result = await _handle_query_sleep(arguments)
|
|
elif name == "query_workouts":
|
|
result = await _handle_query_workouts(arguments)
|
|
elif name == "query_spo2":
|
|
result = await _handle_query_spo2(arguments)
|
|
elif name == "query_stress":
|
|
result = await _handle_query_stress(arguments)
|
|
elif name == "query_abnormal_heart_beat":
|
|
result = await _handle_query_abnormal_heart_beat(arguments)
|
|
elif name == "get_data_coverage":
|
|
result = await _handle_get_data_coverage(arguments)
|
|
else:
|
|
result = {"status": "error", "error": f"Unknown tool: {name}"}
|
|
return [TextContent(type="text", text=json.dumps(result, default=str))]
|
|
except Exception as e:
|
|
logger.exception("Mi Fitness tool error")
|
|
return [TextContent(type="text", text=json.dumps({"status": "error", "error": str(e)}))]
|
|
|
|
|
|
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()
|
|
|
|
connected = False
|
|
if adapter is not None:
|
|
if sync_service and sync_service.sync_in_progress:
|
|
connected = adapter.is_connected()
|
|
else:
|
|
try:
|
|
connected = await asyncio.wait_for(
|
|
adapter.health_check(), timeout=config.health_check_timeout_seconds
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Connection health check failed: %s", exc)
|
|
connected = False
|
|
last_sync = None
|
|
available_types = []
|
|
if db:
|
|
for data_type in [
|
|
"daily_activity",
|
|
"heart_rate",
|
|
"body_measurements",
|
|
"sleep",
|
|
"workouts",
|
|
"spo2",
|
|
"stress",
|
|
"abnormal_heart_beat",
|
|
]:
|
|
owner_scope = {"user_id": adapter.user_id} if getattr(adapter, "user_id", None) else {}
|
|
state = db.get_sync_state(data_type, **owner_scope)
|
|
if state and state.get("last_sync_at"):
|
|
available_types.append(data_type)
|
|
sync_time = datetime.fromisoformat(state["last_sync_at"])
|
|
if last_sync is None or sync_time > last_sync:
|
|
last_sync = sync_time
|
|
result = ConnectionStatus(
|
|
mode=config.mode,
|
|
connected=connected,
|
|
last_sync_at=last_sync,
|
|
available_data_types=(adapter.get_available_data_types() if connected else available_types),
|
|
message=getattr(adapter, "last_error", None) if not connected else None,
|
|
).model_dump()
|
|
result.update(
|
|
{
|
|
"connection_state": "connected" if connected else "disconnected",
|
|
"region": config.region,
|
|
"last_health_check_at": getattr(adapter, "last_health_check_at", None),
|
|
"last_connection_error": getattr(adapter, "last_error", None),
|
|
"sync_in_progress": bool(sync_service and sync_service.sync_in_progress),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
async def _background_sync(sync_id: str, arguments: dict) -> None:
|
|
global sync_active
|
|
try:
|
|
sync_tasks[sync_id].update(
|
|
status="running", started_at=datetime.now(UTC).isoformat()
|
|
)
|
|
sync_tasks[sync_id] = await _run_sync_data(arguments, sync_id)
|
|
except asyncio.CancelledError:
|
|
sync_tasks[sync_id] = {"sync_id": sync_id, "status": "cancelled"}
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Background synchronization failed")
|
|
sync_tasks[sync_id] = {
|
|
"sync_id": sync_id,
|
|
"status": "error",
|
|
"error_code": type(exc).__name__,
|
|
"error": str(exc),
|
|
}
|
|
finally:
|
|
sync_active = False
|
|
|
|
|
|
async def _handle_sync_data(arguments: dict) -> dict:
|
|
global sync_active
|
|
# No await between check and assignment: foreground/background reservation is atomic.
|
|
if sync_active:
|
|
return {"status": "error", "error": "Another synchronization is in progress"}
|
|
sync_active = True
|
|
|
|
if arguments.get("background"):
|
|
try:
|
|
_prune_sync_tasks()
|
|
sync_id = str(uuid.uuid4())
|
|
sync_tasks[sync_id] = {
|
|
"sync_id": sync_id,
|
|
"status": "queued",
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
task = asyncio.create_task(
|
|
_background_sync(sync_id, {**arguments, "background": False})
|
|
)
|
|
sync_tasks[sync_id]["task"] = task
|
|
return {"status": "accepted", "sync_id": sync_id}
|
|
except Exception:
|
|
sync_active = False
|
|
raise
|
|
|
|
try:
|
|
return await _run_sync_data(arguments)
|
|
finally:
|
|
sync_active = False
|
|
|
|
|
|
def _handle_get_sync_status(arguments: dict) -> dict:
|
|
state = sync_tasks.get(arguments.get("sync_id"))
|
|
if state is None:
|
|
return {"status": "error", "error": "Unknown sync_id"}
|
|
return {key: value for key, value in state.items() if key != "task"}
|
|
|
|
|
|
async def _run_sync_data(arguments: dict, sync_id: str | None = None) -> dict:
|
|
|
|
if not sync_service:
|
|
return {"status": "error", "error": "Sync service not initialized"}
|
|
if (not adapter or not adapter.is_connected()) and (not adapter or not await adapter.connect()):
|
|
return {"status": "error", "error": getattr(adapter, "last_error", "Not connected")}
|
|
data_types_arg = arguments.get("data_types")
|
|
if data_types_arg == []:
|
|
return {"status": "error", "error": "data_types must not be empty"}
|
|
data_types = data_types_arg or sync_service.adapter.get_available_data_types()
|
|
supported = set(sync_service.adapter.get_available_data_types())
|
|
unknown = sorted(set(data_types) - supported)
|
|
if unknown:
|
|
return {"status": "error", "error": f"Unsupported data types: {', '.join(unknown)}"}
|
|
sync_id = sync_id or str(uuid.uuid4())
|
|
started_at = datetime.now(UTC)
|
|
totals = {"added": 0, "updated": 0, "skipped": 0}
|
|
details = []
|
|
for data_type in data_types:
|
|
type_started = datetime.now(UTC)
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
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),
|
|
),
|
|
timeout=config.sync_type_timeout_seconds,
|
|
)
|
|
result_status = result.get("status", "ok")
|
|
for key in totals:
|
|
totals[key] += result.get(key, 0)
|
|
details.append(
|
|
{
|
|
"data_type": data_type,
|
|
"status": result_status,
|
|
**result,
|
|
"duration_seconds": (datetime.now(UTC) - type_started).total_seconds(),
|
|
}
|
|
)
|
|
except TimeoutError:
|
|
details.append(
|
|
{
|
|
"data_type": data_type,
|
|
"status": "error",
|
|
"error_code": "timeout",
|
|
"error": "Data type synchronization timed out",
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
logger.exception("Failed to sync %s", data_type)
|
|
details.append(
|
|
{
|
|
"data_type": data_type,
|
|
"status": "error",
|
|
"error_code": type(exc).__name__,
|
|
"error": str(exc),
|
|
}
|
|
)
|
|
succeeded = [item["data_type"] for item in details if item["status"] == "ok"]
|
|
has_partial = any(item["status"] == "partial" for item in details)
|
|
status = (
|
|
"ok"
|
|
if len(succeeded) == len(details)
|
|
else "partial"
|
|
if succeeded or has_partial
|
|
else "error"
|
|
)
|
|
return {
|
|
"status": status,
|
|
"sync_id": sync_id,
|
|
"started_at": started_at.isoformat(),
|
|
"finished_at": datetime.now(UTC).isoformat(),
|
|
"records_added": totals["added"],
|
|
"records_updated": totals["updated"],
|
|
"records_skipped": totals["skipped"],
|
|
"data_types_synced": succeeded,
|
|
"results": details,
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
async def _handle_get_daily_summary(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
start_date = arguments.get("date") or arguments.get("start_date")
|
|
end_date = arguments.get("date") or arguments.get("end_date")
|
|
if not start_date or not end_date:
|
|
return {"status": "error", "error": "date or start_date/end_date required"}
|
|
summaries = query_service.get_daily_summaries(start_date, end_date)
|
|
return QueryResponse(status="ok", source="cache", data={"summaries": summaries}).model_dump()
|
|
|
|
|
|
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()
|
|
|
|
|
|
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()
|
|
|
|
|
|
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"),
|
|
)
|
|
if arguments.get("latest_only") and measurements:
|
|
measurements = [measurements[-1]]
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"measurements": measurements, "count": len(measurements)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_sleep(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
sessions = query_service.get_sleep_sessions(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
include_naps=arguments.get("include_naps", True),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"sessions": sessions, "count": len(sessions)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_workouts(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
workouts = query_service.get_workouts(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
activity_types=arguments.get("activity_types"),
|
|
min_duration=arguments.get("min_duration"),
|
|
min_distance_km=arguments.get("min_distance_km"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"workouts": workouts, "count": len(workouts)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_spo2(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
samples = query_service.get_spo2_samples(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"samples": samples, "count": len(samples)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_stress(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
samples = query_service.get_stress_samples(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
level=arguments.get("level"),
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"samples": samples, "count": len(samples)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_query_abnormal_heart_beat(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
events = query_service.get_abnormal_heart_beat_events(
|
|
start_date=arguments["start_date"],
|
|
end_date=arguments["end_date"],
|
|
limit=arguments.get("limit"),
|
|
)
|
|
return QueryResponse(
|
|
status="ok", source="cache", data={"events": events, "count": len(events)}
|
|
).model_dump()
|
|
|
|
|
|
async def _handle_get_data_coverage(arguments: dict) -> dict:
|
|
if not query_service:
|
|
return {"status": "error", "error": "Query service not initialized"}
|
|
coverage = query_service.get_data_coverage(arguments.get("data_types"))
|
|
return QueryResponse(status="ok", source="cache", data={"coverage": coverage}).model_dump()
|
|
|
|
|
|
async def main():
|
|
global config, db, adapter, sync_service, query_service
|
|
config = load_config()
|
|
db = Database(config.database_path)
|
|
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.http_timeout = config.http_timeout_seconds
|
|
adapter.request_retries = config.request_retries
|
|
adapter.max_pages = config.max_pages
|
|
# Do not connect here: MCP stdio must become available even when Xiaomi
|
|
# authentication or networking is slow. Status/sync tools connect on demand.
|
|
if adapter:
|
|
sync_service = SyncService(
|
|
adapter, db, config.default_lookback_days, config.sync_chunk_days
|
|
)
|
|
query_service = QueryService(db, adapter.get_user_id() or "unknown")
|
|
else:
|
|
query_service = QueryService(db, "unknown")
|
|
try:
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
finally:
|
|
pending = [
|
|
state.get("task") for state in sync_tasks.values() if state.get("task") is not None
|
|
]
|
|
for task in pending:
|
|
if not task.done():
|
|
task.cancel()
|
|
if pending:
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
if adapter:
|
|
await adapter.close()
|