diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 0ea1d4f..fc0384f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -21,6 +21,12 @@ jobs:
publish:
runs-on: ubuntu-latest
+ # Trusted Publishing (OIDC): exchange a short-lived GitHub token for a NuGet
+ # API key — no long-lived NUGET_API_KEY secret. Requires id-token: write.
+ permissions:
+ id-token: write
+ contents: read
+
steps:
# ── Checkout ────────────────────────────────────────────────────────────
- name: Checkout
@@ -75,6 +81,13 @@ jobs:
--no-build
--output ./nupkgs
${{ steps.versioning.outputs.full_version != '' && format('/p:Version={0}', steps.versioning.outputs.full_version) || (steps.versioning.outputs.suffix != '' && format('--version-suffix {0}', steps.versioning.outputs.suffix) || '') }}
+ - name: Pack Avr8Sharp.TestKit.Core
+ run: >
+ dotnet pack src/Avr8Sharp.TestKit.Core/Avr8Sharp.TestKit.Core.csproj
+ -c Release
+ --no-build
+ --output ./nupkgs
+ ${{ steps.versioning.outputs.full_version != '' && format('/p:Version={0}', steps.versioning.outputs.full_version) || (steps.versioning.outputs.suffix != '' && format('--version-suffix {0}', steps.versioning.outputs.suffix) || '') }}
- name: Pack Avr8Sharp.TestKit
run: >
dotnet pack src/Avr8Sharp.TestKit/Avr8Sharp.TestKit.csproj
@@ -83,19 +96,30 @@ jobs:
--output ./nupkgs
${{ steps.versioning.outputs.full_version != '' && format('/p:Version={0}', steps.versioning.outputs.full_version) || (steps.versioning.outputs.suffix != '' && format('--version-suffix {0}', steps.versioning.outputs.suffix) || '') }}
- # ── Push to nuget.org (optional) ─────────────────────────────────────────
+ # ── NuGet Trusted Publishing login (OIDC) ────────────────────────────────
+ # Exchanges the workflow's OIDC token for a short-lived nuget.org API key.
+ # The trusted-publishing policy (owner + repo + workflow) must exist on
+ # nuget.org, and the account username is provided via the NUGET_USER var.
+ - name: NuGet login (Trusted Publishing)
+ id: nuget_login
+ if: >
+ ${{ github.server_url == 'https://github.com' &&
+ (startsWith(github.ref, 'refs/tags/v') ||
+ github.event.inputs.push_public == 'true') }}
+ uses: NuGet/login@v1
+ with:
+ user: ${{ vars.NUGET_USER }}
+
+ # ── Push to nuget.org ────────────────────────────────────────────────────
- name: Push to nuget.org
- env:
- API_KEY: ${{ secrets.NUGET_API_KEY }}
if: >
${{ github.server_url == 'https://github.com' &&
- env.API_KEY != '' &&
(startsWith(github.ref, 'refs/tags/v') ||
github.event.inputs.push_public == 'true') }}
run: >
dotnet nuget push "./nupkgs/*.nupkg"
--source https://api.nuget.org/v3/index.json
- --api-key "${{ secrets.NUGET_API_KEY }}"
+ --api-key "${{ steps.nuget_login.outputs.NUGET_API_KEY }}"
--skip-duplicate
- name: Upload NuGet Packages
diff --git a/AVR8Sharp.sln b/AVR8Sharp.sln
index e615d0c..849baee 100644
--- a/AVR8Sharp.sln
+++ b/AVR8Sharp.sln
@@ -14,6 +14,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avr8Sharp.TestKit.Samples",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avr8Sharp", "src\Avr8Sharp\Avr8Sharp.csproj", "{C0AEC60C-FA55-4C55-BC30-2E7941EB783C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avr8Sharp.TestKit.Core", "src\Avr8Sharp.TestKit.Core\Avr8Sharp.TestKit.Core.csproj", "{3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avr8Sharp.Native", "src\Avr8Sharp.Native\Avr8Sharp.Native.csproj", "{BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -84,6 +88,30 @@ Global
{C0AEC60C-FA55-4C55-BC30-2E7941EB783C}.Release|x64.Build.0 = Release|Any CPU
{C0AEC60C-FA55-4C55-BC30-2E7941EB783C}.Release|x86.ActiveCfg = Release|Any CPU
{C0AEC60C-FA55-4C55-BC30-2E7941EB783C}.Release|x86.Build.0 = Release|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Debug|x64.Build.0 = Debug|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Debug|x86.Build.0 = Debug|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Release|x64.ActiveCfg = Release|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Release|x64.Build.0 = Release|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Release|x86.ActiveCfg = Release|Any CPU
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA}.Release|x86.Build.0 = Release|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Debug|x64.Build.0 = Debug|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Debug|x86.Build.0 = Debug|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Release|x64.ActiveCfg = Release|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Release|x64.Build.0 = Release|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Release|x86.ActiveCfg = Release|Any CPU
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -94,5 +122,7 @@ Global
{0A92DF2E-A13D-4A0B-9CC7-41CD84B85AC4} = {0EAF591C-BC34-4D1F-A9BB-58506ECCEB54}
{8908C29D-3B4B-4E22-AA5D-CB7C11BD4932} = {9B994141-4606-4748-A978-ECA5A59DE073}
{C0AEC60C-FA55-4C55-BC30-2E7941EB783C} = {0EAF591C-BC34-4D1F-A9BB-58506ECCEB54}
+ {3C04CBC2-72ED-43F4-ABAA-8439414BFFAA} = {0EAF591C-BC34-4D1F-A9BB-58506ECCEB54}
+ {BB7B9EB7-55E3-45A6-BFB2-A10A816BAC16} = {0EAF591C-BC34-4D1F-A9BB-58506ECCEB54}
EndGlobalSection
EndGlobal
diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore
new file mode 100644
index 0000000..293308b
--- /dev/null
+++ b/bindings/python/.gitignore
@@ -0,0 +1,15 @@
+# Python build artifacts
+dist/
+build/
+*.egg-info/
+__pycache__/
+*.py[cod]
+
+# Native libraries copied in by hatch_build.py at build time
+src/avr8sharp/*.dylib
+src/avr8sharp/*.so
+src/avr8sharp/*.dll
+
+# Virtualenvs
+.venv/
+venv/
diff --git a/bindings/python/LICENSE b/bindings/python/LICENSE
new file mode 100644
index 0000000..b65bfd8
--- /dev/null
+++ b/bindings/python/LICENSE
@@ -0,0 +1,80 @@
+Business Source License 1.1
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+"Business Source License" is a trademark of MariaDB Corporation Ab.
+
+-----------------------------------------------------------------------------
+
+Parameters
+
+Licensor: Iván Montiel Cardona
+Licensed Work: avr8sharp (the Python bindings and the Avr8Sharp.Native
+ and Avr8Sharp.TestKit(.Core) layers of the avr8sharp
+ repository). The Licensed Work is (c) 2025-present
+ Iván Montiel Cardona.
+Additional Use Grant: You may make production use of the Licensed Work,
+ provided such use does not include offering the Licensed
+ Work to third parties as a hosted or managed service whose
+ primary value is the AVR emulation or test-harness
+ functionality of the Licensed Work.
+Change Date: 2029-06-24
+Change License: Apache License, Version 2.0
+
+-----------------------------------------------------------------------------
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited production
+use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or modified
+form from a third party, the terms and conditions set forth in this License
+apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+-----------------------------------------------------------------------------
+
+Notice
+
+The Business Source License (this document, or the "License") is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+For more information on the use of the Business Source License for the
+avr8sharp project, and on how to use it for your own works, see the README and
+THIRD_PARTY_NOTICES files distributed with the Licensed Work.
+
+The third-party AVR emulation core (derived from avr8js) bundled in this
+package remains under the MIT License; see THIRD_PARTY_NOTICES.
diff --git a/bindings/python/README.md b/bindings/python/README.md
new file mode 100644
index 0000000..eac148f
--- /dev/null
+++ b/bindings/python/README.md
@@ -0,0 +1,53 @@
+# avr8sharp (Python)
+
+Fast AVR-8 emulator with a pytest-friendly test harness.
+
+`avr8sharp` is a Python binding over the C# [Avr8Sharp](https://github.com/begeistert/avr8sharp)
+emulator (itself a port of [avr8js](https://github.com/wokwi/avr8js)), compiled to a
+**self-contained Native AOT shared library**. There is **no .NET runtime to install** — the
+engine ships inside the wheel.
+
+The per-instruction execution loop stays entirely on the native side. Python only drives
+coarse-grained `run_*` calls, so the binding adds negligible overhead and the emulator keeps
+its full speed.
+
+## Install
+
+```bash
+pip install avr8sharp
+```
+
+## Usage
+
+```python
+from avr8sharp import ArduinoUno
+
+uno = ArduinoUno().with_hex(open("blink.hex").read())
+uno.run_ms(500)
+
+assert uno.port_b.pin_high(5) # digital pin 13
+assert "Hello" in uno.serial.text
+assert uno.cpu.cycles <= 8_000_000
+```
+
+Inline assembly, custom boards, and fast per-test reset are supported too:
+
+```python
+from avr8sharp import ArduinoUno
+
+uno = ArduinoUno()
+uno.snapshot() # capture power-on state once
+
+uno.with_asm("ldi r16, 0x20\nout 0x04, r16\nout 0x05, r16\nbreak\n").run_to_break()
+assert uno.port_b.pin_high(5)
+
+uno.restore() # cheap reset to power-on state between tests
+```
+
+Boards: `ArduinoUno` (ATmega328P), `ArduinoMega` (ATmega2560), `ATtiny85`, plus a blank
+`Simulation.create(flash, sram)` with manual `add_gpio` / `add_usart0` / `add_timer` wiring.
+
+## License
+
+Business Source License 1.1 (see `LICENSE`). The bundled AVR emulation core (derived from
+avr8js) and the .NET runtime remain under the MIT License (see `THIRD_PARTY_NOTICES`).
diff --git a/bindings/python/THIRD_PARTY_NOTICES b/bindings/python/THIRD_PARTY_NOTICES
new file mode 100644
index 0000000..ad0506e
--- /dev/null
+++ b/bindings/python/THIRD_PARTY_NOTICES
@@ -0,0 +1,48 @@
+THIRD-PARTY NOTICES
+===================
+
+The avr8sharp wheel bundles a self-contained Native AOT shared library
+(libavr8sharp.{dylib,so} / avr8sharp.dll). That binary statically links the
+following third-party components, whose licenses and notices are reproduced
+below. The avr8sharp Python code and the Avr8Sharp.Native / Avr8Sharp.TestKit
+layers themselves are licensed under the Business Source License 1.1 (see
+LICENSE); the components below are NOT — they retain their own permissive
+licenses.
+
+-----------------------------------------------------------------------------
+1. Avr8Sharp emulator core (derived from avr8js)
+-----------------------------------------------------------------------------
+
+The MIT License (MIT)
+
+Copyright (c) 2019-2023 Uri Shaked
+Copyright (c) 2025-present Iván Montiel Cardona
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+-----------------------------------------------------------------------------
+2. .NET runtime and base class libraries (Native AOT)
+-----------------------------------------------------------------------------
+
+Copyright (c) .NET Foundation and Contributors
+
+Licensed under the MIT License. The .NET runtime libraries statically linked
+into the native binary by Native AOT are distributed by Microsoft and the
+.NET Foundation under the MIT License:
+https://github.com/dotnet/runtime/blob/main/LICENSE.TXT
diff --git a/bindings/python/hatch_build.py b/bindings/python/hatch_build.py
new file mode 100644
index 0000000..fbd0213
--- /dev/null
+++ b/bindings/python/hatch_build.py
@@ -0,0 +1,131 @@
+# hatch_build.py
+# Custom hatchling build hook: compiles the Avr8Sharp.Native AOT shared library and places it
+# next to the avr8sharp package (as libavr8sharp.{dylib,so} / avr8sharp.dll) before packaging.
+#
+# Environment variables:
+# AVR8SHARP_SKIP_DOTNET_BUILD=1
+# Skip `dotnet publish` and use an existing native library at build/native/ instead.
+# When none is present (e.g. sdist-only builds), the hook returns early (source-only).
+# DOTNET_RID
+# Override the target Runtime Identifier (e.g. linux-x64, osx-arm64). Defaults to host.
+# WHEEL_PLATFORM_TAG
+# Override the wheel platform tag for cross-compilation
+# (e.g. manylinux_2_28_x86_64, win_amd64, macosx_14_0_arm64).
+
+from __future__ import annotations
+
+import os
+import platform
+import shutil
+import subprocess
+import sys
+import sysconfig
+from pathlib import Path
+
+from hatchling.builders.hooks.plugin.interface import BuildHookInterface
+
+# Native library filename produced by `dotnet publish` (assembly name = Avr8Sharp.Native),
+# and the bundled name the ctypes loader (_native.py) looks for, per platform.
+_PUBLISHED = {
+ "darwin": "Avr8Sharp.Native.dylib",
+ "linux": "Avr8Sharp.Native.so",
+ "win32": "Avr8Sharp.Native.dll",
+}
+_BUNDLED = {
+ "darwin": "libavr8sharp.dylib",
+ "linux": "libavr8sharp.so",
+ "win32": "avr8sharp.dll",
+}
+
+
+def _platform_key() -> str:
+ if sys.platform.startswith("linux"):
+ return "linux"
+ return sys.platform
+
+
+class CustomBuildHook(BuildHookInterface):
+ PLUGIN_NAME = "custom"
+
+ def initialize(self, version: str, build_data: dict) -> None:
+ root = Path(self.root)
+ key = _platform_key()
+ published_name = _PUBLISHED[key]
+ bundled_name = _BUNDLED[key]
+
+ dst = root / "src" / "avr8sharp" / bundled_name
+ cache = root / "build" / "native" / published_name
+
+ if os.environ.get("AVR8SHARP_SKIP_DOTNET_BUILD") == "1":
+ if cache.exists():
+ self.app.display_info(
+ f"[hatch-hook] Skipping dotnet publish; using cached binary: {cache}"
+ )
+ src = cache
+ else:
+ self.app.display_info(
+ "[hatch-hook] AVR8SHARP_SKIP_DOTNET_BUILD=1 and no cached binary found; "
+ "building source-only package (no native library included)."
+ )
+ return
+ else:
+ # The csproj lives in the monorepo, two levels up from bindings/python.
+ csproj = root.parent.parent / "src" / "Avr8Sharp.Native" / "Avr8Sharp.Native.csproj"
+ publish_dir = root / "build" / "native"
+ publish_dir.mkdir(parents=True, exist_ok=True)
+
+ cmd = [
+ "dotnet", "publish", str(csproj),
+ "-c", "Release",
+ "-o", str(publish_dir),
+ "--nologo",
+ ]
+ rid = _get_rid()
+ if rid:
+ cmd += ["-r", rid]
+ self.app.display_info(f"[hatch-hook] Target RID: {rid}")
+
+ self.app.display_info(f"[hatch-hook] Running dotnet publish -> {publish_dir}")
+ if subprocess.run(cmd).returncode != 0:
+ raise RuntimeError(f"dotnet publish failed: {' '.join(cmd)}")
+
+ src = publish_dir / published_name
+ if not src.exists():
+ raise FileNotFoundError(f"Native library not found after publish: {src}")
+
+ shutil.copy2(str(src), str(dst))
+ if sys.platform != "win32":
+ dst.chmod(0o755)
+ self.app.display_info(f"[hatch-hook] Native library placed at: {dst}")
+
+ build_data["artifacts"].append(str(dst.relative_to(root)))
+ build_data["pure_python"] = False
+ build_data["tag"] = f"py3-none-{_get_wheel_platform_tag()}"
+ self.app.display_info(f"[hatch-hook] Wheel tag: py3-none-{_get_wheel_platform_tag()}")
+
+
+def _get_rid() -> str | None:
+ override = os.environ.get("DOTNET_RID")
+ if override:
+ return override
+ m = platform.machine().lower()
+ s = platform.system().lower()
+ table = {
+ ("linux", "x86_64"): "linux-x64",
+ ("linux", "aarch64"): "linux-arm64",
+ ("darwin", "x86_64"): "osx-x64",
+ ("darwin", "arm64"): "osx-arm64",
+ ("windows", "amd64"): "win-x64",
+ ("windows", "arm64"): "win-arm64",
+ }
+ return table.get((s, m))
+
+
+def _get_wheel_platform_tag() -> str:
+ override = os.environ.get("WHEEL_PLATFORM_TAG")
+ if override:
+ return override
+ if sys.platform.startswith("linux"):
+ arch = platform.machine().lower()
+ return f"manylinux_2_28_{arch}"
+ return sysconfig.get_platform().replace("-", "_").replace(".", "_")
diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml
new file mode 100644
index 0000000..aac5836
--- /dev/null
+++ b/bindings/python/pyproject.toml
@@ -0,0 +1,54 @@
+[build-system]
+requires = ["hatchling>=1.25.0"]
+build-backend = "hatchling.build"
+
+[project]
+name = "avr8sharp"
+version = "1.1.0b1"
+description = "Fast AVR-8 emulator with a pytest-friendly test harness (Python bindings over the C# Avr8Sharp engine, compiled to a self-contained Native AOT library)."
+readme = "README.md"
+authors = [
+ { name = "Iván Montiel Cardona", email = "ivan.montiel.c@gmail.com" },
+]
+requires-python = ">=3.11"
+license = { text = "BUSL-1.1" }
+keywords = ["AVR", "Arduino", "emulator", "simulator", "ATmega", "testing", "firmware"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Topic :: Software Development :: Embedded Systems",
+ "Topic :: System :: Emulators",
+]
+dependencies = []
+
+[project.optional-dependencies]
+test = ["pytest>=7.0"]
+
+[project.urls]
+Homepage = "https://github.com/begeistert/avr8sharp"
+Repository = "https://github.com/begeistert/avr8sharp"
+"Bug Tracker" = "https://github.com/begeistert/avr8sharp/issues"
+
+[tool.hatch.build.hooks.custom]
+path = "hatch_build.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/avr8sharp"]
+# Ship the MIT third-party notices inside the wheel (license compliance for the bundled core).
+force-include = { "THIRD_PARTY_NOTICES" = "avr8sharp/THIRD_PARTY_NOTICES" }
+# The native library is dropped next to the package by hatch_build.py.
+artifacts = [
+ "src/avr8sharp/libavr8sharp.dylib",
+ "src/avr8sharp/libavr8sharp.so",
+ "src/avr8sharp/avr8sharp.dll",
+]
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "src/avr8sharp",
+ "hatch_build.py",
+ "README.md",
+ "LICENSE",
+ "THIRD_PARTY_NOTICES",
+]
diff --git a/bindings/python/src/avr8sharp/__init__.py b/bindings/python/src/avr8sharp/__init__.py
new file mode 100644
index 0000000..93647de
--- /dev/null
+++ b/bindings/python/src/avr8sharp/__init__.py
@@ -0,0 +1,50 @@
+"""avr8sharp — fast AVR-8 emulator with a pytest-friendly test harness.
+
+Python bindings over the C# Avr8Sharp emulator, compiled to a self-contained Native AOT
+shared library and called via ctypes. The per-instruction loop stays native, so the emulator
+keeps its full speed; Python only drives coarse-grained ``run_*`` calls.
+"""
+
+from __future__ import annotations
+
+from .errors import Avr8SharpError
+from .simulation import (
+ Adc,
+ ArduinoMega,
+ ArduinoUno,
+ ATtiny13,
+ ATtiny85,
+ ATtinyX4,
+ Cpu,
+ PinState,
+ Port,
+ Serial,
+ Simulation,
+ Spi,
+ Twi,
+ board,
+ board_for_target,
+ selftest,
+)
+
+__all__ = [
+ "Adc",
+ "ArduinoMega",
+ "ArduinoUno",
+ "ATtiny13",
+ "ATtiny85",
+ "ATtinyX4",
+ "Avr8SharpError",
+ "Cpu",
+ "PinState",
+ "Port",
+ "Serial",
+ "Simulation",
+ "Spi",
+ "Twi",
+ "board",
+ "board_for_target",
+ "selftest",
+]
+
+__version__ = "1.1.0b1"
diff --git a/bindings/python/src/avr8sharp/_native.py b/bindings/python/src/avr8sharp/_native.py
new file mode 100644
index 0000000..2ea368a
--- /dev/null
+++ b/bindings/python/src/avr8sharp/_native.py
@@ -0,0 +1,134 @@
+"""ctypes loader and C-ABI signature declarations for the Avr8Sharp native library.
+
+The native library is a Native AOT shared library built from ``src/Avr8Sharp.Native``. It is
+self-contained (no .NET runtime needed at install time) and exposes a flat C ABI whose entry
+points are all prefixed ``a8s_``. Every call is coarse-grained: the per-instruction loop stays
+on the native side, so the emulator keeps its full speed.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import sys
+from ctypes import (
+ POINTER,
+ c_byte,
+ c_char_p,
+ c_double,
+ c_int,
+ c_long,
+ c_longlong,
+ c_uint,
+ c_ulonglong,
+ c_void_p,
+)
+from pathlib import Path
+
+# Signature table: name -> (argtypes, restype). Applied to the loaded CDLL.
+_SIGNATURES: dict[str, tuple[list, object]] = {
+ # lifecycle
+ "a8s_create": ([c_int, c_int], c_void_p),
+ "a8s_create_uno": ([], c_void_p),
+ "a8s_create_mega": ([], c_void_p),
+ "a8s_create_attiny85": ([], c_void_p),
+ "a8s_create_attinyx4": ([c_int, c_int], c_void_p),
+ "a8s_create_attiny13": ([], c_void_p),
+ "a8s_destroy": ([c_void_p], None),
+ # program loading
+ "a8s_with_frequency": ([c_void_p, c_uint], c_int),
+ "a8s_with_hex": ([c_void_p, c_char_p, c_int], c_int),
+ "a8s_with_asm": ([c_void_p, c_char_p, c_int], c_int),
+ "a8s_with_program": ([c_void_p, c_char_p, c_int], c_int),
+ # generic peripheral wiring
+ "a8s_add_gpio": ([c_void_p, c_int], c_int),
+ "a8s_add_usart0": ([c_void_p], c_int),
+ "a8s_add_timer": ([c_void_p, c_int], c_int),
+ # execution
+ "a8s_run_cycles": ([c_void_p, c_long], c_int),
+ "a8s_run_ms": ([c_void_p, c_double], c_int),
+ "a8s_run_instructions": ([c_void_p, c_int], c_int),
+ "a8s_run_to_break": ([c_void_p, c_int], c_int),
+ "a8s_run_until_serial": ([c_void_p, c_int, c_char_p, c_int, c_double], c_int),
+ "a8s_run_until_serial_bytes": ([c_void_p, c_int, c_int, c_double], c_int),
+ # serial observation
+ "a8s_serial_read": ([c_void_p, c_int, c_char_p, c_int], c_int),
+ "a8s_serial_byte_count": ([c_void_p, c_int], c_int),
+ "a8s_serial_clear": ([c_void_p, c_int], c_int),
+ "a8s_serial_inject": ([c_void_p, c_int, c_byte], c_int),
+ # gpio observation
+ "a8s_gpio_pin": ([c_void_p, c_int, c_int], c_int),
+ # analog / bus peripherals (ATmega328P-family sessions)
+ "a8s_adc_set_channel": ([c_void_p, c_int, c_double], c_int),
+ "a8s_spi_queue_response": ([c_void_p, c_byte], c_int),
+ "a8s_spi_read_mosi": ([c_void_p, c_char_p, c_int], c_int),
+ "a8s_twi_set_slave": ([c_void_p, c_int, c_int], c_int),
+ "a8s_twi_queue_response": ([c_void_p, c_byte], c_int),
+ "a8s_twi_read_writes": ([c_void_p, c_char_p, c_int], c_int),
+ # cpu / memory
+ "a8s_cpu_pc": ([c_void_p], c_uint),
+ "a8s_cpu_sp": ([c_void_p], c_uint),
+ "a8s_cpu_cycles": ([c_void_p], c_ulonglong),
+ "a8s_cpu_sreg": ([c_void_p], c_int),
+ "a8s_read_data": ([c_void_p, c_int], c_int),
+ "a8s_write_data": ([c_void_p, c_int, c_byte], c_int),
+ # reset / snapshot
+ "a8s_reset": ([c_void_p], c_int),
+ "a8s_snapshot": ([c_void_p], c_int),
+ "a8s_restore": ([c_void_p], c_int),
+ # errors / smoke
+ "a8s_last_error": ([c_void_p, c_char_p, c_int], c_int),
+ "a8s_selftest": ([], c_longlong),
+}
+
+
+def _candidate_names() -> list[str]:
+ """Platform-specific shared-library filenames to look for, in priority order."""
+ if sys.platform == "darwin":
+ return ["libavr8sharp.dylib", "Avr8Sharp.Native.dylib"]
+ if sys.platform == "win32":
+ return ["avr8sharp.dll", "Avr8Sharp.Native.dll"]
+ return ["libavr8sharp.so", "Avr8Sharp.Native.so"]
+
+
+def _find_library() -> Path:
+ """Locate the bundled native library next to this package.
+
+ Honours ``AVR8SHARP_LIBRARY`` for development against a freshly published build.
+ """
+ import os
+
+ override = os.environ.get("AVR8SHARP_LIBRARY")
+ if override:
+ p = Path(override)
+ if p.is_file():
+ return p
+ raise FileNotFoundError(f"AVR8SHARP_LIBRARY points to a missing file: {p}")
+
+ here = Path(__file__).resolve().parent
+ for name in _candidate_names():
+ candidate = here / name
+ if candidate.is_file():
+ return candidate
+
+ searched = ", ".join(_candidate_names())
+ raise FileNotFoundError(
+ f"Could not find the Avr8Sharp native library next to {here}. "
+ f"Looked for: {searched}. Set AVR8SHARP_LIBRARY to override."
+ )
+
+
+_lib: ctypes.CDLL | None = None
+
+
+def lib() -> ctypes.CDLL:
+ """Loads (once) and returns the native library with all signatures applied."""
+ global _lib
+ if _lib is not None:
+ return _lib
+ loaded = ctypes.CDLL(str(_find_library()))
+ for name, (argtypes, restype) in _SIGNATURES.items():
+ fn = getattr(loaded, name)
+ fn.argtypes = argtypes
+ fn.restype = restype
+ _lib = loaded
+ return _lib
diff --git a/bindings/python/src/avr8sharp/errors.py b/bindings/python/src/avr8sharp/errors.py
new file mode 100644
index 0000000..9f4b502
--- /dev/null
+++ b/bindings/python/src/avr8sharp/errors.py
@@ -0,0 +1,7 @@
+"""Exception types for the avr8sharp bindings."""
+
+from __future__ import annotations
+
+
+class Avr8SharpError(RuntimeError):
+ """Raised when a native call fails (assembly error, timeout, bad index, etc.)."""
diff --git a/bindings/python/src/avr8sharp/simulation.py b/bindings/python/src/avr8sharp/simulation.py
new file mode 100644
index 0000000..4c68ad3
--- /dev/null
+++ b/bindings/python/src/avr8sharp/simulation.py
@@ -0,0 +1,440 @@
+"""Pythonic fluent wrapper over the Avr8Sharp native simulation harness.
+
+Mirrors the C# ``Avr8Sharp.TestKit`` API. A single ``run_*`` call executes many thousands of CPU
+cycles entirely on the native side, so the binding adds negligible overhead.
+
+Example::
+
+ from avr8sharp import ArduinoUno
+
+ uno = ArduinoUno().with_hex(open("blink.hex").read())
+ uno.run_ms(500)
+ assert uno.port_b.pin_high(5)
+ assert "Hello" in uno.serial.text
+"""
+
+from __future__ import annotations
+
+import ctypes
+from enum import IntEnum
+
+from . import _native
+from .errors import Avr8SharpError
+
+_ERR_BUF = 1024
+
+
+class PinState(IntEnum):
+ LOW = 0
+ HIGH = 1
+ INPUT = 2
+ INPUT_PULLUP = 3
+
+
+def _check(lib, handle, rc: int, what: str) -> None:
+ if rc >= 0:
+ return
+ buf = ctypes.create_string_buffer(_ERR_BUF)
+ n = lib.a8s_last_error(handle, buf, _ERR_BUF)
+ msg = buf.raw[: max(n, 0)].decode("utf-8", "replace") if n > 0 else ""
+ raise Avr8SharpError(f"{what} failed (code {rc}){f': {msg}' if msg else ''}")
+
+
+class Cpu:
+ """Read/write accessor for CPU registers and data memory."""
+
+ def __init__(self, sim: "Simulation") -> None:
+ self._sim = sim
+
+ @property
+ def pc(self) -> int:
+ return self._sim._lib.a8s_cpu_pc(self._sim._h)
+
+ @property
+ def sp(self) -> int:
+ return self._sim._lib.a8s_cpu_sp(self._sim._h)
+
+ @property
+ def cycles(self) -> int:
+ return self._sim._lib.a8s_cpu_cycles(self._sim._h)
+
+ @property
+ def sreg(self) -> int:
+ return self._sim._lib.a8s_cpu_sreg(self._sim._h)
+
+ def reg(self, index: int) -> int:
+ """Returns general-purpose register R0..R31 (data address == index)."""
+ return self.read(index)
+
+ def read(self, address: int) -> int:
+ return self._sim._lib.a8s_read_data(self._sim._h, address)
+
+ def write(self, address: int, value: int) -> None:
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_write_data(self._sim._h, address, value & 0xFF), "write_data")
+
+
+class Port:
+ """GPIO port accessor, identified by a port index registered at creation."""
+
+ def __init__(self, sim: "Simulation", index: int) -> None:
+ self._sim = sim
+ self._index = index
+
+ def state(self, pin: int) -> PinState:
+ rc = self._sim._lib.a8s_gpio_pin(self._sim._h, self._index, pin)
+ if rc < 0:
+ _check(self._sim._lib, self._sim._h, rc, "gpio_pin")
+ return PinState(rc)
+
+ def pin_high(self, pin: int) -> bool:
+ return self.state(pin) == PinState.HIGH
+
+ def pin_low(self, pin: int) -> bool:
+ return self.state(pin) == PinState.LOW
+
+
+def _read_buffer(fn, sim: "Simulation", *args) -> bytes:
+ """Two-pass read of a native length-prefixed byte buffer (call with cap, then size exactly)."""
+ total = fn(sim._h, *args, None, 0)
+ if total <= 0:
+ return b""
+ buf = ctypes.create_string_buffer(total)
+ n = fn(sim._h, *args, buf, total)
+ return buf.raw[: max(n, 0)]
+
+
+class Adc:
+ """Analog input injection (ATmega328P-family sessions). Set the voltage a channel presents
+ so a firmware ADC read on that channel returns the matching value."""
+
+ def __init__(self, sim: "Simulation") -> None:
+ self._sim = sim
+
+ def set_channel(self, channel: int, volts: float) -> None:
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_adc_set_channel(self._sim._h, channel, volts), "adc_set_channel")
+
+
+class Spi:
+ """SPI bus stub: captures what the firmware clocks out (MOSI) and replays a canned response
+ queue back (MISO). The standard way to test SPI sensor/display drivers."""
+
+ def __init__(self, sim: "Simulation") -> None:
+ self._sim = sim
+
+ def queue_response(self, *values: int) -> None:
+ lib = self._sim._lib
+ for v in values:
+ _check(lib, self._sim._h, lib.a8s_spi_queue_response(self._sim._h, v & 0xFF), "spi_queue_response")
+
+ @property
+ def mosi(self) -> bytes:
+ return _read_buffer(self._sim._lib.a8s_spi_read_mosi, self._sim)
+
+
+class Twi:
+ """I²C single-address slave stub: ACKs its address, logs the bytes the firmware writes, and
+ replays a canned response queue on reads."""
+
+ def __init__(self, sim: "Simulation") -> None:
+ self._sim = sim
+
+ def set_slave(self, address: int, present: bool = True) -> None:
+ lib = self._sim._lib
+ rc = lib.a8s_twi_set_slave(self._sim._h, address, 1 if present else 0)
+ _check(lib, self._sim._h, rc, "twi_set_slave")
+
+ def queue_response(self, *values: int) -> None:
+ lib = self._sim._lib
+ for v in values:
+ _check(lib, self._sim._h, lib.a8s_twi_queue_response(self._sim._h, v & 0xFF), "twi_queue_response")
+
+ @property
+ def writes(self) -> bytes:
+ return _read_buffer(self._sim._lib.a8s_twi_read_writes, self._sim)
+
+
+class Serial:
+ """USART capture probe accessor, identified by a serial index registered at creation."""
+
+ def __init__(self, sim: "Simulation", index: int) -> None:
+ self._sim = sim
+ self._index = index
+
+ @property
+ def bytes(self) -> bytes:
+ lib, h = self._sim._lib, self._sim._h
+ n = lib.a8s_serial_byte_count(h, self._index)
+ if n <= 0:
+ return b""
+ buf = ctypes.create_string_buffer(n)
+ total = lib.a8s_serial_read(h, self._index, buf, n)
+ return buf.raw[: max(total, 0)]
+
+ @property
+ def text(self) -> str:
+ # Latin-1 matches the C# SerialProbe.Text decoding (1 byte == 1 char).
+ return self.bytes.decode("latin-1")
+
+ @property
+ def byte_count(self) -> int:
+ return self._sim._lib.a8s_serial_byte_count(self._sim._h, self._index)
+
+ @property
+ def lines(self) -> list[str]:
+ return [ln.rstrip("\r") for ln in self.text.split("\n")]
+
+ def clear(self) -> None:
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_serial_clear(self._sim._h, self._index), "serial_clear")
+
+ def inject(self, value: int) -> None:
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_serial_inject(self._sim._h, self._index, value & 0xFF), "serial_inject")
+
+
+class Simulation:
+ """Base fluent simulation handle. Use a board subclass (:class:`ArduinoUno`, etc.) for the
+ common cases, or construct a blank one with :meth:`create` and wire peripherals manually."""
+
+ def __init__(self, handle: int) -> None:
+ if not handle:
+ raise Avr8SharpError("Native simulation could not be created (null handle).")
+ self._lib = _native.lib()
+ self._h = handle
+ self.cpu = Cpu(self)
+
+ # ── factories ─────────────────────────────────────────────────────────────
+
+ @classmethod
+ def create(cls, flash: int = 0x8000, sram: int = 8192) -> "Simulation":
+ return cls(_native.lib().a8s_create(flash, sram))
+
+ # ── program loading ───────────────────────────────────────────────────────
+
+ def with_frequency(self, hz: int) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_with_frequency(self._h, hz), "with_frequency")
+ return self
+
+ def with_hex(self, hex_content: str) -> "Simulation":
+ data = hex_content.encode("utf-8")
+ _check(self._lib, self._h, self._lib.a8s_with_hex(self._h, data, len(data)), "with_hex")
+ return self
+
+ def with_asm(self, asm_source: str) -> "Simulation":
+ data = asm_source.encode("utf-8")
+ _check(self._lib, self._h, self._lib.a8s_with_asm(self._h, data, len(data)), "with_asm")
+ return self
+
+ def with_program(self, program: bytes) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_with_program(self._h, program, len(program)), "with_program")
+ return self
+
+ # ── execution ─────────────────────────────────────────────────────────────
+
+ def run_cycles(self, cycles: int) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_run_cycles(self._h, cycles), "run_cycles")
+ return self
+
+ def run_ms(self, ms: float) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_run_ms(self._h, ms), "run_ms")
+ return self
+
+ def run_instructions(self, count: int) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_run_instructions(self._h, count), "run_instructions")
+ return self
+
+ def run_to_break(self, max_instructions: int = 100_000) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_run_to_break(self._h, max_instructions), "run_to_break")
+ return self
+
+ def run_until_serial(self, serial: "Serial", text: str, max_ms: float = 2000) -> "Simulation":
+ data = text.encode("utf-8")
+ rc = self._lib.a8s_run_until_serial(self._h, serial._index, data, len(data), max_ms)
+ _check(self._lib, self._h, rc, "run_until_serial")
+ return self
+
+ def run_until_serial_bytes(self, serial: "Serial", byte_count: int, max_ms: float = 2000) -> "Simulation":
+ rc = self._lib.a8s_run_until_serial_bytes(self._h, serial._index, byte_count, max_ms)
+ _check(self._lib, self._h, rc, "run_until_serial_bytes")
+ return self
+
+ # ── custom peripheral wiring (blank ATmega328P-style sessions) ─────────────
+
+ def add_gpio(self, which: int) -> "Port":
+ """which: 0=PortB, 1=PortC, 2=PortD."""
+ idx = self._lib.a8s_add_gpio(self._h, which)
+ _check(self._lib, self._h, idx, "add_gpio")
+ return Port(self, idx)
+
+ def add_usart0(self) -> "Serial":
+ idx = self._lib.a8s_add_usart0(self._h)
+ _check(self._lib, self._h, idx, "add_usart0")
+ return Serial(self, idx)
+
+ def add_timer(self, which: int) -> int:
+ idx = self._lib.a8s_add_timer(self._h, which)
+ _check(self._lib, self._h, idx, "add_timer")
+ return idx
+
+ # ── reset / snapshot ──────────────────────────────────────────────────────
+
+ def reset(self) -> "Simulation":
+ _check(self._lib, self._h, self._lib.a8s_reset(self._h), "reset")
+ return self
+
+ def snapshot(self) -> "Simulation":
+ """Captures the power-on Data snapshot for :meth:`restore`. Call right after creation,
+ before loading firmware, to capture peripheral power-on defaults."""
+ _check(self._lib, self._h, self._lib.a8s_snapshot(self._h), "snapshot")
+ return self
+
+ def restore(self) -> "Simulation":
+ """Full per-test reset: restores the snapshot, resets timers/CPU, zeroes the cycle
+ counter, and clears all serial probes (mirrors PyMCU's SimSession)."""
+ _check(self._lib, self._h, self._lib.a8s_restore(self._h), "restore")
+ return self
+
+ # ── lifecycle ─────────────────────────────────────────────────────────────
+
+ def close(self) -> None:
+ if getattr(self, "_h", None):
+ self._lib.a8s_destroy(self._h)
+ self._h = 0
+
+ def __enter__(self) -> "Simulation":
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.close()
+
+ def __del__(self) -> None:
+ try:
+ self.close()
+ except Exception:
+ pass
+
+
+class ArduinoUno(Simulation):
+ """ATmega328P: ports B/C/D, timers 0/1/2, USART0, plus ADC and SPI/I²C device stubs.
+ 16 MHz, 32 KB flash, 2 KB SRAM."""
+
+ def __init__(self) -> None:
+ super().__init__(_native.lib().a8s_create_uno())
+ self.port_b = Port(self, 0)
+ self.port_c = Port(self, 1)
+ self.port_d = Port(self, 2)
+ self.serial = Serial(self, 0)
+ self.adc = Adc(self)
+ self.spi = Spi(self)
+ self.twi = Twi(self)
+
+
+class ArduinoMega(Simulation):
+ """ATmega2560: ports A..L, timers 0..5, USART0..3."""
+
+ _PORT_LETTERS = "ABCDEFGHJKL"
+
+ def __init__(self) -> None:
+ super().__init__(_native.lib().a8s_create_mega())
+ for i, letter in enumerate(self._PORT_LETTERS):
+ setattr(self, f"port_{letter.lower()}", Port(self, i))
+ self.serial = Serial(self, 0)
+ self.serial0 = Serial(self, 0)
+ self.serial1 = Serial(self, 1)
+ self.serial2 = Serial(self, 2)
+ self.serial3 = Serial(self, 3)
+
+
+class ATtiny85(Simulation):
+ """ATtiny85: port B, timers 0/1, no USART."""
+
+ def __init__(self) -> None:
+ super().__init__(_native.lib().a8s_create_attiny85())
+ self.port_b = Port(self, 0)
+
+
+class ATtinyX4(Simulation):
+ """ATtiny24/44/84 (avr25): ports A and B. GPIO-focused. flash/sram pick the variant."""
+
+ def __init__(self, flash: int = 0x2000, sram: int = 512) -> None:
+ super().__init__(_native.lib().a8s_create_attinyx4(flash, sram))
+ self.port_a = Port(self, 0)
+ self.port_b = Port(self, 1)
+
+
+class ATtiny13(Simulation):
+ """ATtiny13/13A (avr25): port B only. GPIO-focused."""
+
+ def __init__(self) -> None:
+ super().__init__(_native.lib().a8s_create_attiny13())
+ self.port_b = Port(self, 0)
+
+
+# PyMCU `target` (chip) -> simulation factory. The ATmegaX8 family shares the ATmega328P
+# register map, so smaller siblings run correctly in the 328P session (its 32 KB flash is a
+# superset). ATtiny 25/45/85 share the ATtiny85 peripheral layout.
+_X8_FAMILY = frozenset({
+ "atmega48", "atmega48p", "atmega88", "atmega88p",
+ "atmega168", "atmega168p", "atmega328", "atmega328p",
+})
+_ATTINY_X5 = frozenset({"attiny25", "attiny45", "attiny85"})
+# ATtiny24/44/84 share ports A+B; the value is (flash, sram) for the variant.
+_ATTINY_X4 = {"attiny84": (0x2000, 512), "attiny44": (0x1000, 256), "attiny24": (0x0800, 128)}
+_ATTINY13 = frozenset({"attiny13", "attiny13a"})
+
+# Targets known to PyMCU but without a faithful avr8sharp preset yet (see README/coverage notes).
+_UNSUPPORTED_TARGETS = frozenset({
+ "atmega32u4", # Leonardo: distinct port/timer/USART layout, no preset
+ "attiny2313", "attiny4313", # USART parts; preset is a follow-up
+})
+
+
+def board_for_target(target: str) -> Simulation:
+ """Returns a simulation matching a PyMCU ``[tool.pymcu] target`` chip name.
+
+ Raises :class:`Avr8SharpError` for targets without a faithful preset yet (e.g. atmega32u4).
+ """
+ name = target.strip().lower()
+ if name in _X8_FAMILY:
+ return ArduinoUno()
+ if name == "atmega2560":
+ return ArduinoMega()
+ if name in _ATTINY_X5:
+ return ATtiny85()
+ if name in _ATTINY_X4:
+ flash, sram = _ATTINY_X4[name]
+ return ATtinyX4(flash, sram)
+ if name in _ATTINY13:
+ return ATtiny13()
+ if name in _UNSUPPORTED_TARGETS:
+ raise Avr8SharpError(
+ f"No avr8sharp simulation preset for target '{target}' yet. "
+ f"Supported: ATmegaX8 family (48/88/168/328), atmega2560, attiny25/45/85."
+ )
+ raise Avr8SharpError(f"Unknown PyMCU target '{target}'.")
+
+
+# Common board aliases -> factory.
+_BOARD_ALIASES = {
+ "uno": ArduinoUno,
+ "arduino_uno": ArduinoUno,
+ "mega": ArduinoMega,
+ "arduino_mega": ArduinoMega,
+ "attiny85": ATtiny85,
+}
+
+
+def board(name: str) -> Simulation:
+ """Returns a simulation for a board alias (``uno``/``mega``/``attiny85``) or a PyMCU target
+ chip name (``atmega328p``, ``atmega2560``, ...)."""
+ key = name.strip().lower()
+ if key in _BOARD_ALIASES:
+ return _BOARD_ALIASES[key]()
+ return board_for_target(key)
+
+
+def selftest() -> int:
+ """Returns the low byte of the native self-test (42 when the engine is healthy)."""
+ return _native.lib().a8s_selftest() & 0xFF
diff --git a/bindings/python/tests/fixtures/serial_hello.hex b/bindings/python/tests/fixtures/serial_hello.hex
new file mode 100644
index 0000000..2114f17
--- /dev/null
+++ b/bindings/python/tests/fixtures/serial_hello.hex
@@ -0,0 +1,95 @@
+:100000000C9435000C945D000C945D000C945D0024
+:100010000C945D000C945D000C945D000C945D00EC
+:100020000C945D000C945D000C945D000C945D00DC
+:100030000C945D000C945D000C945D000C945D00CC
+:100040000C946C010C945D000C94DC010C94B601D2
+:100050000C945D000C945D000C945D000C945D00AC
+:100060000C945D000C945D00A10211241FBECFEF23
+:10007000D8E0DEBFCDBF11E0A0E0B1E0ECEAF5E0F2
+:1000800002C005900D92A632B107D9F721E0A6E291
+:10009000B1E001C01D92AC3CB207E1F710E0C5E34E
+:1000A000D0E004C02197FE010E94CE02C433D107E4
+:1000B000C9F70E940E020C94D4020C940000AF9277
+:1000C000BF92CF92DF92EF92FF920F931F93CF9345
+:1000D000DF936C017B018B01040F151FEB015E01A7
+:1000E000AE18BF08C017D10759F06991D601ED913C
+:1000F000FC910190F081E02DC6010995892B79F7DB
+:10010000C501DF91CF911F910F91FF90EF90DF908C
+:10011000CF90BF90AF900895FC01538D448D252F53
+:1001200030E0842F90E0821B930B541710F0CF9691
+:10013000089501970895FC01918D828D981761F0C3
+:10014000A28DAE0FBF2FB11D5D968C91928D9F5FDA
+:100150009F73928F90E008958FEF9FEF0895FC01B9
+:10016000918D828D981731F0828DE80FF11D858D6C
+:1001700090E008958FEF9FEF0895FC01918D228DFF
+:10018000892F90E0805C9F4F821B91098F73992784
+:10019000089586E291E00E94BD0021E0892B09F4D8
+:1001A00020E0822F089580E090E0892B29F00E94C2
+:1001B000C90081110C9400000895FC01A48DA80FC2
+:1001C000B92FB11DA35ABF4F2C91848D90E0019699
+:1001D0008F739927848FA689B7892C93A089B189B9
+:1001E0008C91837080648C93938D848D981306C05A
+:1001F0000288F389E02D80818F7D80830895EF92BE
+:10020000FF920F931F93CF93DF93EC0181E0888FD0
+:100210009B8D8C8D98131AC0E889F989808185FFA0
+:1002200015C09FB7F894EE89FF896083E889F98942
+:1002300080818370806480839FBF81E090E0DF9144
+:10024000CF911F910F91FF90EF900895F62E0B8D97
+:1002500010E00F5F1F4F0F731127E02E8C8D8E1152
+:100260000CC00FB607FCFACFE889F989808185FFB9
+:10027000F5CFCE010E94DD00F1CFEB8DEC0FFD2F0D
+:10028000F11DE35AFF4FF0829FB7F8940B8FEA8974
+:10029000FB8980818062CFCFCF93DF93EC01888D83
+:1002A0008823B9F0AA89BB89E889F9898C9185FDF1
+:1002B00003C0808186FD0DC00FB607FCF7CF8C917F
+:1002C00085FFF2CF808185FFEDCFCE010E94DD005A
+:1002D000E9CFDF91CF9108951F920F920FB60F9241
+:1002E00011242F933F938F939F93AF93BF9380914C
+:1002F000C8019091C901A091CA01B091CB01309180
+:10030000C70123E0230F2D3758F50196A11DB11D1C
+:100310002093C7018093C8019093C901A093CA019B
+:10032000B093CB018091C3019091C401A091C5010C
+:10033000B091C6010196A11DB11D8093C301909398
+:10034000C401A093C501B093C601BF91AF919F9125
+:100350008F913F912F910F900FBE0F901F90189586
+:1003600026E8230F0296A11DB11DD2CF1F920F9236
+:100370000FB60F9211242F933F934F935F936F9378
+:100380007F938F939F93AF93BF93EF93FF9386E2F7
+:1003900091E00E94DD00FF91EF91BF91AF919F919D
+:1003A0008F917F916F915F914F913F912F910F901E
+:1003B0000FBE0F901F9018951F920F920FB60F92BD
+:1003C00011242F938F939F93EF93FF93E091360126
+:1003D000F09137018081E0913C01F0913D0182FD77
+:1003E0001BC0908180913F018F5F8F7320914001EE
+:1003F000821741F0E0913F01F0E0EA5DFE4F958FFA
+:1004000080933F01FF91EF919F918F912F910F90DA
+:100410000FBE0F901F9018958081F4CF789484B50B
+:10042000826084BD84B5816084BD85B5826085BDF0
+:1004300085B5816085BD80916E00816080936E007E
+:100440001092810080918100826080938100809170
+:100450008100816080938100809180008160809321
+:1004600080008091B10084608093B1008091B000E1
+:1004700081608093B00080917A00846080937A00DC
+:1004800080917A00826080937A0080917A00816006
+:1004900080937A0080917A00806880937A0010922D
+:1004A000C100E0913601F091370182E08083E09154
+:1004B0003201F09133011082E0913401F091350165
+:1004C00080E1808310923E01E0913A01F0913B017E
+:1004D00086E08083E0913801F0913901808180616C
+:1004E0008083E0913801F0913901808188608083B8
+:1004F000E0913801F0913901808180688083E0913A
+:100500003801F091390180818F7D80834FE050E088
+:1005100062E171E086E291E00E945F0042E050E01B
+:1005200062E271E086E291E00E945F00C0E0D0E00C
+:100530002097F1F30E94C9008823D1F30E940000A4
+:10054000F7CFE6E2F1E01382128288EE93E0A0E0BA
+:10055000B0E084839583A683B78384E091E09183A0
+:10056000808385EC90E09587848784EC90E0978782
+:10057000868780EC90E0918B808B81EC90E0938B70
+:10058000828B82EC90E0958B848B86EC90E0978B4D
+:10059000868B118E128E138E148E0895EE0FFF1F10
+:0C05A0000590F491E02D0994F894FFCF31
+:1005AC0000000000FF005F008C004C01BD009B00B0
+:1005BC00AF0048656C6C6F2066726F6D20556E6F66
+:0605CC0021000D0A0000F1
+:00000001FF
\ No newline at end of file
diff --git a/bindings/python/tests/test_smoke.py b/bindings/python/tests/test_smoke.py
new file mode 100644
index 0000000..f818562
--- /dev/null
+++ b/bindings/python/tests/test_smoke.py
@@ -0,0 +1,87 @@
+"""End-to-end smoke tests for the avr8sharp bindings.
+
+Run against a freshly published native library via::
+
+ AVR8SHARP_LIBRARY=/path/to/Avr8Sharp.Native.dylib \
+ PYTHONPATH=src python -m pytest tests/
+
+or against an installed wheel (the library is bundled, no env var needed)::
+
+ python -m pytest tests/
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import avr8sharp as a
+
+FIXTURES = Path(__file__).parent / "fixtures"
+
+# Sets PB5 (Arduino pin 13) as output and drives it high, then BREAK.
+SET_PB5_HIGH = "ldi r16, 0x20\nout 0x04, r16\nout 0x05, r16\nbreak\n"
+
+
+def test_selftest():
+ assert a.selftest() == 42
+
+
+def test_assemble_run_and_gpio():
+ uno = a.ArduinoUno()
+ uno.with_asm(SET_PB5_HIGH).run_to_break()
+ assert uno.port_b.pin_high(5)
+ assert not uno.port_b.pin_high(4)
+ assert uno.cpu.reg(16) == 0x20
+ assert uno.cpu.cycles == 3
+ uno.close()
+
+
+def test_snapshot_restore_round_trips_state():
+ uno = a.ArduinoUno()
+ uno.snapshot()
+ uno.with_asm(SET_PB5_HIGH).run_to_break()
+ assert uno.port_b.pin_high(5)
+ assert uno.cpu.cycles > 0
+
+ uno.restore()
+ assert uno.cpu.cycles == 0
+ assert not uno.port_b.pin_high(5)
+ uno.close()
+
+
+def test_invalid_assembly_raises():
+ with pytest.raises(a.Avr8SharpError):
+ a.ArduinoUno().with_asm("this is not a valid avr program\n")
+
+
+def test_context_manager_closes():
+ with a.ArduinoUno() as uno:
+ uno.with_asm(SET_PB5_HIGH).run_to_break()
+ assert uno.port_b.pin_high(5)
+
+
+def test_serial_capture_from_hex_firmware():
+ hex_content = (FIXTURES / "serial_hello.hex").read_text()
+ uno = a.ArduinoUno().with_hex(hex_content)
+ uno.run_ms(100)
+ assert "Hello from Uno!" in uno.serial.text
+ assert any("Hello from Uno!" in line for line in uno.serial.lines)
+ uno.close()
+
+
+def test_run_until_serial_stops_early():
+ hex_content = (FIXTURES / "serial_hello.hex").read_text()
+ uno = a.ArduinoUno().with_hex(hex_content)
+ uno.run_until_serial(uno.serial, "Hello", max_ms=2000)
+ assert "Hello" in uno.serial.text
+ uno.close()
+
+
+def test_custom_session_wiring():
+ sim = a.Simulation.create(flash=0x8000, sram=2048)
+ port_b = sim.add_gpio(0)
+ sim.with_asm(SET_PB5_HIGH).run_to_break()
+ assert port_b.pin_high(5)
+ sim.close()
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..78fd20e
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.107",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/src/Avr8Sharp.Native/Avr8Sharp.Native.csproj b/src/Avr8Sharp.Native/Avr8Sharp.Native.csproj
new file mode 100644
index 0000000..077caf6
--- /dev/null
+++ b/src/Avr8Sharp.Native/Avr8Sharp.Native.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ default
+
+
+ true
+ Shared
+
+
+
+
+
+
+
diff --git a/src/Avr8Sharp.Native/DeviceStubs.cs b/src/Avr8Sharp.Native/DeviceStubs.cs
new file mode 100644
index 0000000..63e9949
--- /dev/null
+++ b/src/Avr8Sharp.Native/DeviceStubs.cs
@@ -0,0 +1,62 @@
+using AVR8Sharp.Core.Peripherals;
+
+namespace Avr8Sharp.Native;
+
+///
+/// A canned SPI slave on the bus. Captures every byte the firmware clocks out (MOSI) and replays
+/// a pre-loaded response queue back (MISO) — the standard way to test SPI sensor/display drivers
+/// without modelling a real device. Returns 0xFF once the queue is drained (idle MISO line).
+///
+internal sealed class SpiDeviceStub
+{
+ public readonly List Mosi = new();
+ public readonly Queue Responses = new();
+
+ public int Transfer(byte outgoing)
+ {
+ Mosi.Add(outgoing);
+ return Responses.Count > 0 ? Responses.Dequeue() : 0xFF;
+ }
+}
+
+///
+/// A single-address I²C slave. ACKs transactions addressed to (when
+/// ), logs the bytes the firmware writes, and replays a pre-loaded response
+/// queue on reads (0xFF when drained). Covers the common "firmware talks to one sensor" test.
+///
+internal sealed class TwiDeviceStub(AvrTwi twi) : ITwiEventHandler
+{
+ public byte Address; // 7-bit slave address this device answers to
+ public bool Present; // whether a device is connected at Address
+
+ public readonly List Writes = new();
+ public readonly Queue Responses = new();
+
+ private bool _selected;
+
+ public void Start(bool repeated) => twi.CompleteStart();
+
+ public void Stop()
+ {
+ _selected = false;
+ twi.CompleteStop();
+ }
+
+ public void ConnectToSlave(byte address, bool write)
+ {
+ _selected = Present && address == Address;
+ twi.CompleteConnect(_selected);
+ }
+
+ public void WriteByte(byte data)
+ {
+ if (_selected) Writes.Add(data);
+ twi.CompleteWrite(_selected);
+ }
+
+ public void ReadByte(bool ack)
+ {
+ var b = Responses.Count > 0 ? Responses.Dequeue() : (byte)0xFF;
+ twi.CompleteRead(b);
+ }
+}
diff --git a/src/Avr8Sharp.Native/Exports.cs b/src/Avr8Sharp.Native/Exports.cs
new file mode 100644
index 0000000..2427d38
--- /dev/null
+++ b/src/Avr8Sharp.Native/Exports.cs
@@ -0,0 +1,450 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using AVR8Sharp.Core.Peripherals;
+using Avr8Sharp.TestKit;
+using Avr8Sharp.TestKit.Boards;
+
+namespace Avr8Sharp.Native;
+
+///
+/// Flat C ABI exported by the Native AOT shared library and consumed from Python via ctypes.
+///
+/// Every entry point is coarse-grained: a single call crosses the managed boundary once and runs
+/// many thousands of CPU cycles entirely on the native side. The per-instruction loop is never
+/// exposed, so FFI overhead is amortised to ~0 and the emulator keeps its full speed.
+///
+///
+/// Sessions are opaque handles (a pinned to a ).
+/// Functions that can fail return an int status (0 = ok, negative = error); the message is then
+/// retrievable via .
+///
+///
+public static unsafe class Exports
+{
+ private const int Ok = 0;
+ private const int ErrBadHandle = -1;
+ private const int ErrException = -2;
+ private const int ErrBadIndex = -3;
+
+ private const ushort BreakOpcode = 0x9598;
+
+ // ── Handle helpers ────────────────────────────────────────────────────────
+
+ private static NativeSession? Get(IntPtr h)
+ => h == IntPtr.Zero ? null : GCHandle.FromIntPtr(h).Target as NativeSession;
+
+ private static IntPtr Wrap(NativeSession s)
+ => GCHandle.ToIntPtr(GCHandle.Alloc(s));
+
+ /// Runs guarded against bad handles and exceptions.
+ private static int Run(IntPtr h, Action body)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ try { body(s); return Ok; }
+ catch (Exception e) { s.LastError = e.Message; return ErrException; }
+ }
+
+ private static string Utf8(byte* p, int len) => Encoding.UTF8.GetString(p, len);
+
+ /// Copies into the caller buffer, returning the full length.
+ private static int CopyOut(byte[] src, byte* outBuf, int cap)
+ {
+ var n = Math.Min(src.Length, cap);
+ if (n > 0) Marshal.Copy(src, 0, (IntPtr)outBuf, n);
+ return src.Length;
+ }
+
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_create")]
+ public static IntPtr Create(int flash, int sram)
+ => Wrap(new NativeSession(AvrTestSimulation.Create(flash, sram)));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_create_uno")]
+ public static IntPtr CreateUno()
+ {
+ var uno = new ArduinoUnoSimulation();
+ var s = new NativeSession(uno);
+ s.Ports.AddRange([uno.PortB, uno.PortC, uno.PortD]);
+ s.Timers.AddRange([uno.Timer0, uno.Timer1, uno.Timer2]);
+ s.Serials.Add(uno.Serial);
+ WireAtmega328Analog(uno, s);
+ return Wrap(s);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_create_mega")]
+ public static IntPtr CreateMega()
+ {
+ var m = new ArduinoMegaSimulation();
+ var s = new NativeSession(m);
+ s.Ports.AddRange([m.PortA, m.PortB, m.PortC, m.PortD, m.PortE, m.PortF,
+ m.PortG, m.PortH, m.PortJ, m.PortK, m.PortL]);
+ s.Timers.AddRange([m.Timer0, m.Timer1, m.Timer2, m.Timer3, m.Timer4, m.Timer5]);
+ s.Serials.AddRange([m.Serial0, m.Serial1, m.Serial2, m.Serial3]);
+ return Wrap(s);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_create_attiny85")]
+ public static IntPtr CreateAttiny85()
+ {
+ var t = new ATtiny85Simulation();
+ var s = new NativeSession(t);
+ s.Ports.Add(t.PortB);
+ s.Timers.AddRange([t.Timer0, t.Timer1]);
+ return Wrap(s);
+ }
+
+ /// ATtiny24/44/84 (avr25): port indices 0=A, 1=B. flash/sram pick the variant.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_create_attinyx4")]
+ public static IntPtr CreateAttinyX4(int flash, int sram)
+ {
+ var t = new ATtinyX4Simulation(flash, sram);
+ var s = new NativeSession(t);
+ s.Ports.AddRange([t.PortA, t.PortB]);
+ return Wrap(s);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_create_attiny13")]
+ public static IntPtr CreateAttiny13()
+ {
+ var t = new ATtiny13Simulation();
+ var s = new NativeSession(t);
+ s.Ports.Add(t.PortB);
+ return Wrap(s);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_destroy")]
+ public static void Destroy(IntPtr h)
+ {
+ if (h == IntPtr.Zero) return;
+ var gch = GCHandle.FromIntPtr(h);
+ if (gch.IsAllocated) gch.Free();
+ }
+
+ // ── Program loading ───────────────────────────────────────────────────────
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_with_frequency")]
+ public static int WithFrequency(IntPtr h, uint hz)
+ => Run(h, s => s.Sim.WithFrequency(hz));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_with_hex")]
+ public static int WithHex(IntPtr h, byte* hex, int len)
+ => Run(h, s => s.Sim.WithHex(Utf8(hex, len)));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_with_asm")]
+ public static int WithAsm(IntPtr h, byte* asm, int len)
+ => Run(h, s => s.Sim.WithAsm(Utf8(asm, len)));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_with_program")]
+ public static int WithProgram(IntPtr h, byte* bytes, int len)
+ => Run(h, s =>
+ {
+ var buf = new byte[len];
+ Marshal.Copy((IntPtr)bytes, buf, 0, len);
+ s.Sim.WithProgram(buf);
+ });
+
+ // ── Generic peripheral wiring (custom sessions, ATmega328P layout) ─────────
+
+ /// Adds GPIO port 0=B, 1=C, 2=D. Returns the new port index, or a negative error.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_add_gpio")]
+ public static int AddGpio(IntPtr h, int which)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ try
+ {
+ var cfg = which switch
+ {
+ 0 => AvrIoPort.PortBConfig,
+ 1 => AvrIoPort.PortCConfig,
+ 2 => AvrIoPort.PortDConfig,
+ _ => (AvrPortConfig?)null,
+ };
+ if (cfg is null) return ErrBadIndex;
+ s.Sim.AddGpio(cfg, out var port);
+ s.Ports.Add(port);
+ return s.Ports.Count - 1;
+ }
+ catch (Exception e) { s.LastError = e.Message; return ErrException; }
+ }
+
+ /// Adds USART0 with a capture probe. Returns the new serial index.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_add_usart0")]
+ public static int AddUsart0(IntPtr h)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ try
+ {
+ s.Sim.AddUsart(AvrUsart.Usart0Config, out var serial);
+ s.Serials.Add(serial);
+ return s.Serials.Count - 1;
+ }
+ catch (Exception e) { s.LastError = e.Message; return ErrException; }
+ }
+
+ /// Adds timer 0/1/2. Returns the new timer index.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_add_timer")]
+ public static int AddTimer(IntPtr h, int which)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ try
+ {
+ var cfg = which switch
+ {
+ 0 => AvrTimer.Timer0Config,
+ 1 => AvrTimer.Timer1Config,
+ 2 => AvrTimer.Timer2Config,
+ _ => (AvrTimerConfig?)null,
+ };
+ if (cfg is null) return ErrBadIndex;
+ s.Sim.AddTimer(cfg, out var timer);
+ s.Timers.Add(timer);
+ return s.Timers.Count - 1;
+ }
+ catch (Exception e) { s.LastError = e.Message; return ErrException; }
+ }
+
+ // ── Execution ─────────────────────────────────────────────────────────────
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_run_cycles")]
+ public static int RunCycles(IntPtr h, long cycles)
+ => Run(h, s => s.Sim.RunCycles(cycles));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_run_ms")]
+ public static int RunMs(IntPtr h, double ms)
+ => Run(h, s => s.Sim.RunMilliseconds(ms));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_run_instructions")]
+ public static int RunInstructions(IntPtr h, int count)
+ => Run(h, s => s.Sim.RunInstructions(count));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_run_to_break")]
+ public static int RunToBreak(IntPtr h, int maxInstructions)
+ => Run(h, s => s.Sim.RunToBreak(maxInstructions));
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_run_until_serial")]
+ public static int RunUntilSerial(IntPtr h, int serialIdx, byte* text, int len, double maxMs)
+ => Run(h, s =>
+ {
+ if (serialIdx < 0 || serialIdx >= s.Serials.Count)
+ throw new ArgumentOutOfRangeException(nameof(serialIdx));
+ s.Sim.RunUntilSerial(s.Serials[serialIdx], Utf8(text, len), maxMs);
+ });
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_run_until_serial_bytes")]
+ public static int RunUntilSerialBytes(IntPtr h, int serialIdx, int byteCount, double maxMs)
+ => Run(h, s =>
+ {
+ if (serialIdx < 0 || serialIdx >= s.Serials.Count)
+ throw new ArgumentOutOfRangeException(nameof(serialIdx));
+ s.Sim.RunUntilSerialBytes(s.Serials[serialIdx], byteCount, maxMs);
+ });
+
+ // ── Serial observation ────────────────────────────────────────────────────
+
+ /// Copies the raw captured bytes of serial into the caller
+ /// buffer and returns the full byte count (which may exceed ).
+ [UnmanagedCallersOnly(EntryPoint = "a8s_serial_read")]
+ public static int SerialRead(IntPtr h, int idx, byte* outBuf, int cap)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ if (idx < 0 || idx >= s.Serials.Count) return ErrBadIndex;
+ return CopyOut(s.Serials[idx].Bytes, outBuf, cap);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_serial_byte_count")]
+ public static int SerialByteCount(IntPtr h, int idx)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ if (idx < 0 || idx >= s.Serials.Count) return ErrBadIndex;
+ return s.Serials[idx].ByteCount;
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_serial_clear")]
+ public static int SerialClear(IntPtr h, int idx)
+ => Run(h, s =>
+ {
+ if (idx < 0 || idx >= s.Serials.Count)
+ throw new ArgumentOutOfRangeException(nameof(idx));
+ s.Serials[idx].Clear();
+ });
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_serial_inject")]
+ public static int SerialInject(IntPtr h, int idx, byte value)
+ => Run(h, s =>
+ {
+ if (idx < 0 || idx >= s.Serials.Count)
+ throw new ArgumentOutOfRangeException(nameof(idx));
+ s.Serials[idx].InjectByte(value);
+ });
+
+ // ── GPIO observation ──────────────────────────────────────────────────────
+
+ /// Returns the of on port
+ /// (0=Low,1=High,2=Input,3=InputPullup), or negative on error.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_gpio_pin")]
+ public static int GpioPin(IntPtr h, int portIdx, int pin)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ if (portIdx < 0 || portIdx >= s.Ports.Count) return ErrBadIndex;
+ return (int)s.Ports[portIdx].GetPinState((byte)pin);
+ }
+
+ // ── CPU / memory observation ──────────────────────────────────────────────
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_cpu_pc")]
+ public static uint CpuPc(IntPtr h) => Get(h) is { } s ? s.Sim.Cpu.Pc : 0u;
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_cpu_sp")]
+ public static uint CpuSp(IntPtr h) => Get(h) is { } s ? s.Sim.Cpu.Sp : 0u;
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_cpu_cycles")]
+ public static ulong CpuCycles(IntPtr h) => Get(h) is { } s ? s.Sim.Cpu.Cycles : 0;
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_cpu_sreg")]
+ public static int CpuSreg(IntPtr h) => Get(h) is { } s ? s.Sim.Cpu.Sreg : ErrBadHandle;
+
+ /// Reads data-space byte at (registers, I/O, SRAM), or -1.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_read_data")]
+ public static int ReadData(IntPtr h, int addr)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ return s.Sim.Cpu.ReadData((ushort)addr);
+ }
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_write_data")]
+ public static int WriteData(IntPtr h, int addr, byte value)
+ => Run(h, s => s.Sim.Cpu.WriteData((ushort)addr, value));
+
+ // ── Reset / snapshot (mirrors PyMCU's SimSession fast-reset) ───────────────
+
+ [UnmanagedCallersOnly(EntryPoint = "a8s_reset")]
+ public static int Reset(IntPtr h) => Run(h, s => s.Sim.Reset());
+
+ /// Captures the current Data array as the power-on snapshot for a8s_restore.
+ /// Call this right after creating a board, before loading firmware, to capture peripheral
+ /// power-on defaults (e.g. UCSRA bits) exactly like PyMCU's SimSession.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_snapshot")]
+ public static int Snapshot(IntPtr h)
+ => Run(h, s => s.Snapshot = (byte[])s.Sim.Data.Clone());
+
+ /// Restores the snapshot, resets all timers/CPU, zeroes the cycle counter, and clears
+ /// all serial probes — the full PyMCU SimSession per-test reset, done natively.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_restore")]
+ public static int Restore(IntPtr h)
+ => Run(h, s =>
+ {
+ if (s.Snapshot is null)
+ throw new InvalidOperationException("a8s_restore called before a8s_snapshot.");
+ Array.Copy(s.Snapshot, s.Sim.Data, s.Snapshot.Length);
+ foreach (var t in s.Timers) t.Reset();
+ s.Sim.Cpu.Reset();
+ s.Sim.Cpu.Cycles = 0;
+ foreach (var probe in s.Serials) probe.Clear();
+ if (s.Spi is not null) { s.Spi.Mosi.Clear(); s.Spi.Responses.Clear(); }
+ if (s.Twi is not null) { s.Twi.Writes.Clear(); s.Twi.Responses.Clear(); }
+ });
+
+ // ── Errors ────────────────────────────────────────────────────────────────
+
+ /// Copies the last error message (UTF-8) into the caller buffer; returns full length.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_last_error")]
+ public static int LastError(IntPtr h, byte* outBuf, int cap)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ return CopyOut(Encoding.UTF8.GetBytes(s.LastError), outBuf, cap);
+ }
+
+ // ── Analog/bus peripherals (ATmega328P-family sessions) ───────────────────
+
+ /// Wires ADC, an SPI bus stub, and a single-address I²C slave stub onto an
+ /// ATmega328P-style simulation, using the 328P peripheral configs.
+ private static void WireAtmega328Analog(AvrTestSimulation sim, NativeSession s)
+ {
+ sim.AddAdc(AvrAdc.AdcConfig, out var adc);
+ s.Adc = adc;
+
+ sim.AddSpi(AvrSpi.SpiConfig, out var spi);
+ var spiStub = new SpiDeviceStub();
+ spi.OnTransfer = spiStub.Transfer;
+ s.Spi = spiStub;
+
+ sim.AddTwi(AvrTwi.TwiConfig, out var twi);
+ var twiStub = new TwiDeviceStub(twi);
+ twi.EventHandler = twiStub;
+ s.Twi = twiStub;
+ }
+
+ /// Sets the analog voltage (volts) presented on ADC ,
+ /// so a firmware ADC read on that channel returns the corresponding value.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_adc_set_channel")]
+ public static int AdcSetChannel(IntPtr h, int channel, double volts)
+ => Run(h, s =>
+ {
+ if (s.Adc is null) throw new InvalidOperationException("Session has no ADC peripheral.");
+ if (channel < 0 || channel >= s.Adc.ChannelValues.Length)
+ throw new ArgumentOutOfRangeException(nameof(channel));
+ s.Adc.ChannelValues[channel] = volts;
+ });
+
+ /// Queues a byte for the SPI slave stub to return (MISO) on the next transfer.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_spi_queue_response")]
+ public static int SpiQueueResponse(IntPtr h, byte value)
+ => Run(h, s =>
+ {
+ if (s.Spi is null) throw new InvalidOperationException("Session has no SPI peripheral.");
+ s.Spi.Responses.Enqueue(value);
+ });
+
+ /// Copies the bytes the firmware has clocked out over SPI (MOSI) into the caller
+ /// buffer; returns the full count.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_spi_read_mosi")]
+ public static int SpiReadMosi(IntPtr h, byte* outBuf, int cap)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ if (s.Spi is null) return ErrBadIndex;
+ return CopyOut(s.Spi.Mosi.ToArray(), outBuf, cap);
+ }
+
+ /// Configures the I²C slave stub: ACK transactions to
+ /// (7-bit) when is non-zero.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_twi_set_slave")]
+ public static int TwiSetSlave(IntPtr h, int address, int present)
+ => Run(h, s =>
+ {
+ if (s.Twi is null) throw new InvalidOperationException("Session has no TWI peripheral.");
+ s.Twi.Address = (byte)address;
+ s.Twi.Present = present != 0;
+ });
+
+ /// Queues a byte for the I²C slave stub to return on the next firmware read.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_twi_queue_response")]
+ public static int TwiQueueResponse(IntPtr h, byte value)
+ => Run(h, s =>
+ {
+ if (s.Twi is null) throw new InvalidOperationException("Session has no TWI peripheral.");
+ s.Twi.Responses.Enqueue(value);
+ });
+
+ /// Copies the bytes the firmware has written to the I²C slave into the caller
+ /// buffer; returns the full count.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_twi_read_writes")]
+ public static int TwiReadWrites(IntPtr h, byte* outBuf, int cap)
+ {
+ if (Get(h) is not { } s) return ErrBadHandle;
+ if (s.Twi is null) return ErrBadIndex;
+ return CopyOut(s.Twi.Writes.ToArray(), outBuf, cap);
+ }
+
+ // ── AOT smoke test ────────────────────────────────────────────────────────
+
+ /// Self-contained sanity check: assembles and runs a tiny program, returns
+ /// (cycles << 8) | r16; a correct result has low byte 0x2A (42).
+ [UnmanagedCallersOnly(EntryPoint = "a8s_selftest")]
+ public static long SelfTest()
+ {
+ var sim = AvrTestSimulation.Create();
+ sim.WithAsm("ldi r16, 0x2a\nnop\nnop\nbreak\n").RunToBreak();
+ return ((long)sim.Cpu.Cycles << 8) | sim.Cpu.ReadData(16);
+ }
+}
diff --git a/src/Avr8Sharp.Native/NativeSession.cs b/src/Avr8Sharp.Native/NativeSession.cs
new file mode 100644
index 0000000..f2ee670
--- /dev/null
+++ b/src/Avr8Sharp.Native/NativeSession.cs
@@ -0,0 +1,34 @@
+using AVR8Sharp.Core.Peripherals;
+using Avr8Sharp.TestKit;
+using Avr8Sharp.TestKit.Probes;
+
+namespace Avr8Sharp.Native;
+
+///
+/// Managed state behind a single opaque handle handed to Python. Holds the simulation plus the
+/// peripherals registered against it, indexed by the small integer ids exposed across the C ABI
+/// (port 0..N, serial 0..N, timer 0..N). A
+/// keeps this object alive while Python owns the handle.
+///
+internal sealed class NativeSession(AvrTestSimulation sim)
+{
+ public readonly AvrTestSimulation Sim = sim;
+ public readonly List Ports = new();
+ public readonly List Serials = new();
+ public readonly List Timers = new();
+
+ /// ADC peripheral (ATmega328P-family sessions only); null otherwise.
+ public AvrAdc? Adc;
+
+ /// SPI master-bus stub: captures MOSI, replays a canned response queue.
+ public SpiDeviceStub? Spi;
+
+ /// I²C single-address slave stub: ACKs its address, logs writes, replays reads.
+ public TwiDeviceStub? Twi;
+
+ /// Power-on snapshot of the Data array, captured by a8s_snapshot.
+ public byte[]? Snapshot;
+
+ /// Last error message, retrievable via a8s_last_error.
+ public string LastError = "";
+}
diff --git a/src/Avr8Sharp.TestKit.Core/Avr8Sharp.TestKit.Core.csproj b/src/Avr8Sharp.TestKit.Core/Avr8Sharp.TestKit.Core.csproj
new file mode 100644
index 0000000..9701cbc
--- /dev/null
+++ b/src/Avr8Sharp.TestKit.Core/Avr8Sharp.TestKit.Core.csproj
@@ -0,0 +1,43 @@
+
+
+
+ net10.0
+ enable
+ enable
+ default
+
+
+ true
+
+
+ Avr8Sharp.TestKit.Core
+ AVR-8 Simulator Test Kit (Core)
+ Iván Montiel
+ Assertion-agnostic simulation harness (run loop, probes, board presets) for integration-testing compiled AVR programs. Shared by Avr8Sharp.TestKit and the Python bindings.
+ Copyright (c) Iván Montiel
+ https://github.com/begeistert/avr8sharp
+ Arduino;AVR;Simulator;Testing
+ MIT
+ 1.1.0-beta4
+ https://github.com/begeistert/avr8sharp
+ Iván Montiel
+ README.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avr8Sharp.TestKit/AvrMemoryView.cs b/src/Avr8Sharp.TestKit.Core/AvrMemoryView.cs
similarity index 100%
rename from src/Avr8Sharp.TestKit/AvrMemoryView.cs
rename to src/Avr8Sharp.TestKit.Core/AvrMemoryView.cs
diff --git a/src/Avr8Sharp.TestKit/AvrTestSimulation.cs b/src/Avr8Sharp.TestKit.Core/AvrTestSimulation.cs
similarity index 100%
rename from src/Avr8Sharp.TestKit/AvrTestSimulation.cs
rename to src/Avr8Sharp.TestKit.Core/AvrTestSimulation.cs
diff --git a/src/Avr8Sharp.TestKit.Core/Boards/ATtiny13Simulation.cs b/src/Avr8Sharp.TestKit.Core/Boards/ATtiny13Simulation.cs
new file mode 100644
index 0000000..634e40f
--- /dev/null
+++ b/src/Avr8Sharp.TestKit.Core/Boards/ATtiny13Simulation.cs
@@ -0,0 +1,32 @@
+using AVR8Sharp.Core.Peripherals;
+
+namespace Avr8Sharp.TestKit.Boards;
+
+///
+/// Pre-configured simulation for the ATtiny13/13A (avr25 core).
+///
+/// One GPIO port: Port B (PB0–PB5, PB5 is RESET by default). 1 KB flash, 64 B SRAM, 9.6 MHz
+/// internal RC by default (modelled at the configured frequency).
+///
+/// GPIO-focused preset: Timer0/ADC are not wired (use the generic helpers if needed).
+///
+public sealed class ATtiny13Simulation : AvrTestSimulation
+{
+ private const int Flash = 0x0400;
+ private const int Sram = 64;
+ private const uint Frequency = 9_600_000;
+
+ // Port B — PIN 0x36, DDR 0x37, PORT 0x38
+ private static readonly AvrPortConfig PortBConfig = new(pin: 0x36, ddr: 0x37, port: 0x38);
+
+ /// Port B — the only GPIO port (PB0–PB5).
+ public AvrIoPort PortB { get; }
+
+ public ATtiny13Simulation() : base(Flash, Sram)
+ {
+ WithFrequency(Frequency);
+ Cpu.StackLowLimit = 0x60;
+
+ AddGpio(PortBConfig, out var pb); PortB = pb;
+ }
+}
diff --git a/src/Avr8Sharp.TestKit/Boards/ATtiny85Simulation.cs b/src/Avr8Sharp.TestKit.Core/Boards/ATtiny85Simulation.cs
similarity index 100%
rename from src/Avr8Sharp.TestKit/Boards/ATtiny85Simulation.cs
rename to src/Avr8Sharp.TestKit.Core/Boards/ATtiny85Simulation.cs
diff --git a/src/Avr8Sharp.TestKit.Core/Boards/ATtinyX4Simulation.cs b/src/Avr8Sharp.TestKit.Core/Boards/ATtinyX4Simulation.cs
new file mode 100644
index 0000000..1dd28e8
--- /dev/null
+++ b/src/Avr8Sharp.TestKit.Core/Boards/ATtinyX4Simulation.cs
@@ -0,0 +1,45 @@
+using AVR8Sharp.Core.Peripherals;
+
+namespace Avr8Sharp.TestKit.Boards;
+
+///
+/// Pre-configured simulation for the ATtiny24/44/84 family (avr25 core).
+///
+/// Two GPIO ports are wired: Port A (PA0–PA7) and Port B (PB0–PB3). The variants differ only
+/// in flash/SRAM size — use , , .
+///
+///
+/// GPIO-focused preset: timers, ADC and USI are not wired (add them via the generic
+/// helpers if a test needs them). 8 MHz internal RC.
+///
+///
+public sealed class ATtinyX4Simulation : AvrTestSimulation
+{
+ private const uint Frequency = 8_000_000;
+
+ // Port A — PIN 0x39, DDR 0x3A, PORT 0x3B
+ private static readonly AvrPortConfig PortAConfig = new(pin: 0x39, ddr: 0x3A, port: 0x3B);
+ // Port B — PIN 0x36, DDR 0x37, PORT 0x38 (PB3 is RESET by default)
+ private static readonly AvrPortConfig PortBConfig = new(pin: 0x36, ddr: 0x37, port: 0x38);
+
+ /// Port A — PA0–PA7.
+ public AvrIoPort PortA { get; }
+ /// Port B — PB0–PB3.
+ public AvrIoPort PortB { get; }
+
+ public ATtinyX4Simulation(int flash, int sram) : base(flash, sram)
+ {
+ WithFrequency(Frequency);
+ Cpu.StackLowLimit = 0x60; // SRAM starts at 0x60 on ATtiny parts
+
+ AddGpio(PortAConfig, out var pa); PortA = pa;
+ AddGpio(PortBConfig, out var pb); PortB = pb;
+ }
+
+ /// ATtiny84 — 8 KB flash, 512 B SRAM.
+ public static ATtinyX4Simulation ATtiny84() => new(0x2000, 512);
+ /// ATtiny44 — 4 KB flash, 256 B SRAM.
+ public static ATtinyX4Simulation ATtiny44() => new(0x1000, 256);
+ /// ATtiny24 — 2 KB flash, 128 B SRAM.
+ public static ATtinyX4Simulation ATtiny24() => new(0x0800, 128);
+}
diff --git a/src/Avr8Sharp.TestKit/Boards/ArduinoMegaSimulation.cs b/src/Avr8Sharp.TestKit.Core/Boards/ArduinoMegaSimulation.cs
similarity index 100%
rename from src/Avr8Sharp.TestKit/Boards/ArduinoMegaSimulation.cs
rename to src/Avr8Sharp.TestKit.Core/Boards/ArduinoMegaSimulation.cs
diff --git a/src/Avr8Sharp.TestKit/Boards/ArduinoUnoSimulation.cs b/src/Avr8Sharp.TestKit.Core/Boards/ArduinoUnoSimulation.cs
similarity index 100%
rename from src/Avr8Sharp.TestKit/Boards/ArduinoUnoSimulation.cs
rename to src/Avr8Sharp.TestKit.Core/Boards/ArduinoUnoSimulation.cs
diff --git a/src/Avr8Sharp.TestKit/Probes/SerialProbe.cs b/src/Avr8Sharp.TestKit.Core/Probes/SerialProbe.cs
similarity index 100%
rename from src/Avr8Sharp.TestKit/Probes/SerialProbe.cs
rename to src/Avr8Sharp.TestKit.Core/Probes/SerialProbe.cs
diff --git a/src/Avr8Sharp.TestKit/Avr8Sharp.TestKit.csproj b/src/Avr8Sharp.TestKit/Avr8Sharp.TestKit.csproj
index ec5a85f..dda1c22 100644
--- a/src/Avr8Sharp.TestKit/Avr8Sharp.TestKit.csproj
+++ b/src/Avr8Sharp.TestKit/Avr8Sharp.TestKit.csproj
@@ -14,7 +14,7 @@
https://github.com/begeistert/avr8sharp
Arduino;AVR;Simulator;Testing;FluentAssertions
MIT
- 1.1.0-beta2
+ 1.1.0-beta4
https://github.com/begeistert/avr8sharp
Iván Montiel
README.md
@@ -26,7 +26,10 @@
-
+
+
diff --git a/src/Avr8Sharp/Avr8Sharp.csproj b/src/Avr8Sharp/Avr8Sharp.csproj
index d00be12..54dac84 100644
--- a/src/Avr8Sharp/Avr8Sharp.csproj
+++ b/src/Avr8Sharp/Avr8Sharp.csproj
@@ -15,7 +15,7 @@
https://github.com/begeistert/avr8sharp
Arduino;AVR;Simulator
MIT
- 1.1.0-beta3
+ 1.1.0-beta4
https://github.com/begeistert/avr8sharp
Iván Montiel
README.md