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/daemon/daemon_context.py
CHANGED
|
@@ -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
|
|
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
|