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,560 @@
1
+ """
2
+ Board Ledger - Track attached chip/port mappings.
3
+
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 and uses file locking for
6
+ thread-safe access.
7
+
8
+ Features:
9
+ - Port to chip type mapping with timestamps
10
+ - Automatic stale entry expiration (24 hours)
11
+ - Thread-safe file access with file locking
12
+ - Chip type validation against known ESP32 variants
13
+ - Integration with esptool for chip detection
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ import time
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ # Stale entry threshold: 24 hours
27
+ STALE_THRESHOLD_SECONDS = 24 * 60 * 60
28
+
29
+ # Known chip types and their corresponding environment names
30
+ CHIP_TO_ENVIRONMENT: dict[str, str] = {
31
+ "ESP32": "esp32dev",
32
+ "ESP32-S2": "esp32s2",
33
+ "ESP32-S3": "esp32s3",
34
+ "ESP32-C2": "esp32c2",
35
+ "ESP32-C3": "esp32c3",
36
+ "ESP32-C6": "esp32c6",
37
+ "ESP32-H2": "esp32h2",
38
+ }
39
+
40
+ # Valid chip types (for validation)
41
+ VALID_CHIP_TYPES = set(CHIP_TO_ENVIRONMENT.keys())
42
+
43
+
44
+ class BoardLedgerError(Exception):
45
+ """Raised when board ledger operations fail."""
46
+
47
+ pass
48
+
49
+
50
+ class ChipDetectionError(BoardLedgerError):
51
+ """Raised when chip detection fails."""
52
+
53
+ pass
54
+
55
+
56
+ @dataclass
57
+ class LedgerEntry:
58
+ """A single entry in the board ledger.
59
+
60
+ Attributes:
61
+ chip_type: The detected chip type (e.g., "ESP32-S3")
62
+ timestamp: Unix timestamp when the entry was created/updated
63
+ """
64
+
65
+ chip_type: str
66
+ timestamp: float
67
+
68
+ def is_stale(self, threshold: float = STALE_THRESHOLD_SECONDS) -> bool:
69
+ """Check if this entry is stale (older than threshold).
70
+
71
+ Args:
72
+ threshold: Maximum age in seconds before entry is considered stale
73
+
74
+ Returns:
75
+ True if entry is older than threshold
76
+ """
77
+ return (time.time() - self.timestamp) > threshold
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ """Convert to dictionary for JSON serialization."""
81
+ return {
82
+ "chip_type": self.chip_type,
83
+ "timestamp": self.timestamp,
84
+ }
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: dict[str, Any]) -> "LedgerEntry":
88
+ """Create entry from dictionary."""
89
+ return cls(
90
+ chip_type=data["chip_type"],
91
+ timestamp=data["timestamp"],
92
+ )
93
+
94
+
95
+ class BoardLedger:
96
+ """Manages port to chip type mappings with persistent storage.
97
+
98
+ The ledger stores mappings in ~/.fbuild/board_ledger.json and provides
99
+ thread-safe access through file locking.
100
+
101
+ Example:
102
+ >>> ledger = BoardLedger()
103
+ >>> ledger.set_chip("COM3", "ESP32-S3")
104
+ >>> chip = ledger.get_chip("COM3")
105
+ >>> print(chip) # "ESP32-S3"
106
+ >>> ledger.clear("COM3")
107
+ """
108
+
109
+ def __init__(self, ledger_path: Path | None = None):
110
+ """Initialize the board ledger.
111
+
112
+ Args:
113
+ ledger_path: Optional custom path for ledger file.
114
+ Defaults to ~/.fbuild/board_ledger.json
115
+ """
116
+ if ledger_path is None:
117
+ self._ledger_path = Path.home() / ".fbuild" / "board_ledger.json"
118
+ else:
119
+ self._ledger_path = ledger_path
120
+
121
+ # Thread lock for in-process synchronization
122
+ self._lock = threading.Lock()
123
+
124
+ @property
125
+ def ledger_path(self) -> Path:
126
+ """Get the path to the ledger file."""
127
+ return self._ledger_path
128
+
129
+ def _ensure_directory(self) -> None:
130
+ """Ensure the parent directory exists."""
131
+ self._ledger_path.parent.mkdir(parents=True, exist_ok=True)
132
+
133
+ def _read_ledger(self) -> dict[str, dict[str, Any]]:
134
+ """Read the ledger file.
135
+
136
+ Returns:
137
+ Dictionary mapping port names to entry dictionaries
138
+ """
139
+ if not self._ledger_path.exists():
140
+ return {}
141
+
142
+ try:
143
+ with open(self._ledger_path, encoding="utf-8") as f:
144
+ data = json.load(f)
145
+ if not isinstance(data, dict):
146
+ return {}
147
+ return data
148
+ except (json.JSONDecodeError, OSError):
149
+ return {}
150
+
151
+ def _write_ledger(self, data: dict[str, dict[str, Any]]) -> None:
152
+ """Write the ledger file.
153
+
154
+ Args:
155
+ data: Dictionary mapping port names to entry dictionaries
156
+ """
157
+ self._ensure_directory()
158
+ try:
159
+ with open(self._ledger_path, "w", encoding="utf-8") as f:
160
+ json.dump(data, f, indent=2)
161
+ except OSError as e:
162
+ raise BoardLedgerError(f"Failed to write ledger: {e}") from e
163
+
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
+ def get_chip(self, port: str) -> str | None:
219
+ """Get the cached chip type for a port.
220
+
221
+ Args:
222
+ port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
223
+
224
+ Returns:
225
+ Chip type string (e.g., "ESP32-S3") or None if not found/stale
226
+ """
227
+ with self._lock:
228
+ lock_file = self._acquire_file_lock()
229
+ try:
230
+ data = self._read_ledger()
231
+ entry_data = data.get(port)
232
+ if entry_data is None:
233
+ return None
234
+
235
+ entry = LedgerEntry.from_dict(entry_data)
236
+ if entry.is_stale():
237
+ return None
238
+
239
+ return entry.chip_type
240
+ finally:
241
+ self._release_file_lock(lock_file)
242
+
243
+ def set_chip(self, port: str, chip_type: str) -> None:
244
+ """Set the chip type for a port.
245
+
246
+ Args:
247
+ port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
248
+ chip_type: Chip type (e.g., "ESP32-S3", "ESP32-C6")
249
+
250
+ Raises:
251
+ BoardLedgerError: If chip_type is not valid
252
+ """
253
+ # Normalize chip type (handle lowercase/variant formats)
254
+ normalized = self._normalize_chip_type(chip_type)
255
+ if normalized not in VALID_CHIP_TYPES:
256
+ raise BoardLedgerError(f"Invalid chip type: {chip_type}. Valid types: {', '.join(sorted(VALID_CHIP_TYPES))}")
257
+
258
+ entry = LedgerEntry(chip_type=normalized, timestamp=time.time())
259
+
260
+ with self._lock:
261
+ lock_file = self._acquire_file_lock()
262
+ try:
263
+ data = self._read_ledger()
264
+ data[port] = entry.to_dict()
265
+ self._write_ledger(data)
266
+ finally:
267
+ self._release_file_lock(lock_file)
268
+
269
+ def clear(self, port: str) -> bool:
270
+ """Clear the cached chip type for a port.
271
+
272
+ Args:
273
+ port: Serial port name to clear
274
+
275
+ Returns:
276
+ True if entry was cleared, False if not found
277
+ """
278
+ with self._lock:
279
+ lock_file = self._acquire_file_lock()
280
+ try:
281
+ data = self._read_ledger()
282
+ if port in data:
283
+ del data[port]
284
+ self._write_ledger(data)
285
+ return True
286
+ return False
287
+ finally:
288
+ self._release_file_lock(lock_file)
289
+
290
+ def clear_all(self) -> int:
291
+ """Clear all entries from the ledger.
292
+
293
+ Returns:
294
+ Number of entries cleared
295
+ """
296
+ with self._lock:
297
+ lock_file = self._acquire_file_lock()
298
+ try:
299
+ data = self._read_ledger()
300
+ count = len(data)
301
+ self._write_ledger({})
302
+ return count
303
+ finally:
304
+ self._release_file_lock(lock_file)
305
+
306
+ def clear_stale(self, threshold: float = STALE_THRESHOLD_SECONDS) -> int:
307
+ """Remove all stale entries from the ledger.
308
+
309
+ Args:
310
+ threshold: Maximum age in seconds before entry is considered stale
311
+
312
+ Returns:
313
+ Number of entries removed
314
+ """
315
+ with self._lock:
316
+ lock_file = self._acquire_file_lock()
317
+ try:
318
+ data = self._read_ledger()
319
+ original_count = len(data)
320
+
321
+ # Filter out stale entries
322
+ fresh_data = {}
323
+ for port, entry_data in data.items():
324
+ entry = LedgerEntry.from_dict(entry_data)
325
+ if not entry.is_stale(threshold):
326
+ fresh_data[port] = entry_data
327
+
328
+ self._write_ledger(fresh_data)
329
+ return original_count - len(fresh_data)
330
+ finally:
331
+ self._release_file_lock(lock_file)
332
+
333
+ def get_all(self) -> dict[str, LedgerEntry]:
334
+ """Get all non-stale entries in the ledger.
335
+
336
+ Returns:
337
+ Dictionary mapping port names to LedgerEntry objects
338
+ """
339
+ with self._lock:
340
+ lock_file = self._acquire_file_lock()
341
+ try:
342
+ data = self._read_ledger()
343
+ result = {}
344
+ for port, entry_data in data.items():
345
+ entry = LedgerEntry.from_dict(entry_data)
346
+ if not entry.is_stale():
347
+ result[port] = entry
348
+ return result
349
+ finally:
350
+ self._release_file_lock(lock_file)
351
+
352
+ def get_environment(self, port: str) -> str | None:
353
+ """Get the environment name for a cached port.
354
+
355
+ Args:
356
+ port: Serial port name
357
+
358
+ Returns:
359
+ Environment name (e.g., "esp32s3") or None if not found
360
+ """
361
+ chip_type = self.get_chip(port)
362
+ if chip_type is None:
363
+ return None
364
+ return CHIP_TO_ENVIRONMENT.get(chip_type)
365
+
366
+ @staticmethod
367
+ def _normalize_chip_type(chip_type: str) -> str:
368
+ """Normalize chip type to standard format.
369
+
370
+ Args:
371
+ chip_type: Raw chip type string (e.g., "esp32s3", "ESP32-S3")
372
+
373
+ Returns:
374
+ Normalized chip type (e.g., "ESP32-S3")
375
+ """
376
+ # Map common variations to standard format
377
+ upper = chip_type.upper().replace("_", "-")
378
+
379
+ # Handle formats without hyphen (esp32s3 -> ESP32-S3)
380
+ mappings = {
381
+ "ESP32S2": "ESP32-S2",
382
+ "ESP32S3": "ESP32-S3",
383
+ "ESP32C2": "ESP32-C2",
384
+ "ESP32C3": "ESP32-C3",
385
+ "ESP32C6": "ESP32-C6",
386
+ "ESP32H2": "ESP32-H2",
387
+ }
388
+
389
+ return mappings.get(upper, upper)
390
+
391
+
392
+ @dataclass
393
+ class DetectionResult:
394
+ """Result of chip detection.
395
+
396
+ Attributes:
397
+ chip_type: The detected chip type (e.g., "ESP32-S3")
398
+ environment: The environment name (e.g., "esp32s3")
399
+ was_cached: Whether the result came from cache
400
+ """
401
+
402
+ chip_type: str
403
+ environment: str
404
+ was_cached: bool
405
+
406
+
407
+ def detect_chip_with_esptool(port: str, verbose: bool = False) -> str:
408
+ """Detect chip type using esptool.
409
+
410
+ Args:
411
+ port: Serial port to detect chip on
412
+ verbose: Whether to show verbose output
413
+
414
+ Returns:
415
+ Chip type string (e.g., "ESP32-S3")
416
+
417
+ Raises:
418
+ ChipDetectionError: If detection fails
419
+ """
420
+ try:
421
+ # Build esptool command
422
+ cmd = [
423
+ sys.executable,
424
+ "-m",
425
+ "esptool",
426
+ "--port",
427
+ port,
428
+ "chip_id",
429
+ ]
430
+
431
+ if verbose:
432
+ print(f"Running: {' '.join(cmd)}")
433
+
434
+ # Set up environment (strip MSYS paths on Windows)
435
+ env = os.environ.copy()
436
+ if sys.platform == "win32" and "PATH" in env:
437
+ paths = env["PATH"].split(os.pathsep)
438
+ filtered_paths = [p for p in paths if "msys" not in p.lower()]
439
+ env["PATH"] = os.pathsep.join(filtered_paths)
440
+
441
+ result = subprocess.run(
442
+ cmd,
443
+ capture_output=True,
444
+ text=True,
445
+ timeout=30,
446
+ env=env,
447
+ )
448
+
449
+ if result.returncode != 0:
450
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
451
+ raise ChipDetectionError(f"esptool chip_id failed: {error_msg}")
452
+
453
+ # Parse output to find chip type
454
+ # Example output: "Chip is ESP32-S3 (revision v0.2)"
455
+ output = result.stdout
456
+ for line in output.splitlines():
457
+ if "Chip is" in line:
458
+ # Extract chip name
459
+ # Format: "Chip is ESP32-S3 (revision ...)"
460
+ parts = line.split("Chip is")
461
+ if len(parts) >= 2:
462
+ chip_part = parts[1].strip()
463
+ # Remove revision info if present
464
+ if "(" in chip_part:
465
+ chip_part = chip_part.split("(")[0].strip()
466
+ return BoardLedger._normalize_chip_type(chip_part)
467
+
468
+ raise ChipDetectionError(f"Could not parse chip type from esptool output: {output}")
469
+
470
+ except subprocess.TimeoutExpired:
471
+ raise ChipDetectionError(f"Chip detection timed out on port {port}")
472
+ except KeyboardInterrupt as ke:
473
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
474
+
475
+ handle_keyboard_interrupt_properly(ke)
476
+ except ChipDetectionError:
477
+ raise
478
+ except Exception as e:
479
+ raise ChipDetectionError(f"Chip detection failed: {e}") from e
480
+
481
+
482
+ def detect_and_cache(
483
+ port: str,
484
+ ledger: BoardLedger | None = None,
485
+ force_detect: bool = False,
486
+ verbose: bool = False,
487
+ ) -> DetectionResult:
488
+ """Detect chip type, using cache when available.
489
+
490
+ This function first checks the ledger for a cached chip type. If not found
491
+ or stale (or force_detect is True), it calls esptool to detect the chip
492
+ and caches the result.
493
+
494
+ Args:
495
+ port: Serial port to detect chip on
496
+ ledger: BoardLedger instance (creates default if None)
497
+ force_detect: If True, always call esptool even if cache exists
498
+ verbose: Whether to show verbose output
499
+
500
+ Returns:
501
+ DetectionResult with chip_type, environment, and was_cached flag
502
+
503
+ Raises:
504
+ ChipDetectionError: If detection fails
505
+ BoardLedgerError: If caching fails
506
+ """
507
+ if ledger is None:
508
+ ledger = BoardLedger()
509
+
510
+ # Check cache first (unless force_detect)
511
+ if not force_detect:
512
+ cached_chip = ledger.get_chip(port)
513
+ if cached_chip is not None:
514
+ environment = CHIP_TO_ENVIRONMENT.get(cached_chip)
515
+ if environment is not None:
516
+ if verbose:
517
+ print(f"Using cached chip type: {cached_chip} -> {environment}")
518
+ return DetectionResult(
519
+ chip_type=cached_chip,
520
+ environment=environment,
521
+ was_cached=True,
522
+ )
523
+
524
+ # Detect with esptool
525
+ if verbose:
526
+ print(f"Detecting chip on port {port}...")
527
+
528
+ chip_type = detect_chip_with_esptool(port, verbose=verbose)
529
+ environment = CHIP_TO_ENVIRONMENT.get(chip_type)
530
+
531
+ if environment is None:
532
+ raise ChipDetectionError(f"Unknown chip type: {chip_type}. Supported chips: {', '.join(sorted(VALID_CHIP_TYPES))}")
533
+
534
+ # Cache the result
535
+ try:
536
+ ledger.set_chip(port, chip_type)
537
+ if verbose:
538
+ print(f"Cached chip detection: {port} -> {chip_type} ({environment})")
539
+ except BoardLedgerError as e:
540
+ if verbose:
541
+ print(f"Warning: Failed to cache chip detection: {e}")
542
+
543
+ return DetectionResult(
544
+ chip_type=chip_type,
545
+ environment=environment,
546
+ was_cached=False,
547
+ )
548
+
549
+
550
+ def get_environment_for_chip(chip_type: str) -> str | None:
551
+ """Get the environment name for a chip type.
552
+
553
+ Args:
554
+ chip_type: Chip type string (e.g., "ESP32-S3", "esp32s3")
555
+
556
+ Returns:
557
+ Environment name (e.g., "esp32s3") or None if unknown
558
+ """
559
+ normalized = BoardLedger._normalize_chip_type(chip_type)
560
+ return CHIP_TO_ENVIRONMENT.get(normalized)