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.
Files changed (47) hide show
  1. fbuild/__init__.py +5 -1
  2. fbuild/build/configurable_compiler.py +49 -6
  3. fbuild/build/configurable_linker.py +14 -9
  4. fbuild/build/orchestrator_esp32.py +6 -3
  5. fbuild/build/orchestrator_rp2040.py +6 -2
  6. fbuild/cli.py +300 -5
  7. fbuild/config/ini_parser.py +13 -1
  8. fbuild/daemon/__init__.py +11 -0
  9. fbuild/daemon/async_client.py +5 -4
  10. fbuild/daemon/async_client_lib.py +1543 -0
  11. fbuild/daemon/async_protocol.py +825 -0
  12. fbuild/daemon/async_server.py +2100 -0
  13. fbuild/daemon/client.py +425 -13
  14. fbuild/daemon/configuration_lock.py +13 -13
  15. fbuild/daemon/connection.py +508 -0
  16. fbuild/daemon/connection_registry.py +579 -0
  17. fbuild/daemon/daemon.py +517 -164
  18. fbuild/daemon/daemon_context.py +72 -1
  19. fbuild/daemon/device_discovery.py +477 -0
  20. fbuild/daemon/device_manager.py +821 -0
  21. fbuild/daemon/error_collector.py +263 -263
  22. fbuild/daemon/file_cache.py +332 -332
  23. fbuild/daemon/firmware_ledger.py +46 -123
  24. fbuild/daemon/lock_manager.py +508 -508
  25. fbuild/daemon/messages.py +431 -0
  26. fbuild/daemon/operation_registry.py +288 -288
  27. fbuild/daemon/processors/build_processor.py +34 -1
  28. fbuild/daemon/processors/deploy_processor.py +1 -3
  29. fbuild/daemon/processors/locking_processor.py +7 -7
  30. fbuild/daemon/request_processor.py +457 -457
  31. fbuild/daemon/shared_serial.py +7 -7
  32. fbuild/daemon/status_manager.py +238 -238
  33. fbuild/daemon/subprocess_manager.py +316 -316
  34. fbuild/deploy/docker_utils.py +182 -2
  35. fbuild/deploy/monitor.py +1 -1
  36. fbuild/deploy/qemu_runner.py +71 -13
  37. fbuild/ledger/board_ledger.py +46 -122
  38. fbuild/output.py +238 -2
  39. fbuild/packages/library_compiler.py +15 -5
  40. fbuild/packages/library_manager.py +12 -6
  41. fbuild-1.2.15.dist-info/METADATA +569 -0
  42. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
  43. fbuild-1.2.8.dist-info/METADATA +0 -468
  44. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
  45. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
  46. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
  47. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/top_level.txt +0 -0
fbuild/__init__.py CHANGED
@@ -4,7 +4,9 @@ from dataclasses import dataclass
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
- __version__ = "1.2.8"
7
+ from fbuild.daemon.connection import DaemonConnection, connect_daemon
8
+
9
+ __version__ = "1.2.15"
8
10
 
9
11
 
10
12
  def is_available() -> bool:
@@ -387,4 +389,6 @@ __all__ = [
387
389
  "is_available",
388
390
  "BuildContext",
389
391
  "Daemon",
392
+ "DaemonConnection",
393
+ "connect_daemon",
390
394
  ]
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, List, Dict, Optional, Union, TYPE_CHECKING
15
15
 
16
16
  from ..packages.package import IPackage, IToolchain, IFramework
17
+ from ..output import ProgressCallback
17
18
  from .flag_builder import FlagBuilder
18
19
  from .compilation_executor import CompilationExecutor
19
20
  from .archive_creator import ArchiveCreator
@@ -335,6 +336,11 @@ class ConfigurableCompiler(ICompiler):
335
336
  def compile_sketch(self, sketch_path: Path) -> List[Path]:
336
337
  """Compile an Arduino sketch.
337
338
 
339
+ This method handles Arduino sketches that may contain multiple source files:
340
+ - The main .ino file is preprocessed and compiled
341
+ - Additional .cpp files in the sketch directory are also compiled
342
+ - The sketch directory is added to include paths for header file resolution
343
+
338
344
  Args:
339
345
  sketch_path: Path to .ino file
340
346
 
@@ -346,6 +352,12 @@ class ConfigurableCompiler(ICompiler):
346
352
  """
347
353
  object_files = []
348
354
 
355
+ # Add sketch directory to include paths so headers like ValidationConfig.h can be found
356
+ sketch_dir = sketch_path.parent
357
+ include_paths = self.get_include_paths()
358
+ if sketch_dir not in include_paths:
359
+ include_paths.insert(0, sketch_dir) # Add at front for priority
360
+
349
361
  # Preprocess .ino to .cpp
350
362
  cpp_path = self.preprocess_ino(sketch_path)
351
363
 
@@ -357,19 +369,38 @@ class ConfigurableCompiler(ICompiler):
357
369
  # Skip compilation if object file is up-to-date
358
370
  if not self.needs_rebuild(cpp_path, obj_path):
359
371
  object_files.append(obj_path)
360
- return object_files
372
+ else:
373
+ # Compile preprocessed .cpp
374
+ compiled_obj = self.compile_source(cpp_path, obj_path)
375
+ object_files.append(compiled_obj)
376
+
377
+ # Find and compile additional .cpp files in the sketch directory
378
+ # (Arduino IDE compiles all .cpp files in the sketch folder)
379
+ for cpp_file in sketch_dir.glob("*.cpp"):
380
+ cpp_obj_path = obj_dir / f"{cpp_file.stem}.o"
381
+
382
+ # Skip compilation if object file is up-to-date
383
+ if not self.needs_rebuild(cpp_file, cpp_obj_path):
384
+ object_files.append(cpp_obj_path)
385
+ continue
361
386
 
362
- # Compile preprocessed .cpp
363
- compiled_obj = self.compile_source(cpp_path, obj_path)
364
- object_files.append(compiled_obj)
387
+ try:
388
+ compiled_obj = self.compile_source(cpp_file, cpp_obj_path)
389
+ object_files.append(compiled_obj)
390
+ except ConfigurableCompilerError as e:
391
+ # Re-raise with more context about which file failed
392
+ raise ConfigurableCompilerError(
393
+ f"Failed to compile sketch source file {cpp_file.name}: {e}"
394
+ )
365
395
 
366
396
  return object_files
367
397
 
368
- def compile_core(self, progress_bar: Optional[Any] = None) -> List[Path]:
398
+ def compile_core(self, progress_bar: Optional[Any] = None, progress_callback: ProgressCallback | None = None) -> List[Path]:
369
399
  """Compile Arduino core sources.
370
400
 
371
401
  Args:
372
402
  progress_bar: Optional tqdm progress bar to update during compilation
403
+ progress_callback: Optional callback for progress notifications
373
404
 
374
405
  Returns:
375
406
  List of generated object file paths
@@ -394,9 +425,15 @@ class ConfigurableCompiler(ICompiler):
394
425
  if progress_bar is not None:
395
426
  self.compilation_executor.show_progress = False
396
427
 
428
+ total_sources = len(core_sources)
429
+
397
430
  try:
398
431
  # Compile each core source
399
- for source in core_sources:
432
+ for idx, source in enumerate(core_sources, 1):
433
+ # Notify progress callback of file start
434
+ if progress_callback is not None:
435
+ progress_callback.on_file_start(source.name, idx, total_sources)
436
+
400
437
  # Update progress bar BEFORE compilation for better UX
401
438
  if progress_bar is not None:
402
439
  progress_bar.set_description(f'Compiling {source.name[:30]}')
@@ -407,17 +444,23 @@ class ConfigurableCompiler(ICompiler):
407
444
  # Skip compilation if object file is up-to-date
408
445
  if not self.needs_rebuild(source, obj_path):
409
446
  object_files.append(obj_path)
447
+ if progress_callback is not None:
448
+ progress_callback.on_file_complete(source.name, idx, total_sources, cached=True)
410
449
  if progress_bar is not None:
411
450
  progress_bar.update(1)
412
451
  continue
413
452
 
414
453
  compiled_obj = self.compile_source(source, obj_path)
415
454
  object_files.append(compiled_obj)
455
+ if progress_callback is not None:
456
+ progress_callback.on_file_complete(source.name, idx, total_sources, cached=False)
416
457
  if progress_bar is not None:
417
458
  progress_bar.update(1)
418
459
  except ConfigurableCompilerError as e:
419
460
  if self.show_progress:
420
461
  print(f"Warning: Failed to compile {source.name}: {e}")
462
+ if progress_callback is not None:
463
+ progress_callback.on_file_complete(source.name, idx, total_sources, cached=False)
421
464
  if progress_bar is not None:
422
465
  progress_bar.update(1)
423
466
  finally:
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from typing import List, Dict, Any, Optional, Union
19
19
 
20
20
  from ..packages.package import IPackage, IToolchain, IFramework
21
+ from ..output import log_detail, format_size
21
22
  from .binary_generator import BinaryGenerator
22
23
  from .compiler import ILinker, LinkerError
23
24
 
@@ -322,11 +323,15 @@ class ConfigurableLinker(ILinker):
322
323
 
323
324
  # Execute linker
324
325
  if self.show_progress:
325
- print("Linking firmware.elf...")
326
- print(f" Object files: {len(object_files)}")
327
- print(f" Core archive: {core_archive.name}")
328
- print(f" SDK libraries: {len(sdk_libs)}")
329
- print(f" Linker scripts: {len(linker_scripts)}")
326
+ log_detail("Linking firmware.elf...")
327
+ log_detail(f"Object files: {len(object_files)}")
328
+ log_detail(f"Core archive: {core_archive.name}")
329
+ log_detail(f"SDK libraries: {len(sdk_libs)}")
330
+ log_detail(f"Linker scripts: {len(linker_scripts)}")
331
+ core_size = core_archive.stat().st_size if core_archive.exists() else 0
332
+ lib_size = sum(a.stat().st_size for a in library_archives if a.exists())
333
+ obj_size = sum(o.stat().st_size for o in object_files if o.exists())
334
+ log_detail(f"Inputs: core ({format_size(core_size)}) + {len(library_archives)} libs ({format_size(lib_size)}) + {len(object_files)} objects ({format_size(obj_size)})")
330
335
 
331
336
  # Add retry logic for Windows file locking issues
332
337
  is_windows = platform.system() == "Windows"
@@ -340,7 +345,7 @@ class ConfigurableLinker(ILinker):
340
345
  gc.collect()
341
346
  time.sleep(delay)
342
347
  if self.show_progress:
343
- print(f" Retrying linking (attempt {attempt + 1}/{max_retries})...")
348
+ log_detail(f"Retrying linking (attempt {attempt + 1}/{max_retries})...")
344
349
 
345
350
  result = subprocess.run(
346
351
  cmd,
@@ -362,7 +367,7 @@ class ConfigurableLinker(ILinker):
362
367
  if is_windows and is_file_locking_error:
363
368
  if attempt < max_retries - 1:
364
369
  if self.show_progress:
365
- print(" [Windows] Detected file locking error, retrying...")
370
+ log_detail("[Windows] Detected file locking error, retrying...")
366
371
  delay = min(delay * 2, 1.0) # Exponential backoff, max 1s
367
372
  continue
368
373
  else:
@@ -386,7 +391,7 @@ class ConfigurableLinker(ILinker):
386
391
 
387
392
  if self.show_progress:
388
393
  size = output_elf.stat().st_size
389
- print(f"Created firmware.elf: {size:,} bytes ({size / 1024 / 1024:.2f} MB)")
394
+ log_detail(f"Created firmware.elf: {format_size(size)}")
390
395
 
391
396
  return output_elf
392
397
 
@@ -477,7 +482,7 @@ class ConfigurableLinker(ILinker):
477
482
 
478
483
  if self.show_progress:
479
484
  size = output_hex.stat().st_size
480
- print(f"Created firmware.hex: {size:,} bytes")
485
+ log_detail(f"Created firmware.hex: {format_size(size)}")
481
486
 
482
487
  return output_hex
483
488
 
@@ -25,7 +25,7 @@ from .orchestrator import IBuildOrchestrator, BuildResult
25
25
  from .build_utils import safe_rmtree
26
26
  from .build_state import BuildStateTracker
27
27
  from .build_info_generator import BuildInfoGenerator
28
- from ..output import log_phase, log_detail, log_warning
28
+ from ..output import log_phase, log_detail, log_warning, DefaultProgressCallback
29
29
 
30
30
  # Module-level logger
31
31
  logger = logging.getLogger(__name__)
@@ -299,9 +299,12 @@ class OrchestratorESP32(IBuildOrchestrator):
299
299
  compilation_queue=compilation_queue
300
300
  )
301
301
 
302
+ # Create progress callback for detailed file-by-file tracking
303
+ progress_callback = DefaultProgressCallback(verbose_only=not verbose)
304
+
302
305
  # Compile Arduino core with progress bar
303
306
  if verbose:
304
- core_obj_files = compiler.compile_core()
307
+ core_obj_files = compiler.compile_core(progress_callback=progress_callback)
305
308
  else:
306
309
  # Use tqdm progress bar for non-verbose mode
307
310
  from tqdm import tqdm
@@ -318,7 +321,7 @@ class OrchestratorESP32(IBuildOrchestrator):
318
321
  ncols=80,
319
322
  leave=False
320
323
  ) as pbar:
321
- core_obj_files = compiler.compile_core(progress_bar=pbar)
324
+ core_obj_files = compiler.compile_core(progress_bar=pbar, progress_callback=progress_callback)
322
325
 
323
326
  # Print completion message
324
327
  log_detail(f"Compiled {len(core_obj_files)} core files")
@@ -19,6 +19,7 @@ from ..packages.toolchain_rp2040 import ToolchainRP2040
19
19
  from ..packages.library_manager import LibraryManager, LibraryError
20
20
  from ..config.board_config import BoardConfig
21
21
  from ..cli_utils import BannerFormatter
22
+ from ..output import DefaultProgressCallback
22
23
  from .configurable_compiler import ConfigurableCompiler
23
24
  from .configurable_linker import ConfigurableLinker
24
25
  from .linker import SizeInfo
@@ -276,9 +277,12 @@ class OrchestratorRP2040(IBuildOrchestrator):
276
277
  user_build_flags=build_flags
277
278
  )
278
279
 
280
+ # Create progress callback for detailed file-by-file tracking
281
+ progress_callback = DefaultProgressCallback(verbose_only=not verbose)
282
+
279
283
  # Compile Arduino core with progress bar
280
284
  if verbose:
281
- core_obj_files = compiler.compile_core()
285
+ core_obj_files = compiler.compile_core(progress_callback=progress_callback)
282
286
  else:
283
287
  # Use tqdm progress bar for non-verbose mode
284
288
  from tqdm import tqdm
@@ -295,7 +299,7 @@ class OrchestratorRP2040(IBuildOrchestrator):
295
299
  ncols=80,
296
300
  leave=False
297
301
  ) as pbar:
298
- core_obj_files = compiler.compile_core(progress_bar=pbar)
302
+ core_obj_files = compiler.compile_core(progress_bar=pbar, progress_callback=progress_callback)
299
303
 
300
304
  # Print completion message
301
305
  logger.info(f"Compiled {len(core_obj_files)} core files")
fbuild/cli.py CHANGED
@@ -272,7 +272,194 @@ def monitor_command(args: MonitorArgs) -> None:
272
272
  ErrorFormatter.handle_unexpected_error(e, args.verbose)
273
273
 
274
274
 
275
- def daemon_command(action: str, pid: Optional[int] = None, force: bool = False) -> None:
275
+ def device_command(
276
+ action: str,
277
+ device_id: Optional[str] = None,
278
+ lease_type: str = "exclusive",
279
+ description: str = "",
280
+ reason: str = "",
281
+ refresh: bool = False,
282
+ ) -> None:
283
+ """Manage devices connected to the daemon.
284
+
285
+ Examples:
286
+ fbuild device list # List all connected devices
287
+ fbuild device list --refresh # Refresh device discovery before listing
288
+ fbuild device status <device_id> # Show detailed device status
289
+ fbuild device lease <device_id> # Acquire exclusive lease on device
290
+ fbuild device lease <device_id> --monitor # Acquire monitor (read-only) lease
291
+ fbuild device release <device_id> # Release lease on device
292
+ fbuild device take <device_id> --reason "Urgent deployment" # Preempt current holder
293
+ """
294
+ try:
295
+ if action == "list":
296
+ # List all devices
297
+ devices = daemon_client.list_devices(refresh=refresh)
298
+ if devices is None:
299
+ ErrorFormatter.print_error("Failed to list devices", "Daemon may not be running")
300
+ sys.exit(1)
301
+
302
+ if not devices:
303
+ print("No devices found")
304
+ sys.exit(0)
305
+
306
+ print(f"Found {len(devices)} device(s):\n")
307
+ for device in devices:
308
+ device_id = device.get("device_id", "unknown")
309
+ port = device.get("port", "unknown")
310
+ connected = "✅ connected" if device.get("is_connected", False) else "❌ disconnected"
311
+ exclusive = device.get("exclusive_holder")
312
+ monitor_count = device.get("monitor_count", 0)
313
+
314
+ print(f" {device_id}")
315
+ print(f" Port: {port}")
316
+ print(f" Status: {connected}")
317
+ if exclusive:
318
+ print(f" Exclusive holder: {exclusive}")
319
+ if monitor_count > 0:
320
+ print(f" Monitor sessions: {monitor_count}")
321
+ print()
322
+
323
+ sys.exit(0)
324
+
325
+ elif action == "status":
326
+ if not device_id:
327
+ ErrorFormatter.print_error("Device ID required", "Usage: fbuild device status <device_id>")
328
+ sys.exit(1)
329
+
330
+ status = daemon_client.get_device_status(device_id)
331
+ if status is None:
332
+ ErrorFormatter.print_error(f"Device not found: {device_id}", "")
333
+ sys.exit(1)
334
+
335
+ print(f"Device: {device_id}")
336
+ print(f" Connected: {'✅ Yes' if status.get('is_connected') else '❌ No'}")
337
+ print(f" Port: {status.get('device_info', {}).get('port', 'unknown')}")
338
+ print(f" Available for exclusive: {'✅ Yes' if status.get('is_available_for_exclusive') else '❌ No'}")
339
+
340
+ if status.get("exclusive_lease"):
341
+ lease = status["exclusive_lease"]
342
+ print(f" Exclusive holder: {lease.get('client_id', 'unknown')}")
343
+ print(f" Description: {lease.get('description', 'N/A')}")
344
+
345
+ if status.get("monitor_count", 0) > 0:
346
+ print(f" Monitor sessions: {status['monitor_count']}")
347
+ for monitor in status.get("monitor_leases", []):
348
+ print(f" - {monitor.get('client_id', 'unknown')}")
349
+
350
+ sys.exit(0)
351
+
352
+ elif action == "lease":
353
+ if not device_id:
354
+ ErrorFormatter.print_error("Device ID required", "Usage: fbuild device lease <device_id>")
355
+ sys.exit(1)
356
+
357
+ result = daemon_client.acquire_device_lease(
358
+ device_id=device_id,
359
+ lease_type=lease_type,
360
+ description=description,
361
+ )
362
+
363
+ if result is None:
364
+ ErrorFormatter.print_error("Failed to acquire lease", "Daemon may not be running")
365
+ sys.exit(1)
366
+
367
+ if result.get("success"):
368
+ lease_id = result.get("lease_id", "unknown")
369
+ print(f"✅ Acquired {lease_type} lease on device {device_id}")
370
+ print(f" Lease ID: {lease_id}")
371
+ sys.exit(0)
372
+ else:
373
+ ErrorFormatter.print_error(f"Failed to acquire lease: {result.get('message', 'unknown error')}", "")
374
+ sys.exit(1)
375
+
376
+ elif action == "release":
377
+ if not device_id:
378
+ ErrorFormatter.print_error("Device ID or lease ID required", "Usage: fbuild device release <device_id>")
379
+ sys.exit(1)
380
+
381
+ result = daemon_client.release_device_lease(device_id)
382
+
383
+ if result is None:
384
+ ErrorFormatter.print_error("Failed to release lease", "Daemon may not be running")
385
+ sys.exit(1)
386
+
387
+ if result.get("success"):
388
+ print(f"✅ Released lease on device {device_id}")
389
+ sys.exit(0)
390
+ else:
391
+ ErrorFormatter.print_error(f"Failed to release lease: {result.get('message', 'unknown error')}", "")
392
+ sys.exit(1)
393
+
394
+ elif action == "take":
395
+ if not device_id:
396
+ ErrorFormatter.print_error("Device ID required", 'Usage: fbuild device take <device_id> --reason "..."')
397
+ sys.exit(1)
398
+
399
+ if not reason:
400
+ ErrorFormatter.print_error("Reason required for preemption", 'Usage: fbuild device take <device_id> --reason "..."')
401
+ sys.exit(1)
402
+
403
+ result = daemon_client.preempt_device(device_id, reason)
404
+
405
+ if result is None:
406
+ ErrorFormatter.print_error("Failed to preempt device", "Daemon may not be running")
407
+ sys.exit(1)
408
+
409
+ if result.get("success"):
410
+ preempted = result.get("preempted_client_id")
411
+ print(f"✅ Preempted device {device_id}")
412
+ if preempted:
413
+ print(f" Previous holder: {preempted}")
414
+ print(f" Lease ID: {result.get('lease_id', 'unknown')}")
415
+ sys.exit(0)
416
+ else:
417
+ ErrorFormatter.print_error(f"Failed to preempt device: {result.get('message', 'unknown error')}", "")
418
+ sys.exit(1)
419
+
420
+ else:
421
+ ErrorFormatter.print_error(f"Unknown device action: {action}", "")
422
+ print("Valid actions: list, status, lease, release, take")
423
+ sys.exit(1)
424
+
425
+ except KeyboardInterrupt as ke:
426
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
427
+
428
+ handle_keyboard_interrupt_properly(ke)
429
+ except Exception as e:
430
+ ErrorFormatter.handle_unexpected_error(e, verbose=False)
431
+
432
+
433
+ def show_command(target: str, follow: bool = True, lines: int = 50) -> None:
434
+ """Show daemon logs or other information.
435
+
436
+ Examples:
437
+ fbuild show daemon # Tail daemon logs (Ctrl-C to stop, daemon continues)
438
+ fbuild show daemon --no-follow # Show last 50 lines and exit
439
+ fbuild show daemon --lines 100 # Show last 100 lines then follow
440
+ """
441
+ try:
442
+ if target == "daemon":
443
+ daemon_client.tail_daemon_logs(follow=follow, lines=lines)
444
+ sys.exit(0)
445
+ else:
446
+ from fbuild.cli_utils import ErrorFormatter
447
+
448
+ ErrorFormatter.print_error(f"Unknown target: {target}", "")
449
+ print("Valid targets: daemon")
450
+ sys.exit(1)
451
+
452
+ except KeyboardInterrupt as ke:
453
+ from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
454
+
455
+ handle_keyboard_interrupt_properly(ke)
456
+ except Exception as e:
457
+ from fbuild.cli_utils import ErrorFormatter
458
+
459
+ ErrorFormatter.handle_unexpected_error(e, verbose=False)
460
+
461
+
462
+ def daemon_command(action: str, pid: Optional[int] = None, force: bool = False, follow: bool = True, lines: int = 50) -> None:
276
463
  """Manage the fbuild daemon.
277
464
 
278
465
  Examples:
@@ -282,6 +469,7 @@ def daemon_command(action: str, pid: Optional[int] = None, force: bool = False)
282
469
  fbuild daemon list # List all daemon instances
283
470
  fbuild daemon locks # Show lock status
284
471
  fbuild daemon clear-locks # Clear stale locks
472
+ fbuild daemon monitor # Tail daemon logs (alias for 'fbuild show daemon')
285
473
  fbuild daemon kill --pid 12345 # Kill specific daemon
286
474
  fbuild daemon kill-all # Kill all daemons
287
475
  fbuild daemon kill-all --force # Force kill all daemons
@@ -374,9 +562,14 @@ def daemon_command(action: str, pid: Optional[int] = None, force: bool = False)
374
562
  print("No daemon instances found to kill")
375
563
  sys.exit(0)
376
564
 
565
+ elif action == "monitor":
566
+ # Monitor daemon logs (tail the log file)
567
+ daemon_client.tail_daemon_logs(follow=follow, lines=lines)
568
+ sys.exit(0)
569
+
377
570
  else:
378
571
  ErrorFormatter.print_error(f"Unknown daemon action: {action}", "")
379
- print("Valid actions: status, stop, restart, list, locks, clear-locks, kill, kill-all")
572
+ print("Valid actions: status, stop, restart, list, locks, clear-locks, monitor, kill, kill-all")
380
573
  sys.exit(1)
381
574
 
382
575
  except KeyboardInterrupt as ke:
@@ -470,9 +663,16 @@ def main() -> None:
470
663
 
471
664
  Replace PlatformIO with URL-based platform/toolchain management.
472
665
  """
666
+ # Display daemon stats as the first action (unless --version or --help anywhere)
667
+ help_flags = {"--version", "-V", "--help", "-h"}
668
+ skip_stats = any(arg in help_flags for arg in sys.argv)
669
+ if len(sys.argv) >= 2 and not skip_stats:
670
+ daemon_client.display_daemon_stats_compact()
671
+ print() # Blank line after stats
672
+
473
673
  # Handle default action: fbuild <project_dir> [flags] → deploy with monitor
474
674
  # This check must happen before argparse to avoid conflicts
475
- if len(sys.argv) >= 2 and not sys.argv[1].startswith("-") and sys.argv[1] not in ["build", "deploy", "monitor", "daemon"]:
675
+ if len(sys.argv) >= 2 and not sys.argv[1].startswith("-") and sys.argv[1] not in ["build", "deploy", "monitor", "daemon", "device", "show"]:
476
676
  # User provided a path without a subcommand - use default action
477
677
  deploy_args = parse_default_action_args(sys.argv)
478
678
  deploy_command(deploy_args)
@@ -640,6 +840,29 @@ def main() -> None:
640
840
  help="Show verbose output",
641
841
  )
642
842
 
843
+ # Show command
844
+ show_parser = subparsers.add_parser(
845
+ "show",
846
+ help="Show daemon logs or other information",
847
+ )
848
+ show_parser.add_argument(
849
+ "target",
850
+ choices=["daemon"],
851
+ help="What to show (currently only 'daemon' for daemon logs)",
852
+ )
853
+ show_parser.add_argument(
854
+ "--no-follow",
855
+ action="store_true",
856
+ dest="no_follow",
857
+ help="Don't follow the log file (just print last lines and exit)",
858
+ )
859
+ show_parser.add_argument(
860
+ "--lines",
861
+ type=int,
862
+ default=50,
863
+ help="Number of lines to show initially (default: 50)",
864
+ )
865
+
643
866
  # Daemon command
644
867
  daemon_parser = subparsers.add_parser(
645
868
  "daemon",
@@ -647,7 +870,7 @@ def main() -> None:
647
870
  )
648
871
  daemon_parser.add_argument(
649
872
  "action",
650
- choices=["status", "stop", "restart", "list", "locks", "clear-locks", "kill", "kill-all"],
873
+ choices=["status", "stop", "restart", "list", "locks", "clear-locks", "monitor", "kill", "kill-all"],
651
874
  help="Daemon action to perform",
652
875
  )
653
876
  daemon_parser.add_argument(
@@ -661,6 +884,56 @@ def main() -> None:
661
884
  action="store_true",
662
885
  help="Force kill without graceful shutdown (for 'kill' and 'kill-all' actions)",
663
886
  )
887
+ daemon_parser.add_argument(
888
+ "--no-follow",
889
+ action="store_true",
890
+ dest="no_follow",
891
+ help="Don't follow the log file, just print last lines and exit (for 'monitor' action)",
892
+ )
893
+ daemon_parser.add_argument(
894
+ "--lines",
895
+ type=int,
896
+ default=50,
897
+ help="Number of lines to show initially (for 'monitor' action, default: 50)",
898
+ )
899
+
900
+ # Device command
901
+ device_parser = subparsers.add_parser(
902
+ "device",
903
+ help="Manage devices connected to the daemon",
904
+ )
905
+ device_parser.add_argument(
906
+ "action",
907
+ choices=["list", "status", "lease", "release", "take"],
908
+ help="Device action to perform",
909
+ )
910
+ device_parser.add_argument(
911
+ "device_id",
912
+ nargs="?",
913
+ default=None,
914
+ help="Device ID (required for status, lease, release, take)",
915
+ )
916
+ device_parser.add_argument(
917
+ "--monitor",
918
+ action="store_true",
919
+ dest="lease_monitor",
920
+ help="Acquire monitor (read-only) lease instead of exclusive (for 'lease' action)",
921
+ )
922
+ device_parser.add_argument(
923
+ "--description",
924
+ default="",
925
+ help="Description for lease (for 'lease' action)",
926
+ )
927
+ device_parser.add_argument(
928
+ "--reason",
929
+ default="",
930
+ help="Reason for preemption (required for 'take' action)",
931
+ )
932
+ device_parser.add_argument(
933
+ "--refresh",
934
+ action="store_true",
935
+ help="Refresh device discovery before listing (for 'list' action)",
936
+ )
664
937
 
665
938
  # Parse arguments
666
939
  parsed_args = parser.parse_args()
@@ -710,7 +983,29 @@ def main() -> None:
710
983
  )
711
984
  monitor_command(monitor_args)
712
985
  elif parsed_args.command == "daemon":
713
- daemon_command(parsed_args.action, pid=parsed_args.pid, force=parsed_args.force)
986
+ daemon_command(
987
+ parsed_args.action,
988
+ pid=parsed_args.pid,
989
+ force=parsed_args.force,
990
+ follow=not parsed_args.no_follow,
991
+ lines=parsed_args.lines,
992
+ )
993
+ elif parsed_args.command == "show":
994
+ show_command(
995
+ target=parsed_args.target,
996
+ follow=not parsed_args.no_follow,
997
+ lines=parsed_args.lines,
998
+ )
999
+ elif parsed_args.command == "device":
1000
+ lease_type = "monitor" if parsed_args.lease_monitor else "exclusive"
1001
+ device_command(
1002
+ action=parsed_args.action,
1003
+ device_id=parsed_args.device_id,
1004
+ lease_type=lease_type,
1005
+ description=parsed_args.description,
1006
+ reason=parsed_args.reason,
1007
+ refresh=parsed_args.refresh,
1008
+ )
714
1009
 
715
1010
 
716
1011
  if __name__ == "__main__":
@@ -6,6 +6,7 @@ environment configurations for building embedded projects.
6
6
  """
7
7
 
8
8
  import configparser
9
+ import os
9
10
  from pathlib import Path
10
11
  from typing import Dict, List, Optional
11
12
 
@@ -301,14 +302,25 @@ class PlatformIOConfig:
301
302
 
302
303
  def get_src_dir(self) -> Optional[str]:
303
304
  """
304
- Get source directory override from [platformio] section.
305
+ Get source directory override.
306
+
307
+ Checks in order:
308
+ 1. PLATFORMIO_SRC_DIR environment variable (matches PlatformIO behavior)
309
+ 2. src_dir in [platformio] section of platformio.ini
305
310
 
306
311
  Returns:
307
312
  Source directory path relative to project root, or None if not specified
308
313
 
309
314
  Example:
315
+ If PLATFORMIO_SRC_DIR=examples/Validation, returns 'examples/Validation'
310
316
  If [platformio] section has src_dir = examples/Blink, returns 'examples/Blink'
311
317
  """
318
+ # First check environment variable (PlatformIO standard behavior)
319
+ env_src_dir = os.environ.get("PLATFORMIO_SRC_DIR", "").strip()
320
+ if env_src_dir:
321
+ return env_src_dir
322
+
323
+ # Fall back to platformio.ini setting
312
324
  if "platformio" in self.config:
313
325
  src_dir = self.config["platformio"].get("src_dir", "").strip()
314
326
  # Remove inline comments (everything after ';')