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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# If you run "mix test --cover", coverage assets end up here.
/cover/

# ExUnit tmp_dir scratch space.
/tmp/

# The directory Mix downloads your dependencies sources to.
/deps/

Expand All @@ -21,4 +24,3 @@ erl_crash.dump

# Ignore package tarball (built via "mix hex.build").
exatomvm-*.tar

62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,68 @@ 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.

The custom partition file is 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 path is bind-mounted into the container, so it must be a real path (absolute, or relative starting with `./` or `../`) rather than a bare name, which Docker would treat as a named volume. 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
150 changes: 150 additions & 0 deletions lib/esp32_custom_partitions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
defmodule ExAtomVM.Esp32CustomPartitions do
@moduledoc false

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

# Returns {:ok, path} if a custom partition table file is resolved, or :error.
@doc false
def custom_partitions_path(nil) do
path = Path.join(File.cwd!(), @custom_partitions_csv)
if File.exists?(path), do: {:ok, path}, else: :error
end

@doc false
def custom_partitions_path(user_provided_path) do
path = Path.expand(user_provided_path)
if File.exists?(path), do: {:ok, path}, else: :error
end

# Validates the project's custom_partitions.csv (if present) up front, before
# any expensive build work, so an invalid file fails fast.
@doc false
def validate_custom_partitions(user_provided_path) do
cond do
not is_nil(user_provided_path) and not File.exists?(Path.expand(user_provided_path)) ->
{:error, "Partition table file does not exist: #{user_provided_path}"}

true ->
case custom_partitions_path(user_provided_path) do
:error -> :ok
{:ok, path} -> validate_partition_file(path)
end
end
end

@doc false
def with_custom_partitions(platform_dir, partition_table, fun) do
case custom_partitions_path(partition_table) do
:error ->
fun.()

{:ok, source_path} ->
copy_custom_partitions(source_path, platform_dir, fun)
end
end

@doc false
def copy_custom_partitions(source_path, platform_dir, fun) do
dest_path = Path.join(platform_dir, @atomvm_elixir_partitions_csv)

if same_file?(source_path, dest_path) do
IO.puts("Using custom ESP32 partition table: #{source_path}")
fun.()
else
case 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
case File.cp(source_path, dest_path) do
:ok ->
fun.()

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

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

# Returns {:ok, {:content, binary}} when the file exists, {:ok, :missing} when
# it does not, or {:error, reason} if it exists but cannot be read.
@doc false
def snapshot_file(path) do
case File.read(path) do
{:ok, content} -> {:ok, {:content, content}}
{:error, :enoent} -> {:ok, :missing}
{:error, reason} -> {:error, reason}
end
end

@doc false
def restore_file(path, {:content, content}) do
case File.write(path, content) do
:ok ->
:ok

{:error, reason} ->
IO.puts(
"Warning: failed to restore #{path}: #{:file.format_error(reason)} " <>
"(AtomVM checkout may be left modified)"
)
end
end

@doc false
def restore_file(path, :missing) do
case File.rm(path) do
:ok ->
:ok

{:error, :enoent} ->
:ok

{:error, reason} ->
IO.puts(
"Warning: failed to remove temporary #{path}: #{:file.format_error(reason)} " <>
"(AtomVM checkout may be left modified)"
)
end
end

@doc false
def same_file?(left, right) do
Path.expand(left) == Path.expand(right) or
with {:ok, l} <- File.stat(left),
{:ok, r} <- File.stat(right) do
l.inode != 0 and
{l.major_device, l.minor_device, l.inode} ==
{r.major_device, r.minor_device, r.inode}
else
_ -> false
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