Refactor chip_control to subprocess pattern, remove middleware layer
- Rewrite chip_control.py to use esptool CLI subprocess exclusively - Delete middleware/ directory (~830 lines): esptool_middleware, logger_interceptor, middleware_factory - Remove `import esptool` library dependency (keep as CLI tool only) - Remove mypy override for esptool module - Add address parameter to esp_flash_firmware to prevent bootloader overwrites
This commit is contained in:
parent
9d232305c6
commit
78dc7e1279
@ -32,7 +32,6 @@ classifiers = [
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastmcp>=2.12.4", # FastMCP framework
|
"fastmcp>=2.12.4", # FastMCP framework
|
||||||
"esptool>=5.0.0", # ESPTool Python API
|
|
||||||
"pyserial>=3.5", # Serial communication
|
"pyserial>=3.5", # Serial communication
|
||||||
"pyserial-asyncio>=0.6", # Async serial support
|
"pyserial-asyncio>=0.6", # Async serial support
|
||||||
"thefuzz[speedup]>=0.22.1", # Fuzzy string matching
|
"thefuzz[speedup]>=0.22.1", # Fuzzy string matching
|
||||||
@ -105,9 +104,6 @@ disallow_incomplete_defs = true
|
|||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
strict_optional = true
|
strict_optional = true
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
|
||||||
module = "esptool.*"
|
|
||||||
ignore_missing_imports = true
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@ -1,72 +1,32 @@
|
|||||||
"""
|
"""
|
||||||
Chip Control Component
|
Chip Control Component
|
||||||
|
|
||||||
Provides comprehensive ESP32/ESP8266 chip detection, connection management,
|
Provides ESP32/ESP8266 chip detection, connection verification,
|
||||||
and basic control operations with production-grade reliability features.
|
and basic control operations using esptool CLI subprocesses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from typing import Any
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, TypeVar
|
|
||||||
|
|
||||||
import esptool
|
|
||||||
from fastmcp import Context, FastMCP
|
from fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from ..config import ESPToolServerConfig
|
from ..config import ESPToolServerConfig
|
||||||
from ..middleware import MiddlewareFactory
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Type variable for generic return type
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ChipInfo:
|
|
||||||
"""Information about detected ESP chip"""
|
|
||||||
|
|
||||||
chip_type: str
|
|
||||||
chip_revision: str | None = None
|
|
||||||
mac_address: str | None = None
|
|
||||||
flash_size: str | None = None
|
|
||||||
crystal_frequency: str | None = None
|
|
||||||
features: list[str] = None
|
|
||||||
efuse_info: dict[str, Any] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.features is None:
|
|
||||||
self.features = []
|
|
||||||
if self.efuse_info is None:
|
|
||||||
self.efuse_info = {}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConnectionInfo:
|
|
||||||
"""Information about ESP device connection"""
|
|
||||||
|
|
||||||
port: str
|
|
||||||
baud_rate: int
|
|
||||||
connected: bool = False
|
|
||||||
connection_time: float | None = None
|
|
||||||
stub_loaded: bool = False
|
|
||||||
chip_info: ChipInfo | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ChipControl:
|
class ChipControl:
|
||||||
"""ESP32/ESP8266 chip control and management"""
|
"""ESP32/ESP8266 chip control and management via esptool subprocess"""
|
||||||
|
|
||||||
def __init__(self, app: FastMCP, config: ESPToolServerConfig):
|
def __init__(self, app: FastMCP, config: ESPToolServerConfig):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.config = config
|
self.config = config
|
||||||
self.connections: dict[str, ConnectionInfo] = {}
|
|
||||||
# Set by server after QemuManager initialization (avoids circular import)
|
# Set by server after QemuManager initialization (avoids circular import)
|
||||||
self.qemu_manager = None
|
self.qemu_manager = None
|
||||||
|
|
||||||
# Register tools
|
|
||||||
self._register_tools()
|
self._register_tools()
|
||||||
|
|
||||||
def _register_tools(self) -> None:
|
def _register_tools(self) -> None:
|
||||||
@ -148,22 +108,116 @@ class ChipControl:
|
|||||||
"""
|
"""
|
||||||
return await self._load_test_firmware_impl(context, port, firmware_type)
|
return await self._load_test_firmware_impl(context, port, firmware_type)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Subprocess runner
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run_esptool(
|
||||||
|
self,
|
||||||
|
port: str,
|
||||||
|
command: str,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
connect_attempts: int = 3,
|
||||||
|
extra_args: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Run an esptool command as a fully async subprocess.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: Serial port or socket:// URI
|
||||||
|
command: esptool command (e.g. "chip-id", "flash-id")
|
||||||
|
timeout: Timeout in seconds
|
||||||
|
connect_attempts: Number of connection attempts
|
||||||
|
extra_args: Additional CLI flags inserted before the command
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with "success", "output", and optionally "error"
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
self.config.esptool_path,
|
||||||
|
"--port", port,
|
||||||
|
"--connect-attempts", str(connect_attempts),
|
||||||
|
]
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
cmd.append(command)
|
||||||
|
|
||||||
|
proc = None
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
output = (stdout or b"").decode() + (stderr or b"").decode()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return {"success": False, "error": output.strip()[:200]}
|
||||||
|
return {"success": True, "output": output}
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if proc and proc.returncode is None:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return {"success": False, "error": f"Timeout ({timeout}s)"}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"success": False, "error": f"esptool not found at {self.config.esptool_path}"}
|
||||||
|
except Exception as e:
|
||||||
|
if proc and proc.returncode is None:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Output parsing helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_chip_output(output: str) -> dict[str, Any]:
|
||||||
|
"""Extract chip info fields from esptool chip-id / flash-id output."""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
chip_match = re.search(r"Chip type:\s*(.+?)(?:\n|$)", output)
|
||||||
|
if not chip_match:
|
||||||
|
chip_match = re.search(r"Chip is\s+(.+?)(?:\n|$)", output)
|
||||||
|
if not chip_match:
|
||||||
|
chip_match = re.search(r"Detecting chip type[.…]+\s*(\S+)", output)
|
||||||
|
if chip_match:
|
||||||
|
result["chip_type"] = chip_match.group(1).strip()
|
||||||
|
|
||||||
|
mac_match = re.search(r"MAC:\s*([0-9a-f:]+)", output, re.IGNORECASE)
|
||||||
|
if mac_match:
|
||||||
|
result["mac_address"] = mac_match.group(1)
|
||||||
|
|
||||||
|
features_match = re.search(r"Features:\s*(.+?)(?:\n|$)", output)
|
||||||
|
if features_match:
|
||||||
|
result["features"] = [f.strip() for f in features_match.group(1).split(",")]
|
||||||
|
|
||||||
|
crystal_match = re.search(r"Crystal\s+(?:frequency:\s*|is\s+)(\d+)\s*MHz", output)
|
||||||
|
if crystal_match:
|
||||||
|
result["crystal_freq"] = f"{crystal_match.group(1)}MHz"
|
||||||
|
|
||||||
|
flash_size_match = re.search(r"Detected flash size:\s*(\S+)", output)
|
||||||
|
if flash_size_match:
|
||||||
|
result["flash_size"] = flash_size_match.group(1)
|
||||||
|
|
||||||
|
flash_mfr_match = re.search(r"Manufacturer:\s*(\S+)", output)
|
||||||
|
if flash_mfr_match:
|
||||||
|
result["flash_manufacturer"] = flash_mfr_match.group(1)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Tool implementations
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _detect_chip_impl(
|
async def _detect_chip_impl(
|
||||||
self, context: Context, port: str | None, baud_rate: int | None, detailed: bool
|
self, context: Context, port: str | None, baud_rate: int | None, detailed: bool
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Implementation of chip detection"""
|
"""Detect chip type via esptool chip-id subprocess."""
|
||||||
|
|
||||||
# Use middleware for operation tracking
|
|
||||||
middleware = MiddlewareFactory.create_esptool_middleware(
|
|
||||||
context, f"detect_chip_{int(time.time())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with middleware.activate():
|
|
||||||
try:
|
|
||||||
# Auto-detect port if not specified
|
|
||||||
if not port:
|
if not port:
|
||||||
await middleware._log_info("🔍 Auto-detecting ESP device port...")
|
port = await self._auto_detect_port()
|
||||||
port = await self._auto_detect_port(context)
|
|
||||||
if not port:
|
if not port:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -171,78 +225,52 @@ class ChipControl:
|
|||||||
"scanned_ports": self.config.get_common_ports(),
|
"scanned_ports": self.config.get_common_ports(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use provided baud rate or config default
|
|
||||||
baud_rate = baud_rate or self.config.default_baud_rate
|
baud_rate = baud_rate or self.config.default_baud_rate
|
||||||
|
|
||||||
await middleware._log_info(
|
|
||||||
f"🔌 Connecting to ESP device on {port} at {baud_rate} baud..."
|
|
||||||
)
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
info = await self._run_esptool(
|
||||||
# Use subprocess for reliable timeout (threads can't be killed)
|
port, "chip-id",
|
||||||
probe_result = await self._probe_port_subprocess(port, detailed)
|
extra_args=["--baud", str(baud_rate)],
|
||||||
|
connect_attempts=1,
|
||||||
if not probe_result.get("available"):
|
|
||||||
await middleware._log_error(
|
|
||||||
f"Chip detection failed: {probe_result.get('error', 'Unknown')}"
|
|
||||||
)
|
)
|
||||||
return {
|
if not info["success"]:
|
||||||
"success": False,
|
return {"success": False, "error": info["error"], "port": port, "baud_rate": baud_rate}
|
||||||
"error": probe_result.get("error", "Detection failed"),
|
|
||||||
"port": port,
|
parsed = self._parse_chip_output(info["output"])
|
||||||
"baud_rate": baud_rate,
|
|
||||||
}
|
# Optionally fetch flash details
|
||||||
|
if detailed:
|
||||||
|
flash_info = await self._run_esptool(
|
||||||
|
port, "flash-id",
|
||||||
|
extra_args=["--baud", str(baud_rate)],
|
||||||
|
)
|
||||||
|
if flash_info["success"]:
|
||||||
|
parsed.update(self._parse_chip_output(flash_info["output"]))
|
||||||
|
|
||||||
connection_time = time.time() - start_time
|
connection_time = time.time() - start_time
|
||||||
|
|
||||||
# Create ChipInfo from probe result
|
chip_data = (
|
||||||
chip_info = ChipInfo(
|
{
|
||||||
chip_type=probe_result.get("chip_type", "Unknown"),
|
"chip_type": parsed.get("chip_type", "Unknown"),
|
||||||
mac_address=probe_result.get("mac_address"),
|
"mac_address": parsed.get("mac_address"),
|
||||||
flash_size=probe_result.get("flash_size"),
|
"flash_size": parsed.get("flash_size"),
|
||||||
crystal_frequency=probe_result.get("crystal_freq"),
|
"crystal_frequency": parsed.get("crystal_freq"),
|
||||||
features=probe_result.get("features"),
|
"features": parsed.get("features"),
|
||||||
|
}
|
||||||
|
if detailed
|
||||||
|
else {
|
||||||
|
"chip_type": parsed.get("chip_type", "Unknown"),
|
||||||
|
"mac_address": parsed.get("mac_address"),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store connection info
|
|
||||||
self.connections[port] = ConnectionInfo(
|
|
||||||
port=port,
|
|
||||||
baud_rate=baud_rate,
|
|
||||||
connected=True,
|
|
||||||
connection_time=connection_time,
|
|
||||||
chip_info=chip_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
await middleware._log_success(f"Successfully detected {chip_info.chip_type}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"port": port,
|
"port": port,
|
||||||
"baud_rate": baud_rate,
|
"baud_rate": baud_rate,
|
||||||
"connection_time_seconds": round(connection_time, 2),
|
"connection_time_seconds": round(connection_time, 2),
|
||||||
"chip_info": {
|
"chip_info": chip_data,
|
||||||
"chip_type": chip_info.chip_type,
|
|
||||||
"mac_address": chip_info.mac_address,
|
|
||||||
"flash_size": chip_info.flash_size,
|
|
||||||
"crystal_frequency": chip_info.crystal_frequency,
|
|
||||||
"features": chip_info.features,
|
|
||||||
}
|
}
|
||||||
if detailed
|
|
||||||
else {
|
|
||||||
"chip_type": chip_info.chip_type,
|
|
||||||
"mac_address": chip_info.mac_address,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await middleware._log_error(f"Chip detection failed: {e}")
|
|
||||||
return {"success": False, "error": str(e), "port": port, "baud_rate": baud_rate}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await middleware._log_error(f"Detection operation failed: {e}")
|
|
||||||
return {"success": False, "error": f"Operation failed: {e}"}
|
|
||||||
|
|
||||||
async def _connect_advanced_impl(
|
async def _connect_advanced_impl(
|
||||||
self,
|
self,
|
||||||
@ -253,158 +281,76 @@ class ChipControl:
|
|||||||
use_stub: bool,
|
use_stub: bool,
|
||||||
retry_count: int,
|
retry_count: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Implementation of advanced connection"""
|
"""Verify device connectivity with retries via esptool chip-id subprocess."""
|
||||||
|
|
||||||
middleware = MiddlewareFactory.create_esptool_middleware(
|
|
||||||
context, f"connect_advanced_{int(time.time())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with middleware.activate():
|
|
||||||
# Auto-detect port if needed
|
|
||||||
if not port:
|
if not port:
|
||||||
port = await self._auto_detect_port(context)
|
port = await self._auto_detect_port()
|
||||||
if not port:
|
if not port:
|
||||||
return {"success": False, "error": "No ESP devices found"}
|
return {"success": False, "error": "No ESP devices found"}
|
||||||
|
|
||||||
baud_rate = baud_rate or self.config.default_baud_rate
|
baud_rate = baud_rate or self.config.default_baud_rate
|
||||||
connection_timeout = float(timeout or self.config.connection_timeout)
|
connection_timeout = float(timeout or self.config.connection_timeout)
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
||||||
for attempt in range(retry_count):
|
for attempt in range(retry_count):
|
||||||
await middleware._log_info(f"🔄 Connection attempt {attempt + 1}/{retry_count}")
|
logger.info("Connection attempt %d/%d on %s", attempt + 1, retry_count, port)
|
||||||
|
|
||||||
# Capture variables for closure
|
info = await self._run_esptool(
|
||||||
target_port = port
|
port, "chip-id",
|
||||||
target_baud = baud_rate
|
timeout=connection_timeout,
|
||||||
load_stub = use_stub and self.config.enable_stub_flasher
|
connect_attempts=1,
|
||||||
|
extra_args=["--baud", str(baud_rate)],
|
||||||
def connect_blocking() -> dict[str, Any]:
|
|
||||||
"""Blocking function to connect and get chip info"""
|
|
||||||
esp = self._connect_to_chip(target_port, target_baud)
|
|
||||||
|
|
||||||
# Load stub if requested
|
|
||||||
stub_loaded = False
|
|
||||||
if load_stub:
|
|
||||||
esp.run_stub()
|
|
||||||
stub_loaded = True
|
|
||||||
|
|
||||||
# Test connection
|
|
||||||
chip_type = esp.get_chip_description()
|
|
||||||
mac_address = ":".join(f"{b:02x}" for b in esp.read_mac())
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chip_type": chip_type,
|
|
||||||
"mac_address": mac_address,
|
|
||||||
"stub_loaded": stub_loaded,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self._run_blocking_with_timeout(
|
|
||||||
connect_blocking, timeout=connection_timeout
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store successful connection
|
if info["success"]:
|
||||||
self.connections[port] = ConnectionInfo(
|
parsed = self._parse_chip_output(info["output"])
|
||||||
port=port,
|
|
||||||
baud_rate=baud_rate,
|
|
||||||
connected=True,
|
|
||||||
connection_time=time.time(),
|
|
||||||
stub_loaded=result["stub_loaded"],
|
|
||||||
chip_info=ChipInfo(
|
|
||||||
chip_type=result["chip_type"],
|
|
||||||
mac_address=result["mac_address"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await middleware._log_success(f"Connected to {result['chip_type']} on {port}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"port": port,
|
"port": port,
|
||||||
"baud_rate": baud_rate,
|
"baud_rate": baud_rate,
|
||||||
"attempt": attempt + 1,
|
"attempt": attempt + 1,
|
||||||
"stub_loaded": result["stub_loaded"],
|
"stub_loaded": use_stub, # CLI loads stubs automatically
|
||||||
"chip_type": result["chip_type"],
|
"chip_type": parsed.get("chip_type", "Unknown"),
|
||||||
"mac_address": result["mac_address"],
|
"mac_address": parsed.get("mac_address"),
|
||||||
}
|
}
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
last_error = info["error"]
|
||||||
last_error = f"Connection timeout ({connection_timeout}s)"
|
logger.warning("Attempt %d failed: %s", attempt + 1, last_error)
|
||||||
await middleware._log_warning(f"Attempt {attempt + 1} timed out")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
last_error = str(e)
|
|
||||||
await middleware._log_warning(f"Attempt {attempt + 1} failed: {e}")
|
|
||||||
|
|
||||||
if attempt < retry_count - 1:
|
if attempt < retry_count - 1:
|
||||||
await asyncio.sleep(1) # Brief delay between attempts
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await middleware._log_error(f"All connection attempts failed. Last error: {last_error}")
|
|
||||||
|
|
||||||
return {"success": False, "error": last_error, "attempts": retry_count, "port": port}
|
return {"success": False, "error": last_error, "attempts": retry_count, "port": port}
|
||||||
|
|
||||||
async def _reset_chip_impl(
|
async def _reset_chip_impl(
|
||||||
self, context: Context, port: str | None, reset_type: str
|
self, context: Context, port: str | None, reset_type: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Implementation of chip reset"""
|
"""Reset chip via esptool --after flag."""
|
||||||
|
|
||||||
middleware = MiddlewareFactory.create_esptool_middleware(
|
|
||||||
context, f"reset_chip_{int(time.time())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with middleware.activate():
|
|
||||||
try:
|
|
||||||
# Find active connection or specified port
|
|
||||||
if not port:
|
if not port:
|
||||||
active_connections = [
|
port = await self._auto_detect_port()
|
||||||
conn for conn in self.connections.values() if conn.connected
|
if not port:
|
||||||
]
|
return {"success": False, "error": "No ESP devices found"}
|
||||||
if not active_connections:
|
|
||||||
return {"success": False, "error": "No active connections found"}
|
|
||||||
port = active_connections[0].port
|
|
||||||
|
|
||||||
connection = self.connections.get(port)
|
after_map = {
|
||||||
baud_rate = connection.baud_rate if connection else self.config.default_baud_rate
|
"hard": "hard_reset",
|
||||||
|
"soft": "soft_reset",
|
||||||
# Validate reset type
|
"bootloader": "no_reset",
|
||||||
if reset_type not in ("hard", "soft", "bootloader"):
|
}
|
||||||
|
if reset_type not in after_map:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Unknown reset type: {reset_type}",
|
"error": f"Unknown reset type: {reset_type}",
|
||||||
"available_types": ["hard", "soft", "bootloader"],
|
"available_types": list(after_map.keys()),
|
||||||
}
|
}
|
||||||
|
|
||||||
await middleware._log_info(f"🔄 Performing {reset_type} reset on {port}")
|
info = await self._run_esptool(
|
||||||
|
port, "chip-id",
|
||||||
|
timeout=10.0,
|
||||||
|
connect_attempts=1,
|
||||||
|
extra_args=["--after", after_map[reset_type]],
|
||||||
|
)
|
||||||
|
|
||||||
# Capture variables for closure
|
if not info["success"]:
|
||||||
target_port = port
|
return {"success": False, "error": info["error"], "port": port, "reset_type": reset_type}
|
||||||
target_baud = baud_rate
|
|
||||||
target_reset_type = reset_type
|
|
||||||
|
|
||||||
def perform_reset_blocking() -> bool:
|
|
||||||
"""Blocking function to perform reset"""
|
|
||||||
esp = self._connect_to_chip(target_port, target_baud)
|
|
||||||
|
|
||||||
if target_reset_type == "hard":
|
|
||||||
esp.hard_reset()
|
|
||||||
elif target_reset_type == "soft":
|
|
||||||
esp.soft_reset()
|
|
||||||
elif target_reset_type == "bootloader":
|
|
||||||
# Just connecting puts it in bootloader mode
|
|
||||||
pass
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use timeout wrapper - 10 seconds for reset
|
|
||||||
await self._run_blocking_with_timeout(perform_reset_blocking, timeout=10.0)
|
|
||||||
|
|
||||||
# Update connection status
|
|
||||||
if port in self.connections:
|
|
||||||
self.connections[port].connected = False
|
|
||||||
|
|
||||||
await middleware._log_success(f"Reset completed: {reset_type}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@ -413,66 +359,47 @@ class ChipControl:
|
|||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
await middleware._log_error("Reset operation timed out (10s)")
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "Reset timeout (10s)",
|
|
||||||
"port": port,
|
|
||||||
"reset_type": reset_type,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await middleware._log_error(f"Reset failed: {e}")
|
|
||||||
return {"success": False, "error": str(e), "port": port, "reset_type": reset_type}
|
|
||||||
|
|
||||||
async def _scan_ports_impl(self, context: Context, detailed: bool) -> dict[str, Any]:
|
async def _scan_ports_impl(self, context: Context, detailed: bool) -> dict[str, Any]:
|
||||||
"""Implementation of port scanning using subprocess for reliable timeout."""
|
"""Scan for available ESP devices using subprocess probes."""
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Check common ESP device ports directly (more reliable than enumeration)
|
|
||||||
common_esp_ports = [
|
common_esp_ports = [
|
||||||
"/dev/ttyUSB0",
|
"/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2", "/dev/ttyUSB3",
|
||||||
"/dev/ttyUSB1",
|
"/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyACM2", "/dev/ttyACM3",
|
||||||
"/dev/ttyUSB2",
|
|
||||||
"/dev/ttyUSB3",
|
|
||||||
"/dev/ttyACM0",
|
|
||||||
"/dev/ttyACM1",
|
|
||||||
"/dev/ttyACM2",
|
|
||||||
"/dev/ttyACM3",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Filter to only existing ports
|
|
||||||
usb_ports = [p for p in common_esp_ports if os.path.exists(p)]
|
usb_ports = [p for p in common_esp_ports if os.path.exists(p)]
|
||||||
|
|
||||||
detected_devices = []
|
detected_devices: list[dict[str, Any]] = []
|
||||||
scan_results = {}
|
scan_results: dict[str, Any] = {}
|
||||||
|
|
||||||
if not usb_ports:
|
if not usb_ports:
|
||||||
|
# Still check QEMU before returning empty
|
||||||
|
qemu_devices = self._get_qemu_devices()
|
||||||
|
detected_devices.extend(qemu_devices)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"detected_devices": [],
|
"detected_devices": detected_devices,
|
||||||
"total_scanned": len(common_esp_ports),
|
"total_scanned": len(common_esp_ports) + len(qemu_devices),
|
||||||
"checked_ports": common_esp_ports,
|
"checked_ports": common_esp_ports,
|
||||||
|
"qemu_devices": qemu_devices or None,
|
||||||
"scan_results": {"note": "No USB/ACM ports found on system"},
|
"scan_results": {"note": "No USB/ACM ports found on system"},
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for port in usb_ports:
|
for port in usb_ports:
|
||||||
device_info = await self._probe_port_subprocess(port, detailed)
|
info = await self._run_esptool(port, "chip-id", connect_attempts=1)
|
||||||
if device_info.get("available"):
|
device_info: dict[str, Any] = {"port": port, "available": info["success"]}
|
||||||
|
if info["success"]:
|
||||||
|
device_info.update(self._parse_chip_output(info["output"]))
|
||||||
|
if detailed:
|
||||||
|
flash_info = await self._run_esptool(port, "flash-id")
|
||||||
|
if flash_info["success"]:
|
||||||
|
device_info.update(self._parse_chip_output(flash_info["output"]))
|
||||||
detected_devices.append(device_info)
|
detected_devices.append(device_info)
|
||||||
|
else:
|
||||||
|
device_info["error"] = info["error"]
|
||||||
scan_results[port] = device_info
|
scan_results[port] = device_info
|
||||||
|
|
||||||
# Include running QEMU instances
|
qemu_devices = self._get_qemu_devices()
|
||||||
qemu_devices = []
|
detected_devices.extend(qemu_devices)
|
||||||
if self.qemu_manager:
|
|
||||||
for qemu_info in self.qemu_manager.get_running_ports():
|
|
||||||
qemu_info["available"] = True
|
|
||||||
qemu_devices.append(qemu_info)
|
|
||||||
detected_devices.append(qemu_info)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@ -480,169 +407,20 @@ class ChipControl:
|
|||||||
"total_scanned": len(usb_ports) + len(qemu_devices),
|
"total_scanned": len(usb_ports) + len(qemu_devices),
|
||||||
"checked_ports": common_esp_ports,
|
"checked_ports": common_esp_ports,
|
||||||
"available_ports": usb_ports,
|
"available_ports": usb_ports,
|
||||||
"qemu_devices": qemu_devices if qemu_devices else None,
|
"qemu_devices": qemu_devices or None,
|
||||||
"scan_results": scan_results if detailed else None,
|
"scan_results": scan_results if detailed else None,
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _probe_port_subprocess(self, port: str, detailed: bool = False) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Probe a single port using esptool as an async subprocess.
|
|
||||||
|
|
||||||
Uses asyncio.create_subprocess_exec() so it never blocks the event loop.
|
|
||||||
The subprocess can be killed on timeout, unlike Python threads.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use 1 connect attempt for scanning (fast probe)
|
|
||||||
info = await self._run_esptool_async(port, "chip-id", connect_attempts=1)
|
|
||||||
if not info["success"]:
|
|
||||||
return {"port": port, "available": False, "error": info["error"]}
|
|
||||||
|
|
||||||
output = info["output"]
|
|
||||||
|
|
||||||
# Parse esptool chip-id output
|
|
||||||
result: dict[str, Any] = {"port": port, "available": True}
|
|
||||||
|
|
||||||
# Extract chip type - multiple formats:
|
|
||||||
# "Chip type: ESP32-D0WD-V3 (revision v3.1)"
|
|
||||||
# "Chip is ESP32-S3 (QFN56) (revision v0.2)"
|
|
||||||
chip_match = re.search(r"Chip type:\s*(.+?)(?:\n|$)", output)
|
|
||||||
if not chip_match:
|
|
||||||
chip_match = re.search(r"Chip is\s+(.+?)(?:\n|$)", output)
|
|
||||||
if not chip_match:
|
|
||||||
chip_match = re.search(r"Detecting chip type[.…]+\s*(\S+)", output)
|
|
||||||
if chip_match:
|
|
||||||
result["chip_type"] = chip_match.group(1).strip()
|
|
||||||
|
|
||||||
# Extract MAC address
|
|
||||||
mac_match = re.search(r"MAC:\s*([0-9a-f:]+)", output, re.IGNORECASE)
|
|
||||||
if mac_match:
|
|
||||||
result["mac_address"] = mac_match.group(1)
|
|
||||||
|
|
||||||
# Extract features if present
|
|
||||||
features_match = re.search(r"Features:\s*(.+?)(?:\n|$)", output)
|
|
||||||
if features_match:
|
|
||||||
result["features"] = [f.strip() for f in features_match.group(1).split(",")]
|
|
||||||
|
|
||||||
# Extract crystal frequency - formats:
|
|
||||||
# "Crystal frequency: 40MHz"
|
|
||||||
# "Crystal is 40MHz"
|
|
||||||
crystal_match = re.search(r"Crystal\s+(?:frequency:\s*|is\s+)(\d+)\s*MHz", output)
|
|
||||||
if crystal_match:
|
|
||||||
result["crystal_freq"] = f"{crystal_match.group(1)}MHz"
|
|
||||||
|
|
||||||
if detailed:
|
|
||||||
# Run flash-id for additional info
|
|
||||||
try:
|
|
||||||
flash_info = await self._run_esptool_async(port, "flash-id")
|
|
||||||
if flash_info["success"]:
|
|
||||||
flash_output = flash_info["output"]
|
|
||||||
|
|
||||||
flash_size_match = re.search(r"Detected flash size:\s*(\S+)", flash_output)
|
|
||||||
if flash_size_match:
|
|
||||||
result["flash_size"] = flash_size_match.group(1)
|
|
||||||
|
|
||||||
flash_mfr_match = re.search(r"Manufacturer:\s*(\S+)", flash_output)
|
|
||||||
if flash_mfr_match:
|
|
||||||
result["flash_manufacturer"] = flash_mfr_match.group(1)
|
|
||||||
else:
|
|
||||||
result["flash_info_error"] = flash_info["error"]
|
|
||||||
except Exception as e:
|
|
||||||
result["flash_info_error"] = str(e)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {"port": port, "available": False, "error": str(e)}
|
|
||||||
|
|
||||||
async def _run_esptool_async(
|
|
||||||
self,
|
|
||||||
port: str,
|
|
||||||
command: str,
|
|
||||||
timeout: float = 10.0,
|
|
||||||
connect_attempts: int = 3,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Run an esptool command as a fully async subprocess.
|
|
||||||
|
|
||||||
This is the ONLY safe way to call esptool from an async event loop:
|
|
||||||
- asyncio.create_subprocess_exec() never blocks the event loop
|
|
||||||
- asyncio.wait_for() can cancel and kill the process on timeout
|
|
||||||
- The OS sends SIGKILL if the process doesn't respond
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port: Serial port
|
|
||||||
command: esptool command (e.g. "chip-id", "flash-id")
|
|
||||||
timeout: Timeout in seconds
|
|
||||||
connect_attempts: Number of connection attempts (default: 3)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict with "success", "output", and optionally "error"
|
|
||||||
"""
|
|
||||||
proc = None
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
self.config.esptool_path,
|
|
||||||
"--port",
|
|
||||||
port,
|
|
||||||
"--connect-attempts",
|
|
||||||
str(connect_attempts),
|
|
||||||
command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# wait_for will cancel the coroutine on timeout
|
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
||||||
|
|
||||||
output = (stdout or b"").decode() + (stderr or b"").decode()
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
|
||||||
return {"success": False, "error": output.strip()[:200]}
|
|
||||||
|
|
||||||
return {"success": True, "output": output}
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# Kill the hung process
|
|
||||||
if proc and proc.returncode is None:
|
|
||||||
proc.kill()
|
|
||||||
await proc.wait()
|
|
||||||
return {"success": False, "error": f"Timeout ({timeout}s)"}
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": f"esptool not found at {self.config.esptool_path}",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
if proc and proc.returncode is None:
|
|
||||||
proc.kill()
|
|
||||||
await proc.wait()
|
|
||||||
return {"success": False, "error": str(e)}
|
|
||||||
|
|
||||||
async def _load_test_firmware_impl(
|
async def _load_test_firmware_impl(
|
||||||
self, context: Context, port: str | None, firmware_type: str
|
self, context: Context, port: str | None, firmware_type: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Implementation of test firmware loading"""
|
"""Load test firmware (stub — requires ESP-IDF integration)."""
|
||||||
|
|
||||||
middleware = MiddlewareFactory.create_esptool_middleware(
|
|
||||||
context, f"load_test_firmware_{int(time.time())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with middleware.activate():
|
|
||||||
# This would integrate with ESP-IDF to build and flash test firmware
|
|
||||||
# For now, return a placeholder that shows the architecture
|
|
||||||
|
|
||||||
await middleware._log_info(f"🧪 Loading test firmware: {firmware_type}")
|
|
||||||
|
|
||||||
# Auto-detect port if needed
|
|
||||||
if not port:
|
if not port:
|
||||||
port = await self._auto_detect_port(context)
|
port = await self._auto_detect_port()
|
||||||
if not port:
|
if not port:
|
||||||
return {"success": False, "error": "No ESP devices found"}
|
return {"success": False, "error": "No ESP devices found"}
|
||||||
|
|
||||||
# Check if we have test firmware available
|
|
||||||
test_firmwares = {
|
test_firmwares = {
|
||||||
"blink": "Simple LED blink test",
|
"blink": "Simple LED blink test",
|
||||||
"hello_world": "Serial output hello world",
|
"hello_world": "Serial output hello world",
|
||||||
@ -656,11 +434,6 @@ class ChipControl:
|
|||||||
"available_types": list(test_firmwares.keys()),
|
"available_types": list(test_firmwares.keys()),
|
||||||
}
|
}
|
||||||
|
|
||||||
await middleware._log_info(f"📦 Test firmware: {test_firmwares[firmware_type]}")
|
|
||||||
|
|
||||||
# This is where we would integrate with ESP-IDF or pre-built binaries
|
|
||||||
# For demonstration, we'll simulate the process
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"port": port,
|
"port": port,
|
||||||
@ -670,87 +443,33 @@ class ChipControl:
|
|||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _connect_to_chip(self, port: str, baud_rate: int, connect_attempts: int = 3):
|
# ------------------------------------------------------------------
|
||||||
"""
|
# Helpers
|
||||||
Helper method to connect to ESP chip using correct esptool API
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
Args:
|
def _get_qemu_devices(self) -> list[dict[str, Any]]:
|
||||||
port: Serial port
|
"""Collect running QEMU instances as device entries."""
|
||||||
baud_rate: Connection baud rate
|
if not self.qemu_manager:
|
||||||
connect_attempts: Number of connection attempts (default: 3)
|
return []
|
||||||
|
devices = []
|
||||||
|
for qemu_info in self.qemu_manager.get_running_ports():
|
||||||
|
qemu_info["available"] = True
|
||||||
|
devices.append(qemu_info)
|
||||||
|
return devices
|
||||||
|
|
||||||
Returns:
|
async def _auto_detect_port(self) -> str | None:
|
||||||
Connected ESP device object
|
"""Auto-detect an ESP device port via quick subprocess probes."""
|
||||||
"""
|
for port in self.config.get_common_ports():
|
||||||
return esptool.get_default_connected_device(
|
|
||||||
serial_list=[port],
|
|
||||||
port=port,
|
|
||||||
connect_attempts=connect_attempts,
|
|
||||||
initial_baud=baud_rate,
|
|
||||||
chip="auto",
|
|
||||||
trace=False,
|
|
||||||
before="default_reset",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_blocking_with_timeout(self, func: Callable[[], T], timeout: float = 5.0) -> T:
|
|
||||||
"""
|
|
||||||
Run a blocking function in a thread pool with proper timeout handling.
|
|
||||||
|
|
||||||
This solves the issue where asyncio.wait_for() times out but the
|
|
||||||
ThreadPoolExecutor context manager blocks waiting for the thread to finish.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
func: Blocking function to run
|
|
||||||
timeout: Timeout in seconds (default: 5.0)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result of the function
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
asyncio.TimeoutError: If the operation times out
|
|
||||||
Exception: Any exception from the function
|
|
||||||
"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
executor = ThreadPoolExecutor(max_workers=1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
future = loop.run_in_executor(executor, func)
|
|
||||||
result = await asyncio.wait_for(future, timeout=timeout)
|
|
||||||
return result
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# Critical: shutdown WITHOUT waiting - abandon the hung thread
|
|
||||||
# cancel_futures=True requires Python 3.9+
|
|
||||||
executor.shutdown(wait=False, cancel_futures=True)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
# Always try to shutdown, but don't wait
|
|
||||||
try:
|
|
||||||
executor.shutdown(wait=False, cancel_futures=True)
|
|
||||||
except Exception:
|
|
||||||
pass # Already shutdown or other error
|
|
||||||
|
|
||||||
async def _auto_detect_port(self, context: Context) -> str | None:
|
|
||||||
"""Auto-detect ESP device port using subprocess for reliable timeout."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
ports = self.config.get_common_ports()
|
|
||||||
|
|
||||||
for port in ports:
|
|
||||||
if not os.path.exists(port):
|
if not os.path.exists(port):
|
||||||
continue
|
continue
|
||||||
|
info = await self._run_esptool(port, "chip-id", connect_attempts=1)
|
||||||
# Use subprocess probe - guaranteed to not hang
|
if info["success"]:
|
||||||
result = await self._probe_port_subprocess(port, detailed=False)
|
|
||||||
if result.get("available"):
|
|
||||||
return port
|
return port
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def health_check(self) -> dict[str, Any]:
|
async def health_check(self) -> dict[str, Any]:
|
||||||
"""Component health check"""
|
"""Component health check"""
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"active_connections": len([c for c in self.connections.values() if c.connected]),
|
"esptool_path": self.config.esptool_path,
|
||||||
"total_connections": len(self.connections),
|
|
||||||
"esptool_available": True, # We imported successfully
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,7 @@ class FlashManager:
|
|||||||
context: Context,
|
context: Context,
|
||||||
firmware_path: str,
|
firmware_path: str,
|
||||||
port: str | None = None,
|
port: str | None = None,
|
||||||
|
address: str = "0x0",
|
||||||
verify: bool = True,
|
verify: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Flash firmware to ESP device.
|
"""Flash firmware to ESP device.
|
||||||
@ -98,9 +99,11 @@ class FlashManager:
|
|||||||
Args:
|
Args:
|
||||||
firmware_path: Path to the firmware binary (.bin) to flash
|
firmware_path: Path to the firmware binary (.bin) to flash
|
||||||
port: Serial port or socket:// URI (auto-detect if not specified)
|
port: Serial port or socket:// URI (auto-detect if not specified)
|
||||||
|
address: Flash address to write to (hex string, default: "0x0").
|
||||||
|
Use partition offsets for non-firmware images (e.g. "0x290000" for LittleFS).
|
||||||
verify: Verify flash contents after writing (default: true)
|
verify: Verify flash contents after writing (default: true)
|
||||||
"""
|
"""
|
||||||
return await self._flash_firmware_impl(context, firmware_path, port, verify)
|
return await self._flash_firmware_impl(context, firmware_path, port, address, verify)
|
||||||
|
|
||||||
@self.app.tool("esp_flash_read")
|
@self.app.tool("esp_flash_read")
|
||||||
async def flash_read(
|
async def flash_read(
|
||||||
@ -166,6 +169,7 @@ class FlashManager:
|
|||||||
context: Context,
|
context: Context,
|
||||||
firmware_path: str,
|
firmware_path: str,
|
||||||
port: str | None,
|
port: str | None,
|
||||||
|
address: str,
|
||||||
verify: bool,
|
verify: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Write firmware to flash via esptool write-flash."""
|
"""Write firmware to flash via esptool write-flash."""
|
||||||
@ -179,7 +183,7 @@ class FlashManager:
|
|||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
args = ["write-flash", "0x0", str(fw_path)]
|
args = ["write-flash", address, str(fw_path)]
|
||||||
if not verify:
|
if not verify:
|
||||||
args.insert(0, "--no-verify")
|
args.insert(0, "--no-verify")
|
||||||
|
|
||||||
@ -191,6 +195,7 @@ class FlashManager:
|
|||||||
"error": result["error"],
|
"error": result["error"],
|
||||||
"port": port,
|
"port": port,
|
||||||
"firmware_path": firmware_path,
|
"firmware_path": firmware_path,
|
||||||
|
"address": address,
|
||||||
}
|
}
|
||||||
|
|
||||||
output = result["output"]
|
output = result["output"]
|
||||||
@ -208,6 +213,7 @@ class FlashManager:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"port": port,
|
"port": port,
|
||||||
"firmware_path": firmware_path,
|
"firmware_path": firmware_path,
|
||||||
|
"address": address,
|
||||||
"firmware_size": fw_path.stat().st_size,
|
"firmware_size": fw_path.stat().st_size,
|
||||||
"bytes_written": bytes_written,
|
"bytes_written": bytes_written,
|
||||||
"verified": verified if verify else None,
|
"verified": verified if verify else None,
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
MCP Middleware System
|
|
||||||
|
|
||||||
Universal middleware for integrating CLI tools with FastMCP servers.
|
|
||||||
Provides bidirectional communication, progress tracking, and user interaction.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .esptool_middleware import ESPToolMiddleware
|
|
||||||
from .logger_interceptor import LoggerInterceptor
|
|
||||||
from .middleware_factory import MiddlewareFactory
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LoggerInterceptor",
|
|
||||||
"ESPToolMiddleware",
|
|
||||||
"MiddlewareFactory",
|
|
||||||
]
|
|
||||||
@ -1,362 +0,0 @@
|
|||||||
"""
|
|
||||||
ESPTool-specific middleware implementation
|
|
||||||
|
|
||||||
Provides specialized middleware for intercepting esptool operations and redirecting
|
|
||||||
output to MCP context with intelligent progress tracking and user interaction.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from re import Pattern
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastmcp import Context
|
|
||||||
|
|
||||||
from .logger_interceptor import LoggerInterceptor, MiddlewareError
|
|
||||||
|
|
||||||
|
|
||||||
class ESPToolMiddleware(LoggerInterceptor):
|
|
||||||
"""ESPTool-specific middleware for MCP integration"""
|
|
||||||
|
|
||||||
def __init__(self, context: Context, operation_id: str):
|
|
||||||
super().__init__(context, operation_id)
|
|
||||||
|
|
||||||
# ESPTool-specific state
|
|
||||||
self.original_stdout = None
|
|
||||||
self.original_stderr = None
|
|
||||||
self.captured_output = io.StringIO()
|
|
||||||
self.captured_errors = io.StringIO()
|
|
||||||
|
|
||||||
# Progress tracking patterns
|
|
||||||
self.progress_patterns = self._setup_progress_patterns()
|
|
||||||
self.stage_patterns = self._setup_stage_patterns()
|
|
||||||
|
|
||||||
# Operation tracking
|
|
||||||
self.current_operation = None
|
|
||||||
self.chip_info = {}
|
|
||||||
self.flash_info = {}
|
|
||||||
|
|
||||||
def _setup_progress_patterns(self) -> dict[str, Pattern]:
|
|
||||||
"""Set up regex patterns for progress detection"""
|
|
||||||
return {
|
|
||||||
"flash_progress": re.compile(r"Writing at 0x[0-9a-f]+\.\.\. \((\d+) %\)"),
|
|
||||||
"read_progress": re.compile(r"Reading memory at 0x[0-9a-f]+\.\.\. \((\d+) %\)"),
|
|
||||||
"erase_progress": re.compile(
|
|
||||||
r"Erasing flash \(this may take a while\)\.\.\. \((\d+) %\)"
|
|
||||||
),
|
|
||||||
"verify_progress": re.compile(r"Verifying \((\d+) %\)"),
|
|
||||||
"compress_progress": re.compile(r"Compressed (\d+) bytes to (\d+)\.\.\. \((\d+) %\)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _setup_stage_patterns(self) -> dict[str, Pattern]:
|
|
||||||
"""Set up regex patterns for stage detection"""
|
|
||||||
return {
|
|
||||||
"chip_detection": re.compile(r"Detecting chip type\.\.\. (.+)"),
|
|
||||||
"connecting": re.compile(r"Connecting\.\.\."),
|
|
||||||
"stub_loading": re.compile(r"Running stub\.\.\."),
|
|
||||||
"flash_begin": re.compile(r"Changing baud rate to (\d+)"),
|
|
||||||
"configuring_flash": re.compile(r"Configuring flash size\.\.\."),
|
|
||||||
"erasing_flash": re.compile(r"Erasing flash \(this may take a while\)\.\.\."),
|
|
||||||
"writing_flash": re.compile(r"Writing .+ bytes at 0x[0-9a-f]+\.\.\."),
|
|
||||||
"verifying": re.compile(r"Verifying\.\.\."),
|
|
||||||
"hard_reset": re.compile(r"Hard resetting via RTS pin\.\.\."),
|
|
||||||
"leaving_download": re.compile(r"Leaving\.\.\."),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def install_hooks(self) -> None:
|
|
||||||
"""Install middleware hooks into esptool"""
|
|
||||||
try:
|
|
||||||
# Create custom logger that redirects to MCP
|
|
||||||
mcp_logger = self._create_mcp_logger()
|
|
||||||
|
|
||||||
# Patch esptool's logging
|
|
||||||
self.original_stdout = sys.stdout
|
|
||||||
self.original_stderr = sys.stderr
|
|
||||||
|
|
||||||
# Install our custom output capture
|
|
||||||
sys.stdout = MCPOutputCapture(self, "stdout")
|
|
||||||
sys.stderr = MCPOutputCapture(self, "stderr")
|
|
||||||
|
|
||||||
# Override esptool's main logger
|
|
||||||
esptool_logger = logging.getLogger("esptool")
|
|
||||||
esptool_logger.handlers.clear()
|
|
||||||
esptool_logger.addHandler(mcp_logger)
|
|
||||||
esptool_logger.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
await self._log_info("🔌 ESPTool middleware hooks installed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await self._log_error(f"Failed to install ESPTool hooks: {e}")
|
|
||||||
raise MiddlewareError(f"Hook installation failed: {e}")
|
|
||||||
|
|
||||||
async def remove_hooks(self) -> None:
|
|
||||||
"""Remove middleware hooks from esptool"""
|
|
||||||
try:
|
|
||||||
# Restore original streams
|
|
||||||
if self.original_stdout:
|
|
||||||
sys.stdout = self.original_stdout
|
|
||||||
if self.original_stderr:
|
|
||||||
sys.stderr = self.original_stderr
|
|
||||||
|
|
||||||
# Restore esptool logging
|
|
||||||
esptool_logger = logging.getLogger("esptool")
|
|
||||||
esptool_logger.handlers.clear()
|
|
||||||
|
|
||||||
await self._log_info("🔌 ESPTool middleware hooks removed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await self._log_warning(f"Error removing ESPTool hooks: {e}")
|
|
||||||
|
|
||||||
def get_interaction_points(self) -> list[str]:
|
|
||||||
"""Return ESPTool operations that require user interaction"""
|
|
||||||
return [
|
|
||||||
"erase_flash",
|
|
||||||
"write_flash_encrypt",
|
|
||||||
"burn_efuse",
|
|
||||||
"secure_boot_signing_key",
|
|
||||||
"flash_encryption_key_generate",
|
|
||||||
"reset_to_factory",
|
|
||||||
]
|
|
||||||
|
|
||||||
def _create_mcp_logger(self) -> logging.Handler:
|
|
||||||
"""Create a logging handler that forwards to MCP context"""
|
|
||||||
|
|
||||||
class MCPLogHandler(logging.Handler):
|
|
||||||
def __init__(self, middleware):
|
|
||||||
super().__init__()
|
|
||||||
self.middleware = middleware
|
|
||||||
|
|
||||||
def emit(self, record):
|
|
||||||
try:
|
|
||||||
message = self.format(record)
|
|
||||||
|
|
||||||
# Run async logging in event loop
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
if record.levelno >= logging.ERROR:
|
|
||||||
loop.create_task(self.middleware._log_error(message))
|
|
||||||
elif record.levelno >= logging.WARNING:
|
|
||||||
loop.create_task(self.middleware._log_warning(message))
|
|
||||||
else:
|
|
||||||
loop.create_task(self.middleware._log_info(message))
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
pass # Prevent logging errors from breaking operations
|
|
||||||
|
|
||||||
return MCPLogHandler(self)
|
|
||||||
|
|
||||||
async def process_output_line(self, line: str, stream_type: str) -> None:
|
|
||||||
"""Process a line of output from esptool"""
|
|
||||||
if not line.strip():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check for progress updates
|
|
||||||
await self._check_progress_patterns(line)
|
|
||||||
|
|
||||||
# Check for stage changes
|
|
||||||
await self._check_stage_patterns(line)
|
|
||||||
|
|
||||||
# Check for chip information
|
|
||||||
await self._extract_chip_info(line)
|
|
||||||
|
|
||||||
# Check for flash information
|
|
||||||
await self._extract_flash_info(line)
|
|
||||||
|
|
||||||
# Check for errors
|
|
||||||
await self._check_error_patterns(line)
|
|
||||||
|
|
||||||
# Log the line if it contains useful information
|
|
||||||
if self._is_useful_output(line):
|
|
||||||
await self._log_info(f"📟 {line.strip()}")
|
|
||||||
|
|
||||||
async def _check_progress_patterns(self, line: str) -> None:
|
|
||||||
"""Check line against progress patterns and update progress"""
|
|
||||||
for operation, pattern in self.progress_patterns.items():
|
|
||||||
match = pattern.search(line)
|
|
||||||
if match:
|
|
||||||
if operation == "compress_progress":
|
|
||||||
# Special handling for compression progress
|
|
||||||
original_size = int(match.group(1))
|
|
||||||
compressed_size = int(match.group(2))
|
|
||||||
percentage = int(match.group(3))
|
|
||||||
await self._update_progress(
|
|
||||||
percentage,
|
|
||||||
f"Compressing: {original_size} → {compressed_size} bytes",
|
|
||||||
current=compressed_size,
|
|
||||||
total=original_size,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
percentage = int(match.group(1))
|
|
||||||
operation_name = operation.replace("_", " ").title()
|
|
||||||
await self._update_progress(percentage, f"{operation_name}: {percentage}%")
|
|
||||||
break
|
|
||||||
|
|
||||||
async def _check_stage_patterns(self, line: str) -> None:
|
|
||||||
"""Check line against stage patterns and handle stage changes"""
|
|
||||||
for stage, pattern in self.stage_patterns.items():
|
|
||||||
match = pattern.search(line)
|
|
||||||
if match:
|
|
||||||
stage_message = self._format_stage_message(stage, match)
|
|
||||||
await self._handle_stage_start(stage_message)
|
|
||||||
|
|
||||||
# Store current operation context
|
|
||||||
self.current_operation = stage
|
|
||||||
break
|
|
||||||
|
|
||||||
def _format_stage_message(self, stage: str, match) -> str:
|
|
||||||
"""Format stage message for user display"""
|
|
||||||
stage_messages = {
|
|
||||||
"chip_detection": f"Detecting chip type: {match.group(1)}",
|
|
||||||
"connecting": "Connecting to ESP device",
|
|
||||||
"stub_loading": "Loading ROM bootloader stub",
|
|
||||||
"flash_begin": f"Setting baud rate to {match.group(1)}",
|
|
||||||
"configuring_flash": "Configuring flash parameters",
|
|
||||||
"erasing_flash": "Erasing flash memory",
|
|
||||||
"writing_flash": "Writing firmware to flash",
|
|
||||||
"verifying": "Verifying flash contents",
|
|
||||||
"hard_reset": "Performing hard reset",
|
|
||||||
"leaving_download": "Exiting download mode",
|
|
||||||
}
|
|
||||||
|
|
||||||
return stage_messages.get(stage, stage.replace("_", " ").title())
|
|
||||||
|
|
||||||
async def _extract_chip_info(self, line: str) -> None:
|
|
||||||
"""Extract chip information from esptool output"""
|
|
||||||
patterns = {
|
|
||||||
"chip_type": re.compile(r"Chip is (.+)"),
|
|
||||||
"mac_address": re.compile(r"MAC: ([0-9a-f:]{17})"),
|
|
||||||
"flash_id": re.compile(r"Detected flash size: (.+)"),
|
|
||||||
"crystal_freq": re.compile(r"Crystal is (.+)MHz"),
|
|
||||||
}
|
|
||||||
|
|
||||||
for info_type, pattern in patterns.items():
|
|
||||||
match = pattern.search(line)
|
|
||||||
if match:
|
|
||||||
self.chip_info[info_type] = match.group(1)
|
|
||||||
await self._log_info(f"📋 {info_type.replace('_', ' ').title()}: {match.group(1)}")
|
|
||||||
|
|
||||||
async def _extract_flash_info(self, line: str) -> None:
|
|
||||||
"""Extract flash information from esptool output"""
|
|
||||||
patterns = {
|
|
||||||
"flash_size": re.compile(r"Auto-detected Flash size: (.+)"),
|
|
||||||
"flash_frequency": re.compile(r"Flash frequency: (.+)"),
|
|
||||||
"flash_mode": re.compile(r"Flash mode: (.+)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
for info_type, pattern in patterns.items():
|
|
||||||
match = pattern.search(line)
|
|
||||||
if match:
|
|
||||||
self.flash_info[info_type] = match.group(1)
|
|
||||||
await self._log_info(f"💾 {info_type.replace('_', ' ').title()}: {match.group(1)}")
|
|
||||||
|
|
||||||
async def _check_error_patterns(self, line: str) -> None:
|
|
||||||
"""Check for error patterns in output"""
|
|
||||||
error_patterns = [
|
|
||||||
r"Error:? (.+)",
|
|
||||||
r"Failed to (.+)",
|
|
||||||
r"Could not (.+)",
|
|
||||||
r"No such file or directory: (.+)",
|
|
||||||
r"Permission denied: (.+)",
|
|
||||||
r"Serial exception: (.+)",
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in error_patterns:
|
|
||||||
match = re.search(pattern, line, re.IGNORECASE)
|
|
||||||
if match:
|
|
||||||
await self._log_error(f"ESPTool error: {match.group(1)}")
|
|
||||||
break
|
|
||||||
|
|
||||||
def _is_useful_output(self, line: str) -> bool:
|
|
||||||
"""Determine if output line contains useful information"""
|
|
||||||
# Skip common noise patterns
|
|
||||||
noise_patterns = [
|
|
||||||
r"^\s*$", # Empty lines
|
|
||||||
r"^Uploading stub\.\.\.",
|
|
||||||
r"^Running stub\.\.\.",
|
|
||||||
r"^Stub running\.\.\.",
|
|
||||||
r"^\.", # Progress dots
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in noise_patterns:
|
|
||||||
if re.match(pattern, line):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Include lines with useful keywords
|
|
||||||
useful_keywords = [
|
|
||||||
"chip",
|
|
||||||
"flash",
|
|
||||||
"mac",
|
|
||||||
"crystal",
|
|
||||||
"baud",
|
|
||||||
"size",
|
|
||||||
"error",
|
|
||||||
"warning",
|
|
||||||
"failed",
|
|
||||||
"success",
|
|
||||||
"complete",
|
|
||||||
"writing",
|
|
||||||
"reading",
|
|
||||||
"erasing",
|
|
||||||
"verifying",
|
|
||||||
]
|
|
||||||
|
|
||||||
line_lower = line.lower()
|
|
||||||
return any(keyword in line_lower for keyword in useful_keywords)
|
|
||||||
|
|
||||||
async def get_operation_summary(self) -> dict[str, Any]:
|
|
||||||
"""Get summary of current operation"""
|
|
||||||
return {
|
|
||||||
"operation_id": self.operation_id,
|
|
||||||
"current_operation": self.current_operation,
|
|
||||||
"chip_info": self.chip_info,
|
|
||||||
"flash_info": self.flash_info,
|
|
||||||
"progress_history": self.progress_history[-5:], # Last 5 progress updates
|
|
||||||
"statistics": self.get_operation_statistics(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MCPOutputCapture:
|
|
||||||
"""Custom output capture that forwards to middleware"""
|
|
||||||
|
|
||||||
def __init__(self, middleware: ESPToolMiddleware, stream_type: str):
|
|
||||||
self.middleware = middleware
|
|
||||||
self.stream_type = stream_type
|
|
||||||
self.buffer = ""
|
|
||||||
|
|
||||||
def write(self, text: str) -> int:
|
|
||||||
"""Write text and process for MCP forwarding"""
|
|
||||||
self.buffer += text
|
|
||||||
|
|
||||||
# Process complete lines
|
|
||||||
while "\n" in self.buffer:
|
|
||||||
line, self.buffer = self.buffer.split("\n", 1)
|
|
||||||
|
|
||||||
# Forward to middleware async
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.create_task(self.middleware.process_output_line(line, self.stream_type))
|
|
||||||
|
|
||||||
return len(text)
|
|
||||||
|
|
||||||
def flush(self):
|
|
||||||
"""Flush any remaining buffer content"""
|
|
||||||
if self.buffer.strip():
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.create_task(self.middleware.process_output_line(self.buffer, self.stream_type))
|
|
||||||
self.buffer = ""
|
|
||||||
|
|
||||||
def isatty(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class ESPToolOperationError(MiddlewareError):
|
|
||||||
"""Raised when ESPTool operation fails"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ESPToolConnectionError(MiddlewareError):
|
|
||||||
"""Raised when connection to ESP device fails"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
"""
|
|
||||||
Logger Interceptor Base Class
|
|
||||||
|
|
||||||
Abstract base class for intercepting and redirecting CLI tool logging to MCP context.
|
|
||||||
Provides the foundation for bidirectional communication with any CLI tool.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastmcp import Context
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LoggerInterceptor(ABC):
|
|
||||||
"""Abstract base class for CLI tool logger interception"""
|
|
||||||
|
|
||||||
def __init__(self, context: Context, operation_id: str):
|
|
||||||
"""
|
|
||||||
Initialize logger interceptor
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: FastMCP context for logging and user interaction
|
|
||||||
operation_id: Unique identifier for this operation
|
|
||||||
"""
|
|
||||||
self.context = context
|
|
||||||
self.operation_id = operation_id
|
|
||||||
self.operation_start_time = time.time()
|
|
||||||
|
|
||||||
# Detect MCP client capabilities
|
|
||||||
self.capabilities = self._detect_mcp_capabilities()
|
|
||||||
|
|
||||||
# Operation state
|
|
||||||
self.progress_history: list[dict[str, Any]] = []
|
|
||||||
self.user_confirmations: dict[str, bool] = {}
|
|
||||||
self.active_stages: list[str] = []
|
|
||||||
|
|
||||||
logger.debug(f"Logger interceptor initialized for operation: {operation_id}")
|
|
||||||
|
|
||||||
def _detect_mcp_capabilities(self) -> dict[str, bool]:
|
|
||||||
"""Detect available MCP client capabilities"""
|
|
||||||
capabilities = {
|
|
||||||
"logging": hasattr(self.context, "log") and callable(self.context.log),
|
|
||||||
"progress": hasattr(self.context, "progress") and callable(self.context.progress),
|
|
||||||
"elicitation": hasattr(self.context, "request_user_input")
|
|
||||||
and callable(self.context.request_user_input),
|
|
||||||
"sampling": hasattr(self.context, "sample") and callable(self.context.sample),
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(f"Detected MCP capabilities: {capabilities}")
|
|
||||||
return capabilities
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def install_hooks(self) -> None:
|
|
||||||
"""Install middleware hooks into the target tool"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def remove_hooks(self) -> None:
|
|
||||||
"""Remove middleware hooks from the target tool"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_interaction_points(self) -> list[str]:
|
|
||||||
"""Return list of operations that require user interaction"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def activate(self):
|
|
||||||
"""Context manager for middleware lifecycle"""
|
|
||||||
try:
|
|
||||||
await self.install_hooks()
|
|
||||||
await self._log_operation_start()
|
|
||||||
yield self
|
|
||||||
except Exception as e:
|
|
||||||
await self._log_error(f"Middleware activation failed: {e}")
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
await self._log_operation_end()
|
|
||||||
await self.remove_hooks()
|
|
||||||
|
|
||||||
# Enhanced logging methods
|
|
||||||
async def _log_info(self, message: str, **kwargs) -> None:
|
|
||||||
"""Log informational message to MCP context"""
|
|
||||||
if self.capabilities["logging"]:
|
|
||||||
try:
|
|
||||||
await self.context.log(level="info", message=message, **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to log info message: {e}")
|
|
||||||
|
|
||||||
async def _log_warning(self, message: str, **kwargs) -> None:
|
|
||||||
"""Log warning message to MCP context"""
|
|
||||||
if self.capabilities["logging"]:
|
|
||||||
try:
|
|
||||||
await self.context.log(level="warning", message=f"⚠️ {message}", **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to log warning message: {e}")
|
|
||||||
|
|
||||||
async def _log_error(self, message: str, **kwargs) -> None:
|
|
||||||
"""Log error message to MCP context"""
|
|
||||||
if self.capabilities["logging"]:
|
|
||||||
try:
|
|
||||||
await self.context.log(level="error", message=f"❌ {message}", **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to log error message: {e}")
|
|
||||||
|
|
||||||
async def _log_success(self, message: str, **kwargs) -> None:
|
|
||||||
"""Log success message to MCP context"""
|
|
||||||
if self.capabilities["logging"]:
|
|
||||||
try:
|
|
||||||
await self.context.log(level="info", message=f"✅ {message}", **kwargs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to log success message: {e}")
|
|
||||||
|
|
||||||
async def _update_progress(
|
|
||||||
self,
|
|
||||||
percentage: float,
|
|
||||||
message: str = "",
|
|
||||||
current: int | None = None,
|
|
||||||
total: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Update operation progress"""
|
|
||||||
if self.capabilities["progress"]:
|
|
||||||
try:
|
|
||||||
await self.context.progress(
|
|
||||||
operation_id=self.operation_id,
|
|
||||||
progress=percentage,
|
|
||||||
total=total or 100,
|
|
||||||
current=current or int(percentage),
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store progress history
|
|
||||||
self.progress_history.append(
|
|
||||||
{
|
|
||||||
"timestamp": time.time(),
|
|
||||||
"percentage": percentage,
|
|
||||||
"message": message,
|
|
||||||
"current": current,
|
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to update progress: {e}")
|
|
||||||
|
|
||||||
async def _request_user_confirmation(
|
|
||||||
self, prompt: str, default: bool = True, cache_key: str | None = None
|
|
||||||
) -> bool:
|
|
||||||
"""Request user confirmation with optional caching"""
|
|
||||||
|
|
||||||
# Use cache key or prompt as key
|
|
||||||
confirmation_key = cache_key or prompt
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
if confirmation_key in self.user_confirmations:
|
|
||||||
logger.debug(f"Using cached confirmation for: {confirmation_key}")
|
|
||||||
return self.user_confirmations[confirmation_key]
|
|
||||||
|
|
||||||
if self.capabilities["elicitation"]:
|
|
||||||
try:
|
|
||||||
response = await self.context.request_user_input(
|
|
||||||
prompt=prompt, input_type="confirmation", additional_data={"default": default}
|
|
||||||
)
|
|
||||||
|
|
||||||
confirmed = response.get("confirmed", default)
|
|
||||||
self.user_confirmations[confirmation_key] = confirmed
|
|
||||||
|
|
||||||
await self._log_info(
|
|
||||||
f"User confirmation: {prompt} -> {'Yes' if confirmed else 'No'}"
|
|
||||||
)
|
|
||||||
return confirmed
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await self._log_warning(f"User confirmation failed: {e}")
|
|
||||||
return default
|
|
||||||
else:
|
|
||||||
# No elicitation support, use default
|
|
||||||
await self._log_info(
|
|
||||||
f"Auto-confirming (no elicitation): {prompt} -> {'Yes' if default else 'No'}"
|
|
||||||
)
|
|
||||||
return default
|
|
||||||
|
|
||||||
async def _handle_stage_start(self, stage_message: str) -> None:
|
|
||||||
"""Handle stage start with potential user interaction"""
|
|
||||||
self.active_stages.append(stage_message)
|
|
||||||
await self._log_info(f"🔄 Starting: {stage_message}")
|
|
||||||
|
|
||||||
# Check if this stage requires user confirmation
|
|
||||||
if self._requires_user_interaction(stage_message):
|
|
||||||
confirmed = await self._request_user_confirmation(
|
|
||||||
f"🤔 About to: {stage_message}. Continue?",
|
|
||||||
default=True,
|
|
||||||
cache_key=f"stage_{stage_message}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not confirmed:
|
|
||||||
await self._log_error(f"Operation cancelled by user: {stage_message}")
|
|
||||||
raise RuntimeError(f"User cancelled operation: {stage_message}")
|
|
||||||
|
|
||||||
async def _handle_stage_end(self, stage_message: str | None = None) -> None:
|
|
||||||
"""Handle stage completion"""
|
|
||||||
if self.active_stages:
|
|
||||||
completed_stage = stage_message or self.active_stages.pop()
|
|
||||||
await self._log_success(f"Completed: {completed_stage}")
|
|
||||||
elif stage_message:
|
|
||||||
await self._log_success(f"Completed: {stage_message}")
|
|
||||||
|
|
||||||
def _requires_user_interaction(self, operation: str) -> bool:
|
|
||||||
"""Determine if operation requires user confirmation"""
|
|
||||||
critical_keywords = [
|
|
||||||
"erase",
|
|
||||||
"burn",
|
|
||||||
"encrypt",
|
|
||||||
"secure",
|
|
||||||
"factory",
|
|
||||||
"reset",
|
|
||||||
"delete",
|
|
||||||
"remove",
|
|
||||||
"clear",
|
|
||||||
"format",
|
|
||||||
"destroy",
|
|
||||||
]
|
|
||||||
|
|
||||||
operation_lower = operation.lower()
|
|
||||||
return any(keyword in operation_lower for keyword in critical_keywords)
|
|
||||||
|
|
||||||
def _format_message(self, message: str, *args) -> str:
|
|
||||||
"""Format message with optional arguments"""
|
|
||||||
try:
|
|
||||||
return message % args if args else message
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return f"{message} {' '.join(map(str, args))}" if args else message
|
|
||||||
|
|
||||||
async def _log_operation_start(self) -> None:
|
|
||||||
"""Log operation start"""
|
|
||||||
await self._log_info(f"🔧 Operation started: {self.operation_id}")
|
|
||||||
|
|
||||||
async def _log_operation_end(self) -> None:
|
|
||||||
"""Log operation completion with statistics"""
|
|
||||||
duration = time.time() - self.operation_start_time
|
|
||||||
await self._log_info(
|
|
||||||
f"⏱️ Operation completed: {self.operation_id} "
|
|
||||||
f"(duration: {duration:.2f}s, "
|
|
||||||
f"progress_updates: {len(self.progress_history)}, "
|
|
||||||
f"confirmations: {len(self.user_confirmations)})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_operation_statistics(self) -> dict[str, Any]:
|
|
||||||
"""Get operation statistics for analysis"""
|
|
||||||
duration = time.time() - self.operation_start_time
|
|
||||||
|
|
||||||
return {
|
|
||||||
"operation_id": self.operation_id,
|
|
||||||
"duration_seconds": round(duration, 2),
|
|
||||||
"progress_updates": len(self.progress_history),
|
|
||||||
"user_confirmations": len(self.user_confirmations),
|
|
||||||
"stages_completed": len(self.active_stages),
|
|
||||||
"capabilities_used": [cap for cap, available in self.capabilities.items() if available],
|
|
||||||
"start_time": self.operation_start_time,
|
|
||||||
"end_time": time.time(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MiddlewareError(Exception):
|
|
||||||
"""Base exception for middleware-related errors"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ToolNotFoundError(MiddlewareError):
|
|
||||||
"""Raised when target CLI tool is not found or available"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class HookInstallationError(MiddlewareError):
|
|
||||||
"""Raised when middleware hooks cannot be installed"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UserCancellationError(MiddlewareError):
|
|
||||||
"""Raised when user cancels an operation"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
"""
|
|
||||||
Middleware Factory
|
|
||||||
|
|
||||||
Provides factory methods for creating appropriate middleware instances
|
|
||||||
based on target CLI tools and operation context.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from fastmcp import Context
|
|
||||||
|
|
||||||
from .esptool_middleware import ESPToolMiddleware
|
|
||||||
from .logger_interceptor import LoggerInterceptor, ToolNotFoundError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MiddlewareFactory:
|
|
||||||
"""Factory for creating CLI tool middleware instances"""
|
|
||||||
|
|
||||||
# Registry of available middleware classes
|
|
||||||
_middleware_registry: dict[str, type[LoggerInterceptor]] = {
|
|
||||||
"esptool": ESPToolMiddleware,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_middleware(
|
|
||||||
cls, tool_name: str, context: Context, operation_id: str | None = None, **kwargs
|
|
||||||
) -> LoggerInterceptor:
|
|
||||||
"""
|
|
||||||
Create middleware instance for specified CLI tool
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: Name of the CLI tool (e.g., 'esptool')
|
|
||||||
context: FastMCP context for logging and user interaction
|
|
||||||
operation_id: Unique identifier for this operation
|
|
||||||
**kwargs: Additional parameters for middleware initialization
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured middleware instance
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ToolNotFoundError: If tool is not supported
|
|
||||||
"""
|
|
||||||
if tool_name not in cls._middleware_registry:
|
|
||||||
available_tools = ", ".join(cls._middleware_registry.keys())
|
|
||||||
raise ToolNotFoundError(
|
|
||||||
f"No middleware available for tool: {tool_name}. Available tools: {available_tools}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate operation ID if not provided
|
|
||||||
if operation_id is None:
|
|
||||||
operation_id = f"{tool_name}_{uuid4().hex[:8]}"
|
|
||||||
|
|
||||||
# Get middleware class and create instance
|
|
||||||
middleware_class = cls._middleware_registry[tool_name]
|
|
||||||
|
|
||||||
try:
|
|
||||||
middleware = middleware_class(context, operation_id, **kwargs)
|
|
||||||
logger.info(f"Created {tool_name} middleware with operation ID: {operation_id}")
|
|
||||||
return middleware
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create {tool_name} middleware: {e}")
|
|
||||||
raise ToolNotFoundError(f"Failed to initialize {tool_name} middleware: {e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def register_middleware(cls, tool_name: str, middleware_class: type[LoggerInterceptor]) -> None:
|
|
||||||
"""
|
|
||||||
Register a new middleware class for a CLI tool
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: Name of the CLI tool
|
|
||||||
middleware_class: Middleware class that extends LoggerInterceptor
|
|
||||||
"""
|
|
||||||
if not issubclass(middleware_class, LoggerInterceptor):
|
|
||||||
raise ValueError(f"Middleware class must extend LoggerInterceptor: {middleware_class}")
|
|
||||||
|
|
||||||
cls._middleware_registry[tool_name] = middleware_class
|
|
||||||
logger.info(f"Registered middleware for tool: {tool_name}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_supported_tools(cls) -> dict[str, str]:
|
|
||||||
"""
|
|
||||||
Get list of supported CLI tools and their descriptions
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping tool names to descriptions
|
|
||||||
"""
|
|
||||||
tool_descriptions = {
|
|
||||||
"esptool": "ESP32/ESP8266 programming and debugging tool",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tool: tool_descriptions.get(tool, "CLI tool integration")
|
|
||||||
for tool in cls._middleware_registry.keys()
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_tool_supported(cls, tool_name: str) -> bool:
|
|
||||||
"""Check if a CLI tool is supported by middleware"""
|
|
||||||
return tool_name in cls._middleware_registry
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_esptool_middleware(
|
|
||||||
cls, context: Context, operation_id: str | None = None, **kwargs
|
|
||||||
) -> ESPToolMiddleware:
|
|
||||||
"""
|
|
||||||
Convenience method to create ESPTool middleware with proper typing
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: FastMCP context
|
|
||||||
operation_id: Optional operation identifier
|
|
||||||
**kwargs: Additional ESPTool-specific parameters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured ESPToolMiddleware instance
|
|
||||||
"""
|
|
||||||
middleware = cls.create_middleware("esptool", context, operation_id, **kwargs)
|
|
||||||
return middleware # Type checker knows this is ESPToolMiddleware
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_middleware_info(cls, tool_name: str) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get information about a specific middleware
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: Name of the CLI tool
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with middleware information
|
|
||||||
"""
|
|
||||||
if not cls.is_tool_supported(tool_name):
|
|
||||||
return {"error": f"Tool not supported: {tool_name}"}
|
|
||||||
|
|
||||||
middleware_class = cls._middleware_registry[tool_name]
|
|
||||||
|
|
||||||
# Create temporary instance to get interaction points
|
|
||||||
# (without context, for info purposes only)
|
|
||||||
try:
|
|
||||||
# Use a dummy context for information gathering
|
|
||||||
class DummyContext:
|
|
||||||
pass
|
|
||||||
|
|
||||||
temp_instance = middleware_class(DummyContext(), "info_query")
|
|
||||||
interaction_points = temp_instance.get_interaction_points()
|
|
||||||
except Exception:
|
|
||||||
interaction_points = []
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tool_name": tool_name,
|
|
||||||
"middleware_class": middleware_class.__name__,
|
|
||||||
"description": cls.get_supported_tools()[tool_name],
|
|
||||||
"interaction_points": interaction_points,
|
|
||||||
"module": middleware_class.__module__,
|
|
||||||
}
|
|
||||||
@ -5,9 +5,7 @@ This is the core server that orchestrates all ESP development components using F
|
|||||||
Provides AI-powered ESP32/ESP8266 development workflows with production-grade capabilities.
|
Provides AI-powered ESP32/ESP8266 development workflows with production-grade capabilities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import signal
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
132
uv.lock
generated
132
uv.lock
generated
@ -67,88 +67,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitarray"
|
|
||||||
version = "3.7.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/99/b6/282f5f0331b3877d4e79a8aa1cf63b5113a10f035a39bef1fa1dfe9e9e09/bitarray-3.7.1.tar.gz", hash = "sha256:795b1760418ab750826420ae24f06f392c08e21dc234f0a369a69cc00444f8ec", size = 150474, upload-time = "2025-08-28T22:18:15.346Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/98/bafe556fe4d97a975fa5c31965aaa282388cc91073aca57a2de206745b11/bitarray-3.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a05982bb49c73463cb0f0f4bed2d8da82631708a2c2d1926107ba99651b419ec", size = 147651, upload-time = "2025-08-28T22:14:53.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/87/639c1e4d869ecd7c23d517c326bfee7ab43ade5d5bd0f6ad3373edc861a8/bitarray-3.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d30e7daaf228e3d69cdd8b02c0dd4199cec034c4b93c80109f56f4675a6db957", size = 143967, upload-time = "2025-08-28T22:14:55.333Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/e9/8248a05b35f3e3667ceb103febb0d687d3f7314e4692b2048d21ed943a4e/bitarray-3.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:160f449bb91686f8fc9984200e78b8d793b79e382decf7eb1dc9948d7c21b36f", size = 319901, upload-time = "2025-08-28T22:14:56.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/e8/47f9d8eebb793b6828baf76027b9eefc4e5e09f32b84a25821c4bc19c3c4/bitarray-3.7.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6542e1cfe060badd160cd383ad93a84871595c14bb05fb8129f963248affd946", size = 339005, upload-time = "2025-08-28T22:14:58.291Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/73/2c4695e5acd89d9904c5b3bea7b5b06df86dea15653eee6008881d18a632/bitarray-3.7.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b723f9d10f7d8259f010b87fa66e924bb4d67927d9dcff4526a755e9ee84fef4", size = 329495, upload-time = "2025-08-28T22:14:59.722Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/d9/dc17b9f5b7b750dc9183db0520e197f1ca635dedd48e37ad00ca450d2fab/bitarray-3.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca4b6298c89b92d6b0a67dfc5f98d68ae92b08101d227263ef2033b9c9a03a72", size = 322141, upload-time = "2025-08-28T22:15:00.829Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/45/8fb00265c1b0313070e0a4b09a2f585fd3ee174aaa5352d971069983c983/bitarray-3.7.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:567d6891cb1ddbfd0051fcff3cb1bb86efc82ec818d9c5f98c37d59c1d23cc96", size = 310422, upload-time = "2025-08-28T22:15:01.964Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/77/04cb016694ae16ffe1a146f1a764b79e71f3ddbc7b9d78069594507c9762/bitarray-3.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:37a6a8382864a1defb5b370b66a635e04358c7334054457bbbb8645610cd95b2", size = 314796, upload-time = "2025-08-28T22:15:04.468Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/4f/8e15934995c5362e645ea27d9521e6b29953dc9f8df59e74525c8022e347/bitarray-3.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:01e3ba46c2dee6d47a4ab22561a01d8ee6772f681defc9fcb357097a055e48cf", size = 311222, upload-time = "2025-08-28T22:15:05.846Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/d2/9cc6df1ab5b9d10904bf78820e2427cf9b373376ca82af64a0b31eff7b31/bitarray-3.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:477b9456eb7d70f385dc8f097a1d66ee40771b62e47b3b3e33406dcfbc1c6a3b", size = 339685, upload-time = "2025-08-28T22:15:06.992Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/6d/b79e5e545a928270445c6916cf2d7613a8a8434eee8de023c900a0a08e15/bitarray-3.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2965fd8ba31b04c42e4b696fad509dc5ab50663efca6eb06bb3b6d08587f3a09", size = 339660, upload-time = "2025-08-28T22:15:08.068Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/33/8b836518ba16a85c75c177aa0d6658e843b4b0c1ec5994fb9f1b28e9440d/bitarray-3.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc76ad7453816318d794248fba4032967eaffd992d76e5d1af10ef9d46589770", size = 320079, upload-time = "2025-08-28T22:15:09.276Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/8e/87603ccf798c99296fdb26b9297350f44f87cb2aced76d3b8b0446ac8cd2/bitarray-3.7.1-cp310-cp310-win32.whl", hash = "sha256:d3f38373d9b2629dedc559e647010541cc4ec4ad9bea560e2eb1017e6a00d9ef", size = 141228, upload-time = "2025-08-28T22:15:10.383Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/06/7003c5520d2bb36edb68b016b1a83ddd5946da67b9d9982b12a8ef68d706/bitarray-3.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:e39f5e85e1e3d7d84ac2217cd095b3678306c979e991532df47012880e02215d", size = 147988, upload-time = "2025-08-28T22:15:11.718Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/0b/6fc7221d6d6508b2648f2b99dda9188dc46640023e6c2d3fb78070013901/bitarray-3.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac39319e6322c2c093a660c02cea6bb3b1ae53d049b573d4781df8896e443e04", size = 147645, upload-time = "2025-08-28T22:15:12.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/96/122ef83579cde311e77d5da284b71dfb5ab1c38250b6a97a4f4adae4ef5a/bitarray-3.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a43f4631ecb87bedc510568fef67db53f2a20c4a5953a9d1e07457e7b1d14911", size = 143971, upload-time = "2025-08-28T22:15:14.374Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/f9/cd0e27f8399b930fcea8b87b36de0ba8c88e8f953dbc98e81ca322352d24/bitarray-3.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffd112646486a31ea5a45aa1eca0e2cd90b6a12f67e848e50349e324c24cc2e7", size = 327521, upload-time = "2025-08-28T22:15:15.381Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/ad/f64f4be628536404c9576a0a40b10f5304bb37a69fb6cb37987e9ae92782/bitarray-3.7.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0441e80773d747a1ed9edfb9f75e7acb68ce8627583bbb6f770b7ec49f0064", size = 347583, upload-time = "2025-08-28T22:15:16.708Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/82/98774e33b3286fd83c6e48f5fb4e362d39b531011b4e1dd5aeba9dfdd3b8/bitarray-3.7.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef5a99a8d1a5c47b4cf85925d1420fc4ee584c98be8efc548651447b3047242f", size = 338572, upload-time = "2025-08-28T22:15:20.235Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/cc/aadc3bf1382d9660f755d74b3275c866a20e01ad2062cc777b2378423e97/bitarray-3.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb7af369df317527d697c5bb37ab944bb9a17ea1a5e82e47d5c7c638f3ccdd6", size = 329984, upload-time = "2025-08-28T22:15:21.684Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/ba/f9db45b9d6d01793afe62190c3f58bfe1969bd5798612663225560c24d94/bitarray-3.7.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eda67136343db96752e58ef36ac37116f36cba40961e79fd0e9bd858f5a09b38", size = 318777, upload-time = "2025-08-28T22:15:22.816Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/1b/18d11fe8f3192be5c2986d0faada5b3c9c0e43082ba031c12c75ebc64fd2/bitarray-3.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:79038bf1a7b13d243e51f4b6909c6997c2ba2bffc45bcae264704308a2d17198", size = 322772, upload-time = "2025-08-28T22:15:24.063Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/20/3aaf1c21af0f8dca623d06f12ce44fb45f94c10c6550e8d2e57d811b1881/bitarray-3.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d12c45da97b2f31d0233e15f8d68731cfa86264c9f04b2669b9fdf46aaf68e1f", size = 318773, upload-time = "2025-08-28T22:15:25.536Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/80/2d066264b1f3b3c495e12c55a9d0955733e890388d63ba75c408bb936fb7/bitarray-3.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:64d1143e90299ba8c967324840912a63a903494b1870a52f6675bda53dc332f7", size = 347391, upload-time = "2025-08-28T22:15:26.646Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/4b/819d5614433881ae779a6b23dd74d399c790777e3f084a270851059a77b2/bitarray-3.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c4e04c12f507942f1ddf215cb3a08c244d24051cdd2ba571060166ce8a92be16", size = 347719, upload-time = "2025-08-28T22:15:27.851Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/63/a278c08f1e47711f71e396135c0d6d38811f551613b84af8ac7901bfaea9/bitarray-3.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddc646cec4899a137c134b13818469e4178a251d77f9f4b23229267e3da78cfb", size = 328197, upload-time = "2025-08-28T22:15:29.392Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/73/6a74193cf565b01747ebd7979752060128e6c1423378471b04d8ed28b6f0/bitarray-3.7.1-cp311-cp311-win32.whl", hash = "sha256:a23b5f13f9b292004e94b0b13fead4dae79c7512db04dc817ff2c2478298e04a", size = 141377, upload-time = "2025-08-28T22:15:30.471Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/03/7bbaadf90b282c7f1bc21c3c4855ce869d3ecd444071b1dab55baaec9328/bitarray-3.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:acc56700963f63307ac096689d4547e8061028a66bb78b90e42c5da2898898fb", size = 148203, upload-time = "2025-08-28T22:15:31.525Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/27/46b5b4dabecf84f750587cded3640658448d27c59f4dd2cbaa589085f43a/bitarray-3.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b99a0347bc6131046c19e056a113daa34d7df99f1f45510161bc78bc8461a470", size = 147349, upload-time = "2025-08-28T22:15:32.729Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/1e/7f61150577127a1540136ba8a63ba17c661a17e721e03404fcd5833a4a05/bitarray-3.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7e274ac1975e55ebfb8166cce27e13dc99120c1d6ce9e490d7a716b9be9abb5", size = 143922, upload-time = "2025-08-28T22:15:33.963Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/b2/7c852472df8c644d05530bc0ad586fead5f23a9d176873c2c54f57e16b4e/bitarray-3.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b9a2eb7d2e0e9c2f25256d2663c0a2a4798fe3110e3ddbbb1a7b71740b4de08", size = 330277, upload-time = "2025-08-28T22:15:34.997Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/38/681340eea0997c48ef2dbf1acb0786090518704ca32f9a2c3c669bdea08e/bitarray-3.7.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e15e70a3cf5bb519e2448524d689c02ff6bcd4750587a517e2bffee06065bf27", size = 349562, upload-time = "2025-08-28T22:15:36.554Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/f4/6fc43f896af85c5b10a74b1d8a87c05915464869594131a2d7731707a108/bitarray-3.7.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c65257899bb8faf6a111297b4ff0066324a6b901318582c0453a01422c3bcd5a", size = 341249, upload-time = "2025-08-28T22:15:37.774Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/c7/1f71164799cacd44964ead87e1fc7e2f0ddec6d0519515a82d54eb8c8a13/bitarray-3.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38b0261483c59bb39ae9300ad46bf0bbf431ab604266382d986a349c96171b36", size = 332874, upload-time = "2025-08-28T22:15:38.935Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/cd/4d7c19064fa7fe94c2818712695fa186a1d0bb9c5cb0cf34693df81d3202/bitarray-3.7.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2b1ed363a4ef5622dccbf7822f01b51195062c4f382b28c9bd125d046d0324c", size = 321107, upload-time = "2025-08-28T22:15:40.071Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/d2/7d5ffe491c70614c0eb4a0186666efe925a02e25ed80ebd19c5fcb1c62e8/bitarray-3.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dfde50ae55e075dcd5801e2c3ea0e749c849ed2cbbee991af0f97f1bdbadb2a6", size = 324999, upload-time = "2025-08-28T22:15:41.241Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/d9/95fb87ec72c01169dad574baf7bc9e0d2bb73975d7ea29a83920a38646f4/bitarray-3.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45660e2fabcdc1bab9699a468b312f47956300d41d6a2ea91c8f067572aaf38a", size = 321816, upload-time = "2025-08-28T22:15:42.417Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/57ac96bbd125df75219c59afa297242054c09f22548aff028a8cefa8f120/bitarray-3.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7b4a41dc183d7d16750634f65566205990f94144755a39f33da44c0350c3e1a8", size = 349342, upload-time = "2025-08-28T22:15:43.997Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/14/d28f7456d2c3b3f7898186498b6d7fd3eecab267c300fb333fc2a8d55965/bitarray-3.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8b8e07374d60040b24d1a158895d9758424db13be63d4b2fe1870e37f9dec009", size = 350501, upload-time = "2025-08-28T22:15:45.377Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/a4/0f803dc446e602b21e61315f5fa2cdec02a65340147b08f7efadba559f38/bitarray-3.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f31d8c2168bf2a52e4539232392352832c2296e07e0e14b6e06a44da574099ba", size = 331362, upload-time = "2025-08-28T22:15:46.577Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/03/25e4c4b91a33f1eae0a9e9b2b11f1eaed14e37499abbde154ff33888f5f5/bitarray-3.7.1-cp312-cp312-win32.whl", hash = "sha256:fe1f1f4010244cb07f6a079854a12e1627e4fb9ea99d672f2ceccaf6653ca514", size = 141474, upload-time = "2025-08-28T22:15:48.185Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/53/98efa8ee389e4cbd91fc7c87bfebd4e11d6f8a027eb3f9be42d1addf1f51/bitarray-3.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:f41a4b57cbc128a699e9d716a56c90c7fc76554e680fe2962f49cc4d8688b051", size = 148458, upload-time = "2025-08-28T22:15:49.256Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/7f/16d59c041b0208bc1003fcfbf466f1936b797440e6119ce0adca7318af48/bitarray-3.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e62892645f6a214eefb58a42c3ed2501af2e40a797844e0e09ec1e400ce75f3d", size = 147343, upload-time = "2025-08-28T22:15:50.617Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/fb/5add457d3faa0e17fde5e220bb33c0084355b9567ff9bcba2fe70fef3626/bitarray-3.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3092f6bbf4a75b1e6f14a5b1030e27c435f341afeb23987115e45a25cc68ba91", size = 143904, upload-time = "2025-08-28T22:15:52.06Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/b9/c5ab584bb8d0ba1ec72eaac7fc1e712294db77a6230c033c9b15a2de33ae/bitarray-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:851398428f5604c53371b72c5e0a28163274264ada4a08cd1eafe65fde1f68d0", size = 330206, upload-time = "2025-08-28T22:15:53.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/cd/a4d95232a2374ce55e740fbb052a1e3a9aa52e96c7597d9152b1c9d79ecc/bitarray-3.7.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa05460dc4f57358680b977b4a254d331b24c8beb501319b998625fd6a22654b", size = 349372, upload-time = "2025-08-28T22:15:55.043Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/6c/8fb54cea100bd9358a7478d392042845800e809ab3a00873f2f0ae3d0306/bitarray-3.7.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ad0df7886cb9d6d2ff75e87d323108a0e32bdca5c9918071681864129ce8ea8", size = 341120, upload-time = "2025-08-28T22:15:56.372Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/eb/dcbb1782bf93afa2baccbc1206bb1053f61fe999443e9180e7d9be322565/bitarray-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55c31bc3d2c9e48741c812ee5ce4607c6f33e33f339831c214d923ffc7777d21", size = 332759, upload-time = "2025-08-28T22:15:57.984Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/f2/164aed832c5ece367d5347610cb7e50e5706ca1a882b9f172cb84669f591/bitarray-3.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44f468fb4857fff86c65bec5e2fb67067789e40dad69258e9bb78fc6a6df49e7", size = 320992, upload-time = "2025-08-28T22:16:01.039Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/35/fd51da63ad364d5c03690bb895e34b20c9bedce10c6d0b4d7ed7677c4b09/bitarray-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:340c524c7c934b61d1985d805bffe7609180fb5d16ece6ce89b51aa535b936f2", size = 324987, upload-time = "2025-08-28T22:16:02.327Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/f3/3f4f31a80f343c6c3360ca4eac04f471bf009b6346de745016f8b4990bad/bitarray-3.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0751596f60f33df66245b2dafa3f7fbe13cb7ac91dd14ead87d8c2eec57cb3ed", size = 321816, upload-time = "2025-08-28T22:16:03.751Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/60/26ce8cff96255198581cb88f9566820d6b3c262db4c185995cc5537b3d07/bitarray-3.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e501bd27c795105aaba02b5212ecd1bb552ca2ee2ede53e5a8cb74deee0e2052", size = 349354, upload-time = "2025-08-28T22:16:04.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/f8/e2edda9c37ba9be5349beb145dcad14d8d339f7de293b4b2bd770227c5a7/bitarray-3.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe2493d3f49e314e573022ead4d8c845c9748979b7eb95e815429fe947c4bde2", size = 350491, upload-time = "2025-08-28T22:16:06.778Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/c5/b82dd6bd8699ad818c13ae02b6acfc6c38c9278af1f71005b5d0c5f29338/bitarray-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f1575cc0f66aa70a0bb5cb57c8d9d1b7d541d920455169c6266919bf804dc20", size = 331367, upload-time = "2025-08-28T22:16:08.53Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/82/03613ad262d6e2a76b906dd279de26694910a95e4ed8ebde57c9fd3f3aa7/bitarray-3.7.1-cp313-cp313-win32.whl", hash = "sha256:da3dfd2776226e15d3288a3a24c7975f9ee160ba198f2efa66bc28c5ba76d792", size = 141481, upload-time = "2025-08-28T22:16:09.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/7e/1730701a865fd1e4353900d5821c96e68695aed88d121f8783aea14c4e74/bitarray-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f604bffd06b170637f8a48ddcf42074ed1e1980366ac46058e065ce04bfe2a", size = 148450, upload-time = "2025-08-28T22:16:10.959Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/1f/80316ba4ed605d005efeb0b09c97cecde2c66ac4deae9d1d698670e1525f/bitarray-3.7.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c9bf2bf29854f165a47917b8782b6cf3a7d602971bf454806208d0cbb96f797a", size = 143423, upload-time = "2025-08-28T22:17:37.879Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/c3/52a491e18ba41911455f145906b20898fe8e7955d0bcc5b20207bf2aba09/bitarray-3.7.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:002b73bf4a9f7b3ecb02260bd4dd332a6ee4d7f74ee9779a1ef342a36244d0cf", size = 139870, upload-time = "2025-08-28T22:17:39.266Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/df/4674d16f39841fc71db6ecc6298390cbb91a7dd8c4eccd55248a4ddced06/bitarray-3.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:481239cd0966f965c2b8fa78b88614be5f12a64e7773bb5feecc567d39bb2dd5", size = 148773, upload-time = "2025-08-28T22:17:40.81Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/85/9cd8bc811ab446491a5bdc47a70d6d51adb21e3b005b549d2fd5e04f5c7f/bitarray-3.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f583a1fb180a123c00064fab1a3bfb9d43e574b6474be1be3f6469e0331e3e2e", size = 149609, upload-time = "2025-08-28T22:17:42.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/84/e413c51313a4093ed67f657d21519c5fc592bdb9129c0ab8c7bad226e2b8/bitarray-3.7.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3db0648536f3e08afa7ceb928153c39913f98fd50a5c3adf92a4d0d4268f213e", size = 151343, upload-time = "2025-08-28T22:17:43.749Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/4f/921176e539866a8f7428d92962861bbfa6104f2cea0cbdd578abe5768a83/bitarray-3.7.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3875578748b484638f6ea776f534e9088cfb15eee131aac051036cba40fd5d05", size = 146847, upload-time = "2025-08-28T22:17:45.209Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitstring"
|
|
||||||
version = "4.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "bitarray" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/15/a8/a80c890db75d5bdd5314b5de02c4144c7de94fd0cefcae51acaeb14c6a3f/bitstring-4.3.1.tar.gz", hash = "sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a", size = 251426, upload-time = "2025-03-22T09:39:06.978Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/2d/174566b533755ddf8efb32a5503af61c756a983de379f8ad3aed6a982d38/bitstring-4.3.1-py3-none-any.whl", hash = "sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a", size = 71930, upload-time = "2025-03-22T09:39:05.163Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.9.0"
|
version = "25.9.0"
|
||||||
@ -603,22 +521,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "esptool"
|
|
||||||
version = "5.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "bitstring" },
|
|
||||||
{ name = "click" },
|
|
||||||
{ name = "cryptography" },
|
|
||||||
{ name = "intelhex" },
|
|
||||||
{ name = "pyserial" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "reedsolo" },
|
|
||||||
{ name = "rich-click" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/03/d7d79a77dd787dbe6029809c5f81ad88912340a131c88075189f40df3aba/esptool-5.1.0.tar.gz", hash = "sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da", size = 383926, upload-time = "2025-09-16T05:27:23.715Z" }
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@ -780,15 +682,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "intelhex"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/37/1e7522494557d342a24cb236e2aec5d078fac8ed03ad4b61372586406b01/intelhex-2.3.0.tar.gz", hash = "sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093", size = 44513, upload-time = "2020-10-20T20:35:51.526Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/78/79461288da2b13ed0a13deb65c4ad1428acb674b95278fa9abf1cefe62a2/intelhex-2.3.0-py2.py3-none-any.whl", hash = "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", size = 50914, upload-time = "2020-10-20T20:35:50.162Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "isodate"
|
name = "isodate"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@ -1020,7 +913,6 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncio-mqtt" },
|
{ name = "asyncio-mqtt" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "esptool" },
|
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyserial" },
|
{ name = "pyserial" },
|
||||||
@ -1060,7 +952,6 @@ requires-dist = [
|
|||||||
{ name = "asyncio-mqtt", specifier = ">=0.16.0" },
|
{ name = "asyncio-mqtt", specifier = ">=0.16.0" },
|
||||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
|
||||||
{ name = "click", specifier = ">=8.0.0" },
|
{ name = "click", specifier = ">=8.0.0" },
|
||||||
{ name = "esptool", specifier = ">=5.0.0" },
|
|
||||||
{ name = "factory-boy", marker = "extra == 'testing'", specifier = ">=3.3.0" },
|
{ name = "factory-boy", marker = "extra == 'testing'", specifier = ">=3.3.0" },
|
||||||
{ name = "fastmcp", specifier = ">=2.12.4" },
|
{ name = "fastmcp", specifier = ">=2.12.4" },
|
||||||
{ name = "gunicorn", marker = "extra == 'production'", specifier = ">=21.0.0" },
|
{ name = "gunicorn", marker = "extra == 'production'", specifier = ">=21.0.0" },
|
||||||
@ -1789,15 +1680,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b6/58/f515c44ba8c6fa5daa35134b94b99661ced852628c5505ead07b905c3fc7/rapidfuzz-3.14.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a4f18092db4825f2517d135445015b40033ed809a41754918a03ef062abe88a0", size = 1513859, upload-time = "2025-09-08T21:08:13.07Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/58/f515c44ba8c6fa5daa35134b94b99661ced852628c5505ead07b905c3fc7/rapidfuzz-3.14.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a4f18092db4825f2517d135445015b40033ed809a41754918a03ef062abe88a0", size = 1513859, upload-time = "2025-09-08T21:08:13.07Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reedsolo"
|
|
||||||
version = "1.7.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/61/a67338cbecf370d464e71b10e9a31355f909d6937c3a8d6b17dd5d5beb5e/reedsolo-1.7.0.tar.gz", hash = "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732", size = 59723, upload-time = "2023-01-17T05:10:19.733Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "referencing"
|
name = "referencing"
|
||||||
version = "0.36.2"
|
version = "0.36.2"
|
||||||
@ -1852,20 +1734,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rich-click"
|
|
||||||
version = "1.9.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "click" },
|
|
||||||
{ name = "rich" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/29/c2/f08b5e7c1a33af8a115be640aa0796ba01c4732696da6d2254391376b314/rich_click-1.9.1.tar.gz", hash = "sha256:4f2620589d7287f86265432e6a909de4f281de909fe68d8c835fbba49265d268", size = 73109, upload-time = "2025-09-20T22:40:35.362Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/77/e9144dcf68a0b3f3f4386986f97255c3d9f7c659be58bb7a5fe8f26f3efa/rich_click-1.9.1-py3-none-any.whl", hash = "sha256:ea6114a9e081b7d68cc07b315070398f806f01bb0e0c49da56f129e672877817", size = 69759, upload-time = "2025-09-20T22:40:34.099Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich-rst"
|
name = "rich-rst"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user