wokwi-client 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wokwi_client-0.0.1/.editorconfig +19 -0
- wokwi_client-0.0.1/.github/workflows/ci.yaml +28 -0
- wokwi_client-0.0.1/.github/workflows/docs.yaml +37 -0
- wokwi_client-0.0.1/.github/workflows/release.yaml +38 -0
- wokwi_client-0.0.1/.gitignore +58 -0
- wokwi_client-0.0.1/.pre-commit-config.yaml +33 -0
- wokwi_client-0.0.1/.ruff.toml +21 -0
- wokwi_client-0.0.1/CHANGELOG.md +5 -0
- wokwi_client-0.0.1/LICENSE +21 -0
- wokwi_client-0.0.1/PKG-INFO +64 -0
- wokwi_client-0.0.1/README.md +38 -0
- wokwi_client-0.0.1/mkdocs.yml +51 -0
- wokwi_client-0.0.1/mypy.ini +14 -0
- wokwi_client-0.0.1/pyproject.toml +97 -0
- wokwi_client-0.0.1/src/wokwi_client/__init__.py +18 -0
- wokwi_client-0.0.1/src/wokwi_client/__main__.py +10 -0
- wokwi_client-0.0.1/src/wokwi_client/__version__.py +18 -0
- wokwi_client-0.0.1/src/wokwi_client/cli/__init__.py +15 -0
- wokwi_client-0.0.1/src/wokwi_client/client.py +182 -0
- wokwi_client-0.0.1/src/wokwi_client/constants.py +13 -0
- wokwi_client-0.0.1/src/wokwi_client/event_queue.py +57 -0
- wokwi_client-0.0.1/src/wokwi_client/exceptions.py +12 -0
- wokwi_client-0.0.1/src/wokwi_client/file_ops.py +24 -0
- wokwi_client-0.0.1/src/wokwi_client/models.py +17 -0
- wokwi_client-0.0.1/src/wokwi_client/protocol_types.py +43 -0
- wokwi_client-0.0.1/src/wokwi_client/serial.py +16 -0
- wokwi_client-0.0.1/src/wokwi_client/simulation.py +33 -0
- wokwi_client-0.0.1/src/wokwi_client/transport.py +152 -0
- wokwi_client-0.0.1/tests/__init__.py +3 -0
- wokwi_client-0.0.1/tests/test_cli.py +20 -0
- wokwi_client-0.0.1/tests/test_hello_esp32.py +26 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# EditorConfig helps maintain consistent coding styles between editors & IDEs
|
|
2
|
+
# https://editorconfig.org
|
|
3
|
+
root = true
|
|
4
|
+
|
|
5
|
+
[*]
|
|
6
|
+
charset = utf-8
|
|
7
|
+
end_of_line = lf
|
|
8
|
+
indent_style = space
|
|
9
|
+
indent_size = 4
|
|
10
|
+
insert_final_newline = true
|
|
11
|
+
trim_trailing_whitespace = true
|
|
12
|
+
|
|
13
|
+
# Keep README & docs within the same column width as code
|
|
14
|
+
[*.{py,toml,yaml,yml,md}]
|
|
15
|
+
max_line_length = 100
|
|
16
|
+
|
|
17
|
+
# Make makes you use tabs
|
|
18
|
+
[Makefile]
|
|
19
|
+
indent_style = tab
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
py: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
runs-on: ubuntu-24.04
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.py }}
|
|
20
|
+
- run: pip install hatch
|
|
21
|
+
- run: hatch run ruff format --check .
|
|
22
|
+
- run: hatch run ruff check .
|
|
23
|
+
- run: hatch run mypy .
|
|
24
|
+
- name: Run a Wokwi CI server
|
|
25
|
+
uses: wokwi/wokwi-ci-server-action@v1
|
|
26
|
+
- run: hatch run dev:pytest
|
|
27
|
+
env:
|
|
28
|
+
WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- "docs/**"
|
|
8
|
+
- "mkdocs.yml"
|
|
9
|
+
- "src/**"
|
|
10
|
+
- ".github/workflows/docs.yml"
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
pages: write
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build-deploy:
|
|
18
|
+
runs-on: ubuntu-24.04
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.12"
|
|
25
|
+
|
|
26
|
+
- run: pip install hatch
|
|
27
|
+
|
|
28
|
+
- name: Build & version docs
|
|
29
|
+
run: hatch run mkdocs build --strict
|
|
30
|
+
|
|
31
|
+
- uses: actions/upload-pages-artifact@v3
|
|
32
|
+
with:
|
|
33
|
+
path: site
|
|
34
|
+
|
|
35
|
+
- uses: actions/deploy-pages@v4
|
|
36
|
+
with:
|
|
37
|
+
artifact_name: github-pages
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write # OIDC token for trusted publishing
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build-and-publish:
|
|
14
|
+
runs-on: ubuntu-24.04
|
|
15
|
+
environment: pypi
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Build wheel & sdist
|
|
24
|
+
run: |
|
|
25
|
+
pip install --quiet hatch
|
|
26
|
+
hatch build
|
|
27
|
+
|
|
28
|
+
- name: Publish to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
30
|
+
with:
|
|
31
|
+
verbose: true # shows twine output
|
|
32
|
+
|
|
33
|
+
- uses: softprops/action-gh-release@v2
|
|
34
|
+
with:
|
|
35
|
+
generate_release_notes: true
|
|
36
|
+
files: |
|
|
37
|
+
dist/*.whl
|
|
38
|
+
dist/*.tar.gz
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Python artifacts
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.so
|
|
5
|
+
*.pyd
|
|
6
|
+
|
|
7
|
+
# Packaging / build
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
|
|
13
|
+
# Hatch & virtual-envs
|
|
14
|
+
.hatch/ # local env metadata/cache
|
|
15
|
+
.env/
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
env/
|
|
19
|
+
env.bak/
|
|
20
|
+
*.virtualenv
|
|
21
|
+
|
|
22
|
+
# Test & coverage outputs
|
|
23
|
+
.coverage*
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.nox/
|
|
27
|
+
pytest_cache/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
*.log
|
|
31
|
+
|
|
32
|
+
# Lint / formatter caches
|
|
33
|
+
.ruff_cache/
|
|
34
|
+
.black/
|
|
35
|
+
.pyre/
|
|
36
|
+
|
|
37
|
+
# Documentation build (mkdocs)
|
|
38
|
+
site/
|
|
39
|
+
*.html
|
|
40
|
+
|
|
41
|
+
# IDE/editor settings
|
|
42
|
+
.vscode/
|
|
43
|
+
.idea/
|
|
44
|
+
*.sublime-project
|
|
45
|
+
*.sublime-workspace
|
|
46
|
+
*.code-workspace
|
|
47
|
+
|
|
48
|
+
# OS miscellany
|
|
49
|
+
.DS_Store
|
|
50
|
+
Thumbs.db
|
|
51
|
+
desktop.ini
|
|
52
|
+
|
|
53
|
+
# Logs & temp files
|
|
54
|
+
*.log
|
|
55
|
+
*.tmp
|
|
56
|
+
*.swp
|
|
57
|
+
*.swo
|
|
58
|
+
*~
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# .pre-commit-config.yaml – patched
|
|
2
|
+
repos:
|
|
3
|
+
# Ruff: formatter + linter
|
|
4
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
5
|
+
rev: v0.12.0
|
|
6
|
+
hooks:
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
- id: ruff
|
|
9
|
+
args: [--fix]
|
|
10
|
+
|
|
11
|
+
# mypy (strict)
|
|
12
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
13
|
+
rev: v1.16.1
|
|
14
|
+
hooks:
|
|
15
|
+
- id: mypy
|
|
16
|
+
additional_dependencies:
|
|
17
|
+
[pydantic==2.8.0, typing-extensions, types-click, types-requests]
|
|
18
|
+
|
|
19
|
+
# Blacken-docs
|
|
20
|
+
- repo: https://github.com/asottile/blacken-docs
|
|
21
|
+
rev: "1.19.1"
|
|
22
|
+
hooks:
|
|
23
|
+
- id: blacken-docs
|
|
24
|
+
|
|
25
|
+
# Basic hygiene hooks (official, maintained)
|
|
26
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
27
|
+
rev: v5.0.0
|
|
28
|
+
hooks:
|
|
29
|
+
- id: end-of-file-fixer
|
|
30
|
+
- id: trailing-whitespace
|
|
31
|
+
|
|
32
|
+
default_language_version:
|
|
33
|
+
python: python3.12
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# https://docs.astral.sh/ruff/configuration/
|
|
2
|
+
target-version = "py39"
|
|
3
|
+
line-length = 100
|
|
4
|
+
src = ["src", "tests"]
|
|
5
|
+
|
|
6
|
+
# Enable rule bundles
|
|
7
|
+
lint.select = [
|
|
8
|
+
"E", # pycodestyle errors
|
|
9
|
+
"F", # pyflakes
|
|
10
|
+
"I", # isort
|
|
11
|
+
"UP", # pyupgrade
|
|
12
|
+
"PL", # pylint-worthwhile subset
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
lint.ignore = [
|
|
16
|
+
"E501", # handled by formatter
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# Per-folder overrides --------------------------------------------------------
|
|
20
|
+
[lint.per-file-ignores]
|
|
21
|
+
"tests/**/*.py" = ["PLR2004"] # magic-numbers okay in tests
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Wokwi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wokwi-client
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Typed, asyncio-friendly Python SDK for the Wokwi Simulation API
|
|
5
|
+
Project-URL: Documentation, https://github.com/wokwi/wokwi-python-client#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/wokwi/wokwi-python-client/issues
|
|
7
|
+
Project-URL: Source, https://github.com/wokwi/wokwi-python-client
|
|
8
|
+
Author-email: Uri Shaked <uri@wokwi.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,arduino,asyncio,avr,esp32,python,rp2040,simulation,wokwi
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
16
|
+
Classifier: Topic :: System :: Emulators
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: pydantic>=2.8
|
|
20
|
+
Requires-Dist: websockets<13,>=12
|
|
21
|
+
Provides-Extra: cli
|
|
22
|
+
Requires-Dist: click<9,>=8.1; extra == 'cli'
|
|
23
|
+
Requires-Dist: typer[all]>=0.12; extra == 'cli'
|
|
24
|
+
Requires-Dist: types-click; extra == 'cli'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Wokwi Python Client 🚀
|
|
28
|
+
|
|
29
|
+
Typed, asyncio-friendly Python SDK for the **Wokwi Simulation API**
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/wokwi-client/)
|
|
32
|
+
[](https://pypi.org/project/wokwi-client/)
|
|
33
|
+
[](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
> **TL;DR:** Run and control your Wokwi simulations from Python with first-class type hints and zero boilerplate.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation requirements
|
|
41
|
+
|
|
42
|
+
Python ≥ 3.9
|
|
43
|
+
|
|
44
|
+
An API token from [https://wokwi.com/dashboard/ci](https://wokwi.com/dashboard/ci).
|
|
45
|
+
|
|
46
|
+
## Running the examples
|
|
47
|
+
|
|
48
|
+
The basic example is in the [examples/hello_esp32/main.py](examples/hello_esp32/main.py) file. It shows how to:
|
|
49
|
+
|
|
50
|
+
- Connect to the Wokwi Simulator
|
|
51
|
+
- Upload a diagram and firmware files
|
|
52
|
+
- Start a simulation
|
|
53
|
+
- Monitor the serial output
|
|
54
|
+
|
|
55
|
+
You can run the example with:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e .[dev]
|
|
59
|
+
python -m examples.hello_esp32.main
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Wokwi Python Client 🚀
|
|
2
|
+
|
|
3
|
+
Typed, asyncio-friendly Python SDK for the **Wokwi Simulation API**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/wokwi-client/)
|
|
6
|
+
[](https://pypi.org/project/wokwi-client/)
|
|
7
|
+
[](https://github.com/wokwi/wokwi-python-client/actions/workflows/ci.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
> **TL;DR:** Run and control your Wokwi simulations from Python with first-class type hints and zero boilerplate.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installation requirements
|
|
15
|
+
|
|
16
|
+
Python ≥ 3.9
|
|
17
|
+
|
|
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
|
+
```
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
site_name: Wokwi Python Client Library
|
|
2
|
+
site_description: Typed, asyncio-friendly Python SDK for the Wokwi Simulation API
|
|
3
|
+
site_url: https://wokwi.github.io/wokwi-python-client/
|
|
4
|
+
|
|
5
|
+
repo_url: https://github.com/wokwi/wokwi-python-client
|
|
6
|
+
repo_name: wokwi-python-client
|
|
7
|
+
edit_uri: "https://github.com/wokwi/wokwi-python-client/edit/main/docs"
|
|
8
|
+
|
|
9
|
+
# ── Theme ───────────────────────────────────────────────────────────────
|
|
10
|
+
theme:
|
|
11
|
+
name: material
|
|
12
|
+
language: en
|
|
13
|
+
features:
|
|
14
|
+
- navigation.tabs
|
|
15
|
+
- navigation.instant
|
|
16
|
+
- content.code.copy
|
|
17
|
+
- content.action.edit
|
|
18
|
+
|
|
19
|
+
# ── Docs structure ------------------------------------------------------
|
|
20
|
+
nav:
|
|
21
|
+
- Home: index.md
|
|
22
|
+
- Reference:
|
|
23
|
+
- API: reference/wokwi_client.md
|
|
24
|
+
|
|
25
|
+
# ── Plugins -------------------------------------------------------------
|
|
26
|
+
plugins:
|
|
27
|
+
- search
|
|
28
|
+
- mkdocstrings:
|
|
29
|
+
handlers:
|
|
30
|
+
python:
|
|
31
|
+
paths: ["src"]
|
|
32
|
+
options:
|
|
33
|
+
show_signature_annotations: true
|
|
34
|
+
merge_init_into_class: true
|
|
35
|
+
docstring_style: google
|
|
36
|
+
- autorefs # automatic cross-page links
|
|
37
|
+
|
|
38
|
+
# ── Markdown extensions -------------------------------------------------
|
|
39
|
+
markdown_extensions:
|
|
40
|
+
- admonition
|
|
41
|
+
- pymdownx.highlight
|
|
42
|
+
- pymdownx.inlinehilite
|
|
43
|
+
- pymdownx.superfences
|
|
44
|
+
- pymdownx.tabbed
|
|
45
|
+
- toc:
|
|
46
|
+
permalink: "¶"
|
|
47
|
+
|
|
48
|
+
# ── Extra build settings ------------------------------------------------
|
|
49
|
+
strict: true # fail the build on broken links
|
|
50
|
+
watch:
|
|
51
|
+
- src # live-reload when source code changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[mypy]
|
|
2
|
+
python_version = 3.9
|
|
3
|
+
plugins = pydantic.mypy
|
|
4
|
+
|
|
5
|
+
strict = True
|
|
6
|
+
show_error_codes = True
|
|
7
|
+
pretty = True
|
|
8
|
+
warn_unused_ignores = True
|
|
9
|
+
warn_return_any = True
|
|
10
|
+
warn_unreachable = True
|
|
11
|
+
|
|
12
|
+
# Silence import-time noise from non-typed deps
|
|
13
|
+
[mypy-typer.*]
|
|
14
|
+
ignore_missing_imports = True
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25", "hatch-vcs>=0.4"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wokwi-client"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Typed, asyncio-friendly Python SDK for the Wokwi Simulation API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Uri Shaked", email = "uri@wokwi.com" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = [
|
|
13
|
+
"wokwi",
|
|
14
|
+
"simulation",
|
|
15
|
+
"api",
|
|
16
|
+
"python",
|
|
17
|
+
"asyncio",
|
|
18
|
+
"esp32",
|
|
19
|
+
"rp2040",
|
|
20
|
+
"avr",
|
|
21
|
+
"arduino",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.9"
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python",
|
|
28
|
+
"Topic :: Software Development :: Embedded Systems",
|
|
29
|
+
"Topic :: System :: Emulators",
|
|
30
|
+
"Typing :: Typed",
|
|
31
|
+
]
|
|
32
|
+
dependencies = ["pydantic>=2.8", "websockets>=12,<13"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Documentation = "https://github.com/wokwi/wokwi-python-client#readme"
|
|
36
|
+
Issues = "https://github.com/wokwi/wokwi-python-client/issues"
|
|
37
|
+
Source = "https://github.com/wokwi/wokwi-python-client"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
cli = ["typer[all]>=0.12", "click>=8.1,<9", "types-click"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
wokwi-client = "wokwi_client.cli:wokwi_client"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.version]
|
|
47
|
+
source = "vcs"
|
|
48
|
+
tag-pattern = "v(?P<version>.*)"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
exclude = ["/docs", "/examples"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
addopts = "--strict-markers --cov=wokwi_client --cov-report=term-missing"
|
|
55
|
+
|
|
56
|
+
# Ruff (acts as both linter and formatter)
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
target-version = "py39"
|
|
59
|
+
line-length = 100
|
|
60
|
+
lint.select = ["E", "F", "I", "UP", "PL"]
|
|
61
|
+
|
|
62
|
+
[tool.hatch.envs.types]
|
|
63
|
+
extra-dependencies = ["mypy>=1.0.0"]
|
|
64
|
+
[tool.hatch.envs.types.scripts]
|
|
65
|
+
check = "mypy --install-types --non-interactive {args:src/wokwi_client tests}"
|
|
66
|
+
|
|
67
|
+
[tool.coverage.run]
|
|
68
|
+
source_pkgs = ["wokwi_client", "tests"]
|
|
69
|
+
branch = true
|
|
70
|
+
parallel = true
|
|
71
|
+
omit = ["src/wokwi_client/__about__.py"]
|
|
72
|
+
|
|
73
|
+
[tool.coverage.paths]
|
|
74
|
+
wokwi_client = ["src/wokwi_client", "*/wokwi-client/src/wokwi_client"]
|
|
75
|
+
tests = ["tests", "*/wokwi-client/tests"]
|
|
76
|
+
|
|
77
|
+
[tool.coverage.report]
|
|
78
|
+
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
strict = true
|
|
82
|
+
plugins = ["pydantic.mypy"]
|
|
83
|
+
|
|
84
|
+
[tool.hatch.envs.default]
|
|
85
|
+
dependencies = [
|
|
86
|
+
"ruff>=0.12.0",
|
|
87
|
+
"mypy>=1.16.1",
|
|
88
|
+
"pymdown-extensions",
|
|
89
|
+
"mkdocs-material[extensions]",
|
|
90
|
+
"mkdocstrings[python]",
|
|
91
|
+
"types-requests",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.hatch.envs.dev]
|
|
95
|
+
template = "default"
|
|
96
|
+
features = ["cli"]
|
|
97
|
+
extra-dependencies = ["pytest", "pytest-cov"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wokwi Python Client Library
|
|
3
|
+
|
|
4
|
+
Typed, asyncio-friendly Python SDK for the Wokwi Simulation API.
|
|
5
|
+
|
|
6
|
+
Provides the WokwiClient class for connecting to, controlling, and monitoring Wokwi simulations from Python.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
10
|
+
#
|
|
11
|
+
# SPDX-License-Identifier: MIT
|
|
12
|
+
|
|
13
|
+
from .__version__ import get_version
|
|
14
|
+
from .client import WokwiClient
|
|
15
|
+
from .constants import GET_TOKEN_URL
|
|
16
|
+
|
|
17
|
+
__version__ = get_version()
|
|
18
|
+
__all__ = ["WokwiClient", "__version__", "GET_TOKEN_URL"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try: # works for normal + editable installs
|
|
8
|
+
__version__: str = version(__name__.replace("_", "-"))
|
|
9
|
+
except PackageNotFoundError: # running from a raw source tree
|
|
10
|
+
try:
|
|
11
|
+
# if you use hatch-vcs, this tiny module is auto-generated at build time
|
|
12
|
+
from ._version import version as __version__ # type: ignore
|
|
13
|
+
except ModuleNotFoundError:
|
|
14
|
+
__version__ = "0.0.0+local"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_version() -> str:
|
|
18
|
+
return __version__
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from wokwi_client.__version__ import get_version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(
|
|
10
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
11
|
+
invoke_without_command=True,
|
|
12
|
+
)
|
|
13
|
+
@click.version_option(version=get_version(), prog_name="wokwi-client")
|
|
14
|
+
def wokwi_client() -> None:
|
|
15
|
+
click.echo("Hello Virtual World!")
|
|
@@ -0,0 +1,182 @@
|
|
|
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 .event_queue import EventQueue
|
|
11
|
+
from .file_ops import upload, upload_file
|
|
12
|
+
from .protocol_types import EventMessage, ResponseMessage
|
|
13
|
+
from .serial import monitor_lines
|
|
14
|
+
from .simulation import pause, restart, resume, start
|
|
15
|
+
from .transport import Transport
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WokwiClient:
|
|
19
|
+
"""
|
|
20
|
+
Asynchronous client for the Wokwi Simulation API.
|
|
21
|
+
|
|
22
|
+
This class provides methods to connect to the Wokwi simulator, upload files, control simulations,
|
|
23
|
+
and monitor serial output. It is designed to be asyncio-friendly and easy to use in Python scripts
|
|
24
|
+
and applications.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
version: str
|
|
28
|
+
last_pause_nanos: int
|
|
29
|
+
|
|
30
|
+
def __init__(self, token: str, server: Optional[str] = None):
|
|
31
|
+
"""
|
|
32
|
+
Initialize the WokwiClient.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
token: API token for authentication (get from https://wokwi.com/dashboard/ci).
|
|
36
|
+
server: Optional custom server URL. Defaults to the public Wokwi server.
|
|
37
|
+
"""
|
|
38
|
+
self.version = get_version()
|
|
39
|
+
self._transport = Transport(token, server or DEFAULT_WS_URL)
|
|
40
|
+
self.last_pause_nanos = 0
|
|
41
|
+
self._transport.add_event_listener("sim:pause", self._on_pause)
|
|
42
|
+
self._pause_queue = EventQueue(self._transport, "sim:pause")
|
|
43
|
+
|
|
44
|
+
async def connect(self) -> dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Connect to the Wokwi simulator server.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A dictionary with server information (e.g., version).
|
|
50
|
+
"""
|
|
51
|
+
return await self._transport.connect()
|
|
52
|
+
|
|
53
|
+
async def disconnect(self) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Disconnect from the Wokwi simulator server.
|
|
56
|
+
"""
|
|
57
|
+
await self._transport.close()
|
|
58
|
+
|
|
59
|
+
async def upload(self, name: str, content: bytes) -> ResponseMessage:
|
|
60
|
+
"""
|
|
61
|
+
Upload a file to the simulator from bytes content.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
name: The name to use for the uploaded file.
|
|
65
|
+
content: The file content as bytes.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The response message from the server.
|
|
69
|
+
"""
|
|
70
|
+
return await upload(self._transport, name, content)
|
|
71
|
+
|
|
72
|
+
async def upload_file(
|
|
73
|
+
self, filename: str, local_path: Optional[Path] = None
|
|
74
|
+
) -> ResponseMessage:
|
|
75
|
+
"""
|
|
76
|
+
Upload a local file to the simulator.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
filename: The name to use for the uploaded file.
|
|
80
|
+
local_path: Optional path to the local file. If not provided, uses filename as the path.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The response message from the server.
|
|
84
|
+
"""
|
|
85
|
+
return await upload_file(self._transport, filename, local_path)
|
|
86
|
+
|
|
87
|
+
async def start_simulation(
|
|
88
|
+
self,
|
|
89
|
+
firmware: str,
|
|
90
|
+
elf: str,
|
|
91
|
+
pause: bool = False,
|
|
92
|
+
chips: list[str] = [],
|
|
93
|
+
) -> ResponseMessage:
|
|
94
|
+
"""
|
|
95
|
+
Start a new simulation with the given parameters.
|
|
96
|
+
|
|
97
|
+
The firmware and ELF files must be uploaded to the simulator first using the
|
|
98
|
+
`upload()` or `upload_file()` methods. The firmware and ELF files are required
|
|
99
|
+
for the simulation to run.
|
|
100
|
+
|
|
101
|
+
The optional `chips` parameter can be used to load custom chips into the simulation.
|
|
102
|
+
For each custom chip, you need to upload two files:
|
|
103
|
+
- A JSON file with the chip definition, called `<chip_name>.chip.json`.
|
|
104
|
+
- A binary file with the chip firmware, called `<chip_name>.chip.bin`.
|
|
105
|
+
|
|
106
|
+
For example, to load the `inverter` chip, you need to upload the `inverter.chip.json`
|
|
107
|
+
and `inverter.chip.bin` files. Then you can pass `["inverter"]` to the `chips` parameter,
|
|
108
|
+
and reference it in your diagram.json file by adding a part with the type `chip-inverter`.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
firmware: The firmware binary filename.
|
|
112
|
+
elf: The ELF file filename.
|
|
113
|
+
pause: Whether to start the simulation paused (default: False).
|
|
114
|
+
chips: List of custom chips to load into the simulation (default: empty list).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The response message from the server.
|
|
118
|
+
"""
|
|
119
|
+
return await start(
|
|
120
|
+
self._transport,
|
|
121
|
+
firmware=firmware,
|
|
122
|
+
elf=elf,
|
|
123
|
+
pause=pause,
|
|
124
|
+
chips=chips,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def pause_simulation(self) -> ResponseMessage:
|
|
128
|
+
"""
|
|
129
|
+
Pause the running simulation.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The response message from the server.
|
|
133
|
+
"""
|
|
134
|
+
return await pause(self._transport)
|
|
135
|
+
|
|
136
|
+
async def resume_simulation(self, pause_after: Optional[int] = None) -> ResponseMessage:
|
|
137
|
+
"""
|
|
138
|
+
Resume the simulation, optionally pausing after a given number of nanoseconds.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
pause_after: Number of nanoseconds to run before pausing again (optional).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The response message from the server.
|
|
145
|
+
"""
|
|
146
|
+
return await resume(self._transport, pause_after)
|
|
147
|
+
|
|
148
|
+
async def wait_until_simulation_time(self, seconds: float) -> None:
|
|
149
|
+
"""
|
|
150
|
+
Pause and resume the simulation until the given simulation time (in seconds) is reached.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
seconds: The simulation time to wait for, in seconds.
|
|
154
|
+
"""
|
|
155
|
+
await pause(self._transport)
|
|
156
|
+
remaining_nanos = seconds * 1e9 - self.last_pause_nanos
|
|
157
|
+
if remaining_nanos > 0:
|
|
158
|
+
self._pause_queue.flush()
|
|
159
|
+
await resume(self._transport, int(remaining_nanos))
|
|
160
|
+
await self._pause_queue.get()
|
|
161
|
+
|
|
162
|
+
async def restart_simulation(self, pause: bool = False) -> ResponseMessage:
|
|
163
|
+
"""
|
|
164
|
+
Restart the simulation, optionally starting paused.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
pause: Whether to start the simulation paused (default: False).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The response message from the server.
|
|
171
|
+
"""
|
|
172
|
+
return await restart(self._transport, pause)
|
|
173
|
+
|
|
174
|
+
async def serial_monitor_cat(self) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Print serial monitor output to stdout as it is received from the simulation.
|
|
177
|
+
"""
|
|
178
|
+
async for line in monitor_lines(self._transport):
|
|
179
|
+
print(line.decode("utf-8"), end="", flush=True)
|
|
180
|
+
|
|
181
|
+
def _on_pause(self, event: EventMessage) -> None:
|
|
182
|
+
self.last_pause_nanos = int(event["nanos"])
|
|
@@ -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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Queue-based event subscription helper for Transport.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
with EventQueue(transport, "serial-monitor:data") as queue:
|
|
10
|
+
# Use the queue to get events, calling get() or get_nowait()
|
|
11
|
+
event = await queue.get()
|
|
12
|
+
# do something with the event
|
|
13
|
+
...
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
from .protocol_types import EventMessage
|
|
19
|
+
from .transport import Transport
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EventQueue:
|
|
23
|
+
"""A queue for events from a specific event type."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, transport: Transport, event_type: str) -> None:
|
|
26
|
+
self._queue: asyncio.Queue[EventMessage] = asyncio.Queue()
|
|
27
|
+
self._transport = transport
|
|
28
|
+
self._event_type = event_type
|
|
29
|
+
|
|
30
|
+
def listener(event: EventMessage) -> None:
|
|
31
|
+
self._queue.put_nowait(event)
|
|
32
|
+
|
|
33
|
+
self._listener = listener
|
|
34
|
+
self._transport.add_event_listener(self._event_type, self._listener)
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
"""Close the queue. This is useful when you want to stop listening for events."""
|
|
38
|
+
self._transport.remove_event_listener(self._event_type, self._listener)
|
|
39
|
+
|
|
40
|
+
async def get(self) -> EventMessage:
|
|
41
|
+
"""Get an event from the queue. Blocks until an event is available."""
|
|
42
|
+
return await self._queue.get()
|
|
43
|
+
|
|
44
|
+
def get_nowait(self) -> EventMessage:
|
|
45
|
+
"""Get an event from the queue. Raises QueueEmpty if no event is available."""
|
|
46
|
+
return self._queue.get_nowait()
|
|
47
|
+
|
|
48
|
+
def flush(self) -> None:
|
|
49
|
+
"""Flush the queue. This is useful when you want to wait for all events to be processed."""
|
|
50
|
+
while not self._queue.empty():
|
|
51
|
+
self._queue.get_nowait()
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> "EventQueue":
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, exc_type: type, exc_value: Exception, traceback: object) -> None:
|
|
57
|
+
self.close()
|
|
@@ -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())
|
|
@@ -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)
|
|
@@ -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]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
|
|
7
|
+
from .event_queue import EventQueue
|
|
8
|
+
from .transport import Transport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def monitor_lines(transport: Transport) -> AsyncGenerator[bytes, None]:
|
|
12
|
+
await transport.request("serial-monitor:listen", {})
|
|
13
|
+
with EventQueue(transport, "serial-monitor:data") as queue:
|
|
14
|
+
while True:
|
|
15
|
+
event_msg = await queue.get()
|
|
16
|
+
yield bytes(event_msg["payload"]["bytes"])
|
|
@@ -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,0 +1,152 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Any, Callable, Optional, cast
|
|
10
|
+
|
|
11
|
+
import websockets
|
|
12
|
+
|
|
13
|
+
from .__version__ import get_version
|
|
14
|
+
from .constants import (
|
|
15
|
+
DEFAULT_WS_URL,
|
|
16
|
+
MSG_TYPE_EVENT,
|
|
17
|
+
MSG_TYPE_HELLO,
|
|
18
|
+
MSG_TYPE_RESPONSE,
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
)
|
|
21
|
+
from .exceptions import ProtocolError, ServerError, WokwiError
|
|
22
|
+
from .protocol_types import EventMessage, HelloMessage, IncomingMessage, ResponseMessage
|
|
23
|
+
|
|
24
|
+
TRANSPORT_DEFAULT_WS_URL = os.getenv("WOKWI_CLI_SERVER", DEFAULT_WS_URL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Transport:
|
|
28
|
+
def __init__(self, token: str, url: str = TRANSPORT_DEFAULT_WS_URL):
|
|
29
|
+
self._token = token
|
|
30
|
+
self._url = url
|
|
31
|
+
self._next_id = 1
|
|
32
|
+
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
|
33
|
+
self._event_listeners: dict[str, list[Callable[[EventMessage], Any]]] = {}
|
|
34
|
+
self._response_futures: dict[str, asyncio.Future[ResponseMessage]] = {}
|
|
35
|
+
self._recv_task: Optional[asyncio.Task[None]] = None
|
|
36
|
+
self._closed = False
|
|
37
|
+
|
|
38
|
+
async def connect(self) -> dict[str, Any]:
|
|
39
|
+
self._ws = await websockets.connect(
|
|
40
|
+
self._url,
|
|
41
|
+
extra_headers={
|
|
42
|
+
"Authorization": f"Bearer {self._token}",
|
|
43
|
+
"User-Agent": f"wokwi-client-py/{get_version()}",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
hello: IncomingMessage = await self._recv()
|
|
47
|
+
if hello["type"] != MSG_TYPE_HELLO or hello.get("protocolVersion") != PROTOCOL_VERSION:
|
|
48
|
+
raise ProtocolError(f"Unsupported protocol handshake: {hello}")
|
|
49
|
+
hello_msg = cast(HelloMessage, hello)
|
|
50
|
+
self._closed = False
|
|
51
|
+
# Start background message processor
|
|
52
|
+
self._recv_task = asyncio.create_task(self._background_recv())
|
|
53
|
+
return {"version": hello_msg["appVersion"]}
|
|
54
|
+
|
|
55
|
+
async def close(self) -> None:
|
|
56
|
+
self._closed = True
|
|
57
|
+
if self._recv_task:
|
|
58
|
+
self._recv_task.cancel()
|
|
59
|
+
try:
|
|
60
|
+
await self._recv_task
|
|
61
|
+
except asyncio.CancelledError:
|
|
62
|
+
pass
|
|
63
|
+
if self._ws:
|
|
64
|
+
await self._ws.close()
|
|
65
|
+
|
|
66
|
+
def add_event_listener(self, event_type: str, listener: Callable[[EventMessage], Any]) -> None:
|
|
67
|
+
"""Register a listener for a specific event type."""
|
|
68
|
+
if event_type not in self._event_listeners:
|
|
69
|
+
self._event_listeners[event_type] = []
|
|
70
|
+
self._event_listeners[event_type].append(listener)
|
|
71
|
+
|
|
72
|
+
def remove_event_listener(
|
|
73
|
+
self, event_type: str, listener: Callable[[EventMessage], Any]
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Remove a previously registered listener for a specific event type."""
|
|
76
|
+
if event_type in self._event_listeners:
|
|
77
|
+
self._event_listeners[event_type] = [
|
|
78
|
+
registered_listener
|
|
79
|
+
for registered_listener in self._event_listeners[event_type]
|
|
80
|
+
if registered_listener != listener
|
|
81
|
+
]
|
|
82
|
+
if not self._event_listeners[event_type]:
|
|
83
|
+
del self._event_listeners[event_type]
|
|
84
|
+
|
|
85
|
+
async def _dispatch_event(self, event_msg: EventMessage) -> None:
|
|
86
|
+
listeners = self._event_listeners.get(event_msg["event"], [])
|
|
87
|
+
for listener in listeners:
|
|
88
|
+
result = listener(event_msg)
|
|
89
|
+
if hasattr(result, "__await__"):
|
|
90
|
+
await result
|
|
91
|
+
|
|
92
|
+
async def request(self, command: str, params: dict[str, Any]) -> ResponseMessage:
|
|
93
|
+
msg_id = str(self._next_id)
|
|
94
|
+
self._next_id += 1
|
|
95
|
+
if self._ws is None:
|
|
96
|
+
raise WokwiError("Not connected")
|
|
97
|
+
loop = asyncio.get_running_loop()
|
|
98
|
+
future: asyncio.Future[ResponseMessage] = loop.create_future()
|
|
99
|
+
self._response_futures[msg_id] = future
|
|
100
|
+
await self._ws.send(
|
|
101
|
+
json.dumps({"type": "command", "command": command, "params": params, "id": msg_id})
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
resp_msg_resp = await future
|
|
105
|
+
if resp_msg_resp.get("error"):
|
|
106
|
+
result = resp_msg_resp["result"]
|
|
107
|
+
raise ServerError(result["message"])
|
|
108
|
+
return resp_msg_resp
|
|
109
|
+
finally:
|
|
110
|
+
del self._response_futures[msg_id]
|
|
111
|
+
|
|
112
|
+
async def _background_recv(self) -> None:
|
|
113
|
+
try:
|
|
114
|
+
while not self._closed and self._ws is not None:
|
|
115
|
+
msg: IncomingMessage = await self._recv()
|
|
116
|
+
if msg["type"] == MSG_TYPE_EVENT:
|
|
117
|
+
resp_msg_event = cast(EventMessage, msg)
|
|
118
|
+
await self._dispatch_event(resp_msg_event)
|
|
119
|
+
elif msg["type"] == MSG_TYPE_RESPONSE:
|
|
120
|
+
resp_msg_resp = cast(ResponseMessage, msg)
|
|
121
|
+
future = self._response_futures.get(resp_msg_resp["id"])
|
|
122
|
+
if future is None or future.done():
|
|
123
|
+
continue
|
|
124
|
+
future.set_result(resp_msg_resp)
|
|
125
|
+
except (websockets.ConnectionClosed, asyncio.CancelledError):
|
|
126
|
+
pass
|
|
127
|
+
except Exception as e:
|
|
128
|
+
warnings.warn(f"Background recv error: {e}", RuntimeWarning)
|
|
129
|
+
|
|
130
|
+
async def _recv(self) -> IncomingMessage:
|
|
131
|
+
if self._ws is None:
|
|
132
|
+
raise WokwiError("Not connected")
|
|
133
|
+
raw_message = await self._ws.recv()
|
|
134
|
+
while isinstance(raw_message, bytes):
|
|
135
|
+
warnings.warn("Unexpected binary message received and skipped", RuntimeWarning)
|
|
136
|
+
raw_message = await self._ws.recv()
|
|
137
|
+
try:
|
|
138
|
+
message = json.loads(raw_message)
|
|
139
|
+
except json.JSONDecodeError as e:
|
|
140
|
+
raise WokwiError(f"Failed to parse message: {raw_message}") from e
|
|
141
|
+
if "type" not in message:
|
|
142
|
+
raise WokwiError(f"Invalid message: {message}")
|
|
143
|
+
if message["type"] == "error":
|
|
144
|
+
raise WokwiError(f"Server error: {message['message']}")
|
|
145
|
+
if message["type"] == "response" and message.get("error"):
|
|
146
|
+
result = (
|
|
147
|
+
message["result"]
|
|
148
|
+
if "result" in message
|
|
149
|
+
else {"code": -1, "message": "Unknown error"}
|
|
150
|
+
)
|
|
151
|
+
raise WokwiError(f"Server error {result['code']}: {result['message']}")
|
|
152
|
+
return cast(IncomingMessage, message)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from click.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from wokwi_client.__version__ import get_version
|
|
10
|
+
from wokwi_client.cli import wokwi_client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_version_option() -> None:
|
|
14
|
+
"""`wokwi --version` prints the same __version__ string and exits with 0."""
|
|
15
|
+
runner = CliRunner()
|
|
16
|
+
result = runner.invoke(wokwi_client, ["--version"])
|
|
17
|
+
|
|
18
|
+
assert result.exit_code == 0, result.output
|
|
19
|
+
pattern = rf"wokwi.*{re.escape(get_version())}"
|
|
20
|
+
assert re.search(pattern, result.output), result.output
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present CodeMagic LTD
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_hello_esp32_example() -> None:
|
|
11
|
+
"""`python -m examples.hello_esp32.main` runs the hello_esp32 example and exits with 0."""
|
|
12
|
+
|
|
13
|
+
assert os.environ.get("WOKWI_CLI_TOKEN") is not None, (
|
|
14
|
+
"WOKWI_CLI_TOKEN environment variable is not set. You can get it from https://wokwi.com/dashboard/ci."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
[sys.executable, "-m", "examples.hello_esp32.main"],
|
|
19
|
+
check=False,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
env={**os.environ, "WOKWI_SLEEP_TIME": "1"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert result.returncode == 0
|
|
26
|
+
assert "main_task: Calling app_main()" in result.stdout
|