diff --git a/docs/en/introduction/what-is-esp-docs.rst b/docs/en/introduction/what-is-esp-docs.rst index f5b93b7..6df70c3 100644 --- a/docs/en/introduction/what-is-esp-docs.rst +++ b/docs/en/introduction/what-is-esp-docs.rst @@ -151,6 +151,17 @@ It creates and adds the espidf.sty LaTeX package to the output directory, which The ``include-build-file`` directive is like the built-in ``include-file`` directive, but the file path is evaluated relative to ``build_dir``. +:project_file:`Docs Embed ` +""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +This is a Sphinx extension to embed Wokwi simulator into the documentation. +The extension provides two directives: ``.. wokwi::`` and ``.. wokwi-example::``. + +- ``.. wokwi::`` directive embeds a Wokwi diagram specified by JSON file and firmware binary file URLs. +- ``.. wokwi-example::`` directive embeds a Wokwi example from the docs folder. Currently supports only Arduino framework examples. + +See :doc:`Wokwi Embed <../writing-documentation/docs-embed>` for more information and examples. + IDF-Specific Extensions ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/en/writing-documentation/docs-embed.rst b/docs/en/writing-documentation/docs-embed.rst new file mode 100644 index 0000000..34c1d1d --- /dev/null +++ b/docs/en/writing-documentation/docs-embed.rst @@ -0,0 +1,464 @@ +Wokwi Simulator Integration +============================ + +This page describes how to embed interactive Wokwi simulators into your documentation using the esp-docs Sphinx extension. The Wokwi integration allows you to include interactive circuit simulations directly in your documentation, enabling readers to experiment with ESP32-based projects without physical hardware. + +Overview +-------- + +The Wokwi embed extension provides two main features: + +1. **Interactive Simulator Embedding**: Embed Wokwi circuit simulators directly in your documentation pages +2. **Arduino Example Integration**: Automatically discover and embed multiple target variants of Arduino examples with tabbed interfaces + +The extension includes both a Sphinx plugin for embedding simulators in documentation and a CLI tool for managing diagram files and CI configuration. + +Sphinx Plugin Setup +------------------- + +To use the Wokwi embed extension in your documentation, you need to enable it in your Sphinx configuration. + +Enabling the Extension +^^^^^^^^^^^^^^^^^^^^^^ + +Add the extension to your ``extensions`` list in ``conf.py`` or ``conf_common.py``: + +.. code-block:: python + + extensions += [ + 'esp_docs.esp_extensions.docs_embed', + ] + +Configuration Options +^^^^^^^^^^^^^^^^^^^^^ + +The extension can be configured via environment variables or in your ``conf.py`` file. Environment variables take precedence over ``conf.py`` settings. Environment variable names are the uppercase version of the config key (e.g., ``docs_embed_wokwi_viewer_url`` becomes ``DOCS_EMBED_WOKWI_VIEWER_URL``). + +Available Configuration Options: + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Config Key + - Default + - Description + * - ``docs_embed_wokwi_viewer_url`` + - ``https://wokwi.com/experimental/viewer`` + - Base URL for the Wokwi viewer iframe + * - ``docs_embed_default_width`` + - ``100%`` + - Default width for embedded simulators + * - ``docs_embed_default_height`` + - ``500px`` + - Default height for embedded simulators + * - ``docs_embed_default_allowfullscreen`` + - ``True`` + - Enable fullscreen mode by default + * - ``docs_embed_default_loading`` + - ``lazy`` + - Default iframe loading strategy (lazy, eager, or auto) + * - ``docs_embed_esp_launchpad_url`` + - ``https://espressif.github.io/esp-launchpad`` + - Base URL for ESP LaunchPad integration + * - ``docs_embed_about_wokwi_url`` + - ``https://docs.wokwi.com`` + - URL for Wokwi documentation/about page + * - ``docs_embed_skip_validation`` + - ``False`` + - Skip file existence validation (useful for CI builds) + * - ``docs_embed_github_base_url`` + - ``None`` (required for wokwi-example) + - Base URL for GitHub repository (for source code links) + * - ``docs_embed_github_branch`` + - ``None`` (required for wokwi-example) + - GitHub branch name (for source code links) + * - ``docs_embed_public_root`` + - ``None`` (required for wokwi-example) + - Public URL root where documentation is hosted + * - ``docs_embed_binaries_dir`` + - ``None`` (required for wokwi-example) + - Directory path where firmware binaries are stored (relative to source) + +Example Configuration +^^^^^^^^^^^^^^^^^^^^^ + +Here's an example configuration in ``conf.py``: + +.. code-block:: python + + # Wokwi embed configuration + docs_embed_public_root = "https://docs.espressif.com" + docs_embed_binaries_dir = "_static" + docs_embed_github_base_url = "https://github.com/espressif/arduino-esp32" + docs_embed_github_branch = "master" + docs_embed_skip_validation = False + +Or using environment variables: + +.. code-block:: bash + + export DOCS_EMBED_PUBLIC_ROOT="https://docs.espressif.com" + export DOCS_EMBED_BINARIES_DIR="_static" + export DOCS_EMBED_GITHUB_BASE_URL="https://github.com/espressif/arduino-esp32" + export DOCS_EMBED_GITHUB_BRANCH="master" + +Basic Wokwi Embed Directive +---------------------------- + +The ``.. wokwi::`` directive allows you to embed a single Wokwi simulator with explicit diagram and firmware URLs. + +Syntax +^^^^^^ + +.. code-block:: rst + + .. wokwi:: [name] + :diagram: + :firmware: + :width: + :height: + :loading: + :allowfullscreen: + :title: ' + + ''; + + modal.querySelector('iframe').src = url; + + function closeModal() { + try { + document.body.removeChild(modal); + } catch (_) {} + } + + modal.addEventListener('click', function(ev) { + if (ev.target.classList.contains('wokwi-modal')) { + closeModal(); + } + }); + + modal.querySelector('.wokwi-modal__close').addEventListener('click', closeModal); + + document.addEventListener('keydown', function escHandler(ev) { + if (ev.key === 'Escape') { + closeModal(); + document.removeEventListener('keydown', escHandler); + } + }); + + return modal; + } + + function openFullscreen(root) { + var panel = root.querySelector('.wokwi-panel[data-active="true"]'); + var url = panel ? panel.getAttribute('data-viewer-url') : null; + + if (!url) { + var iframe = root.querySelector('iframe'); + if (iframe && iframe.src) url = iframe.src; + } + + if (url) { + document.body.appendChild(buildModal(url)); + } + } + + function onFullscreenClick(e) { + var btn = e.target.closest('.wokwi-fullscreen-btn'); + if (!btn) return; + + e.preventDefault(); + var root = btn.closest('.wokwi-tabs') || btn.closest('.wokwi-frame'); + if (root) { + openFullscreen(root); + } + } + + // Event listeners + document.addEventListener('click', onTabClick); + document.addEventListener('click', onFullscreenClick); + + document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.wokwi-tabs').forEach(function(root) { + setActionsVisibility(root); + updateLaunchpadUrl(root); + }); + + // Initialize auto-pause/resume functionality + initializeWokwiIframes(); + setupIntersectionObserver(); + setupPageVisibilityHandler(); + + // Watch for dynamically added iframes + if (typeof MutationObserver !== 'undefined') { + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + mutation.addedNodes.forEach(function(node) { + if (node.nodeType === 1) { + if (node.tagName === 'IFRAME' && isWokwiApiIframe(node)) { + setupWokwiClient(node); + } + var iframes = node.querySelectorAll && node.querySelectorAll('iframe'); + if (iframes) { + iframes.forEach(function(iframe) { + if (isWokwiApiIframe(iframe)) { + setupWokwiClient(iframe); + } + }); + } + } + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + } + }); +})(); diff --git a/src/esp_docs/esp_extensions/docs_embed/cli.py b/src/esp_docs/esp_extensions/docs_embed/cli.py new file mode 100644 index 0000000..50e926e --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/cli.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Diagram and CI synchronization CLI for Wokwi examples. + +This module provides command-line tools for managing Wokwi diagram files, +CI configuration, and ESP LaunchPad configuration for embedded system projects. +""" + +import sys +import click +from esp_docs.esp_extensions.docs_embed.tool.wokwi_tool import ( + DiagramSync, + target_to_boards, +) + + +# CLI Commands +@click.group( + help="docs-embed: Utility to manage Wokwi diagrams and ESP LaunchPad configurations" +) +@click.option( + "--path", + default=".", + type=str, + help="Path to the directory with examples", +) +@click.pass_context +def main(ctx: click.Context, path: str): + """Main command group for diagram synchronization tools. + + All commands operate on a specified directory containing project files. + By default, uses the current directory (.). + """ + ctx.ensure_object(dict) + ctx.obj["path"] = path + + +@main.command(name="init-diagram") +@click.option( + "--platforms", + type=str, + required=True, + help=f"Comma-separated list of platforms to initialise. Valid: {', '.join(target_to_boards.keys())}", +) +@click.option( + "--override/--no-override", + type=bool, + default=False, + help="Override existing files", +) +@click.pass_context +def init_project(ctx: click.Context, platforms: str, override: bool): + """Initialize a new project with Wokwi diagrams and CI configuration. + + Creates default diagram files and initializes the CI configuration for the + specified platforms. + + Examples: + docs-embed init-diagram --platforms esp32,esp32s2 + docs-embed --path folder/examples init-diagram --platforms esp32,esp32s2 --override + """ + try: + sync = DiagramSync(ctx.obj.get("path")) + platforms_list = [p.strip() for p in platforms.split(",") if p.strip()] + allowed = set(target_to_boards.keys()) + invalid = [p for p in platforms_list if p not in allowed] + + if invalid: + click.echo( + f"Invalid platform(s): {', '.join(invalid)}. Allowed: {', '.join(sorted(allowed))}", + err=True, + ) + sys.exit(1) + + sync.init_project(platforms_list, override) + except FileNotFoundError as e: + click.echo(f"Error: Directory not found - {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@main.command() +@click.option( + "--platform", + help="Specific platform to process (e.g., esp32, esp32s2, esp32s3). If not specified, processes all diagrams.", +) +@click.option( + "--override/--no-override", + default=False, + help="Override existing ci.yml content in the upload-binary section", +) +@click.pass_context +def ci_from_diagram(ctx: click.Context, platform, override): + """Generate ci.yml from diagram files. + + Reads diagram.*.json files and extracts their configuration to generate + or update the ci.yml file with the upload-binary section. + + Examples: + docs-embed ci-from-diagram + docs-embed --path folder/examples ci-from-diagram --platform esp32 --override + """ + try: + sync = DiagramSync(ctx.obj.get("path")) + click.echo("Generating ci.yml from diagram files...") + sync.generate_ci_from_diagram(platform, override) + except FileNotFoundError as e: + click.echo(f"Error: Directory not found - {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@main.command() +@click.option( + "--platform", + help="Specific platform to process (e.g., esp32, esp32s2, esp32s3). If not specified, processes all platforms in ci.yml.", +) +@click.option( + "--override/--no-override", + default=False, + help="Override existing diagram files", +) +@click.pass_context +def diagram_from_ci(ctx: click.Context, platform, override): + """Generate diagram files from ci.yml configuration. + + Reads platform-specific diagram configurations from ci.yml and generates + diagram.*.json files by merging them with default diagram templates. + + Examples: + docs-embed diagram-from-ci + docs-embed --path folder/examples diagram-from-ci --platform esp32 --override + """ + try: + sync = DiagramSync(ctx.obj.get("path")) + click.echo("Generating diagram files from ci.yml...") + sync.generate_diagram_from_ci(platform, override) + except FileNotFoundError as e: + click.echo(f"Error: Directory not found - {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@main.command() +@click.option( + "--storage-url-prefix", + type=str, + required=True, + envvar="STORAGE_URL_PREFIX", + help="Base URL prefix where firmware binaries are hosted", +) +@click.option( + "--repo-url-prefix", + type=str, + required=True, + envvar="REPO_URL_PREFIX", + help="Base URL prefix for the repository resources", +) +@click.option( + "--override/--no-override", + default=False, + help="Override existing launchpad.toml file", +) +@click.pass_context +def launchpad_config(ctx: click.Context, storage_url_prefix, repo_url_prefix, override): + """Generate ESP LaunchPad configuration file. + + Creates a TOML configuration file for ESP LaunchPad with firmware images, + supported chipsets, and project metadata extracted from ci.yml. + + Can use environment variables: + - STORAGE_URL_PREFIX: URL prefix for firmware binaries + - REPO_URL_PREFIX: URL prefix for repository + + Examples: + docs-embed launchpad-config --storage-url-prefix https://storage.url --repo-url-prefix https://repo.url + docs-embed --path folder/examples launchpad-config \\ + --storage-url-prefix https://storage.url \\ + --repo-url-prefix https://repo.url --override + """ + try: + sync = DiagramSync(ctx.obj.get("path")) + click.echo("Generating ESP LaunchPad configuration...") + sync.generate_launchpad_config(storage_url_prefix, repo_url_prefix, override) + except FileNotFoundError as e: + click.echo(f"Error: Directory not found - {e}", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/esp_docs/esp_extensions/docs_embed/plugin.py b/src/esp_docs/esp_extensions/docs_embed/plugin.py new file mode 100644 index 0000000..d7154a0 --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/plugin.py @@ -0,0 +1,198 @@ +"""Sphinx plugin for Wokwi diagram embedding in documentation. + +This module provides a Sphinx extension for embedding interactive Wokwi diagrams +and LaunchPad configurations in documentation. It handles configuration management, +directive registration, and node processing for multiple output formats. + +Configuration: + The plugin can be configured via: + 1. Environment variables (prefixed with uppercase config key name) + 2. conf.py settings in your Sphinx configuration + + See CONFIG_DEFAULTS for all available configuration options. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from esp_docs.esp_extensions.docs_embed.sphinx.helpers import get_static_path + +from .sphinx.nodes import WokwiNode, WokwiTabsNode, TabListNode, TabPanelNode +from .sphinx.directives import WokwiDirective, WokwiExampleDirective +from .sphinx import html +from sphinx.application import Sphinx + + +# Configuration defaults with their config keys +# Format: "config_key": default_value +# You can define these using environment variables or in conf.py +# None means that has to be defined externally (does not have a default) +CONFIG_DEFAULTS = { + "docs_embed_wokwi_viewer_url": "https://wokwi.com/experimental/viewer", + "docs_embed_default_width": "100%", + "docs_embed_default_height": "500px", + "docs_embed_default_allowfullscreen": True, + "docs_embed_default_loading": "lazy", + "docs_embed_esp_launchpad_url": "https://espressif.github.io/esp-launchpad", + "docs_embed_about_wokwi_url": "https://docs.wokwi.com", + "docs_embed_skip_validation": False, + "docs_embed_github_base_url": None, # Deduced from Github ENV in CI + "docs_embed_github_branch": None, # Deduced from Github ENV in CI + "docs_embed_public_root": None, # Deduced from Github ENV in CI + "docs_embed_binaries_dir": None, # Deduced from Github ENV in CI +} + + +def _override_config_from_env(app: Sphinx, config) -> None: + """Override configuration values from environment variables. + + Checks for environment variables matching each config key name in uppercase. + For example: docs_embed_github_branch -> DOCS_EMBED_GITHUB_BRANCH + + Required config values (set to None) must be provided via environment variables + or will raise a RuntimeError. + + Args: + app: Sphinx application instance + config: Sphinx configuration object + + Raises: + RuntimeError: If a required config value is not set and no env var provided + """ + for config_key, config_val in CONFIG_DEFAULTS.items(): + env_var = config_key.upper() + env_value = os.environ.get(env_var) + if env_value is not None: + # Handle boolean conversion for allowfullscreen + if config_key == "docs_embed_default_allowfullscreen": + env_value = env_value.lower() in ("true", "1", "yes", "on") + setattr(config, config_key, env_value) + + +def _register_static(app: Sphinx) -> None: + """Register static assets (CSS and JavaScript) for the Wokwi embed plugin. + + Adds the plugin's static files to Sphinx's html_static_path and registers + the wokwi_embed.css and wokwi_embed.js files for inclusion in HTML output. + + Args: + app: Sphinx application instance + """ + pkg_static = Path(__file__).parent / "_static" + if getattr(app.config, "html_static_path", None) is None: + app.config.html_static_path = [] + if str(pkg_static) not in app.config.html_static_path: + app.config.html_static_path.append(str(pkg_static)) + app.add_css_file("wokwi_embed.css") + + +def _add_wokwi_modules(app: Sphinx, pagename: str, templatename: str, context: dict, doctree) -> None: + """Add import map and module scripts for Wokwi client library. + + Args: + app: Sphinx application instance + pagename: Name of the page being rendered + templatename: Name of the template being used + context: HTML context dictionary + doctree: Document tree + """ + # Calculate the correct static path based on page depth + static_path = get_static_path(pagename) + + # Add import map and module scripts via metatags + metatags = context.get("metatags", "") + wokwi_modules = f""" + + + """ + metatags = wokwi_modules + metatags + context["metatags"] = metatags + + +def setup(app: Sphinx) -> dict: + """Setup the Wokwi embed Sphinx extension. + + Registers all configuration values, directives, nodes, and event handlers. + This function is called automatically by Sphinx when loading the extension. + + Args: + app: Sphinx application instance + + Returns: + Dictionary with extension metadata (version, parallel safety flags) + """ + # Register all config values with their defaults + for config_key, default_value in CONFIG_DEFAULTS.items(): + app.add_config_value(config_key, default_value, "env") + + # Register nodes with their visitor methods for each output format + # WokwiNode: Main diagram node + app.add_node( + WokwiNode, + html=(html.visit_wokwi_html, html.depart_wokwi_html), + singlehtml=(html.visit_wokwi_html, html.depart_wokwi_html), + dirhtml=(html.visit_wokwi_html, html.depart_wokwi_html), + epub=(html.visit_wokwi_html, html.depart_wokwi_html), + text=(html.visit_wokwi_text, html.depart_wokwi_text), + latex=(html.visit_wokwi_latex, html.depart_wokwi_latex), + man=(html.visit_wokwi_text, html.depart_wokwi_text), + ) + + # WokwiTabsNode: Tabbed diagram view + app.add_node( + WokwiTabsNode, + html=(html.visit_wokwi_tabs_html, html.depart_wokwi_tabs_html), + singlehtml=(html.visit_wokwi_tabs_html, html.depart_wokwi_tabs_html), + dirhtml=(html.visit_wokwi_tabs_html, html.depart_wokwi_tabs_html), + epub=(html.visit_wokwi_tabs_html, html.depart_wokwi_tabs_html), + text=(html.visit_wokwi_tabs_text, html.depart_wokwi_tabs_text), + latex=(html.visit_wokwi_tabs_latex, html.depart_wokwi_tabs_latex), + man=(html.visit_wokwi_tabs_text, html.depart_wokwi_tabs_text), + ) + + # TabListNode: Container for tab headers + app.add_node( + TabListNode, + html=(html.visit_tablist_html, html.depart_tablist_html), + singlehtml=(html.visit_tablist_html, html.depart_tablist_html), + dirhtml=(html.visit_tablist_html, html.depart_tablist_html), + epub=(html.visit_tablist_html, html.depart_tablist_html), + text=(html.visit_tablist_text, html.depart_tablist_text), + latex=(html.visit_tablist_latex, html.depart_tablist_latex), + man=(html.visit_tablist_text, html.depart_tablist_text), + ) + + # TabPanelNode: Individual tab content + app.add_node( + TabPanelNode, + html=(html.visit_tabpanel_html, html.depart_tabpanel_html), + singlehtml=(html.visit_tabpanel_html, html.depart_tabpanel_html), + dirhtml=(html.visit_tabpanel_html, html.depart_tabpanel_html), + epub=(html.visit_tabpanel_html, html.depart_tabpanel_html), + text=(html.visit_tabpanel_text, html.depart_tabpanel_text), + latex=(html.visit_tabpanel_latex, html.depart_tabpanel_latex), + man=(html.visit_tabpanel_text, html.depart_tabpanel_text), + ) + + # Register directives + app.add_directive("wokwi", WokwiDirective) + app.add_directive("wokwi-example", WokwiExampleDirective) + + # Register event handlers + app.connect("config-inited", _override_config_from_env) + app.connect("builder-inited", _register_static) + app.connect("html-page-context", _add_wokwi_modules) + + return { + "version": "0.0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/src/esp_docs/esp_extensions/docs_embed/sphinx/__init__.py b/src/esp_docs/esp_extensions/docs_embed/sphinx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/esp_docs/esp_extensions/docs_embed/sphinx/directives.py b/src/esp_docs/esp_extensions/docs_embed/sphinx/directives.py new file mode 100644 index 0000000..582cf91 --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/sphinx/directives.py @@ -0,0 +1,336 @@ +"""Sphinx directives for embedding Wokwi diagrams in documentation. + +This module provides two main directives: +1. WokwiDirective: Embed a single Wokwi diagram with explicit diagram/firmware URLs +2. WokwiExampleDirective: Embed Arduino examples with auto-discovery from ci.yml + +Both directives support tabbed interfaces for displaying multiple targets/variations. +""" + +from __future__ import annotations + +from typing import List +from urllib.parse import urlparse, urlencode, urlunparse, parse_qs + +from docutils import nodes +from docutils.parsers.rst import Directive, directives +from .helpers import css_size, get_static_path, loading_choice, url_join +from .nodes import WokwiNode, WokwiTabsNode, TabListNode, TabPanelNode +from os import path + + + +class WokwiDirective(Directive): + """Directive to embed a single Wokwi diagram with explicit URLs. + + Usage: + .. wokwi:: [name] + :diagram: + :firmware: + :width: 100% + :height: 500px + :loading: lazy + :allowfullscreen: + :tab: ESP32 + + Options: + name (optional text arg): Name/label for the diagram (used in tabs) + diagram: URL to Wokwi diagram JSON file (required) + firmware: URL to compiled firmware binary (required) + width: CSS width value (default: 100%) + height: CSS height value (default: 500px) + loading: iframe loading strategy - lazy, eager, or auto (default: lazy) + title: Title text for the iframe + allowfullscreen: Enable fullscreen mode + tab: Tab label (alternative to name argument) + class: CSS class names to apply + + Returns: + WokwiNode containing the diagram configuration + """ + has_content = False + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + + option_spec = { + "name": directives.unchanged, + "tab": directives.unchanged, + "diagram": directives.uri, + "firmware": directives.uri, + "width": css_size, + "height": css_size, + "title": directives.unchanged, + "allowfullscreen": directives.flag, + "loading": loading_choice, + "class": directives.class_option, + } + + def run(self): + env = self.state.document.settings.env + cfg = env.app.config + + diagram_url = self.options.get("diagram") + firmware_url = self.options.get("firmware") + if not diagram_url or not firmware_url: + raise self.error("wokwi directive: :diagram: and :firmware: are required (UF2/bin).") + + node = WokwiNode() + node["iframe_page"] = cfg.docs_embed_wokwi_viewer_url + node["iframe_page_params"] = {"api": "1"} # Enable Wokwi API + node["diagram_url"] = diagram_url + node["firmware_url"] = firmware_url + node["width"] = self.options.get("width", cfg.docs_embed_default_width) + node["height"] = self.options.get("height", cfg.docs_embed_default_height) + node["title"] = self.options.get("title", "Wokwi simulation") + node["allowfullscreen"] = cfg.docs_embed_default_allowfullscreen if "allowfullscreen" not in self.options else True + node["loading"] = self.options.get("loading", cfg.docs_embed_default_loading) + node["classes"] = ["wokwi-embed"] + self.options.get("class", []) + node["static_path"] = get_static_path(env.docname) + node["suppress_header"] = False + + tab_label = ( + self.options.get("name") + or self.options.get("tab") + or (self.arguments[0].strip() if self.arguments else None) + ) + if tab_label: + node["tab_label"] = tab_label + + return [node] + + +class WokwiExampleDirective(Directive): + """Directive to embed Arduino examples with auto-discovered targets. + + Embeds Wokwi simulations for Arduino ESP32 examples with multiple target support. + Automatically discovers targets from ci.yml and creates tabbed interface for each. + + Usage: + .. wokwi-example:: libraries/ESP32/examples/GPIO/Blink + :width: 100% + :height: 500px + :allowfullscreen: + + Expected Directory Structure: + Arduino Source: + libraries/ESP32/examples/GPIO/Blink/ + ├── Blink.ino + └── ci.yml + + Built Artifacts (_static/): + _static/libraries/ESP32/examples/GPIO/Blink/ + ├── ci.yml (copied by the build process) + ├── launchpad.toml (optional) + ├── esp32/ + │ ├── Blink.ino.merged.bin + │ └── diagram.esp32.json + └── esp32s2/ + ├── Blink.ino.merged.bin + └── diagram.esp32s2.json + + Configuration (in conf.py): + docs_embed_public_root = "https://example.com" + docs_embed_binaries_dir = "_static" + docs_embed_esp_launchpad_url = "https://espressif.github.io/esp-launchpad" + docs_embed_github_base_url = "https://github.com/espressif/esp-idf" + docs_embed_github_branch = "master" + docs_embed_skip_validation = False + + Features: + - Reads ci.yml to auto-discover supported targets (ESP32, ESP32-S2, etc.) + - Creates tabs for each target's simulation + - Shows source code tab with .ino file content + - Optionally links to ESP Launchpad configuration + - Optionally links to GitHub source code + - Validates file existence (configurable) + + Returns: + WokwiTabsNode containing tabbed interface with code and diagram tabs + """ + required_arguments = 1 # path to example directory + optional_arguments = 0 + final_argument_whitespace = False + + option_spec = { + "width": css_size, + "height": css_size, + "allowfullscreen": directives.flag, + "loading": directives.unchanged, + "class": directives.class_option, + } + + has_content = False + + def run(self): + import yaml + + env = self.state.document.settings.env + app = env.app + cfg = app.config + + # Example: libraries/ESP32/examples/GPIO/Blink -> sketch name: Blink + example_path = self.arguments[0].strip() + sketch_name = example_path.split("/")[-1] + docs_embed_esp32_relative_root = "../.." # shift to the project root + + docs_embed_public_root = getattr(cfg, "docs_embed_public_root", None) + if not docs_embed_public_root: + raise self.error("wokwi-example: 'docs_embed_public_root' must be configured in ENV or conf.py") + + docs_embed_binaries_dir = getattr(cfg, "docs_embed_binaries_dir", None) + if not docs_embed_binaries_dir: + raise self.error("wokwi-example: 'docs_embed_binaries_dir' must be configured in ENV or conf.py") + docs_embed_binaries_dir = path.normpath(docs_embed_binaries_dir) + + # Build path to ci.yml + ci_yml_path = path.join(env.srcdir, docs_embed_esp32_relative_root, example_path, "ci.yml") + if not path.isfile(ci_yml_path): + raise self.error(f"wokwi-example: ci.yml not found at {ci_yml_path}") + + # Load ci.yml + try: + with open(ci_yml_path, "r") as f: + ci_data = yaml.safe_load(f) + except Exception as e: + raise self.error(f"wokwi-example: failed to parse ci.yml: {e}") + + # Extract targets + upload_binary = ci_data.get("upload-binary", {}) + targets = upload_binary.get("targets", []) + + if not targets: + raise self.error(f"wokwi-example: no targets found in ci.yml at {ci_yml_path}") + + # Get configuration + skip_validation = getattr(cfg, "docs_embed_skip_validation", False) + + # Create WokwiNode instances for each target + wokwi_nodes: List[WokwiNode] = [] + for target in targets: + firmware_path = url_join(docs_embed_binaries_dir, example_path, target, f"{sketch_name}.ino.merged.bin") + firmware_url = url_join(docs_embed_public_root, firmware_path) + + diagram_path = url_join(docs_embed_binaries_dir, example_path, target, f"diagram.{target}.json") + diagram_url = url_join(docs_embed_public_root, diagram_path) + + # Validate files exist (unless skip_validation is set) + if not skip_validation: + firmware_full_path = url_join(env.srcdir, "..", firmware_path) + if not path.isfile(firmware_full_path): + raise self.error( + f"wokwi-example: firmware file not found at {firmware_full_path}. " + f"Set 'docs_embed_skip_validation = True' in conf.py to bypass this check.") + + diagram_full_path = url_join(env.srcdir, "..", diagram_path) + if not path.isfile(diagram_full_path): + raise self.error( + f"wokwi-example: diagram file not found at {diagram_full_path}. " + f"Set 'docs_embed_skip_validation = True' in conf.py to bypass this check.") + + # Create tab label (e.g., "ESP32", "ESP32-S2") + if target.startswith('esp32'): + base = target[5:] + tab_label = f"ESP32-{base.upper()}" if base else "ESP32" + else: + tab_label = target.upper() + + # Create WokwiNode + wn = WokwiNode() + wn["iframe_page"] = cfg.docs_embed_wokwi_viewer_url + wn["iframe_page_params"] = {"api": "1"} # Enable Wokwi API + wn["diagram_url"] = diagram_url + wn["firmware_url"] = firmware_url + wn["width"] = self.options.get("width", getattr(cfg, "docs_embed_default_width")) + wn["height"] = self.options.get("height", getattr(cfg, "docs_embed_default_height")) + wn["title"] = f"Wokwi simulation — {tab_label}" + wn["allowfullscreen"] = getattr(cfg, "docs_embed_default_allowfullscreen") if "allowfullscreen" not in self.options else True + wn["loading"] = self.options.get("loading", getattr(cfg, "docs_embed_default_loading")) + wn["classes"] = ["wokwi-embed", "from-example"] + self.options.get("class", []) + wn["static_path"] = get_static_path(env.docname) + + wn["tab_label"] = tab_label + wn["suppress_header"] = True # rendered inside tabs + + wokwi_nodes.append(wn) + + # Now create the tab structure + code_panels: List[TabPanelNode] = [] + wokwi_panels: List[TabPanelNode] = [] + + ino_filename = f"{sketch_name}.ino" + ino_full_path = path.join(env.srcdir, docs_embed_esp32_relative_root, example_path, ino_filename) + if path.isfile(ino_full_path): + with open(ino_full_path, 'r', encoding='utf-8') as f: + source_content = f.read() + + code_block = nodes.literal_block(source_content, source_content) + code_block['language'] = 'arduino' + code_block['classes'] = ['highlight'] + + source_panel = TabPanelNode() + source_panel["label"] = ino_filename + source_panel["active"] = True + source_panel.children = [code_block] + code_panels.append(source_panel) + + for i, wn in enumerate(wokwi_nodes): + panel = TabPanelNode() + panel["label"] = wn["tab_label"] + panel["active"] = False + panel.children = [wn] + wokwi_panels.append(panel) + + # Combine all panels + panels = code_panels + wokwi_panels + + # Create tabs structure + serial = env.new_serialno("wokwi-tabs") if env and hasattr(env, "new_serialno") else id(self) + root_id = f"wokwi-tabs-{serial}" + + tablist = TabListNode() + tablist["root_id"] = root_id + labels = [p.get("label") or f"Tab {i+1}" for i, p in enumerate(panels)] + tablist["labels"] = labels + panel_ids = [f"{root_id}-panel-{i}" for i in range(len(panels))] + tablist["panel_ids"] = panel_ids + + # Separate labels for code and wokwi + tablist["tabs_code"] = [p.get("label") for p in code_panels] + tablist["tabs_wokwi"] = [p.get("label") for p in wokwi_panels] + + tablist["static_path"] = get_static_path(env.docname) + + # Link to ESP Launchpad if launchpad.toml exists (optional) + launchpad_path = path.join(docs_embed_binaries_dir, example_path, "launchpad.toml") + launchpad_full_path = path.join(env.srcdir, "..", launchpad_path) + if path.isfile(launchpad_full_path): + launchpad_url = url_join(docs_embed_public_root, launchpad_path) + launchpad_base_url = getattr(env.app.config, "docs_embed_esp_launchpad_url", "") + + # Properly construct URL with query parameters + parsed = urlparse(launchpad_base_url.rstrip('/')) + query_params = parse_qs(parsed.query) + query_params['flashConfigURL'] = [launchpad_url] + new_query = urlencode(query_params, doseq=True) + + # Reconstruct URL with new query parameters + new_parsed = parsed._replace(query=new_query) + tablist["launchpad_href"] = urlunparse(new_parsed) + + # Link to GitHub source .ino file + github_base = getattr(cfg, "docs_embed_github_base_url") + github_branch = getattr(cfg, "docs_embed_github_branch") + if github_base and github_branch: + tablist["github_href"] = url_join(github_base, "tree", github_branch, example_path, f"{sketch_name}.ino") + + for pid, panel in zip(panel_ids, panels): + panel["panel_id"] = pid + + tabs_root = WokwiTabsNode() + tabs_root.children = [tablist] + panels + if "class" in self.options: + tabs_root["classes"] = self.options["class"] + tabs_root["variant"] = "example" + + return [tabs_root] diff --git a/src/esp_docs/esp_extensions/docs_embed/sphinx/helpers.py b/src/esp_docs/esp_extensions/docs_embed/sphinx/helpers.py new file mode 100644 index 0000000..2d022c8 --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/sphinx/helpers.py @@ -0,0 +1,111 @@ +"""Helper functions for Wokwi diagram rendering and URL construction. + +This module provides utility functions for validating and processing configuration +values, escaping HTML content, and constructing iframe URLs for diagram embedding. +""" + +from __future__ import annotations + +import html +from typing import Dict, Optional +from urllib.parse import urlencode, quote + +__all__ = [ + "_escape", + "css_size", + "loading_choice", + "iframe_url", + "get_static_path", +] + +_escape = html.escape + + +def css_size(value: str) -> str: + """Validate and normalize a CSS size value. + + Accepts strings like '500px', '100%', '50em', etc. Strips whitespace + and validates that the value is not empty. + """ + if not isinstance(value, str): + raise ValueError("Expected a string CSS size, e.g. '500px' or '100%'.") + v = value.strip() + if not v: + raise ValueError("Width/height cannot be empty.") + return v + + +def loading_choice(arg: str) -> str: + """Validate and normalize iframe loading attribute. + + The loading attribute controls how the browser loads the iframe: + - 'lazy': Deferred loading until iframe is near viewport + - 'eager': Load immediately (default browser behavior) + - 'auto': Browser decides the loading strategy + """ + v = arg.strip().lower() + if v not in ("lazy", "eager", "auto"): + raise ValueError("loading must be one of: lazy | eager | auto") + return v + + +def iframe_url( + base: str, + diagram_url: str, + firmware_url: str, + iframe_page_params: Optional[Dict[str, str]] = None, +) -> str: + """Construct a complete iframe URL with diagram and firmware parameters. + + Builds the full URL for embedding a diagram viewer iframe by combining + the base URL with diagram and firmware URLs, plus any additional parameters. + + Args: + base: Base URL of the iframe viewer (e.g., 'https://wokwi.com/experimental/viewer') + diagram_url: URL to the Wokwi diagram JSON file + firmware_url: URL to the compiled firmware binary + iframe_page_params: Optional additional URL parameters to include + + Returns: + Complete iframe URL with all parameters URL-encoded + """ + params: Dict[str, str] = { + "diagram": diagram_url, + "firmware": firmware_url, + } + if iframe_page_params: + params.update(iframe_page_params) + qs = urlencode(params, quote_via=quote, safe="") + return base + (("?" + qs) if qs else "") + + +def get_static_path(pagename: str) -> str: + """Calculate relative path to _static directory from current document. + + Args: + pagename: Name of the page being rendered (e.g., "api/gpio") + + Returns: + Relative path string to _static directory (e.g., "../_static/" or "_static/") + """ + if pagename: + # Count directory levels to go up + levels_up = len(pagename.split("/")) - 1 + if levels_up > 0: + return "../" * levels_up + "_static/" + return "_static/" + + +def url_join(*args: str) -> str: + """Join URL path segments with forward slashes. + + Safely combines multiple path segments into a URL path, handling trailing + slashes correctly. + + Args: + *args: Path segments to join + + Returns: + Joined URL path with single forward slashes between segments + """ + return "/".join(map(lambda x: str(x).rstrip('/'), args)) diff --git a/src/esp_docs/esp_extensions/docs_embed/sphinx/html.py b/src/esp_docs/esp_extensions/docs_embed/sphinx/html.py new file mode 100644 index 0000000..b79cad8 --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/sphinx/html.py @@ -0,0 +1,352 @@ +"""Visitor functions for rendering Wokwi diagram nodes in various output formats. + +This module provides visitor and departure functions for rendering custom AST nodes +in different output formats: +- HTML: Full interactive UI with tabs, buttons, and styling +- Text: Plain text fallback with URLs and labels +- LaTeX: PDF-compatible rendering with minimal formatting + +Each node type has visitor/departure function pairs for each output format. +""" + +from __future__ import annotations + +from typing import Optional +from docutils import nodes as _n + +from .helpers import _escape, iframe_url +from .nodes import WokwiNode, WokwiTabsNode, TabListNode, TabPanelNode + + +def _render_iframe_attrs(node: WokwiNode) -> tuple[str, str, str]: + """Render iframe HTML attributes from WokwiNode configuration. + + Args: + node: WokwiNode containing diagram and viewer configuration + + Returns: + Tuple of (attribute_string, allowfullscreen_string, viewer_url) + - attribute_string: Space-separated iframe attributes (src, width, height, etc.) + - allowfullscreen_string: " allowfullscreen" if enabled, else "" + - viewer_url: Complete iframe URL with diagram and firmware parameters + """ + iframe_page = node.get("iframe_page", "") + iframe_page_params = node.get("iframe_page_params", {}) + diagram_url = node.get("diagram_url", "") + firmware_url = node.get("firmware_url", "") + width = node.get("width") + height = node.get("height") + loading = node.get("loading", "lazy") + classes = " ".join(node.get("classes", [])) + viewer_url = iframe_url(iframe_page, diagram_url, firmware_url, iframe_page_params) + + attrs = { + "src": viewer_url, + "width": _escape(str(width), quote=True), + "height": _escape(str(height), quote=True), + "loading": _escape(str(loading), quote=True), + "class": _escape(classes, quote=True), + "frameborder": "0", + } + attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items() if v) + allow = " allowfullscreen" if bool(node.get("allowfullscreen")) else "" + return attr_str, allow, viewer_url + + +def visit_wokwi_html(self, node: WokwiNode): + """Visit WokwiNode and render as HTML iframe with Wokwi UI frame. + + Generates HTML for a single diagram viewer embedded in a styled frame + with header showing Wokwi branding and action buttons (info, fullscreen). + """ + attr_str, allow, _ = _render_iframe_attrs(node) + iframe = f"" + + if node.get("suppress_header"): + self.body.append(iframe) + raise _n.SkipNode + + about_wokwi_url = getattr(self.builder.app.config, "docs_embed_about_wokwi_url") + static_path = node.get("static_path", "") + + self.body.append('
') + self.body.append('
') + self.body.append('
') + + # Single WOKWI SIMULATOR GROUP + self.body.append('
') + # Header with icon, label, and buttons + self.body.append('
') + self.body.append(f'Wokwi') + self.body.append('
WOKWI SIMULATOR
') + # Add info and fullscreen buttons + self.body.append('
') + if about_wokwi_url: + self.body.append( + f'' + ) + self.body.append('') + self.body.append("
") # header-actions + self.body.append("
") # group header + self.body.append("
") # simulator group + + self.body.append("
") # wokwi-groups-container + self.body.append("
") # tabsbar + self.body.append(iframe) + self.body.append("
") # wokwi-frame + raise _n.SkipNode + + +def depart_wokwi_html(self, node: WokwiNode): + """Departure function for WokwiNode HTML rendering (no-op).""" + pass + + +def visit_wokwi_tabs_html(self, node: WokwiTabsNode): + """Visit WokwiTabsNode and render as HTML tabbed container. + + Creates the root div wrapper for a tabbed interface with appropriate + CSS classes and data attributes for styling and JavaScript interaction. + """ + classes = "wokwi-tabs" + (" " + " ".join(node.get("classes", [])) if node.get("classes") else "") + data_variant = f' data-variant="{_escape(node.get("variant"), True)}"' if node.get("variant") else "" + self.body.append(f'
') + + +def depart_wokwi_tabs_html(self, node: WokwiTabsNode): + """Departure function for WokwiTabsNode HTML rendering.""" + self.body.append("
") + + +def visit_tablist_html(self, node: TabListNode): + """Visit TabListNode and render as HTML tab header bar. + + Generates the interactive tab buttons and header sections organized into groups: + - CODE group: Source code file tab(s) with GitHub link + - WOKWI SIMULATOR group: Diagram tab(s) with Wokwi branding + - LAUNCHPAD group: Flash button with LaunchPad config link (optional) + """ + labels = node.get("labels", []) + panel_ids = node.get("panel_ids", []) + tabs_code = node.get("tabs_code", []) + tabs_wokwi = node.get("tabs_wokwi", []) + github_href = node.get("github_href") + about_wokwi_url = getattr(self.builder.app.config, "docs_embed_about_wokwi_url", None) + launchpad_href = node.get("launchpad_href") + static_path = node.get("static_path") + + self.body.append('
') + self.body.append('
') + + # CODE GROUP - First bordered section + if tabs_code: + self.body.append('
') + # Header with GitHub icon and CODE label + self.body.append('
') + if github_href: + self.body.append(f'') + self.body.append(f'GitHub') + self.body.append('') + self.body.append('
CODE
') + self.body.append("
") # group header + + self.body.append('
') + for i, label in enumerate(tabs_code): + panel_index = labels.index(label) + pid = panel_ids[panel_index] + selected = "true" if i == 0 else "false" + self.body.append( + f'' + ) + self.body.append("
") # code tablist + self.body.append("
") # code group + + # WOKWI SIMULATOR GROUP - Second bordered section + if tabs_wokwi: + self.body.append('
') + # Header with icon, label, and buttons + self.body.append('
') + self.body.append(f'Wokwi') + self.body.append('
WOKWI SIMULATOR
') + # Add info and fullscreen buttons + self.body.append('
') + if about_wokwi_url: + self.body.append( + f'' + ) + self.body.append('') + self.body.append("
") # header-actions + self.body.append("
") # group header + # Simulator tabs + self.body.append('
') + for i, label in enumerate(tabs_wokwi): + panel_index = labels.index(label) + pid = panel_ids[panel_index] + selected = "true" if i == 0 and not tabs_code else "false" + self.body.append( + f'' + ) + self.body.append("
") # wokwi tablist + self.body.append("
") # simulator group + + # LAUNCHPAD GROUP - Third bordered section + if launchpad_href: + self.body.append('
') + self.body.append('
launchpad
') + self.body.append( + f'' + f'ESP Launchpad' + f'Flash' + f'' + ) + self.body.append("
") # launchpad group + + self.body.append("
") # wokwi-groups-container + self.body.append("
") # tabsbar + raise _n.SkipNode + + +def depart_tablist_html(self, node: TabListNode): + """Departure function for TabListNode HTML rendering (no-op).""" + pass + + +def visit_tabpanel_html(self, node: TabPanelNode): + """Visit TabPanelNode and render as HTML tab content panel. + + Creates a tab panel div that can be shown/hidden via JavaScript based on + tab selection. Stores viewer URL for use by interactive scripts. + """ + pid = node.get("panel_id") + active = "true" if node.get("active") else "false" + + viewer_url: Optional[str] = None + for ch in node.children: + if isinstance(ch, WokwiNode): + _attr, _allow, url = _render_iframe_attrs(ch) + viewer_url = url + break + + data_attr = f' data-viewer-url="{_escape(viewer_url, True)}"' if viewer_url else "" + self.body.append(f'
') + + +def depart_tabpanel_html(self, node: TabPanelNode): + """Departure function for TabPanelNode HTML rendering.""" + self.body.append("
") + + +def _fallback_text(viewer_url: str) -> str: + """Generate fallback text representation of a Wokwi diagram. + + Args: + viewer_url: Complete iframe URL + + Returns: + Human-readable text representation + """ + return f"Wokwi simulation: {viewer_url}" + + +def visit_wokwi_text(self, node: WokwiNode): + """Visit WokwiNode and render as plain text (fallback format). + + Used for text-based output formats where HTML/interactive content isn't supported. + Outputs viewer URL so users can access the diagram if needed. + """ + viewer_url = iframe_url(node.get("iframe_page"), node.get("diagram_url"), node.get("firmware_url")) + self.add_text(_fallback_text(viewer_url)) + raise _n.SkipNode + + +def depart_wokwi_text(self, node: WokwiNode): + """Departure function for WokwiNode text rendering (no-op).""" + pass + + +def visit_wokwi_tabs_text(self, node: WokwiTabsNode): + """Visit WokwiTabsNode and render as plain text tabs header.""" + self.add_text("Tabs:\n") + + +def depart_wokwi_tabs_text(self, node: WokwiTabsNode): + """Departure function for WokwiTabsNode text rendering (no-op).""" + pass + + +def visit_tablist_text(self, node: TabListNode): + """Visit TabListNode and render as plain text tab list.""" + labels = node.get("labels", []) + for i, lbl in enumerate(labels, 1): + self.add_text(f" {i}. {lbl}\n") + raise _n.SkipNode + + +def depart_tablist_text(self, node: TabListNode): + """Departure function for TabListNode text rendering (no-op).""" + pass + + +def visit_tabpanel_text(self, node: TabPanelNode): + """Visit TabPanelNode and render as plain text section.""" + label = node.get("label") or "Tab" + self.add_text(f"\n[{label}]\n") + + +def depart_tabpanel_text(self, node: TabPanelNode): + """Departure function for TabPanelNode text rendering.""" + self.add_text("\n") + + +def visit_wokwi_latex(self, node: WokwiNode): + """Visit WokwiNode and render as LaTeX URL reference. + + Generates a clickable URL for PDF output, since interactive iframes + are not supported in LaTeX/PDF format. + """ + viewer_url = iframe_url(node.get("iframe_page"), node.get("diagram_url"), node.get("firmware_url")) + self.body.append(r"\url{" + viewer_url + "}") + raise _n.SkipNode + + +def depart_wokwi_latex(self, node: WokwiNode): + """Departure function for WokwiNode LaTeX rendering (no-op).""" + pass + + +def visit_wokwi_tabs_latex(self, node: WokwiTabsNode): + """Visit WokwiTabsNode and render as LaTeX spacing.""" + self.body.append("\\par\\medskip{}\n") + + +def depart_wokwi_tabs_latex(self, node: WokwiTabsNode): + """Departure function for WokwiTabsNode LaTeX rendering.""" + self.body.append("\\par\\medskip{}\n") + + +def visit_tablist_latex(self, node: TabListNode): + """Visit TabListNode and render as LaTeX tab list.""" + labels = node.get("labels", []) + if labels: + self.body.append("\\textbf{Tabs:} " + ", ".join(labels) + "\\\\\n") + raise _n.SkipNode + + +def depart_tablist_latex(self, node: TabListNode): + """Departure function for TabListNode LaTeX rendering (no-op).""" + pass + + +def visit_tabpanel_latex(self, node: TabPanelNode): + """Visit TabPanelNode and render as LaTeX panel header.""" + label = node.get("label") or "Tab" + self.body.append("\\textbf{" + label + "}: ") + + +def depart_tabpanel_latex(self, node: TabPanelNode): + """Departure function for TabPanelNode LaTeX rendering.""" + self.body.append("\\par\n") diff --git a/src/esp_docs/esp_extensions/docs_embed/sphinx/nodes.py b/src/esp_docs/esp_extensions/docs_embed/sphinx/nodes.py new file mode 100644 index 0000000..0c3863a --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/sphinx/nodes.py @@ -0,0 +1,67 @@ +"""Docutils AST nodes for Wokwi diagram rendering in Sphinx. + +This module defines custom AST nodes used to represent Wokwi diagram elements +in the Sphinx documentation abstract syntax tree. These nodes are used by +directives to structure diagram content and by visitors to render them in +various output formats (HTML, LaTeX, etc.). + +Node Hierarchy: + - WokwiNode: Single embedded diagram + - WokwiTabsNode: Tabbed diagram view (contains TabList + TabPanels) + - TabListNode: Tab header buttons + - TabPanelNode: Tab content area +""" + +from __future__ import annotations + +from docutils import nodes + + +class WokwiNode(nodes.General, nodes.Element): + """Represents a single embedded Wokwi diagram with viewer frame UI. + + This node wraps all content related to a single diagram iframe including + the diagram viewer, any interactive controls, and metadata. + """ + + pass + + +class WokwiTabsNode(nodes.General, nodes.Element): + """Root container for tabbed diagram view. + + This node represents a tabbed interface that can display multiple diagrams. + It contains a TabListNode with tab headers and one or more TabPanelNode + elements, each containing diagram content. + + Structure: + WokwiTabsNode + ├── TabListNode (tab headers) + │ └── tab button elements + └── TabPanelNode* (tab panels, repeating) + └── WokwiNode (diagram content) + """ + + pass + + +class TabListNode(nodes.General, nodes.Element): + """Clickable tab headers area. + + This node represents the clickable buttons that switch between tab panels. + Each tab corresponds to a TabPanelNode in the parent WokwiTabsNode. + """ + + pass + + +class TabPanelNode(nodes.General, nodes.Element): + """Single tab panel wrapping arbitrary children. + + This node represents one panel (pane) in a tabbed interface. It contains + the content that should be displayed when its corresponding tab is active. + Typically contains a WokwiNode for diagram display, but can hold other + elements as needed. + """ + + pass diff --git a/src/esp_docs/esp_extensions/docs_embed/tool/__init__.py b/src/esp_docs/esp_extensions/docs_embed/tool/__init__.py new file mode 100644 index 0000000..02dc595 --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/tool/__init__.py @@ -0,0 +1,3 @@ +from .wokwi_tool import DiagramSync + +__all__ = ["DiagramSync"] diff --git a/src/esp_docs/esp_extensions/docs_embed/tool/file_utils.py b/src/esp_docs/esp_extensions/docs_embed/tool/file_utils.py new file mode 100644 index 0000000..fd8b224 --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/tool/file_utils.py @@ -0,0 +1,148 @@ +"""Utility functions for file operations: loading and saving JSON, YAML, and TOML files. + +This module provides functions for safely loading and saving configuration files +with proper error handling, encoding, and formatting. All functions support both +relative and absolute paths and create parent directories as needed. +""" + + +from pathlib import Path +import json +import sys +from typing import Any, Dict + +import click +import yaml +import tomli_w + +def load_json(file_path: Path) -> Dict[str, Any]: + """Load and parse a JSON file with error handling. + + Args: + file_path: Path to the JSON file to load + + Returns: + Dictionary containing the parsed JSON data + + Raises: + SystemExit: If file not found or JSON is invalid + """ + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + click.echo(f"Error: JSON file not found at {file_path}", err=True) + sys.exit(1) + except json.JSONDecodeError as e: + click.echo(f"Error: Invalid JSON in {file_path}: {e}", err=True) + sys.exit(1) + + +def load_yaml(file_path: Path) -> Dict[str, Any]: + """Load and parse a YAML file with error handling. + + Args: + file_path: Path to the YAML file to load + + Returns: + Dictionary containing the parsed YAML data + + Raises: + SystemExit: If file not found or YAML is invalid + """ + try: + with open(file_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + except FileNotFoundError: + click.echo(f"Error: YAML file not found at {file_path}", err=True) + sys.exit(1) + except yaml.YAMLError as e: + click.echo(f"Error: Invalid YAML in {file_path}: {e}", err=True) + sys.exit(1) + + +def save_yaml(file_path: Path, data: Dict[str, Any], override: bool = False) -> None: + """Save data as a YAML file with proper formatting. + + Creates parent directories if they don't exist. Uses safe_dump to avoid + arbitrary code execution and formats output for readability. + + Args: + file_path: Path where the YAML file will be saved + data: Dictionary to serialize as YAML + override: If False, skip if file exists; if True, overwrite existing file + + Raises: + SystemExit: If file write operation fails + """ + if file_path.exists() and not override: + click.echo(f"Warning: {file_path} already exists. Use --override to overwrite.") + return + + file_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(file_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False, indent=2) + except Exception as e: + click.echo(f"Error: Failed to write YAML to {file_path}: {e}", err=True) + sys.exit(1) + + click.echo(f"Saved: {file_path}") + + +def save_json(file_path: Path, data: Dict[str, Any], override: bool = False) -> None: + """Save data as a JSON file with proper formatting. + + Creates parent directories if they don't exist. Uses 2-space indentation + for readability and preserves Unicode characters. + + Args: + file_path: Path where the JSON file will be saved + data: Dictionary to serialize as JSON + override: If False, skip if file exists; if True, overwrite existing file + + Raises: + SystemExit: If file write operation fails + """ + if file_path.exists() and not override: + click.echo(f"Warning: {file_path} already exists. Use --override to overwrite.") + return + + file_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False, separators=(',', ': ')) + except Exception as e: + click.echo(f"Error: Failed to write JSON to {file_path}: {e}", err=True) + sys.exit(1) + + click.echo(f"Saved: {file_path}") + + +def save_toml(file_path: Path, data: Dict[str, Any], override: bool = False) -> None: + """Save data as a TOML file with proper formatting. + + Creates parent directories if they don't exist. TOML format is commonly used + for configuration files and is more human-readable than JSON. + + Args: + file_path: Path where the TOML file will be saved + data: Dictionary to serialize as TOML + override: If False, skip if file exists; if True, overwrite existing file + + Raises: + SystemExit: If file write operation fails + """ + if file_path.exists() and not override: + click.echo(f"Warning: {file_path} already exists. Use --override to overwrite.") + return + + file_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(file_path, 'wb') as f: + tomli_w.dump(data, f) + except Exception as e: + click.echo(f"Error: Failed to write TOML to {file_path}: {e}", err=True) + sys.exit(1) + + click.echo(f"Saved: {file_path}") diff --git a/src/esp_docs/esp_extensions/docs_embed/tool/wokwi_tool.py b/src/esp_docs/esp_extensions/docs_embed/tool/wokwi_tool.py new file mode 100644 index 0000000..736c2ff --- /dev/null +++ b/src/esp_docs/esp_extensions/docs_embed/tool/wokwi_tool.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Diagram and CI synchronization script for ESP32 Arduino examples. + +This module provides tools for synchronizing Wokwi diagram files with CI configuration +and generating LaunchPad configuration files from project metadata. +""" + +from pathlib import Path +from typing import Dict, List, Any, Optional +import click + +from esp_docs.esp_extensions.docs_embed.sphinx.helpers import url_join +from esp_docs.esp_extensions.docs_embed.tool.file_utils import load_json, load_yaml, save_yaml, save_json, save_toml + +# Mapping of platform names to Wokwi board types +target_to_boards = { + 'esp32': 'board-esp32-devkit-c-v4', + 'esp32c3': 'board-esp32-c3-devkitm-1', + 'esp32c6': 'board-esp32-c6-devkitc-1', + 'esp32h2': 'board-esp32-h2-devkitm-1', + 'esp32p4': 'board-esp32-p4-function-ev', + 'esp32s2': 'board-esp32-s2-devkitm-1', + 'esp32s3': 'board-esp32-s3-devkitc-1', +} + +# File naming constants +DIAGRAM_FILE_PREFIX = "diagram." +DIAGRAM_FILE_SUFFIX = ".json" +LAUNCHPAD_CONFIG_FILE = "launchpad.toml" +CI_CONFIG_FILE = "ci.yml" +README_FILE = "README.md" + + +class DiagramSync: + """Main class for synchronizing diagram files with CI configuration.""" + + def __init__(self, base_path: str = "."): + self.base_path = Path(base_path) + if not self.base_path.exists(): + raise FileNotFoundError(f"Base path {self.base_path} does not exist") + if not self.base_path.is_dir(): + raise NotADirectoryError(f"Base path {self.base_path} is not a directory") + + self.serial_connections = [ + ["esp:RX", "$serialMonitor:TX", "", []], + ["esp:TX", "$serialMonitor:RX", "", []] + ] + + # Default diagram configuration + self.default_diagram_config = { + 'version': 1, + 'author': 'Espressif Systems', + 'editor': 'wokwi', + 'parts': [], + 'dependencies': {} + } + + def init_project(self, platforms_list: List[str], override: bool) -> None: + """Initialize project by generating diagrams for specified platforms. + + Args: + platforms_list: List of platform names to generate diagrams for + override: Whether to overwrite existing diagram files + """ + click.echo(f"Initializing project in {self.base_path}") + + for platform in platforms_list: + click.echo(f"Generating diagram for platform: {platform}") + self.generate_diagram(platform, override, {}) + + # Data Processing + def is_serial_connection(self, connection: List[str]) -> bool: + """Return True if the connection is a serial monitor connection.""" + return connection[:3] in [conn[:3] for conn in self.serial_connections] + + def filter_parts(self, parts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Filter out board parts from diagram parts.""" + return [part for part in parts if not part.get("type", "").startswith("board-")] + + def filter_connections(self, connections: List[List[str]]) -> List[List[str]]: + """Filter out serial monitor connections.""" + return [conn for conn in connections if not self.is_serial_connection(conn)] + + def get_platforms_from_ci(self) -> List[str]: + """Get all platforms from ci.yml targets. + + Reads the ci.yml file and extracts platform names from the + upload-binary section. + + Returns: + List of platform names from ci.yml, or empty list if file doesn't exist + """ + ci_file = self.base_path / CI_CONFIG_FILE + if not ci_file.exists(): + return [] + + ci_data = load_yaml(ci_file) + return ci_data.get("upload-binary", {}).get("targets", []) + + def get_platforms_from_diagrams(self) -> List[str]: + """Get all platforms from existing diagram files. + + Scans the base directory for diagram.*.json files and extracts + platform names from the filenames. + + Returns: + List of platform names found in diagram files + """ + platforms = [] + for file_path in self.base_path.glob(f"{DIAGRAM_FILE_PREFIX}*{DIAGRAM_FILE_SUFFIX}"): + if file_path.name.startswith(DIAGRAM_FILE_PREFIX) and file_path.name.endswith(DIAGRAM_FILE_SUFFIX): + platform = file_path.name.replace(DIAGRAM_FILE_PREFIX, "").replace(DIAGRAM_FILE_SUFFIX, "") + if platform != "default": + platforms.append(platform) + return platforms + + # Diagram Generation + def create_default_diagram(self, platform: str) -> Dict[str, Any]: + """Create default diagram with platform-specific pin handling.""" + # Special case for esp32p4 + if platform == 'esp32p4': + rx_pin = '38' + tx_pin = '37' + else: + rx_pin = 'RX' + tx_pin = 'TX' + + diagram = self.default_diagram_config.copy() + diagram['connections'] = [ + [f'esp:{tx_pin}', '$serialMonitor:RX', '', []], + [f'esp:{rx_pin}', '$serialMonitor:TX', '', []] + ] + + board_type = target_to_boards.get(platform) + if not board_type: + raise ValueError( + f"Unknown or unsupported platform: '{platform}'. " + f"Valid platforms are: {', '.join(target_to_boards.keys())}" + ) + + diagram['parts'] = [{ + "type": board_type, + "id": "esp", + "top": 0, + "left": 0, + "attrs": {} + }] + return diagram + + def platform_to_chipset(self, platform: str) -> str: + """Convert platform name to ESP LaunchPad chipset format.""" + if platform == 'esp32': + return 'ESP32' + elif platform.startswith('esp32'): + base = platform[5:] # Remove 'esp32' prefix + if len(base) == 2: # e.g., 's3', 'c3', 'h2', 'p4' + return f'ESP32-{base.upper()}' + raise ValueError(f"Unknown platform '{platform}'. Cannot map to chipset name.") + + def generate_ci_from_diagram(self, platform: Optional[str] = None, override: bool = False) -> None: + """Generate ci.yml from diagram files. + + Processes diagram files and extracts platform-specific configuration + to generate or update the ci.yml file with upload-binary section. + + Args: + platform: Optional specific platform to process. If None, all platforms are processed. + override: Whether to overwrite the upload-binary section if it exists + """ + platforms = [platform] if platform else self.get_platforms_from_diagrams() + + if not platforms: + click.echo("No diagram files found to process") + return + + # Load existing ci.yml if it exists + ci_file = self.base_path / CI_CONFIG_FILE + ci_data = {} + if ci_file.exists(): + ci_data = load_yaml(ci_file) + if ci_data.get("upload-binary") and not override: + click.echo("ci.yml already has 'upload-binary' section. Use --override to overwrite.") + return + + # Ensure upload-binary structure exists in ci_data + if "upload-binary" not in ci_data: + ci_data["upload-binary"] = {} + + upload_binary = ci_data["upload-binary"] + upload_binary["targets"] = upload_binary.get("targets", []) + upload_binary["diagram"] = upload_binary.get("diagram", {}) + + # Process each platform + for plat in platforms: + self._process_diagram_file_to_ci(plat, upload_binary) + + # Ensure the modified upload_binary is saved back to ci_data + ci_data["upload-binary"] = upload_binary + save_yaml(ci_file, ci_data, True) + + def _process_diagram_file_to_ci(self, platform: str, upload_binary: Dict[str, Any]) -> None: + """Process a single diagram file and update upload_binary configuration. + + Args: + platform: Platform name to process + upload_binary: Dictionary to update with processed configuration + """ + # Add platform to targets if not already present + if platform not in upload_binary["targets"]: + upload_binary["targets"].append(platform) + + diagram_file = self.base_path / f"{DIAGRAM_FILE_PREFIX}{platform}{DIAGRAM_FILE_SUFFIX}" + if not diagram_file.exists(): + click.echo(f"- {platform}: Warning: {diagram_file.name} not found, skipping") + return + + diagram_data = load_json(diagram_file) + + # Build platform-specific diagram + parts = self.filter_parts(diagram_data.get("parts", [])) + connections = self.filter_connections(diagram_data.get("connections", [])) + dependencies = diagram_data.get("dependencies") + + # Skip platforms with no meaningful content + if not parts and not connections and not dependencies: + click.echo(f"- {platform}: Skipping: no parts, connections, or dependencies") + return + + platform_diagram = { + "parts": parts, + "connections": connections + } + + # Add dependencies if they exist + if dependencies: + platform_diagram["dependencies"] = dependencies + + # Update platform data + upload_binary["diagram"][platform] = platform_diagram + + click.echo( + f"- {platform}: Processed with {len(platform_diagram['parts'])} parts and " + f"{len(platform_diagram['connections'])} connections" + ) + + def generate_diagram_from_ci(self, platform: Optional[str] = None, override: bool = False) -> None: + """Generate diagram files from ci.yml. + + Reads platform-specific diagram configuration from ci.yml and generates + diagram.*.json files by merging them with default diagram configurations. + + Args: + platform: Optional specific platform to generate. If None, generates for all platforms in ci.yml + override: Whether to overwrite existing diagram files + """ + platforms = [platform] if platform else self.get_platforms_from_ci() + + if not platforms: + click.echo("No platforms found in ci.yml") + return + + # Load ci.yml + ci_file = self.base_path / CI_CONFIG_FILE + if not ci_file.exists(): + click.echo(f"Error: {CI_CONFIG_FILE} not found", err=True) + return + + ci_data = load_yaml(ci_file) + + # Process each platform + for plat in platforms: + self.generate_diagram(plat, override, ci_data.get("upload-binary", {})) + + def generate_diagram(self, platform: str, override: bool = False, config_data: Optional[Dict[str, Any]] = None) -> None: + """Generate a diagram file for the specified platform. + + Args: + platform: Target platform name + override: Whether to overwrite existing diagram files + config_data: Configuration data containing platform-specific diagram settings + """ + if config_data is None: + config_data = {} + + diagram_file = self.base_path / f"diagram.{platform}.json" + + # Check if file exists and we're not overriding + if diagram_file.exists() and not override: + click.echo(f"Warning: diagram.{platform}.json already exists. Use --override to overwrite.") + return + + platform_diagram = config_data.get("diagram", {}).get(platform, {}) + + # Start with default diagram for this platform + diagram_data = self.create_default_diagram(platform) + + # Add platform-specific parts + if platform_parts := platform_diagram.get("parts"): + diagram_data["parts"].extend(platform_parts) + + # Add platform-specific connections + if platform_connections := platform_diagram.get("connections"): + existing_connections = diagram_data.get("connections", []) + diagram_data["connections"] = existing_connections + platform_connections + + # Add dependencies if they exist + if platform_dependencies := platform_diagram.get("dependencies"): + diagram_data["dependencies"] = platform_dependencies + + save_json(diagram_file, diagram_data, True) + + def generate_launchpad_config(self, storage_url_prefix: str, repo_url_prefix: str, override: bool = False, output_dir: Optional[Path] = None) -> None: + """Generate ESP LaunchPad config file from ci.yml targets. + + Creates a TOML configuration file for ESP LaunchPad with firmware images, + supported chipsets, and project metadata extracted from ci.yml. + + Args: + storage_url_prefix: Base URL prefix for firmware images + repo_url_prefix: Base URL prefix for repository resources + override: Whether to overwrite existing config files + output_dir: Optional output directory. If None, uses base_path + """ + project_name = self.base_path.name + config_file = (output_dir / LAUNCHPAD_CONFIG_FILE) if output_dir else (self.base_path / LAUNCHPAD_CONFIG_FILE) + + if config_file.exists() and not override: + click.echo(f"Warning: {config_file} already exists. Use --override to overwrite.") + return + + # Load ci.yml to get platforms + ci_file = self.base_path / CI_CONFIG_FILE + if not ci_file.exists(): + click.echo("Error: ci.yml not found", err=True) + return + + ci_data = load_yaml(ci_file) + platforms = ci_data.get("upload-binary", {}).get("targets", []) + + if not platforms: + click.echo("No platforms found in ci.yml") + return + + # Convert platforms to chipsets + chipsets = [self.platform_to_chipset(platform) for platform in platforms] + + # create firmware_images_url link from base_path (removing 'docs/' prefix if present) + firmware_images_url = url_join(storage_url_prefix, self.base_path.as_posix().lstrip("docs/")) + "/" + + # Generate config data structure + config_data = { + 'esp_toml_version': 1.0, + 'firmware_images_url': firmware_images_url, + 'supported_apps': [project_name], + project_name: { + 'chipsets': chipsets, + 'image': {} + } + } + + # Add image configurations for each platform + for platform in platforms: + self._add_platform_image_config(config_data, project_name, platform) + + # Extract description from ci.yml if available + description = ci_data.get("upload-binary", {}).get("description") + if description: + click.echo(f"- Found description in ci.yml: {description}") + config_data[project_name]['description'] = description + + save_toml(config_file, config_data, override) + + click.echo(f"Generated ESP LaunchPad config: {config_file}") + click.echo(f"Supported chipsets: {', '.join(chipsets)}") + + def _add_platform_image_config(self, config_data: Dict[str, Any], project_name: str, platform: str) -> None: + """Add platform-specific image configuration to LaunchPad config. + + Args: + config_data: Configuration dictionary to update + project_name: Name of the project + platform: Platform name + """ + chipset = self.platform_to_chipset(platform) + lowercase_chipset = chipset.lower() + binary_name = url_join(platform, f"{project_name}.ino.merged.bin") + config_data[project_name]['image'][lowercase_chipset] = binary_name diff --git a/test/unit_tests/test_deploy.py b/test/unit_tests/test_deploy.py index 07ecf37..a1a0f45 100644 --- a/test/unit_tests/test_deploy.py +++ b/test/unit_tests/test_deploy.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import os import unittest import tempfile import tarfile @@ -19,7 +20,11 @@ class TestBuildTarball(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() - copy_tree(self.BUILD_DIR_TEMPLATE_PATH, self.temp_dir.name) + # Get the absolute path to the _build_deploy directory relative to this test file + test_dir = os.path.dirname(os.path.realpath(__file__)) + build_dir_path = os.path.join(test_dir, self.BUILD_DIR_TEMPLATE_PATH) + + copy_tree(build_dir_path, self.temp_dir.name) def tearDown(self): self.temp_dir.cleanup() diff --git a/test/unit_tests/test_docs.py b/test/unit_tests/test_docs.py index 0a81841..db0f240 100755 --- a/test/unit_tests/test_docs.py +++ b/test/unit_tests/test_docs.py @@ -17,9 +17,15 @@ class DocBuilder(): def __init__(self, src_dir, build_dir, target, language): self.language = language self.target = target + # Make src_dir absolute if it's relative + if not os.path.isabs(src_dir): + src_dir = os.path.join(CURRENT_DIR, src_dir) self.src_dir = src_dir + # Make build_dir absolute if it's relative + if not os.path.isabs(build_dir): + build_dir = os.path.join(CURRENT_DIR, build_dir) self.build_dir = build_dir - self.html_out_dir = os.path.join(CURRENT_DIR, build_dir, language, target, 'html') + self.html_out_dir = os.path.join(self.build_dir, language, target, 'html') def build(self, opt_args=[]): args = ['build-docs', '-b', self.build_dir, '-s', self.src_dir, '-t', self.target, '-l', self.language] diff --git a/test/unit_tests/test_wokwi_tool.py b/test/unit_tests/test_wokwi_tool.py new file mode 100644 index 0000000..5ae92fd --- /dev/null +++ b/test/unit_tests/test_wokwi_tool.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 + +import json +import yaml +import tempfile +import shutil +import unittest +from pathlib import Path + +from esp_docs.esp_extensions.docs_embed.tool.wokwi_tool import DiagramSync, target_to_boards + + +class TestDiagramSyncInit(unittest.TestCase): + """Test DiagramSync initialization.""" + + def test_init_with_valid_directory(self): + """Test initialization with valid directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + sync = DiagramSync(tmpdir) + self.assertEqual(sync.base_path, Path(tmpdir)) + self.assertIsInstance(sync.serial_connections, list) + self.assertIsInstance(sync.default_diagram_config, dict) + + def test_init_with_nonexistent_path(self): + """Test initialization with non-existent path raises FileNotFoundError.""" + with self.assertRaises(FileNotFoundError): + DiagramSync("/nonexistent/path/that/does/not/exist") + + def test_init_with_file_instead_of_directory(self): + """Test initialization with file path raises NotADirectoryError.""" + with tempfile.NamedTemporaryFile() as tmpfile: + with self.assertRaises(NotADirectoryError): + DiagramSync(tmpfile.name) + + +class TestDiagramSyncDataProcessing(unittest.TestCase): + """Test data processing methods.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.sync = DiagramSync(self.tmpdir) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_is_serial_connection_true(self): + """Test serial connection detection returns True.""" + serial_conn = ["esp:RX", "$serialMonitor:TX", "", []] + self.assertTrue(self.sync.is_serial_connection(serial_conn)) + + def test_is_serial_connection_false(self): + """Test non-serial connection detection returns False.""" + normal_conn = ["led:VCC", "esp:GPIO2", "red", []] + self.assertFalse(self.sync.is_serial_connection(normal_conn)) + + def test_filter_parts(self): + """Test filtering board parts from parts list.""" + parts = [ + {"type": "board-esp32-devkit-c-v4", "id": "esp"}, + {"type": "wokwi-led", "id": "led1"}, + {"type": "board-esp32-s3-devkitc-1", "id": "esp2"}, + {"type": "wokwi-resistor", "id": "r1"} + ] + + filtered = self.sync.filter_parts(parts) + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0]["type"], "wokwi-led") + self.assertEqual(filtered[1]["type"], "wokwi-resistor") + + def test_filter_connections(self): + """Test filtering serial connections from connections list.""" + connections = [ + ["esp:RX", "$serialMonitor:TX", "", []], + ["led:VCC", "esp:GPIO2", "red", []], + ["esp:TX", "$serialMonitor:RX", "", []], + ["r1:1", "led:A", "", []] + ] + + filtered = self.sync.filter_connections(connections) + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0][0], "led:VCC") + self.assertEqual(filtered[1][0], "r1:1") + + def test_platform_to_chipset_esp32(self): + """Test platform to chipset conversion for ESP32.""" + self.assertEqual(self.sync.platform_to_chipset("esp32"), "ESP32") + + def test_platform_to_chipset_esp32s3(self): + """Test platform to chipset conversion for ESP32-S3.""" + self.assertEqual(self.sync.platform_to_chipset("esp32s3"), "ESP32-S3") + + def test_platform_to_chipset_esp32c3(self): + """Test platform to chipset conversion for ESP32-C3.""" + self.assertEqual(self.sync.platform_to_chipset("esp32c3"), "ESP32-C3") + + def test_platform_to_chipset_invalid(self): + """Test platform to chipset conversion with invalid platform.""" + with self.assertRaises(ValueError): + self.sync.platform_to_chipset("invalid") + + +class TestDiagramSyncPlatformDiscovery(unittest.TestCase): + """Test platform discovery methods.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.sync = DiagramSync(self.tmpdir) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_get_platforms_from_ci_with_file(self): + """Test getting platforms from existing ci.yml.""" + ci_file = Path(self.tmpdir) / "ci.yml" + ci_data = { + "upload-binary": { + "targets": ["esp32", "esp32s2", "esp32s3"] + } + } + with open(ci_file, 'w') as f: + yaml.safe_dump(ci_data, f) + + platforms = self.sync.get_platforms_from_ci() + self.assertEqual(platforms, ["esp32", "esp32s2", "esp32s3"]) + + def test_get_platforms_from_ci_without_file(self): + """Test getting platforms when ci.yml doesn't exist.""" + platforms = self.sync.get_platforms_from_ci() + self.assertEqual(platforms, []) + + def test_get_platforms_from_diagrams(self): + """Test getting platforms from diagram files.""" + # Create diagram files + for platform in ["esp32", "esp32s3", "esp32c3"]: + diagram_file = Path(self.tmpdir) / f"diagram.{platform}.json" + with open(diagram_file, 'w') as f: + json.dump({"version": 1}, f) + + platforms = self.sync.get_platforms_from_diagrams() + self.assertIn("esp32", platforms) + self.assertIn("esp32s3", platforms) + self.assertIn("esp32c3", platforms) + + +class TestDiagramSyncDiagramGeneration(unittest.TestCase): + """Test diagram generation methods.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.sync = DiagramSync(self.tmpdir) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_create_default_diagram_esp32(self): + """Test creating default diagram for ESP32.""" + diagram = self.sync.create_default_diagram("esp32") + + self.assertEqual(diagram["version"], 1) + self.assertEqual(diagram["author"], "Espressif Systems") + self.assertEqual(len(diagram["parts"]), 1) + self.assertEqual(diagram["parts"][0]["type"], "board-esp32-devkit-c-v4") + self.assertEqual(len(diagram["connections"]), 2) + # Check RX/TX pins + self.assertIn("esp:TX", diagram["connections"][0][0]) + self.assertIn("esp:RX", diagram["connections"][1][0]) + + def test_create_default_diagram_esp32p4(self): + """Test creating default diagram for ESP32-P4 with special pins.""" + diagram = self.sync.create_default_diagram("esp32p4") + + self.assertEqual(diagram["parts"][0]["type"], "board-esp32-p4-function-ev") + # Check special pins for P4 + self.assertIn("esp:37", diagram["connections"][0][0]) + self.assertIn("esp:38", diagram["connections"][1][0]) + + def test_create_default_diagram_invalid_platform(self): + """Test creating diagram for invalid platform.""" + with self.assertRaises(ValueError): + self.sync.create_default_diagram("invalid_platform") + + def test_generate_diagram(self): + """Test generating diagram file.""" + self.sync.generate_diagram("esp32", override=True) + + diagram_file = Path(self.tmpdir) / "diagram.esp32.json" + self.assertTrue(diagram_file.exists()) + + with open(diagram_file, 'r') as f: + diagram_data = json.load(f) + + self.assertEqual(diagram_data["version"], 1) + self.assertEqual(diagram_data["parts"][0]["type"], "board-esp32-devkit-c-v4") + + def test_generate_ci_from_diagram(self): + """Test generating ci.yml from diagram files.""" + # Create diagram files + for platform in ["esp32", "esp32s3"]: + diagram_file = Path(self.tmpdir) / f"diagram.{platform}.json" + diagram_data = { + "version": 1, + "parts": [ + {"type": "board-" + platform, "id": "esp"}, + {"type": "wokwi-led", "id": "led1"} + ], + "connections": [ + ["esp:RX", "$serialMonitor:TX", "", []], + ["esp:TX", "$serialMonitor:RX", "", []], + ["led1:VCC", "esp:GPIO2", "red", []] + ] + } + with open(diagram_file, 'w') as f: + json.dump(diagram_data, f) + + # Generate CI + self.sync.generate_ci_from_diagram(override=True) + + # Check ci.yml was created + ci_file = Path(self.tmpdir) / "ci.yml" + self.assertTrue(ci_file.exists()) + + with open(ci_file, 'r') as f: + ci_data = yaml.safe_load(f) + + self.assertIn("upload-binary", ci_data) + self.assertIn("targets", ci_data["upload-binary"]) + self.assertIn("diagram", ci_data["upload-binary"]) + self.assertIn("esp32", ci_data["upload-binary"]["targets"]) + self.assertIn("esp32s3", ci_data["upload-binary"]["targets"]) + + def test_generate_diagram_from_ci(self): + """Test generating diagram files from ci.yml.""" + # Create ci.yml + ci_file = Path(self.tmpdir) / "ci.yml" + ci_data = { + "upload-binary": { + "targets": ["esp32", "esp32s2"], + "diagram": { + "esp32": { + "parts": [{"type": "wokwi-led", "id": "led1"}], + "connections": [["led1:VCC", "esp:GPIO2", "red", []]] + }, + "esp32s2": { + "parts": [{"type": "wokwi-resistor", "id": "r1"}], + "connections": [["r1:1", "esp:GPIO3", "", []]] + } + } + } + } + with open(ci_file, 'w') as f: + yaml.safe_dump(ci_data, f) + + # Generate diagrams + self.sync.generate_diagram_from_ci(override=True) + + # Check diagram files were created + esp32_diagram = Path(self.tmpdir) / "diagram.esp32.json" + esp32s2_diagram = Path(self.tmpdir) / "diagram.esp32s2.json" + + self.assertTrue(esp32_diagram.exists()) + self.assertTrue(esp32s2_diagram.exists()) + + with open(esp32_diagram, 'r') as f: + diagram_data = json.load(f) + + # Check it has both default and custom parts + self.assertTrue(any(p["type"].startswith("board-") for p in diagram_data["parts"])) + self.assertTrue(any(p["type"] == "wokwi-led" for p in diagram_data["parts"])) + + +class TestDiagramSyncConfigGeneration(unittest.TestCase): + """Test LaunchPad config generation.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.sync = DiagramSync(self.tmpdir) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_generate_launchpad_config(self): + """Test generating LaunchPad config file.""" + # Create ci.yml + ci_file = Path(self.tmpdir) / "ci.yml" + ci_data = { + "upload-binary": { + "targets": ["esp32", "esp32s3"], + "description": "Test project description" + } + } + with open(ci_file, 'w') as f: + yaml.safe_dump(ci_data, f) + + storage_url = "https://storage.example.com/binaries" + repo_url = "https://github.com/user/repo/tree/main" + + # Generate config + self.sync.generate_launchpad_config(storage_url, repo_url, override=True) + + # Check launchpad.toml was created + config_file = Path(self.tmpdir) / "launchpad.toml" + self.assertTrue(config_file.exists()) + + with open(config_file, 'r') as f: + content = f.read() + + # Check content + self.assertIn("esp_toml_version = 1.0", content) + self.assertIn(f'firmware_images_url = "', content) + self.assertIn(storage_url, content) + self.assertIn('esp32 = ', content) + self.assertIn('esp32-s3 = ', content) + + def test_generate_launchpad_config_without_readme(self): + """Test generating LaunchPad config without README.md.""" + # Create ci.yml + ci_file = Path(self.tmpdir) / "ci.yml" + ci_data = { + "upload-binary": { + "targets": ["esp32"] + } + } + with open(ci_file, 'w') as f: + yaml.safe_dump(ci_data, f) + + storage_url = "https://storage.example.com/binaries" + repo_url = "https://github.com/user/repo/tree/main" + + # Generate config + self.sync.generate_launchpad_config(storage_url, repo_url, override=True) + + # Check launchpad.toml was created + config_file = Path(self.tmpdir) / "launchpad.toml" + self.assertTrue(config_file.exists()) + + with open(config_file, 'r') as f: + content = f.read() + + # Should not have readme URL + self.assertNotIn('config_readme_url', content) + + +class TestTargetToBoards(unittest.TestCase): + """Test target_to_boards mapping.""" + + def test_all_platforms_supported(self): + """Test that all expected platforms are in target_to_boards.""" + expected_platforms = ['esp32', 'esp32c3', 'esp32c6', 'esp32h2', 'esp32p4', 'esp32s2', 'esp32s3'] + for platform in expected_platforms: + self.assertIn(platform, target_to_boards) + self.assertTrue(target_to_boards[platform].startswith('board-')) + + +if __name__ == '__main__': + unittest.main() +