Skip to content

Commit 0e314f2

Browse files
feat: add WokwiClientSync - a synchronous Wokwi client (#9)
1 parent b8b6e1f commit 0e314f2

File tree

12 files changed

+437
-4
lines changed

12 files changed

+437
-4
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: 54 additions & 2 deletions
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
@@ -47,7 +49,9 @@ def __init__(self, token: str, server: Optional[str] = None):
4749
self._transport = Transport(token, server or DEFAULT_WS_URL)
4850
self.last_pause_nanos = 0
4951
self._transport.add_event_listener("sim:pause", self._on_pause)
50-
self._pause_queue = EventQueue(self._transport, "sim:pause")
52+
# Lazily create in an active event loop (important for py3.9 and sync client)
53+
self._pause_queue: Optional[EventQueue] = None
54+
self._serial_monitor_tasks: set[asyncio.Task[None]] = set()
5155

5256
async def connect(self) -> dict[str, Any]:
5357
"""
@@ -61,7 +65,10 @@ async def connect(self) -> dict[str, Any]:
6165
async def disconnect(self) -> None:
6266
"""
6367
Disconnect from the Wokwi simulator server.
68+
69+
This also stops all active serial monitors.
6470
"""
71+
self.stop_serial_monitors()
6572
await self._transport.close()
6673

6774
async def upload(self, name: str, content: bytes) -> None:
@@ -175,6 +182,8 @@ async def wait_until_simulation_time(self, seconds: float) -> None:
175182
await pause(self._transport)
176183
remaining_nanos = seconds * 1e9 - self.last_pause_nanos
177184
if remaining_nanos > 0:
185+
if self._pause_queue is None:
186+
self._pause_queue = EventQueue(self._transport, "sim:pause")
178187
self._pause_queue.flush()
179188
await resume(self._transport, int(remaining_nanos))
180189
await self._pause_queue.get()
@@ -188,6 +197,49 @@ async def restart_simulation(self, pause: bool = False) -> None:
188197
"""
189198
await restart(self._transport, pause)
190199

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

0 commit comments

Comments
 (0)