Skip to content

Commit 4cbc9ce

Browse files
committed
refactor: replace WokwiCLI with Wokwi class and update related references
1 parent a034cb7 commit 4cbc9ce

File tree

9 files changed

+243
-195
lines changed

9 files changed

+243
-195
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/pyproject.toml

Lines changed: 3 additions & 0 deletions
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"}
@@ -34,6 +35,8 @@ requires-python = ">=3.7"
3435
dependencies = [
3536
"pytest-embedded~=1.17.0a2",
3637
"toml~=0.10.2",
38+
# Temporary workaround for Wokwi client - will be redirected to the official repo
39+
"wokwi-client @ git+https://github.com/JakubAndrysek/wokwi-python-client.git@sync-wokwi-client",
3740
]
3841

3942
[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+
6+
from packaging.version import Version
7+
from pytest_embedded.log import DuplicateStdoutPopen, MessageQueue
8+
from pytest_embedded.utils import Meta
9+
from wokwi_client import GET_TOKEN_URL, WokwiClientSync
10+
11+
from pytest_embedded_wokwi import WOKWI_CLI_MINIMUM_VERSION
12+
13+
from .idf import IDFFirmwareResolver
14+
15+
if t.TYPE_CHECKING: # pragma: no cover
16+
from pytest_embedded_idf.app import IdfApp
17+
18+
19+
target_to_board = {
20+
'esp32': 'board-esp32-devkit-c-v4',
21+
'esp32c3': 'board-esp32-c3-devkitm-1',
22+
'esp32c6': 'board-esp32-c6-devkitc-1',
23+
'esp32h2': 'board-esp32-h2-devkitm-1',
24+
'esp32p4': 'board-esp32-p4-function-ev',
25+
'esp32s2': 'board-esp32-s2-devkitm-1',
26+
'esp32s3': 'board-esp32-s3-devkitc-1',
27+
}
28+
29+
30+
class Wokwi(DuplicateStdoutPopen):
31+
"""Synchronous Wokwi integration that inherits from DuplicateStdoutPopen.
32+
33+
This class provides a synchronous interface to the Wokwi simulator while maintaining
34+
compatibility with pytest-embedded's logging and message queue infrastructure.
35+
"""
36+
37+
SOURCE = 'Wokwi'
38+
REDIRECT_CLS = None # We'll handle output redirection manually
39+
40+
def __init__(
41+
self,
42+
msg_queue: MessageQueue,
43+
firmware_resolver: IDFFirmwareResolver,
44+
wokwi_diagram: t.Optional[str] = None,
45+
app: t.Optional['IdfApp'] = None,
46+
_wokwi_cli_path: t.Optional[str] = None, # ignored for compatibility
47+
_wokwi_timeout: t.Optional[int] = None, # ignored for compatibility
48+
_wokwi_scenario: t.Optional[str] = None, # ignored for compatibility
49+
meta: t.Optional[Meta] = None,
50+
**kwargs,
51+
):
52+
self.app = app
53+
self.firmware_resolver = firmware_resolver
54+
55+
# Get Wokwi API token
56+
token = os.getenv('WOKWI_CLI_TOKEN')
57+
if not token:
58+
raise SystemExit(f'Set WOKWI_CLI_TOKEN in your environment. You can get it from {GET_TOKEN_URL}.')
59+
60+
# Initialize synchronous Wokwi client
61+
self.client = WokwiClientSync(token)
62+
63+
# Check version compatibility
64+
if Version(self.client.version) < Version(WOKWI_CLI_MINIMUM_VERSION):
65+
logging.warning(
66+
'Wokwi client version %s < required %s (compatibility not guaranteed)',
67+
self.client.version,
68+
WOKWI_CLI_MINIMUM_VERSION,
69+
)
70+
logging.info('Wokwi client library version: %s', self.client.version)
71+
72+
# Prepare diagram file if not supplied
73+
if wokwi_diagram is None:
74+
self.create_diagram_json()
75+
wokwi_diagram = os.path.join(self.app.app_path, 'diagram.json')
76+
77+
# Filter out Wokwi-specific kwargs that shouldn't be passed to subprocess.Popen
78+
wokwi_specific_kwargs = {'wokwi_timeout', 'wokwi_scenario', 'wokwi_diagram', 'firmware_resolver', 'app'}
79+
filtered_kwargs = {k: v for k, v in kwargs.items() if k not in wokwi_specific_kwargs}
80+
81+
# Initialize parent class
82+
super().__init__(msg_queue=msg_queue, meta=meta, **filtered_kwargs)
83+
84+
# Connect and start simulation
85+
try:
86+
firmware_path = self.firmware_resolver.resolve_firmware(app)
87+
self._setup_simulation(wokwi_diagram, firmware_path, app.elf_file)
88+
self._start_serial_monitoring()
89+
except Exception as e:
90+
self.close()
91+
raise e
92+
93+
def _setup_simulation(self, diagram: str, firmware_path: str, elf_path: str):
94+
"""Set up the Wokwi simulation."""
95+
hello = self.client.connect()
96+
logging.info('Connected to Wokwi Simulator, server version: %s', hello.get('version', 'unknown'))
97+
98+
# Upload files
99+
self.client.upload_file('diagram.json', diagram)
100+
self.client.upload_file('pytest.bin', firmware_path)
101+
self.client.upload_file('pytest.elf', elf_path)
102+
103+
# Start simulation
104+
self.client.start_simulation(firmware='pytest.bin', elf='pytest.elf')
105+
106+
def _start_serial_monitoring(self):
107+
"""Start monitoring serial output and forward to stdout and message queue."""
108+
109+
def serial_callback(data: bytes):
110+
# Write to stdout for live monitoring
111+
try:
112+
decoded = data.decode('utf-8', errors='replace')
113+
print(decoded, end='', flush=True)
114+
except Exception as e:
115+
logging.debug(f'Error writing to stdout: {e}')
116+
117+
# Write to log file if available
118+
try:
119+
if hasattr(self, '_fw') and self._fw and not self._fw.closed:
120+
decoded = data.decode('utf-8', errors='replace')
121+
self._fw.write(decoded)
122+
self._fw.flush()
123+
except Exception as e:
124+
logging.debug(f'Error writing to log file: {e}')
125+
126+
# Put in message queue for expect() functionality
127+
try:
128+
if hasattr(self, '_q') and self._q:
129+
self._q.put(data)
130+
except Exception as e:
131+
logging.debug(f'Error putting data in message queue: {e}')
132+
133+
# Start monitoring in background
134+
self.client.monitor_serial(serial_callback)
135+
136+
def write(self, s: t.Union[str, bytes]) -> None:
137+
"""Write data to the Wokwi serial interface."""
138+
try:
139+
data = s if isinstance(s, bytes) else s.encode('utf-8')
140+
self.client.write_serial(data)
141+
logging.debug(f'{self.SOURCE} ->: {s}')
142+
except Exception as e:
143+
logging.error(f'Failed to write to Wokwi serial: {e}')
144+
145+
def close(self):
146+
"""Clean up resources."""
147+
try:
148+
if hasattr(self, 'client') and self.client:
149+
self.client.disconnect()
150+
except Exception as e:
151+
logging.debug(f'Error during Wokwi cleanup: {e}')
152+
finally:
153+
super().close()
154+
155+
def __del__(self):
156+
"""Destructor to ensure cleanup when object is garbage collected."""
157+
self.close()
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)