Sort glob results when auto-detecting QEMU binaries to reliably pick the newest version. Previously filesystem ordering could select an older build (8.2.0 instead of 9.0.0), missing ESP32-S3 support. Remove ESP32-S2 from CHIP_MACHINES — the Espressif QEMU fork has no esp32s2 machine type.
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""
|
|
Configuration management for MCP ESPTool Server
|
|
|
|
Handles environment variables, MCP roots detection, and configuration validation.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastmcp import Context
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ESPToolServerConfig:
|
|
"""Configuration for MCP ESPTool Server"""
|
|
|
|
# Core paths and tools
|
|
esptool_path: str = field(default="esptool")
|
|
esp_idf_path: Path | None = field(default=None)
|
|
project_roots: list[Path] = field(default_factory=list)
|
|
|
|
# Communication settings
|
|
default_baud_rate: int = field(default=460800)
|
|
connection_timeout: int = field(default=30)
|
|
enable_stub_flasher: bool = field(default=True)
|
|
|
|
# MCP integration settings
|
|
enable_progress: bool = field(default=True)
|
|
enable_elicitation: bool = field(default=True)
|
|
log_level: str = field(default="INFO")
|
|
|
|
# Performance settings
|
|
max_concurrent_operations: int = field(default=5)
|
|
operation_timeout: int = field(default=300)
|
|
|
|
# Development settings
|
|
dev_enable_hot_reload: bool = field(default=False)
|
|
dev_mock_hardware: bool = field(default=False)
|
|
dev_enable_tracing: bool = field(default=False)
|
|
|
|
# Production settings
|
|
production_mode: bool = field(default=False)
|
|
enable_security_audit: bool = field(default=True)
|
|
require_confirmations: bool = field(default=True)
|
|
|
|
# QEMU emulation settings
|
|
qemu_xtensa_path: str | None = field(default=None)
|
|
qemu_riscv_path: str | None = field(default=None)
|
|
qemu_base_port: int = field(default=5555)
|
|
qemu_max_instances: int = field(default=4)
|
|
|
|
def __post_init__(self):
|
|
"""Post-initialization setup and validation"""
|
|
self._load_environment_variables()
|
|
self._setup_esp_idf_path()
|
|
self._setup_qemu_paths()
|
|
self._setup_project_roots()
|
|
self._validate_configuration()
|
|
self._setup_logging()
|
|
|
|
def _load_environment_variables(self) -> None:
|
|
"""Load configuration from environment variables"""
|
|
self.esptool_path = os.getenv("ESPTOOL_PATH", self.esptool_path)
|
|
self.default_baud_rate = int(
|
|
os.getenv("ESP_DEFAULT_BAUD_RATE", str(self.default_baud_rate))
|
|
)
|
|
self.connection_timeout = int(
|
|
os.getenv("ESP_CONNECTION_TIMEOUT", str(self.connection_timeout))
|
|
)
|
|
self.enable_stub_flasher = (
|
|
os.getenv("ESP_ENABLE_STUB_FLASHER", str(self.enable_stub_flasher)).lower() == "true"
|
|
)
|
|
|
|
self.enable_progress = (
|
|
os.getenv("MCP_ENABLE_PROGRESS", str(self.enable_progress)).lower() == "true"
|
|
)
|
|
self.enable_elicitation = (
|
|
os.getenv("MCP_ENABLE_ELICITATION", str(self.enable_elicitation)).lower() == "true"
|
|
)
|
|
self.log_level = os.getenv("MCP_LOG_LEVEL", self.log_level)
|
|
|
|
self.max_concurrent_operations = int(
|
|
os.getenv("ESP_MAX_CONCURRENT_OPERATIONS", str(self.max_concurrent_operations))
|
|
)
|
|
self.operation_timeout = int(
|
|
os.getenv("ESP_OPERATION_TIMEOUT", str(self.operation_timeout))
|
|
)
|
|
|
|
self.dev_enable_hot_reload = (
|
|
os.getenv("DEV_ENABLE_HOT_RELOAD", str(self.dev_enable_hot_reload)).lower() == "true"
|
|
)
|
|
self.dev_mock_hardware = (
|
|
os.getenv("DEV_MOCK_HARDWARE", str(self.dev_mock_hardware)).lower() == "true"
|
|
)
|
|
self.dev_enable_tracing = (
|
|
os.getenv("DEV_ENABLE_TRACING", str(self.dev_enable_tracing)).lower() == "true"
|
|
)
|
|
|
|
self.production_mode = (
|
|
os.getenv("PRODUCTION_MODE", str(self.production_mode)).lower() == "true"
|
|
)
|
|
self.enable_security_audit = (
|
|
os.getenv("PROD_ENABLE_SECURITY_AUDIT", str(self.enable_security_audit)).lower()
|
|
== "true"
|
|
)
|
|
self.require_confirmations = (
|
|
os.getenv("PROD_REQUIRE_CONFIRMATIONS", str(self.require_confirmations)).lower()
|
|
== "true"
|
|
)
|
|
|
|
# QEMU settings
|
|
self.qemu_xtensa_path = os.getenv("QEMU_XTENSA_PATH", self.qemu_xtensa_path)
|
|
self.qemu_riscv_path = os.getenv("QEMU_RISCV_PATH", self.qemu_riscv_path)
|
|
self.qemu_base_port = int(os.getenv("QEMU_BASE_PORT", str(self.qemu_base_port)))
|
|
self.qemu_max_instances = int(
|
|
os.getenv("QEMU_MAX_INSTANCES", str(self.qemu_max_instances))
|
|
)
|
|
|
|
def _setup_esp_idf_path(self) -> None:
|
|
"""Set up ESP-IDF path from environment or auto-detect"""
|
|
idf_path_env = os.getenv("ESP_IDF_PATH")
|
|
if idf_path_env:
|
|
self.esp_idf_path = Path(idf_path_env)
|
|
else:
|
|
# Try common ESP-IDF locations
|
|
common_paths = [
|
|
Path.home() / "esp" / "esp-idf",
|
|
Path("/opt/esp-idf"),
|
|
Path("/usr/local/esp-idf"),
|
|
]
|
|
|
|
for path in common_paths:
|
|
if path.exists() and (path / "idf.py").exists():
|
|
self.esp_idf_path = path
|
|
logger.info(f"Auto-detected ESP-IDF at: {path}")
|
|
break
|
|
|
|
def _setup_qemu_paths(self) -> None:
|
|
"""Auto-detect Espressif QEMU fork binaries from ~/.espressif/tools/"""
|
|
import glob
|
|
|
|
if not self.qemu_xtensa_path:
|
|
matches = sorted(glob.glob(
|
|
str(Path.home() / ".espressif/tools/qemu-xtensa/*/qemu/bin/qemu-system-xtensa")
|
|
))
|
|
if matches:
|
|
self.qemu_xtensa_path = matches[-1]
|
|
logger.info(f"Auto-detected QEMU Xtensa: {self.qemu_xtensa_path}")
|
|
|
|
if not self.qemu_riscv_path:
|
|
matches = sorted(glob.glob(
|
|
str(Path.home() / ".espressif/tools/qemu-riscv32/*/qemu/bin/qemu-system-riscv32")
|
|
))
|
|
if matches:
|
|
self.qemu_riscv_path = matches[-1]
|
|
logger.info(f"Auto-detected QEMU RISC-V: {self.qemu_riscv_path}")
|
|
|
|
def _setup_project_roots(self) -> None:
|
|
"""Set up project roots from environment or defaults"""
|
|
# Check for explicit project roots
|
|
project_roots_env = os.getenv("MCP_PROJECT_ROOTS")
|
|
if project_roots_env:
|
|
self.project_roots = [Path(p.strip()) for p in project_roots_env.split(":")]
|
|
else:
|
|
# Default project locations
|
|
default_roots = [
|
|
Path.home() / "esp_projects",
|
|
Path.home() / "Arduino",
|
|
Path.home() / "Documents" / "Arduino",
|
|
Path("/workspace/projects"), # Docker environment
|
|
]
|
|
|
|
self.project_roots = [p for p in default_roots if p.exists()]
|
|
|
|
logger.info(f"Project roots: {[str(p) for p in self.project_roots]}")
|
|
|
|
def _validate_configuration(self) -> None:
|
|
"""Validate configuration settings"""
|
|
errors = []
|
|
|
|
# Validate esptool availability
|
|
if not self._check_tool_availability(self.esptool_path):
|
|
errors.append(f"esptool not found at: {self.esptool_path}")
|
|
|
|
# Validate numeric ranges
|
|
if self.default_baud_rate not in [9600, 57600, 115200, 230400, 460800, 921600]:
|
|
logger.warning(f"Unusual baud rate: {self.default_baud_rate}")
|
|
|
|
if self.connection_timeout < 5 or self.connection_timeout > 300:
|
|
errors.append(f"Connection timeout must be between 5-300s: {self.connection_timeout}")
|
|
|
|
if self.max_concurrent_operations < 1 or self.max_concurrent_operations > 20:
|
|
errors.append(
|
|
f"Max concurrent operations must be 1-20: {self.max_concurrent_operations}"
|
|
)
|
|
|
|
if errors:
|
|
raise ValueError(f"Configuration validation failed: {'; '.join(errors)}")
|
|
|
|
def _check_tool_availability(self, tool_path: str) -> bool:
|
|
"""Check if a tool is available in PATH or at specified path"""
|
|
import shutil
|
|
|
|
return shutil.which(tool_path) is not None
|
|
|
|
def _setup_logging(self) -> None:
|
|
"""Set up logging configuration"""
|
|
log_level = getattr(logging, self.log_level.upper(), logging.INFO)
|
|
logging.basicConfig(
|
|
level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
)
|
|
|
|
async def initialize_with_context(self, context: Context) -> bool:
|
|
"""Initialize configuration with MCP context to get roots"""
|
|
try:
|
|
# Try to get roots from MCP context
|
|
mcp_roots = await context.list_roots()
|
|
|
|
if mcp_roots:
|
|
logger.info(f"Found {len(mcp_roots)} MCP roots")
|
|
|
|
# Add MCP roots to project roots, preferring ESP-specific directories
|
|
for root in mcp_roots:
|
|
root_path = Path(root.get("uri", "").replace("file://", ""))
|
|
if root_path.exists():
|
|
self.project_roots.append(root_path)
|
|
|
|
# Look for ESP-specific subdirectories
|
|
esp_subdirs = ["esp_projects", "arduino", "esp32", "esp8266"]
|
|
for subdir in esp_subdirs:
|
|
esp_path = root_path / subdir
|
|
if esp_path.exists():
|
|
self.project_roots.append(esp_path)
|
|
|
|
# Remove duplicates while preserving order
|
|
seen = set()
|
|
self.project_roots = [
|
|
p for p in self.project_roots if not (str(p) in seen or seen.add(str(p)))
|
|
]
|
|
|
|
logger.info(
|
|
f"Updated project roots with MCP context: {len(self.project_roots)} total"
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Could not initialize with MCP context: {e}")
|
|
|
|
return False
|
|
|
|
def get_sketch_directory(self) -> Path:
|
|
"""Get the primary sketch directory"""
|
|
# Prefer MCP roots if available
|
|
for root in self.project_roots:
|
|
if "esp" in str(root).lower() or "arduino" in str(root).lower():
|
|
return root
|
|
|
|
# Fall back to first available root
|
|
if self.project_roots:
|
|
return self.project_roots[0]
|
|
|
|
# Create default if none exist
|
|
default_dir = Path.home() / "esp_projects"
|
|
default_dir.mkdir(exist_ok=True)
|
|
return default_dir
|
|
|
|
def get_idf_available(self) -> bool:
|
|
"""Check if ESP-IDF is available"""
|
|
if not self.esp_idf_path:
|
|
return False
|
|
|
|
return self.esp_idf_path.exists() and (self.esp_idf_path / "idf.py").exists()
|
|
|
|
def get_qemu_available(self) -> bool:
|
|
"""Check if at least one QEMU binary is available"""
|
|
if self.qemu_xtensa_path and Path(self.qemu_xtensa_path).exists():
|
|
return True
|
|
if self.qemu_riscv_path and Path(self.qemu_riscv_path).exists():
|
|
return True
|
|
return False
|
|
|
|
def get_common_ports(self) -> list[str]:
|
|
"""Get list of common ESP device ports for scanning"""
|
|
import platform
|
|
|
|
system = platform.system().lower()
|
|
|
|
if system == "linux":
|
|
return [f"/dev/ttyUSB{i}" for i in range(4)] + [f"/dev/ttyACM{i}" for i in range(4)]
|
|
elif system == "darwin": # macOS
|
|
return [f"/dev/cu.usbserial-{i:04x}" for i in range(16)] + ["/dev/cu.wchusbserial*"]
|
|
elif system == "windows":
|
|
return [f"COM{i}" for i in range(1, 21)]
|
|
else:
|
|
return []
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""Convert configuration to dictionary for serialization"""
|
|
return {
|
|
"esptool_path": self.esptool_path,
|
|
"esp_idf_path": str(self.esp_idf_path) if self.esp_idf_path else None,
|
|
"project_roots": [str(p) for p in self.project_roots],
|
|
"default_baud_rate": self.default_baud_rate,
|
|
"connection_timeout": self.connection_timeout,
|
|
"enable_stub_flasher": self.enable_stub_flasher,
|
|
"max_concurrent_operations": self.max_concurrent_operations,
|
|
"production_mode": self.production_mode,
|
|
"idf_available": self.get_idf_available(),
|
|
"qemu_available": self.get_qemu_available(),
|
|
"qemu_xtensa_path": self.qemu_xtensa_path,
|
|
"qemu_riscv_path": self.qemu_riscv_path,
|
|
}
|
|
|
|
@classmethod
|
|
def from_environment(cls) -> "ESPToolServerConfig":
|
|
"""Create configuration from environment variables"""
|
|
return cls()
|
|
|
|
def __repr__(self) -> str:
|
|
"""String representation of configuration"""
|
|
return (
|
|
f"ESPToolServerConfig("
|
|
f"esptool_path='{self.esptool_path}', "
|
|
f"esp_idf_available={self.get_idf_available()}, "
|
|
f"project_roots={len(self.project_roots)}, "
|
|
f"production_mode={self.production_mode})"
|
|
)
|
|
|
|
|
|
# Global configuration instance
|
|
_config: ESPToolServerConfig | None = None
|
|
|
|
|
|
def get_config() -> ESPToolServerConfig:
|
|
"""Get the global configuration instance"""
|
|
global _config
|
|
if _config is None:
|
|
_config = ESPToolServerConfig.from_environment()
|
|
return _config
|
|
|
|
|
|
def set_config(config: ESPToolServerConfig) -> None:
|
|
"""Set the global configuration instance"""
|
|
global _config
|
|
_config = config
|