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.
Files changed (121) hide show
  1. fbuild/__init__.py +390 -0
  2. fbuild/assets/example.txt +1 -0
  3. fbuild/build/__init__.py +117 -0
  4. fbuild/build/archive_creator.py +186 -0
  5. fbuild/build/binary_generator.py +444 -0
  6. fbuild/build/build_component_factory.py +131 -0
  7. fbuild/build/build_info_generator.py +624 -0
  8. fbuild/build/build_state.py +325 -0
  9. fbuild/build/build_utils.py +93 -0
  10. fbuild/build/compilation_executor.py +422 -0
  11. fbuild/build/compiler.py +165 -0
  12. fbuild/build/compiler_avr.py +574 -0
  13. fbuild/build/configurable_compiler.py +664 -0
  14. fbuild/build/configurable_linker.py +637 -0
  15. fbuild/build/flag_builder.py +214 -0
  16. fbuild/build/library_dependency_processor.py +185 -0
  17. fbuild/build/linker.py +708 -0
  18. fbuild/build/orchestrator.py +67 -0
  19. fbuild/build/orchestrator_avr.py +651 -0
  20. fbuild/build/orchestrator_esp32.py +878 -0
  21. fbuild/build/orchestrator_rp2040.py +719 -0
  22. fbuild/build/orchestrator_stm32.py +696 -0
  23. fbuild/build/orchestrator_teensy.py +580 -0
  24. fbuild/build/source_compilation_orchestrator.py +218 -0
  25. fbuild/build/source_scanner.py +516 -0
  26. fbuild/cli.py +717 -0
  27. fbuild/cli_utils.py +314 -0
  28. fbuild/config/__init__.py +16 -0
  29. fbuild/config/board_config.py +542 -0
  30. fbuild/config/board_loader.py +92 -0
  31. fbuild/config/ini_parser.py +369 -0
  32. fbuild/config/mcu_specs.py +88 -0
  33. fbuild/daemon/__init__.py +42 -0
  34. fbuild/daemon/async_client.py +531 -0
  35. fbuild/daemon/client.py +1505 -0
  36. fbuild/daemon/compilation_queue.py +293 -0
  37. fbuild/daemon/configuration_lock.py +865 -0
  38. fbuild/daemon/daemon.py +585 -0
  39. fbuild/daemon/daemon_context.py +293 -0
  40. fbuild/daemon/error_collector.py +263 -0
  41. fbuild/daemon/file_cache.py +332 -0
  42. fbuild/daemon/firmware_ledger.py +546 -0
  43. fbuild/daemon/lock_manager.py +508 -0
  44. fbuild/daemon/logging_utils.py +149 -0
  45. fbuild/daemon/messages.py +957 -0
  46. fbuild/daemon/operation_registry.py +288 -0
  47. fbuild/daemon/port_state_manager.py +249 -0
  48. fbuild/daemon/process_tracker.py +366 -0
  49. fbuild/daemon/processors/__init__.py +18 -0
  50. fbuild/daemon/processors/build_processor.py +248 -0
  51. fbuild/daemon/processors/deploy_processor.py +664 -0
  52. fbuild/daemon/processors/install_deps_processor.py +431 -0
  53. fbuild/daemon/processors/locking_processor.py +777 -0
  54. fbuild/daemon/processors/monitor_processor.py +285 -0
  55. fbuild/daemon/request_processor.py +457 -0
  56. fbuild/daemon/shared_serial.py +819 -0
  57. fbuild/daemon/status_manager.py +238 -0
  58. fbuild/daemon/subprocess_manager.py +316 -0
  59. fbuild/deploy/__init__.py +21 -0
  60. fbuild/deploy/deployer.py +67 -0
  61. fbuild/deploy/deployer_esp32.py +310 -0
  62. fbuild/deploy/docker_utils.py +315 -0
  63. fbuild/deploy/monitor.py +519 -0
  64. fbuild/deploy/qemu_runner.py +603 -0
  65. fbuild/interrupt_utils.py +34 -0
  66. fbuild/ledger/__init__.py +52 -0
  67. fbuild/ledger/board_ledger.py +560 -0
  68. fbuild/output.py +352 -0
  69. fbuild/packages/__init__.py +66 -0
  70. fbuild/packages/archive_utils.py +1098 -0
  71. fbuild/packages/arduino_core.py +412 -0
  72. fbuild/packages/cache.py +256 -0
  73. fbuild/packages/concurrent_manager.py +510 -0
  74. fbuild/packages/downloader.py +518 -0
  75. fbuild/packages/fingerprint.py +423 -0
  76. fbuild/packages/framework_esp32.py +538 -0
  77. fbuild/packages/framework_rp2040.py +349 -0
  78. fbuild/packages/framework_stm32.py +459 -0
  79. fbuild/packages/framework_teensy.py +346 -0
  80. fbuild/packages/github_utils.py +96 -0
  81. fbuild/packages/header_trampoline_cache.py +394 -0
  82. fbuild/packages/library_compiler.py +203 -0
  83. fbuild/packages/library_manager.py +549 -0
  84. fbuild/packages/library_manager_esp32.py +725 -0
  85. fbuild/packages/package.py +163 -0
  86. fbuild/packages/platform_esp32.py +383 -0
  87. fbuild/packages/platform_rp2040.py +400 -0
  88. fbuild/packages/platform_stm32.py +581 -0
  89. fbuild/packages/platform_teensy.py +312 -0
  90. fbuild/packages/platform_utils.py +131 -0
  91. fbuild/packages/platformio_registry.py +369 -0
  92. fbuild/packages/sdk_utils.py +231 -0
  93. fbuild/packages/toolchain.py +436 -0
  94. fbuild/packages/toolchain_binaries.py +196 -0
  95. fbuild/packages/toolchain_esp32.py +489 -0
  96. fbuild/packages/toolchain_metadata.py +185 -0
  97. fbuild/packages/toolchain_rp2040.py +436 -0
  98. fbuild/packages/toolchain_stm32.py +417 -0
  99. fbuild/packages/toolchain_teensy.py +404 -0
  100. fbuild/platform_configs/esp32.json +150 -0
  101. fbuild/platform_configs/esp32c2.json +144 -0
  102. fbuild/platform_configs/esp32c3.json +143 -0
  103. fbuild/platform_configs/esp32c5.json +151 -0
  104. fbuild/platform_configs/esp32c6.json +151 -0
  105. fbuild/platform_configs/esp32p4.json +149 -0
  106. fbuild/platform_configs/esp32s3.json +151 -0
  107. fbuild/platform_configs/imxrt1062.json +56 -0
  108. fbuild/platform_configs/rp2040.json +70 -0
  109. fbuild/platform_configs/rp2350.json +76 -0
  110. fbuild/platform_configs/stm32f1.json +59 -0
  111. fbuild/platform_configs/stm32f4.json +63 -0
  112. fbuild/py.typed +0 -0
  113. fbuild-1.2.8.dist-info/METADATA +468 -0
  114. fbuild-1.2.8.dist-info/RECORD +121 -0
  115. fbuild-1.2.8.dist-info/WHEEL +5 -0
  116. fbuild-1.2.8.dist-info/entry_points.txt +5 -0
  117. fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
  118. fbuild-1.2.8.dist-info/top_level.txt +2 -0
  119. fbuild_lint/__init__.py +0 -0
  120. fbuild_lint/ruff_plugins/__init__.py +0 -0
  121. 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
+ ]