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
@@ -9,10 +9,12 @@ makes dependencies explicit, and eliminates global mutable state.
9
9
  import threading
10
10
  from dataclasses import dataclass, field
11
11
  from pathlib import Path
12
+ from typing import TYPE_CHECKING
12
13
 
13
14
  from fbuild.daemon.async_client import ClientConnectionManager
14
15
  from fbuild.daemon.compilation_queue import CompilationJobQueue
15
16
  from fbuild.daemon.configuration_lock import ConfigurationLockManager
17
+ from fbuild.daemon.device_manager import DeviceManager
16
18
  from fbuild.daemon.error_collector import ErrorCollector
17
19
  from fbuild.daemon.file_cache import FileCache
18
20
  from fbuild.daemon.firmware_ledger import FirmwareLedger
@@ -23,6 +25,9 @@ from fbuild.daemon.shared_serial import SharedSerialManager
23
25
  from fbuild.daemon.status_manager import StatusManager
24
26
  from fbuild.daemon.subprocess_manager import SubprocessManager
25
27
 
28
+ if TYPE_CHECKING:
29
+ from fbuild.daemon.async_server import AsyncDaemonServer
30
+
26
31
 
27
32
  @dataclass
28
33
  class DaemonContext:
@@ -50,6 +55,7 @@ class DaemonContext:
50
55
  configuration_lock_manager: Centralized locking for (project, env, port) configs
51
56
  firmware_ledger: Tracks deployed firmware on devices to avoid re-upload
52
57
  shared_serial_manager: Manages shared serial port access for multiple clients
58
+ device_manager: Manages device inventory and exclusive/monitor leases
53
59
  operation_in_progress: Flag indicating if any operation is running
54
60
  operation_lock: Lock protecting the operation_in_progress flag
55
61
  """
@@ -74,6 +80,12 @@ class DaemonContext:
74
80
  firmware_ledger: FirmwareLedger
75
81
  shared_serial_manager: SharedSerialManager
76
82
 
83
+ # Device manager for resource management (multi-board concurrent development)
84
+ device_manager: DeviceManager
85
+
86
+ # Async server for real-time client communication (Iteration 2)
87
+ async_server: "AsyncDaemonServer | None" = None
88
+
77
89
  # Operation state
78
90
  operation_in_progress: bool = False
79
91
  operation_lock: threading.Lock = field(default_factory=threading.Lock)
@@ -85,6 +97,8 @@ def create_daemon_context(
85
97
  num_workers: int,
86
98
  file_cache_path: Path,
87
99
  status_file_path: Path,
100
+ enable_async_server: bool = True,
101
+ async_server_port: int = 9876,
88
102
  ) -> DaemonContext:
89
103
  """Factory function to create and initialize a DaemonContext.
90
104
 
@@ -97,6 +111,9 @@ def create_daemon_context(
97
111
  num_workers: Number of compilation worker threads
98
112
  file_cache_path: Path to the file cache JSON file
99
113
  status_file_path: Path to the status file
114
+ enable_async_server: Whether to start the async TCP server for real-time
115
+ client communication. Defaults to True.
116
+ async_server_port: Port for async server to listen on. Defaults to 9876.
100
117
 
101
118
  Returns:
102
119
  Fully initialized DaemonContext
@@ -175,6 +192,10 @@ def create_daemon_context(
175
192
  shared_serial_manager = SharedSerialManager()
176
193
  logging.info("Shared serial manager initialized")
177
194
 
195
+ # Initialize device manager for multi-board resource management
196
+ device_manager = DeviceManager()
197
+ logging.info("Device manager initialized")
198
+
178
199
  # Register cleanup callbacks: when a client disconnects, release their resources
179
200
  def on_client_disconnect(client_id: str) -> None:
180
201
  """Cleanup callback for when a client disconnects."""
@@ -183,12 +204,38 @@ def create_daemon_context(
183
204
  released = configuration_lock_manager.release_all_client_locks(client_id)
184
205
  if released > 0:
185
206
  logging.info(f"Released {released} configuration locks for client {client_id}")
207
+ # Release all device leases held by this client
208
+ released = device_manager.release_all_client_leases(client_id)
209
+ if released > 0:
210
+ logging.info(f"Released {released} device leases for client {client_id}")
186
211
  # Disconnect from shared serial sessions
187
212
  shared_serial_manager.disconnect_client(client_id)
188
213
 
189
214
  client_manager.register_cleanup_callback(on_client_disconnect)
190
215
  logging.info("Client cleanup callback registered")
191
216
 
217
+ # Initialize async server for real-time client communication (Iteration 2)
218
+ async_server = None
219
+ if enable_async_server:
220
+ try:
221
+ from fbuild.daemon.async_server import AsyncDaemonServer
222
+
223
+ async_server = AsyncDaemonServer(
224
+ host="localhost",
225
+ port=async_server_port,
226
+ configuration_lock_manager=configuration_lock_manager,
227
+ firmware_ledger=firmware_ledger,
228
+ shared_serial_manager=shared_serial_manager,
229
+ client_manager=client_manager,
230
+ device_manager=device_manager,
231
+ )
232
+ logging.info(f"Async server initialized on port {async_server_port}")
233
+ except KeyboardInterrupt: # noqa: KBI002
234
+ raise
235
+ except Exception as e:
236
+ logging.error(f"Failed to initialize async server: {e}")
237
+ # Continue without async server - fall back to file-based IPC
238
+
192
239
  # Create context
193
240
  context = DaemonContext(
194
241
  daemon_pid=daemon_pid,
@@ -205,6 +252,8 @@ def create_daemon_context(
205
252
  configuration_lock_manager=configuration_lock_manager,
206
253
  firmware_ledger=firmware_ledger,
207
254
  shared_serial_manager=shared_serial_manager,
255
+ device_manager=device_manager,
256
+ async_server=async_server,
208
257
  )
209
258
 
210
259
  logging.info("✅ Daemon context initialized successfully")
@@ -230,7 +279,18 @@ def cleanup_daemon_context(context: DaemonContext) -> None:
230
279
 
231
280
  logging.info("Shutting down daemon context...")
232
281
 
233
- # Shutdown shared serial manager first (closes all serial ports)
282
+ # Shutdown async server first (stops accepting new connections)
283
+ if context.async_server:
284
+ try:
285
+ context.async_server.stop()
286
+ logging.info("Async server stopped")
287
+ except KeyboardInterrupt: # noqa: KBI002
288
+ logging.warning("KeyboardInterrupt during async server shutdown")
289
+ raise
290
+ except Exception as e:
291
+ logging.error(f"Error shutting down async server: {e}")
292
+
293
+ # Shutdown shared serial manager (closes all serial ports)
234
294
  if context.shared_serial_manager:
235
295
  try:
236
296
  context.shared_serial_manager.shutdown()
@@ -252,6 +312,17 @@ def cleanup_daemon_context(context: DaemonContext) -> None:
252
312
  except Exception as e:
253
313
  logging.error(f"Error clearing configuration locks: {e}")
254
314
 
315
+ # Clear all device leases
316
+ if context.device_manager:
317
+ try:
318
+ cleared = context.device_manager.clear_all_leases()
319
+ logging.info(f"Cleared {cleared} device leases during shutdown")
320
+ except KeyboardInterrupt: # noqa: KBI002
321
+ logging.warning("KeyboardInterrupt during device manager cleanup")
322
+ raise
323
+ except Exception as e:
324
+ logging.error(f"Error clearing device leases: {e}")
325
+
255
326
  # Clear all client connections
256
327
  if context.client_manager:
257
328
  try:
@@ -0,0 +1,477 @@
1
+ """
2
+ Device Discovery - Enumerate and identify connected serial devices.
3
+
4
+ This module provides device enumeration using pyserial, creating stable device
5
+ identifiers for physical boards. It supports:
6
+
7
+ - USB serial devices with unique serial numbers (preferred identifier)
8
+ - VID/PID-based identification for devices without serial numbers
9
+ - QEMU virtual devices (placeholder support)
10
+ - Human-readable device descriptions
11
+
12
+ The stable device ID is crucial for multi-board concurrent development,
13
+ ensuring that device leases remain valid across reconnections.
14
+ """
15
+
16
+ import _thread
17
+ import hashlib
18
+ import logging
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ # Import pyserial types for type checking only
23
+ if TYPE_CHECKING:
24
+ from serial.tools.list_ports_common import ListPortInfo
25
+
26
+ try:
27
+ import serial.tools.list_ports as list_ports
28
+
29
+ HAS_SERIAL = True
30
+ except ImportError:
31
+ list_ports = None
32
+ HAS_SERIAL = False
33
+
34
+
35
+ @dataclass
36
+ class DeviceInfo:
37
+ """Information about a discovered serial device.
38
+
39
+ Attributes:
40
+ port: The serial port path (e.g., "COM3" or "/dev/ttyUSB0")
41
+ device_id: Stable unique identifier for this device
42
+ vid: USB Vendor ID (None if not available)
43
+ pid: USB Product ID (None if not available)
44
+ serial_number: USB serial number (None if not available)
45
+ description: Human-readable device description
46
+ manufacturer: Device manufacturer (None if not available)
47
+ product: Product name (None if not available)
48
+ hwid: Hardware ID string from the system
49
+ is_qemu: True if this is a QEMU virtual device
50
+ """
51
+
52
+ port: str
53
+ device_id: str
54
+ vid: int | None
55
+ pid: int | None
56
+ serial_number: str | None
57
+ description: str
58
+ manufacturer: str | None
59
+ product: str | None
60
+ hwid: str
61
+ is_qemu: bool = False
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """Convert to dictionary for JSON serialization."""
65
+ return {
66
+ "port": self.port,
67
+ "device_id": self.device_id,
68
+ "vid": self.vid,
69
+ "pid": self.pid,
70
+ "serial_number": self.serial_number,
71
+ "description": self.description,
72
+ "manufacturer": self.manufacturer,
73
+ "product": self.product,
74
+ "hwid": self.hwid,
75
+ "is_qemu": self.is_qemu,
76
+ }
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict[str, Any]) -> "DeviceInfo":
80
+ """Create DeviceInfo from dictionary."""
81
+ return cls(
82
+ port=data["port"],
83
+ device_id=data["device_id"],
84
+ vid=data.get("vid"),
85
+ pid=data.get("pid"),
86
+ serial_number=data.get("serial_number"),
87
+ description=data.get("description", ""),
88
+ manufacturer=data.get("manufacturer"),
89
+ product=data.get("product"),
90
+ hwid=data.get("hwid", ""),
91
+ is_qemu=data.get("is_qemu", False),
92
+ )
93
+
94
+ def matches_port(self, port: str) -> bool:
95
+ """Check if this device matches a given port name.
96
+
97
+ Handles case-insensitive comparison for Windows COM ports.
98
+ """
99
+ return self.port.lower() == port.lower()
100
+
101
+
102
+ def _generate_device_id_from_serial(serial_number: str) -> str:
103
+ """Generate a stable device ID from a USB serial number.
104
+
105
+ The USB serial number is the most stable identifier for a device,
106
+ as it remains constant regardless of which port the device is
107
+ plugged into.
108
+
109
+ Args:
110
+ serial_number: The USB serial number
111
+
112
+ Returns:
113
+ A stable device ID string prefixed with "usb-"
114
+ """
115
+ # Clean up the serial number (remove any whitespace)
116
+ clean_serial = serial_number.strip()
117
+ return f"usb-{clean_serial}"
118
+
119
+
120
+ def _generate_device_id_from_hardware(
121
+ vid: int | None,
122
+ pid: int | None,
123
+ port: str,
124
+ ) -> str:
125
+ """Generate a device ID from VID/PID and port path.
126
+
127
+ This is a fallback for devices without USB serial numbers.
128
+ The device ID includes a hash of the port path for uniqueness,
129
+ but this makes it less stable across port changes.
130
+
131
+ Args:
132
+ vid: USB Vendor ID
133
+ pid: USB Product ID
134
+ port: The serial port path
135
+
136
+ Returns:
137
+ A device ID string prefixed with "hw-"
138
+ """
139
+ # Create a hash from the combination
140
+ vid_str = f"{vid:04x}" if vid else "0000"
141
+ pid_str = f"{pid:04x}" if pid else "0000"
142
+
143
+ # Include port in hash for uniqueness when VID/PID aren't available
144
+ combined = f"{vid_str}:{pid_str}:{port}"
145
+ hash_suffix = hashlib.sha256(combined.encode()).hexdigest()[:8]
146
+
147
+ return f"hw-{vid_str}-{pid_str}-{hash_suffix}"
148
+
149
+
150
+ def _generate_qemu_device_id(identifier: str) -> str:
151
+ """Generate a device ID for a QEMU virtual device.
152
+
153
+ Args:
154
+ identifier: A unique identifier for the QEMU instance
155
+
156
+ Returns:
157
+ A device ID string prefixed with "qemu-"
158
+ """
159
+ return f"qemu-{identifier}"
160
+
161
+
162
+ def _port_info_to_device_info(port_info: "ListPortInfo") -> DeviceInfo:
163
+ """Convert a pyserial ListPortInfo to our DeviceInfo.
164
+
165
+ Args:
166
+ port_info: Port information from pyserial
167
+
168
+ Returns:
169
+ DeviceInfo with stable device ID
170
+ """
171
+ # Extract basic information
172
+ port = port_info.device
173
+ vid = port_info.vid
174
+ pid = port_info.pid
175
+ serial_number = port_info.serial_number
176
+ description = port_info.description or ""
177
+ manufacturer = port_info.manufacturer
178
+ product = port_info.product
179
+ hwid = port_info.hwid or ""
180
+
181
+ # Generate stable device ID
182
+ # Prefer USB serial number if available
183
+ if serial_number and serial_number.strip():
184
+ device_id = _generate_device_id_from_serial(serial_number)
185
+ else:
186
+ device_id = _generate_device_id_from_hardware(vid, pid, port)
187
+
188
+ return DeviceInfo(
189
+ port=port,
190
+ device_id=device_id,
191
+ vid=vid,
192
+ pid=pid,
193
+ serial_number=serial_number,
194
+ description=description,
195
+ manufacturer=manufacturer,
196
+ product=product,
197
+ hwid=hwid,
198
+ is_qemu=False,
199
+ )
200
+
201
+
202
+ def discover_devices(
203
+ include_links: bool = False,
204
+ ) -> list[DeviceInfo]:
205
+ """Enumerate all connected serial devices.
206
+
207
+ This function discovers all serial ports on the system and creates
208
+ DeviceInfo objects with stable device IDs for each.
209
+
210
+ Args:
211
+ include_links: Whether to include symbolic links (Unix only).
212
+ On Windows this has no effect.
213
+
214
+ Returns:
215
+ List of DeviceInfo objects for all discovered devices,
216
+ sorted by port name.
217
+
218
+ Raises:
219
+ RuntimeError: If pyserial is not installed
220
+
221
+ Example:
222
+ >>> devices = discover_devices()
223
+ >>> for device in devices:
224
+ ... print(f"{device.port}: {device.device_id} - {device.description}")
225
+ COM3: usb-A50285BI - Silicon Labs CP210x USB to UART Bridge
226
+ COM4: hw-10c4-ea60-abc12345 - USB Serial Device
227
+ """
228
+ if not HAS_SERIAL or list_ports is None:
229
+ raise RuntimeError("pyserial is required for device discovery. Install it with: pip install pyserial")
230
+
231
+ devices: list[DeviceInfo] = []
232
+
233
+ try:
234
+ ports = list_ports.comports(include_links=include_links)
235
+
236
+ for port_info in ports:
237
+ try:
238
+ device_info = _port_info_to_device_info(port_info)
239
+ devices.append(device_info)
240
+ logging.debug(f"Discovered device: {device_info.port} (id={device_info.device_id}, desc={device_info.description})")
241
+ except KeyboardInterrupt:
242
+ _thread.interrupt_main()
243
+ raise
244
+ except Exception as e:
245
+ logging.warning(f"Failed to process port {port_info.device}: {e}")
246
+
247
+ except KeyboardInterrupt:
248
+ _thread.interrupt_main()
249
+ raise
250
+ except Exception as e:
251
+ logging.error(f"Error enumerating serial ports: {e}")
252
+ raise
253
+
254
+ # Sort by port name for consistent ordering
255
+ devices.sort(key=lambda d: d.port.lower())
256
+
257
+ logging.info(f"Discovered {len(devices)} serial device(s)")
258
+ return devices
259
+
260
+
261
+ def get_device_by_port(port: str) -> DeviceInfo | None:
262
+ """Get device information for a specific port.
263
+
264
+ This is a convenience function that discovers all devices and
265
+ returns the one matching the specified port.
266
+
267
+ Args:
268
+ port: The serial port to look up (e.g., "COM3" or "/dev/ttyUSB0")
269
+
270
+ Returns:
271
+ DeviceInfo for the port, or None if not found
272
+ """
273
+ try:
274
+ devices = discover_devices()
275
+ for device in devices:
276
+ if device.matches_port(port):
277
+ return device
278
+ except KeyboardInterrupt:
279
+ _thread.interrupt_main()
280
+ raise
281
+ except Exception as e:
282
+ logging.error(f"Error looking up device for port {port}: {e}")
283
+
284
+ return None
285
+
286
+
287
+ def get_device_id(port: str) -> str:
288
+ """Get stable device ID for a port.
289
+
290
+ This function looks up the device connected to a port and returns
291
+ its stable device ID. If the device cannot be found, it generates
292
+ a fallback ID based on the port name.
293
+
294
+ Args:
295
+ port: The serial port (e.g., "COM3" or "/dev/ttyUSB0")
296
+
297
+ Returns:
298
+ A stable device ID string
299
+ """
300
+ device = get_device_by_port(port)
301
+ if device:
302
+ return device.device_id
303
+
304
+ # Fallback: generate ID from port name
305
+ logging.warning(f"Could not find device for port {port}, using port-based ID")
306
+ port_hash = hashlib.sha256(port.encode()).hexdigest()[:8]
307
+ return f"port-{port_hash}"
308
+
309
+
310
+ def create_qemu_device(
311
+ instance_id: str,
312
+ description: str = "QEMU Virtual Device",
313
+ ) -> DeviceInfo:
314
+ """Create a DeviceInfo for a QEMU virtual device.
315
+
316
+ QEMU devices don't have physical serial ports but still need
317
+ device IDs for lease management.
318
+
319
+ Args:
320
+ instance_id: Unique identifier for the QEMU instance
321
+ description: Human-readable description
322
+
323
+ Returns:
324
+ DeviceInfo for the QEMU device
325
+ """
326
+ device_id = _generate_qemu_device_id(instance_id)
327
+
328
+ return DeviceInfo(
329
+ port=f"qemu:{instance_id}",
330
+ device_id=device_id,
331
+ vid=None,
332
+ pid=None,
333
+ serial_number=None,
334
+ description=description,
335
+ manufacturer="QEMU",
336
+ product="Virtual Device",
337
+ hwid=f"QEMU\\{instance_id}",
338
+ is_qemu=True,
339
+ )
340
+
341
+
342
+ def find_device_by_id(device_id: str) -> DeviceInfo | None:
343
+ """Find a device by its stable device ID.
344
+
345
+ This function discovers all devices and returns the one with
346
+ the matching device ID.
347
+
348
+ Args:
349
+ device_id: The stable device ID to search for
350
+
351
+ Returns:
352
+ DeviceInfo if found, None otherwise
353
+ """
354
+ try:
355
+ devices = discover_devices()
356
+ for device in devices:
357
+ if device.device_id == device_id:
358
+ return device
359
+ except KeyboardInterrupt:
360
+ _thread.interrupt_main()
361
+ raise
362
+ except Exception as e:
363
+ logging.error(f"Error searching for device {device_id}: {e}")
364
+
365
+ return None
366
+
367
+
368
+ def find_devices_by_vid_pid(
369
+ vid: int,
370
+ pid: int,
371
+ ) -> list[DeviceInfo]:
372
+ """Find all devices matching a VID/PID combination.
373
+
374
+ Useful for finding all devices of a specific type (e.g., all
375
+ ESP32-C6 development boards).
376
+
377
+ Args:
378
+ vid: USB Vendor ID to match
379
+ pid: USB Product ID to match
380
+
381
+ Returns:
382
+ List of matching DeviceInfo objects
383
+ """
384
+ try:
385
+ devices = discover_devices()
386
+ return [d for d in devices if d.vid == vid and d.pid == pid]
387
+ except KeyboardInterrupt:
388
+ _thread.interrupt_main()
389
+ raise
390
+ except Exception as e:
391
+ logging.error(f"Error searching for devices with VID={vid:04x} PID={pid:04x}: {e}")
392
+ return []
393
+
394
+
395
+ def is_esp32_device(device: DeviceInfo) -> bool:
396
+ """Check if a device appears to be an ESP32 board.
397
+
398
+ This uses heuristics based on common ESP32 USB-UART chips
399
+ and description patterns.
400
+
401
+ Args:
402
+ device: The device to check
403
+
404
+ Returns:
405
+ True if the device appears to be an ESP32
406
+ """
407
+ # Common ESP32 USB-UART chip VID/PIDs
408
+ esp32_vid_pids = [
409
+ (0x10C4, 0xEA60), # Silicon Labs CP210x
410
+ (0x1A86, 0x7523), # QinHeng CH340
411
+ (0x1A86, 0x55D4), # QinHeng CH9102
412
+ (0x303A, 0x1001), # Espressif ESP32-S2
413
+ (0x303A, 0x1002), # Espressif ESP32-S3
414
+ (0x303A, 0x0002), # Espressif ESP32-S2 (CDC)
415
+ (0x303A, 0x1002), # Espressif ESP32-S3 (CDC)
416
+ (0x303A, 0x4001), # Espressif ESP32-C3/C6 JTAG
417
+ (0x0403, 0x6001), # FTDI FT232
418
+ (0x0403, 0x6010), # FTDI FT2232
419
+ (0x0403, 0x6011), # FTDI FT4232
420
+ (0x0403, 0x6014), # FTDI FT232H
421
+ (0x0403, 0x6015), # FTDI FT-X
422
+ ]
423
+
424
+ if device.vid and device.pid:
425
+ if (device.vid, device.pid) in esp32_vid_pids:
426
+ return True
427
+
428
+ # Check description for ESP32 keywords
429
+ desc_lower = device.description.lower()
430
+ esp_keywords = ["esp32", "esp-32", "espressif", "cp210x", "ch340", "ch9102"]
431
+ return any(kw in desc_lower for kw in esp_keywords)
432
+
433
+
434
+ def get_device_summary(device: DeviceInfo) -> str:
435
+ """Get a human-readable summary of a device.
436
+
437
+ Args:
438
+ device: The device to summarize
439
+
440
+ Returns:
441
+ A formatted string summary
442
+ """
443
+ parts = [device.port]
444
+
445
+ if device.description:
446
+ parts.append(f"- {device.description}")
447
+
448
+ if device.manufacturer:
449
+ parts.append(f"({device.manufacturer})")
450
+
451
+ if device.is_qemu:
452
+ parts.append("[QEMU]")
453
+
454
+ return " ".join(parts)
455
+
456
+
457
+ # Well-known VID/PID constants for common development boards
458
+ class KnownDevices:
459
+ """Well-known VID/PID combinations for common devices."""
460
+
461
+ # Silicon Labs CP210x (common on ESP32 boards)
462
+ CP210X_VID = 0x10C4
463
+ CP210X_PID = 0xEA60
464
+
465
+ # QinHeng CH340 (common on cheap ESP32 boards)
466
+ CH340_VID = 0x1A86
467
+ CH340_PID = 0x7523
468
+
469
+ # QinHeng CH9102 (newer version)
470
+ CH9102_VID = 0x1A86
471
+ CH9102_PID = 0x55D4
472
+
473
+ # Espressif native USB (ESP32-S2/S3/C3/C6)
474
+ ESPRESSIF_VID = 0x303A
475
+
476
+ # FTDI (common on many dev boards)
477
+ FTDI_VID = 0x0403