Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ repos:
rev: v1.16.1
hooks:
- id: mypy
exclude: ^src/wokwi_client/client_sync\.pyi$
additional_dependencies:
[pydantic==2.8.0, typing-extensions, types-click, types-requests]

Expand Down
6 changes: 6 additions & 0 deletions examples/hello_esp32/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ async def main() -> None:

# Stream serial output for a few seconds
serial_task = asyncio.create_task(client.serial_monitor_cat())

# Alternative lambda version
# serial_task = client.serial_monitor(
# lambda line: print(line.decode("utf-8", errors="replace"), end="", flush=True)
# )

print(f"Simulation started, waiting for {SLEEP_TIME} seconds…")
await client.wait_until_simulation_time(SLEEP_TIME)
serial_task.cancel()
Expand Down
3 changes: 3 additions & 0 deletions examples/hello_esp32_sync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Ignore the firmware files, as they are downloaded from the internet
hello_world.bin
hello_world.elf
Empty file.
22 changes: 22 additions & 0 deletions examples/hello_esp32_sync/diagram.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"version": 1,
"author": "Uri Shaked",
"editor": "wokwi",
"parts": [
{
"type": "wokwi-esp32-devkit-v1",
"id": "esp",
"top": 0,
"left": 0,
"attrs": { "fullBoot": "1" }
}
],
"connections": [
["esp:TX0", "$serialMonitor:RX", "", []],
["esp:RX0", "$serialMonitor:TX", "", []]
],
"serialMonitor": {
"display": "terminal"
},
"dependencies": {}
}
66 changes: 66 additions & 0 deletions examples/hello_esp32_sync/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# SPDX-License-Identifier: MIT
# Copyright (C) 2025, CodeMagic LTD

import os
from pathlib import Path

import requests

from wokwi_client import GET_TOKEN_URL, WokwiClientSync

EXAMPLE_DIR = Path(__file__).parent
HELLO_WORLD_URL = "https://github.com/wokwi/esp-idf-hello-world/raw/refs/heads/main/bin"
FIRMWARE_FILES = {
"hello_world.bin": f"{HELLO_WORLD_URL}/hello_world.bin",
"hello_world.elf": f"{HELLO_WORLD_URL}/hello_world.elf",
}
SLEEP_TIME = int(os.getenv("WOKWI_SLEEP_TIME", "10"))


def main() -> None:
token = os.getenv("WOKWI_CLI_TOKEN")
if not token:
raise SystemExit(
f"Set WOKWI_CLI_TOKEN in your environment. You can get it from {GET_TOKEN_URL}."
)

for filename, url in FIRMWARE_FILES.items():
if (EXAMPLE_DIR / filename).exists():
continue
print(f"Downloading {filename} from {url}")
response = requests.get(url)
response.raise_for_status()
with open(EXAMPLE_DIR / filename, "wb") as f:
f.write(response.content)

client = WokwiClientSync(token)
print(f"Wokwi client library version: {client.version}")

hello = client.connect()
print("Connected to Wokwi Simulator, server version:", hello["version"])

# Upload the diagram and firmware files
client.upload_file("diagram.json", EXAMPLE_DIR / "diagram.json")
client.upload_file("hello_world.bin", EXAMPLE_DIR / "hello_world.bin")
client.upload_file("hello_world.elf", EXAMPLE_DIR / "hello_world.elf")

# Start the simulation
client.start_simulation(
firmware="hello_world.bin",
elf="hello_world.elf",
)

# Stream serial output for a few seconds (non-blocking)
client.serial_monitor_cat()
# Alternative lambda version
# client.serial_monitor(lambda line: print(line.decode("utf-8", errors="replace"), end="", flush=True))

print(f"Simulation started, waiting for {SLEEP_TIME} seconds…")
client.wait_until_simulation_time(SLEEP_TIME)

# Disconnect from the simulator
client.disconnect()


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion src/wokwi_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

from .__version__ import get_version
from .client import WokwiClient
from .client_sync import WokwiClientSync
from .constants import GET_TOKEN_URL

__version__ = get_version()
__all__ = ["WokwiClient", "__version__", "GET_TOKEN_URL"]
__all__ = ["WokwiClient", "WokwiClientSync", "__version__", "GET_TOKEN_URL"]
56 changes: 54 additions & 2 deletions src/wokwi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
#
# SPDX-License-Identifier: MIT

import asyncio
import base64
import inspect
from pathlib import Path
from typing import Any, Optional, Union, cast
from typing import Any, Callable, Optional, Union, cast

from .__version__ import get_version
from .constants import DEFAULT_WS_URL
Expand Down Expand Up @@ -47,7 +49,9 @@ def __init__(self, token: str, server: Optional[str] = None):
self._transport = Transport(token, server or DEFAULT_WS_URL)
self.last_pause_nanos = 0
self._transport.add_event_listener("sim:pause", self._on_pause)
self._pause_queue = EventQueue(self._transport, "sim:pause")
# Lazily create in an active event loop (important for py3.9 and sync client)
self._pause_queue: Optional[EventQueue] = None
self._serial_monitor_tasks: set[asyncio.Task[None]] = set()

async def connect(self) -> dict[str, Any]:
"""
Expand All @@ -61,7 +65,10 @@ async def connect(self) -> dict[str, Any]:
async def disconnect(self) -> None:
"""
Disconnect from the Wokwi simulator server.

This also stops all active serial monitors.
"""
self.stop_serial_monitors()
await self._transport.close()

async def upload(self, name: str, content: bytes) -> None:
Expand Down Expand Up @@ -175,6 +182,8 @@ async def wait_until_simulation_time(self, seconds: float) -> None:
await pause(self._transport)
remaining_nanos = seconds * 1e9 - self.last_pause_nanos
if remaining_nanos > 0:
if self._pause_queue is None:
self._pause_queue = EventQueue(self._transport, "sim:pause")
self._pause_queue.flush()
await resume(self._transport, int(remaining_nanos))
await self._pause_queue.get()
Expand All @@ -188,6 +197,49 @@ async def restart_simulation(self, pause: bool = False) -> None:
"""
await restart(self._transport, pause)

def serial_monitor(self, callback: Callable[[bytes], Any]) -> asyncio.Task[None]:
"""
Start monitoring the serial output in the background and invoke `callback` for each line.

This method **does not block**: it creates and returns an asyncio.Task that runs until the
transport is closed or the task is cancelled. The callback may be synchronous or async.

Example:
task = client.serial_monitor(lambda line: print(line.decode(), end=""))
... do other async work ...
task.cancel()
"""

async def _runner() -> None:
try:
async for line in monitor_lines(self._transport):
try:
result = callback(line)
if inspect.isawaitable(result):
await result
except Exception:
# Swallow callback exceptions to keep the monitor alive.
# Users can add their own error handling inside the callback.
pass
finally:
# Clean up task from the set when it completes
self._serial_monitor_tasks.discard(task)

task = asyncio.create_task(_runner(), name="wokwi-serial-monitor")
self._serial_monitor_tasks.add(task)
return task

def stop_serial_monitors(self) -> None:
"""
Stop all active serial monitor tasks.

This method cancels all tasks created by the serial_monitor method.
After calling this method, all active serial monitors will stop receiving data.
"""
for task in self._serial_monitor_tasks.copy():
task.cancel()
self._serial_monitor_tasks.clear()

async def serial_monitor_cat(self, decode_utf8: bool = True, errors: str = "replace") -> None:
"""
Print serial monitor output to stdout as it is received from the simulation.
Expand Down
Loading