Async progress notifications for long-running tools, connection keepalive

Convert scan, analyze, capture, and cal to async with MCP progress
reporting via FastMCP Context. Blocking serial I/O wrapped with
asyncio.to_thread() so the event loop stays free to deliver progress
notifications during hardware sweeps.

Add connection keepalive: _ensure_connected validates stale connections
with a sync probe after 30s idle, and retries on cold-start failures
(fixes flaky first-connect after MCP server restart).
This commit is contained in:
Ryan Malloy 2026-01-30 15:05:50 -07:00
parent 4569fea9f9
commit 48e91a755c

View File

@ -6,9 +6,12 @@ in server.py. The NanoVNA class manages connection lifecycle with lazy auto-conn
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import re import re
from fastmcp import Context
from mcnanovna.discovery import find_first_nanovna, find_nanovna_ports from mcnanovna.discovery import find_first_nanovna, find_nanovna_ports
from mcnanovna.protocol import ( from mcnanovna.protocol import (
SCAN_MASK_BINARY, SCAN_MASK_BINARY,
@ -45,21 +48,47 @@ POWER_DESCRIPTIONS = {
} }
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
"""Report progress if Context is available."""
if ctx:
await ctx.report_progress(progress, total, message)
class NanoVNA: class NanoVNA:
"""MCP tool class for NanoVNA-H vector network analyzers. """MCP tool class for NanoVNA-H vector network analyzers.
Manages a serial connection with lazy auto-connect: the first tool Manages a serial connection with lazy auto-connect: the first tool
call that needs hardware triggers USB discovery and initialization. call that needs hardware triggers USB discovery and initialization.
Connection is validated with a sync probe after idle periods.
""" """
_KEEPALIVE_TIMEOUT = 30.0 # seconds before re-validating connection
def __init__(self) -> None: def __init__(self) -> None:
self._protocol = NanoVNAProtocol() self._protocol = NanoVNAProtocol()
self._port: str | None = None self._port: str | None = None
self._last_ok: float = 0.0
def _ensure_connected(self) -> None: def _ensure_connected(self) -> None:
"""Auto-connect on first use, or reconnect if dropped.""" """Auto-connect on first use, reconnect if stale, retry on failure."""
import time as _time
if self._protocol.connected: if self._protocol.connected:
return # Recently active — trust the connection
if (_time.monotonic() - self._last_ok) < self._KEEPALIVE_TIMEOUT:
self._last_ok = _time.monotonic()
return
# Idle too long — validate with a sync probe
try:
if self._protocol.sync():
self._last_ok = _time.monotonic()
return
except Exception:
pass
# Stale connection — close and fall through to reconnect
self._protocol.close()
self._port = None
port_info = find_first_nanovna() port_info = find_first_nanovna()
if port_info is None: if port_info is None:
raise NanoVNAConnectionError( raise NanoVNAConnectionError(
@ -67,8 +96,21 @@ class NanoVNA:
"appears as a serial port (VID 0x0483, PID 0x5740)." "appears as a serial port (VID 0x0483, PID 0x5740)."
) )
self._port = port_info.device self._port = port_info.device
self._protocol.open(self._port)
self._protocol.initialize() # Connect with retry — first sync can fail on cold/stale serial ports
last_error: Exception | None = None
for attempt in range(2):
try:
self._protocol.open(self._port)
self._protocol.initialize()
self._last_ok = _time.monotonic()
return
except NanoVNAConnectionError as exc:
last_error = exc
self._protocol.close()
if attempt == 0:
_time.sleep(0.3)
raise last_error # type: ignore[misc]
def _has_capability(self, cmd: str) -> bool: def _has_capability(self, cmd: str) -> bool:
return cmd in self._protocol.device_info.capabilities return cmd in self._protocol.device_info.capabilities
@ -128,7 +170,7 @@ class NanoVNA:
} }
return {"start_hz": 0, "stop_hz": 0, "points": 0} return {"start_hz": 0, "stop_hz": 0, "points": 0}
def scan( async def scan(
self, self,
start_hz: int, start_hz: int,
stop_hz: int, stop_hz: int,
@ -136,6 +178,7 @@ class NanoVNA:
s11: bool = True, s11: bool = True,
s21: bool = True, s21: bool = True,
apply_cal: bool = True, apply_cal: bool = True,
ctx: Context | None = None,
) -> dict: ) -> dict:
"""Perform a frequency sweep and return S-parameter measurement data. """Perform a frequency sweep and return S-parameter measurement data.
@ -150,7 +193,9 @@ class NanoVNA:
s21: Include S21 transmission data s21: Include S21 transmission data
apply_cal: Apply stored calibration correction (set False for raw data) apply_cal: Apply stored calibration correction (set False for raw data)
""" """
self._ensure_connected() await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
mask = SCAN_MASK_OUT_FREQ mask = SCAN_MASK_OUT_FREQ
if s11: if s11:
mask |= SCAN_MASK_OUT_DATA0 mask |= SCAN_MASK_OUT_DATA0
@ -161,19 +206,26 @@ class NanoVNA:
use_binary = self._has_capability("scan_bin") use_binary = self._has_capability("scan_bin")
await _progress(ctx, 2, 4, "Sending scan command...")
if use_binary: if use_binary:
binary_mask = mask | SCAN_MASK_BINARY binary_mask = mask | SCAN_MASK_BINARY
rx_mask, rx_points, raw = self._protocol.send_binary_scan( await _progress(ctx, 3, 4, f"Waiting for sweep data ({points} points)...")
start_hz, stop_hz, points, binary_mask rx_mask, rx_points, raw = await asyncio.to_thread(
self._protocol.send_binary_scan, start_hz, stop_hz, points, binary_mask
) )
scan_points = parse_scan_binary(rx_mask, rx_points, raw) scan_points = parse_scan_binary(rx_mask, rx_points, raw)
else: else:
lines = self._protocol.send_text_command( await _progress(ctx, 3, 4, f"Waiting for sweep data ({points} points)...")
lines = await asyncio.to_thread(
self._protocol.send_text_command,
f"scan {start_hz} {stop_hz} {points} {mask}", f"scan {start_hz} {stop_hz} {points} {mask}",
timeout=30.0, 30.0,
) )
scan_points = parse_scan_text(lines, mask) scan_points = parse_scan_text(lines, mask)
await _progress(ctx, 4, 4, "Parsing measurement data...")
data = [] data = []
for pt in scan_points: for pt in scan_points:
entry: dict = {} entry: dict = {}
@ -259,7 +311,7 @@ class NanoVNA:
pass pass
return {"markers": markers} return {"markers": markers}
def cal(self, step: str | None = None) -> dict: async def cal(self, step: str | None = None, ctx: Context | None = None) -> dict:
"""Query calibration status or perform a calibration step. """Query calibration status or perform a calibration step.
Steps: 'load', 'open', 'short', 'thru', 'isoln', 'done', 'on', 'off', 'reset'. Steps: 'load', 'open', 'short', 'thru', 'isoln', 'done', 'on', 'off', 'reset'.
@ -268,15 +320,19 @@ class NanoVNA:
Args: Args:
step: Calibration step to execute step: Calibration step to execute
""" """
self._ensure_connected() await asyncio.to_thread(self._ensure_connected)
if step is not None: if step is not None:
valid = {"load", "open", "short", "thru", "isoln", "done", "on", "off", "reset"} valid = {"load", "open", "short", "thru", "isoln", "done", "on", "off", "reset"}
if step not in valid: if step not in valid:
return {"error": f"Invalid step '{step}'. Valid: {', '.join(sorted(valid))}"} return {"error": f"Invalid step '{step}'. Valid: {', '.join(sorted(valid))}"}
lines = self._protocol.send_text_command(f"cal {step}", timeout=10.0) await _progress(ctx, 1, 2, f"Sending calibration command: {step}...")
lines = await asyncio.to_thread(
self._protocol.send_text_command, f"cal {step}", 10.0
)
await _progress(ctx, 2, 2, f"Calibration step '{step}' complete")
return {"step": step, "response": lines} return {"step": step, "response": lines}
lines = self._protocol.send_text_command("cal") lines = await asyncio.to_thread(self._protocol.send_text_command, "cal")
return {"status": lines} return {"status": lines}
def save(self, slot: int) -> dict: def save(self, slot: int) -> dict:
@ -418,29 +474,19 @@ class NanoVNA:
pass pass
return {"voltage_mv": 0, "voltage_v": 0.0, "raw": lines} return {"voltage_mv": 0, "voltage_v": 0.0, "raw": lines}
def capture(self, raw: bool = False): def _capture_raw_bytes(self) -> tuple[int, int, bytearray]:
"""Capture the current LCD screen as RGB565 pixel data (base64 encoded). """Read raw RGB565 pixel data from the device. Blocking serial I/O."""
import time
Returns width, height, and raw pixel data for rendering. The pixel format
is RGB565 (16-bit, 2 bytes per pixel). Total size = width * height * 2 bytes.
Args:
raw: If True, return raw RGB565 data as a dict with base64-encoded bytes.
If False (default), convert to PNG and return as an Image.
"""
self._ensure_connected()
di = self._protocol.device_info di = self._protocol.device_info
width = di.lcd_width width = di.lcd_width
height = di.lcd_height height = di.lcd_height
expected_size = width * height * 2 expected_size = width * height * 2
# capture command outputs raw binary RGB565 data after echo line
self._protocol._drain() self._protocol._drain()
self._protocol._send_command("capture") self._protocol._send_command("capture")
ser = self._protocol._require_connection() ser = self._protocol._require_connection()
import time
old_timeout = ser.timeout old_timeout = ser.timeout
ser.timeout = 10.0 ser.timeout = 10.0
try: try:
@ -481,40 +527,61 @@ class NanoVNA:
if b"ch> " in trailing or not chunk: if b"ch> " in trailing or not chunk:
break break
if raw: return width, height, swapped
return {
"format": "rgb565",
"width": width,
"height": height,
"data_length": len(swapped),
"data_base64": base64.b64encode(bytes(swapped)).decode("ascii"),
}
# Convert RGB565 to PNG and return as MCP Image
import io
import struct as _struct
from PIL import Image as PILImage
from fastmcp.utilities.types import Image
img = PILImage.new("RGB", (width, height))
pixels = img.load()
for y in range(height):
for x in range(width):
offset = (y * width + x) * 2
pixel = _struct.unpack(">H", swapped[offset : offset + 2])[0]
r = ((pixel >> 11) & 0x1F) << 3
g = ((pixel >> 5) & 0x3F) << 2
b = (pixel & 0x1F) << 3
pixels[x, y] = (r, g, b)
buf_png = io.BytesIO()
img.save(buf_png, format="PNG")
return Image(data=buf_png.getvalue(), format="png")
finally: finally:
ser.timeout = old_timeout ser.timeout = old_timeout
async def capture(self, raw: bool = False, ctx: Context | None = None):
"""Capture the current LCD screen as RGB565 pixel data (base64 encoded).
Returns width, height, and raw pixel data for rendering. The pixel format
is RGB565 (16-bit, 2 bytes per pixel). Total size = width * height * 2 bytes.
Args:
raw: If True, return raw RGB565 data as a dict with base64-encoded bytes.
If False (default), convert to PNG and return as an Image.
"""
await _progress(ctx, 1, 3, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 3, "Reading LCD pixel data...")
width, height, swapped = await asyncio.to_thread(self._capture_raw_bytes)
if raw:
await _progress(ctx, 3, 3, "Capture complete")
return {
"format": "rgb565",
"width": width,
"height": height,
"data_length": len(swapped),
"data_base64": base64.b64encode(bytes(swapped)).decode("ascii"),
}
await _progress(ctx, 3, 3, "Encoding PNG image...")
# Convert RGB565 to PNG and return as MCP Image
import io
import struct as _struct
from PIL import Image as PILImage
from fastmcp.utilities.types import Image
img = PILImage.new("RGB", (width, height))
pixels = img.load()
for y in range(height):
for x in range(width):
offset = (y * width + x) * 2
pixel = _struct.unpack(">H", swapped[offset : offset + 2])[0]
r = ((pixel >> 11) & 0x1F) << 3
g = ((pixel >> 5) & 0x3F) << 2
b = (pixel & 0x1F) << 3
pixels[x, y] = (r, g, b)
buf_png = io.BytesIO()
img.save(buf_png, format="PNG")
return Image(data=buf_png.getvalue(), format="png")
# ── Tier 3: Advanced tools ───────────────────────────────────────── # ── Tier 3: Advanced tools ─────────────────────────────────────────
def trace( def trace(
@ -1147,6 +1214,10 @@ class NanoVNA:
Shows all running threads with their stack usage, priority, and state. Shows all running threads with their stack usage, priority, and state.
Useful for diagnosing firmware issues. Useful for diagnosing firmware issues.
TODO: When hardware with ENABLE_THREADS_COMMAND is available, explore
representing ChibiOS threads as MCP Tasks (FastMCP tasks=True) so they
surface in Claude Code's /tasks UI with live state tracking.
""" """
self._ensure_connected() self._ensure_connected()
if not self._has_capability("threads"): if not self._has_capability("threads"):
@ -1398,13 +1469,14 @@ class NanoVNA:
# ── Convenience: analyze scan data server-side ──────────────────── # ── Convenience: analyze scan data server-side ────────────────────
def analyze( async def analyze(
self, self,
start_hz: int, start_hz: int,
stop_hz: int, stop_hz: int,
points: int = 101, points: int = 101,
s11: bool = True, s11: bool = True,
s21: bool = False, s21: bool = False,
ctx: Context | None = None,
) -> dict: ) -> dict:
"""Run a scan and return comprehensive S-parameter analysis. """Run a scan and return comprehensive S-parameter analysis.
@ -1422,10 +1494,17 @@ class NanoVNA:
""" """
from mcnanovna.calculations import analyze_scan from mcnanovna.calculations import analyze_scan
scan_result = self.scan(start_hz, stop_hz, points, s11=s11, s21=s21) await _progress(ctx, 1, 5, "Connecting to NanoVNA...")
await asyncio.to_thread(self._ensure_connected)
await _progress(ctx, 2, 5, f"Scanning {points} points from {start_hz} to {stop_hz} Hz...")
scan_result = await self.scan(start_hz, stop_hz, points, s11=s11, s21=s21)
if "error" in scan_result: if "error" in scan_result:
return scan_result return scan_result
await _progress(ctx, 3, 5, f"Received {scan_result['points']} measurement points")
await _progress(ctx, 4, 5, "Calculating S-parameter metrics...")
analysis = analyze_scan(scan_result["data"]) analysis = analyze_scan(scan_result["data"])
analysis["scan_info"] = { analysis["scan_info"] = {
"start_hz": start_hz, "start_hz": start_hz,
@ -1433,4 +1512,6 @@ class NanoVNA:
"points": scan_result["points"], "points": scan_result["points"],
"binary": scan_result.get("binary", False), "binary": scan_result.get("binary", False),
} }
await _progress(ctx, 5, 5, "Analysis complete")
return analysis return analysis