Skip to content

Commit ac6e6b3

Browse files
committed
feat: initial library implementation + example
1 parent 9cfee60 commit ac6e6b3

17 files changed

+427
-4
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ repos:
1313
rev: v1.16.1
1414
hooks:
1515
- id: mypy
16-
additional_dependencies: [pydantic==2.8.0, typing-extensions, types-click]
16+
additional_dependencies:
17+
[pydantic==2.8.0, typing-extensions, types-click, types-requests]
1718

1819
# Blacken-docs
1920
- repo: https://github.com/asottile/blacken-docs

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,31 @@ Typed, asyncio-friendly Python SDK for the **Wokwi Simulation API**
77
[![CI](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml)
88
[![License: MIT](https://img.shields.io/github/license/wokwi/wokwi-python-client)](LICENSE)
99

10-
> **TL;DR:** Run and control your Wokwi simulations from Python, synchronously **or** asynchronously, with first-class type hints and zero boilerplate.
10+
> **TL;DR:** Run and control your Wokwi simulations from Python with first-class type hints and zero boilerplate.
1111
1212
---
1313

1414
## Installation requirements
1515

1616
Python ≥ 3.9
1717

18-
An API token from https://wokwi.com/dashboard/ci
18+
An API token from [https://wokwi.com/dashboard/ci](https://wokwi.com/dashboard/ci).
19+
20+
## Running the examples
21+
22+
The basic example is in the [examples/hello_esp32/main.py](examples/hello_esp32/main.py) file. It shows how to:
23+
24+
- Connect to the Wokwi Simulator
25+
- Upload a diagram and firmware files
26+
- Start a simulation
27+
- Monitor the serial output
28+
29+
You can run the example with:
30+
31+
```bash
32+
pip install -e .[dev]
33+
python -m examples.hello_esp32.main
34+
```
1935

2036
## License
2137

examples/hello_esp32/.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/diagram.json

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/main.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (C) 2025, CodeMagic LTD
3+
4+
import asyncio
5+
import os
6+
from pathlib import Path
7+
8+
import requests
9+
10+
from wokwi_client import GET_TOKEN_URL, WokwiClient
11+
12+
EXAMPLE_DIR = Path(__file__).parent
13+
HELLO_WORLD_URL = "https://github.com/wokwi/esp-idf-hello-world/raw/refs/heads/main/bin"
14+
FIRMWARE_FILES = {
15+
"hello_world.bin": f"{HELLO_WORLD_URL}/hello_world.bin",
16+
"hello_world.elf": f"{HELLO_WORLD_URL}/hello_world.elf",
17+
}
18+
19+
20+
async 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+
client = WokwiClient(token)
28+
print(f"Wokwi client library version: {client.version}")
29+
30+
hello = await client.connect()
31+
print("Connected to Wokwi Simulator, server version:", hello["version"])
32+
33+
for filename, url in FIRMWARE_FILES.items():
34+
if (EXAMPLE_DIR / filename).exists():
35+
continue
36+
print(f"Downloading {filename} from {url}")
37+
response = requests.get(url)
38+
response.raise_for_status()
39+
with open(EXAMPLE_DIR / filename, "wb") as f:
40+
f.write(response.content)
41+
42+
# Upload the diagram and firmware files
43+
await client.upload_file("diagram.json", EXAMPLE_DIR / "diagram.json")
44+
await client.upload_file("hello_world.bin", EXAMPLE_DIR / "hello_world.bin")
45+
await client.upload_file("hello_world.elf", EXAMPLE_DIR / "hello_world.elf")
46+
47+
# Start the simulation
48+
await client.start_simulation(
49+
firmware="hello_world.bin",
50+
elf="hello_world.elf",
51+
)
52+
53+
# Stream serial output for 10 seconds
54+
serial_task = asyncio.create_task(client.serial_monitor_cat())
55+
print("Simulation started, waiting for 10 seconds…")
56+
await asyncio.sleep(10)
57+
serial_task.cancel()
58+
59+
# Disconnect from the simulator
60+
await client.disconnect()
61+
62+
63+
if __name__ == "__main__":
64+
asyncio.run(main())

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ classifiers = [
2727
"Typing :: Typed",
2828
"License :: OSI Approved :: MIT License",
2929
]
30-
dependencies = ["pydantic>=2.8"]
30+
dependencies = ["pydantic>=2.8", "websockets>=12,<13"]
3131

3232
[project.urls]
3333
Documentation = "https://github.com/wokwi/wokwi-python-client#readme"
@@ -86,6 +86,7 @@ dependencies = [
8686
"pymdown-extensions",
8787
"mkdocs-material[extensions]",
8888
"mkdocstrings[python]",
89+
"types-requests",
8990
]
9091

9192
[tool.hatch.envs.dev]

src/wokwi_client/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
22
#
33
# SPDX-License-Identifier: MIT
4+
5+
from .__version__ import get_version
6+
from .client import WokwiClient
7+
from .constants import GET_TOKEN_URL
8+
9+
__version__ = get_version()
10+
__all__ = ["WokwiClient", "__version__", "GET_TOKEN_URL"]

src/wokwi_client/__version__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
15
from importlib.metadata import PackageNotFoundError, version
26

37
try: # works for normal + editable installs

src/wokwi_client/client.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from pathlib import Path
6+
from typing import Any, Optional
7+
8+
from .__version__ import get_version
9+
from .constants import DEFAULT_WS_URL
10+
from .file_ops import upload, upload_file
11+
from .protocol_types import ResponseMessage
12+
from .serial import monitor_lines
13+
from .simulation import pause, restart, resume, start
14+
from .transport import Transport
15+
16+
17+
class WokwiClient:
18+
version: str
19+
20+
def __init__(self, token: str, server: Optional[str] = None):
21+
self.version = get_version()
22+
self._transport = Transport(token, server or DEFAULT_WS_URL)
23+
24+
async def connect(self) -> dict[str, Any]:
25+
return await self._transport.connect()
26+
27+
async def disconnect(self) -> None:
28+
await self._transport.close()
29+
30+
async def upload(self, name: str, content: bytes) -> ResponseMessage:
31+
return await upload(self._transport, name, content)
32+
33+
async def upload_file(
34+
self, filename: str, local_path: Optional[Path] = None
35+
) -> ResponseMessage:
36+
return await upload_file(self._transport, filename, local_path)
37+
38+
async def start_simulation(self, **kwargs: Any) -> ResponseMessage:
39+
return await start(self._transport, **kwargs)
40+
41+
async def pause_simulation(self) -> ResponseMessage:
42+
return await pause(self._transport)
43+
44+
async def resume_simulation(self, pause_after: Optional[int] = None) -> ResponseMessage:
45+
return await resume(self._transport, pause_after)
46+
47+
async def restart_simulation(self, pause: bool = False) -> ResponseMessage:
48+
return await restart(self._transport, pause)
49+
50+
async def serial_monitor_cat(self) -> None:
51+
async for line in monitor_lines(self._transport):
52+
print(line.decode("utf-8"), end="", flush=True)

src/wokwi_client/constants.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
DEFAULT_WS_URL = "wss://wokwi.com/api/ws/beta"
6+
GET_TOKEN_URL = "https://wokwi.com/dashboard/ci"
7+
8+
MSG_TYPE_ERROR = "error"
9+
MSG_TYPE_RESPONSE = "response"
10+
MSG_TYPE_EVENT = "event"
11+
MSG_TYPE_COMMAND = "command"
12+
MSG_TYPE_HELLO = "hello"
13+
PROTOCOL_VERSION = 1

src/wokwi_client/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
6+
class WokwiError(Exception): ...
7+
8+
9+
class ProtocolError(WokwiError): ...
10+
11+
12+
class ServerError(WokwiError): ...

src/wokwi_client/file_ops.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import base64
6+
from pathlib import Path
7+
from typing import Optional
8+
9+
from .models import UploadParams
10+
from .protocol_types import ResponseMessage
11+
from .transport import Transport
12+
13+
14+
async def upload_file(
15+
transport: Transport, filename: str, local_path: Optional[Path] = None
16+
) -> ResponseMessage:
17+
path = Path(local_path or filename)
18+
content = path.read_bytes()
19+
return await upload(transport, filename, content)
20+
21+
22+
async def upload(transport: Transport, name: str, content: bytes) -> ResponseMessage:
23+
params = UploadParams(name=name, binary=base64.b64encode(content).decode())
24+
return await transport.request("file:upload", params.model_dump())

src/wokwi_client/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from pydantic import BaseModel, Field
6+
7+
8+
class UploadParams(BaseModel):
9+
name: str
10+
binary: str # base64
11+
12+
13+
class SimulationParams(BaseModel):
14+
firmware: str
15+
elf: str
16+
pause: bool = False
17+
chips: list[str] = Field(default_factory=list)

src/wokwi_client/protocol_types.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from typing import Any, TypedDict, Union
6+
7+
8+
class HelloMessage(TypedDict):
9+
type: str # "hello"
10+
protocolVersion: int
11+
appName: str
12+
appVersion: str
13+
14+
15+
class CommandMessage(TypedDict, total=False):
16+
type: str # "command"
17+
command: str
18+
id: str
19+
params: dict[str, Any]
20+
21+
22+
class ResponseMessage(TypedDict):
23+
type: str # "response"
24+
command: str
25+
id: str
26+
result: dict[str, Any]
27+
error: bool
28+
29+
30+
class ErrorResult(TypedDict):
31+
code: int
32+
message: str
33+
34+
35+
class EventMessage(TypedDict):
36+
type: str # "event"
37+
event: str
38+
payload: dict[str, Any]
39+
nanos: float
40+
paused: bool
41+
42+
43+
IncomingMessage = Union[HelloMessage, ResponseMessage, EventMessage]

src/wokwi_client/serial.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from collections.abc import AsyncGenerator
6+
from typing import cast
7+
8+
from .protocol_types import EventMessage
9+
from .transport import Transport
10+
11+
12+
async def monitor_lines(transport: Transport) -> AsyncGenerator[bytes, None]:
13+
await transport.request("serial-monitor:listen", {})
14+
while True:
15+
msg = await transport.recv()
16+
if msg["type"] == "event":
17+
event_msg = cast(EventMessage, msg)
18+
if event_msg["event"] == "serial-monitor:data":
19+
yield bytes(event_msg["payload"]["bytes"])

src/wokwi_client/simulation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from typing import Optional
6+
7+
from .protocol_types import ResponseMessage
8+
from .transport import Transport
9+
10+
11+
async def start(
12+
transport: Transport,
13+
*,
14+
firmware: str,
15+
elf: str,
16+
pause: bool = False,
17+
chips: list[str] = [],
18+
) -> ResponseMessage:
19+
return await transport.request(
20+
"sim:start", {"firmware": firmware, "elf": elf, "pause": pause, "chips": chips}
21+
)
22+
23+
24+
async def pause(transport: Transport) -> ResponseMessage:
25+
return await transport.request("sim:pause", {})
26+
27+
28+
async def resume(transport: Transport, pause_after: Optional[int] = None) -> ResponseMessage:
29+
return await transport.request("sim:resume", {"pauseAfter": pause_after})
30+
31+
32+
async def restart(transport: Transport, pause: bool = False) -> ResponseMessage:
33+
return await transport.request("sim:restart", {"pause": pause})

0 commit comments

Comments
 (0)