idf-wokwi 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ # EditorConfig helps maintain consistent coding styles between editors & IDEs
2
+ # https://editorconfig.org
3
+ root = true
4
+
5
+ [*]
6
+ charset = utf-8
7
+ end_of_line = lf
8
+ indent_style = space
9
+ indent_size = 4
10
+ insert_final_newline = true
11
+ trim_trailing_whitespace = true
12
+
13
+ # Keep README & docs within the same column width as code
14
+ [*.{py,toml,yaml,yml,md}]
15
+ max_line_length = 100
16
+
17
+ # Make makes you use tabs
18
+ [Makefile]
19
+ indent_style = tab
@@ -0,0 +1,93 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ branches:
7
+ - main
8
+ workflow_dispatch:
9
+
10
+ env:
11
+ ESP_IDF_BRANCH: release/v6.0
12
+ ESP_IDF_TARGET: esp32
13
+
14
+ jobs:
15
+ lint:
16
+ runs-on: ubuntu-24.04
17
+ steps:
18
+ - uses: actions/checkout@v6
19
+ - uses: actions/setup-python@v6
20
+ with:
21
+ python-version: "3.12"
22
+ - run: pip install hatch
23
+ - run: hatch run ruff format --check .
24
+ - run: hatch run ruff check .
25
+ - run: hatch run mypy .
26
+
27
+ test:
28
+ strategy:
29
+ fail-fast: false
30
+ matrix:
31
+ os: [ubuntu-24.04, windows-latest]
32
+ python-version: ["3.9", "3.12", "3.13"]
33
+
34
+ runs-on: ${{ matrix.os }}
35
+ steps:
36
+ - uses: actions/checkout@v6
37
+ - uses: actions/setup-python@v6
38
+ with:
39
+ python-version: ${{ matrix.python-version }}
40
+ - run: pip install hatch
41
+ - run: hatch run dev:pytest tests/test_integration.py::test_action_extensions_structure
42
+
43
+ integration:
44
+ runs-on: ubuntu-24.04
45
+ steps:
46
+ - uses: actions/checkout@v6
47
+ - uses: actions/setup-python@v6
48
+ with:
49
+ python-version: "3.12"
50
+
51
+ - uses: actions/cache@v4
52
+ id: cache-esp-idf
53
+ with:
54
+ path: |
55
+ ~/esp-idf
56
+ ~/.espressif
57
+ key: esp-idf-${{ env.ESP_IDF_BRANCH }}-${{ env.ESP_IDF_TARGET }}
58
+
59
+ - name: Install ESP-IDF
60
+ if: steps.cache-esp-idf.outputs.cache-hit != 'true'
61
+ run: |
62
+ git clone --depth 1 --recursive --shallow-submodules -b $ESP_IDF_BRANCH https://github.com/espressif/esp-idf.git ~/esp-idf
63
+ ~/esp-idf/install.sh $ESP_IDF_TARGET
64
+
65
+ - uses: actions/cache@v4
66
+ id: cache-build
67
+ with:
68
+ path: test-project/build
69
+ key: test-build-${{ env.ESP_IDF_TARGET }}-${{ hashFiles('test-project/**') }}-${{ env.ESP_IDF_BRANCH }}
70
+
71
+ - name: Build test project
72
+ if: steps.cache-build.outputs.cache-hit != 'true'
73
+ run: |
74
+ . ~/esp-idf/export.sh
75
+ cd test-project
76
+ idf.py set-target $ESP_IDF_TARGET
77
+ idf.py build
78
+
79
+ - name: Install idf-wokwi
80
+ run: |
81
+ . ~/esp-idf/export.sh
82
+ pip install .
83
+
84
+ - uses: wokwi/wokwi-ci-server-action@v1
85
+
86
+ - name: Test idf.py wokwi
87
+ run: |
88
+ . ~/esp-idf/export.sh
89
+ cd test-project
90
+ idf.py wokwi --timeout 10000 --expect-text "Hello world!" 2>&1 | tee output.log
91
+ grep -q "Expected text found: Hello world!" output.log
92
+ env:
93
+ WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }}
@@ -0,0 +1,38 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+
8
+ permissions:
9
+ id-token: write # OIDC token for trusted publishing
10
+ contents: write # For the release action
11
+
12
+ jobs:
13
+ build-and-publish:
14
+ runs-on: ubuntu-24.04
15
+ environment: pypi
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+
19
+ - uses: actions/setup-python@v6
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Build wheel & sdist
24
+ run: |
25
+ pip install --quiet hatch
26
+ hatch build
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ verbose: true
32
+
33
+ - uses: softprops/action-gh-release@v2
34
+ with:
35
+ generate_release_notes: true
36
+ files: |
37
+ dist/*.whl
38
+ dist/*.tar.gz
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ sdkconfig
10
+ sdkconfig.old
@@ -0,0 +1,24 @@
1
+ repos:
2
+ # Ruff: formatter + linter
3
+ - repo: https://github.com/astral-sh/ruff-pre-commit
4
+ rev: v0.12.0
5
+ hooks:
6
+ - id: ruff-format
7
+ - id: ruff
8
+ args: [--fix]
9
+
10
+ # mypy (strict)
11
+ - repo: https://github.com/pre-commit/mirrors-mypy
12
+ rev: v1.16.1
13
+ hooks:
14
+ - id: mypy
15
+
16
+ # Basic hygiene hooks (official, maintained)
17
+ - repo: https://github.com/pre-commit/pre-commit-hooks
18
+ rev: v5.0.0
19
+ hooks:
20
+ - id: end-of-file-fixer
21
+ - id: trailing-whitespace
22
+
23
+ default_language_version:
24
+ python: python3.12
@@ -0,0 +1,23 @@
1
+ # https://docs.astral.sh/ruff/configuration/
2
+ target-version = "py39"
3
+ line-length = 100
4
+ src = ["src", "tests"]
5
+
6
+ # Enable rule bundles
7
+ lint.select = [
8
+ "E", # pycodestyle errors
9
+ "F", # pyflakes
10
+ "I", # isort
11
+ "UP", # pyupgrade
12
+ "PL", # pylint-worthwhile subset
13
+ ]
14
+
15
+ lint.ignore = [
16
+ "E501", # handled by formatter
17
+ "PLC0415", # lazy imports for platform-specific modules are fine
18
+ "PLR0913", # callback signature dictated by idf.py extension API
19
+ ]
20
+
21
+ # Per-folder overrides --------------------------------------------------------
22
+ [lint.per-file-ignores]
23
+ "tests/**/*.py" = ["PLR2004"] # magic-numbers okay in tests
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 - 2026-02-10
4
+
5
+ Initial release.
@@ -0,0 +1,30 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ pip install hatch
7
+ ```
8
+
9
+ ## Development
10
+
11
+ Lint and type-check:
12
+
13
+ ```bash
14
+ hatch run ruff format --check .
15
+ hatch run ruff check .
16
+ hatch run mypy .
17
+ ```
18
+
19
+ Run tests:
20
+
21
+ ```bash
22
+ hatch run dev:pytest
23
+ ```
24
+
25
+ ## Pre-commit hooks
26
+
27
+ ```bash
28
+ pip install pre-commit
29
+ pre-commit install
30
+ ```
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: idf-wokwi
3
+ Version: 1.0.0
4
+ Summary: Wokwi simulation extension for ESP-IDF (idf.py wokwi)
5
+ Project-URL: Source, https://github.com/wokwi/idf-wokwi
6
+ Project-URL: Issues, https://github.com/wokwi/idf-wokwi/issues
7
+ Author-email: Uri Shaked <uri@wokwi.com>
8
+ License-Expression: Apache-2.0
9
+ Keywords: esp-idf,esp32,idf.py,simulation,wokwi
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Embedded Systems
20
+ Classifier: Topic :: System :: Emulators
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: wokwi-client>=0.1.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # idf-wokwi
27
+
28
+ Wokwi simulation extension for ESP-IDF. Adds `idf.py wokwi` command.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install idf-wokwi
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Set `WOKWI_CLI_TOKEN` to your [Wokwi API token](https://wokwi.com/dashboard/ci?utm_source=idf-wokwi).
39
+
40
+ ```bash
41
+ export WOKWI_CLI_TOKEN="your-token-here"
42
+
43
+ # Build and simulate
44
+ idf.py build
45
+ idf.py wokwi
46
+
47
+ # CI mode: exit when expected text appears
48
+ idf.py wokwi --timeout 10000 --expect-text "Hello world!"
49
+ ```
50
+
51
+ ## Options
52
+
53
+ | Option | Description |
54
+ | ---------------- | -------------------------------------------------------------- |
55
+ | `--diagram-file` | Path to `diagram.json` (defaults to project root) |
56
+ | `--timeout` | Simulation timeout in milliseconds (exit code 42 on timeout) |
57
+ | `--expect-text` | Exit successfully when this text appears in serial output |
58
+ | `--fail-text` | Exit with error when this text appears in serial output |
59
+ | `--expect-regex` | Exit successfully when this regex matches a serial output line |
60
+ | `--fail-regex` | Exit with error when this regex matches a serial output line |
61
+
62
+ ## Contributing
63
+
64
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,39 @@
1
+ # idf-wokwi
2
+
3
+ Wokwi simulation extension for ESP-IDF. Adds `idf.py wokwi` command.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install idf-wokwi
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Set `WOKWI_CLI_TOKEN` to your [Wokwi API token](https://wokwi.com/dashboard/ci?utm_source=idf-wokwi).
14
+
15
+ ```bash
16
+ export WOKWI_CLI_TOKEN="your-token-here"
17
+
18
+ # Build and simulate
19
+ idf.py build
20
+ idf.py wokwi
21
+
22
+ # CI mode: exit when expected text appears
23
+ idf.py wokwi --timeout 10000 --expect-text "Hello world!"
24
+ ```
25
+
26
+ ## Options
27
+
28
+ | Option | Description |
29
+ | ---------------- | -------------------------------------------------------------- |
30
+ | `--diagram-file` | Path to `diagram.json` (defaults to project root) |
31
+ | `--timeout` | Simulation timeout in milliseconds (exit code 42 on timeout) |
32
+ | `--expect-text` | Exit successfully when this text appears in serial output |
33
+ | `--fail-text` | Exit with error when this text appears in serial output |
34
+ | `--expect-regex` | Exit successfully when this regex matches a serial output line |
35
+ | `--fail-regex` | Exit with error when this regex matches a serial output line |
36
+
37
+ ## Contributing
38
+
39
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,14 @@
1
+ [mypy]
2
+ python_version = 3.9
3
+ strict = True
4
+ show_error_codes = True
5
+ pretty = True
6
+ warn_unused_ignores = True
7
+ warn_return_any = True
8
+ warn_unreachable = True
9
+
10
+ [mypy-wokwi_client.*]
11
+ ignore_missing_imports = True
12
+
13
+ [mypy-pytest.*]
14
+ ignore_missing_imports = True
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "idf-wokwi"
7
+ version = "1.0.0"
8
+ description = "Wokwi simulation extension for ESP-IDF (idf.py wokwi)"
9
+ readme = "README.md"
10
+ authors = [{ name = "Uri Shaked", email = "uri@wokwi.com" }]
11
+ license = "Apache-2.0"
12
+ keywords = ["wokwi", "esp-idf", "simulation", "esp32", "idf.py"]
13
+ requires-python = ">=3.9"
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Embedded Systems",
25
+ "Topic :: System :: Emulators",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = ["wokwi-client>=0.1.0"]
29
+
30
+ [project.urls]
31
+ Source = "https://github.com/wokwi/idf-wokwi"
32
+ Issues = "https://github.com/wokwi/idf-wokwi/issues"
33
+
34
+ [project.entry-points.idf_extension]
35
+ wokwi = "idf_wokwi.wokwi_ext:action_extensions"
36
+
37
+ [tool.hatch.envs.default]
38
+ dependencies = ["ruff>=0.12.0", "mypy>=1.16.1"]
39
+
40
+ [tool.hatch.envs.dev]
41
+ template = "default"
42
+ extra-dependencies = ["pytest"]
43
+
44
+ [tool.pytest.ini_options]
45
+ addopts = "--strict-markers"
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
@@ -0,0 +1,14 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+
9
+ def red_print(msg: str) -> None:
10
+ print(f"\033[31m{msg}\033[0m", file=sys.stderr)
11
+
12
+
13
+ def yellow_print(msg: str) -> None:
14
+ print(f"\033[33m{msg}\033[0m", file=sys.stderr)
@@ -0,0 +1,56 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
3
+
4
+ """Diagram generation and discovery for Wokwi simulations."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+
11
+ from ._log import red_print
12
+
13
+ TARGET_WOKWI_PARTS: dict[str, str] = {
14
+ "esp32": "board-esp32-devkit-c-v4",
15
+ "esp32s2": "board-esp32-s2-devkitm-1",
16
+ "esp32s3": "board-esp32-s3-devkitc-1",
17
+ "esp32c3": "board-esp32-c3-devkitm-1",
18
+ "esp32c5": "board-esp32-c5-devkitc-1",
19
+ "esp32c6": "board-esp32-c6-devkitc-1",
20
+ "esp32h2": "board-esp32-h2-devkitm-1",
21
+ "esp32p4": "board-esp32-p4-function-ev",
22
+ }
23
+
24
+
25
+ def generate_diagram(target: str) -> str:
26
+ """Return a minimal diagram.json string for the given target."""
27
+ part_type = TARGET_WOKWI_PARTS.get(target, f"board-{target}-devkit")
28
+ diagram = {
29
+ "version": 1,
30
+ "author": "idf-wokwi",
31
+ "editor": "wokwi",
32
+ "parts": [{"type": part_type, "id": "esp"}],
33
+ "connections": [
34
+ ["esp:TX", "$serialMonitor:RX", "", []],
35
+ ["esp:RX", "$serialMonitor:TX", "", []],
36
+ ],
37
+ "serialMonitor": {"display": "terminal"},
38
+ }
39
+ return json.dumps(diagram)
40
+
41
+
42
+ def find_diagram(project_path: str, diagram_file: str) -> str:
43
+ """Locate diagram.json - returns path or empty string if auto-generation needed."""
44
+ if diagram_file:
45
+ if not os.path.isabs(diagram_file):
46
+ diagram_file = os.path.join(project_path, diagram_file)
47
+ if not os.path.exists(diagram_file):
48
+ red_print(f"Diagram file not found: {diagram_file}")
49
+ raise SystemExit(1)
50
+ return diagram_file
51
+
52
+ default = os.path.join(project_path, "diagram.json")
53
+ if os.path.exists(default):
54
+ return default
55
+
56
+ return ""
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
3
+
4
+ """Line-based serial text/regex matcher"""
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import threading
10
+ import time
11
+
12
+ from ._log import red_print, yellow_print
13
+
14
+ TIMEOUT_EXIT_CODE = 42
15
+
16
+ _MAX_LINE_LENGTH = 10_000
17
+
18
+
19
+ class ExpectEngine:
20
+ """Buffer chars until newline, then test the complete line against patterns."""
21
+
22
+ def __init__(
23
+ self,
24
+ expect_text: str = "",
25
+ fail_text: str = "",
26
+ expect_regex: str = "",
27
+ fail_regex: str = "",
28
+ ) -> None:
29
+ self.expect_text = expect_text
30
+ self.fail_text = fail_text
31
+ self._expect_re = re.compile(expect_regex) if expect_regex else None
32
+ self._fail_re = re.compile(fail_regex) if fail_regex else None
33
+ self.match_event = threading.Event()
34
+ self.fail_event = threading.Event()
35
+ self.active = bool(expect_text or fail_text or self._expect_re or self._fail_re)
36
+ self._current_line: list[str] = []
37
+
38
+ def _test_line(self, line: str) -> None:
39
+ if self.expect_text and self.expect_text in line:
40
+ self.match_event.set()
41
+ if self._expect_re and self._expect_re.search(line):
42
+ self.match_event.set()
43
+ if self.fail_text and self.fail_text in line:
44
+ self.fail_event.set()
45
+ if self._fail_re and self._fail_re.search(line):
46
+ self.fail_event.set()
47
+
48
+ def feed(self, data: bytes) -> None:
49
+ """Feed raw serial bytes - prints to stdout and tests for pattern matches."""
50
+ text = data.decode("utf-8", errors="replace")
51
+ print(text, end="", flush=True)
52
+
53
+ if not self.active:
54
+ return
55
+
56
+ for ch in text:
57
+ if ch == "\n":
58
+ self._test_line("".join(self._current_line))
59
+ self._current_line.clear()
60
+ elif len(self._current_line) < _MAX_LINE_LENGTH:
61
+ self._current_line.append(ch)
62
+
63
+ def wait(self, timeout_ms: int) -> None:
64
+ """Block until a match/fail is found or timeout elapses. Raises SystemExit."""
65
+ deadline = time.monotonic() + timeout_ms / 1000.0 if timeout_ms else float("inf")
66
+
67
+ while True:
68
+ remaining = deadline - time.monotonic()
69
+ if remaining <= 0:
70
+ red_print(f"Timeout after {timeout_ms} ms")
71
+ raise SystemExit(TIMEOUT_EXIT_CODE)
72
+
73
+ if self.fail_event.is_set():
74
+ msg = self.fail_text or str(self._fail_re and self._fail_re.pattern)
75
+ red_print(f"Fail text detected: {msg}")
76
+ raise SystemExit(1)
77
+
78
+ if self.match_event.is_set():
79
+ msg = self.expect_text or str(self._expect_re and self._expect_re.pattern)
80
+ yellow_print(f"Expected text found: {msg}")
81
+ return
82
+
83
+ time.sleep(min(remaining, 0.1))
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
3
+
4
+ """Interactive mode - forward stdin to simulation, platform-aware."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ from wokwi_client import WokwiClientSync
11
+
12
+ from ._log import yellow_print
13
+
14
+ if sys.platform == "win32":
15
+ import msvcrt
16
+
17
+ def _platform_loop(client: WokwiClientSync) -> None:
18
+ try:
19
+ while True:
20
+ ch = msvcrt.getch()
21
+ if ch == b"\x1d": # Ctrl+]
22
+ break
23
+ client.serial_write(ch)
24
+ except OSError:
25
+ pass
26
+
27
+ else:
28
+ import os
29
+ import termios
30
+ import tty
31
+
32
+ def _platform_loop(client: WokwiClientSync) -> None:
33
+ stdin_fd = sys.stdin.fileno()
34
+ old_settings = termios.tcgetattr(stdin_fd)
35
+ try:
36
+ tty.setcbreak(stdin_fd)
37
+ while True:
38
+ ch = os.read(stdin_fd, 1)
39
+ if ch == b"\x1d": # Ctrl+]
40
+ break
41
+ client.serial_write(ch)
42
+ except OSError:
43
+ pass
44
+ finally:
45
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
46
+
47
+
48
+ def interactive_loop(client: WokwiClientSync) -> None:
49
+ yellow_print("Press Ctrl+] to stop the simulation")
50
+ _platform_loop(client)
@@ -0,0 +1,185 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
3
+
4
+ """
5
+ ESP-IDF extension that adds ``idf.py wokwi`` for running Wokwi simulations.
6
+
7
+ Install once with ``pip install idf-wokwi`` and the command is available in
8
+ every ESP-IDF project.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from wokwi_client import GET_TOKEN_URL, WokwiClientSync
19
+
20
+ from ._log import red_print, yellow_print
21
+ from .diagram import find_diagram, generate_diagram
22
+ from .expect_engine import TIMEOUT_EXIT_CODE, ExpectEngine
23
+ from .interactive import interactive_loop
24
+
25
+ __all__ = ["TIMEOUT_EXIT_CODE", "action_extensions", "wokwi_callback"]
26
+
27
+
28
+ def _get_project_desc(build_dir: str) -> dict[str, Any]:
29
+ desc_path = os.path.join(build_dir, "project_description.json")
30
+ if not os.path.exists(desc_path):
31
+ red_print(f"Project description not found: {desc_path}")
32
+ red_print("Please build the project first with: idf.py build")
33
+ raise SystemExit(1)
34
+ with open(desc_path, encoding="utf-8") as f:
35
+ project_desc: dict[str, Any] = json.load(f)
36
+ return project_desc
37
+
38
+
39
+ def _upload_artifacts(
40
+ client: WokwiClientSync,
41
+ project_desc: dict[str, Any],
42
+ build_dir: str,
43
+ diagram_path: str,
44
+ flasher_args_path: str,
45
+ elf_path: str,
46
+ ) -> tuple[str, str]:
47
+ """Upload diagram, firmware, and ELF to the simulator. Returns (firmware_name, elf_name)."""
48
+ if diagram_path:
49
+ client.upload_file("diagram.json", Path(diagram_path))
50
+ yellow_print(f"Uploaded diagram: {diagram_path}")
51
+ else:
52
+ target: str = project_desc.get("target", "esp32")
53
+ diagram_content = generate_diagram(target)
54
+ client.upload("diagram.json", diagram_content.encode("utf-8"))
55
+ yellow_print(f"Generated diagram for target: {target}")
56
+
57
+ firmware_name: str = client.upload_file("flasher_args.json", Path(flasher_args_path))
58
+ yellow_print("Uploaded firmware")
59
+
60
+ elf_name = ""
61
+ if elf_path and os.path.exists(elf_path):
62
+ elf_name = client.upload_file(os.path.basename(elf_path), Path(elf_path))
63
+ yellow_print(f"Uploaded ELF: {os.path.basename(elf_path)}")
64
+
65
+ return firmware_name, elf_name
66
+
67
+
68
+ def wokwi_callback(
69
+ action: str,
70
+ ctx: Any,
71
+ args: Any,
72
+ diagram_file: str,
73
+ timeout: int,
74
+ expect_text: str,
75
+ fail_text: str,
76
+ expect_regex: str,
77
+ fail_regex: str,
78
+ ) -> None:
79
+ token = os.environ.get("WOKWI_CLI_TOKEN", "")
80
+ if not token:
81
+ red_print("WOKWI_CLI_TOKEN environment variable is not set.")
82
+ red_print(f"Get your token at: {GET_TOKEN_URL}")
83
+ raise SystemExit(1)
84
+
85
+ build_dir: str = args.build_dir
86
+ project_desc = _get_project_desc(build_dir)
87
+ project_path: str = project_desc.get("project_path", os.getcwd())
88
+
89
+ flasher_args_path = os.path.join(build_dir, "flasher_args.json")
90
+ if not os.path.exists(flasher_args_path):
91
+ red_print(f"flasher_args.json not found in {build_dir}")
92
+ red_print("Please build the project first with: idf.py build")
93
+ raise SystemExit(1)
94
+
95
+ elf_path = project_desc.get("app_elf", "")
96
+ if elf_path and not os.path.isabs(elf_path):
97
+ elf_path = os.path.join(build_dir, elf_path)
98
+
99
+ diagram_path = find_diagram(project_path, diagram_file)
100
+
101
+ server = os.environ.get("WOKWI_CLI_SERVER")
102
+ client = WokwiClientSync(token, server=server)
103
+
104
+ try:
105
+ hello = client.connect()
106
+ yellow_print(f"Connected to Wokwi Simulator (server {hello.get('version', '?')})")
107
+
108
+ firmware_name, elf_name = _upload_artifacts(
109
+ client, project_desc, build_dir, diagram_path, flasher_args_path, elf_path
110
+ )
111
+
112
+ engine = ExpectEngine(expect_text, fail_text, expect_regex, fail_regex)
113
+ client.serial_monitor(engine.feed)
114
+
115
+ client.start_simulation(firmware=firmware_name, elf=elf_name or None)
116
+ yellow_print("Simulation started")
117
+
118
+ if engine.active or timeout:
119
+ engine.wait(timeout)
120
+ else:
121
+ interactive_loop(client)
122
+
123
+ finally:
124
+ try:
125
+ client.disconnect()
126
+ except Exception:
127
+ pass
128
+
129
+
130
+ def action_extensions(base_actions: dict[str, Any], project_path: str) -> dict[str, Any]:
131
+ return {
132
+ "version": "1",
133
+ "actions": {
134
+ "wokwi": {
135
+ "callback": wokwi_callback,
136
+ "help": "Run Wokwi simulation.",
137
+ "dependencies": ["all"],
138
+ "options": [
139
+ {
140
+ "names": ["--diagram-file"],
141
+ "help": (
142
+ "Path to diagram.json file. "
143
+ "If not specified, looks for diagram.json in the project root."
144
+ ),
145
+ "is_flag": False,
146
+ "default": "",
147
+ },
148
+ {
149
+ "names": ["--timeout"],
150
+ "help": (
151
+ "Simulation timeout in milliseconds. "
152
+ "Exits with code 42 on timeout. 0 means no timeout."
153
+ ),
154
+ "is_flag": False,
155
+ "default": 0,
156
+ "type": int,
157
+ },
158
+ {
159
+ "names": ["--expect-text"],
160
+ "help": "Exit successfully when this text appears in serial output.",
161
+ "is_flag": False,
162
+ "default": "",
163
+ },
164
+ {
165
+ "names": ["--fail-text"],
166
+ "help": "Exit with error when this text appears in serial output.",
167
+ "is_flag": False,
168
+ "default": "",
169
+ },
170
+ {
171
+ "names": ["--expect-regex"],
172
+ "help": "Exit successfully when this regex matches a serial output line.",
173
+ "is_flag": False,
174
+ "default": "",
175
+ },
176
+ {
177
+ "names": ["--fail-regex"],
178
+ "help": "Exit with error when this regex matches a serial output line.",
179
+ "is_flag": False,
180
+ "default": "",
181
+ },
182
+ ],
183
+ }
184
+ },
185
+ }
@@ -0,0 +1,3 @@
1
+ cmake_minimum_required(VERSION 3.16)
2
+ include($ENV{IDF_PATH}/tools/cmake/project.cmake)
3
+ project(hello_world)
@@ -0,0 +1 @@
1
+ idf_component_register(SRCS "main.c" INCLUDE_DIRS ".")
@@ -0,0 +1,13 @@
1
+ #include <stdio.h>
2
+ #include "freertos/FreeRTOS.h"
3
+ #include "freertos/task.h"
4
+
5
+ void app_main(void)
6
+ {
7
+ printf("Hello world!\n");
8
+ for (int i = 10; i >= 0; i--) {
9
+ printf("Restarting in %d seconds...\n", i);
10
+ vTaskDelay(1000 / portTICK_PERIOD_MS);
11
+ }
12
+ esp_restart();
13
+ }
File without changes
@@ -0,0 +1,124 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2026, CodeMagic LTD
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import threading
8
+ from typing import Any
9
+ from unittest.mock import MagicMock
10
+
11
+ import pytest
12
+
13
+ from idf_wokwi.wokwi_ext import TIMEOUT_EXIT_CODE, action_extensions, wokwi_callback
14
+
15
+
16
+ def _hello_world_build_dir() -> str:
17
+ """Return path to a pre-built hello_world build directory.
18
+
19
+ Set BUILD_DIR env var to override.
20
+ """
21
+ explicit = os.environ.get("BUILD_DIR")
22
+ if explicit:
23
+ assert os.path.isfile(os.path.join(explicit, "flasher_args.json")), (
24
+ f"BUILD_DIR={explicit} does not contain flasher_args.json"
25
+ )
26
+ return explicit
27
+
28
+ idf_path = os.environ.get("IDF_PATH", "/workspace/esp-idf")
29
+ candidates = [
30
+ os.path.join(idf_path, "examples", "get-started", "hello_world", "build"),
31
+ ]
32
+ for c in candidates:
33
+ if os.path.isfile(os.path.join(c, "flasher_args.json")):
34
+ return c
35
+ pytest.fail("No hello_world build directory with flasher_args.json found")
36
+ raise AssertionError # unreachable, helps mypy
37
+
38
+
39
+ def test_action_extensions_structure() -> None:
40
+ result = action_extensions({}, "/tmp")
41
+ assert result["version"] == "1"
42
+ assert "wokwi" in result["actions"]
43
+ assert callable(result["actions"]["wokwi"]["callback"])
44
+ assert result["actions"]["wokwi"]["dependencies"] == ["all"]
45
+
46
+
47
+ class TestWokwiIntegration:
48
+ """Integration tests that connect to a real Wokwi CI server."""
49
+
50
+ def _make_args(self, build_dir: str) -> Any:
51
+ mock = MagicMock()
52
+ mock.build_dir = build_dir
53
+ return mock
54
+
55
+ def _run_callback_in_thread(
56
+ self,
57
+ build_dir: str,
58
+ *,
59
+ timeout: int = 10000,
60
+ expect_text: str = "",
61
+ fail_text: str = "",
62
+ expect_regex: str = "",
63
+ fail_regex: str = "",
64
+ diagram_file: str = "",
65
+ ) -> int:
66
+ """Run wokwi_callback in a thread and return the exit code (0=success)."""
67
+ exit_code = 0
68
+
69
+ def _target() -> None:
70
+ nonlocal exit_code
71
+ try:
72
+ wokwi_callback(
73
+ action="wokwi",
74
+ ctx=None,
75
+ args=self._make_args(build_dir),
76
+ diagram_file=diagram_file,
77
+ timeout=timeout,
78
+ expect_text=expect_text,
79
+ fail_text=fail_text,
80
+ expect_regex=expect_regex,
81
+ fail_regex=fail_regex,
82
+ )
83
+ except SystemExit as e:
84
+ exit_code = e.code if isinstance(e.code, int) else 1
85
+
86
+ t = threading.Thread(target=_target)
87
+ t.start()
88
+ t.join(timeout=30)
89
+ if t.is_alive():
90
+ exit_code = -1
91
+ return exit_code
92
+
93
+ def test_expect_text_hello_world(self) -> None:
94
+ """Simulation should find 'Hello world' in serial output."""
95
+ build_dir = _hello_world_build_dir()
96
+ code = self._run_callback_in_thread(build_dir, timeout=15000, expect_text="Hello world")
97
+ assert code == 0
98
+
99
+ def test_timeout_returns_42(self) -> None:
100
+ """A very short timeout should exit with code 42."""
101
+ build_dir = _hello_world_build_dir()
102
+ code = self._run_callback_in_thread(
103
+ build_dir, timeout=1, expect_text="this_text_will_never_appear"
104
+ )
105
+ assert code == TIMEOUT_EXIT_CODE
106
+
107
+ def test_fail_text(self) -> None:
108
+ """fail-text should cause exit code 1."""
109
+ build_dir = _hello_world_build_dir()
110
+ # "rst:0x1" appears early in ESP32 boot output
111
+ code = self._run_callback_in_thread(build_dir, timeout=15000, fail_text="rst:0x1")
112
+ assert code == 1
113
+
114
+ def test_expect_regex(self) -> None:
115
+ """expect-regex should match serial output with a pattern."""
116
+ build_dir = _hello_world_build_dir()
117
+ code = self._run_callback_in_thread(build_dir, timeout=15000, expect_regex=r"Hello\s+world")
118
+ assert code == 0
119
+
120
+ def test_fail_regex(self) -> None:
121
+ """fail-regex should cause exit code 1 on pattern match."""
122
+ build_dir = _hello_world_build_dir()
123
+ code = self._run_callback_in_thread(build_dir, timeout=15000, fail_regex=r"rst:0x[0-9a-f]+")
124
+ assert code == 1