Skip to content

Commit 3ab494f

Browse files
authored
Merge pull request #365 from JakubAndrysek/wokwi-python-client
refactor: replace WokwiCLI with Wokwi class (RDT-1428)
2 parents 586fbdd + 4b425c8 commit 3ab494f

File tree

12 files changed

+272
-277
lines changed

12 files changed

+272
-277
lines changed

docs/apis/pytest-embedded-wokwi.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
:undoc-members:
1818
:show-inheritance:
1919

20-
.. automodule:: pytest_embedded_wokwi.wokwi_cli
20+
.. automodule:: pytest_embedded_wokwi.wokwi
2121
:members:
2222
:undoc-members:
2323
:show-inheritance:

pytest-embedded-wokwi/README.md

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,6 @@ Wokwi supports most ESP32 targets, including: esp32, esp32s2, esp32s3, esp32c3,
66

77
Running the tests with Wokwi requires an internet connection. Your firmware is uploaded to the Wokwi server for the duration of the simulation, but it is not saved on the server. On-premises Wokwi installations are available for enterprise customers.
88

9-
#### Wokwi CLI installation
10-
11-
The Wokwi plugin uses the [Wokwi CLI](https://github.com/wokwi/wokwi-cli) to interact with the wokwi simulation server. You can download the precompiled CLI binaries from the [releases page](https://github.com/wokwi/wokwi-cli/releases). Alternatively, on Linux or Mac OS, you can install the CLI using the following command:
12-
13-
```bash
14-
curl -L https://wokwi.com/ci/install.sh | sh
15-
```
16-
17-
And on Windows:
18-
19-
```powershell
20-
iwr https://wokwi.com/ci/install.ps1 -useb | iex
21-
```
22-
239
#### Wokwi API Tokens
2410

2511
Before using this plugin, you need to create a free Wokwi account and [generate an API key](https://wokwi.com/dashboard/ci). You can then set the `WOKWI_CLI_TOKEN` environment variable to the API key.
@@ -44,8 +30,32 @@ To run your tests with Wokwi, make sure to specify the `wokwi` service when runn
4430
pytest --embedded-services idf,wokwi
4531
```
4632

47-
To limit the amount of simulation time, use the `--wokwi-timeout` flag. For example, to set the simulation time limit to 60 seconds (60000 milliseconds):
33+
#### Writing Tests
4834

49-
```
50-
pytest --embedded-services idf,wokwi --wokwi-timeout=60000
35+
When writing tests for your firmware, you can use the same pytest fixtures and assertions as you would for local testing. The main difference is that your tests will be executed in the Wokwi simulation environment and you have access to the Wokwi API for controlling the simulation through the `wokwi` fixture.
36+
37+
All interactions with the Wokwi simulation is through the `wokwi.client` - [wokwi-python-client](https://github.com/wokwi/wokwi-python-client)
38+
39+
For example, you can use `wokwi.client.set_control()` to control virtual components in the simulation, such as buttons, LEDs, and other peripherals.
40+
Whole documentations can be found at [Wokwi Documentation](https://wokwi.github.io/wokwi-python-client/)
41+
42+
Button test:
43+
```py
44+
import logging
45+
from pytest_embedded_wokwi import Wokwi
46+
from pytest_embedded import Dut
47+
48+
49+
def test_gpio(dut: Dut, wokwi: Wokwi):
50+
LOGGER = logging.getLogger(__name__)
51+
52+
LOGGER.info("Waiting for Button test begin...")
53+
dut.expect_exact("Butston test")
54+
55+
for i in range(3):
56+
LOGGER.info(f"Setting button pressed for {i + 1} seconds")
57+
wokwi.client.set_control("btn1", "pressed", 1)
58+
59+
dut.expect_exact(f"Button pressed {i + 1} times")
60+
wokwi.client.set_control("btn1", "pressed", 0)
5161
```

pytest-embedded-wokwi/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ name = "pytest-embedded-wokwi"
77
authors = [
88
{name = "Fu Hanxi", email = "fuhanxi@espressif.com"},
99
{name = "Uri Shaked", email = "uri@wokwi.com"},
10+
{name = "Jakub Andrysek", email = "jakub.andrysek@espressif.com"},
1011
]
1112
readme = "README.md"
1213
license = {file = "LICENSE"}
@@ -30,7 +31,7 @@ requires-python = ">=3.10"
3031

3132
dependencies = [
3233
"pytest-embedded~=1.17.0a2",
33-
"toml~=0.10.2",
34+
"wokwi-client>=0.2.0",
3435
]
3536

3637
[project.optional-dependencies]

pytest-embedded-wokwi/pytest_embedded_wokwi/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
WOKWI_CLI_MINIMUM_VERSION = '0.10.1'
44

55
from .dut import WokwiDut # noqa
6-
from .wokwi_cli import WokwiCLI # noqa
6+
from .wokwi import Wokwi # noqa
77

88
__all__ = [
99
'WOKWI_CLI_MINIMUM_VERSION',
10-
'WokwiCLI',
10+
'Wokwi',
1111
'WokwiDut',
1212
]
1313

pytest-embedded-wokwi/pytest_embedded_wokwi/dut.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from pytest_embedded.dut import Dut
44

5-
from .wokwi_cli import WokwiCLI
5+
from .wokwi import Wokwi
66

77

88
class WokwiDut(Dut):
@@ -12,7 +12,7 @@ class WokwiDut(Dut):
1212

1313
def __init__(
1414
self,
15-
wokwi: WokwiCLI,
15+
wokwi: Wokwi,
1616
**kwargs,
1717
) -> None:
1818
self.wokwi = wokwi
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import json
2+
import logging
3+
import os
4+
import typing as t
5+
from pathlib import Path
6+
7+
from packaging.version import Version
8+
from pytest_embedded.log import DuplicateStdoutPopen, MessageQueue
9+
from pytest_embedded.utils import Meta
10+
from wokwi_client import GET_TOKEN_URL, WokwiClientSync
11+
12+
from pytest_embedded_wokwi import WOKWI_CLI_MINIMUM_VERSION
13+
14+
from .idf import IDFFirmwareResolver
15+
16+
if t.TYPE_CHECKING: # pragma: no cover
17+
from pytest_embedded_idf.app import IdfApp
18+
19+
20+
target_to_board = {
21+
'esp32': 'board-esp32-devkit-c-v4',
22+
'esp32c3': 'board-esp32-c3-devkitm-1',
23+
'esp32c6': 'board-esp32-c6-devkitc-1',
24+
'esp32h2': 'board-esp32-h2-devkitm-1',
25+
'esp32p4': 'board-esp32-p4-function-ev',
26+
'esp32s2': 'board-esp32-s2-devkitm-1',
27+
'esp32s3': 'board-esp32-s3-devkitc-1',
28+
}
29+
30+
31+
class Wokwi(DuplicateStdoutPopen):
32+
"""Synchronous Wokwi integration that inherits from DuplicateStdoutPopen.
33+
34+
This class provides a synchronous interface to the Wokwi simulator while maintaining
35+
compatibility with pytest-embedded's logging and message queue infrastructure.
36+
"""
37+
38+
SOURCE = 'Wokwi'
39+
REDIRECT_CLS = None # We'll handle output redirection manually
40+
41+
def __init__(
42+
self,
43+
msg_queue: MessageQueue,
44+
firmware_resolver: IDFFirmwareResolver,
45+
wokwi_diagram: str | None = None,
46+
app: t.Optional['IdfApp'] = None,
47+
meta: Meta | None = None,
48+
**kwargs,
49+
):
50+
self.app = app
51+
52+
# Get Wokwi API token
53+
token = os.getenv('WOKWI_CLI_TOKEN')
54+
if not token:
55+
raise SystemExit(f'Set WOKWI_CLI_TOKEN in your environment. You can get it from {GET_TOKEN_URL}.')
56+
57+
# Initialize synchronous Wokwi client
58+
self.client = WokwiClientSync(token)
59+
60+
# Check version compatibility
61+
if Version(self.client.version) < Version(WOKWI_CLI_MINIMUM_VERSION):
62+
logging.warning(
63+
'Wokwi client version %s < required %s (compatibility not guaranteed)',
64+
self.client.version,
65+
WOKWI_CLI_MINIMUM_VERSION,
66+
)
67+
logging.info('Wokwi client library version: %s', self.client.version)
68+
69+
# Prepare diagram file if not supplied
70+
if wokwi_diagram is None:
71+
self.create_diagram_json()
72+
wokwi_diagram = os.path.join(self.app.app_path, 'diagram.json')
73+
74+
# Initialize parent class
75+
super().__init__(msg_queue=msg_queue, meta=meta, **kwargs)
76+
77+
# Connect and start simulation
78+
try:
79+
flasher_args = firmware_resolver.resolve_firmware(app)
80+
firmware_path = Path(flasher_args).as_posix()
81+
elf_path = Path(app.elf_file).as_posix()
82+
83+
self._setup_simulation(wokwi_diagram, firmware_path, elf_path)
84+
self._start_serial_monitoring()
85+
except Exception as e:
86+
self.close()
87+
raise e
88+
89+
def _setup_simulation(self, diagram: str, firmware_path: str, elf_path: str):
90+
"""Set up the Wokwi simulation."""
91+
hello = self.client.connect()
92+
logging.info('Connected to Wokwi Simulator, server version: %s', hello.get('version', 'unknown'))
93+
94+
# Upload files
95+
self.client.upload_file('diagram.json', diagram)
96+
firmware = self.client.upload_file('pytest.bin', firmware_path)
97+
98+
self.client.upload_file('pytest.elf', elf_path)
99+
100+
logging.info('Uploaded diagram and firmware to Wokwi. Starting simulation...')
101+
102+
# Start simulation
103+
self.client.start_simulation(firmware, elf='pytest.elf')
104+
105+
def _start_serial_monitoring(self):
106+
"""Start monitoring serial output and forward to stdout and message queue."""
107+
108+
def serial_callback(data: bytes):
109+
# Write to stdout for live monitoring
110+
try:
111+
decoded = data.decode('utf-8', errors='replace')
112+
print(decoded, end='', flush=True)
113+
except Exception as e:
114+
logging.debug(f'Error writing to stdout: {e}')
115+
116+
# Write to log file if available
117+
try:
118+
if hasattr(self, '_fw') and self._fw and not self._fw.closed:
119+
decoded = data.decode('utf-8', errors='replace')
120+
self._fw.write(decoded)
121+
self._fw.flush()
122+
except Exception as e:
123+
logging.debug(f'Error writing to log file: {e}')
124+
125+
# Put in message queue for expect() functionality
126+
try:
127+
if hasattr(self, '_q') and self._q:
128+
self._q.put(data)
129+
except Exception as e:
130+
logging.debug(f'Error putting data in message queue: {e}')
131+
132+
# Start monitoring in background
133+
self.client.serial_monitor(serial_callback)
134+
135+
def write(self, s: str | bytes) -> None:
136+
"""Write data to the Wokwi serial interface."""
137+
try:
138+
data = s if isinstance(s, bytes) else s.encode('utf-8')
139+
self.client.serial_write(data)
140+
logging.debug(f'{self.SOURCE} ->: {s}')
141+
except Exception as e:
142+
logging.error(f'Failed to write to Wokwi serial: {e}')
143+
144+
def close(self):
145+
"""Clean up resources."""
146+
try:
147+
if hasattr(self, 'client') and self.client:
148+
self.client.disconnect()
149+
except Exception as e:
150+
logging.debug(f'Error during Wokwi cleanup: {e}')
151+
finally:
152+
super().close()
153+
154+
def __del__(self):
155+
"""Destructor to ensure cleanup when object is garbage collected."""
156+
self.close()
157+
super().__del__()
158+
159+
def terminate(self):
160+
"""Terminate the Wokwi connection."""
161+
self.close()
162+
super().terminate()
163+
164+
def create_diagram_json(self):
165+
"""Create a diagram.json file for the simulation."""
166+
app = self.app
167+
target_board = target_to_board[app.target]
168+
169+
# Check for existing diagram.json file
170+
diagram_json_path = os.path.join(app.app_path, 'diagram.json')
171+
if os.path.exists(diagram_json_path):
172+
with open(diagram_json_path) as f:
173+
json_data = json.load(f)
174+
if not any(part['type'] == target_board for part in json_data['parts']):
175+
logging.warning(
176+
f'diagram.json exists, no part with type "{target_board}" found. '
177+
+ 'You may need to update the diagram.json file manually to match the target board.'
178+
)
179+
return
180+
181+
# Create default diagram
182+
if app.target == 'esp32p4':
183+
rx_pin = '38'
184+
tx_pin = '37'
185+
else:
186+
rx_pin = 'RX'
187+
tx_pin = 'TX'
188+
189+
diagram = {
190+
'version': 1,
191+
'author': 'Uri Shaked',
192+
'editor': 'wokwi',
193+
'parts': [{'type': target_board, 'id': 'esp'}],
194+
'connections': [
195+
['esp:' + tx_pin, '$serialMonitor:RX', ''],
196+
['esp:' + rx_pin, '$serialMonitor:TX', ''],
197+
],
198+
}
199+
200+
with open(diagram_json_path, 'w') as f:
201+
json.dump(diagram, f, indent=2)
202+
203+
def _hard_reset(self):
204+
"""Fake hard_reset to maintain API consistency."""
205+
raise NotImplementedError('Hard reset not supported in Wokwi simulation')

0 commit comments

Comments
 (0)