picoride 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/launch.json +11 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +1518 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/Dockerfile +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +76 -0
- data/Rakefile +12 -0
- data/docker/esp32/Dockerfile +82 -0
- data/docker/pico/Dockerfile +77 -0
- data/exe/picoride +16 -0
- data/lib/picoride/background_job.rb +71 -0
- data/lib/picoride/project_store.rb +133 -0
- data/lib/picoride/r2p2_state.rb +137 -0
- data/lib/picoride/server.rb +1156 -0
- data/lib/picoride/version.rb +5 -0
- data/lib/picoride.rb +11 -0
- data/sig/picoruby/esp32/ide.rbs +8 -0
- data/web/funicular/README.md +111 -0
- data/web/funicular/ruby/components/directory_browser_dialog.rb +212 -0
- data/web/funicular/ruby/components/editor_app.rb +1560 -0
- data/web/funicular/ruby/components/log_panel.rb +102 -0
- data/web/funicular/ruby/components/menu_bar.rb +266 -0
- data/web/funicular/ruby/components/project_settings_dialog.rb +203 -0
- data/web/funicular/ruby/components/prompt_dialog.rb +76 -0
- data/web/funicular/ruby/components/toolbar.rb +30 -0
- data/web/funicular/ruby/components/tree_view.rb +112 -0
- data/web/funicular/ruby/main.rb +3 -0
- data/web/public/css/style.css +886 -0
- data/web/public/js/editor.js +257 -0
- data/web/views/index.erb +94 -0
- metadata +132 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "background_job"
|
|
10
|
+
require_relative "r2p2_state"
|
|
11
|
+
require_relative "project_store"
|
|
12
|
+
|
|
13
|
+
module Picoride
|
|
14
|
+
class Server < Sinatra::Base
|
|
15
|
+
# 静的ファイル・ERBテンプレート・Funicular(ブラウザ側Ruby)はgemルートの
|
|
16
|
+
# web/ 配下に置いている(lib/はサーバ側のRubyコード専用、exe/は実行ファイル専用)。
|
|
17
|
+
WEB_ROOT = File.expand_path("../../web", __dir__)
|
|
18
|
+
|
|
19
|
+
set :public_folder, File.join(WEB_ROOT, "public")
|
|
20
|
+
set :views, File.join(WEB_ROOT, "views")
|
|
21
|
+
|
|
22
|
+
if settings.development?
|
|
23
|
+
set :static_cache_control, [:no_store, :no_cache, :must_revalidate]
|
|
24
|
+
|
|
25
|
+
before do
|
|
26
|
+
cache_control :no_store, :no_cache, :must_revalidate
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
set :static_cache_control, [:public, max_age: 3600]
|
|
30
|
+
|
|
31
|
+
before do
|
|
32
|
+
cache_control :public, max_age: 3600
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 編集対象のプロジェクト(app/・mrbgems/・build_config.rbを持つディレクトリ)。
|
|
37
|
+
# 固定ディレクトリ・環境変数での既定値は持たず、UI(メニューバーのプロジェクト
|
|
38
|
+
# 切り替えドロップダウン、または「ファイル」>「プロジェクトを開く」)から
|
|
39
|
+
# 都度ユーザーが指定して開く方式にしてある(「よくあるファイルビューアの
|
|
40
|
+
# ような見た目でディレクトリを探せるように」という要望でDirectoryBrowserDialog
|
|
41
|
+
# 経由になっている)。
|
|
42
|
+
#
|
|
43
|
+
# 以前は「プロジェクトを束ねる"ワークスペース"ディレクトリを開き、その配下の
|
|
44
|
+
# 複数プロジェクトから1つを選ぶ」という2階層構成だった(workspace_root +
|
|
45
|
+
# project_root(name)で解決)。しかし「ワークスペース=プロジェクトだった」
|
|
46
|
+
# というフィードバックにより、2階層は不要と判明し1階層に統合した: ここで
|
|
47
|
+
# 開く・切り替えるディレクトリがそのままプロジェクト本体(project_root)になる。
|
|
48
|
+
#
|
|
49
|
+
# project_rootは「現在アクティブな」1つだけを指す。known_projectsは
|
|
50
|
+
# 一度でも開いたプロジェクトの絶対パスを開いた順に積んだ配列で、
|
|
51
|
+
# メニューバーのドロップダウンの選択肢になる(複数プロジェクトを開いておいて
|
|
52
|
+
# ドロップダウンで切り替えたい、という要望)。
|
|
53
|
+
#
|
|
54
|
+
# project_settingsは絶対パス(known_projectsの要素)をキーに、そのプロジェクトの
|
|
55
|
+
# ターゲット・VM・USB Console設定を持つHash(詳細は下の「プロジェクト設定」の
|
|
56
|
+
# 節参照)。
|
|
57
|
+
#
|
|
58
|
+
# これら3つはいずれも`~/.picoride/project.toml`(ProjectStore)に永続化する
|
|
59
|
+
# (一度開いたら覚えておいてほしい、再起動しても開かれた状態で起動してほしい、
|
|
60
|
+
# という要望)。起動時にここで一度読み込んで初期値にし、変更するたびに
|
|
61
|
+
# 書き戻す(ProjectStore.save参照)。
|
|
62
|
+
class << self
|
|
63
|
+
attr_accessor :project_root, :known_projects, :project_settings
|
|
64
|
+
end
|
|
65
|
+
loaded_projects = ProjectStore.load
|
|
66
|
+
self.known_projects = loaded_projects[:paths]
|
|
67
|
+
self.project_root = loaded_projects[:active]
|
|
68
|
+
self.project_settings = loaded_projects[:settings]
|
|
69
|
+
|
|
70
|
+
FUNICULAR_ROOT = File.join(WEB_ROOT, "funicular")
|
|
71
|
+
|
|
72
|
+
# プロジェクトごとに独立したR2P2-ESP32のビルド状態(sdkconfig・build/・
|
|
73
|
+
# managed_components等)を置くディレクトリ名。「プロジェクトディレクトリの中に
|
|
74
|
+
# 置きたい、gitには含めたくない、UI上のファイル一覧にも出したくない」という
|
|
75
|
+
# 要望から、プロジェクト直下のドット始まりのディレクトリにしてある。
|
|
76
|
+
# `.`始まりのパスは`Dir.glob("**/*")`が中身ごと辿らない(GET /api/files参照)ので、
|
|
77
|
+
# 特別なフィルタ処理を書かなくてもUI上には出てこない。
|
|
78
|
+
R2P2_STATE_DIRNAME = ".r2p2-esp32"
|
|
79
|
+
|
|
80
|
+
# Raspberry Pi Pico用のビルド状態(picoruby本体のチェックアウト。R2P2は
|
|
81
|
+
# picorubyに`picoruby-r2p2` gemとして統合されたため。r2p2_state.rb参照)。
|
|
82
|
+
# ESP32用と同じ理由でプロジェクト直下のドット始まりのディレクトリにしてある。
|
|
83
|
+
PICO_STATE_DIRNAME = ".r2p2-pico"
|
|
84
|
+
|
|
85
|
+
# ターゲット(設定ダイアログの「ターゲット」)をファミリーごとに分けたもの。
|
|
86
|
+
# ファミリーによって使うビルド用イメージ・状態ディレクトリ・ビルド手順が切り替わる。
|
|
87
|
+
PLATFORM_FAMILIES = {
|
|
88
|
+
"esp32" => %w[esp32 esp32c3 esp32c6 esp32h2 esp32p4 esp32s3],
|
|
89
|
+
"pico" => %w[pico pico_w pico2 pico2_w]
|
|
90
|
+
}.freeze
|
|
91
|
+
PLATFORM_TARGETS = PLATFORM_FAMILIES.values.flatten.freeze
|
|
92
|
+
|
|
93
|
+
# ターゲット未設定のプロジェクトは従来通りESP32として扱う(Pico対応以前に
|
|
94
|
+
# 作られたプロジェクトの挙動を変えないため)。
|
|
95
|
+
DEFAULT_PLATFORM_FAMILY = "esp32"
|
|
96
|
+
|
|
97
|
+
# ファミリーごとのビルド専用イメージ(docker/esp32/Dockerfile・docker/pico/Dockerfile)。
|
|
98
|
+
# どちらも事前に`docker build`しておく必要がある(BUILDER_DOCKERFILE_DIRS参照)。
|
|
99
|
+
# ESP32側は旧来の環境変数PICORUBY_BUILDER_IMAGEでも上書きできるようにしてある。
|
|
100
|
+
BUILDER_IMAGES = {
|
|
101
|
+
"esp32" => ENV["PICORIDE_ESP32_BUILDER_IMAGE"] || ENV["PICORUBY_BUILDER_IMAGE"] || "picoride-builder-esp32",
|
|
102
|
+
"pico" => ENV["PICORIDE_PICO_BUILDER_IMAGE"] || "picoride-builder-pico"
|
|
103
|
+
}.freeze
|
|
104
|
+
BUILDER_DOCKERFILE_DIRS = { "esp32" => "docker/esp32", "pico" => "docker/pico" }.freeze
|
|
105
|
+
|
|
106
|
+
# ビルド専用コンテナの中で、プロジェクト本体・ビルド状態(R2P2-ESP32または
|
|
107
|
+
# picoruby)をそれぞれbind mountする先の固定パス。ホスト側の実パスとコンテナ内
|
|
108
|
+
# パスの対応はこれらの定数を通じて一箇所にまとめておく(builder_docker_command参照)。
|
|
109
|
+
CONTAINER_PROJECT_MOUNT = "/project_src"
|
|
110
|
+
CONTAINER_R2P2_MOUNT = "/R2P2-ESP32"
|
|
111
|
+
CONTAINER_PICORUBY_MOUNT = "/picoruby"
|
|
112
|
+
CONTAINER_STATE_MOUNTS = { "esp32" => CONTAINER_R2P2_MOUNT, "pico" => CONTAINER_PICORUBY_MOUNT }.freeze
|
|
113
|
+
|
|
114
|
+
ALLOWED_EXTENSIONS = %w[.rb .c .h .rake].freeze
|
|
115
|
+
|
|
116
|
+
# mrbgem名として許可する文字種。ディレクトリ名としてそのまま使うので、
|
|
117
|
+
# パストラバーサルや意図しない特殊文字を避けるため英数字・アンダースコア・
|
|
118
|
+
# ハイフンのみに絞ってある。
|
|
119
|
+
MRBGEM_NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/.freeze
|
|
120
|
+
|
|
121
|
+
# 1プロジェクト = app/・mrbgems/・build_config.rbをまとめて持つディレクトリ、
|
|
122
|
+
# という単位にしてある。
|
|
123
|
+
APP_DIRNAME = "app"
|
|
124
|
+
MRBGEMS_DIRNAME = "mrbgems"
|
|
125
|
+
|
|
126
|
+
# 「ファイル」メニューからの新規ファイル/新規フォルダ作成は、実機の起動スクリプト
|
|
127
|
+
# である app/ 配下だけに限定する(mrbgems/ 以下は「mrbgemを追加」が専用の雛形を
|
|
128
|
+
# 用意するので対象外)。拡張子もRubyの.rbのみに絞る(app/はR2P2起動時にmain_task.rb
|
|
129
|
+
# がloadするRubyスクリプト置き場であり、.c/.h/.rakeを置く場所ではないため)。
|
|
130
|
+
NEW_FILE_EXTENSION = ".rb"
|
|
131
|
+
|
|
132
|
+
# プロジェクト固有のビルド設定。ユーザーが直接編集できる普通のファイルという位置づけ
|
|
133
|
+
# (以前はGemsダイアログが生成する専用ファイルだったが、mrbgemがプロジェクトの中に
|
|
134
|
+
# 物理的に入るようになったことで、追加するmrbgemを選ぶUIは不要になった)。
|
|
135
|
+
BUILD_CONFIG_FILENAME = "build_config.rb"
|
|
136
|
+
|
|
137
|
+
# ビルド時にPROJECT_BUILD_CONFIG経由でR2P2-ESP32へ実際に渡すファイル。
|
|
138
|
+
# mrbgems/以下から自動生成した`conf.gem gemdir:`の並びの後ろに、プロジェクトの
|
|
139
|
+
# build_config.rbの内容をそのまま連結したもの(ユーザーが書いたbuild_config.rb自体は
|
|
140
|
+
# 書き換えない)。
|
|
141
|
+
GENERATED_BUILD_CONFIG_FILENAME = ".build_config.generated.rb"
|
|
142
|
+
|
|
143
|
+
BUILD_JOB = BackgroundJob.new
|
|
144
|
+
PLATFORM_JOB = BackgroundJob.new
|
|
145
|
+
|
|
146
|
+
helpers do
|
|
147
|
+
def project_root
|
|
148
|
+
self.class.project_root
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def known_projects
|
|
152
|
+
self.class.known_projects
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def project_settings
|
|
156
|
+
self.class.project_settings
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# 現在のプロジェクト(project_root)の設定。未保存(そのプロジェクトを
|
|
160
|
+
# 一度も設定したことがない)なら「何も設定されていない」扱い
|
|
161
|
+
# (platform/vmはnil、usb_consoleはfalse)にする。
|
|
162
|
+
def read_project_config
|
|
163
|
+
project_settings[project_root] || { "platform" => nil, "vm" => nil, "usb_console" => false }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# path traversal (../ などによる範囲外アクセス) を防ぎつつ絶対パスに変換する
|
|
167
|
+
def safe_path(root, rel_path)
|
|
168
|
+
full = File.expand_path(File.join(root, rel_path.to_s))
|
|
169
|
+
root_with_sep = root + File::SEPARATOR
|
|
170
|
+
unless full == root || full.start_with?(root_with_sep)
|
|
171
|
+
raise ArgumentError, "invalid path"
|
|
172
|
+
end
|
|
173
|
+
full
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# プロジェクトのR2P2-ESP32状態ディレクトリの絶対パス。
|
|
177
|
+
def r2p2_state_dir(root)
|
|
178
|
+
File.join(root, R2P2_STATE_DIRNAME)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ターゲット名 → ファミリー("esp32"/"pico")。未設定はDEFAULT_PLATFORM_FAMILY。
|
|
182
|
+
def platform_family(platform)
|
|
183
|
+
return DEFAULT_PLATFORM_FAMILY if platform.to_s.empty?
|
|
184
|
+
|
|
185
|
+
PLATFORM_FAMILIES.each { |family, targets| return family if targets.include?(platform) }
|
|
186
|
+
DEFAULT_PLATFORM_FAMILY
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 現在のプロジェクト設定のファミリー。
|
|
190
|
+
def current_platform_family
|
|
191
|
+
platform_family(read_project_config["platform"])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# ファミリーごとのビルド状態ディレクトリの絶対パス。
|
|
195
|
+
def build_state_dir(root, family)
|
|
196
|
+
File.join(root, family == "pico" ? PICO_STATE_DIRNAME : R2P2_STATE_DIRNAME)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# .gitignoreを(プロジェクト内に無ければ作成、あれば足りない行だけ追記して)
|
|
200
|
+
# 用意する。R2P2-ESP32の状態やビルド生成物をプロジェクトのgit管理に含めたくない、
|
|
201
|
+
# という要望から、プロジェクトの中に状態を置くようにした以上セットで必要になる。
|
|
202
|
+
# 既存の.gitignoreの中身(ユーザー自身が書いた分)は壊さない。
|
|
203
|
+
def ensure_project_gitignore(root)
|
|
204
|
+
entries = ["#{R2P2_STATE_DIRNAME}/", "#{PICO_STATE_DIRNAME}/", GENERATED_BUILD_CONFIG_FILENAME]
|
|
205
|
+
path = File.join(root, ".gitignore")
|
|
206
|
+
existing = File.file?(path) ? File.read(path) : ""
|
|
207
|
+
existing_lines = existing.lines.map(&:chomp)
|
|
208
|
+
missing = entries.reject { |entry| existing_lines.include?(entry) }
|
|
209
|
+
return if missing.empty?
|
|
210
|
+
|
|
211
|
+
File.open(path, "a") do |f|
|
|
212
|
+
f.puts if !existing.empty? && !existing.end_with?("\n")
|
|
213
|
+
missing.each { |entry| f.puts(entry) }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# 新しく開いた(あるいは初めて開く)プロジェクトに、起動スクリプトの土台
|
|
218
|
+
# (app/app.rb)とビルド設定ファイル(build_config.rb)を空で用意しておく。
|
|
219
|
+
# 以前の「新しいプロジェクト」(ワークスペース配下に名前でサブディレクトリを
|
|
220
|
+
# 作る機能)が担っていた雛形作成を、「プロジェクトを開く」
|
|
221
|
+
# (DirectoryBrowserDialogで任意のディレクトリを指定・新規フォルダ作成して
|
|
222
|
+
# 開く)に統合したもの。
|
|
223
|
+
#
|
|
224
|
+
# **空のディレクトリのときだけ**生成する(rootが完全に空かどうかをDir.empty?で
|
|
225
|
+
# 判定)。最初は「app/app.rb・build_config.rbが無ければ(他に何があっても)
|
|
226
|
+
# 作る」という実装にしていたが、間違って1階層上の(本来のプロジェクトを
|
|
227
|
+
# サブディレクトリとして含む)ディレクトリを開いてしまった場合に、そこへ
|
|
228
|
+
# 無関係な空のapp/・build_config.rbを生成してしまい、ファイルツリーに
|
|
229
|
+
# 「正しいプロジェクトのサブディレクトリ」と「誤って生成された空のapp/」が
|
|
230
|
+
# 並んで表示される、という実際の事故が起きた。ディレクトリに何かしら既存の
|
|
231
|
+
# 中身がある場合は「間違ったディレクトリを開いた可能性がある」とみなして
|
|
232
|
+
# 何もしない(空ディレクトリ=新規作成したばかりで明らかに安全、という
|
|
233
|
+
# ケースだけに絞る)。
|
|
234
|
+
def ensure_project_scaffold(root)
|
|
235
|
+
return unless Dir.empty?(root)
|
|
236
|
+
|
|
237
|
+
FileUtils.mkdir_p(File.join(root, APP_DIRNAME))
|
|
238
|
+
File.write(File.join(root, APP_DIRNAME, "app.rb"), "")
|
|
239
|
+
File.write(File.join(root, BUILD_CONFIG_FILENAME), "")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def json_error(status, message)
|
|
243
|
+
halt status, { error: message }.to_json
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def valid_name?(name)
|
|
247
|
+
name.is_a?(String) && MRBGEM_NAME_PATTERN.match?(name)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# 新規ファイル/フォルダの作成・削除で使う相対パスのチェック。safe_pathの
|
|
251
|
+
# パストラバーサル対策に加えて、ドット始まりのセグメント(R2P2_STATE_DIRNAME・
|
|
252
|
+
# GENERATED_BUILD_CONFIG_FILENAME等、UI上のファイル一覧に出さない前提の
|
|
253
|
+
# ものと衝突しうる)や空セグメントを拒否する。
|
|
254
|
+
def valid_relative_path?(rel)
|
|
255
|
+
return false if rel.nil? || rel.empty?
|
|
256
|
+
|
|
257
|
+
segments = rel.split("/")
|
|
258
|
+
segments.each do |seg|
|
|
259
|
+
return false if seg.empty? || seg == "." || seg == ".." || seg.start_with?(".")
|
|
260
|
+
end
|
|
261
|
+
true
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# 新規ファイル/新規フォルダの作成先を app/ 配下に限定するチェック。
|
|
265
|
+
# "app" 自身(プロジェクト作成時に既に存在する)は対象外で、その下に
|
|
266
|
+
# 最低1階層のセグメントが必要。
|
|
267
|
+
def under_app_dir?(rel)
|
|
268
|
+
prefix = "#{APP_DIRNAME}/"
|
|
269
|
+
rel.start_with?(prefix) && rel.length > prefix.length
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# プロジェクトが開かれていなくても使える/api/*(プロジェクト自体を開くための
|
|
274
|
+
# エンドポイント群)。残りの/api/*はプロジェクト(project_root)が開かれていないと
|
|
275
|
+
# 処理できないので、ここで先に弾いておくことで、各ルートで個別にnilチェックを
|
|
276
|
+
# 書かずに済む。
|
|
277
|
+
PROJECT_EXEMPT_API_PATHS = ["/api/project", "/api/browse"].freeze
|
|
278
|
+
|
|
279
|
+
# パターン付きbefore(例: `before %r{...}`)にすると、Sinatraがそのパターンを
|
|
280
|
+
# ルーティングエンジン(Mustermann)でコンパイルしようとする。Mustermannは
|
|
281
|
+
# 独自に文字列全体へのアンカーを付与する実装になっていて、渡した正規表現が
|
|
282
|
+
# (`\A`/`\z`はおろか`^`/`$`でも)既にアンカーを含むと
|
|
283
|
+
# `Mustermann::CompileError`になる(実際に起動して確認した)。パターン無しの
|
|
284
|
+
# `before do ... end`にして、Mustermannを経由しない素のRubyの文字列比較で
|
|
285
|
+
# パスを判定する。
|
|
286
|
+
before do
|
|
287
|
+
next unless request.path_info.start_with?("/api/") &&
|
|
288
|
+
!PROJECT_EXEMPT_API_PATHS.include?(request.path_info)
|
|
289
|
+
|
|
290
|
+
content_type :json
|
|
291
|
+
json_error(400, "project is not open") unless project_root
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
get "/" do
|
|
295
|
+
erb :index
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Funicular アプリの Ruby ソース。
|
|
299
|
+
get %r{/ruby/(.+\.rb)} do |rel|
|
|
300
|
+
full = File.expand_path(File.join(FUNICULAR_ROOT, "ruby", rel))
|
|
301
|
+
root_with_sep = File.join(FUNICULAR_ROOT, "ruby") + File::SEPARATOR
|
|
302
|
+
|
|
303
|
+
json_error(400, "invalid path") unless full.start_with?(root_with_sep)
|
|
304
|
+
halt 404 unless File.file?(full)
|
|
305
|
+
|
|
306
|
+
content_type "text/plain", charset: "utf-8"
|
|
307
|
+
File.read(full)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 現在開いているプロジェクト(絶対パス、未設定ならnull)と、このセッションで
|
|
311
|
+
# 一度でも開いたプロジェクトの一覧(known、メニューバーのドロップダウンの
|
|
312
|
+
# 選択肢)を返す。before フィルタの対象外にしてある唯一の/api/*エンドポイント。
|
|
313
|
+
get "/api/project" do
|
|
314
|
+
content_type :json
|
|
315
|
+
{ path: project_root, known: known_projects }.to_json
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# プロジェクトを開く(新規に指定した場合)、または既にknown_projectsに
|
|
319
|
+
# あるものへ切り替える(どちらも同じエンドポイントで、違いは渡されたpathが
|
|
320
|
+
# 既知かどうかだけ)。body(JSON): { path: "/abs/path/to/dir" }。
|
|
321
|
+
# 既存の(あるいはこれから使う)ディレクトリを指定するだけで良く、空の
|
|
322
|
+
# ディレクトリであってもensure_project_scaffoldが起動スクリプトの土台を
|
|
323
|
+
# 用意する。
|
|
324
|
+
post "/api/project" do
|
|
325
|
+
content_type :json
|
|
326
|
+
|
|
327
|
+
payload =
|
|
328
|
+
begin
|
|
329
|
+
JSON.parse(request.body.read)
|
|
330
|
+
rescue JSON::ParserError
|
|
331
|
+
json_error(400, "invalid json body")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
path = payload["path"].to_s.strip
|
|
335
|
+
json_error(400, "path is required") if path.empty?
|
|
336
|
+
|
|
337
|
+
full = File.expand_path(path)
|
|
338
|
+
json_error(400, "directory does not exist") unless File.directory?(full)
|
|
339
|
+
|
|
340
|
+
ensure_project_scaffold(full)
|
|
341
|
+
|
|
342
|
+
self.class.project_root = full
|
|
343
|
+
self.class.known_projects << full unless known_projects.include?(full)
|
|
344
|
+
ProjectStore.save(active: project_root, paths: known_projects, settings: project_settings)
|
|
345
|
+
|
|
346
|
+
{ status: "ok", path: full, known: known_projects }.to_json
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# --- ディレクトリブラウザ(「プロジェクトを開く」のフォルダ選択UI) ---------
|
|
350
|
+
#
|
|
351
|
+
# プロジェクトの絶対パスを一行のテキストで入力させる代わりに、「よくある
|
|
352
|
+
# ファイルビューア」のようにディレクトリを辿って選べるようにするためのAPI。
|
|
353
|
+
# プロジェクト自体をまだ開いていない状態でも使える必要がある(これを使って
|
|
354
|
+
# プロジェクトを開くため)ので、PROJECT_EXEMPT_API_PATHSに/api/browseを
|
|
355
|
+
# 含めてある。GET /api/files等(開いているプロジェクト内の対象拡張子の
|
|
356
|
+
# ファイル一覧)とは別物: ここではファイルシステム上の任意の場所の
|
|
357
|
+
# サブディレクトリだけを列挙する(どのプロジェクトにも属さない)。
|
|
358
|
+
|
|
359
|
+
# ドット始まりのディレクトリ(.git・.cache等)は、プロジェクト内のファイル
|
|
360
|
+
# 一覧(GET /api/files等)と同じ理由で一覧から除外し、見た目をすっきりさせる。
|
|
361
|
+
def list_browse_entries(path)
|
|
362
|
+
names = Dir.children(path).select do |name|
|
|
363
|
+
!name.start_with?(".") && File.directory?(File.join(path, name))
|
|
364
|
+
end
|
|
365
|
+
names.sort.map { |name| { name: name, path: File.join(path, name) } }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# ファイルシステムのルート("/")では".."しても自分自身になるので、
|
|
369
|
+
# その場合はparentをnilにして「これ以上は上がれない」ことを表す。
|
|
370
|
+
def browse_parent(path)
|
|
371
|
+
parent = File.expand_path(File.join(path, ".."))
|
|
372
|
+
parent == path ? nil : parent
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# 指定ディレクトリ(省略時はホームディレクトリ)のサブディレクトリ一覧を返す。
|
|
376
|
+
# 「最初はホームディレクトリが開いている状態で」という要望により、
|
|
377
|
+
# pathが空ならDir.home(Linux上は$HOME)を使う。
|
|
378
|
+
get "/api/browse" do
|
|
379
|
+
content_type :json
|
|
380
|
+
|
|
381
|
+
raw = params[:path].to_s.strip
|
|
382
|
+
full = raw.empty? ? Dir.home : File.expand_path(raw)
|
|
383
|
+
|
|
384
|
+
json_error(400, "directory does not exist") unless File.directory?(full)
|
|
385
|
+
|
|
386
|
+
entries =
|
|
387
|
+
begin
|
|
388
|
+
list_browse_entries(full)
|
|
389
|
+
rescue Errno::EACCES
|
|
390
|
+
json_error(400, "permission denied")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
{ path: full, parent: browse_parent(full), entries: entries }.to_json
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# 新しいフォルダを作成する。body(JSON): { path: "親ディレクトリの絶対パス", name: "新しいフォルダ名" }。
|
|
397
|
+
# 「新しいフォルダ」ボタンを押す動機は大抵そこを開きたいからなので、
|
|
398
|
+
# 作成後はそのフォルダ自身をGETと同じ形(path/parent/entries)で返し、
|
|
399
|
+
# フロント側はそのままそこへ移動したものとして扱う。
|
|
400
|
+
post "/api/browse" do
|
|
401
|
+
content_type :json
|
|
402
|
+
|
|
403
|
+
payload =
|
|
404
|
+
begin
|
|
405
|
+
JSON.parse(request.body.read)
|
|
406
|
+
rescue JSON::ParserError
|
|
407
|
+
json_error(400, "invalid json body")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
parent_path = payload["path"].to_s.strip
|
|
411
|
+
json_error(400, "path is required") if parent_path.empty?
|
|
412
|
+
|
|
413
|
+
full_parent = File.expand_path(parent_path)
|
|
414
|
+
json_error(400, "directory does not exist") unless File.directory?(full_parent)
|
|
415
|
+
|
|
416
|
+
name = payload["name"].to_s.strip
|
|
417
|
+
json_error(400, "name is required") if name.empty?
|
|
418
|
+
json_error(400, "invalid name") if name.include?("/") || name == "." || name == ".."
|
|
419
|
+
|
|
420
|
+
full = File.join(full_parent, name)
|
|
421
|
+
json_error(409, "already exists") if File.exist?(full)
|
|
422
|
+
|
|
423
|
+
begin
|
|
424
|
+
Dir.mkdir(full)
|
|
425
|
+
rescue Errno::EACCES
|
|
426
|
+
json_error(400, "permission denied")
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
entries =
|
|
430
|
+
begin
|
|
431
|
+
list_browse_entries(full)
|
|
432
|
+
rescue Errno::EACCES
|
|
433
|
+
[]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
{ path: full, parent: full_parent, entries: entries }.to_json
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# 編集可能なファイル一覧を返す(現在開いているプロジェクト内)
|
|
440
|
+
get "/api/files" do
|
|
441
|
+
content_type :json
|
|
442
|
+
|
|
443
|
+
files = Dir.glob(File.join(project_root, "**", "*")).select do |f|
|
|
444
|
+
File.file?(f) && ALLOWED_EXTENSIONS.include?(File.extname(f))
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
relative_paths = files.map { |f| f.sub(project_root + File::SEPARATOR, "") }.sort
|
|
448
|
+
|
|
449
|
+
relative_paths.to_json
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# ディレクトリ一覧を返す。GET /api/files はファイルの並びからツリーを
|
|
453
|
+
# 組み立てる都合上、中身が空のディレクトリを表現できない(TreeView側は
|
|
454
|
+
# state[:files]から導出したノードしか描画しないため)。新規フォルダ作成
|
|
455
|
+
# (POST /api/folders)で空のフォルダを作れるようになったことでこれが
|
|
456
|
+
# 表面化するため、ディレクトリ一覧だけは別に取得できるようにしてある。
|
|
457
|
+
# ドット始まりのディレクトリ(.r2p2-esp32等)は GET /api/files と同じ理由
|
|
458
|
+
# (Dir.globの既定動作)で自動的に除外される。
|
|
459
|
+
get "/api/dirs" do
|
|
460
|
+
content_type :json
|
|
461
|
+
|
|
462
|
+
dirs = Dir.glob(File.join(project_root, "**", "*")).select { |f| File.directory?(f) }
|
|
463
|
+
relative_paths = dirs.map { |f| f.sub(project_root + File::SEPARATOR, "") }.sort
|
|
464
|
+
|
|
465
|
+
relative_paths.to_json
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# 指定ファイルの内容を返す
|
|
469
|
+
get "/api/file" do
|
|
470
|
+
content_type :json
|
|
471
|
+
|
|
472
|
+
rel = params[:path]
|
|
473
|
+
json_error(400, "path is required") if rel.nil? || rel.empty?
|
|
474
|
+
|
|
475
|
+
full =
|
|
476
|
+
begin
|
|
477
|
+
safe_path(project_root, rel)
|
|
478
|
+
rescue ArgumentError
|
|
479
|
+
json_error(400, "invalid path")
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
json_error(404, "file not found") unless File.file?(full)
|
|
483
|
+
json_error(400, "unsupported file type") unless ALLOWED_EXTENSIONS.include?(File.extname(full))
|
|
484
|
+
|
|
485
|
+
{ path: rel, content: File.read(full) }.to_json
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# ファイル内容を保存する
|
|
489
|
+
post "/api/file" do
|
|
490
|
+
content_type :json
|
|
491
|
+
|
|
492
|
+
payload =
|
|
493
|
+
begin
|
|
494
|
+
JSON.parse(request.body.read)
|
|
495
|
+
rescue JSON::ParserError
|
|
496
|
+
json_error(400, "invalid json body")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
rel = payload["path"]
|
|
500
|
+
content = payload["content"]
|
|
501
|
+
|
|
502
|
+
json_error(400, "path is required") if rel.nil? || rel.empty?
|
|
503
|
+
json_error(400, "content is required") if content.nil?
|
|
504
|
+
|
|
505
|
+
full =
|
|
506
|
+
begin
|
|
507
|
+
safe_path(project_root, rel)
|
|
508
|
+
rescue ArgumentError
|
|
509
|
+
json_error(400, "invalid path")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# 既存ファイルの上書きのみ許可(新規ファイル作成はサンプルでは対象外)
|
|
513
|
+
json_error(404, "file not found") unless File.file?(full)
|
|
514
|
+
json_error(400, "unsupported file type") unless ALLOWED_EXTENSIONS.include?(File.extname(full))
|
|
515
|
+
|
|
516
|
+
File.write(full, content)
|
|
517
|
+
|
|
518
|
+
{ status: "ok", path: rel, bytes: content.bytesize }.to_json
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# 新規ファイルを作成する(空ファイル)。POST /api/fileは既存ファイルの上書きしか
|
|
522
|
+
# 許可していないので、新規作成はこちらに分けてある。
|
|
523
|
+
post "/api/files" do
|
|
524
|
+
content_type :json
|
|
525
|
+
|
|
526
|
+
payload =
|
|
527
|
+
begin
|
|
528
|
+
JSON.parse(request.body.read)
|
|
529
|
+
rescue JSON::ParserError
|
|
530
|
+
json_error(400, "invalid json body")
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
rel = payload["path"].to_s
|
|
534
|
+
json_error(400, "path is required") if rel.empty?
|
|
535
|
+
json_error(400, "unsupported file type") unless File.extname(rel) == NEW_FILE_EXTENSION
|
|
536
|
+
json_error(400, "invalid path") unless valid_relative_path?(rel)
|
|
537
|
+
json_error(400, "files can only be created under #{APP_DIRNAME}/") unless under_app_dir?(rel)
|
|
538
|
+
|
|
539
|
+
full =
|
|
540
|
+
begin
|
|
541
|
+
safe_path(project_root, rel)
|
|
542
|
+
rescue ArgumentError
|
|
543
|
+
json_error(400, "invalid path")
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
json_error(409, "file already exists") if File.exist?(full)
|
|
547
|
+
|
|
548
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
549
|
+
File.write(full, "")
|
|
550
|
+
|
|
551
|
+
{ status: "ok", path: rel }.to_json
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# 新規フォルダを作成する(空ディレクトリ)。新規ファイルと同じくapp/配下のみ許可する。
|
|
555
|
+
post "/api/folders" do
|
|
556
|
+
content_type :json
|
|
557
|
+
|
|
558
|
+
payload =
|
|
559
|
+
begin
|
|
560
|
+
JSON.parse(request.body.read)
|
|
561
|
+
rescue JSON::ParserError
|
|
562
|
+
json_error(400, "invalid json body")
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
rel = payload["path"].to_s
|
|
566
|
+
json_error(400, "path is required") if rel.empty?
|
|
567
|
+
json_error(400, "invalid path") unless valid_relative_path?(rel)
|
|
568
|
+
json_error(400, "folders can only be created under #{APP_DIRNAME}/") unless under_app_dir?(rel)
|
|
569
|
+
|
|
570
|
+
full =
|
|
571
|
+
begin
|
|
572
|
+
safe_path(project_root, rel)
|
|
573
|
+
rescue ArgumentError
|
|
574
|
+
json_error(400, "invalid path")
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
json_error(409, "folder already exists") if File.exist?(full)
|
|
578
|
+
|
|
579
|
+
FileUtils.mkdir_p(full)
|
|
580
|
+
|
|
581
|
+
{ status: "ok", path: rel }.to_json
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# ファイルを削除する。ツリーのコンテキストメニュー(「削除」)から呼ばれる。
|
|
585
|
+
# 新規作成とは違いapp/配下に限定しない(mrbgemの.c/.h等、プロジェクト内の
|
|
586
|
+
# ALLOWED_EXTENSIONSファイルであればどこでも削除できる)。
|
|
587
|
+
delete "/api/files" do
|
|
588
|
+
content_type :json
|
|
589
|
+
|
|
590
|
+
rel = params[:path].to_s
|
|
591
|
+
json_error(400, "path is required") if rel.empty?
|
|
592
|
+
|
|
593
|
+
full =
|
|
594
|
+
begin
|
|
595
|
+
safe_path(project_root, rel)
|
|
596
|
+
rescue ArgumentError
|
|
597
|
+
json_error(400, "invalid path")
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
json_error(404, "file not found") unless File.file?(full)
|
|
601
|
+
json_error(400, "unsupported file type") unless ALLOWED_EXTENSIONS.include?(File.extname(full))
|
|
602
|
+
|
|
603
|
+
File.delete(full)
|
|
604
|
+
|
|
605
|
+
{ status: "ok", path: rel }.to_json
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# フォルダを再帰的に削除する。app/自身・mrbgems/自身は構造上必須
|
|
609
|
+
# (app/はR2P2起動スクリプトの置き場所、mrbgems/は「mrbgemを追加」専用の
|
|
610
|
+
# 置き場所)なので削除させない。UIのコンテキストメニューでもこの2つには
|
|
611
|
+
# 「削除」を出していないが、APIを直接叩かれた場合の防御として二重にチェックする。
|
|
612
|
+
delete "/api/folders" do
|
|
613
|
+
content_type :json
|
|
614
|
+
|
|
615
|
+
rel = params[:path].to_s
|
|
616
|
+
json_error(400, "path is required") if rel.empty?
|
|
617
|
+
json_error(400, "invalid path") unless valid_relative_path?(rel)
|
|
618
|
+
json_error(400, "cannot delete this folder") if rel == APP_DIRNAME || rel == MRBGEMS_DIRNAME
|
|
619
|
+
|
|
620
|
+
full =
|
|
621
|
+
begin
|
|
622
|
+
safe_path(project_root, rel)
|
|
623
|
+
rescue ArgumentError
|
|
624
|
+
json_error(400, "invalid path")
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
json_error(404, "folder not found") unless File.directory?(full)
|
|
628
|
+
|
|
629
|
+
FileUtils.rm_rf(full)
|
|
630
|
+
|
|
631
|
+
{ status: "ok", path: rel }.to_json
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# --- プロジェクト設定(ターゲット・VM・USB Console) ----------------------
|
|
635
|
+
#
|
|
636
|
+
# 以前はビルドボタンを押すたびにダイアログでVM/USB Consoleを選ばせ、ターゲット
|
|
637
|
+
# チップはメニューバーの常設セレクトで選ぶ形だったが、「プロジェクトの設定として
|
|
638
|
+
# プロジェクトに含めたい」というフィードバックを受けて、プロジェクトごとに
|
|
639
|
+
# 隠しファイル`.config.yml`へ永続化する方式に変更した。その後「設定はワークスペース
|
|
640
|
+
# (当時はプロジェクトの上位概念だった)毎に持たせてほしい」という要望で
|
|
641
|
+
# `~/.picoride/workspace.toml`へ移し、さらに「ワークスペース=プロジェクトだった」
|
|
642
|
+
# というフィードバックで2階層構成自体を1階層に統合した結果、現在は単純に
|
|
643
|
+
# 「開いているプロジェクトの設定」として`~/.picoride/project.toml`
|
|
644
|
+
# (ProjectStore、project_root/known_projectsと同じファイル)に永続化している。
|
|
645
|
+
# UI側は「設定」ダイアログで編集し、ビルド/プラットフォームセットアップは
|
|
646
|
+
# その時点の設定値をそのまま使って即座に実行するだけになる。
|
|
647
|
+
|
|
648
|
+
get "/api/project/config" do
|
|
649
|
+
content_type :json
|
|
650
|
+
read_project_config.to_json
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# body(JSON): { platform:, vm:, usb_console: } — platform/vmはnull(未選択)も許可
|
|
654
|
+
post "/api/project/config" do
|
|
655
|
+
content_type :json
|
|
656
|
+
|
|
657
|
+
payload =
|
|
658
|
+
begin
|
|
659
|
+
JSON.parse(request.body.read)
|
|
660
|
+
rescue JSON::ParserError
|
|
661
|
+
json_error(400, "invalid json body")
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
platform = payload["platform"]
|
|
665
|
+
json_error(400, "invalid platform") if platform && !PLATFORM_TARGETS.include?(platform)
|
|
666
|
+
|
|
667
|
+
vm = payload["vm"]
|
|
668
|
+
json_error(400, "invalid vm") if vm && !BUILD_VM_FLAGS.key?(vm)
|
|
669
|
+
if vm && platform_family(platform) == "pico" && !PICO_BOARD_VMS.fetch(platform).include?(vm)
|
|
670
|
+
json_error(400, "#{platform} では #{vm} を使えません(使えるVM: #{PICO_BOARD_VMS.fetch(platform).join(', ')})")
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
config = { "platform" => platform, "vm" => vm, "usb_console" => payload["usb_console"] == true }
|
|
674
|
+
self.class.project_settings = project_settings.merge(project_root => config)
|
|
675
|
+
ProjectStore.save(active: project_root, paths: known_projects, settings: project_settings)
|
|
676
|
+
|
|
677
|
+
{ status: "ok" }.to_json
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# --- mrbgemのビルド組み込み -------------------------------------------
|
|
681
|
+
#
|
|
682
|
+
# R2P2-ESP32本体のbuild_config(components/picoruby-esp32/build_config/*.rb)は
|
|
683
|
+
# xtensa/riscv・femtoruby/picoruby の組み合わせで4種あり、ツールチェイン設定など
|
|
684
|
+
# 込み入った内容を持つ。これをプロジェクトごとに複製すると本家の変更に追従できなく
|
|
685
|
+
# なるため、複製はせず「本家の設定を評価した最後に、プロジェクト側の追加/除外だけを
|
|
686
|
+
# 差し込む」フックをDockerfileで1行だけ本家4ファイルに追加している(該当箇所は
|
|
687
|
+
# `conf.instance_eval(File.read(ENV['PROJECT_BUILD_CONFIG'])) if ...`)。
|
|
688
|
+
#
|
|
689
|
+
# mrbgems/以下にあるものは全部自動でビルドに含める(選ぶUIは無い)。
|
|
690
|
+
# プロジェクトの中に物理的にmrbgemを置く=そのプロジェクトで使う、という構造そのものが
|
|
691
|
+
# 選択を兼ねているので、ビルドのたびに`generated_build_config_content`で
|
|
692
|
+
# 「その時点でmrbgems/以下にあるgem一覧」から`conf.gem gemdir:`の並びを作り、
|
|
693
|
+
# その後ろにプロジェクトのbuild_config.rb(デフォルトgemを外したい場合はここに
|
|
694
|
+
# `conf.gems.reject!`を書く、普通の編集可能ファイル)をそのまま連結したものを
|
|
695
|
+
# PROJECT_BUILD_CONFIG経由で渡す。gemdir(自作mrbgemの絶対パス)は常にコンテナ内の
|
|
696
|
+
# 絶対パスで書く(相対パス解決の基点があいまいなmruby側の挙動に依存しないため)。
|
|
697
|
+
|
|
698
|
+
# mrbgems/直下のディレクトリ名一覧を返す(存在チェック・列挙はホスト側のrootを見る)。
|
|
699
|
+
def project_mrbgem_names(root)
|
|
700
|
+
mrbgems_dir = File.join(root, MRBGEMS_DIRNAME)
|
|
701
|
+
return [] unless Dir.exist?(mrbgems_dir)
|
|
702
|
+
|
|
703
|
+
Dir.children(mrbgems_dir).select { |name| File.directory?(File.join(mrbgems_dir, name)) }.sort
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# 最小構成のmrbgem.rake(mrbgemとして成立する最低限の内容)。
|
|
707
|
+
def mrbgem_rake_content(name)
|
|
708
|
+
<<~RUBY
|
|
709
|
+
MRuby::Gem::Specification.new("#{name}") do |spec|
|
|
710
|
+
spec.license = "MIT"
|
|
711
|
+
spec.authors = "#{name}"
|
|
712
|
+
spec.summary = "#{name}"
|
|
713
|
+
end
|
|
714
|
+
RUBY
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# mrbgems/以下に新しいmrbgemの雛形(mrbgem.rake・mrblib/<name>.rb)を作る。
|
|
718
|
+
# ここに置くだけで次回ビルドから自動的に組み込まれる
|
|
719
|
+
# (generated_build_config_content参照。選ぶUIは無い)。
|
|
720
|
+
post "/api/mrbgems" do
|
|
721
|
+
content_type :json
|
|
722
|
+
|
|
723
|
+
payload =
|
|
724
|
+
begin
|
|
725
|
+
JSON.parse(request.body.read)
|
|
726
|
+
rescue JSON::ParserError
|
|
727
|
+
json_error(400, "invalid json body")
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
gem_name = payload["name"].to_s
|
|
731
|
+
json_error(400, "invalid mrbgem name") unless valid_name?(gem_name)
|
|
732
|
+
|
|
733
|
+
gem_dir = File.join(project_root, MRBGEMS_DIRNAME, gem_name)
|
|
734
|
+
json_error(409, "mrbgem already exists") if File.exist?(gem_dir)
|
|
735
|
+
|
|
736
|
+
FileUtils.mkdir_p(File.join(gem_dir, "mrblib"))
|
|
737
|
+
File.write(File.join(gem_dir, "mrbgem.rake"), mrbgem_rake_content(gem_name))
|
|
738
|
+
File.write(File.join(gem_dir, "mrblib", "#{gem_name}.rb"), "")
|
|
739
|
+
|
|
740
|
+
{ status: "ok", name: gem_name }.to_json
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def generated_build_config_content(root)
|
|
744
|
+
lines = ["# picoride が自動生成する部分(#{MRBGEMS_DIRNAME}/以下のgemを追加する)"]
|
|
745
|
+
project_mrbgem_names(root).each do |name|
|
|
746
|
+
# gemdirはビルドコンテナの中から見たパス(CONTAINER_PROJECT_MOUNT配下)で書く。
|
|
747
|
+
# ホスト側の実パスではない点に注意(builder_docker_commandでproject_srcとしてマウントする)。
|
|
748
|
+
container_path = File.join(CONTAINER_PROJECT_MOUNT, MRBGEMS_DIRNAME, name)
|
|
749
|
+
lines << "conf.gem gemdir: #{container_path.inspect}"
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
build_config_path = File.join(root, BUILD_CONFIG_FILENAME)
|
|
753
|
+
if File.file?(build_config_path)
|
|
754
|
+
lines << ""
|
|
755
|
+
lines << "# ここから下は #{BUILD_CONFIG_FILENAME} の内容"
|
|
756
|
+
lines << File.read(build_config_path)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
lines.join("\n") + "\n"
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# ビルド専用コンテナ(ファミリーごとのBUILDER_IMAGES)を使い捨てで起動し、
|
|
763
|
+
# その中でinner_cmdを実行するコマンド文字列を組み立てる。state_dir(プロジェクト
|
|
764
|
+
# ごとのビルド状態。ESP32ならR2P2-ESP32、Picoならpicoruby本体)を
|
|
765
|
+
# CONTAINER_STATE_MOUNTS[family]に、project_root(mrbgems/やapp/を含む
|
|
766
|
+
# プロジェクト本体)をCONTAINER_PROJECT_MOUNTにbind mountする。
|
|
767
|
+
#
|
|
768
|
+
# ESP32では idf.py / rake が ESP-IDF の export.sh を読み込んだシェルでしか
|
|
769
|
+
# 使えないため、コンテナ内で明示的に読み込んでから inner_cmd を実行する
|
|
770
|
+
# (Pico用イメージはPATH・PICO_SDK_PATHがENVで設定済みなので不要)。
|
|
771
|
+
#
|
|
772
|
+
# コンテナは(`--user`を付けず)デフォルトのrootで動かす。rakelib/setup.rakeが
|
|
773
|
+
# 内部で呼ぶ`bundle install`がシステムのgemディレクトリ(/var/lib/gems/...)へ
|
|
774
|
+
# 書き込む必要があり、`--user <非root>`にすると権限エラーで失敗することを
|
|
775
|
+
# 実際に確認したため(root専用のシステムgemパスを使っている以上、非rootでは
|
|
776
|
+
# そもそも動かせない)。その代わり、bind mountしたstate_dir配下がroot所有のまま
|
|
777
|
+
# ホストに残ってプロジェクトディレクトリの持ち主(ホストユーザー)から扱いにくく
|
|
778
|
+
# なる問題を避けるため、コマンド完了後に必ず`chown`でホストユーザーの所有に戻す
|
|
779
|
+
# (`;`でつないでinner_cmdの成否に関わらず実行し、`inner_cmd`自体の終了コードは
|
|
780
|
+
# `$?`で保持して最後に`exit`し直すことで、chown自体の成否でBUILD_JOBの成功/失敗
|
|
781
|
+
# 判定が変わらないようにしている)。
|
|
782
|
+
def builder_docker_command(family, state_dir, project_dir, inner_cmd)
|
|
783
|
+
state_mount = CONTAINER_STATE_MOUNTS.fetch(family)
|
|
784
|
+
chown_target = "#{Process.uid}:#{Process.gid}"
|
|
785
|
+
env_setup = family == "esp32" ? ". \"$IDF_PATH/export.sh\" > /dev/null && " : ""
|
|
786
|
+
full_inner_cmd =
|
|
787
|
+
"#{env_setup}cd #{Shellwords.escape(state_mount)} && " \
|
|
788
|
+
"#{inner_cmd}; " \
|
|
789
|
+
"exit_code=$?; " \
|
|
790
|
+
"chown -R #{chown_target} #{Shellwords.escape(state_mount)}; " \
|
|
791
|
+
"exit $exit_code"
|
|
792
|
+
|
|
793
|
+
"docker run --rm " \
|
|
794
|
+
"-v #{Shellwords.escape(state_dir)}:#{state_mount} " \
|
|
795
|
+
"-v #{Shellwords.escape(project_dir)}:#{CONTAINER_PROJECT_MOUNT} " \
|
|
796
|
+
"#{Shellwords.escape(BUILDER_IMAGES.fetch(family))} " \
|
|
797
|
+
"bash -c #{Shellwords.escape(full_inner_cmd)}"
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# ファミリーのビルド用イメージがローカルに存在するかどうか。無いままdocker runすると
|
|
801
|
+
# 分かりにくいエラーになるので、事前にチェックして案内を出せるようにする。
|
|
802
|
+
def builder_image_available?(family)
|
|
803
|
+
system("docker", "image", "inspect", BUILDER_IMAGES.fetch(family), out: File::NULL, err: File::NULL)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def ensure_builder_image!(family)
|
|
807
|
+
return if builder_image_available?(family)
|
|
808
|
+
|
|
809
|
+
image = BUILDER_IMAGES.fetch(family)
|
|
810
|
+
json_error(500, "ビルド用イメージ #{image} が見つかりません。" \
|
|
811
|
+
"picorideのディレクトリで docker build -t #{image} #{BUILDER_DOCKERFILE_DIRS.fetch(family)} を実行してください")
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# R2P2State.ensure_checkout / ensure_pico_checkout は git clone 等に失敗すると
|
|
815
|
+
# 例外を投げる(R2P2State.run! 参照)。Sinatraのリクエストハンドラ内で素通しすると
|
|
816
|
+
# 生の500になってしまうので、json_errorでラップする。
|
|
817
|
+
def ensure_build_checkout!(family, state_dir)
|
|
818
|
+
if family == "pico"
|
|
819
|
+
R2P2State.ensure_pico_checkout(state_dir)
|
|
820
|
+
else
|
|
821
|
+
R2P2State.ensure_checkout(state_dir)
|
|
822
|
+
end
|
|
823
|
+
rescue StandardError => e
|
|
824
|
+
name = family == "pico" ? "picoruby(R2P2)" : "R2P2-ESP32"
|
|
825
|
+
json_error(500, "#{name}のセットアップに失敗しました: #{e.message}")
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# R2P2-ESP32 のビルド状態(実行中/成功/失敗/未実行)とログを返す。
|
|
829
|
+
# UI 側はこれをポーリングして進捗を表示する。
|
|
830
|
+
get "/api/build" do
|
|
831
|
+
content_type :json
|
|
832
|
+
BUILD_JOB.to_response_json
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# idf.py build -DPICORB_VM=xxx に渡すフラグ(R2P2-ESP32/Rakefile の PICORB_VMS と同じ対応)。
|
|
836
|
+
BUILD_VM_FLAGS = { "femtoruby" => "mrubyc", "picoruby" => "mruby" }.freeze
|
|
837
|
+
|
|
838
|
+
# Pico各ボードで使えるVM(picoruby/build_config/r2p2-{vm}-{board}.rb が存在する組み合わせ。
|
|
839
|
+
# RP2040(pico/pico_w)はmruby VMのビルド設定が無い)。先頭がVM未指定時の既定値。
|
|
840
|
+
PICO_BOARD_VMS = {
|
|
841
|
+
"pico" => %w[femtoruby],
|
|
842
|
+
"pico_w" => %w[femtoruby],
|
|
843
|
+
"pico2" => %w[femtoruby picoruby],
|
|
844
|
+
"pico2_w" => %w[femtoruby picoruby]
|
|
845
|
+
}.freeze
|
|
846
|
+
|
|
847
|
+
# Picoのビルドモード(`rake r2p2:{vm}:{board}:{mode}`のmode)。IDEからは
|
|
848
|
+
# 配布用のprodだけを使う(debugはUART経由のデバッグ出力等、開発者向け)。
|
|
849
|
+
PICO_BUILD_MODE = "prod"
|
|
850
|
+
|
|
851
|
+
# 外部USB-UART変換チップを持たないボード向けの設定フラグメント
|
|
852
|
+
# (R2P2-ESP32/sdkconfigs/usb_console、README.md「Hardware-specific Configuration」参照)。
|
|
853
|
+
USB_CONSOLE_SDKCONFIG_DEFAULTS = "sdkconfig.defaults;sdkconfigs/usb_console"
|
|
854
|
+
|
|
855
|
+
# 現在の sdkconfig が USB Console設定でビルドされているかどうか。
|
|
856
|
+
# sdkconfigが無い(セットアップ直後、または一度もビルドしていない)場合はfalse扱い。
|
|
857
|
+
def sdkconfig_has_usb_console?(state_dir)
|
|
858
|
+
sdkconfig_path = File.join(state_dir, "sdkconfig")
|
|
859
|
+
return false unless File.file?(sdkconfig_path)
|
|
860
|
+
|
|
861
|
+
File.foreach(sdkconfig_path).any? { |line| line.start_with?("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y") }
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Picoのボード・VM設定(未指定VMは既定値)を解決する。不正な組み合わせなら400。
|
|
865
|
+
def pico_board_and_vm!(config)
|
|
866
|
+
board = config["platform"]
|
|
867
|
+
vms = PICO_BOARD_VMS.fetch(board)
|
|
868
|
+
vm = config["vm"] || vms.first
|
|
869
|
+
json_error(400, "#{board} では #{vm} を使えません(使えるVM: #{vms.join(', ')})") unless vms.include?(vm)
|
|
870
|
+
[board, vm]
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# mrbgems/以下の現在の一覧とbuild_config.rbから、実際にビルドへ渡す
|
|
874
|
+
# ファイルをビルドのたびに作り直す(generated_build_config_content参照)。
|
|
875
|
+
# 戻り値はPROJECT_BUILD_CONFIGに渡す「コンテナ内から見たパス」の代入文
|
|
876
|
+
# (ホスト側の実パスではない)。
|
|
877
|
+
def prepare_project_build_config(root)
|
|
878
|
+
generated_build_config = File.join(root, GENERATED_BUILD_CONFIG_FILENAME)
|
|
879
|
+
File.write(generated_build_config, generated_build_config_content(root))
|
|
880
|
+
container_build_config = File.join(CONTAINER_PROJECT_MOUNT, GENERATED_BUILD_CONFIG_FILENAME)
|
|
881
|
+
"PROJECT_BUILD_CONFIG=#{Shellwords.escape(container_build_config)}"
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# ビルドをバックグラウンドで開始する。
|
|
885
|
+
# 実行中に重ねて叩かれた場合は 409 を返す(同時に複数走らせない)。
|
|
886
|
+
# ターゲット・VM・USB Consoleはリクエストでは受け取らず、現在のプロジェクトの設定
|
|
887
|
+
# (read_project_config、`~/.picoride/project.toml`から読む)に保存されている
|
|
888
|
+
# 値をそのまま使う。ターゲットのファミリー(ESP32/Pico)によって、使うビルド用
|
|
889
|
+
# イメージ・状態ディレクトリ・ビルド手順が切り替わる。ビルド状態はプロジェクトごとに
|
|
890
|
+
# 独立している(build_state_dir)ので、初回はここでgit clone(R2P2State)が
|
|
891
|
+
# 走り、数分かかることがある。
|
|
892
|
+
post "/api/build" do
|
|
893
|
+
content_type :json
|
|
894
|
+
|
|
895
|
+
# BUILD_JOB/PLATFORM_JOBは別々のロックなので、これが無いと「ビルド中に別の
|
|
896
|
+
# リクエストでプラットフォームセットアップ(rake setup_xxxのdeep_clean)を始める」
|
|
897
|
+
# ことを防げず、同じプロジェクトのビルド状態ディレクトリを2つのdocker run
|
|
898
|
+
# コンテナが同時に書き換えてしまう(実際に発生し、ビルド中のファイルが
|
|
899
|
+
# セットアップのdeep_cleanで消えてErrno::ENOENTになった)。
|
|
900
|
+
json_error(409, "platform setup is running") if PLATFORM_JOB.running?
|
|
901
|
+
|
|
902
|
+
project_config = read_project_config
|
|
903
|
+
family = platform_family(project_config["platform"])
|
|
904
|
+
ensure_builder_image!(family)
|
|
905
|
+
|
|
906
|
+
root = project_root
|
|
907
|
+
state_dir = build_state_dir(root, family)
|
|
908
|
+
ensure_project_gitignore(root)
|
|
909
|
+
ensure_build_checkout!(family, state_dir)
|
|
910
|
+
|
|
911
|
+
inner_cmd =
|
|
912
|
+
if family == "pico"
|
|
913
|
+
pico_build_inner_command(root, project_config)
|
|
914
|
+
else
|
|
915
|
+
esp32_build_inner_command(root, state_dir, project_config)
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
started = BUILD_JOB.start(builder_docker_command(family, state_dir, root, inner_cmd))
|
|
919
|
+
json_error(409, "build already running") unless started
|
|
920
|
+
|
|
921
|
+
{ status: "ok" }.to_json
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# Pico: picorubyの`rake r2p2:{vm}:{board}:prod`(mruby/cのライブラリビルド +
|
|
925
|
+
# pico-sdkのcmakeビルド)を実行する。成果物の.uf2は
|
|
926
|
+
# build/r2p2/{vm}/{board}/prod/ にできる(pico_uf2_path参照)。
|
|
927
|
+
#
|
|
928
|
+
# ESP32と違い、app/以下をファームウェアに焼き込む仕組みは無い(R2P2はPicoの
|
|
929
|
+
# フラッシュをUSBマスストレージとして見せる方式で、ストレージのイメージを
|
|
930
|
+
# ビルド時に作らない)。app/はターミナルタブの「app/ をアップロード」
|
|
931
|
+
# (PicoModem)で書き込む。
|
|
932
|
+
#
|
|
933
|
+
# r2p2のcmakeはmain_task.rbのコンパイルに`<picoruby>/bin/mrbc`を使うが、
|
|
934
|
+
# cloneしたばかりのpicorubyにはこれが無く、r2p2タスク自体も作らない
|
|
935
|
+
# (`/picoruby/bin/mrbc: not found`で失敗することを実際のビルドで確認した)。
|
|
936
|
+
# mrbcはVMに依存しないスタンドアロンのコンパイラなので、無ければ先に
|
|
937
|
+
# build_config/mrbc.rb で1回だけビルドする(bin/mrbcへのシンボリックリンクができる)。
|
|
938
|
+
def pico_build_inner_command(root, project_config)
|
|
939
|
+
board, vm = pico_board_and_vm!(project_config)
|
|
940
|
+
"#{PICO_PREPARE_COMMAND} && " \
|
|
941
|
+
"{ [ -x bin/mrbc ] || MRUBY_CONFIG=build_config/mrbc.rb rake; } && " \
|
|
942
|
+
"#{prepare_project_build_config(root)} rake r2p2:#{vm}:#{board}:#{PICO_BUILD_MODE}"
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Wi-Fi付きボード(pico_w/pico2_w)のbuild_configが使うpicoruby-socket・
|
|
946
|
+
# picoruby-mbedtlsは、lwIP・mbedTLSをpicoruby側のpico-sdkサブモジュール
|
|
947
|
+
# (mrbgems/picoruby-r2p2/lib/pico-sdk/lib/{lwip,mbedtls})へのシンボリックリンク
|
|
948
|
+
# 経由で参照する。このサブモジュールはcloneしていない(pico-sdkはイメージ側の
|
|
949
|
+
# /opt/pico-sdkを使う。r2p2_state.rb参照)ので、コンテナ内でそのパスを
|
|
950
|
+
# $PICO_SDK_PATHへのシンボリックリンクにしておく(無いと
|
|
951
|
+
# `lwip/opt.h: No such file or directory`で失敗することを実際のビルドで確認した)。
|
|
952
|
+
# リンク先はコンテナ内のパスなので、ホスト側から見るとリンク切れになるが害は無い。
|
|
953
|
+
# `rake ...:clean`もbuild_configを読み込む(picoruby-socketのmrbgem.rakeは
|
|
954
|
+
# リンク切れだと例外を投げる)ので、セットアップでも同じ準備をする。
|
|
955
|
+
PICO_SDK_SUBMODULE_PATH = "mrbgems/picoruby-r2p2/lib/pico-sdk"
|
|
956
|
+
#
|
|
957
|
+
# 「中身があるか」ではなく「シンボリックリンクかどうか」で判定する。picoruby-mbedtlsの
|
|
958
|
+
# mrbgem.rakeは、リンク先が無いと自分でpico-sdkサブモジュールとその中のmbedtls
|
|
959
|
+
# だけをgit submodule update --initしてしまう(lwipは取らない)ので、一度でも
|
|
960
|
+
# その状態になると「ディレクトリはあるがlwipが空」になって中身チェックをすり抜ける
|
|
961
|
+
# (実際に起きた)。
|
|
962
|
+
PICO_PREPARE_COMMAND =
|
|
963
|
+
"{ [ -L #{PICO_SDK_SUBMODULE_PATH} ] || " \
|
|
964
|
+
"{ rm -rf #{PICO_SDK_SUBMODULE_PATH} && ln -s \"$PICO_SDK_PATH\" #{PICO_SDK_SUBMODULE_PATH}; }; }"
|
|
965
|
+
|
|
966
|
+
# ESP32: idf.py build。
|
|
967
|
+
def esp32_build_inner_command(root, state_dir, project_config)
|
|
968
|
+
# プロジェクトのapp/以下を実機の起動スクリプトにする(storage/home/を参照する
|
|
969
|
+
# R2P2本来の仕組み。詳細は上のコメント参照)。前回このプロジェクトをビルドした
|
|
970
|
+
# ときの残骸が残らないよう、まずstorage/home/を空にしてからコピーし直す。
|
|
971
|
+
storage_home_dir = File.join(state_dir, "storage", "home")
|
|
972
|
+
FileUtils.mkdir_p(storage_home_dir)
|
|
973
|
+
FileUtils.rm_rf(Dir.glob(File.join(storage_home_dir, "*")))
|
|
974
|
+
project_app_dir = File.join(root, APP_DIRNAME)
|
|
975
|
+
if Dir.exist?(project_app_dir)
|
|
976
|
+
FileUtils.cp_r(Dir.glob(File.join(project_app_dir, "*")), storage_home_dir)
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
vm = project_config["vm"]
|
|
980
|
+
usb_console = project_config["usb_console"]
|
|
981
|
+
|
|
982
|
+
build_cmd = +"idf.py build"
|
|
983
|
+
build_cmd << " -DPICORB_VM=#{BUILD_VM_FLAGS[vm]}" if vm
|
|
984
|
+
|
|
985
|
+
env_assignments = [prepare_project_build_config(root)]
|
|
986
|
+
|
|
987
|
+
if usb_console != sdkconfig_has_usb_console?(state_dir)
|
|
988
|
+
# SDKCONFIG_DEFAULTS は sdkconfig ファイルが無いときにしか読まれない仕組みなので、
|
|
989
|
+
# 現在の設定と要求された設定が食い違うときだけ sdkconfig を消してビルドし直す
|
|
990
|
+
# (README.md「If you change SDKCONFIG_DEFAULTS, delete the sdkconfig file and
|
|
991
|
+
# rebuild from scratch」参照。fullclean/deep_cleanでも消えないので明示的に消す)。
|
|
992
|
+
# 値が変わらない限りはこのクリーンビルドを避け、従来通りの差分ビルドのままにする。
|
|
993
|
+
sdkconfig_defaults = usb_console ? USB_CONSOLE_SDKCONFIG_DEFAULTS : "sdkconfig.defaults"
|
|
994
|
+
env_assignments << "SDKCONFIG_DEFAULTS=#{Shellwords.escape(sdkconfig_defaults)}"
|
|
995
|
+
"rm -f sdkconfig && #{env_assignments.join(' ')} #{build_cmd}"
|
|
996
|
+
else
|
|
997
|
+
"#{env_assignments.join(' ')} #{build_cmd}"
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# プラットフォーム(ターゲットチップ)セットアップの実行状態とログを返す。
|
|
1002
|
+
get "/api/platform" do
|
|
1003
|
+
content_type :json
|
|
1004
|
+
PLATFORM_JOB.to_response_json
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# 現在のプロジェクトの設定に設定されているターゲットのセットアップを
|
|
1008
|
+
# バックグラウンドで実行する。ターゲット自体はリクエストでは受け取らず、
|
|
1009
|
+
# 設定ダイアログで保存された値を使う。
|
|
1010
|
+
#
|
|
1011
|
+
# - ESP32: `rake setup_#{platform}`。deep_clean + setup(mrubyの再ビルド) +
|
|
1012
|
+
# idf.py set-target という重い処理の直列実行(R2P2-ESP32/rakelib/setup.rake参照)。
|
|
1013
|
+
# - Pico: `rake r2p2:{vm}:{board}:clean`。Picoはボード×VMごとにビルド
|
|
1014
|
+
# ディレクトリが分かれていてターゲット切り替えの準備は不要なので、
|
|
1015
|
+
# 「そのボードのビルド成果物を消してクリーンな状態からビルドし直せるようにする」
|
|
1016
|
+
# だけ(picorubyのチェックアウト自体はensure_build_checkout!で行われる)。
|
|
1017
|
+
post "/api/platform" do
|
|
1018
|
+
content_type :json
|
|
1019
|
+
|
|
1020
|
+
project_config = read_project_config
|
|
1021
|
+
platform = project_config["platform"]
|
|
1022
|
+
json_error(400, "platform is not configured for this project") if platform.to_s.empty?
|
|
1023
|
+
|
|
1024
|
+
# BUILD_JOBとの同時実行を防ぐ理由はPOST /api/build側のコメント参照。
|
|
1025
|
+
json_error(409, "build is running") if BUILD_JOB.running?
|
|
1026
|
+
|
|
1027
|
+
family = platform_family(platform)
|
|
1028
|
+
ensure_builder_image!(family)
|
|
1029
|
+
|
|
1030
|
+
root = project_root
|
|
1031
|
+
state_dir = build_state_dir(root, family)
|
|
1032
|
+
ensure_project_gitignore(root)
|
|
1033
|
+
ensure_build_checkout!(family, state_dir)
|
|
1034
|
+
|
|
1035
|
+
inner_cmd =
|
|
1036
|
+
if family == "pico"
|
|
1037
|
+
board, vm = pico_board_and_vm!(project_config)
|
|
1038
|
+
"#{PICO_PREPARE_COMMAND} && rake r2p2:#{vm}:#{board}:clean"
|
|
1039
|
+
else
|
|
1040
|
+
"rake setup_#{platform}"
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
started = PLATFORM_JOB.start(builder_docker_command(family, state_dir, root, inner_cmd))
|
|
1044
|
+
json_error(409, "platform setup already running") unless started
|
|
1045
|
+
|
|
1046
|
+
{ status: "ok", platform: platform }.to_json
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
# デバイスへの書き込み(インストール)は ESP Web Tools
|
|
1050
|
+
# (https://esphome.github.io/esp-web-tools/、index.html でCDN読み込み)経由の
|
|
1051
|
+
# ブラウザのWeb Serial APIで行う。サーバはビルド成果物からマニフェストと.binを
|
|
1052
|
+
# 配信するだけで、実際の書き込み処理はブラウザ側(esp-web-install-button)が担う。
|
|
1053
|
+
# ビルド成果物は現在のプロジェクトのR2P2-ESP32状態ディレクトリ(r2p2_state_dir)の下、
|
|
1054
|
+
# `build/`にある。
|
|
1055
|
+
|
|
1056
|
+
# idf.py set-target のターゲット名 → ESP Web Tools の chipFamily 名。
|
|
1057
|
+
# ESP Web Tools が対応しているチップ一覧(esp-web-tools/src/const.ts の Build#chipFamily)
|
|
1058
|
+
# のうち、R2P2-ESP32側がサポートしている(PLATFORM_TARGETSにある)ものだけ載せてある。
|
|
1059
|
+
CHIP_FAMILY_MAP = {
|
|
1060
|
+
"esp32" => "ESP32",
|
|
1061
|
+
"esp32c3" => "ESP32-C3",
|
|
1062
|
+
"esp32c6" => "ESP32-C6",
|
|
1063
|
+
"esp32h2" => "ESP32-H2",
|
|
1064
|
+
"esp32p4" => "ESP32-P4",
|
|
1065
|
+
"esp32s3" => "ESP32-S3"
|
|
1066
|
+
}.freeze
|
|
1067
|
+
|
|
1068
|
+
def firmware_build_dir(root)
|
|
1069
|
+
File.join(r2p2_state_dir(root), "build")
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# --- Raspberry Pi Pico のファームウェア(.uf2) ---------------------------
|
|
1073
|
+
#
|
|
1074
|
+
# PicoはESP Web Toolsのような「ブラウザから直接書き込む」仕組みを使わず、
|
|
1075
|
+
# BOOTSELボタンを押しながら接続すると現れるUSBドライブ(RPI-RP2 / RP2350)へ
|
|
1076
|
+
# .uf2をコピーするだけで書き込める。そのためサーバはビルド成果物の.uf2を
|
|
1077
|
+
# ダウンロードさせるだけにしてある(UI側はメニューバーの「UF2をダウンロード」)。
|
|
1078
|
+
|
|
1079
|
+
# 現在のプロジェクト設定(ボード・VM)に対応する最新の.uf2の絶対パス(無ければnil)。
|
|
1080
|
+
# ファイル名にビルド日付・リビジョンが入る(R2P2-FEMTORUBY-<version>-PICO-<date>-<rev>.uf2)
|
|
1081
|
+
# ため過去のビルドの.uf2が残りうるので、更新日時が最も新しいものを選ぶ。
|
|
1082
|
+
def pico_uf2_path(root, board, vm)
|
|
1083
|
+
dir = File.join(build_state_dir(root, "pico"), "build", "r2p2", vm, board, PICO_BUILD_MODE)
|
|
1084
|
+
Dir.glob(File.join(dir, "*.uf2")).max_by { |path| File.mtime(path) }
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
get "/api/firmware/uf2" do
|
|
1088
|
+
project_config = read_project_config
|
|
1089
|
+
json_error(400, "target is not a Raspberry Pi Pico") unless current_platform_family == "pico"
|
|
1090
|
+
|
|
1091
|
+
board, vm = pico_board_and_vm!(project_config)
|
|
1092
|
+
path = pico_uf2_path(project_root, board, vm)
|
|
1093
|
+
json_error(404, "not built yet") unless path
|
|
1094
|
+
|
|
1095
|
+
send_file path, type: "application/octet-stream", disposition: "attachment", filename: File.basename(path)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# ESP Web Tools 用のマニフェストを、直近のビルド成果物
|
|
1099
|
+
# (build/project_description.json の "target" と build/flash_args)から動的に組み立てる。
|
|
1100
|
+
# flash_args は `idf.py build` が生成する、esptool write_flash にそのまま渡せる
|
|
1101
|
+
# "<オフセット(16進)> <binへの相対パス>" の行の並び(1行目は --flash_mode 等のオプション行)。
|
|
1102
|
+
get "/api/firmware/manifest.json" do
|
|
1103
|
+
content_type :json
|
|
1104
|
+
|
|
1105
|
+
json_error(400, "target is not an ESP32") unless current_platform_family == "esp32"
|
|
1106
|
+
|
|
1107
|
+
root = project_root
|
|
1108
|
+
build_dir = firmware_build_dir(root)
|
|
1109
|
+
desc_path = File.join(build_dir, "project_description.json")
|
|
1110
|
+
flash_args_path = File.join(build_dir, "flash_args")
|
|
1111
|
+
json_error(404, "not built yet") unless File.file?(desc_path) && File.file?(flash_args_path)
|
|
1112
|
+
|
|
1113
|
+
target = JSON.parse(File.read(desc_path))["target"]
|
|
1114
|
+
chip_family = CHIP_FAMILY_MAP[target]
|
|
1115
|
+
json_error(500, "unsupported target: #{target}") unless chip_family
|
|
1116
|
+
|
|
1117
|
+
parts = File.readlines(flash_args_path).drop(1).filter_map do |line|
|
|
1118
|
+
line = line.strip
|
|
1119
|
+
next if line.empty?
|
|
1120
|
+
|
|
1121
|
+
offset_hex, rel_path = line.split(" ", 2)
|
|
1122
|
+
# rel_path は "bootloader/bootloader.bin" のようにサブディレクトリを含むことがある。
|
|
1123
|
+
# basename に切り詰めると実体(build/bootloader/bootloader.bin)と食い違って
|
|
1124
|
+
# 404になるため、相対パスのままURLに使う(下の配信ルート側もそれに合わせてある)。
|
|
1125
|
+
{ path: "/api/firmware/#{rel_path}", offset: Integer(offset_hex, 16) }
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
{
|
|
1129
|
+
name: "R2P2-ESP32",
|
|
1130
|
+
version: Time.now.strftime("%Y%m%d%H%M%S"),
|
|
1131
|
+
builds: [{ chipFamily: chip_family, parts: parts }]
|
|
1132
|
+
}.to_json
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# ビルド成果物の .bin ファイルを配信する。flash_args の相対パスをそのまま受け取る
|
|
1136
|
+
# (例: "bootloader/bootloader.bin" のようにサブディレクトリを含むことがある)ので、
|
|
1137
|
+
# 固定セグメントの :filename ではなくワイルドカードで受ける。
|
|
1138
|
+
# manifestのpartsが返すpathはここに合わせてある。
|
|
1139
|
+
get "/api/firmware/*" do
|
|
1140
|
+
rel_path = params[:splat].first
|
|
1141
|
+
json_error(400, "invalid filename") unless rel_path.end_with?(".bin")
|
|
1142
|
+
|
|
1143
|
+
full =
|
|
1144
|
+
begin
|
|
1145
|
+
safe_path(firmware_build_dir(project_root), rel_path)
|
|
1146
|
+
rescue ArgumentError
|
|
1147
|
+
json_error(400, "invalid path")
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
json_error(404, "file not found") unless File.file?(full)
|
|
1151
|
+
|
|
1152
|
+
content_type "application/octet-stream"
|
|
1153
|
+
File.read(full, mode: "rb")
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
end
|