Add birdcage-mcp FastMCP server for satellite dish control
34 tools (connection, movement, signal, system, satellite, console), 5 resources, 3 prompts. Backed by DemoDevice for offline testing. 46 tests passing against the demo backend via run_server_async.
This commit is contained in:
parent
16ca4892b3
commit
8a6b99bd8c
21 changed files with 3233 additions and 0 deletions
74
mcp/tests/conftest.py
Normal file
74
mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Shared fixtures for birdcage-mcp tests.
|
||||
|
||||
Uses FastMCP's run_server_async to spin up a real MCP server backed by
|
||||
DemoDevice + DemoCraftClient. No serial hardware, no subprocesses.
|
||||
"""
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from birdcage.demo import DemoCraftClient, DemoDevice
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import StreamableHttpTransport
|
||||
from fastmcp.utilities.tests import run_server_async
|
||||
|
||||
from birdcage_mcp.state import BirdcageState
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _test_lifespan(server: FastMCP):
|
||||
"""Test lifespan that pre-connects a DemoDevice."""
|
||||
device = DemoDevice()
|
||||
device.connect()
|
||||
device.initialize()
|
||||
|
||||
state = BirdcageState(
|
||||
device=device,
|
||||
craft_client=DemoCraftClient(),
|
||||
demo_mode=True,
|
||||
serial_port="/dev/demo",
|
||||
firmware_name="g2",
|
||||
connected=True,
|
||||
)
|
||||
yield state
|
||||
|
||||
|
||||
def _build_server() -> FastMCP:
|
||||
"""Build a FastMCP server with all tools registered."""
|
||||
from birdcage_mcp import prompts, resources
|
||||
from birdcage_mcp.tools import (
|
||||
connection,
|
||||
console,
|
||||
movement,
|
||||
satellite,
|
||||
signal,
|
||||
system,
|
||||
)
|
||||
|
||||
mcp = FastMCP("birdcage-test", lifespan=_test_lifespan)
|
||||
connection.register(mcp)
|
||||
movement.register(mcp)
|
||||
signal.register(mcp)
|
||||
system.register(mcp)
|
||||
satellite.register(mcp)
|
||||
console.register(mcp)
|
||||
resources.register(mcp)
|
||||
prompts.register(mcp)
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_client():
|
||||
"""Yield a connected MCP client backed by DemoDevice."""
|
||||
server = _build_server()
|
||||
async with (
|
||||
run_server_async(server) as url,
|
||||
Client(StreamableHttpTransport(url)) as client,
|
||||
):
|
||||
yield client
|
||||
|
||||
|
||||
def parse_result(result) -> dict:
|
||||
"""Extract the dict from a tool call result."""
|
||||
return json.loads(result.content[0].text)
|
||||
29
mcp/tests/test_connection.py
Normal file
29
mcp/tests/test_connection.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Tests for connection tools (connect, disconnect, status)."""
|
||||
|
||||
import pytest
|
||||
from conftest import parse_result
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_shows_connected(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("status", {})
|
||||
data = parse_result(result)
|
||||
assert data["connected"] is True
|
||||
assert data["demo_mode"] is True
|
||||
assert data["firmware"] == "g2"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connect_demo_noop(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("connect", {})
|
||||
data = parse_result(result)
|
||||
assert data["mode"] == "demo"
|
||||
assert data["status"] == "connected"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_disconnect_demo_noop(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("disconnect", {})
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "demo"
|
||||
56
mcp/tests/test_console.py
Normal file
56
mcp/tests/test_console.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Tests for raw console tool (safety-gated)."""
|
||||
|
||||
import pytest
|
||||
from conftest import parse_result
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_raw_command(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"send_raw_command", {"command": "?"}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert "response" in data
|
||||
assert "Available commands" in data["response"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_q_command_blocked(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"send_raw_command", {"command": "q"}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["blocked"] is True
|
||||
assert "power cycle" in data["error"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_q_command_blocked_with_whitespace(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"send_raw_command", {"command": " Q "}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["blocked"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_submenu_navigation(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"send_raw_command", {"command": "mot"}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert "MOT>" in data["response"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raw_motor_query(mcp_client: Client):
|
||||
# Enter mot submenu first
|
||||
await mcp_client.call_tool("send_raw_command", {"command": "mot"})
|
||||
# Query position
|
||||
result = await mcp_client.call_tool(
|
||||
"send_raw_command", {"command": "a"}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert "Angle[0]" in data["response"]
|
||||
assert "Angle[1]" in data["response"]
|
||||
100
mcp/tests/test_movement.py
Normal file
100
mcp/tests/test_movement.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Tests for movement tools."""
|
||||
|
||||
import pytest
|
||||
from conftest import parse_result
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_position(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_position", {})
|
||||
data = parse_result(result)
|
||||
assert "azimuth" in data
|
||||
assert "elevation" in data
|
||||
assert isinstance(data["azimuth"], float)
|
||||
assert isinstance(data["elevation"], float)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_move_to(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"move_to", {"azimuth": 200.0, "elevation": 40.0}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "moving"
|
||||
assert data["target_azimuth"] == 200.0
|
||||
assert data["target_elevation"] == 40.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_move_motor_az(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"move_motor", {"motor_id": 0, "degrees": 90.0}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["axis"] == "azimuth"
|
||||
assert data["target"] == 90.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_move_motor_el(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"move_motor", {"motor_id": 1, "degrees": 30.0}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["axis"] == "elevation"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_move_motor_invalid_id(mcp_client: Client):
|
||||
with pytest.raises(ToolError):
|
||||
await mcp_client.call_tool(
|
||||
"move_motor", {"motor_id": 2, "degrees": 10.0}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_home_motor(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("home_motor", {"motor_id": 1})
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "homing"
|
||||
assert data["axis"] == "elevation"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_engage_release(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("release_motors", {})
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "released"
|
||||
|
||||
result = await mcp_client.call_tool("engage_motors", {})
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "engaged"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stow(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("stow", {})
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "stowing"
|
||||
assert data["target_azimuth"] == 0.0
|
||||
assert data["target_elevation"] == 65.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_step_positions(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_step_positions", {})
|
||||
data = parse_result(result)
|
||||
assert "az_steps" in data
|
||||
assert "el_steps" in data
|
||||
assert isinstance(data["az_steps"], int)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_el_limits(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_el_limits", {})
|
||||
data = parse_result(result)
|
||||
assert data["min"] == 18.0
|
||||
assert data["max"] == 65.0
|
||||
assert data["home"] == 65.0
|
||||
86
mcp/tests/test_satellite.py
Normal file
86
mcp/tests/test_satellite.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Tests for satellite tracking tools."""
|
||||
|
||||
import pytest
|
||||
from conftest import parse_result
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_satellites(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"search_satellites", {"query": "ISS"}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["count"] >= 1
|
||||
names = [r["name"] for r in data["results"]]
|
||||
assert any("ISS" in n for n in names)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_no_results(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"search_satellites", {"query": "nonexistent_xyz"}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_with_limit(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"search_satellites", {"query": "", "limit": 3}
|
||||
)
|
||||
data = parse_result(result)
|
||||
# Empty query won't match any catalog entries
|
||||
assert data["count"] >= 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_passes(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_passes", {"norad_id": 25544}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["count"] > 0
|
||||
p = data["passes"][0]
|
||||
assert "aos_time" in p
|
||||
assert "tca_time" in p
|
||||
assert "los_time" in p
|
||||
assert "max_elevation" in p
|
||||
assert p["norad_id"] == 25544
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_next_pass(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_next_pass", {"norad_id": 25544}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["pass"] is not None
|
||||
assert data["pass"]["satellite_name"] == "ISS (ZARYA)"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_visible_targets(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_visible_targets", {}
|
||||
)
|
||||
data = parse_result(result)
|
||||
# Celestial bodies (Moon, Sun, Jupiter) are always visible
|
||||
assert data["count"] >= 1
|
||||
names = [t["name"] for t in data["targets"]]
|
||||
# At least celestial bodies should be present
|
||||
assert any(
|
||||
n in names for n in ("Moon", "Sun", "Jupiter")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_visible_targets_with_min_alt(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"get_visible_targets", {"min_alt": 80.0}
|
||||
)
|
||||
data = parse_result(result)
|
||||
# Very high min_alt should filter out most targets
|
||||
for t in data["targets"]:
|
||||
assert t["altitude"] >= 80.0
|
||||
80
mcp/tests/test_signal.py
Normal file
80
mcp/tests/test_signal.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Tests for signal measurement tools."""
|
||||
|
||||
import pytest
|
||||
from conftest import parse_result
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_rssi_default(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_rssi", {})
|
||||
data = parse_result(result)
|
||||
assert data["reads"] == 10
|
||||
assert isinstance(data["average"], int)
|
||||
assert isinstance(data["current"], int)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_rssi_custom_iterations(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_rssi", {"iterations": 5})
|
||||
data = parse_result(result)
|
||||
assert data["reads"] == 5
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_adc_rssi(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_adc_rssi", {})
|
||||
data = parse_result(result)
|
||||
assert "raw" in data
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_lock_status(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_lock_status", {})
|
||||
data = parse_result(result)
|
||||
assert "raw" in data
|
||||
assert "Lock:" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enable_lna(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("enable_lna", {})
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "lna_enabled"
|
||||
assert data["voltage"] == "13V"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_dvb_config(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_dvb_config", {})
|
||||
data = parse_result(result)
|
||||
assert "BCM" in data["raw"]
|
||||
assert "0x4515" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_channel_params(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_channel_params", {})
|
||||
data = parse_result(result)
|
||||
assert "Frequency" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_az_sweep(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"az_sweep",
|
||||
{
|
||||
"start_az": 195.0,
|
||||
"span": 10.0,
|
||||
"step_cdeg": 200,
|
||||
"num_xponders": 1,
|
||||
},
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["count"] > 0
|
||||
assert len(data["points"]) == data["count"]
|
||||
pt = data["points"][0]
|
||||
assert "az" in pt
|
||||
assert "rssi" in pt
|
||||
assert "lock" in pt
|
||||
assert "snr" in pt
|
||||
121
mcp/tests/test_system.py
Normal file
121
mcp/tests/test_system.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Tests for system and firmware tools."""
|
||||
|
||||
import pytest
|
||||
from conftest import parse_result
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_firmware_id(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_firmware_id", {})
|
||||
data = parse_result(result)
|
||||
assert "02.02.48" in data["raw"]
|
||||
assert "TWELINCH" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_motor_dynamics(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_motor_dynamics", {})
|
||||
data = parse_result(result)
|
||||
assert data["az_max_vel"] == 65.0
|
||||
assert data["el_max_vel"] == 45.0
|
||||
assert data["az_accel"] == 400.0
|
||||
assert data["el_accel"] == 400.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_max_velocity(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"set_max_velocity", {"motor_id": 0, "deg_per_sec": 30.0}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["axis"] == "azimuth"
|
||||
assert data["max_velocity"] == 30.0
|
||||
|
||||
# Verify it stuck
|
||||
result = await mcp_client.call_tool("get_motor_dynamics", {})
|
||||
data = parse_result(result)
|
||||
assert data["az_max_vel"] == 30.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_max_velocity_invalid_motor(mcp_client: Client):
|
||||
with pytest.raises(ToolError):
|
||||
await mcp_client.call_tool(
|
||||
"set_max_velocity", {"motor_id": 3, "deg_per_sec": 10.0}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_max_acceleration(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"set_max_acceleration", {"motor_id": 1, "accel": 200.0}
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["axis"] == "elevation"
|
||||
assert data["max_acceleration"] == 200.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_motor_life(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_motor_life", {})
|
||||
data = parse_result(result)
|
||||
assert "AZ total moves" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_pid_gains(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_pid_gains", {})
|
||||
data = parse_result(result)
|
||||
assert data["az"]["kp"] == 600.0
|
||||
assert data["az"]["kv"] == 60.0
|
||||
assert data["el"]["kp"] == 250.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_set_pid_gains(mcp_client: Client):
|
||||
result = await mcp_client.call_tool(
|
||||
"set_pid_gains",
|
||||
{"motor_id": 0, "kp": 500.0, "kv": 55.0, "ki": 2.0},
|
||||
)
|
||||
data = parse_result(result)
|
||||
assert data["status"] == "set"
|
||||
assert data["kp"] == 500.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_a3981_diag(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_a3981_diag", {})
|
||||
data = parse_result(result)
|
||||
assert "OK" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_a3981_modes(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("get_a3981_modes", {})
|
||||
data = parse_result(result)
|
||||
assert "AUTO" in data["step_mode"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvs_dump(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("nvs_dump", {})
|
||||
data = parse_result(result)
|
||||
assert "Disable Tracker" in data["raw"]
|
||||
assert "AZ Max Vel" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvs_read(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("nvs_read", {"index": 20})
|
||||
data = parse_result(result)
|
||||
assert data["index"] == 20
|
||||
assert "Disable Tracker" in data["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nvs_read_missing_index(mcp_client: Client):
|
||||
result = await mcp_client.call_tool("nvs_read", {"index": 999})
|
||||
data = parse_result(result)
|
||||
assert "not found" in data["raw"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue