fbuild 1.2.8__py3-none-any.whl → 1.2.15__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 +5 -1
- fbuild/build/configurable_compiler.py +49 -6
- fbuild/build/configurable_linker.py +14 -9
- fbuild/build/orchestrator_esp32.py +6 -3
- fbuild/build/orchestrator_rp2040.py +6 -2
- fbuild/cli.py +300 -5
- fbuild/config/ini_parser.py +13 -1
- fbuild/daemon/__init__.py +11 -0
- fbuild/daemon/async_client.py +5 -4
- fbuild/daemon/async_client_lib.py +1543 -0
- fbuild/daemon/async_protocol.py +825 -0
- fbuild/daemon/async_server.py +2100 -0
- fbuild/daemon/client.py +425 -13
- fbuild/daemon/configuration_lock.py +13 -13
- fbuild/daemon/connection.py +508 -0
- fbuild/daemon/connection_registry.py +579 -0
- fbuild/daemon/daemon.py +517 -164
- fbuild/daemon/daemon_context.py +72 -1
- fbuild/daemon/device_discovery.py +477 -0
- fbuild/daemon/device_manager.py +821 -0
- fbuild/daemon/error_collector.py +263 -263
- fbuild/daemon/file_cache.py +332 -332
- fbuild/daemon/firmware_ledger.py +46 -123
- fbuild/daemon/lock_manager.py +508 -508
- fbuild/daemon/messages.py +431 -0
- fbuild/daemon/operation_registry.py +288 -288
- fbuild/daemon/processors/build_processor.py +34 -1
- fbuild/daemon/processors/deploy_processor.py +1 -3
- fbuild/daemon/processors/locking_processor.py +7 -7
- fbuild/daemon/request_processor.py +457 -457
- fbuild/daemon/shared_serial.py +7 -7
- fbuild/daemon/status_manager.py +238 -238
- fbuild/daemon/subprocess_manager.py +316 -316
- fbuild/deploy/docker_utils.py +182 -2
- fbuild/deploy/monitor.py +1 -1
- fbuild/deploy/qemu_runner.py +71 -13
- fbuild/ledger/board_ledger.py +46 -122
- fbuild/output.py +238 -2
- fbuild/packages/library_compiler.py +15 -5
- fbuild/packages/library_manager.py +12 -6
- fbuild-1.2.15.dist-info/METADATA +569 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
- fbuild-1.2.8.dist-info/METADATA +0 -468
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/top_level.txt +0 -0
fbuild/deploy/docker_utils.py
CHANGED
|
@@ -3,6 +3,12 @@ Docker utilities for QEMU deployment.
|
|
|
3
3
|
|
|
4
4
|
This module provides utilities for managing Docker containers for ESP32 QEMU emulation,
|
|
5
5
|
including automatic Docker daemon startup detection and image management.
|
|
6
|
+
|
|
7
|
+
Enhanced Docker auto-start functionality based on patterns from fastled-wasm:
|
|
8
|
+
- WSL2 backend detection on Windows
|
|
9
|
+
- Docker Desktop restart workflow
|
|
10
|
+
- Retry logic for Docker client connection
|
|
11
|
+
- Cross-platform support (Windows, macOS, Linux)
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
import os
|
|
@@ -10,6 +16,7 @@ import platform
|
|
|
10
16
|
import subprocess
|
|
11
17
|
import sys
|
|
12
18
|
import time
|
|
19
|
+
from pathlib import Path
|
|
13
20
|
from typing import Optional
|
|
14
21
|
|
|
15
22
|
from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
|
|
@@ -73,11 +80,14 @@ def get_docker_desktop_path() -> Optional[str]:
|
|
|
73
80
|
|
|
74
81
|
if system == "Windows":
|
|
75
82
|
# Common locations for Docker Desktop on Windows
|
|
83
|
+
home_dir = Path.home()
|
|
76
84
|
paths = [
|
|
77
85
|
r"C:\Program Files\Docker\Docker\Docker Desktop.exe",
|
|
78
86
|
r"C:\Program Files (x86)\Docker\Docker\Docker Desktop.exe",
|
|
79
87
|
os.path.expandvars(r"%ProgramFiles%\Docker\Docker\Docker Desktop.exe"),
|
|
80
88
|
os.path.expandvars(r"%LocalAppData%\Programs\Docker\Docker\Docker Desktop.exe"),
|
|
89
|
+
str(home_dir / "AppData" / "Local" / "Docker" / "Docker Desktop.exe"),
|
|
90
|
+
str(home_dir / "AppData" / "Local" / "Programs" / "Docker" / "Docker" / "Docker Desktop.exe"),
|
|
81
91
|
]
|
|
82
92
|
for path in paths:
|
|
83
93
|
if os.path.exists(path):
|
|
@@ -99,11 +109,149 @@ def get_docker_desktop_path() -> Optional[str]:
|
|
|
99
109
|
return None
|
|
100
110
|
|
|
101
111
|
|
|
112
|
+
def _check_wsl2_docker_backend() -> tuple[bool, str]:
|
|
113
|
+
"""Check if Docker's WSL2 backend (docker-desktop) is running on Windows.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
tuple[bool, str]: (is_running, status_message)
|
|
117
|
+
"""
|
|
118
|
+
if platform.system() != "Windows":
|
|
119
|
+
return True, "Not Windows - WSL2 check not applicable"
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
["wsl", "--list", "--verbose"],
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
timeout=10,
|
|
127
|
+
env=get_docker_env(),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if result.returncode != 0:
|
|
131
|
+
return False, "WSL2 not installed (wsl command failed)"
|
|
132
|
+
|
|
133
|
+
output = result.stdout
|
|
134
|
+
|
|
135
|
+
# Handle WSL output encoding issues (Git Bash adds spaces between characters)
|
|
136
|
+
cleaned_lines = []
|
|
137
|
+
for line in output.split("\n"):
|
|
138
|
+
cleaned = line.replace("\x00", "").strip()
|
|
139
|
+
# Remove spaces between single characters: "d o c k e r" -> "docker"
|
|
140
|
+
if " " in cleaned and len([c for c in cleaned.split() if len(c) == 1]) > 3:
|
|
141
|
+
cleaned = cleaned.replace(" ", "")
|
|
142
|
+
cleaned_lines.append(cleaned)
|
|
143
|
+
|
|
144
|
+
# Detect status: running/stopped
|
|
145
|
+
for line in cleaned_lines:
|
|
146
|
+
if "docker-desktop" in line.lower():
|
|
147
|
+
if "running" in line.lower():
|
|
148
|
+
return True, "docker-desktop WSL2 backend is running"
|
|
149
|
+
elif "stopped" in line.lower():
|
|
150
|
+
return False, "docker-desktop WSL2 backend is stopped"
|
|
151
|
+
|
|
152
|
+
return False, "docker-desktop WSL2 distribution not found"
|
|
153
|
+
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
return False, "WSL2 not installed (wsl command not found)"
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
return False, "WSL2 command timed out"
|
|
158
|
+
except KeyboardInterrupt as ke:
|
|
159
|
+
handle_keyboard_interrupt_properly(ke)
|
|
160
|
+
return False, "Interrupted by user" # Will not reach here
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return False, f"Error checking WSL2 backend: {e}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _kill_docker_desktop_windows() -> bool:
|
|
166
|
+
"""Forcefully terminate Docker Desktop and backend processes on Windows.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
bool: True if processes were killed successfully
|
|
170
|
+
"""
|
|
171
|
+
if platform.system() != "Windows":
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
# Kill Docker Desktop GUI
|
|
176
|
+
subprocess.run(
|
|
177
|
+
["taskkill", "/F", "/IM", "Docker Desktop.exe"],
|
|
178
|
+
capture_output=True,
|
|
179
|
+
timeout=10,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Kill Docker backend engine
|
|
183
|
+
subprocess.run(
|
|
184
|
+
["taskkill", "/F", "/IM", "com.docker.backend.exe"],
|
|
185
|
+
capture_output=True,
|
|
186
|
+
timeout=10,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
time.sleep(3) # Allow cleanup
|
|
190
|
+
return True
|
|
191
|
+
except KeyboardInterrupt as ke:
|
|
192
|
+
handle_keyboard_interrupt_properly(ke)
|
|
193
|
+
return False # Will not reach here
|
|
194
|
+
except Exception:
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _restart_docker_desktop_windows() -> tuple[bool, str]:
|
|
199
|
+
"""Complete Docker Desktop restart workflow on Windows.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
tuple[bool, str]: (success, message)
|
|
203
|
+
"""
|
|
204
|
+
print(" Attempting to restart Docker Desktop to fix WSL2 backend...")
|
|
205
|
+
|
|
206
|
+
docker_path = get_docker_desktop_path()
|
|
207
|
+
if not docker_path:
|
|
208
|
+
return False, "Docker Desktop executable not found"
|
|
209
|
+
|
|
210
|
+
print(" Stopping Docker Desktop...")
|
|
211
|
+
_kill_docker_desktop_windows()
|
|
212
|
+
|
|
213
|
+
time.sleep(5) # Wait for cleanup
|
|
214
|
+
|
|
215
|
+
print(" Starting Docker Desktop...")
|
|
216
|
+
try:
|
|
217
|
+
subprocess.Popen(
|
|
218
|
+
[docker_path],
|
|
219
|
+
stdout=subprocess.DEVNULL,
|
|
220
|
+
stderr=subprocess.DEVNULL,
|
|
221
|
+
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
|
|
222
|
+
)
|
|
223
|
+
except KeyboardInterrupt as ke:
|
|
224
|
+
handle_keyboard_interrupt_properly(ke)
|
|
225
|
+
return False, "Interrupted by user" # Will not reach here
|
|
226
|
+
except Exception as e:
|
|
227
|
+
return False, f"Failed to start Docker Desktop: {e}"
|
|
228
|
+
|
|
229
|
+
print(" Waiting for Docker Desktop and WSL2 backend to initialize...")
|
|
230
|
+
|
|
231
|
+
# Poll for up to 2 minutes
|
|
232
|
+
for attempt in range(120):
|
|
233
|
+
time.sleep(1)
|
|
234
|
+
|
|
235
|
+
# Check if Docker engine is available
|
|
236
|
+
if check_docker_daemon_running():
|
|
237
|
+
# Also check WSL2 backend
|
|
238
|
+
wsl_running, _ = _check_wsl2_docker_backend()
|
|
239
|
+
if wsl_running:
|
|
240
|
+
return True, "Docker Desktop restarted successfully - WSL2 backend is running"
|
|
241
|
+
|
|
242
|
+
# Progress indicator every 15 seconds
|
|
243
|
+
if (attempt + 1) % 15 == 0:
|
|
244
|
+
print(f" Still waiting ({attempt + 1}s)...")
|
|
245
|
+
|
|
246
|
+
return False, "Docker Desktop started but WSL2 backend failed to initialize within 2 minutes"
|
|
247
|
+
|
|
248
|
+
|
|
102
249
|
def start_docker_daemon() -> bool:
|
|
103
250
|
"""Attempt to start the Docker daemon.
|
|
104
251
|
|
|
105
252
|
This function tries to start Docker Desktop on Windows/macOS
|
|
106
|
-
or the Docker service on Linux.
|
|
253
|
+
or the Docker service on Linux. On Windows, it includes WSL2
|
|
254
|
+
backend detection and restart workflow.
|
|
107
255
|
|
|
108
256
|
Returns:
|
|
109
257
|
True if Docker daemon started successfully, False otherwise
|
|
@@ -116,6 +264,22 @@ def start_docker_daemon() -> bool:
|
|
|
116
264
|
print("Docker daemon is not running. Attempting to start...")
|
|
117
265
|
|
|
118
266
|
if system == "Windows":
|
|
267
|
+
# Check WSL2 backend status on Windows
|
|
268
|
+
print(" Checking Docker Desktop WSL2 backend status...")
|
|
269
|
+
wsl_running, wsl_message = _check_wsl2_docker_backend()
|
|
270
|
+
print(f" WSL2 Status: {wsl_message}")
|
|
271
|
+
|
|
272
|
+
# Detect split-brain state and trigger restart
|
|
273
|
+
if not wsl_running and "stopped" in wsl_message.lower():
|
|
274
|
+
print(" Issue detected: Docker Desktop app may be running but WSL2 backend is stopped")
|
|
275
|
+
success, message = _restart_docker_desktop_windows()
|
|
276
|
+
if success:
|
|
277
|
+
print(f" {message}")
|
|
278
|
+
return True
|
|
279
|
+
else:
|
|
280
|
+
print(f" {message}")
|
|
281
|
+
return False
|
|
282
|
+
|
|
119
283
|
docker_path = get_docker_desktop_path()
|
|
120
284
|
if docker_path:
|
|
121
285
|
try:
|
|
@@ -126,9 +290,22 @@ def start_docker_daemon() -> bool:
|
|
|
126
290
|
stderr=subprocess.DEVNULL,
|
|
127
291
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
|
|
128
292
|
)
|
|
129
|
-
|
|
293
|
+
success = _wait_for_docker_daemon(timeout=120) # 2 minute timeout
|
|
294
|
+
|
|
295
|
+
if not success:
|
|
296
|
+
# If startup failed, try full restart
|
|
297
|
+
print(" Docker Desktop didn't start properly, attempting full restart...")
|
|
298
|
+
success, message = _restart_docker_desktop_windows()
|
|
299
|
+
if success:
|
|
300
|
+
print(f" {message}")
|
|
301
|
+
return True
|
|
302
|
+
else:
|
|
303
|
+
print(f" {message}")
|
|
304
|
+
return False
|
|
305
|
+
return success
|
|
130
306
|
except KeyboardInterrupt as ke:
|
|
131
307
|
handle_keyboard_interrupt_properly(ke)
|
|
308
|
+
return False
|
|
132
309
|
except Exception as e:
|
|
133
310
|
print(f"Failed to start Docker Desktop: {e}")
|
|
134
311
|
return False
|
|
@@ -148,6 +325,7 @@ def start_docker_daemon() -> bool:
|
|
|
148
325
|
return _wait_for_docker_daemon()
|
|
149
326
|
except KeyboardInterrupt as ke:
|
|
150
327
|
handle_keyboard_interrupt_properly(ke)
|
|
328
|
+
return False
|
|
151
329
|
except Exception as e:
|
|
152
330
|
print(f"Failed to start Docker Desktop: {e}")
|
|
153
331
|
return False
|
|
@@ -176,6 +354,7 @@ def start_docker_daemon() -> bool:
|
|
|
176
354
|
return _wait_for_docker_daemon()
|
|
177
355
|
except KeyboardInterrupt as ke:
|
|
178
356
|
handle_keyboard_interrupt_properly(ke)
|
|
357
|
+
return False
|
|
179
358
|
except Exception as e:
|
|
180
359
|
print(f"Failed to start Docker service: {e}")
|
|
181
360
|
|
|
@@ -278,6 +457,7 @@ def pull_docker_image(image_name: str, timeout: int = 600) -> bool:
|
|
|
278
457
|
return False
|
|
279
458
|
except KeyboardInterrupt as ke:
|
|
280
459
|
handle_keyboard_interrupt_properly(ke)
|
|
460
|
+
return False # Will not reach here, but satisfies type checker
|
|
281
461
|
except subprocess.TimeoutExpired:
|
|
282
462
|
print(f"Timeout pulling {image_name}")
|
|
283
463
|
return False
|
fbuild/deploy/monitor.py
CHANGED
|
@@ -450,7 +450,7 @@ class SerialMonitor:
|
|
|
450
450
|
|
|
451
451
|
return 1
|
|
452
452
|
except KeyboardInterrupt:
|
|
453
|
-
# Interrupt other threads
|
|
453
|
+
# Interrupt other threads (notify them of the interrupt)
|
|
454
454
|
_thread.interrupt_main()
|
|
455
455
|
|
|
456
456
|
elapsed_time = time.time() - start_time if "start_time" in locals() else 0.0
|
fbuild/deploy/qemu_runner.py
CHANGED
|
@@ -189,12 +189,26 @@ class QEMURunner:
|
|
|
189
189
|
print(f"Error pulling Docker image: {e}")
|
|
190
190
|
return False
|
|
191
191
|
|
|
192
|
-
def _prepare_firmware(self, firmware_path: Path, flash_size_mb: int = 4) -> Path:
|
|
192
|
+
def _prepare_firmware(self, firmware_path: Path, flash_size_mb: int = 4, machine: str = "esp32") -> Path:
|
|
193
193
|
"""Prepare firmware files for mounting into Docker container.
|
|
194
194
|
|
|
195
|
+
Creates a complete flash image with bootloader, partition table,
|
|
196
|
+
and application at their correct offsets for QEMU.
|
|
197
|
+
|
|
198
|
+
ESP32/ESP32-S2 Flash Layout:
|
|
199
|
+
- 0x1000: Bootloader (second stage)
|
|
200
|
+
- 0x8000: Partition table
|
|
201
|
+
- 0x10000: Application (firmware.bin)
|
|
202
|
+
|
|
203
|
+
ESP32-S3/ESP32-C3/ESP32-C6 Flash Layout:
|
|
204
|
+
- 0x0000: Bootloader (second stage)
|
|
205
|
+
- 0x8000: Partition table
|
|
206
|
+
- 0x10000: Application (firmware.bin)
|
|
207
|
+
|
|
195
208
|
Args:
|
|
196
209
|
firmware_path: Path to firmware.bin file
|
|
197
210
|
flash_size_mb: Flash size in MB (must be 2, 4, 8, or 16)
|
|
211
|
+
machine: QEMU machine type (esp32, esp32s3, esp32c3)
|
|
198
212
|
|
|
199
213
|
Returns:
|
|
200
214
|
Path to the prepared firmware directory
|
|
@@ -212,19 +226,63 @@ class QEMURunner:
|
|
|
212
226
|
# Create proper flash image for QEMU
|
|
213
227
|
flash_size = flash_size_mb * 1024 * 1024
|
|
214
228
|
|
|
215
|
-
#
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
229
|
+
# ESP32 flash layout offsets - different MCUs have different bootloader offsets
|
|
230
|
+
# ESP32/ESP32-S2: 0x1000, ESP32-S3/C3/C6: 0x0
|
|
231
|
+
if machine in ["esp32", "esp32s2"]:
|
|
232
|
+
BOOTLOADER_OFFSET = 0x1000
|
|
233
|
+
else:
|
|
234
|
+
BOOTLOADER_OFFSET = 0x0
|
|
235
|
+
PARTITION_OFFSET = 0x8000
|
|
236
|
+
APP_OFFSET = 0x10000
|
|
237
|
+
|
|
238
|
+
# Start with erased flash (0xFF)
|
|
239
|
+
flash_data = bytearray(b"\xff" * flash_size)
|
|
240
|
+
|
|
241
|
+
# Try to find bootloader.bin and partitions.bin in the same directory
|
|
242
|
+
build_dir = firmware_path.parent
|
|
243
|
+
bootloader_path = build_dir / "bootloader.bin"
|
|
244
|
+
partitions_path = build_dir / "partitions.bin"
|
|
245
|
+
|
|
246
|
+
# Place bootloader at 0x1000 if available
|
|
247
|
+
if bootloader_path.exists():
|
|
248
|
+
bootloader_data = bootloader_path.read_bytes()
|
|
249
|
+
bootloader_end = BOOTLOADER_OFFSET + len(bootloader_data)
|
|
250
|
+
if bootloader_end <= flash_size:
|
|
251
|
+
flash_data[BOOTLOADER_OFFSET:bootloader_end] = bootloader_data
|
|
252
|
+
if self.verbose:
|
|
253
|
+
print(f" Bootloader: {len(bootloader_data)} bytes at 0x{BOOTLOADER_OFFSET:X}")
|
|
254
|
+
else:
|
|
255
|
+
print("Warning: Bootloader too large to fit in flash")
|
|
256
|
+
else:
|
|
257
|
+
if self.verbose:
|
|
258
|
+
print(f" Warning: bootloader.bin not found at {bootloader_path}")
|
|
259
|
+
|
|
260
|
+
# Place partition table at 0x8000 if available
|
|
261
|
+
if partitions_path.exists():
|
|
262
|
+
partitions_data = partitions_path.read_bytes()
|
|
263
|
+
partitions_end = PARTITION_OFFSET + len(partitions_data)
|
|
264
|
+
if partitions_end <= flash_size:
|
|
265
|
+
flash_data[PARTITION_OFFSET:partitions_end] = partitions_data
|
|
266
|
+
if self.verbose:
|
|
267
|
+
print(f" Partitions: {len(partitions_data)} bytes at 0x{PARTITION_OFFSET:X}")
|
|
268
|
+
else:
|
|
269
|
+
print("Warning: Partition table too large to fit in flash")
|
|
270
|
+
else:
|
|
271
|
+
if self.verbose:
|
|
272
|
+
print(f" Warning: partitions.bin not found at {partitions_path}")
|
|
224
273
|
|
|
225
|
-
|
|
274
|
+
# Place firmware at 0x10000
|
|
275
|
+
firmware_data = firmware_path.read_bytes()
|
|
276
|
+
firmware_end = APP_OFFSET + len(firmware_data)
|
|
277
|
+
if firmware_end > flash_size:
|
|
278
|
+
raise ValueError(f"Firmware size ({len(firmware_data)} bytes) exceeds available space (flash_size={flash_size} - app_offset={APP_OFFSET})")
|
|
279
|
+
flash_data[APP_OFFSET:firmware_end] = firmware_data
|
|
280
|
+
if self.verbose:
|
|
281
|
+
print(f" Application: {len(firmware_data)} bytes at 0x{APP_OFFSET:X}")
|
|
226
282
|
|
|
227
|
-
|
|
283
|
+
# Write the complete flash image
|
|
284
|
+
(temp_dir / "flash.bin").write_bytes(bytes(flash_data))
|
|
285
|
+
print(f"Created flash image: {flash_size_mb}MB ({flash_size:,} bytes)")
|
|
228
286
|
|
|
229
287
|
return temp_dir
|
|
230
288
|
|
|
@@ -352,7 +410,7 @@ class QEMURunner:
|
|
|
352
410
|
temp_firmware_dir: Optional[Path] = None
|
|
353
411
|
|
|
354
412
|
try:
|
|
355
|
-
temp_firmware_dir = self._prepare_firmware(firmware_path, flash_size)
|
|
413
|
+
temp_firmware_dir = self._prepare_firmware(firmware_path, flash_size, machine)
|
|
356
414
|
|
|
357
415
|
# Generate unique container name
|
|
358
416
|
self.container_name = f"fbuild-qemu-{machine}-{int(time.time())}"
|
fbuild/ledger/board_ledger.py
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
Board Ledger - Track attached chip/port mappings.
|
|
3
3
|
|
|
4
4
|
This module provides a simple ledger to cache chip type detections for serial ports.
|
|
5
|
-
The cache is stored in ~/.fbuild/board_ledger.json
|
|
6
|
-
thread-safe access.
|
|
5
|
+
The cache is stored in ~/.fbuild/board_ledger.json.
|
|
7
6
|
|
|
8
7
|
Features:
|
|
9
8
|
- Port to chip type mapping with timestamps
|
|
10
9
|
- Automatic stale entry expiration (24 hours)
|
|
11
|
-
- Thread-safe
|
|
10
|
+
- Thread-safe in-process access via threading.Lock
|
|
12
11
|
- Chip type validation against known ESP32 variants
|
|
13
12
|
- Integration with esptool for chip detection
|
|
13
|
+
|
|
14
|
+
Note: Cross-process synchronization is handled by the daemon which holds locks in memory.
|
|
14
15
|
"""
|
|
15
16
|
|
|
16
17
|
import json
|
|
@@ -96,7 +97,8 @@ class BoardLedger:
|
|
|
96
97
|
"""Manages port to chip type mappings with persistent storage.
|
|
97
98
|
|
|
98
99
|
The ledger stores mappings in ~/.fbuild/board_ledger.json and provides
|
|
99
|
-
thread-safe access through
|
|
100
|
+
thread-safe in-process access through threading.Lock. Cross-process
|
|
101
|
+
synchronization is handled by the daemon which holds locks in memory.
|
|
100
102
|
|
|
101
103
|
Example:
|
|
102
104
|
>>> ledger = BoardLedger()
|
|
@@ -161,60 +163,6 @@ class BoardLedger:
|
|
|
161
163
|
except OSError as e:
|
|
162
164
|
raise BoardLedgerError(f"Failed to write ledger: {e}") from e
|
|
163
165
|
|
|
164
|
-
def _acquire_file_lock(self) -> Any:
|
|
165
|
-
"""Acquire a file lock for cross-process synchronization.
|
|
166
|
-
|
|
167
|
-
Returns:
|
|
168
|
-
Lock file handle (or None on platforms without locking support)
|
|
169
|
-
"""
|
|
170
|
-
self._ensure_directory()
|
|
171
|
-
lock_path = self._ledger_path.with_suffix(".lock")
|
|
172
|
-
|
|
173
|
-
try:
|
|
174
|
-
# Open lock file
|
|
175
|
-
lock_file = open(lock_path, "w", encoding="utf-8")
|
|
176
|
-
|
|
177
|
-
# Platform-specific locking
|
|
178
|
-
if sys.platform == "win32":
|
|
179
|
-
import msvcrt
|
|
180
|
-
|
|
181
|
-
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
182
|
-
else: # pragma: no cover - Unix only
|
|
183
|
-
import fcntl # type: ignore[import-not-found]
|
|
184
|
-
|
|
185
|
-
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
186
|
-
|
|
187
|
-
return lock_file
|
|
188
|
-
except (ImportError, OSError):
|
|
189
|
-
# Locking not available or failed - continue without lock
|
|
190
|
-
return None
|
|
191
|
-
|
|
192
|
-
def _release_file_lock(self, lock_file: Any) -> None:
|
|
193
|
-
"""Release a file lock.
|
|
194
|
-
|
|
195
|
-
Args:
|
|
196
|
-
lock_file: Lock file handle from _acquire_file_lock
|
|
197
|
-
"""
|
|
198
|
-
if lock_file is None:
|
|
199
|
-
return
|
|
200
|
-
|
|
201
|
-
try:
|
|
202
|
-
if sys.platform == "win32":
|
|
203
|
-
import msvcrt
|
|
204
|
-
|
|
205
|
-
try:
|
|
206
|
-
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
207
|
-
except OSError:
|
|
208
|
-
pass
|
|
209
|
-
else: # pragma: no cover - Unix only
|
|
210
|
-
import fcntl # type: ignore[import-not-found]
|
|
211
|
-
|
|
212
|
-
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
213
|
-
|
|
214
|
-
lock_file.close()
|
|
215
|
-
except (ImportError, OSError):
|
|
216
|
-
pass
|
|
217
|
-
|
|
218
166
|
def get_chip(self, port: str) -> str | None:
|
|
219
167
|
"""Get the cached chip type for a port.
|
|
220
168
|
|
|
@@ -225,20 +173,16 @@ class BoardLedger:
|
|
|
225
173
|
Chip type string (e.g., "ESP32-S3") or None if not found/stale
|
|
226
174
|
"""
|
|
227
175
|
with self._lock:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if entry_data is None:
|
|
233
|
-
return None
|
|
176
|
+
data = self._read_ledger()
|
|
177
|
+
entry_data = data.get(port)
|
|
178
|
+
if entry_data is None:
|
|
179
|
+
return None
|
|
234
180
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
181
|
+
entry = LedgerEntry.from_dict(entry_data)
|
|
182
|
+
if entry.is_stale():
|
|
183
|
+
return None
|
|
238
184
|
|
|
239
|
-
|
|
240
|
-
finally:
|
|
241
|
-
self._release_file_lock(lock_file)
|
|
185
|
+
return entry.chip_type
|
|
242
186
|
|
|
243
187
|
def set_chip(self, port: str, chip_type: str) -> None:
|
|
244
188
|
"""Set the chip type for a port.
|
|
@@ -258,13 +202,9 @@ class BoardLedger:
|
|
|
258
202
|
entry = LedgerEntry(chip_type=normalized, timestamp=time.time())
|
|
259
203
|
|
|
260
204
|
with self._lock:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
data[port] = entry.to_dict()
|
|
265
|
-
self._write_ledger(data)
|
|
266
|
-
finally:
|
|
267
|
-
self._release_file_lock(lock_file)
|
|
205
|
+
data = self._read_ledger()
|
|
206
|
+
data[port] = entry.to_dict()
|
|
207
|
+
self._write_ledger(data)
|
|
268
208
|
|
|
269
209
|
def clear(self, port: str) -> bool:
|
|
270
210
|
"""Clear the cached chip type for a port.
|
|
@@ -276,16 +216,12 @@ class BoardLedger:
|
|
|
276
216
|
True if entry was cleared, False if not found
|
|
277
217
|
"""
|
|
278
218
|
with self._lock:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
data
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return True
|
|
286
|
-
return False
|
|
287
|
-
finally:
|
|
288
|
-
self._release_file_lock(lock_file)
|
|
219
|
+
data = self._read_ledger()
|
|
220
|
+
if port in data:
|
|
221
|
+
del data[port]
|
|
222
|
+
self._write_ledger(data)
|
|
223
|
+
return True
|
|
224
|
+
return False
|
|
289
225
|
|
|
290
226
|
def clear_all(self) -> int:
|
|
291
227
|
"""Clear all entries from the ledger.
|
|
@@ -294,14 +230,10 @@ class BoardLedger:
|
|
|
294
230
|
Number of entries cleared
|
|
295
231
|
"""
|
|
296
232
|
with self._lock:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
self._write_ledger({})
|
|
302
|
-
return count
|
|
303
|
-
finally:
|
|
304
|
-
self._release_file_lock(lock_file)
|
|
233
|
+
data = self._read_ledger()
|
|
234
|
+
count = len(data)
|
|
235
|
+
self._write_ledger({})
|
|
236
|
+
return count
|
|
305
237
|
|
|
306
238
|
def clear_stale(self, threshold: float = STALE_THRESHOLD_SECONDS) -> int:
|
|
307
239
|
"""Remove all stale entries from the ledger.
|
|
@@ -313,22 +245,18 @@ class BoardLedger:
|
|
|
313
245
|
Number of entries removed
|
|
314
246
|
"""
|
|
315
247
|
with self._lock:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
self._write_ledger(fresh_data)
|
|
329
|
-
return original_count - len(fresh_data)
|
|
330
|
-
finally:
|
|
331
|
-
self._release_file_lock(lock_file)
|
|
248
|
+
data = self._read_ledger()
|
|
249
|
+
original_count = len(data)
|
|
250
|
+
|
|
251
|
+
# Filter out stale entries
|
|
252
|
+
fresh_data = {}
|
|
253
|
+
for port, entry_data in data.items():
|
|
254
|
+
entry = LedgerEntry.from_dict(entry_data)
|
|
255
|
+
if not entry.is_stale(threshold):
|
|
256
|
+
fresh_data[port] = entry_data
|
|
257
|
+
|
|
258
|
+
self._write_ledger(fresh_data)
|
|
259
|
+
return original_count - len(fresh_data)
|
|
332
260
|
|
|
333
261
|
def get_all(self) -> dict[str, LedgerEntry]:
|
|
334
262
|
"""Get all non-stale entries in the ledger.
|
|
@@ -337,17 +265,13 @@ class BoardLedger:
|
|
|
337
265
|
Dictionary mapping port names to LedgerEntry objects
|
|
338
266
|
"""
|
|
339
267
|
with self._lock:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
result[port] = entry
|
|
348
|
-
return result
|
|
349
|
-
finally:
|
|
350
|
-
self._release_file_lock(lock_file)
|
|
268
|
+
data = self._read_ledger()
|
|
269
|
+
result = {}
|
|
270
|
+
for port, entry_data in data.items():
|
|
271
|
+
entry = LedgerEntry.from_dict(entry_data)
|
|
272
|
+
if not entry.is_stale():
|
|
273
|
+
result[port] = entry
|
|
274
|
+
return result
|
|
351
275
|
|
|
352
276
|
def get_environment(self, port: str) -> str | None:
|
|
353
277
|
"""Get the environment name for a cached port.
|