Skip to content

Commit ac93e42

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 ac93e42

File tree

5 files changed

+372
-3
lines changed

5 files changed

+372
-3
lines changed

lib/esptool_helper.ex

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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 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+
35+
tool_args =
36+
if not Enum.member?(tool_args, "--port") do
37+
selected_device = select_device()
38+
39+
["--port", selected_device["port"]] ++ tool_args
40+
else
41+
tool_args
42+
end
43+
44+
{_result, globals} =
45+
try do
46+
Pythonx.eval(
47+
"""
48+
import esptool
49+
import sys
50+
51+
command = [arg.decode('utf-8') for arg in tool_args]
52+
53+
def flash_esp():
54+
esptool.main(command)
55+
56+
if __name__ == "__main__":
57+
try:
58+
result = flash_esp()
59+
result = True
60+
except SystemExit as e:
61+
exit_code = int(str(e))
62+
result = exit_code == 0
63+
except Exception as e:
64+
print(f"Warning: {e}")
65+
result = False
66+
67+
""",
68+
%{"tool_args" => tool_args}
69+
)
70+
rescue
71+
e in Pythonx.Error ->
72+
IO.inspect("Pythonx error occurred: #{inspect(e)}")
73+
exit({:shutdown, 1})
74+
end
75+
76+
case Pythonx.decode(globals["result"]) do
77+
true -> exit({:shutdown, 0})
78+
false -> exit({:shutdown, 1})
79+
end
80+
end
81+
82+
def erase_flash(tool_args \\ ["--chip", "auto"]) do
83+
tool_args =
84+
if not Enum.member?(tool_args, "--port") do
85+
selected_device = select_device()
86+
87+
confirmation =
88+
IO.gets(
89+
"\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]: "
90+
)
91+
92+
case String.trim(confirmation) do
93+
input when input in ["Y", "y"] ->
94+
IO.puts("Erasing..")
95+
96+
_ ->
97+
IO.puts("Flash erase cancelled.")
98+
exit({:shutdown, 0})
99+
end
100+
101+
["--port", selected_device["port"]] ++ tool_args ++ ["erase-flash"]
102+
else
103+
tool_args ++ ["erase-flash"]
104+
end
105+
106+
{_result, globals} =
107+
try do
108+
Pythonx.eval(
109+
"""
110+
import esptool
111+
112+
command = [arg.decode('utf-8') for arg in tool_args]
113+
114+
def flash_esp():
115+
esptool.main(command)
116+
117+
if __name__ == "__main__":
118+
try:
119+
result = flash_esp()
120+
result = True
121+
except SystemExit as e:
122+
exit_code = int(str(e))
123+
result = exit_code == 0
124+
except Exception as e:
125+
print(f"Warning: {e}")
126+
result = False
127+
""",
128+
%{"tool_args" => tool_args}
129+
)
130+
rescue
131+
e in Pythonx.Error ->
132+
IO.inspect("Pythonx error occurred: #{inspect(e)}")
133+
exit({:shutdown, 1})
134+
end
135+
136+
Pythonx.decode(globals["result"])
137+
end
138+
139+
def connected_devices do
140+
{_result, globals} =
141+
try do
142+
Pythonx.eval(
143+
"""
144+
from esptool.cmds import (detect_chip, read_flash, attach_flash)
145+
import serial.tools.list_ports as list_ports
146+
import re
147+
148+
ports = []
149+
for port in list_ports.comports():
150+
if port.vid is None:
151+
continue
152+
ports.append(port.device)
153+
154+
result = []
155+
for port in ports:
156+
try:
157+
with detect_chip(port) as esp:
158+
description = esp.get_chip_description()
159+
features = esp.get_chip_features()
160+
mac_addr = ':'.join(['%02X' % b for b in esp.read_mac()])
161+
162+
# chips like esp32-s2 can have more specific names, so we call this chip family
163+
# https://github.com/espressif/esptool/blob/807d02b0c5eb07ba46f871a492c84395fb9f37be/esptool/targets/esp32s2.py#L167
164+
chip_family_name = esp.CHIP_NAME
165+
166+
# read 128 bytes at 0x10030
167+
attach_flash(esp)
168+
app_header = read_flash(esp, 0x10030, 128, None)
169+
app_header_strings = [s for s in re.split('\\x00', app_header.decode('utf-8', errors='replace')) if s]
170+
171+
result.append({"port": port, "chip_family_name": chip_family_name,
172+
"features": features, "build_info": app_header_strings, "mac_address": mac_addr
173+
})
174+
except Exception as e:
175+
print(f"Error: {e}")
176+
result = False
177+
""",
178+
%{}
179+
)
180+
rescue
181+
e in Pythonx.Error ->
182+
{:error, "Pythonx error occurred: #{inspect(e)}"}
183+
end
184+
185+
Pythonx.decode(globals["result"])
186+
|> Enum.map(fn device ->
187+
Map.put(device, "atomvm_installed", Enum.member?(device["build_info"], "atomvm-esp32"))
188+
end)
189+
end
190+
191+
def select_device do
192+
devices = connected_devices()
193+
194+
selected_device =
195+
case length(devices) do
196+
0 ->
197+
IO.puts("Found no esp32 devices..")
198+
exit({:shutdown, 1})
199+
200+
1 ->
201+
hd(devices)
202+
203+
_ ->
204+
IO.puts("\nMultiple ESP32 devices found:")
205+
206+
devices
207+
|> Enum.with_index(1)
208+
|> Enum.each(fn {device, index} ->
209+
IO.puts(
210+
"#{index}. #{device["chip_family_name"]} - Port: #{device["port"]} MAC: #{device["mac_address"]}"
211+
)
212+
end)
213+
214+
selected =
215+
IO.gets("\nSelect device (1-#{length(devices)}): ")
216+
|> String.trim()
217+
|> Integer.parse()
218+
219+
case selected do
220+
{num, _} when num > 0 and num <= length(devices) ->
221+
Enum.at(devices, num - 1)
222+
223+
_ ->
224+
IO.puts("Invalid selection.")
225+
exit({:shutdown, 1})
226+
end
227+
end
228+
229+
selected_device
230+
end
231+
end

lib/mix/tasks/esp32_erase_flash.ex

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
result <- ExAtomVM.EsptoolHelper.erase_flash() do
13+
case result do
14+
true -> exit({:shutdown, 0})
15+
false -> exit({:shutdown, 1})
16+
end
17+
else
18+
{:error, :pythonx_not_available, message} ->
19+
IO.puts("\nError: #{message}")
20+
exit({:shutdown, 1})
21+
22+
{:error, reason} ->
23+
IO.puts("Error: #{reason}")
24+
exit({:shutdown, 1})
25+
end
26+
end
27+
end

lib/mix/tasks/esp32_flash.ex

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,17 @@ 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.setup()
66+
ExAtomVM.EsptoolHelper.flash_pythonx(tool_args)
67+
68+
_ ->
69+
IO.inspect("Flashing using esptool..")
70+
tool_full_path = get_esptool_path(idf_path)
71+
System.cmd(tool_full_path, tool_args, stderr_to_stdout: true, into: IO.stream(:stdio, 1))
72+
end
6473
end
6574

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

lib/mix/tasks/esp32_info.ex

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 while preserving spaces
89+
|> String.replace(~r/[^\x20-\x7E\s]/u, "")
90+
|> case do
91+
"" -> "<unreadable>"
92+
sanitized -> sanitized
93+
end
94+
end
95+
96+
defp sanitize_string(_), do: "<invalid>"
97+
98+
defp format_atomvm_status(true), do: "✅"
99+
defp format_atomvm_status(_), do: "❌"
100+
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)