Ryan Malloy 4a63dbbb9d 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.
2026-02-17 15:08:52 -07:00

177 lines
5.4 KiB
Python

"""Shared fixtures for skywalker-mcp tests."""
from unittest.mock import MagicMock
import pytest
import skywalker_mcp.server as srv
from skywalker_mcp.server import DeviceBridge
class MockSkyWalker1:
"""Mock SkyWalker1 device that returns plausible data without USB hardware."""
def __init__(self, verbose=False):
self.verbose = verbose
self._motor_halted = False
self._armed = False
self._lnb_on = True
self._calls = []
def _record(self, method, *args, **kwargs):
self._calls.append((method, args, kwargs))
def open(self):
self._record("open")
def close(self):
self._record("close")
def ensure_booted(self):
self._record("ensure_booted")
def get_fw_version(self):
self._record("get_fw_version")
return {"version": "3.05.0-test", "date": "2026-02-17", "raw": b"\x03\x05\x00"}
def get_config(self):
self._record("get_config")
return 0x3F
def get_usb_speed(self):
self._record("get_usb_speed")
return 2
def get_serial_number(self):
self._record("get_serial_number")
return bytes([0xDE, 0xAD, 0xBE, 0xEF])
def get_last_error(self):
self._record("get_last_error")
return 0x00
def signal_monitor(self):
self._record("signal_monitor")
return {
"snr_raw": 200, "snr_db": 8.5, "snr_pct": 42.5,
"agc1": 1200, "agc2": 800, "power_db": -45.3,
"locked": True, "lock": 0x1F, "status": 0x01,
}
def get_stream_diag(self, reset=False):
self._record("get_stream_diag", reset=reset)
return {"poll_count": 100, "overflow_count": 0, "sync_loss": 0, "armed": self._armed}
def sweep_spectrum(self, start_mhz, stop_mhz, step_mhz=5.0, dwell_ms=15):
self._record("sweep_spectrum", start_mhz, stop_mhz, step_mhz=step_mhz, dwell_ms=dwell_ms)
n_points = int((stop_mhz - start_mhz) / step_mhz) + 1
freqs = [start_mhz + i * step_mhz for i in range(n_points)]
powers = []
for f in freqs:
base = -50.0
if abs(f - 1420.0) < 5:
base += 8.0 * (1.0 - abs(f - 1420.0) / 5.0)
powers.append(base)
raw = [(int((p + 60) * 100), 0) for p in powers]
return freqs, powers, raw
def tune_monitor(self, sr_sps, freq_khz, mod_idx, fec_idx, dwell_ms):
self._record("tune_monitor", sr_sps, freq_khz, mod_idx, fec_idx, dwell_ms)
return {
"snr_raw": 180, "snr_db": 7.8, "snr_pct": 39.0,
"agc1": 1100, "agc2": 750, "power_db": -46.1,
"locked": True, "lock": 0x1F, "status": 0x01,
"dwell_ms": dwell_ms,
}
def adaptive_blind_scan(self, freq_khz, sr_min, sr_max, sr_step):
self._record("adaptive_blind_scan", freq_khz, sr_min, sr_max, sr_step)
return {"freq_khz": freq_khz, "locked": True, "sr_sps": 20000000}
def motor_halt(self):
self._record("motor_halt")
self._motor_halted = True
def motor_drive_east(self, steps):
self._record("motor_drive_east", steps)
self._motor_halted = False
def motor_drive_west(self, steps):
self._record("motor_drive_west", steps)
self._motor_halted = False
def motor_goto_position(self, slot):
self._record("motor_goto_position", slot)
def motor_goto_x(self, observer_lon, sat_lon):
self._record("motor_goto_x", observer_lon, sat_lon)
def motor_store_position(self, slot):
self._record("motor_store_position", slot)
def start_intersil(self, on=True):
self._record("start_intersil", on=on)
self._lnb_on = on
def set_lnb_voltage(self, high):
self._record("set_lnb_voltage", high)
def set_22khz_tone(self, on):
self._record("set_22khz_tone", on)
def i2c_bus_scan(self):
self._record("i2c_bus_scan")
return [0x08, 0x61, 0x51]
def i2c_raw_read(self, slave, register):
self._record("i2c_raw_read", slave, register)
return 0xAB
def arm_transfer(self, on):
self._record("arm_transfer", on)
self._armed = on
def read_stream(self, timeout=500):
self._record("read_stream", timeout=timeout)
if self._armed:
return bytes([0x47, 0x00, 0x00, 0x10] + [0xFF] * 184)
return None
class MockContext:
"""Minimal mock of FastMCP Context for direct tool function calls.
Provides the bridge via request_context.lifespan_context["bridge"],
matching what _get_bridge(ctx) expects.
"""
def __init__(self, bridge: DeviceBridge):
self.request_context = MagicMock()
self.request_context.lifespan_context = {"bridge": bridge}
self._progress = []
async def report_progress(self, current, total):
self._progress.append((current, total))
@pytest.fixture
def mock_device():
"""Provide a fresh MockSkyWalker1 instance."""
return MockSkyWalker1()
@pytest.fixture
def bridge(mock_device):
"""Provide a DeviceBridge wrapping the mock device."""
b = DeviceBridge(mock_device)
srv._bridge = b
yield b
b.cancel_motor_watchdog()
srv._bridge = None
@pytest.fixture
def ctx(bridge):
"""Provide a MockContext wired to the bridge."""
return MockContext(bridge)