From f2ff7f55376cbc6091657895ebe10b704e94fbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Montiel=20Cardona?= Date: Fri, 26 Jun 2026 17:40:59 -0600 Subject: [PATCH] feat: Python bindings + TestKit.Core split, release 1.1.0-beta4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the assertion-agnostic simulation harness (run loop, probes, board presets) into a new Avr8Sharp.TestKit.Core project so it can be reused by both the FluentAssertions-based TestKit and a Native AOT C-ABI layer without dragging FluentAssertions into the AOT image. New components: - Avr8Sharp.TestKit.Core (MIT): shared harness, now the dependency of TestKit. Adds ATtinyX4 (24/44/84) and ATtiny13 board presets. - Avr8Sharp.Native: Native AOT shared library exporting a flat C-ABI (a8s_*, [UnmanagedCallersOnly]) over the harness, handle-based via GCHandle. - bindings/python (BUSL-1.1): self-contained `avr8sharp` PyPI package — ctypes loader + fluent API, packaged with hatchling (no .NET runtime for users). Release: - Bump Avr8Sharp, Avr8Sharp.TestKit and Avr8Sharp.TestKit.Core to 1.1.0-beta4 so the TestKit -> TestKit.Core -> Avr8Sharp dependency chain resolves to matching versions on NuGet.org. - publish.yml now packs TestKit.Core (before TestKit) and pushes to nuget.org via NuGet Trusted Publishing (OIDC) instead of a long-lived API key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 34 +- AVR8Sharp.sln | 30 ++ bindings/python/.gitignore | 15 + bindings/python/LICENSE | 80 ++++ bindings/python/README.md | 53 +++ bindings/python/THIRD_PARTY_NOTICES | 48 ++ bindings/python/hatch_build.py | 131 +++++ bindings/python/pyproject.toml | 54 +++ bindings/python/src/avr8sharp/__init__.py | 50 ++ bindings/python/src/avr8sharp/_native.py | 134 ++++++ bindings/python/src/avr8sharp/errors.py | 7 + bindings/python/src/avr8sharp/simulation.py | 440 +++++++++++++++++ .../python/tests/fixtures/serial_hello.hex | 95 ++++ bindings/python/tests/test_smoke.py | 87 ++++ global.json | 6 + src/Avr8Sharp.Native/Avr8Sharp.Native.csproj | 19 + src/Avr8Sharp.Native/DeviceStubs.cs | 62 +++ src/Avr8Sharp.Native/Exports.cs | 450 ++++++++++++++++++ src/Avr8Sharp.Native/NativeSession.cs | 34 ++ .../Avr8Sharp.TestKit.Core.csproj | 43 ++ .../AvrMemoryView.cs | 0 .../AvrTestSimulation.cs | 0 .../Boards/ATtiny13Simulation.cs | 32 ++ .../Boards/ATtiny85Simulation.cs | 0 .../Boards/ATtinyX4Simulation.cs | 45 ++ .../Boards/ArduinoMegaSimulation.cs | 0 .../Boards/ArduinoUnoSimulation.cs | 0 .../Probes/SerialProbe.cs | 0 .../Avr8Sharp.TestKit.csproj | 7 +- src/Avr8Sharp/Avr8Sharp.csproj | 2 +- 30 files changed, 1950 insertions(+), 8 deletions(-) create mode 100644 bindings/python/.gitignore create mode 100644 bindings/python/LICENSE create mode 100644 bindings/python/README.md create mode 100644 bindings/python/THIRD_PARTY_NOTICES create mode 100644 bindings/python/hatch_build.py create mode 100644 bindings/python/pyproject.toml create mode 100644 bindings/python/src/avr8sharp/__init__.py create mode 100644 bindings/python/src/avr8sharp/_native.py create mode 100644 bindings/python/src/avr8sharp/errors.py create mode 100644 bindings/python/src/avr8sharp/simulation.py create mode 100644 bindings/python/tests/fixtures/serial_hello.hex create mode 100644 bindings/python/tests/test_smoke.py create mode 100644 global.json create mode 100644 src/Avr8Sharp.Native/Avr8Sharp.Native.csproj create mode 100644 src/Avr8Sharp.Native/DeviceStubs.cs create mode 100644 src/Avr8Sharp.Native/Exports.cs create mode 100644 src/Avr8Sharp.Native/NativeSession.cs create mode 100644 src/Avr8Sharp.TestKit.Core/Avr8Sharp.TestKit.Core.csproj rename src/{Avr8Sharp.TestKit => Avr8Sharp.TestKit.Core}/AvrMemoryView.cs (100%) rename src/{Avr8Sharp.TestKit => Avr8Sharp.TestKit.Core}/AvrTestSimulation.cs (100%) create mode 100644 src/Avr8Sharp.TestKit.Core/Boards/ATtiny13Simulation.cs rename src/{Avr8Sharp.TestKit => Avr8Sharp.TestKit.Core}/Boards/ATtiny85Simulation.cs (100%) create mode 100644 src/Avr8Sharp.TestKit.Core/Boards/ATtinyX4Simulation.cs rename src/{Avr8Sharp.TestKit => Avr8Sharp.TestKit.Core}/Boards/ArduinoMegaSimulation.cs (100%) rename src/{Avr8Sharp.TestKit => Avr8Sharp.TestKit.Core}/Boards/ArduinoUnoSimulation.cs (100%) rename src/{Avr8Sharp.TestKit => Avr8Sharp.TestKit.Core}/Probes/SerialProbe.cs (100%) 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