Skip to content

Commit d031db5

Browse files
committed
Built-in esptool flash (via Pythonx)
Optional use built-in esptool install via Pythonx. Signed-off-by: Peter M <[email protected]>
1 parent cbd2fb6 commit d031db5

File tree

5 files changed

+359
-3
lines changed

5 files changed

+359
-3
lines changed

lib/esptool_helper.ex

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
defmodule ExAtomVM.EsptoolHelper do
2+
@moduledoc """
3+
Module for setting up and using esptool through Pythonx.
4+
"""
5+
6+
@doc """
7+
Initializes Python environment with project configuration.
8+
We use locked main branch esptool version, pending a stable release 5.x release,
9+
as we need the read to memory (instead of only to file) features.
10+
"""
11+
def setup do
12+
case Code.ensure_loaded(Pythonx) do
13+
{:module, Pythonx} ->
14+
Application.ensure_all_started(:pythonx)
15+
16+
Pythonx.uv_init("""
17+
[project]
18+
name = "project"
19+
version = "0.0.0"
20+
requires-python = "==3.13.*"
21+
dependencies = [
22+
"esptool @ git+https://github.com/espressif/esptool.git@6f0d779"
23+
]
24+
""")
25+
26+
_ ->
27+
{:error, :pythonx_not_available,
28+
"The :pythonx dependency is not available. Please add it to your mix.exs dependencies.\n{:pythonx, \"~> 0.4.0\"}"}
29+
end
30+
end
31+
32+
def flash_pythonx(tool_args) do
33+
# https://github.com/espressif/esptool/blob/master/docs/en/esptool/scripting.rst
34+
setup()
35+
36+
{_result, globals} =
37+
try do
38+
Pythonx.eval(
39+
"""
40+
import esptool
41+
import sys
42+
43+
command = [arg.decode('utf-8') for arg in tool_args]
44+
45+
def flash_esp():
46+
esptool.main(command)
47+
48+
if __name__ == "__main__":
49+
try:
50+
result = flash_esp()
51+
result = True
52+
except SystemExit as e:
53+
exit_code = int(str(e))
54+
result = exit_code == 0
55+
except Exception as e:
56+
print(f"Warning: {e}")
57+
result = False
58+
59+
""",
60+
%{"tool_args" => tool_args}
61+
)
62+
rescue
63+
e in Pythonx.Error ->
64+
IO.inspect("Pythonx error occurred: #{inspect(e)}")
65+
exit({:shutdown, 1})
66+
end
67+
68+
case Pythonx.decode(globals["result"]) do
69+
true -> exit({:shutdown, 0})
70+
false -> exit({:shutdown, 1})
71+
end
72+
end
73+
74+
def erase_flash(tool_args \\ ["--chip", "auto"]) do
75+
selected_device = select_device()
76+
77+
confirmation =
78+
IO.gets(
79+
"\nAre you sure you want to erase the flash of\n#{selected_device["chip_family_name"]} - Port: #{selected_device["port"]} MAC: #{selected_device["mac_address"]} ? [N/y]: "
80+
)
81+
82+
case String.trim(confirmation) do
83+
input when input in ["Y", "y"] ->
84+
IO.puts("Erasing..")
85+
86+
_ ->
87+
IO.puts("Flash erase cancelled.")
88+
exit({:shutdown, 0})
89+
end
90+
91+
tool_args = ["--port", selected_device["port"]] ++ tool_args ++ ["erase-flash"]
92+
93+
{_result, globals} =
94+
try do
95+
Pythonx.eval(
96+
"""
97+
import esptool
98+
99+
command = [arg.decode('utf-8') for arg in tool_args]
100+
101+
def flash_esp():
102+
esptool.main(command)
103+
104+
if __name__ == "__main__":
105+
try:
106+
result = flash_esp()
107+
result = True
108+
except SystemExit as e:
109+
exit_code = int(str(e))
110+
result = exit_code == 0
111+
except Exception as e:
112+
print(f"Warning: {e}")
113+
result = False
114+
""",
115+
%{"tool_args" => tool_args}
116+
)
117+
rescue
118+
e in Pythonx.Error ->
119+
IO.inspect("Pythonx error occurred: #{inspect(e)}")
120+
exit({:shutdown, 1})
121+
end
122+
123+
case Pythonx.decode(globals["result"]) do
124+
true -> exit({:shutdown, 0})
125+
false -> exit({:shutdown, 1})
126+
end
127+
end
128+
129+
def connected_devices do
130+
{_result, globals} =
131+
try do
132+
Pythonx.eval(
133+
"""
134+
from esptool.cmds import (detect_chip, read_flash, attach_flash)
135+
import serial.tools.list_ports as list_ports
136+
import re
137+
138+
ports = []
139+
for port in list_ports.comports():
140+
if port.vid is None:
141+
continue
142+
ports.append(port.device)
143+
144+
result = []
145+
for port in ports:
146+
try:
147+
with detect_chip(port) as esp:
148+
description = esp.get_chip_description()
149+
features = esp.get_chip_features()
150+
mac_addr = ':'.join(['%02X' % b for b in esp.read_mac()])
151+
152+
# chips like esp32-s2 can have more specific names, so we call this chip family
153+
# https://github.com/espressif/esptool/blob/807d02b0c5eb07ba46f871a492c84395fb9f37be/esptool/targets/esp32s2.py#L167
154+
chip_family_name = esp.CHIP_NAME
155+
156+
# read 128 bytes at 0x10030
157+
attach_flash(esp)
158+
app_header = read_flash(esp, 0x10030, 128, None)
159+
app_header_strings = [s for s in re.split('\\x00', app_header.decode('utf-8', errors='replace')) if s]
160+
161+
result.append({"port": port, "chip_family_name": chip_family_name,
162+
"features": features, "build_info": app_header_strings, "mac_address": mac_addr
163+
})
164+
except Exception as e:
165+
print(f"Error: {e}")
166+
result = False
167+
""",
168+
%{}
169+
)
170+
rescue
171+
e in Pythonx.Error ->
172+
{:error, "Pythonx error occurred: #{inspect(e)}"}
173+
end
174+
175+
Pythonx.decode(globals["result"])
176+
|> Enum.map(fn device ->
177+
Map.put(device, "atomvm_installed", Enum.member?(device["build_info"], "atomvm-esp32"))
178+
end)
179+
end
180+
181+
defp select_device do
182+
devices = connected_devices()
183+
184+
selected_device =
185+
case length(devices) do
186+
0 ->
187+
IO.puts("Found no esp32 devices..")
188+
exit({:shutdown, 1})
189+
190+
1 ->
191+
hd(devices)
192+
193+
_ ->
194+
IO.puts("\nMultiple ESP32 devices found:")
195+
196+
devices
197+
|> Enum.with_index(1)
198+
|> Enum.each(fn {device, index} ->
199+
IO.puts(
200+
"#{index}. #{device["chip_family_name"]} - Port: #{device["port"]} MAC: #{device["mac_address"]}"
201+
)
202+
end)
203+
204+
selected =
205+
IO.gets("\nSelect device (1-#{length(devices)}): ")
206+
|> String.trim()
207+
|> Integer.parse()
208+
209+
case selected do
210+
{num, _} when num > 0 and num <= length(devices) ->
211+
Enum.at(devices, num - 1)
212+
213+
_ ->
214+
IO.puts("Invalid selection.")
215+
exit({:shutdown, 1})
216+
end
217+
end
218+
219+
selected_device
220+
end
221+
end

lib/mix/tasks/esp32_erase_flash.ex

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
defmodule Mix.Tasks.Atomvm.Esp32.EraseFlash do
2+
@moduledoc """
3+
Mix task to get erase the flash of an connected ESP32 devices.
4+
"""
5+
use Mix.Task
6+
7+
@shortdoc "Erase flash of ESP32"
8+
9+
@impl Mix.Task
10+
def run(_args) do
11+
with :ok <- ExAtomVM.EsptoolHelper.setup(),
12+
_ <- ExAtomVM.EsptoolHelper.erase_flash() do
13+
:ok
14+
else
15+
{:error, :pythonx_not_available, message} ->
16+
IO.puts("\nError: #{message}")
17+
exit({:shutdown, 1})
18+
19+
{:error, reason} ->
20+
IO.puts("Error: #{reason}")
21+
exit({:shutdown, 1})
22+
end
23+
end
24+
end

lib/mix/tasks/esp32_flash.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,16 @@ defmodule Mix.Tasks.Atomvm.Esp32.Flash do
5959

6060
tool_args = if port == "auto", do: tool_args, else: ["--port", port] ++ tool_args
6161

62-
tool_full_path = get_esptool_path(idf_path)
63-
System.cmd(tool_full_path, tool_args, stderr_to_stdout: true, into: IO.stream(:stdio, 1))
62+
case Code.ensure_loaded(Pythonx) do
63+
{:module, Pythonx} ->
64+
IO.inspect("Flashing using Pythonx installed esptool..")
65+
ExAtomVM.EsptoolHelper.flash_pythonx(tool_args)
66+
67+
_ ->
68+
IO.inspect("Flashing using esptool..")
69+
tool_full_path = get_esptool_path(idf_path)
70+
System.cmd(tool_full_path, tool_args, stderr_to_stdout: true, into: IO.stream(:stdio, 1))
71+
end
6472
end
6573

6674
defp get_esptool_path(<<"">>) do

lib/mix/tasks/esp32_info.ex

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
defmodule Mix.Tasks.Atomvm.Esp32.Info do
2+
@moduledoc """
3+
Mix task to get information about connected ESP32 devices.
4+
"""
5+
use Mix.Task
6+
7+
@shortdoc "Get information about connected ESP32 devices"
8+
9+
@impl Mix.Task
10+
def run(_args) do
11+
with :ok <- ExAtomVM.EsptoolHelper.setup(),
12+
devices <- ExAtomVM.EsptoolHelper.connected_devices() do
13+
case length(devices) do
14+
0 -> IO.puts("Found no esp32 devices..")
15+
devices -> IO.puts("Found #{devices} connected esp32:")
16+
end
17+
18+
if length(devices) > 1 do
19+
Enum.each(devices, fn device ->
20+
IO.puts(
21+
"#{format_atomvm_status(device["atomvm_installed"])}#{device["chip_family_name"]} - Port: #{device["port"]}"
22+
)
23+
end)
24+
end
25+
26+
Enum.each(devices, fn device ->
27+
IO.puts("\n━━━━━━━━━━━━━━━━━━━━━━")
28+
29+
IO.puts(
30+
"#{format_atomvm_status(device["atomvm_installed"])}#{device["chip_family_name"]} - Port: #{device["port"]}"
31+
)
32+
33+
IO.puts("MAC: #{device["mac_address"]}")
34+
IO.puts("AtomVM installed: #{device["atomvm_installed"]}")
35+
36+
IO.puts("\nBuild Information:")
37+
38+
Enum.each(format_build_info(device["build_info"]), fn build_info ->
39+
IO.puts(build_info)
40+
end)
41+
42+
IO.puts("\nFeatures:")
43+
44+
Enum.each(device["features"], fn feature ->
45+
IO.puts(" • #{feature}")
46+
end)
47+
end)
48+
49+
IO.puts("\n")
50+
else
51+
{:error, :pythonx_not_available, message} ->
52+
IO.puts("\nError: #{message}")
53+
exit({:shutdown, 1})
54+
55+
{:error, reason} ->
56+
IO.puts("\nError: Failed to get ESP32 device information")
57+
IO.puts("Reason: #{reason}")
58+
exit({:shutdown, 1})
59+
end
60+
end
61+
62+
defp format_build_info(build_info) when is_list(build_info) and length(build_info) == 5 do
63+
[version, target, time, date, sdk] =
64+
build_info
65+
|> Enum.map(&sanitize_string/1)
66+
67+
[
68+
" Version: #{version}",
69+
" Target: #{target}",
70+
" Built: #{time} #{date}",
71+
" SDK: #{sdk}"
72+
]
73+
end
74+
75+
defp format_build_info(build_info) when is_list(build_info) do
76+
build_info
77+
|> Enum.map(&sanitize_string/1)
78+
|> Enum.with_index(1)
79+
|> Enum.map(fn {info, index} -> " Info #{index}: #{info}" end)
80+
end
81+
82+
defp format_build_info(_) do
83+
[" Build info not available or corrupted"]
84+
end
85+
86+
defp sanitize_string(str) when is_binary(str) do
87+
str
88+
# Remove non-printable characters
89+
|> String.replace(~r/[^x20-x7E]/u, "")
90+
|> String.trim()
91+
|> case do
92+
"" -> "<unreadable>"
93+
sanitized -> sanitized
94+
end
95+
end
96+
97+
defp sanitize_string(_), do: "<invalid>"
98+
99+
defp format_atomvm_status(true), do: "✅"
100+
defp format_atomvm_status(_), do: "❌"
101+
end

mix.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ defmodule ExAtomVM.MixProject do
2121
# Run "mix help deps" to learn about dependencies.
2222
defp deps do
2323
[
24-
{:uf2tool, "1.1.0"}
24+
{:uf2tool, "1.1.0"},
25+
{:pythonx, "~> 0.4.0", runtime: false, optional: true}
26+
2527
# {:dep_from_hexpm, "~> 0.3.0"},
2628
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
2729
]

0 commit comments

Comments
 (0)