diff --git a/src/gnuradio_mcp/middlewares/oot_exporter.py b/src/gnuradio_mcp/middlewares/oot_exporter.py index fe19317..582279a 100644 --- a/src/gnuradio_mcp/middlewares/oot_exporter.py +++ b/src/gnuradio_mcp/middlewares/oot_exporter.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any from gnuradio_mcp.models import ( GeneratedBlockCode, + GrcYamlResult, OOTExportResult, OOTSkeletonResult, ) @@ -146,15 +147,15 @@ from importlib import import_module {imports} ''' -BLOCK_YML_TEMPLATE = '''id: {module_name}_{block_name} +BLOCK_YML_TEMPLATE = '''id: {block_id} label: {block_label} -category: [{module_name}] +category: [{category}] +flags: [ python ] templates: - imports: from {module_name} import {block_name} - make: {module_name}.{block_name}({make_args}) + imports: {import_line} + make: {make_line} {callbacks} - parameters: {parameters} @@ -163,7 +164,7 @@ inputs: outputs: {outputs} - +{asserts} documentation: |- {documentation} @@ -410,7 +411,7 @@ class OOTExporterMiddleware: # Generate and write .block.yml grc_dir = base_path / "grc" yml_content = self._generate_block_yml( - generated, module_name, block_name + generated, module_name, block_name, None ) yml_file = grc_dir / f"{module_name}_{block_name}.block.yml" yml_file.write_text(yml_content) @@ -513,70 +514,144 @@ class OOTExporterMiddleware: # Block YAML Generation # ────────────────────────────────────────── + def generate_grc_yaml( + self, + generated: GeneratedBlockCode, + module_name: str = "custom", + category: str | None = None, + ) -> GrcYamlResult: + """Generate a GRC YAML block definition (.block.yml). + + Produces a YAML file that makes a Python block usable in + GNU Radio Companion's graphical editor. Without this file, + the block can only be used programmatically. + + Args: + generated: Block code from generate_*_block tools + module_name: Module namespace (e.g., "custom" → id becomes "custom_blockname") + category: GRC palette category (default: module_name) + + Returns: + GrcYamlResult with YAML content and metadata. + """ + module_name = self._sanitize_module_name(module_name) + block_name = self._sanitize_block_name(generated.block_name) + + try: + yaml_content = self._generate_block_yml( + generated, module_name, block_name, category + ) + block_id = f"{module_name}_{block_name}" + filename = f"{block_id}.block.yml" + + notes = [] + if not generated.parameters: + notes.append("No parameters defined — block has no configurable properties in GRC.") + if not any( + m for m in self._parse_block_source(generated.source_code).get("callbacks", []) + ): + notes.append( + "No set_*() methods found — parameters won't update at runtime. " + "Add setter methods to enable GRC callback support." + ) + + return GrcYamlResult( + success=True, + block_id=block_id, + yaml_content=yaml_content, + filename=filename, + notes=notes, + ) + except Exception as e: + logger.exception("Failed to generate GRC YAML") + return GrcYamlResult( + success=False, + error=str(e), + ) + def _generate_block_yml( self, generated: GeneratedBlockCode, module_name: str, block_name: str, + category: str | None = None, ) -> str: """Generate .block.yml content for a block.""" - # Parse source to extract info block_info = self._parse_block_source(generated.source_code) - # Build label + block_id = f"{module_name}_{block_name}" block_label = block_name.replace("_", " ").title() + cat = category or module_name - # Build make args + # Import and make lines + import_line = f"from {module_name} import {block_name}" make_args = ", ".join([ f"{p.name}=${{{p.name}}}" for p in generated.parameters ]) if generated.parameters else "" + make_line = f"{module_name}.{block_name}({make_args})" - # Build callbacks section + # Callbacks from detected setter methods callbacks = "" if block_info.get("callbacks"): - callbacks = "callbacks:\n" + "\n".join([ + cb_lines = "\n".join([ f" - set_{name}(${{{name}}})" for name in block_info["callbacks"] ]) + callbacks = f" callbacks:\n{cb_lines}\n" - # Build parameters section + # Parameters params_yml = "" for p in generated.parameters: - params_yml += f"""- id: {p.name} - label: {p.name.replace('_', ' ').title()} - dtype: {self._python_to_grc_dtype(p.dtype)} - default: {repr(p.default)} -""" + grc_dtype = self._python_to_grc_dtype(p.dtype) + params_yml += f"- id: {p.name}\n" + params_yml += f" label: {p.name.replace('_', ' ').title()}\n" + params_yml += f" dtype: {grc_dtype}\n" + params_yml += f" default: {repr(p.default)}\n" + if p.description: + params_yml += f" # {p.description}\n" - # Build inputs section + # Asserts from parameter min/max bounds + asserts_yml = "" + assert_lines = [] + for p in generated.parameters: + if p.min_value is not None: + assert_lines.append(f"- ${{ {p.name} >= {p.min_value} }}") + if p.max_value is not None: + assert_lines.append(f"- ${{ {p.name} <= {p.max_value} }}") + if assert_lines: + asserts_yml = "asserts:\n" + "\n".join(assert_lines) + "\n" + + # Inputs inputs_yml = "" for i, inp in enumerate(generated.inputs): - inputs_yml += f"""- label: in{i} - domain: stream - dtype: {inp.dtype} - vlen: {inp.vlen} -""" + inputs_yml += f"- label: in{i}\n" + inputs_yml += f" domain: stream\n" + inputs_yml += f" dtype: {inp.dtype}\n" + if inp.vlen > 1: + inputs_yml += f" vlen: {inp.vlen}\n" - # Build outputs section + # Outputs outputs_yml = "" for i, out in enumerate(generated.outputs): - outputs_yml += f"""- label: out{i} - domain: stream - dtype: {out.dtype} - vlen: {out.vlen} -""" + outputs_yml += f"- label: out{i}\n" + outputs_yml += f" domain: stream\n" + outputs_yml += f" dtype: {out.dtype}\n" + if out.vlen > 1: + outputs_yml += f" vlen: {out.vlen}\n" - # Documentation + # Documentation from docstring or generation prompt doc = block_info.get("doc", generated.generation_prompt or "Custom block") return BLOCK_YML_TEMPLATE.format( - module_name=module_name, - block_name=block_name, + block_id=block_id, block_label=block_label, - make_args=make_args, + category=cat, + import_line=import_line, + make_line=make_line, callbacks=callbacks, parameters=params_yml or "[]", inputs=inputs_yml or "[]", outputs=outputs_yml or "[]", + asserts=asserts_yml, documentation=doc.replace("\n", "\n "), ) diff --git a/src/gnuradio_mcp/models.py b/src/gnuradio_mcp/models.py index 12d9964..d06ab67 100644 --- a/src/gnuradio_mcp/models.py +++ b/src/gnuradio_mcp/models.py @@ -677,6 +677,21 @@ class IQAnalysisResult(BaseModel): # ────────────────────────────────────────────── +class GrcYamlResult(BaseModel): + """Result of GRC YAML block definition generation. + + Contains the YAML content for a .block.yml file that makes a + block usable in GNU Radio Companion's graphical editor. + """ + + success: bool + block_id: str = "" # e.g. "custom_my_gain" + yaml_content: str = "" + filename: str = "" # e.g. "custom_my_gain.block.yml" + error: str | None = None + notes: list[str] = [] # Warnings or suggestions + + class OOTExportResult(BaseModel): """Result of exporting an embedded block to full OOT module. diff --git a/src/gnuradio_mcp/prompts/__init__.py b/src/gnuradio_mcp/prompts/__init__.py index a5e4b5a..0e25795 100644 --- a/src/gnuradio_mcp/prompts/__init__.py +++ b/src/gnuradio_mcp/prompts/__init__.py @@ -8,6 +8,7 @@ from gnuradio_mcp.prompts.sync_block import SYNC_BLOCK_PROMPT from gnuradio_mcp.prompts.basic_block import BASIC_BLOCK_PROMPT from gnuradio_mcp.prompts.decoder_chain import DECODER_CHAIN_PROMPT from gnuradio_mcp.prompts.common_patterns import COMMON_PATTERNS_PROMPT +from gnuradio_mcp.prompts.grc_yaml import GRC_YAML_PROMPT from gnuradio_mcp.prompts.protocol_templates import ( PROTOCOL_TEMPLATES, BLUETOOTH_LE_TEMPLATE, @@ -24,6 +25,7 @@ __all__ = [ "BASIC_BLOCK_PROMPT", "DECODER_CHAIN_PROMPT", "COMMON_PATTERNS_PROMPT", + "GRC_YAML_PROMPT", # Protocol templates "PROTOCOL_TEMPLATES", "BLUETOOTH_LE_TEMPLATE", diff --git a/src/gnuradio_mcp/prompts/grc_yaml.py b/src/gnuradio_mcp/prompts/grc_yaml.py new file mode 100644 index 0000000..5392d7a --- /dev/null +++ b/src/gnuradio_mcp/prompts/grc_yaml.py @@ -0,0 +1,124 @@ +"""GRC YAML block definition guidance for LLM-assisted generation.""" + +GRC_YAML_PROMPT = """# GRC Block Definition (.block.yml) Guide + +GNU Radio Companion (GRC) requires a `.block.yml` file for each block to +appear in the graphical editor. Without this file, blocks are only +available programmatically. + +## When to Generate GRC YAML + +Generate a .block.yml when: +- The user will use GNU Radio Companion's graphical editor +- The block is being exported to an OOT module +- The block needs a GUI properties dialog + +You do NOT need a .block.yml for: +- Blocks used only in Python scripts +- Embedded Python blocks (epy_block) — GRC handles these internally +- Temporary/test blocks + +## Key Fields + +```yaml +id: module_block # Unique ID, format: {module}_{block} +label: Block Name # Display name in GRC palette +category: [Module Name] # GRC palette category +flags: [ python ] # Implementation language + +templates: + imports: from module import block # Python import + make: module.block(gain=${gain}) # Constructor with ${param} substitution + callbacks: # Runtime update methods + - set_gain(${gain}) + +parameters: +- id: gain + label: Gain + dtype: real # real, int, string, bool, enum, raw, complex + default: 1.0 + +inputs: +- label: in0 + domain: stream # stream (samples) or message (PDUs) + dtype: float # float, complex, byte, short, int + +outputs: +- label: out0 + domain: stream + dtype: float + +asserts: # Parameter validation +- ${ gain >= 0 } + +documentation: |- + Block description shown in GRC tooltips. + +file_format: 1 +``` + +## GRC dtype vs Python dtype + +| Python type | GRC parameter dtype | GRC port dtype | +|-------------|--------------------:|---------------:| +| float | real | float | +| int | int | int | +| str | string | — | +| bool | bool | — | +| complex | complex | complex | +| bytes | — | byte | + +## Callbacks (Runtime Updates) + +For parameters to update at runtime in GRC, the block must have +setter methods and the YAML must list them as callbacks: + +```python +# Python block +def set_gain(self, gain): + self.gain = gain +``` + +```yaml +# YAML definition +templates: + callbacks: + - set_gain(${gain}) +``` + +## Enum Parameters + +```yaml +- id: mode + label: Mode + dtype: enum + default: 'low' + options: ['low', 'medium', 'high'] + option_labels: ['Low Power', 'Medium Power', 'High Power'] +``` + +## Message Ports + +```yaml +inputs: +- domain: message + id: pdu_in + optional: true + +outputs: +- domain: message + id: pdu_out +``` + +## File Location + +Install .block.yml files to `$GR_DATA_DIR/grc/blocks/` (typically +`/usr/share/gnuradio/grc/blocks/`). OOT modules handle this via +CMake — use `export_block_to_oot()` for the full workflow. + +## Workflow + +1. Generate block code: `generate_sync_block(...)` +2. Generate GRC YAML: `generate_grc_yaml(source_code, block_name, ...)` +3. (Optional) Export to OOT: `export_block_to_oot(...)` — includes YAML +""" diff --git a/src/gnuradio_mcp/providers/block_dev.py b/src/gnuradio_mcp/providers/block_dev.py index 8f156e3..f5df7ba 100644 --- a/src/gnuradio_mcp/providers/block_dev.py +++ b/src/gnuradio_mcp/providers/block_dev.py @@ -14,6 +14,7 @@ from gnuradio_mcp.models import ( BlockTestResult, DecoderPipelineModel, GeneratedBlockCode, + GrcYamlResult, IQAnalysisResult, OOTExportResult, OOTSkeletonResult, @@ -654,6 +655,65 @@ if __name__ == "__main__": return self._protocol_analyzer.get_missing_oot_modules(pipeline) + # ────────────────────────────────────────── + # GRC YAML Generation + # ────────────────────────────────────────── + + def generate_grc_yaml( + self, + source_code: str, + block_name: str, + module_name: str = "custom", + inputs: list[dict[str, Any]] | None = None, + outputs: list[dict[str, Any]] | None = None, + parameters: list[dict[str, Any]] | None = None, + category: str | None = None, + ) -> GrcYamlResult: + """Generate a GRC YAML block definition for GNU Radio Companion. + + Produces a .block.yml file that makes a Python block appear in + GRC's block palette with configurable parameters, typed ports, + and runtime callbacks. Without this file, blocks can only be + used programmatically — not in GRC's graphical editor. + + Args: + source_code: Python source code of the block + block_name: Block name (e.g., "my_gain") + module_name: Module namespace (default "custom") + inputs: Input port specs [{"dtype": "float", "vlen": 1}] + If not provided, extracted from code (defaults to 1x float) + outputs: Output port specs (same format as inputs) + parameters: Block params [{"name": "gain", "dtype": "float", "default": 1.0}] + category: GRC palette category (default: module_name) + + Returns: + GrcYamlResult with YAML content. Save as + grc/{module}_{block}.block.yml in your OOT module. + + Note: + For blocks to appear in GRC, the .block.yml must be + installed to $GR_DATA_DIR/grc/blocks/ (typically + /usr/share/gnuradio/grc/blocks/). OOT modules do this + via CMake — see export_block_to_oot() for the full workflow. + """ + input_items = [SignatureItem(**inp) for inp in (inputs or [{"dtype": "float", "vlen": 1}])] + output_items = [SignatureItem(**out) for out in (outputs or [{"dtype": "float", "vlen": 1}])] + param_items = [BlockParameter(**p) for p in (parameters or [])] + + generated = GeneratedBlockCode( + source_code=source_code, + block_name=block_name, + block_class="sync_block", + inputs=input_items, + outputs=output_items, + parameters=param_items, + is_valid=True, + ) + + return self._oot_exporter.generate_grc_yaml( + generated, module_name, category + ) + # ────────────────────────────────────────── # OOT Module Export # ────────────────────────────────────────── diff --git a/src/gnuradio_mcp/providers/mcp_block_dev.py b/src/gnuradio_mcp/providers/mcp_block_dev.py index 4a7a2a3..2592352 100644 --- a/src/gnuradio_mcp/providers/mcp_block_dev.py +++ b/src/gnuradio_mcp/providers/mcp_block_dev.py @@ -104,6 +104,7 @@ class McpBlockDevProvider: - validate_block_code: Static code analysis - test_block_in_docker: Isolated testing (if Docker available) - inject_block: Add generated block to flowgraph + - generate_grc_yaml: Create .block.yml for GNU Radio Companion - parse_protocol_spec: Extract protocol params from description - generate_decoder_chain: Generate block pipeline from protocol - analyze_iq_file: Detect signals and modulation in IQ captures @@ -117,6 +118,7 @@ class McpBlockDevProvider: - Create protocol-specific decoders - Build and test new DSP algorithms - Analyze captured signals and auto-generate decoders + - Generate GRC block definitions for graphical editor use - Export blocks to distributable OOT modules """ if self._block_dev_enabled: @@ -205,6 +207,9 @@ class McpBlockDevProvider: # Signal analysis tools (Phase 4) self._add_tool("analyze_iq_file", p.analyze_iq_file) + # GRC YAML generation + self._add_tool("generate_grc_yaml", p.generate_grc_yaml) + # OOT export tools (Phase 5) self._add_tool("generate_oot_skeleton", p.generate_oot_skeleton) self._add_tool("export_block_to_oot", p.export_block_to_oot) @@ -474,6 +479,7 @@ class McpBlockDevProvider: BASIC_BLOCK_PROMPT, COMMON_PATTERNS_PROMPT, DECODER_CHAIN_PROMPT, + GRC_YAML_PROMPT, SYNC_BLOCK_PROMPT, ) @@ -513,7 +519,16 @@ class McpBlockDevProvider: def get_common_patterns_prompt() -> str: return COMMON_PATTERNS_PROMPT - logger.info("Registered 4 prompt template resources") + @self._mcp.resource( + "prompts://block-generation/grc-yaml", + name="grc_yaml_prompt", + description="Guide for generating GRC YAML block definitions (.block.yml)", + mime_type="text/markdown", + ) + def get_grc_yaml_prompt() -> str: + return GRC_YAML_PROMPT + + logger.info("Registered 5 prompt template resources") # ────────────────────────────────────────── # Factory diff --git a/tests/unit/test_oot_exporter.py b/tests/unit/test_oot_exporter.py index aed1b19..496bec3 100644 --- a/tests/unit/test_oot_exporter.py +++ b/tests/unit/test_oot_exporter.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest from gnuradio_mcp.middlewares.oot_exporter import OOTExporterMiddleware -from gnuradio_mcp.models import GeneratedBlockCode, SignatureItem +from gnuradio_mcp.models import BlockParameter, GeneratedBlockCode, SignatureItem class TestOOTExporterMiddleware: @@ -317,3 +317,122 @@ class TestOOTExporterYAMLGeneration: assert "label:" in yaml_content assert "category:" in yaml_content assert "templates:" in yaml_content + + +class TestGenerateGrcYaml: + """Tests for the standalone generate_grc_yaml method.""" + + @pytest.fixture + def exporter(self): + return OOTExporterMiddleware() + + @pytest.fixture + def gain_block(self): + return GeneratedBlockCode( + source_code=''' +import numpy +from gnuradio import gr + +class blk(gr.sync_block): + """Configurable gain block.""" + def __init__(self, gain=1.0): + gr.sync_block.__init__( + self, name="configurable_gain", + in_sig=[numpy.float32], out_sig=[numpy.float32], + ) + self.gain = gain + + def set_gain(self, gain): + self.gain = gain + + def work(self, input_items, output_items): + output_items[0][:] = input_items[0] * self.gain + return len(output_items[0]) +''', + block_name="configurable_gain", + block_class="sync_block", + inputs=[SignatureItem(dtype="float", vlen=1)], + outputs=[SignatureItem(dtype="float", vlen=1)], + parameters=[ + BlockParameter(name="gain", dtype="float", default=1.0, + description="Gain factor", min_value=0.0), + ], + is_valid=True, + ) + + def test_returns_success(self, exporter, gain_block): + """generate_grc_yaml returns a successful result.""" + result = exporter.generate_grc_yaml(gain_block) + assert result.success is True + assert result.yaml_content + assert result.filename == "custom_configurable_gain.block.yml" + assert result.block_id == "custom_configurable_gain" + + def test_yaml_has_flags(self, exporter, gain_block): + """Generated YAML includes flags: [ python ].""" + result = exporter.generate_grc_yaml(gain_block) + assert "flags: [ python ]" in result.yaml_content + + def test_yaml_has_callbacks(self, exporter, gain_block): + """Setter methods are detected as GRC callbacks.""" + result = exporter.generate_grc_yaml(gain_block) + assert "set_gain(${gain})" in result.yaml_content + assert "callbacks:" in result.yaml_content + + def test_yaml_has_asserts_from_min_value(self, exporter, gain_block): + """Parameter min_value generates GRC asserts.""" + result = exporter.generate_grc_yaml(gain_block) + assert "gain >= 0.0" in result.yaml_content + + def test_yaml_has_parameter_dtype_mapping(self, exporter, gain_block): + """Python 'float' maps to GRC 'real' dtype.""" + result = exporter.generate_grc_yaml(gain_block) + assert "dtype: real" in result.yaml_content + + def test_yaml_has_documentation(self, exporter, gain_block): + """Docstring is included as documentation.""" + result = exporter.generate_grc_yaml(gain_block) + assert "Configurable gain block" in result.yaml_content + + def test_custom_module_name(self, exporter, gain_block): + """Module name changes block ID and import.""" + result = exporter.generate_grc_yaml(gain_block, module_name="dsp") + assert result.block_id == "dsp_configurable_gain" + assert "from dsp import configurable_gain" in result.yaml_content + + def test_custom_category(self, exporter, gain_block): + """Category can be overridden.""" + result = exporter.generate_grc_yaml(gain_block, category="Signal Processing") + assert "category: [Signal Processing]" in result.yaml_content + + def test_notes_for_no_setters(self, exporter): + """Notes warn about missing setter methods.""" + block = GeneratedBlockCode( + source_code=''' +import numpy +from gnuradio import gr + +class blk(gr.sync_block): + def __init__(self, gain=1.0): + gr.sync_block.__init__(self, name="no_setters", + in_sig=[numpy.float32], out_sig=[numpy.float32]) + self.gain = gain + + def work(self, input_items, output_items): + output_items[0][:] = input_items[0] * self.gain + return len(output_items[0]) +''', + block_name="no_setters", + block_class="sync_block", + inputs=[SignatureItem(dtype="float", vlen=1)], + outputs=[SignatureItem(dtype="float", vlen=1)], + parameters=[BlockParameter(name="gain", dtype="float", default=1.0)], + is_valid=True, + ) + result = exporter.generate_grc_yaml(block) + assert any("set_*()" in note for note in result.notes) + + def test_file_format_1(self, exporter, gain_block): + """YAML includes file_format: 1.""" + result = exporter.generate_grc_yaml(gain_block) + assert "file_format: 1" in result.yaml_content