fbuild 1.2.8__py3-none-any.whl
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.
- fbuild/__init__.py +390 -0
- fbuild/assets/example.txt +1 -0
- fbuild/build/__init__.py +117 -0
- fbuild/build/archive_creator.py +186 -0
- fbuild/build/binary_generator.py +444 -0
- fbuild/build/build_component_factory.py +131 -0
- fbuild/build/build_info_generator.py +624 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +93 -0
- fbuild/build/compilation_executor.py +422 -0
- fbuild/build/compiler.py +165 -0
- fbuild/build/compiler_avr.py +574 -0
- fbuild/build/configurable_compiler.py +664 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +214 -0
- fbuild/build/library_dependency_processor.py +185 -0
- fbuild/build/linker.py +708 -0
- fbuild/build/orchestrator.py +67 -0
- fbuild/build/orchestrator_avr.py +651 -0
- fbuild/build/orchestrator_esp32.py +878 -0
- fbuild/build/orchestrator_rp2040.py +719 -0
- fbuild/build/orchestrator_stm32.py +696 -0
- fbuild/build/orchestrator_teensy.py +580 -0
- fbuild/build/source_compilation_orchestrator.py +218 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +717 -0
- fbuild/cli_utils.py +314 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +542 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +369 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +42 -0
- fbuild/daemon/async_client.py +531 -0
- fbuild/daemon/client.py +1505 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/configuration_lock.py +865 -0
- fbuild/daemon/daemon.py +585 -0
- fbuild/daemon/daemon_context.py +293 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/firmware_ledger.py +546 -0
- fbuild/daemon/lock_manager.py +508 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +957 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/port_state_manager.py +249 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +18 -0
- fbuild/daemon/processors/build_processor.py +248 -0
- fbuild/daemon/processors/deploy_processor.py +664 -0
- fbuild/daemon/processors/install_deps_processor.py +431 -0
- fbuild/daemon/processors/locking_processor.py +777 -0
- fbuild/daemon/processors/monitor_processor.py +285 -0
- fbuild/daemon/request_processor.py +457 -0
- fbuild/daemon/shared_serial.py +819 -0
- fbuild/daemon/status_manager.py +238 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +21 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +310 -0
- fbuild/deploy/docker_utils.py +315 -0
- fbuild/deploy/monitor.py +519 -0
- fbuild/deploy/qemu_runner.py +603 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/ledger/__init__.py +52 -0
- fbuild/ledger/board_ledger.py +560 -0
- fbuild/output.py +352 -0
- fbuild/packages/__init__.py +66 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +256 -0
- fbuild/packages/concurrent_manager.py +510 -0
- fbuild/packages/downloader.py +518 -0
- fbuild/packages/fingerprint.py +423 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_rp2040.py +349 -0
- fbuild/packages/framework_stm32.py +459 -0
- fbuild/packages/framework_teensy.py +346 -0
- fbuild/packages/github_utils.py +96 -0
- fbuild/packages/header_trampoline_cache.py +394 -0
- fbuild/packages/library_compiler.py +203 -0
- fbuild/packages/library_manager.py +549 -0
- fbuild/packages/library_manager_esp32.py +725 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_rp2040.py +400 -0
- fbuild/packages/platform_stm32.py +581 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +369 -0
- fbuild/packages/sdk_utils.py +231 -0
- fbuild/packages/toolchain.py +436 -0
- fbuild/packages/toolchain_binaries.py +196 -0
- fbuild/packages/toolchain_esp32.py +489 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_rp2040.py +436 -0
- fbuild/packages/toolchain_stm32.py +417 -0
- fbuild/packages/toolchain_teensy.py +404 -0
- fbuild/platform_configs/esp32.json +150 -0
- fbuild/platform_configs/esp32c2.json +144 -0
- fbuild/platform_configs/esp32c3.json +143 -0
- fbuild/platform_configs/esp32c5.json +151 -0
- fbuild/platform_configs/esp32c6.json +151 -0
- fbuild/platform_configs/esp32p4.json +149 -0
- fbuild/platform_configs/esp32s3.json +151 -0
- fbuild/platform_configs/imxrt1062.json +56 -0
- fbuild/platform_configs/rp2040.json +70 -0
- fbuild/platform_configs/rp2350.json +76 -0
- fbuild/platform_configs/stm32f1.json +59 -0
- fbuild/platform_configs/stm32f4.json +63 -0
- fbuild/py.typed +0 -0
- fbuild-1.2.8.dist-info/METADATA +468 -0
- fbuild-1.2.8.dist-info/RECORD +121 -0
- fbuild-1.2.8.dist-info/WHEEL +5 -0
- fbuild-1.2.8.dist-info/entry_points.txt +5 -0
- fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
- fbuild-1.2.8.dist-info/top_level.txt +2 -0
- fbuild_lint/__init__.py +0 -0
- fbuild_lint/ruff_plugins/__init__.py +0 -0
- fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QEMU runner module for ESP32 emulation using Docker.
|
|
3
|
+
|
|
4
|
+
This module handles running ESP32 firmware in QEMU using Docker containers
|
|
5
|
+
for ESP32-S3 and other ESP32 variants. It provides an alternative deployment
|
|
6
|
+
target for testing without physical hardware.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import _thread
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
# Docker image constants
|
|
21
|
+
DEFAULT_DOCKER_IMAGE = "espressif/idf:latest"
|
|
22
|
+
ALTERNATIVE_DOCKER_IMAGE = "espressif/idf:latest"
|
|
23
|
+
FALLBACK_DOCKER_IMAGE = "espressif/idf:release-v5.2"
|
|
24
|
+
|
|
25
|
+
# QEMU binary paths inside espressif/idf Docker container
|
|
26
|
+
QEMU_RISCV32_PATH = "/opt/esp/tools/qemu-riscv32/esp_develop_9.2.2_20250817/qemu/bin/qemu-system-riscv32"
|
|
27
|
+
QEMU_XTENSA_PATH = "/opt/esp/tools/qemu-xtensa/esp_develop_9.2.2_20250817/qemu/bin/qemu-system-xtensa"
|
|
28
|
+
|
|
29
|
+
# QEMU wrapper script template (formatted at runtime)
|
|
30
|
+
QEMU_WRAPPER_SCRIPT_TEMPLATE = """#!/bin/bash
|
|
31
|
+
set -e
|
|
32
|
+
echo "Starting {echo_target} QEMU emulation..."
|
|
33
|
+
echo "Firmware: {firmware_path}"
|
|
34
|
+
echo "Machine: {qemu_machine}"
|
|
35
|
+
echo "QEMU system: {qemu_system}"
|
|
36
|
+
echo "Container: $(cat /etc/os-release | head -1)"
|
|
37
|
+
|
|
38
|
+
# Check if firmware file exists
|
|
39
|
+
if [ ! -f "{firmware_path}" ]; then
|
|
40
|
+
echo "ERROR: Firmware file not found: {firmware_path}"
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Check firmware size
|
|
45
|
+
FIRMWARE_SIZE=$(stat -c%s "{firmware_path}")
|
|
46
|
+
echo "Firmware size: $FIRMWARE_SIZE bytes"
|
|
47
|
+
|
|
48
|
+
# Copy firmware to writable location since QEMU needs write access
|
|
49
|
+
cp "{firmware_path}" /tmp/flash.bin
|
|
50
|
+
echo "Copied firmware to writable location: /tmp/flash.bin"
|
|
51
|
+
|
|
52
|
+
# Try different QEMU configurations depending on machine type
|
|
53
|
+
if [ "{qemu_machine}" = "esp32c3" ]; then
|
|
54
|
+
# ESP32C3 uses RISC-V architecture
|
|
55
|
+
echo "Running {qemu_system} for {qemu_machine}"
|
|
56
|
+
{qemu_system} \\
|
|
57
|
+
-nographic \\
|
|
58
|
+
-machine {qemu_machine} \\
|
|
59
|
+
-drive file="/tmp/flash.bin",if=mtd,format=raw \\
|
|
60
|
+
-monitor none \\
|
|
61
|
+
-serial mon:stdio
|
|
62
|
+
else
|
|
63
|
+
# ESP32 uses Xtensa architecture
|
|
64
|
+
echo "Running {qemu_system} for {qemu_machine}"
|
|
65
|
+
{qemu_system} \\
|
|
66
|
+
-nographic \\
|
|
67
|
+
-machine {qemu_machine} \\
|
|
68
|
+
-drive file="/tmp/flash.bin",if=mtd,format=raw \\
|
|
69
|
+
-global driver=timer.esp32.timg,property=wdt_disable,value=true \\
|
|
70
|
+
-monitor none \\
|
|
71
|
+
-serial mon:stdio
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
echo "QEMU execution completed"
|
|
75
|
+
exit 0
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_docker_env() -> dict[str, str]:
|
|
80
|
+
"""Get environment for Docker commands, handling Git Bash/MSYS2 path conversion."""
|
|
81
|
+
env = os.environ.copy()
|
|
82
|
+
# Set UTF-8 encoding environment variables for Windows
|
|
83
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
84
|
+
env["PYTHONUTF8"] = "1"
|
|
85
|
+
# Only set MSYS_NO_PATHCONV if we're in a Git Bash/MSYS2 environment
|
|
86
|
+
if "MSYSTEM" in os.environ or os.environ.get("TERM") == "xterm" or "bash.exe" in os.environ.get("SHELL", ""):
|
|
87
|
+
env["MSYS_NO_PATHCONV"] = "1"
|
|
88
|
+
return env
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_docker_available() -> bool:
|
|
92
|
+
"""Check if Docker is available and running.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if Docker is available, False otherwise
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
["docker", "version"],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
timeout=10,
|
|
102
|
+
env=get_docker_env(),
|
|
103
|
+
)
|
|
104
|
+
return result.returncode == 0
|
|
105
|
+
except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ensure_docker_available() -> bool:
|
|
110
|
+
"""Ensure Docker is available, attempting to start daemon if necessary.
|
|
111
|
+
|
|
112
|
+
This function checks if Docker is installed and running, and attempts
|
|
113
|
+
to start Docker Desktop automatically if it's not running.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if Docker is now available, False otherwise
|
|
117
|
+
"""
|
|
118
|
+
from fbuild.deploy.docker_utils import ensure_docker_available as _ensure
|
|
119
|
+
|
|
120
|
+
return _ensure()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class QEMURunner:
|
|
124
|
+
"""Runner for ESP32 QEMU emulation using Docker containers.
|
|
125
|
+
|
|
126
|
+
This class handles running ESP32 firmware in a QEMU emulator inside
|
|
127
|
+
Docker containers. It supports esp32, esp32c3, and esp32s3 targets.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, docker_image: Optional[str] = None, verbose: bool = False):
|
|
131
|
+
"""Initialize QEMU runner.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
docker_image: Docker image to use, defaults to espressif/idf:latest
|
|
135
|
+
verbose: Whether to show verbose output
|
|
136
|
+
"""
|
|
137
|
+
self.docker_image = docker_image or DEFAULT_DOCKER_IMAGE
|
|
138
|
+
self.verbose = verbose
|
|
139
|
+
self.container_name: Optional[str] = None
|
|
140
|
+
# Use Linux-style paths for all containers since we're using Ubuntu/Alpine
|
|
141
|
+
self.firmware_mount_path = "/workspace/firmware"
|
|
142
|
+
|
|
143
|
+
def pull_image(self) -> bool:
|
|
144
|
+
"""Pull the Docker image if not already available.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if image is available, False otherwise
|
|
148
|
+
"""
|
|
149
|
+
print(f"Ensuring Docker image {self.docker_image} is available...")
|
|
150
|
+
try:
|
|
151
|
+
# Check if image exists locally
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
["docker", "images", "-q", self.docker_image],
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
env=get_docker_env(),
|
|
157
|
+
)
|
|
158
|
+
if result.stdout.strip():
|
|
159
|
+
print(f"Image {self.docker_image} already available locally")
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
# Image doesn't exist, pull it
|
|
163
|
+
print(f"Pulling Docker image: {self.docker_image}")
|
|
164
|
+
print("This may take a few minutes on first run...")
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["docker", "pull", self.docker_image],
|
|
167
|
+
env=get_docker_env(),
|
|
168
|
+
)
|
|
169
|
+
if result.returncode == 0:
|
|
170
|
+
print(f"Successfully pulled {self.docker_image}")
|
|
171
|
+
return True
|
|
172
|
+
else:
|
|
173
|
+
print(f"Failed to pull {self.docker_image}")
|
|
174
|
+
# Try alternative image
|
|
175
|
+
if self.docker_image == DEFAULT_DOCKER_IMAGE:
|
|
176
|
+
print(f"Trying fallback image: {FALLBACK_DOCKER_IMAGE}")
|
|
177
|
+
self.docker_image = FALLBACK_DOCKER_IMAGE
|
|
178
|
+
result = subprocess.run(
|
|
179
|
+
["docker", "pull", self.docker_image],
|
|
180
|
+
env=get_docker_env(),
|
|
181
|
+
)
|
|
182
|
+
return result.returncode == 0
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
_thread.interrupt_main()
|
|
187
|
+
raise
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"Error pulling Docker image: {e}")
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
def _prepare_firmware(self, firmware_path: Path, flash_size_mb: int = 4) -> Path:
|
|
193
|
+
"""Prepare firmware files for mounting into Docker container.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
firmware_path: Path to firmware.bin file
|
|
197
|
+
flash_size_mb: Flash size in MB (must be 2, 4, 8, or 16)
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Path to the prepared firmware directory
|
|
201
|
+
"""
|
|
202
|
+
if flash_size_mb not in [2, 4, 8, 16]:
|
|
203
|
+
raise ValueError(f"Flash size must be 2, 4, 8, or 16 MB, got {flash_size_mb}")
|
|
204
|
+
|
|
205
|
+
# Create temporary directory for firmware files
|
|
206
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="qemu_firmware_"))
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Copy firmware file
|
|
210
|
+
shutil.copy2(firmware_path, temp_dir / "firmware.bin")
|
|
211
|
+
|
|
212
|
+
# Create proper flash image for QEMU
|
|
213
|
+
flash_size = flash_size_mb * 1024 * 1024
|
|
214
|
+
|
|
215
|
+
# Read firmware content
|
|
216
|
+
firmware_data = firmware_path.read_bytes()
|
|
217
|
+
|
|
218
|
+
# Create flash image: firmware at beginning, rest filled with 0xFF
|
|
219
|
+
flash_data = firmware_data + b"\xff" * (flash_size - len(firmware_data))
|
|
220
|
+
|
|
221
|
+
# Ensure we have exactly the right size
|
|
222
|
+
if len(flash_data) > flash_size:
|
|
223
|
+
raise ValueError(f"Firmware size ({len(firmware_data)} bytes) exceeds flash size ({flash_size} bytes)")
|
|
224
|
+
|
|
225
|
+
flash_data = flash_data[:flash_size] # Truncate to exact size
|
|
226
|
+
|
|
227
|
+
(temp_dir / "flash.bin").write_bytes(flash_data)
|
|
228
|
+
|
|
229
|
+
return temp_dir
|
|
230
|
+
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
233
|
+
_thread.interrupt_main()
|
|
234
|
+
raise
|
|
235
|
+
except Exception as e:
|
|
236
|
+
# Clean up on error
|
|
237
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
238
|
+
raise e
|
|
239
|
+
|
|
240
|
+
def _get_qemu_config(self, machine: str) -> tuple[str, str, str]:
|
|
241
|
+
"""Get QEMU configuration for the given machine type.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
machine: QEMU machine type (esp32, esp32c3, esp32s3)
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Tuple of (qemu_system_path, qemu_machine, echo_target)
|
|
248
|
+
"""
|
|
249
|
+
if machine == "esp32c3":
|
|
250
|
+
return QEMU_RISCV32_PATH, "esp32c3", "ESP32C3"
|
|
251
|
+
elif machine == "esp32s3":
|
|
252
|
+
return QEMU_XTENSA_PATH, "esp32s3", "ESP32S3"
|
|
253
|
+
else:
|
|
254
|
+
# Default to ESP32 (Xtensa)
|
|
255
|
+
return QEMU_XTENSA_PATH, "esp32", "ESP32"
|
|
256
|
+
|
|
257
|
+
def _build_qemu_command(
|
|
258
|
+
self,
|
|
259
|
+
machine: str = "esp32",
|
|
260
|
+
) -> list[str]:
|
|
261
|
+
"""Build QEMU command to run inside Docker container.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
machine: QEMU machine type (esp32, esp32c3, esp32s3)
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
List of command arguments for QEMU
|
|
268
|
+
"""
|
|
269
|
+
firmware_path = f"{self.firmware_mount_path}/flash.bin"
|
|
270
|
+
qemu_system, qemu_machine, echo_target = self._get_qemu_config(machine)
|
|
271
|
+
|
|
272
|
+
# Format the wrapper script template with runtime values
|
|
273
|
+
wrapper_script = QEMU_WRAPPER_SCRIPT_TEMPLATE.format(
|
|
274
|
+
echo_target=echo_target,
|
|
275
|
+
firmware_path=firmware_path,
|
|
276
|
+
qemu_machine=qemu_machine,
|
|
277
|
+
qemu_system=qemu_system,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return ["bash", "-c", wrapper_script]
|
|
281
|
+
|
|
282
|
+
def _windows_to_docker_path(self, path: Path) -> str:
|
|
283
|
+
"""Convert Windows path to Docker volume mount format.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
path: Path to convert
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Docker-compatible path string
|
|
290
|
+
"""
|
|
291
|
+
# Check if we're in Git Bash/MSYS2 environment
|
|
292
|
+
is_git_bash = "MSYSTEM" in os.environ or os.environ.get("TERM") == "xterm" or "bash.exe" in os.environ.get("SHELL", "")
|
|
293
|
+
|
|
294
|
+
path_str = str(path)
|
|
295
|
+
|
|
296
|
+
if os.name == "nt" and is_git_bash:
|
|
297
|
+
# Convert C:\path\to\dir to /c/path/to/dir for Git Bash
|
|
298
|
+
path_str = path_str.replace("\\", "/")
|
|
299
|
+
if len(path_str) > 2 and path_str[1:3] == ":/": # Drive letter
|
|
300
|
+
path_str = "/" + path_str[0].lower() + path_str[2:]
|
|
301
|
+
|
|
302
|
+
return path_str
|
|
303
|
+
|
|
304
|
+
def run(
|
|
305
|
+
self,
|
|
306
|
+
firmware_path: Path,
|
|
307
|
+
machine: str = "esp32s3",
|
|
308
|
+
timeout: int = 30,
|
|
309
|
+
flash_size: int = 4,
|
|
310
|
+
interrupt_regex: Optional[str] = None,
|
|
311
|
+
output_file: Optional[Path] = None,
|
|
312
|
+
skip_pull: bool = False,
|
|
313
|
+
) -> int:
|
|
314
|
+
"""Run ESP32 firmware in QEMU using Docker.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
firmware_path: Path to firmware.bin file
|
|
318
|
+
machine: QEMU machine type (esp32, esp32c3, esp32s3)
|
|
319
|
+
timeout: Timeout in seconds (timeout is treated as success)
|
|
320
|
+
flash_size: Flash size in MB
|
|
321
|
+
interrupt_regex: Regex pattern to detect in output (informational)
|
|
322
|
+
output_file: Optional file path to write QEMU output to
|
|
323
|
+
skip_pull: Skip pulling Docker image (assumes image already exists)
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Exit code: 0 for success (including timeout), non-zero for error
|
|
327
|
+
"""
|
|
328
|
+
if not check_docker_available():
|
|
329
|
+
print("ERROR: Docker is not available or not running", file=sys.stderr)
|
|
330
|
+
print("Please install Docker and ensure it's running", file=sys.stderr)
|
|
331
|
+
print()
|
|
332
|
+
print("Install Docker:")
|
|
333
|
+
print(" - Windows/Mac: https://www.docker.com/products/docker-desktop")
|
|
334
|
+
print(" - Linux: https://docs.docker.com/engine/install/")
|
|
335
|
+
return 1
|
|
336
|
+
|
|
337
|
+
# Pull image if needed
|
|
338
|
+
if not skip_pull:
|
|
339
|
+
if not self.pull_image():
|
|
340
|
+
print("ERROR: Failed to pull Docker image", file=sys.stderr)
|
|
341
|
+
return 1
|
|
342
|
+
else:
|
|
343
|
+
print(f"Skipping image pull (using existing {self.docker_image})")
|
|
344
|
+
|
|
345
|
+
# Validate firmware path
|
|
346
|
+
if not firmware_path.exists():
|
|
347
|
+
print(f"ERROR: Firmware not found at {firmware_path}", file=sys.stderr)
|
|
348
|
+
return 1
|
|
349
|
+
|
|
350
|
+
# Prepare firmware files
|
|
351
|
+
print(f"Preparing firmware from: {firmware_path}")
|
|
352
|
+
temp_firmware_dir: Optional[Path] = None
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
temp_firmware_dir = self._prepare_firmware(firmware_path, flash_size)
|
|
356
|
+
|
|
357
|
+
# Generate unique container name
|
|
358
|
+
self.container_name = f"fbuild-qemu-{machine}-{int(time.time())}"
|
|
359
|
+
|
|
360
|
+
# Convert path for Docker volume mount
|
|
361
|
+
docker_firmware_path = self._windows_to_docker_path(temp_firmware_dir)
|
|
362
|
+
|
|
363
|
+
# Build Docker run command
|
|
364
|
+
docker_cmd = [
|
|
365
|
+
"docker",
|
|
366
|
+
"run",
|
|
367
|
+
"--rm",
|
|
368
|
+
"--name",
|
|
369
|
+
self.container_name,
|
|
370
|
+
"-v",
|
|
371
|
+
f"{docker_firmware_path}:{self.firmware_mount_path}:ro",
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
# Add image and QEMU command
|
|
375
|
+
docker_cmd.append(self.docker_image)
|
|
376
|
+
docker_cmd.extend(self._build_qemu_command(machine))
|
|
377
|
+
|
|
378
|
+
print(f"Running QEMU in Docker container: {self.container_name}")
|
|
379
|
+
if self.verbose:
|
|
380
|
+
print(f"Docker command: {' '.join(docker_cmd)}")
|
|
381
|
+
|
|
382
|
+
# Run Docker container with streaming output
|
|
383
|
+
return self._run_container_streaming(
|
|
384
|
+
docker_cmd,
|
|
385
|
+
timeout=timeout,
|
|
386
|
+
interrupt_regex=interrupt_regex,
|
|
387
|
+
output_file=output_file,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
except KeyboardInterrupt:
|
|
391
|
+
_thread.interrupt_main()
|
|
392
|
+
raise
|
|
393
|
+
except Exception as e:
|
|
394
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
395
|
+
return 1
|
|
396
|
+
|
|
397
|
+
finally:
|
|
398
|
+
# Cleanup temp directory
|
|
399
|
+
if temp_firmware_dir and temp_firmware_dir.exists():
|
|
400
|
+
shutil.rmtree(temp_firmware_dir, ignore_errors=True)
|
|
401
|
+
|
|
402
|
+
def _run_container_streaming(
|
|
403
|
+
self,
|
|
404
|
+
cmd: list[str],
|
|
405
|
+
timeout: int = 30,
|
|
406
|
+
interrupt_regex: Optional[str] = None,
|
|
407
|
+
output_file: Optional[Path] = None,
|
|
408
|
+
) -> int:
|
|
409
|
+
"""Run Docker container with streaming output.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
cmd: Docker command to run
|
|
413
|
+
timeout: Timeout in seconds
|
|
414
|
+
interrupt_regex: Regex pattern to detect in output
|
|
415
|
+
output_file: Optional file to write output to
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Exit code (0 for success/timeout, non-zero for error)
|
|
419
|
+
"""
|
|
420
|
+
env = get_docker_env()
|
|
421
|
+
output_handle = None
|
|
422
|
+
timeout_occurred = False
|
|
423
|
+
start_time = time.time()
|
|
424
|
+
|
|
425
|
+
if output_file:
|
|
426
|
+
try:
|
|
427
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
output_handle = open(output_file, "w", encoding="utf-8")
|
|
429
|
+
except KeyboardInterrupt:
|
|
430
|
+
_thread.interrupt_main()
|
|
431
|
+
raise
|
|
432
|
+
except Exception as e:
|
|
433
|
+
print(f"Warning: Could not open output file {output_file}: {e}")
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
# Start the Docker process
|
|
437
|
+
proc = subprocess.Popen(
|
|
438
|
+
cmd,
|
|
439
|
+
stdout=subprocess.PIPE,
|
|
440
|
+
stderr=subprocess.STDOUT,
|
|
441
|
+
env=env,
|
|
442
|
+
text=True,
|
|
443
|
+
errors="replace",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# Stream output
|
|
447
|
+
while True:
|
|
448
|
+
# Check timeout
|
|
449
|
+
if (time.time() - start_time) > timeout:
|
|
450
|
+
print(f"Timeout reached ({timeout}s), terminating container...")
|
|
451
|
+
timeout_occurred = True
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
# Read line (non-blocking would be better, but this works)
|
|
455
|
+
if proc.stdout:
|
|
456
|
+
line = proc.stdout.readline()
|
|
457
|
+
if not line:
|
|
458
|
+
# Process ended
|
|
459
|
+
if proc.poll() is not None:
|
|
460
|
+
break
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Print the line
|
|
464
|
+
print(line.rstrip())
|
|
465
|
+
|
|
466
|
+
# Write to output file
|
|
467
|
+
if output_handle:
|
|
468
|
+
output_handle.write(line)
|
|
469
|
+
output_handle.flush()
|
|
470
|
+
|
|
471
|
+
# Check for interrupt pattern
|
|
472
|
+
if interrupt_regex and re.search(interrupt_regex, line):
|
|
473
|
+
print(f"Pattern detected: {interrupt_regex}")
|
|
474
|
+
|
|
475
|
+
# Check if process ended
|
|
476
|
+
if proc.poll() is not None:
|
|
477
|
+
# Read any remaining output
|
|
478
|
+
if proc.stdout:
|
|
479
|
+
for remaining_line in proc.stdout:
|
|
480
|
+
print(remaining_line.rstrip())
|
|
481
|
+
if output_handle:
|
|
482
|
+
output_handle.write(remaining_line)
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
# Handle timeout case
|
|
486
|
+
if timeout_occurred:
|
|
487
|
+
# Stop the container
|
|
488
|
+
if self.container_name:
|
|
489
|
+
try:
|
|
490
|
+
subprocess.run(
|
|
491
|
+
["docker", "stop", "--time=1", self.container_name],
|
|
492
|
+
capture_output=True,
|
|
493
|
+
timeout=10,
|
|
494
|
+
env=env,
|
|
495
|
+
)
|
|
496
|
+
except KeyboardInterrupt:
|
|
497
|
+
_thread.interrupt_main()
|
|
498
|
+
raise
|
|
499
|
+
except Exception as e:
|
|
500
|
+
print(f"Warning: Failed to stop container: {e}")
|
|
501
|
+
|
|
502
|
+
# Wait for process to complete
|
|
503
|
+
try:
|
|
504
|
+
proc.wait(timeout=10)
|
|
505
|
+
except subprocess.TimeoutExpired:
|
|
506
|
+
proc.kill()
|
|
507
|
+
|
|
508
|
+
print("Process terminated due to timeout - treating as success")
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
# Return actual exit code
|
|
512
|
+
return proc.returncode if proc.returncode is not None else 1
|
|
513
|
+
|
|
514
|
+
except KeyboardInterrupt:
|
|
515
|
+
print("\nInterrupted by user")
|
|
516
|
+
if self.container_name:
|
|
517
|
+
try:
|
|
518
|
+
subprocess.run(
|
|
519
|
+
["docker", "stop", "--time=1", self.container_name],
|
|
520
|
+
capture_output=True,
|
|
521
|
+
timeout=10,
|
|
522
|
+
env=env,
|
|
523
|
+
)
|
|
524
|
+
except KeyboardInterrupt:
|
|
525
|
+
_thread.interrupt_main()
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
_thread.interrupt_main()
|
|
529
|
+
return 130
|
|
530
|
+
|
|
531
|
+
except Exception as e:
|
|
532
|
+
print(f"Error during execution: {e}")
|
|
533
|
+
return 1
|
|
534
|
+
|
|
535
|
+
finally:
|
|
536
|
+
if output_handle:
|
|
537
|
+
output_handle.close()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def map_board_to_machine(board_id: str) -> str:
|
|
541
|
+
"""Map board ID to QEMU machine type.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
board_id: Board identifier (e.g., 'esp32s3', 'esp32-c6-devkitc-1')
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
QEMU machine type (esp32, esp32c3, esp32s3)
|
|
548
|
+
"""
|
|
549
|
+
board_lower = board_id.lower()
|
|
550
|
+
|
|
551
|
+
# Map boards to QEMU machine types
|
|
552
|
+
if "esp32s3" in board_lower or "s3" in board_lower:
|
|
553
|
+
return "esp32s3"
|
|
554
|
+
elif "esp32c3" in board_lower or "c3" in board_lower:
|
|
555
|
+
return "esp32c3"
|
|
556
|
+
elif "esp32c6" in board_lower or "c6" in board_lower:
|
|
557
|
+
# ESP32-C6 uses RISC-V, similar to C3 but not fully supported in QEMU yet
|
|
558
|
+
# Fall back to esp32c3 for now
|
|
559
|
+
print("Note: ESP32-C6 QEMU support is limited, using esp32c3 emulation")
|
|
560
|
+
return "esp32c3"
|
|
561
|
+
else:
|
|
562
|
+
return "esp32"
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def main() -> int:
|
|
566
|
+
"""Main entry point for testing QEMU runner."""
|
|
567
|
+
import argparse
|
|
568
|
+
|
|
569
|
+
parser = argparse.ArgumentParser(description="Run ESP32 firmware in QEMU using Docker")
|
|
570
|
+
parser.add_argument("firmware_path", type=Path, help="Path to firmware.bin file")
|
|
571
|
+
parser.add_argument(
|
|
572
|
+
"--machine",
|
|
573
|
+
type=str,
|
|
574
|
+
default="esp32s3",
|
|
575
|
+
help="QEMU machine type: esp32, esp32c3, esp32s3 (default: esp32s3)",
|
|
576
|
+
)
|
|
577
|
+
parser.add_argument("--timeout", type=int, default=30, help="Timeout in seconds (default: 30)")
|
|
578
|
+
parser.add_argument("--flash-size", type=int, default=4, help="Flash size in MB (default: 4)")
|
|
579
|
+
parser.add_argument("--interrupt-regex", type=str, help="Regex pattern to detect in output")
|
|
580
|
+
parser.add_argument("--output-file", type=Path, help="File to write QEMU output to")
|
|
581
|
+
parser.add_argument("--skip-pull", action="store_true", help="Skip pulling Docker image")
|
|
582
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
|
583
|
+
|
|
584
|
+
args = parser.parse_args()
|
|
585
|
+
|
|
586
|
+
if not args.firmware_path.exists():
|
|
587
|
+
print(f"ERROR: Firmware path does not exist: {args.firmware_path}")
|
|
588
|
+
return 1
|
|
589
|
+
|
|
590
|
+
runner = QEMURunner(verbose=args.verbose)
|
|
591
|
+
return runner.run(
|
|
592
|
+
firmware_path=args.firmware_path,
|
|
593
|
+
machine=args.machine,
|
|
594
|
+
timeout=args.timeout,
|
|
595
|
+
flash_size=args.flash_size,
|
|
596
|
+
interrupt_regex=args.interrupt_regex,
|
|
597
|
+
output_file=args.output_file,
|
|
598
|
+
skip_pull=args.skip_pull,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
if __name__ == "__main__":
|
|
603
|
+
sys.exit(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Utilities for handling KeyboardInterrupt in try-except blocks.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to ensure KeyboardInterrupt is properly
|
|
4
|
+
propagated to the main thread when caught in exception handlers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import _thread
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def handle_keyboard_interrupt_properly(ke: KeyboardInterrupt) -> NoReturn:
|
|
12
|
+
"""Handle KeyboardInterrupt by propagating it to the main thread.
|
|
13
|
+
|
|
14
|
+
This utility ensures that KeyboardInterrupt is properly handled in try-except
|
|
15
|
+
blocks by calling _thread.interrupt_main() before re-raising the exception.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
try:
|
|
19
|
+
# Some code that might be interrupted
|
|
20
|
+
pass
|
|
21
|
+
except KeyboardInterrupt as ke:
|
|
22
|
+
handle_keyboard_interrupt_properly(ke)
|
|
23
|
+
except Exception:
|
|
24
|
+
# Handle other exceptions
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
ke: The KeyboardInterrupt exception to handle
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
KeyboardInterrupt: Always re-raises the exception after handling
|
|
32
|
+
"""
|
|
33
|
+
_thread.interrupt_main()
|
|
34
|
+
raise ke
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Board Ledger - Track attached chip/port mappings.
|
|
3
|
+
|
|
4
|
+
This module provides persistent caching of chip type detections for serial ports,
|
|
5
|
+
enabling faster operations by avoiding repeated esptool calls.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from fbuild.ledger import BoardLedger, detect_and_cache
|
|
9
|
+
>>>
|
|
10
|
+
>>> # Detect chip and cache result
|
|
11
|
+
>>> result = detect_and_cache("COM3")
|
|
12
|
+
>>> print(f"Chip: {result.chip_type}, Env: {result.environment}, Cached: {result.was_cached}")
|
|
13
|
+
>>>
|
|
14
|
+
>>> # Manual ledger operations
|
|
15
|
+
>>> ledger = BoardLedger()
|
|
16
|
+
>>> ledger.set_chip("COM4", "ESP32-C6")
|
|
17
|
+
>>> chip = ledger.get_chip("COM4")
|
|
18
|
+
>>> env = ledger.get_environment("COM4")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .board_ledger import (
|
|
22
|
+
CHIP_TO_ENVIRONMENT,
|
|
23
|
+
STALE_THRESHOLD_SECONDS,
|
|
24
|
+
VALID_CHIP_TYPES,
|
|
25
|
+
BoardLedger,
|
|
26
|
+
BoardLedgerError,
|
|
27
|
+
ChipDetectionError,
|
|
28
|
+
DetectionResult,
|
|
29
|
+
LedgerEntry,
|
|
30
|
+
detect_and_cache,
|
|
31
|
+
detect_chip_with_esptool,
|
|
32
|
+
get_environment_for_chip,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Main class
|
|
37
|
+
"BoardLedger",
|
|
38
|
+
# Data classes
|
|
39
|
+
"LedgerEntry",
|
|
40
|
+
"DetectionResult",
|
|
41
|
+
# Exceptions
|
|
42
|
+
"BoardLedgerError",
|
|
43
|
+
"ChipDetectionError",
|
|
44
|
+
# Functions
|
|
45
|
+
"detect_and_cache",
|
|
46
|
+
"detect_chip_with_esptool",
|
|
47
|
+
"get_environment_for_chip",
|
|
48
|
+
# Constants
|
|
49
|
+
"CHIP_TO_ENVIRONMENT",
|
|
50
|
+
"VALID_CHIP_TYPES",
|
|
51
|
+
"STALE_THRESHOLD_SECONDS",
|
|
52
|
+
]
|