Skip to content

Commit de005de

Browse files
committed
feat: add synchronous Wokwi client and corresponding tests
1 parent b8b6e1f commit de005de

File tree

11 files changed

+428
-2
lines changed

11 files changed

+428
-2
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ repos:
1313
rev: v1.16.1
1414
hooks:
1515
- id: mypy
16+
exclude: ^src/wokwi_client/client_sync\.pyi$
1617
additional_dependencies:
1718
[pydantic==2.8.0, typing-extensions, types-click, types-requests]
1819

examples/hello_esp32/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ async def main() -> None:
5353

5454
# Stream serial output for a few seconds
5555
serial_task = asyncio.create_task(client.serial_monitor_cat())
56+
57+
# Alternative lambda version
58+
# serial_task = client.serial_monitor(
59+
# lambda line: print(line.decode("utf-8", errors="replace"), end="", flush=True)
60+
# )
61+
5662
print(f"Simulation started, waiting for {SLEEP_TIME} seconds…")
5763
await client.wait_until_simulation_time(SLEEP_TIME)
5864
serial_task.cancel()

examples/hello_esp32_sync/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Ignore the firmware files, as they are downloaded from the internet
2+
hello_world.bin
3+
hello_world.elf

examples/hello_esp32_sync/__init__.py

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"version": 1,
3+
"author": "Uri Shaked",
4+
"editor": "wokwi",
5+
"parts": [
6+
{
7+
"type": "wokwi-esp32-devkit-v1",
8+
"id": "esp",
9+
"top": 0,
10+
"left": 0,
11+
"attrs": { "fullBoot": "1" }
12+
}
13+
],
14+
"connections": [
15+
["esp:TX0", "$serialMonitor:RX", "", []],
16+
["esp:RX0", "$serialMonitor:TX", "", []]
17+
],
18+
"serialMonitor": {
19+
"display": "terminal"
20+
},
21+
"dependencies": {}
22+
}

examples/hello_esp32_sync/main.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (C) 2025, CodeMagic LTD
3+
4+
import os
5+
from pathlib import Path
6+
7+
import requests
8+
9+
from wokwi_client import GET_TOKEN_URL, WokwiClientSync
10+
11+
EXAMPLE_DIR = Path(__file__).parent
12+
HELLO_WORLD_URL = "https://github.com/wokwi/esp-idf-hello-world/raw/refs/heads/main/bin"
13+
FIRMWARE_FILES = {
14+
"hello_world.bin": f"{HELLO_WORLD_URL}/hello_world.bin",
15+
"hello_world.elf": f"{HELLO_WORLD_URL}/hello_world.elf",
16+
}
17+
SLEEP_TIME = int(os.getenv("WOKWI_SLEEP_TIME", "10"))
18+
19+
20+
def main() -> None:
21+
token = os.getenv("WOKWI_CLI_TOKEN")
22+
if not token:
23+
raise SystemExit(
24+
f"Set WOKWI_CLI_TOKEN in your environment. You can get it from {GET_TOKEN_URL}."
25+
)
26+
27+
for filename, url in FIRMWARE_FILES.items():
28+
if (EXAMPLE_DIR / filename).exists():
29+
continue
30+
print(f"Downloading {filename} from {url}")
31+
response = requests.get(url)
32+
response.raise_for_status()
33+
with open(EXAMPLE_DIR / filename, "wb") as f:
34+
f.write(response.content)
35+
36+
client = WokwiClientSync(token)
37+
print(f"Wokwi client library version: {client.version}")
38+
39+
hello = client.connect()
40+
print("Connected to Wokwi Simulator, server version:", hello["version"])
41+
42+
# Upload the diagram and firmware files
43+
client.upload_file("diagram.json", EXAMPLE_DIR / "diagram.json")
44+
client.upload_file("hello_world.bin", EXAMPLE_DIR / "hello_world.bin")
45+
client.upload_file("hello_world.elf", EXAMPLE_DIR / "hello_world.elf")
46+
47+
# Start the simulation
48+
client.start_simulation(
49+
firmware="hello_world.bin",
50+
elf="hello_world.elf",
51+
)
52+
53+
# Stream serial output for a few seconds (non-blocking)
54+
client.serial_monitor_cat()
55+
# Alternative lambda version
56+
# client.serial_monitor(lambda line: print(line.decode("utf-8", errors="replace"), end="", flush=True))
57+
58+
print(f"Simulation started, waiting for {SLEEP_TIME} seconds…")
59+
client.wait_until_simulation_time(SLEEP_TIME)
60+
61+
# Disconnect from the simulator
62+
client.disconnect()
63+
64+
65+
if __name__ == "__main__":
66+
main()

src/wokwi_client/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
from .__version__ import get_version
1414
from .client import WokwiClient
15+
from .client_sync import WokwiClientSync
1516
from .constants import GET_TOKEN_URL
1617

1718
__version__ = get_version()
18-
__all__ = ["WokwiClient", "__version__", "GET_TOKEN_URL"]
19+
__all__ = ["WokwiClient", "WokwiClientSync", "__version__", "GET_TOKEN_URL"]

src/wokwi_client/client.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5+
import asyncio
56
import base64
7+
import inspect
68
from pathlib import Path
7-
from typing import Any, Optional, Union, cast
9+
from typing import Any, Callable, Optional, Union, cast
810

911
from .__version__ import get_version
1012
from .constants import DEFAULT_WS_URL
@@ -48,6 +50,7 @@ def __init__(self, token: str, server: Optional[str] = None):
4850
self.last_pause_nanos = 0
4951
self._transport.add_event_listener("sim:pause", self._on_pause)
5052
self._pause_queue = EventQueue(self._transport, "sim:pause")
53+
self._serial_monitor_tasks: set[asyncio.Task[None]] = set()
5154

5255
async def connect(self) -> dict[str, Any]:
5356
"""
@@ -61,7 +64,10 @@ async def connect(self) -> dict[str, Any]:
6164
async def disconnect(self) -> None:
6265
"""
6366
Disconnect from the Wokwi simulator server.
67+
68+
This also stops all active serial monitors.
6469
"""
70+
self.stop_serial_monitors()
6571
await self._transport.close()
6672

6773
async def upload(self, name: str, content: bytes) -> None:
@@ -188,6 +194,49 @@ async def restart_simulation(self, pause: bool = False) -> None:
188194
"""
189195
await restart(self._transport, pause)
190196

197+
def serial_monitor(self, callback: Callable[[bytes], Any]) -> asyncio.Task[None]:
198+
"""
199+
Start monitoring the serial output in the background and invoke `callback` for each line.
200+
201+
This method **does not block**: it creates and returns an asyncio.Task that runs until the
202+
transport is closed or the task is cancelled. The callback may be synchronous or async.
203+
204+
Example:
205+
task = client.serial_monitor(lambda line: print(line.decode(), end=""))
206+
... do other async work ...
207+
task.cancel()
208+
"""
209+
210+
async def _runner() -> None:
211+
try:
212+
async for line in monitor_lines(self._transport):
213+
try:
214+
result = callback(line)
215+
if inspect.isawaitable(result):
216+
await result
217+
except Exception:
218+
# Swallow callback exceptions to keep the monitor alive.
219+
# Users can add their own error handling inside the callback.
220+
pass
221+
finally:
222+
# Clean up task from the set when it completes
223+
self._serial_monitor_tasks.discard(task)
224+
225+
task = asyncio.create_task(_runner(), name="wokwi-serial-monitor")
226+
self._serial_monitor_tasks.add(task)
227+
return task
228+
229+
def stop_serial_monitors(self) -> None:
230+
"""
231+
Stop all active serial monitor tasks.
232+
233+
This method cancels all tasks created by the serial_monitor method.
234+
After calling this method, all active serial monitors will stop receiving data.
235+
"""
236+
for task in self._serial_monitor_tasks.copy():
237+
task.cancel()
238+
self._serial_monitor_tasks.clear()
239+
191240
async def serial_monitor_cat(self, decode_utf8: bool = True, errors: str = "replace") -> None:
192241
"""
193242
Print serial monitor output to stdout as it is received from the simulation.

0 commit comments

Comments
 (0)