gr-mcp/src/gnuradio_mcp/models.py
Ryan Malloy 2084c41228 runtime: Phase 1 Docker + XML-RPC control
Add RuntimeProvider with 17 MCP tools for controlling running flowgraphs:
- Container lifecycle: launch, list, stop, remove
- Connection: connect by URL or container name
- Variable control: list, get, set via XML-RPC introspection
- Flowgraph execution: start, stop, lock, unlock
- Visual feedback: screenshot capture, container logs

Docker is optional - 10 tools work without it for external flowgraphs.

Includes:
- DockerMiddleware wrapping docker.DockerClient
- XmlRpcMiddleware wrapping xmlrpc.client.ServerProxy
- Dockerfile with Xvfb + ImageMagick + VNC for headless QT
- 29 new unit tests (71 total)
2026-01-27 09:48:44 -07:00

158 lines
3.7 KiB
Python

from __future__ import annotations
from typing import Any, Literal, Protocol, Type, get_args
from gnuradio.grc.core.blocks.block import Block
from gnuradio.grc.core.Connection import Connection
from gnuradio.grc.core.params.param import Param
from gnuradio.grc.core.ports.port import Port
from pydantic import BaseModel, field_validator
class BlockTypeModel(BaseModel):
label: str
key: str
@classmethod
def from_block_type(cls, block: Type[Block]) -> BlockTypeModel:
return cls(label=block.label, key=block.key)
class KeyedModel(Protocol):
def to_key(self) -> str:
...
class BlockModel(BaseModel):
label: str
name: str
@classmethod
def from_block(cls, block: Block) -> BlockModel:
return cls(label=block.label, name=block.name)
def to_key(self) -> str:
return f"{self.label}:{self.name}"
class ParamModel(BaseModel):
parent: str
key: str
name: str
dtype: str
value: Any
@classmethod
def from_param(cls, param: Param) -> ParamModel:
return cls(
parent=param.parent.name,
key=param.key,
name=param.name,
dtype=param.dtype,
value=param.get_value(),
)
def to_key(self) -> str:
return f"{self.parent}:{self.key}"
DirectionType = Literal["sink", "source"]
SINK, SOURCE = get_args(DirectionType)
class PortModel(BaseModel):
parent: str
key: str
name: str
dtype: str
direction: DirectionType
optional: bool = False
hidden: bool = False
@classmethod
def from_port(
cls,
port: Port,
direction: DirectionType | None = None,
) -> PortModel:
direction = direction or port._dir
return cls(
parent=port.parent.name,
key=port.key,
name=port.name,
dtype=port.dtype,
direction=direction,
optional=port.optional,
hidden=port.hidden,
)
def to_key(self) -> str:
return f"{self.parent}:{self.direction}[{self.key}]"
class ConnectionModel(BaseModel):
source: PortModel
sink: PortModel
@classmethod
def from_connection(cls, connection: Connection) -> "ConnectionModel":
return cls(
source=PortModel.from_port(connection.source_port),
sink=PortModel.from_port(connection.sink_port),
)
def to_key(self) -> str:
return f"{self.source.to_key()}-{self.sink.to_key()}"
class ErrorModel(BaseModel):
type: str
key: str
message: str
@field_validator("key", mode="before")
@classmethod
def transform_key(cls, v: KeyedModel) -> str:
return v.to_key()
# ──────────────────────────────────────────────
# Runtime Models (Phase 1: Docker + XML-RPC)
# ──────────────────────────────────────────────
class ContainerModel(BaseModel):
name: str
container_id: str
status: str
flowgraph_path: str
xmlrpc_port: int
vnc_port: int | None = None
device_paths: list[str] = []
class VariableModel(BaseModel):
name: str
value: Any
class ConnectionInfoModel(BaseModel):
url: str
container_name: str | None = None
xmlrpc_port: int
methods: list[str] = []
class ScreenshotModel(BaseModel):
container_name: str
image_base64: str
format: str = "png"
width: int | None = None
height: int | None = None
class RuntimeStatusModel(BaseModel):
connected: bool
connection: ConnectionInfoModel | None = None
containers: list[ContainerModel] = []