Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,70 @@ Example:
Compiling 1 file (.ex)
Generated my_project app

### The `atomvm.esp32.build` task

The `atomvm.esp32.build` task builds an AtomVM ESP32 firmware image from source, with Elixir support enabled, using either a local ESP-IDF installation or the ESP-IDF Docker image. The resulting flashable image is written under `_build/atomvm_images/` and can be flashed with `mix atomvm.esp32.install`.

If no AtomVM source is supplied, the task clones the AtomVM `main` branch automatically. Use `--atomvm-path` to build from a local checkout or `--atomvm-url`/`--ref` to build from a specific git source.

#### Requirements

* Erlang/OTP 27 or later, Elixir 1.18 or later, and Git
* **Without Docker:** CMake (3.13+), Ninja (preferred) or Make, and ESP-IDF (v5.5.4 or later recommended)
* **With Docker (`--use-docker`):** Docker. Note that Docker build support requires AtomVM `main` from Jan 2, 2026 or later; earlier AtomVM versions must be built with a local ESP-IDF toolchain.

#### Options

| Option | Default | Description |
|--------|---------|-------------|
| `--atomvm-path` | - | Path to a local AtomVM repository (overrides `--atomvm-url` if both are given) |
| `--atomvm-url` | `https://github.com/atomvm/AtomVM` | Git URL to clone AtomVM from |
| `--ref` | `main` | Git reference to check out: branch, tag, commit SHA, or PR (e.g. `pr/1234` or `pull/1234/head`) |
| `--chip` | `esp32` | Target chip(s), comma-separated for multiple (`esp32`, `esp32s2`, `esp32s3`, `esp32c2`, `esp32c3`, `esp32c6`, `esp32h2`, `esp32p4`) |
| `--idf-path` | `idf.py` | Path to the `idf.py` executable |
| `--use-docker` | `false` | Use the ESP-IDF Docker image instead of a local installation |
| `--idf-version` | `v5.5.4` | ESP-IDF version for the Docker image |
| `--clean` | `false` | Clean the build directory before building |
| `--mbedtls-prefix` | - | Path to a custom MbedTLS installation (falls back to the `MBEDTLS_PREFIX` env var) |
| `--partition-table` | - | Path to custom partition table CSV file (falls back to `custom_partitions.csv` in project root) |

#### Custom partition table

You can explicitly specify a custom partition table file with the `--partition-table` option:

```shell
mix atomvm.esp32.build --partition-table path/to/partitions.csv
```

If the `--partition-table` option is not provided but the root of your Mix project contains `custom_partitions.csv`, it is used as the default partition table for the build.

ExAtomVM passes custom partition contents through unchanged, without imposing partition names, types, offsets, or sizes. It only checks that the selected file is readable, non-empty, and regular; AtomVM and ESP-IDF handle the contents during the build.

The selected file is read once before cloning or building and reused for every chip, even if cleaning removes the source file. Its contents are copied into the AtomVM ESP32 platform tree only while the build runs — so Docker builds see it through the mounted AtomVM source tree — and the original partition table is restored afterwards, leaving the AtomVM checkout clean.

> Note. When a custom partition table is used (either via `--partition-table` or default `custom_partitions.csv`), the task automatically forces a clean ESP32 platform build so CMake regenerates the partition layout — you do not need to pass `--clean` yourself.

#### Examples

# Build for the default esp32 chip (clones AtomVM main automatically)
shell$ mix atomvm.esp32.build

# Build from a local AtomVM checkout for a specific chip, cleaning first
shell$ mix atomvm.esp32.build --atomvm-path /path/to/AtomVM --chip esp32s3 --clean

# Build for multiple chips in one run
shell$ mix atomvm.esp32.build --chip esp32,esp32s3,esp32c6

# Build from a pull request
shell$ mix atomvm.esp32.build --ref pr/1234

# Build using Docker (clones AtomVM main automatically)
shell$ mix atomvm.esp32.build --use-docker --chip esp32s3 --clean

When combining `--use-docker` with a local `--atomvm-path`, the task expands the checkout path to an absolute path and bind-mounts it into the container. For example, if AtomVM is checked out next to your project:

shell$ mix atomvm.esp32.build --use-docker --atomvm-path ../AtomVM --chip esp32s3

### The `atomvm.esp32.flash` task

The `atomvm.esp32.flash` task is used to flash your application to a micro-controller and executed by the AtomVM virtual machine.
Expand Down
45 changes: 45 additions & 0 deletions lib/esp32_build_staging.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule ExAtomVM.Esp32BuildStaging do
@moduledoc false

# Snapshots and restores files that a build stages into the AtomVM checkout,
# so the checkout is left as it was found.
#
# A snapshot is `{:content, binary}` for a regular file or `:missing` when the
# path does not exist. Directories, symlinks, and other non-regular files are
# refused with `{:error, :einval}`, since writing to them would replace them
# instead of restoring their contents.

@doc false
def snapshot_file(path) do
case File.lstat(path) do
{:ok, %File.Stat{type: :regular}} ->
case File.read(path) do
{:ok, content} -> {:ok, {:content, content}}
{:error, reason} -> {:error, reason}
end

{:ok, _stat} ->
{:error, :einval}

{:error, :enoent} ->
{:ok, :missing}

{:error, reason} ->
{:error, reason}
end
end

@doc false
def restore_file(path, {:content, content}) do
File.write(path, content)
end

@doc false
def restore_file(path, :missing) do
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
94 changes: 94 additions & 0 deletions lib/esp32_custom_partitions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
defmodule ExAtomVM.Esp32CustomPartitions do
@moduledoc false

alias ExAtomVM.Esp32BuildStaging

@custom_partitions_csv "custom_partitions.csv"
@atomvm_elixir_partitions_csv "partitions-elixir.csv"

# Capture the selection before cloning or cleaning can remove the source.
def load_custom_partitions(user_provided_path) do
path = Path.expand(user_provided_path || @custom_partitions_csv)

case File.lstat(path) do
{:error, :enoent} when is_nil(user_provided_path) ->
{:ok, nil}

{:error, :enoent} ->
{:error, "Partition table file does not exist: #{user_provided_path}"}

_ ->
with :ok <- validate_partition_file(path),
{:ok, content} <- read_partition_file(path) do
{:ok, %{path: path, content: content}}
end
end
end

def with_custom_partitions(_platform_dir, nil, fun), do: fun.()

def with_custom_partitions(platform_dir, %{path: source_path, content: content}, fun) do
dest_path = Path.join(platform_dir, @atomvm_elixir_partitions_csv)

case Esp32BuildStaging.snapshot_file(dest_path) do
{:ok, original} ->
source_filename = Path.basename(source_path)
IO.puts("Copying #{source_filename} to #{dest_path} for this build...")

try do
# Writing bytes preserves the existing destination's permissions.
case File.write(dest_path, content) do
:ok ->
fun.()

{:error, reason} ->
{:error, "Failed to copy #{source_filename}: #{:file.format_error(reason)}"}
end
after
restore!(dest_path, original)
end

{:error, reason} ->
{:error,
"Failed to read existing #{@atomvm_elixir_partitions_csv}: #{:file.format_error(reason)}"}
end
end

# A failed restoration leaves the AtomVM checkout modified, so it must raise
# rather than let the build report success.
defp restore!(path, snapshot) do
case Esp32BuildStaging.restore_file(path, snapshot) do
:ok ->
:ok

{:error, reason} ->
raise File.Error, reason: reason, action: "restore", path: path
end
end

defp read_partition_file(path) do
case File.read(path) do
{:ok, content} ->
{:ok, content}

{:error, reason} ->
{:error, "cannot read #{Path.basename(path)}: #{:file.format_error(reason)}"}
end
end

defp validate_partition_file(path) do
case File.stat(path) do
{:ok, %File.Stat{type: :regular, size: 0}} ->
{:error, "#{Path.basename(path)} is empty"}

{:ok, %File.Stat{type: :regular}} ->
:ok

{:ok, _stat} ->
{:error, "#{Path.basename(path)} exists but is not a regular file"}

{:error, reason} ->
{:error, "cannot read #{Path.basename(path)}: #{:file.format_error(reason)}"}
end
end
end
Loading