simantic 0.3.1__tar.gz → 0.4.0__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.
Files changed (46) hide show
  1. {simantic-0.3.1 → simantic-0.4.0}/.github/workflows/ci.yml +5 -12
  2. simantic-0.4.0/PKG-INFO +283 -0
  3. {simantic-0.3.1 → simantic-0.4.0}/PUBLISHING.md +11 -14
  4. simantic-0.4.0/README.md +261 -0
  5. simantic-0.4.0/docs/session-api.md +177 -0
  6. {simantic-0.3.1 → simantic-0.4.0}/examples/parallel_sweep.py +2 -2
  7. {simantic-0.3.1 → simantic-0.4.0}/examples/step_and_peek.py +2 -2
  8. {simantic-0.3.1 → simantic-0.4.0}/pyproject.toml +3 -3
  9. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/__init__.py +6 -4
  10. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/_cli.py +70 -3
  11. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/_elf.py +7 -3
  12. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/_replx.py +28 -9
  13. simantic-0.4.0/src/simantic/_rust.py +342 -0
  14. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/engine.py +24 -0
  15. simantic-0.4.0/src/simantic/esp_image.py +406 -0
  16. simantic-0.4.0/src/simantic/fixtures.py +272 -0
  17. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/mcu.py +38 -17
  18. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/pytest_plugin.py +102 -35
  19. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/session.py +138 -38
  20. simantic-0.4.0/tests/test_esp_image.py +274 -0
  21. simantic-0.4.0/tests/test_fixtures.py +225 -0
  22. simantic-0.4.0/tests/test_model_auth_error.py +19 -0
  23. simantic-0.4.0/tests/test_rust_backend.py +409 -0
  24. simantic-0.4.0/tests/test_session.py +104 -0
  25. simantic-0.3.1/PKG-INFO +0 -165
  26. simantic-0.3.1/README.md +0 -143
  27. simantic-0.3.1/docs/session-api.md +0 -108
  28. simantic-0.3.1/src/simantic/_rust.py +0 -128
  29. simantic-0.3.1/src/simantic/fixtures.py +0 -144
  30. simantic-0.3.1/tests/test_fixtures.py +0 -141
  31. simantic-0.3.1/tests/test_rust_backend.py +0 -218
  32. simantic-0.3.1/tests/test_session.py +0 -56
  33. {simantic-0.3.1 → simantic-0.4.0}/.gitignore +0 -0
  34. {simantic-0.3.1 → simantic-0.4.0}/LICENSE +0 -0
  35. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/__main__.py +0 -0
  36. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/_locate.py +0 -0
  37. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/auth.py +0 -0
  38. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/install.py +0 -0
  39. {simantic-0.3.1 → simantic-0.4.0}/src/simantic/telemetry.py +0 -0
  40. {simantic-0.3.1 → simantic-0.4.0}/tests/conftest.py +0 -0
  41. {simantic-0.3.1 → simantic-0.4.0}/tests/test_auth.py +0 -0
  42. {simantic-0.3.1 → simantic-0.4.0}/tests/test_install.py +0 -0
  43. {simantic-0.3.1 → simantic-0.4.0}/tests/test_packaging.py +0 -0
  44. {simantic-0.3.1 → simantic-0.4.0}/tests/test_pytest_surface.py +0 -0
  45. {simantic-0.3.1 → simantic-0.4.0}/tests/test_spool.py +0 -0
  46. {simantic-0.3.1 → simantic-0.4.0}/tests/test_telemetry.py +0 -0
@@ -25,8 +25,6 @@ on:
25
25
  - '.claude/**'
26
26
  - 'LICENSE'
27
27
  pull_request:
28
- # `ready_for_review` is NOT a default activity type. Without it, flipping a
29
- # draft to ready fires no event, so the macOS leg gated below never runs.
30
28
  types: [opened, synchronize, reopened, ready_for_review]
31
29
  paths-ignore:
32
30
  - 'docs/**'
@@ -40,20 +38,15 @@ concurrency:
40
38
 
41
39
  jobs:
42
40
  test:
43
- runs-on: ${{ matrix.os }}
44
- # The SDK is pure Python, so a macOS runner proves very little here that
45
- # Linux does not — and it bills at $0.062/min against Linux's $0.006, with a
46
- # 10x draw on the included-minutes allowance. Two of the six legs were
47
- # macOS. It now joins on a push to main, on a v* tag (a release should be
48
- # proven on all three), and on a pull request once marked ready for review.
49
- # Windows is left unconditional: at 2x it is not what the bill is made of.
41
+ runs-on: ubuntu-latest
42
+ # macOS dropped from CI entirely (ci: drop macOS, single-OS fixtures,
43
+ # manual-only aux checks) — the SDK is pure Python, so a macOS runner
44
+ # proved very little here that Linux does not, at 10x Linux's billing
45
+ # rate. macOS is tested locally instead.
50
46
  timeout-minutes: 15
51
47
  strategy:
52
48
  fail-fast: false
53
49
  matrix:
54
- os: ${{ (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
55
- && fromJSON('["ubuntu-latest", "macos-latest"]')
56
- || fromJSON('["ubuntu-latest"]') }}
57
50
  # 3.11 is the floor (tomllib landed there); 3.13 guards the top end.
58
51
  python: ["3.11", "3.13"]
59
52
  steps:
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.5
2
+ Name: simantic
3
+ Version: 0.4.0
4
+ Summary: Python SDK and pytest plugin for the Simantic circuit and firmware simulators
5
+ Project-URL: Homepage, https://simantic.dev
6
+ Project-URL: Source, https://github.com/simantic-dev/pippy
7
+ Project-URL: Issues, https://github.com/simantic-dev/pippy/issues
8
+ Author: Simantic
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: circuit,eda,firmware,kicad,pytest,simulation,spice
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: pythonnet>=3.0.5
20
+ Requires-Dist: pyyaml>=6
21
+ Description-Content-Type: text/markdown
22
+
23
+ <p align="center">
24
+ <img src="https://simantic.dev/simantic_logo_4_full.png" alt="Simantic" width="340">
25
+ </p>
26
+
27
+ <h3 align="center">Test your firmware without a board.</h3>
28
+
29
+ <p align="center">
30
+ <a href="https://pypi.org/project/simantic/"><img src="https://img.shields.io/pypi/v/simantic.svg" alt="PyPI"></a>
31
+ <img src="https://img.shields.io/pypi/pyversions/simantic.svg" alt="Python versions">
32
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT licence">
33
+ </p>
34
+
35
+ Nothing to plug in, nothing to flash. Simantic boots your real ELF on a
36
+ simulated microcontroller and hands you the whole machine from Python. Watch it
37
+ print, press a button, read a variable straight out of RAM.
38
+
39
+ ```bash
40
+ pip install simantic
41
+ ```
42
+
43
+ ```python
44
+ from simantic import Sim
45
+
46
+ with Sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2") as sim:
47
+ sim.expect("ready")
48
+ sim.inject_gpio("gpioc", 13, True) # press the user button
49
+ sim.expect("button pressed")
50
+ assert sim.read_u32("press_count") == 1
51
+ ```
52
+
53
+ That is a whole test. No probe, no breakpoint, no waiting on hardware.
54
+
55
+ Three things you get that a bench cannot give you:
56
+
57
+ * **See inside.** Read any variable, register, or RTOS thread while the
58
+ firmware runs, without halting it.
59
+ * **Poke it.** Press buttons, send CAN frames, feed the radio, all from your
60
+ script.
61
+ * **Repeat exactly.** Time moves only when you ask, so a run comes out the same
62
+ every time, on your laptop and in CI.
63
+
64
+ The simulator lives inside your Python process, so there is no server to start
65
+ and no port to talk to.
66
+
67
+ > **Alpha, version 0.4.x.** We are still moving things around, so the API can
68
+ > change without a deprecation period. Pin an exact version
69
+ > (`simantic==0.4.0`) if you depend on it, and please hold off on production
70
+ > pipelines for now. Tell us what breaks.
71
+
72
+ ## Setup
73
+
74
+ `pip install` is the whole setup. The first `Sim(...)` downloads the engine it
75
+ needs into `~/.simantic/` and checks it against the published checksum. The
76
+ wheel on PyPI holds only Python code; the simulators are never inside it.
77
+
78
+ Sign in once if you want to name MCUs by part number:
79
+
80
+ ```bash
81
+ simantic auth
82
+ ```
83
+
84
+ That opens a browser tab, much like `gh auth login`, and saves a token to
85
+ `~/.sim_id`. In CI, pipe one in instead: `echo $TOKEN | simantic auth`.
86
+
87
+ Already have the `sim` binary? Put it on PATH or point `$SIMANTIC_SIM` at it.
88
+ `simantic status` shows what resolved.
89
+
90
+ ## Pick your engine
91
+
92
+ The same script runs on either engine. You choose per simulation:
93
+
94
+ ```python
95
+ Sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2") # Renode engine, the default
96
+ Sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2", backend="rust") # our Rust engine
97
+ ```
98
+
99
+ The Rust engine is a small extension module, runs one machine, and is
100
+ considerably faster. Anything it cannot do yet, such as multi machine scenarios
101
+ or CAN and radio injection, raises `simantic.NotSupported` and names the gap
102
+ instead of quietly doing nothing. What each engine covers is listed in
103
+ [the engine table](https://github.com/simantic-dev/pippy/blob/main/docs/session-api.md#what-each-engine-supports).
104
+
105
+ ## ESP32 images
106
+
107
+ The ESP32-C3, C6 and P4 boot the way silicon does: Espressif's mask ROM runs
108
+ first, then your bootloader, then your app. `sim --elf` therefore takes one ELF
109
+ that carries the ROM and your whole flash image. Build it from the files your
110
+ ESP-IDF or PlatformIO build already produced:
111
+
112
+ ```bash
113
+ simantic esp-image --chip esp32c3 \
114
+ --part 0x0:bootloader.bin --part 0x8000:partitions.bin --part 0x10000:firmware.bin \
115
+ --flash-size 16MB -o image.elf
116
+ sim --elf image.elf ...
117
+ ```
118
+
119
+ Already have a merged image from `esptool.py merge_bin`? Pass `--flash merged.bin`
120
+ instead of the parts. The same thing from Python is
121
+ `simantic.esp_image.build_image("esp32c3", flash, out="image.elf")`.
122
+
123
+ The mask ROM is Espressif's and is not bundled. On first use it is downloaded
124
+ from Espressif's own repositories, pinned by commit and SHA-256, and cached in
125
+ `~/.simantic/esp-rom`: the raw C3 and C6 dumps from
126
+ [espressif/qemu](https://github.com/espressif/qemu/tree/master/pc-bios), and
127
+ the P4 ROM from the [esp-rom-elfs](https://github.com/espressif/esp-rom-elfs)
128
+ release ESP-IDF installs. Offline, or with your own copy, pass `--rom`.
129
+ `simantic esp-rom --chip esp32c3` downloads it ahead of time and prints where it
130
+ went.
131
+
132
+ ## Testing with pytest
133
+
134
+ Take the `sim` fixture and write ordinary tests:
135
+
136
+ ```python
137
+ def test_timer_irq_fires(sim):
138
+ s = sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2")
139
+ s.expect("fired=1", timeout=8)
140
+ assert s.read_u32("fired") == 1
141
+ ```
142
+
143
+ The engine starts once per worker rather than once per test, and every machine
144
+ is closed for you. When a test fails, its UART transcript is attached to the
145
+ report, because that is usually the evidence you want.
146
+
147
+ ### Test what went over the wire
148
+
149
+ Firmware that prints "sensor OK" is reporting its own bookkeeping. The bus
150
+ tells you what happened. Put a scripted device on the other end of each bus
151
+ (a `parts` entry per device, each backed by a few dozen lines of Python), wire
152
+ the media in a scenario, and assert on `frames()`:
153
+
154
+ ```python
155
+ SCENARIO = {
156
+ "machines": {"dut": {
157
+ "mcu": "STM32H753IITX", "elf": "fc.elf",
158
+ "parts": [
159
+ {"name": "imu0", "type": "spi-device", "bus": "spi1", "cs": "PC4", "script": "icm42688.py"},
160
+ {"name": "baro", "type": "i2c-device", "bus": "i2c2", "address": 0x63, "script": "icp20100.py"},
161
+ {"name": "gpspeer", "type": "uart-device", "baud": 230400, "script": "gps.py"},
162
+ {"name": "canpeer", "type": "can-node", "script": "dronecan.py"},
163
+ ],
164
+ }},
165
+ "media": [
166
+ {"type": "uart", "connect": ["dut.usart6", "dut.gpspeer"]},
167
+ {"type": "can", "connect": ["dut.fdcan1", "dut.canpeer"]},
168
+ ],
169
+ }
170
+
171
+ def test_imu_configured_then_streams(sim):
172
+ s = sim(scenario=SCENARIO, machine="dut", uart="usart1")
173
+ s.run_for(3.0) # virtual seconds
174
+ imu = [f for f in s.frames() if f["label"] == "imu0"] # one dict per chip-select window
175
+ cfg = next(f for f in imu if f["data"][0] == 0x4F) # GYRO_CONFIG0 write
176
+ assert cfg["data"][1] & 0x0F == 0x06 # ODR the driver programmed
177
+ assert cfg["miso"][0] == 0x00 # what the slave answered
178
+ bursts = [f for f in imu if f["t"] > cfg["t"] and len(f["data"]) > 16]
179
+ assert len(bursts) > 100 # FIFO reads after configuration
180
+ ```
181
+
182
+ Every record carries its virtual-time stamp, so a rate is a subtraction between
183
+ consecutive frames and an ordering is a comparison. The scenario dict is the
184
+ same shape as `sim --scenario`'s YAML, and because the test owns it, the
185
+ negative control is the same test with a peer removed from `media`. Changing
186
+ what a device does (a NACK for 50 ms, a stale frame, a node that stops
187
+ answering at t = 5 s) is an edit to the peer script, on the virtual clock. The
188
+ `sim` guide covers every output and the peer API:
189
+ https://simantic.com/docs
190
+
191
+ `--sim-backend=renode|rust|both` chooses the engine. With `both`, each test runs
192
+ on each and the engine name appears in the test id. Anything an engine cannot do
193
+ is reported as a skip with the reason, so one suite can target both and stay
194
+ honest about what each covers.
195
+
196
+ `test.yaml` manifests follow the same option. On `rust` a manifest, including a
197
+ multi machine one with `media:`, runs to its timeout in one call and its UART
198
+ output is checked afterwards; `expect_frames` checks are skipped there for now.
199
+ `simantic.run_firmware(..., backend="rust")` does the same for a single ELF.
200
+
201
+ One tip worth real time: on the Renode engine, every hand off between Python and
202
+ the simulation costs a few hundred microseconds. Reading is free, pausing and
203
+ resuming is not. Prefer `expect()`, which crosses once, over a loop that polls
204
+ every millisecond. On the Rust engine, polling is essentially free.
205
+
206
+ If you keep `test.yaml` fixture manifests, installing the package also turns
207
+ each one into its own pytest item, so you get `-k` filtering, `--junitxml`, and
208
+ xdist for free. A manifest is the fire-and-forget form of a test: name the
209
+ machine(s) with their `parts`, the `media` that wire UART and CAN peers to
210
+ the firmware's controllers, how long to run, and what must and must not
211
+ appear:
212
+
213
+ ```yaml
214
+ machines:
215
+ dut:
216
+ mcu: STM32H753ZI
217
+ elf: fc.elf
218
+ parts:
219
+ - { name: gpspeer, type: uart-device, baud: 230400, script: gps.py }
220
+ media:
221
+ - { type: uart, connect: [dut.usart6, dut.gpspeer] }
222
+ timeout: 12
223
+ expect: ["GPS fix: 3"]
224
+ expect_absent: ["RESULT: FAIL"]
225
+ expect_frames: ["SPI Rx cs=0 len=2 mosi=4F 06"] # same line shape as `sim --frames`
226
+ expect_frames_absent: ["CAN Dropped"]
227
+ ```
228
+
229
+ Single-machine manifests (`mcu:` at the top level) are the one-machine case.
230
+ Models resolve through your account; no checkout of ours is needed. A model can
231
+ require a minimum engine version; if yours is older, the failure names the
232
+ version and the fix (`simantic install engine --force`).
233
+
234
+ Three more keys cover fixtures that need files or peers on the host side:
235
+ `sparse_files` creates blank files of a given size before the run (a blank SD
236
+ card, say), `networkServices` adds scripted network peers, and `{TEST_DIR}` and
237
+ `{WORK_DIR}` in a board file expand to the manifest's directory and a per-test
238
+ scratch directory. A manifest whose `sim_args` asks for a `sim`
239
+ command-line flag with no equivalent here is skipped, with the flag named.
240
+
241
+ A run also has a wall-clock budget: 30 s of host time by default, 100 s with
242
+ `wireless: true`, or whatever `wall:` says. Going over it fails the test with
243
+ the virtual time reached. Slow simulation is a firmware busy-wait or a model
244
+ gap, and the report tells you which to go find rather than waiting it out.
245
+
246
+ For a single run with no assertions in the middle, there is `run_firmware(...)`:
247
+
248
+ ```python
249
+ run = simantic.run_firmware("build/zephyr.elf", mcu="STM32F401RE",
250
+ expect=["RESULT: PASS"])
251
+ assert run.passed, run.failure_report()
252
+ ```
253
+
254
+ ## Telemetry
255
+
256
+ Only when you are signed in, we report two things:
257
+
258
+ * **Test counts.** At the end of a pytest run, how many simulator tests passed,
259
+ failed and were skipped. One request per run.
260
+ * **Which calls you use.** The names of the SDK calls and `simantic` commands
261
+ you run, such as `sdk.run_firmware` or `cli.install`, and how often. They are
262
+ counted in `~/.simantic/usage.jsonl` and uploaded at most hourly.
263
+
264
+ Each report also carries the version of this package, your Python version,
265
+ operating system and CPU architecture. Reports go out when a pytest run or a
266
+ `simantic` command finishes, never while a simulation is running, and a failed
267
+ or slow request is dropped silently.
268
+
269
+ We do not send file paths, project names, test names, call arguments, firmware,
270
+ or simulation output. Those are yours. Turn it off whenever you like:
271
+
272
+ ```bash
273
+ export SIMANTIC_TELEMETRY=0 # or DO_NOT_TRACK=1
274
+ ```
275
+
276
+ ## Questions
277
+
278
+ We would genuinely like to hear how this goes for you, especially if something
279
+ is confusing or broken. Write to **founder@simantic.dev**, or open an issue.
280
+
281
+ ## License
282
+
283
+ MIT. The simulators it drives are separate software under their own terms.
@@ -1,8 +1,8 @@
1
1
  # Publishing `simantic` to PyPI
2
2
 
3
- First-time setup, then the per-release loop. Steps marked **[you]** need a
3
+ One-time setup (done; kept for reference), then the per-release loop. Steps marked **[you]** need a
4
4
  human with the accounts; everything else is automated in
5
- `.github/workflows/python.yml`.
5
+ `.github/workflows/ci.yml`.
6
6
 
7
7
  ## One-time setup
8
8
 
@@ -18,18 +18,15 @@ the first upload so a mistake is not permanent.
18
18
 
19
19
  ### 2. Claim the name **[you]**
20
20
 
21
- `simantic` was unregistered as of this writing. Names are first-come and a
22
- published version number can never be reused or overwritten — only yanked —
23
- so publish `0.1.0` to TestPyPI first, confirm it looks right, and only then
24
- push the real tag. Claiming early is cheap insurance against someone else
25
- taking it.
21
+ Done: `simantic` is ours on PyPI. A published version number can never be
22
+ reused or overwritten — only yanked — so check the version before pushing a
23
+ tag.
26
24
 
27
25
  ### 3. Configure Trusted Publishing **[you]**
28
26
 
29
27
  This replaces API tokens with short-lived OIDC credentials, so there is no
30
28
  long-lived secret in the repo. On PyPI, go to your account's **Publishing**
31
- page and add a *pending* publisher (pending = the project does not exist
32
- yet, which is the case before the first upload):
29
+ page and add a publisher:
33
30
 
34
31
  | Field | Value |
35
32
  |---|---|
@@ -69,8 +66,8 @@ uv run --with simantic --index https://test.pypi.org/simple/ \
69
66
  3. Tag and push:
70
67
 
71
68
  ```bash
72
- git tag v0.1.0
73
- git push origin v0.1.0
69
+ git tag v0.4.0
70
+ git push origin v0.4.0
74
71
  ```
75
72
 
76
73
  The workflow tests on Linux/macOS/Windows across 3.11 and 3.13, builds the
@@ -79,9 +76,9 @@ sdist and wheel, runs `twine check`, and uploads via trusted publishing.
79
76
  ## Versioning
80
77
 
81
78
  Semantic versioning on the SDK's own surface, which is independent of the
82
- `analog-cli` version it drives. The coupling that matters is the report
83
- schema: `simantic` speaks `analog-cli.test-report/1` and refuses anything
84
- else, so a schema revision in the CLI is a major bump here.
79
+ engine version it drives: the package is 0.4.x while the engine is 0.6.x, and
80
+ the two are not meant to match. The engine a model needs is declared by the
81
+ model (`min_sim_version`), not by this package's version.
85
82
 
86
83
  Stay on `0.x` until the API has survived real use. Pre-1.0 signals that
87
84
  breaking changes can still happen, which is honest for a first release.
@@ -0,0 +1,261 @@
1
+ <p align="center">
2
+ <img src="https://simantic.dev/simantic_logo_4_full.png" alt="Simantic" width="340">
3
+ </p>
4
+
5
+ <h3 align="center">Test your firmware without a board.</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://pypi.org/project/simantic/"><img src="https://img.shields.io/pypi/v/simantic.svg" alt="PyPI"></a>
9
+ <img src="https://img.shields.io/pypi/pyversions/simantic.svg" alt="Python versions">
10
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT licence">
11
+ </p>
12
+
13
+ Nothing to plug in, nothing to flash. Simantic boots your real ELF on a
14
+ simulated microcontroller and hands you the whole machine from Python. Watch it
15
+ print, press a button, read a variable straight out of RAM.
16
+
17
+ ```bash
18
+ pip install simantic
19
+ ```
20
+
21
+ ```python
22
+ from simantic import Sim
23
+
24
+ with Sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2") as sim:
25
+ sim.expect("ready")
26
+ sim.inject_gpio("gpioc", 13, True) # press the user button
27
+ sim.expect("button pressed")
28
+ assert sim.read_u32("press_count") == 1
29
+ ```
30
+
31
+ That is a whole test. No probe, no breakpoint, no waiting on hardware.
32
+
33
+ Three things you get that a bench cannot give you:
34
+
35
+ * **See inside.** Read any variable, register, or RTOS thread while the
36
+ firmware runs, without halting it.
37
+ * **Poke it.** Press buttons, send CAN frames, feed the radio, all from your
38
+ script.
39
+ * **Repeat exactly.** Time moves only when you ask, so a run comes out the same
40
+ every time, on your laptop and in CI.
41
+
42
+ The simulator lives inside your Python process, so there is no server to start
43
+ and no port to talk to.
44
+
45
+ > **Alpha, version 0.4.x.** We are still moving things around, so the API can
46
+ > change without a deprecation period. Pin an exact version
47
+ > (`simantic==0.4.0`) if you depend on it, and please hold off on production
48
+ > pipelines for now. Tell us what breaks.
49
+
50
+ ## Setup
51
+
52
+ `pip install` is the whole setup. The first `Sim(...)` downloads the engine it
53
+ needs into `~/.simantic/` and checks it against the published checksum. The
54
+ wheel on PyPI holds only Python code; the simulators are never inside it.
55
+
56
+ Sign in once if you want to name MCUs by part number:
57
+
58
+ ```bash
59
+ simantic auth
60
+ ```
61
+
62
+ That opens a browser tab, much like `gh auth login`, and saves a token to
63
+ `~/.sim_id`. In CI, pipe one in instead: `echo $TOKEN | simantic auth`.
64
+
65
+ Already have the `sim` binary? Put it on PATH or point `$SIMANTIC_SIM` at it.
66
+ `simantic status` shows what resolved.
67
+
68
+ ## Pick your engine
69
+
70
+ The same script runs on either engine. You choose per simulation:
71
+
72
+ ```python
73
+ Sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2") # Renode engine, the default
74
+ Sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2", backend="rust") # our Rust engine
75
+ ```
76
+
77
+ The Rust engine is a small extension module, runs one machine, and is
78
+ considerably faster. Anything it cannot do yet, such as multi machine scenarios
79
+ or CAN and radio injection, raises `simantic.NotSupported` and names the gap
80
+ instead of quietly doing nothing. What each engine covers is listed in
81
+ [the engine table](https://github.com/simantic-dev/pippy/blob/main/docs/session-api.md#what-each-engine-supports).
82
+
83
+ ## ESP32 images
84
+
85
+ The ESP32-C3, C6 and P4 boot the way silicon does: Espressif's mask ROM runs
86
+ first, then your bootloader, then your app. `sim --elf` therefore takes one ELF
87
+ that carries the ROM and your whole flash image. Build it from the files your
88
+ ESP-IDF or PlatformIO build already produced:
89
+
90
+ ```bash
91
+ simantic esp-image --chip esp32c3 \
92
+ --part 0x0:bootloader.bin --part 0x8000:partitions.bin --part 0x10000:firmware.bin \
93
+ --flash-size 16MB -o image.elf
94
+ sim --elf image.elf ...
95
+ ```
96
+
97
+ Already have a merged image from `esptool.py merge_bin`? Pass `--flash merged.bin`
98
+ instead of the parts. The same thing from Python is
99
+ `simantic.esp_image.build_image("esp32c3", flash, out="image.elf")`.
100
+
101
+ The mask ROM is Espressif's and is not bundled. On first use it is downloaded
102
+ from Espressif's own repositories, pinned by commit and SHA-256, and cached in
103
+ `~/.simantic/esp-rom`: the raw C3 and C6 dumps from
104
+ [espressif/qemu](https://github.com/espressif/qemu/tree/master/pc-bios), and
105
+ the P4 ROM from the [esp-rom-elfs](https://github.com/espressif/esp-rom-elfs)
106
+ release ESP-IDF installs. Offline, or with your own copy, pass `--rom`.
107
+ `simantic esp-rom --chip esp32c3` downloads it ahead of time and prints where it
108
+ went.
109
+
110
+ ## Testing with pytest
111
+
112
+ Take the `sim` fixture and write ordinary tests:
113
+
114
+ ```python
115
+ def test_timer_irq_fires(sim):
116
+ s = sim(elf="fw.elf", mcu="STM32F401RE", uart="usart2")
117
+ s.expect("fired=1", timeout=8)
118
+ assert s.read_u32("fired") == 1
119
+ ```
120
+
121
+ The engine starts once per worker rather than once per test, and every machine
122
+ is closed for you. When a test fails, its UART transcript is attached to the
123
+ report, because that is usually the evidence you want.
124
+
125
+ ### Test what went over the wire
126
+
127
+ Firmware that prints "sensor OK" is reporting its own bookkeeping. The bus
128
+ tells you what happened. Put a scripted device on the other end of each bus
129
+ (a `parts` entry per device, each backed by a few dozen lines of Python), wire
130
+ the media in a scenario, and assert on `frames()`:
131
+
132
+ ```python
133
+ SCENARIO = {
134
+ "machines": {"dut": {
135
+ "mcu": "STM32H753IITX", "elf": "fc.elf",
136
+ "parts": [
137
+ {"name": "imu0", "type": "spi-device", "bus": "spi1", "cs": "PC4", "script": "icm42688.py"},
138
+ {"name": "baro", "type": "i2c-device", "bus": "i2c2", "address": 0x63, "script": "icp20100.py"},
139
+ {"name": "gpspeer", "type": "uart-device", "baud": 230400, "script": "gps.py"},
140
+ {"name": "canpeer", "type": "can-node", "script": "dronecan.py"},
141
+ ],
142
+ }},
143
+ "media": [
144
+ {"type": "uart", "connect": ["dut.usart6", "dut.gpspeer"]},
145
+ {"type": "can", "connect": ["dut.fdcan1", "dut.canpeer"]},
146
+ ],
147
+ }
148
+
149
+ def test_imu_configured_then_streams(sim):
150
+ s = sim(scenario=SCENARIO, machine="dut", uart="usart1")
151
+ s.run_for(3.0) # virtual seconds
152
+ imu = [f for f in s.frames() if f["label"] == "imu0"] # one dict per chip-select window
153
+ cfg = next(f for f in imu if f["data"][0] == 0x4F) # GYRO_CONFIG0 write
154
+ assert cfg["data"][1] & 0x0F == 0x06 # ODR the driver programmed
155
+ assert cfg["miso"][0] == 0x00 # what the slave answered
156
+ bursts = [f for f in imu if f["t"] > cfg["t"] and len(f["data"]) > 16]
157
+ assert len(bursts) > 100 # FIFO reads after configuration
158
+ ```
159
+
160
+ Every record carries its virtual-time stamp, so a rate is a subtraction between
161
+ consecutive frames and an ordering is a comparison. The scenario dict is the
162
+ same shape as `sim --scenario`'s YAML, and because the test owns it, the
163
+ negative control is the same test with a peer removed from `media`. Changing
164
+ what a device does (a NACK for 50 ms, a stale frame, a node that stops
165
+ answering at t = 5 s) is an edit to the peer script, on the virtual clock. The
166
+ `sim` guide covers every output and the peer API:
167
+ https://simantic.com/docs
168
+
169
+ `--sim-backend=renode|rust|both` chooses the engine. With `both`, each test runs
170
+ on each and the engine name appears in the test id. Anything an engine cannot do
171
+ is reported as a skip with the reason, so one suite can target both and stay
172
+ honest about what each covers.
173
+
174
+ `test.yaml` manifests follow the same option. On `rust` a manifest, including a
175
+ multi machine one with `media:`, runs to its timeout in one call and its UART
176
+ output is checked afterwards; `expect_frames` checks are skipped there for now.
177
+ `simantic.run_firmware(..., backend="rust")` does the same for a single ELF.
178
+
179
+ One tip worth real time: on the Renode engine, every hand off between Python and
180
+ the simulation costs a few hundred microseconds. Reading is free, pausing and
181
+ resuming is not. Prefer `expect()`, which crosses once, over a loop that polls
182
+ every millisecond. On the Rust engine, polling is essentially free.
183
+
184
+ If you keep `test.yaml` fixture manifests, installing the package also turns
185
+ each one into its own pytest item, so you get `-k` filtering, `--junitxml`, and
186
+ xdist for free. A manifest is the fire-and-forget form of a test: name the
187
+ machine(s) with their `parts`, the `media` that wire UART and CAN peers to
188
+ the firmware's controllers, how long to run, and what must and must not
189
+ appear:
190
+
191
+ ```yaml
192
+ machines:
193
+ dut:
194
+ mcu: STM32H753ZI
195
+ elf: fc.elf
196
+ parts:
197
+ - { name: gpspeer, type: uart-device, baud: 230400, script: gps.py }
198
+ media:
199
+ - { type: uart, connect: [dut.usart6, dut.gpspeer] }
200
+ timeout: 12
201
+ expect: ["GPS fix: 3"]
202
+ expect_absent: ["RESULT: FAIL"]
203
+ expect_frames: ["SPI Rx cs=0 len=2 mosi=4F 06"] # same line shape as `sim --frames`
204
+ expect_frames_absent: ["CAN Dropped"]
205
+ ```
206
+
207
+ Single-machine manifests (`mcu:` at the top level) are the one-machine case.
208
+ Models resolve through your account; no checkout of ours is needed. A model can
209
+ require a minimum engine version; if yours is older, the failure names the
210
+ version and the fix (`simantic install engine --force`).
211
+
212
+ Three more keys cover fixtures that need files or peers on the host side:
213
+ `sparse_files` creates blank files of a given size before the run (a blank SD
214
+ card, say), `networkServices` adds scripted network peers, and `{TEST_DIR}` and
215
+ `{WORK_DIR}` in a board file expand to the manifest's directory and a per-test
216
+ scratch directory. A manifest whose `sim_args` asks for a `sim`
217
+ command-line flag with no equivalent here is skipped, with the flag named.
218
+
219
+ A run also has a wall-clock budget: 30 s of host time by default, 100 s with
220
+ `wireless: true`, or whatever `wall:` says. Going over it fails the test with
221
+ the virtual time reached. Slow simulation is a firmware busy-wait or a model
222
+ gap, and the report tells you which to go find rather than waiting it out.
223
+
224
+ For a single run with no assertions in the middle, there is `run_firmware(...)`:
225
+
226
+ ```python
227
+ run = simantic.run_firmware("build/zephyr.elf", mcu="STM32F401RE",
228
+ expect=["RESULT: PASS"])
229
+ assert run.passed, run.failure_report()
230
+ ```
231
+
232
+ ## Telemetry
233
+
234
+ Only when you are signed in, we report two things:
235
+
236
+ * **Test counts.** At the end of a pytest run, how many simulator tests passed,
237
+ failed and were skipped. One request per run.
238
+ * **Which calls you use.** The names of the SDK calls and `simantic` commands
239
+ you run, such as `sdk.run_firmware` or `cli.install`, and how often. They are
240
+ counted in `~/.simantic/usage.jsonl` and uploaded at most hourly.
241
+
242
+ Each report also carries the version of this package, your Python version,
243
+ operating system and CPU architecture. Reports go out when a pytest run or a
244
+ `simantic` command finishes, never while a simulation is running, and a failed
245
+ or slow request is dropped silently.
246
+
247
+ We do not send file paths, project names, test names, call arguments, firmware,
248
+ or simulation output. Those are yours. Turn it off whenever you like:
249
+
250
+ ```bash
251
+ export SIMANTIC_TELEMETRY=0 # or DO_NOT_TRACK=1
252
+ ```
253
+
254
+ ## Questions
255
+
256
+ We would genuinely like to hear how this goes for you, especially if something
257
+ is confusing or broken. Write to **founder@simantic.dev**, or open an issue.
258
+
259
+ ## License
260
+
261
+ MIT. The simulators it drives are separate software under their own terms.