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:
parent
a9dcf84c38
commit
4a63dbbb9d
3 changed files with 741 additions and 6 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue