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.
177 lines
5.4 KiB
Python
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)
|