Add software watchdog and timeout protection for all I2C/USB paths (firmware v3.05.0)

Motor watchdog: asyncio background task auto-halts motor after 30s of
continuous drive, fires even if LLM client disconnects. Integrated into
move_dish (start on continuous, cancel on halt/goto/gotox) and lifespan
teardown.

Test suite: 64 tests covering all 17 MCP tools — device status, spectrum
sweep validation, tune/blind-scan boundary checks, motor safety (stepped,
continuous opt-in, watchdog lifecycle), jog/store limits, LNB/I2C, TS
capture, frequency identification, and path traversal protection. Uses
MockSkyWalker1 + MockContext for direct async function testing without
USB hardware.

Fixes: FastMCP 2.x description→instructions constructor change,
parents[4] path resolution for tools directory import.
This commit is contained in:
Ryan Malloy 2026-02-17 15:08:52 -07:00
parent a9dcf84c38
commit 4a63dbbb9d
3 changed files with 741 additions and 6 deletions

View file

@ -16,7 +16,7 @@ from pathlib import Path
from fastmcp import FastMCP, Context
# Add the tools directory to path so we can import the hardware library
_TOOLS_DIR = Path(__file__).resolve().parents[3] / "tools"
_TOOLS_DIR = Path(__file__).resolve().parents[4] / "tools"
if str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
@ -34,6 +34,9 @@ from signal_analysis import adaptive_noise_floor, detect_peaks_enhanced # noqa:
from survey_engine import SurveyEngine # noqa: E402
MOTOR_WATCHDOG_SECS = 30
class DeviceBridge:
"""Thread-safe wrapper around SkyWalker1 for MCP tool access.
@ -42,11 +45,16 @@ class DeviceBridge:
Internal access pattern: always use `with bridge.lock:` then `bridge._dev.method()`.
The `call()` convenience method does this automatically for simple cases.
Includes a motor watchdog: when continuous drive is started, a background
asyncio task will automatically halt the motor after MOTOR_WATCHDOG_SECS
unless cancelled by a halt or new motor command.
"""
def __init__(self, device: SkyWalker1):
self._dev = device
self._lock = threading.RLock()
self._motor_watchdog: asyncio.Task | None = None
def call(self, method_name: str, *args, **kwargs):
"""Call a SkyWalker1 method under the lock."""
@ -57,6 +65,34 @@ class DeviceBridge:
def lock(self) -> threading.RLock:
return self._lock
def cancel_motor_watchdog(self):
"""Cancel any running motor watchdog timer."""
if self._motor_watchdog is not None and not self._motor_watchdog.done():
self._motor_watchdog.cancel()
self._motor_watchdog = None
def start_motor_watchdog(self, timeout: float = MOTOR_WATCHDOG_SECS):
"""Start or restart the motor watchdog timer.
After `timeout` seconds, the motor is automatically halted.
Any subsequent motor command or explicit halt cancels the watchdog.
"""
self.cancel_motor_watchdog()
async def _watchdog():
await asyncio.sleep(timeout)
print(
f"skywalker-mcp: MOTOR WATCHDOG fired after {timeout}s — halting motor",
file=sys.stderr,
)
with self._lock:
try:
self._dev.motor_halt()
except Exception as e:
print(f"skywalker-mcp: watchdog halt failed: {e}", file=sys.stderr)
self._motor_watchdog = asyncio.create_task(_watchdog())
# Global bridge reference, set during lifespan
_bridge: DeviceBridge | None = None
@ -74,6 +110,8 @@ async def lifespan(server: FastMCP):
print(f"skywalker-mcp: device open, fw {dev.get_fw_version()['version']}", file=sys.stderr)
yield {"bridge": _bridge}
finally:
if _bridge is not None:
_bridge.cancel_motor_watchdog()
_bridge = None
dev.close()
print("skywalker-mcp: device closed", file=sys.stderr)
@ -81,9 +119,9 @@ async def lifespan(server: FastMCP):
mcp = FastMCP(
"skywalker-mcp",
description="MCP server for the Genpix SkyWalker-1 DVB-S USB receiver. "
"Provides spectrum sweep, signal monitoring, carrier survey, "
"dish motor control, and transport stream analysis.",
instructions="MCP server for the Genpix SkyWalker-1 DVB-S USB receiver. "
"Provides spectrum sweep, signal monitoring, carrier survey, "
"dish motor control, and transport stream analysis.",
lifespan=lifespan,
)
@ -438,7 +476,8 @@ async def move_dish(
else:
bridge._dev.motor_drive_west(steps)
mode = "continuous (send halt to stop)" if steps == 0 else "stepped"
return {"action": action, "steps": steps, "mode": mode, "status": "driving"}
return {"action": action, "steps": steps, "mode": mode, "status": "driving",
"continuous": steps == 0}
elif action == "goto":
slot = int(value)
@ -463,7 +502,24 @@ async def move_dish(
else:
return {"error": f"Unknown action '{action}'. Valid: halt, east, west, goto, gotox"}
return await asyncio.to_thread(_move)
result = await asyncio.to_thread(_move)
# Motor watchdog management: cancel on halt/goto/gotox, start on continuous drive
if "error" not in result:
if action == "halt":
bridge.cancel_motor_watchdog()
elif action in ("goto", "gotox"):
# GotoX/Goto have inherent motor-stop at destination
bridge.cancel_motor_watchdog()
elif result.get("continuous"):
bridge.start_motor_watchdog()
result["watchdog_secs"] = MOTOR_WATCHDOG_SECS
result["warning"] = (
f"Motor watchdog active: auto-halt in {MOTOR_WATCHDOG_SECS}s. "
"Send action='halt' to stop sooner."
)
return result
@mcp.tool()