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.
- fbuild/__init__.py +390 -0
- fbuild/assets/example.txt +1 -0
- fbuild/build/__init__.py +117 -0
- fbuild/build/archive_creator.py +186 -0
- fbuild/build/binary_generator.py +444 -0
- fbuild/build/build_component_factory.py +131 -0
- fbuild/build/build_info_generator.py +624 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +93 -0
- fbuild/build/compilation_executor.py +422 -0
- fbuild/build/compiler.py +165 -0
- fbuild/build/compiler_avr.py +574 -0
- fbuild/build/configurable_compiler.py +664 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +214 -0
- fbuild/build/library_dependency_processor.py +185 -0
- fbuild/build/linker.py +708 -0
- fbuild/build/orchestrator.py +67 -0
- fbuild/build/orchestrator_avr.py +651 -0
- fbuild/build/orchestrator_esp32.py +878 -0
- fbuild/build/orchestrator_rp2040.py +719 -0
- fbuild/build/orchestrator_stm32.py +696 -0
- fbuild/build/orchestrator_teensy.py +580 -0
- fbuild/build/source_compilation_orchestrator.py +218 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +717 -0
- fbuild/cli_utils.py +314 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +542 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +369 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +42 -0
- fbuild/daemon/async_client.py +531 -0
- fbuild/daemon/client.py +1505 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/configuration_lock.py +865 -0
- fbuild/daemon/daemon.py +585 -0
- fbuild/daemon/daemon_context.py +293 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/firmware_ledger.py +546 -0
- fbuild/daemon/lock_manager.py +508 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +957 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/port_state_manager.py +249 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +18 -0
- fbuild/daemon/processors/build_processor.py +248 -0
- fbuild/daemon/processors/deploy_processor.py +664 -0
- fbuild/daemon/processors/install_deps_processor.py +431 -0
- fbuild/daemon/processors/locking_processor.py +777 -0
- fbuild/daemon/processors/monitor_processor.py +285 -0
- fbuild/daemon/request_processor.py +457 -0
- fbuild/daemon/shared_serial.py +819 -0
- fbuild/daemon/status_manager.py +238 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +21 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +310 -0
- fbuild/deploy/docker_utils.py +315 -0
- fbuild/deploy/monitor.py +519 -0
- fbuild/deploy/qemu_runner.py +603 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/ledger/__init__.py +52 -0
- fbuild/ledger/board_ledger.py +560 -0
- fbuild/output.py +352 -0
- fbuild/packages/__init__.py +66 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +256 -0
- fbuild/packages/concurrent_manager.py +510 -0
- fbuild/packages/downloader.py +518 -0
- fbuild/packages/fingerprint.py +423 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_rp2040.py +349 -0
- fbuild/packages/framework_stm32.py +459 -0
- fbuild/packages/framework_teensy.py +346 -0
- fbuild/packages/github_utils.py +96 -0
- fbuild/packages/header_trampoline_cache.py +394 -0
- fbuild/packages/library_compiler.py +203 -0
- fbuild/packages/library_manager.py +549 -0
- fbuild/packages/library_manager_esp32.py +725 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_rp2040.py +400 -0
- fbuild/packages/platform_stm32.py +581 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +369 -0
- fbuild/packages/sdk_utils.py +231 -0
- fbuild/packages/toolchain.py +436 -0
- fbuild/packages/toolchain_binaries.py +196 -0
- fbuild/packages/toolchain_esp32.py +489 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_rp2040.py +436 -0
- fbuild/packages/toolchain_stm32.py +417 -0
- fbuild/packages/toolchain_teensy.py +404 -0
- fbuild/platform_configs/esp32.json +150 -0
- fbuild/platform_configs/esp32c2.json +144 -0
- fbuild/platform_configs/esp32c3.json +143 -0
- fbuild/platform_configs/esp32c5.json +151 -0
- fbuild/platform_configs/esp32c6.json +151 -0
- fbuild/platform_configs/esp32p4.json +149 -0
- fbuild/platform_configs/esp32s3.json +151 -0
- fbuild/platform_configs/imxrt1062.json +56 -0
- fbuild/platform_configs/rp2040.json +70 -0
- fbuild/platform_configs/rp2350.json +76 -0
- fbuild/platform_configs/stm32f1.json +59 -0
- fbuild/platform_configs/stm32f4.json +63 -0
- fbuild/py.typed +0 -0
- fbuild-1.2.8.dist-info/METADATA +468 -0
- fbuild-1.2.8.dist-info/RECORD +121 -0
- fbuild-1.2.8.dist-info/WHEEL +5 -0
- fbuild-1.2.8.dist-info/entry_points.txt +5 -0
- fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
- fbuild-1.2.8.dist-info/top_level.txt +2 -0
- fbuild_lint/__init__.py +0 -0
- fbuild_lint/ruff_plugins/__init__.py +0 -0
- fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Serial Manager - Centralized serial port management for multiple clients.
|
|
3
|
+
|
|
4
|
+
This module provides the SharedSerialManager class which allows multiple clients
|
|
5
|
+
to read serial output from a device (broadcast), while only one client can write
|
|
6
|
+
to it at a time (exclusive access). Key features:
|
|
7
|
+
|
|
8
|
+
- Centralized serial port management on the daemon
|
|
9
|
+
- Multiple "reader" clients can attach and receive serial output (broadcast)
|
|
10
|
+
- Single "writer" client has exclusive input access
|
|
11
|
+
- Output is buffered and distributed to all attached readers
|
|
12
|
+
- Thread-safe operations
|
|
13
|
+
- Handle reader/writer attach/detach
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> manager = SharedSerialManager()
|
|
17
|
+
>>> # Open port and attach as reader
|
|
18
|
+
>>> manager.open_port("COM3", 115200, client_id="client_1")
|
|
19
|
+
>>> manager.attach_reader("COM3", "client_1")
|
|
20
|
+
>>> # Another client can also read
|
|
21
|
+
>>> manager.attach_reader("COM3", "client_2")
|
|
22
|
+
>>> # Only one writer at a time
|
|
23
|
+
>>> manager.acquire_writer("COM3", "client_1", timeout=5.0)
|
|
24
|
+
>>> manager.write("COM3", "client_1", b"hello\\n")
|
|
25
|
+
>>> manager.release_writer("COM3", "client_1")
|
|
26
|
+
>>> # Get buffered output
|
|
27
|
+
>>> lines = manager.read_buffer("COM3", "client_1")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from collections import deque
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any, Callable
|
|
36
|
+
|
|
37
|
+
# Default buffer size (number of lines)
|
|
38
|
+
DEFAULT_BUFFER_SIZE = 10000
|
|
39
|
+
|
|
40
|
+
# Serial read timeout in seconds
|
|
41
|
+
SERIAL_READ_TIMEOUT = 0.1
|
|
42
|
+
|
|
43
|
+
# Writer acquisition timeout default
|
|
44
|
+
DEFAULT_WRITER_TIMEOUT = 10.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SerialSession:
|
|
49
|
+
"""Information about an active serial session.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
port: Serial port identifier (e.g., "COM3", "/dev/ttyUSB0")
|
|
53
|
+
baud_rate: Baud rate for the serial connection
|
|
54
|
+
is_open: Whether the serial port is currently open
|
|
55
|
+
writer_client_id: Client ID that has exclusive write access (None if no writer)
|
|
56
|
+
reader_client_ids: Set of client IDs attached as readers
|
|
57
|
+
output_buffer: Deque of recent output lines (thread-safe)
|
|
58
|
+
total_bytes_read: Total bytes read from the serial port
|
|
59
|
+
total_bytes_written: Total bytes written to the serial port
|
|
60
|
+
started_at: Unix timestamp when the session was started
|
|
61
|
+
owner_client_id: Client ID that opened the port (owner)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
port: str
|
|
65
|
+
baud_rate: int
|
|
66
|
+
is_open: bool = False
|
|
67
|
+
writer_client_id: str | None = None
|
|
68
|
+
reader_client_ids: set[str] = field(default_factory=set)
|
|
69
|
+
output_buffer: deque[str] = field(default_factory=lambda: deque(maxlen=DEFAULT_BUFFER_SIZE))
|
|
70
|
+
total_bytes_read: int = 0
|
|
71
|
+
total_bytes_written: int = 0
|
|
72
|
+
started_at: float = field(default_factory=time.time)
|
|
73
|
+
owner_client_id: str | None = None
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
"""Convert session info to dictionary for JSON serialization."""
|
|
77
|
+
return {
|
|
78
|
+
"port": self.port,
|
|
79
|
+
"baud_rate": self.baud_rate,
|
|
80
|
+
"is_open": self.is_open,
|
|
81
|
+
"writer_client_id": self.writer_client_id,
|
|
82
|
+
"reader_client_ids": list(self.reader_client_ids),
|
|
83
|
+
"buffer_size": len(self.output_buffer),
|
|
84
|
+
"total_bytes_read": self.total_bytes_read,
|
|
85
|
+
"total_bytes_written": self.total_bytes_written,
|
|
86
|
+
"started_at": self.started_at,
|
|
87
|
+
"owner_client_id": self.owner_client_id,
|
|
88
|
+
"uptime_seconds": time.time() - self.started_at,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SharedSerialManager:
|
|
93
|
+
"""Manages shared serial port access for multiple clients.
|
|
94
|
+
|
|
95
|
+
This class provides centralized serial port management where:
|
|
96
|
+
- Multiple clients can attach as readers and receive output (broadcast)
|
|
97
|
+
- Only one client can have write access at a time (exclusive)
|
|
98
|
+
- Output is buffered and available for clients to poll
|
|
99
|
+
- All operations are thread-safe
|
|
100
|
+
|
|
101
|
+
The manager maintains a background thread per port for reading serial data
|
|
102
|
+
and distributing it to all attached readers.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
>>> manager = SharedSerialManager()
|
|
106
|
+
>>>
|
|
107
|
+
>>> # Open port (becomes owner)
|
|
108
|
+
>>> manager.open_port("COM3", 115200, "client_1")
|
|
109
|
+
>>>
|
|
110
|
+
>>> # Attach as reader to get output
|
|
111
|
+
>>> manager.attach_reader("COM3", "client_1")
|
|
112
|
+
>>> manager.attach_reader("COM3", "client_2") # Another client
|
|
113
|
+
>>>
|
|
114
|
+
>>> # Get write access
|
|
115
|
+
>>> if manager.acquire_writer("COM3", "client_1", timeout=5.0):
|
|
116
|
+
... manager.write("COM3", "client_1", b"test\\n")
|
|
117
|
+
... manager.release_writer("COM3", "client_1")
|
|
118
|
+
>>>
|
|
119
|
+
>>> # Read buffered output
|
|
120
|
+
>>> lines = manager.read_buffer("COM3", "client_1", max_lines=100)
|
|
121
|
+
>>>
|
|
122
|
+
>>> # Cleanup when done
|
|
123
|
+
>>> manager.detach_reader("COM3", "client_2")
|
|
124
|
+
>>> manager.close_port("COM3", "client_1")
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, max_buffer_size: int = DEFAULT_BUFFER_SIZE) -> None:
|
|
128
|
+
"""Initialize the SharedSerialManager.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
max_buffer_size: Maximum number of lines to buffer per session
|
|
132
|
+
"""
|
|
133
|
+
self._lock = threading.Lock() # Master lock for sessions dictionary
|
|
134
|
+
self._sessions: dict[str, SerialSession] = {} # port -> session
|
|
135
|
+
self._serial_ports: dict[str, Any] = {} # port -> pyserial.Serial object
|
|
136
|
+
self._reader_threads: dict[str, threading.Thread] = {} # port -> reader thread
|
|
137
|
+
self._stop_events: dict[str, threading.Event] = {} # port -> stop event
|
|
138
|
+
self._writer_locks: dict[str, threading.Lock] = {} # port -> writer lock
|
|
139
|
+
self._writer_conditions: dict[str, threading.Condition] = {} # port -> writer condition
|
|
140
|
+
self._max_buffer_size = max_buffer_size
|
|
141
|
+
self._reader_callbacks: dict[str, dict[str, Callable[[str, str], None]]] = {} # port -> {client_id -> callback}
|
|
142
|
+
|
|
143
|
+
def open_port(self, port: str, baud_rate: int, client_id: str) -> bool:
|
|
144
|
+
"""Open a serial port if not already open.
|
|
145
|
+
|
|
146
|
+
The client that opens the port becomes the owner. Only the owner or the
|
|
147
|
+
last reader can close the port.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
port: Serial port identifier (e.g., "COM3", "/dev/ttyUSB0")
|
|
151
|
+
baud_rate: Baud rate for the serial connection
|
|
152
|
+
client_id: Unique identifier for the client
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if port was opened successfully or was already open, False on error
|
|
156
|
+
"""
|
|
157
|
+
with self._lock:
|
|
158
|
+
# Check if port is already open
|
|
159
|
+
if port in self._sessions and self._sessions[port].is_open:
|
|
160
|
+
logging.info(f"Port {port} already open (requested by {client_id})")
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
# Try to open the port
|
|
164
|
+
try:
|
|
165
|
+
import serial
|
|
166
|
+
|
|
167
|
+
ser = serial.Serial(
|
|
168
|
+
port,
|
|
169
|
+
baud_rate,
|
|
170
|
+
timeout=SERIAL_READ_TIMEOUT,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Create session
|
|
174
|
+
session = SerialSession(
|
|
175
|
+
port=port,
|
|
176
|
+
baud_rate=baud_rate,
|
|
177
|
+
is_open=True,
|
|
178
|
+
owner_client_id=client_id,
|
|
179
|
+
started_at=time.time(),
|
|
180
|
+
output_buffer=deque(maxlen=self._max_buffer_size),
|
|
181
|
+
)
|
|
182
|
+
self._sessions[port] = session
|
|
183
|
+
self._serial_ports[port] = ser
|
|
184
|
+
self._writer_locks[port] = threading.Lock()
|
|
185
|
+
self._writer_conditions[port] = threading.Condition(self._writer_locks[port])
|
|
186
|
+
self._reader_callbacks[port] = {}
|
|
187
|
+
|
|
188
|
+
# Start background reader thread
|
|
189
|
+
stop_event = threading.Event()
|
|
190
|
+
self._stop_events[port] = stop_event
|
|
191
|
+
reader_thread = threading.Thread(
|
|
192
|
+
target=self._serial_reader_loop,
|
|
193
|
+
args=(port, ser, stop_event),
|
|
194
|
+
name=f"SerialReader-{port}",
|
|
195
|
+
daemon=True,
|
|
196
|
+
)
|
|
197
|
+
self._reader_threads[port] = reader_thread
|
|
198
|
+
reader_thread.start()
|
|
199
|
+
|
|
200
|
+
logging.info(f"Opened serial port {port} at {baud_rate} baud (owner: {client_id})")
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
except ImportError:
|
|
204
|
+
logging.error("pyserial not installed. Install with: pip install pyserial")
|
|
205
|
+
return False
|
|
206
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
207
|
+
raise
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logging.error(f"Failed to open serial port {port}: {e}")
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
def close_port(self, port: str, client_id: str) -> bool:
|
|
213
|
+
"""Close a serial port.
|
|
214
|
+
|
|
215
|
+
The port can be closed by the owner or if there are no more readers.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
port: Serial port identifier
|
|
219
|
+
client_id: Client requesting the close
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
True if port was closed, False if not allowed or not found
|
|
223
|
+
"""
|
|
224
|
+
with self._lock:
|
|
225
|
+
if port not in self._sessions:
|
|
226
|
+
logging.warning(f"Cannot close unknown port: {port}")
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
session = self._sessions[port]
|
|
230
|
+
|
|
231
|
+
# Check if client is allowed to close
|
|
232
|
+
is_owner = session.owner_client_id == client_id
|
|
233
|
+
no_readers = len(session.reader_client_ids) == 0
|
|
234
|
+
is_last_reader = session.reader_client_ids == {client_id}
|
|
235
|
+
|
|
236
|
+
if not (is_owner or no_readers or is_last_reader):
|
|
237
|
+
logging.warning(f"Client {client_id} not allowed to close port {port} " f"(owner: {session.owner_client_id}, readers: {session.reader_client_ids})")
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
return self._close_port_internal(port)
|
|
241
|
+
|
|
242
|
+
def _close_port_internal(self, port: str) -> bool:
|
|
243
|
+
"""Internal method to close a port (caller must hold _lock).
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
port: Serial port identifier
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
True if port was closed, False on error
|
|
250
|
+
"""
|
|
251
|
+
if port not in self._sessions:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
session = self._sessions[port]
|
|
255
|
+
|
|
256
|
+
# Stop reader thread
|
|
257
|
+
if port in self._stop_events:
|
|
258
|
+
self._stop_events[port].set()
|
|
259
|
+
del self._stop_events[port]
|
|
260
|
+
|
|
261
|
+
# Wait for reader thread to finish (with timeout)
|
|
262
|
+
if port in self._reader_threads:
|
|
263
|
+
thread = self._reader_threads[port]
|
|
264
|
+
# Release lock while waiting to avoid deadlock
|
|
265
|
+
self._lock.release()
|
|
266
|
+
try:
|
|
267
|
+
thread.join(timeout=2.0)
|
|
268
|
+
finally:
|
|
269
|
+
self._lock.acquire()
|
|
270
|
+
del self._reader_threads[port]
|
|
271
|
+
|
|
272
|
+
# Close serial port
|
|
273
|
+
if port in self._serial_ports:
|
|
274
|
+
try:
|
|
275
|
+
self._serial_ports[port].close()
|
|
276
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
277
|
+
raise
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logging.error(f"Error closing serial port {port}: {e}")
|
|
280
|
+
del self._serial_ports[port]
|
|
281
|
+
|
|
282
|
+
# Cleanup synchronization objects
|
|
283
|
+
if port in self._writer_locks:
|
|
284
|
+
del self._writer_locks[port]
|
|
285
|
+
if port in self._writer_conditions:
|
|
286
|
+
del self._writer_conditions[port]
|
|
287
|
+
if port in self._reader_callbacks:
|
|
288
|
+
del self._reader_callbacks[port]
|
|
289
|
+
|
|
290
|
+
# Mark session as closed and remove
|
|
291
|
+
session.is_open = False
|
|
292
|
+
del self._sessions[port]
|
|
293
|
+
|
|
294
|
+
logging.info(f"Closed serial port {port}")
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
def attach_reader(self, port: str, client_id: str, callback: Callable[[str, str], None] | None = None) -> bool:
|
|
298
|
+
"""Attach as a reader to receive serial output.
|
|
299
|
+
|
|
300
|
+
Readers receive all output from the serial port. Multiple readers can
|
|
301
|
+
be attached simultaneously.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
port: Serial port identifier
|
|
305
|
+
client_id: Unique identifier for the client
|
|
306
|
+
callback: Optional callback function(port, line) called for each line received
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
True if successfully attached, False if port not open
|
|
310
|
+
"""
|
|
311
|
+
with self._lock:
|
|
312
|
+
if port not in self._sessions or not self._sessions[port].is_open:
|
|
313
|
+
logging.warning(f"Cannot attach reader to closed/unknown port: {port}")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
session = self._sessions[port]
|
|
317
|
+
session.reader_client_ids.add(client_id)
|
|
318
|
+
|
|
319
|
+
if callback is not None:
|
|
320
|
+
self._reader_callbacks[port][client_id] = callback
|
|
321
|
+
|
|
322
|
+
logging.debug(f"Client {client_id} attached as reader to {port}")
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
def detach_reader(self, port: str, client_id: str) -> bool:
|
|
326
|
+
"""Detach as a reader and stop receiving output.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
port: Serial port identifier
|
|
330
|
+
client_id: Client identifier to detach
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
True if successfully detached, False if not found
|
|
334
|
+
"""
|
|
335
|
+
with self._lock:
|
|
336
|
+
if port not in self._sessions:
|
|
337
|
+
logging.warning(f"Cannot detach reader from unknown port: {port}")
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
session = self._sessions[port]
|
|
341
|
+
if client_id not in session.reader_client_ids:
|
|
342
|
+
logging.warning(f"Client {client_id} is not a reader on port {port}")
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
session.reader_client_ids.discard(client_id)
|
|
346
|
+
|
|
347
|
+
if port in self._reader_callbacks and client_id in self._reader_callbacks[port]:
|
|
348
|
+
del self._reader_callbacks[port][client_id]
|
|
349
|
+
|
|
350
|
+
logging.debug(f"Client {client_id} detached from {port}")
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
def acquire_writer(self, port: str, client_id: str, timeout: float = DEFAULT_WRITER_TIMEOUT) -> bool:
|
|
354
|
+
"""Acquire exclusive write access to the serial port.
|
|
355
|
+
|
|
356
|
+
Only one client can have write access at a time. If another client
|
|
357
|
+
holds write access, this will block until the timeout expires.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
port: Serial port identifier
|
|
361
|
+
client_id: Client requesting write access
|
|
362
|
+
timeout: Maximum time to wait for write access (seconds)
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
True if write access acquired, False if timeout or port not open
|
|
366
|
+
"""
|
|
367
|
+
with self._lock:
|
|
368
|
+
if port not in self._sessions or not self._sessions[port].is_open:
|
|
369
|
+
logging.warning(f"Cannot acquire writer on closed/unknown port: {port}")
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
session = self._sessions[port]
|
|
373
|
+
condition = self._writer_conditions[port]
|
|
374
|
+
|
|
375
|
+
# Use condition variable to wait for writer access
|
|
376
|
+
with condition:
|
|
377
|
+
deadline = time.time() + timeout
|
|
378
|
+
|
|
379
|
+
while True:
|
|
380
|
+
with self._lock:
|
|
381
|
+
# Check if port is still valid
|
|
382
|
+
if port not in self._sessions or not self._sessions[port].is_open:
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
session = self._sessions[port]
|
|
386
|
+
|
|
387
|
+
# Check if we can acquire
|
|
388
|
+
if session.writer_client_id is None:
|
|
389
|
+
session.writer_client_id = client_id
|
|
390
|
+
logging.info(f"Client {client_id} acquired writer on {port}")
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
# Check if we already have it
|
|
394
|
+
if session.writer_client_id == client_id:
|
|
395
|
+
logging.debug(f"Client {client_id} already has writer on {port}")
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
# Wait with timeout
|
|
399
|
+
remaining = deadline - time.time()
|
|
400
|
+
if remaining <= 0:
|
|
401
|
+
logging.warning(f"Timeout acquiring writer on {port} for {client_id} " f"(current writer: {session.writer_client_id})")
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
condition.wait(timeout=min(remaining, 0.5))
|
|
405
|
+
|
|
406
|
+
def release_writer(self, port: str, client_id: str) -> bool:
|
|
407
|
+
"""Release exclusive write access.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
port: Serial port identifier
|
|
411
|
+
client_id: Client releasing write access
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
True if released successfully, False if not the current writer
|
|
415
|
+
"""
|
|
416
|
+
with self._lock:
|
|
417
|
+
if port not in self._sessions:
|
|
418
|
+
logging.warning(f"Cannot release writer on unknown port: {port}")
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
session = self._sessions[port]
|
|
422
|
+
|
|
423
|
+
if session.writer_client_id != client_id:
|
|
424
|
+
logging.warning(f"Client {client_id} is not the writer on {port} " f"(current: {session.writer_client_id})")
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
session.writer_client_id = None
|
|
428
|
+
condition = self._writer_conditions.get(port)
|
|
429
|
+
|
|
430
|
+
# Notify waiting writers
|
|
431
|
+
if condition:
|
|
432
|
+
with condition:
|
|
433
|
+
condition.notify_all()
|
|
434
|
+
|
|
435
|
+
logging.info(f"Client {client_id} released writer on {port}")
|
|
436
|
+
return True
|
|
437
|
+
|
|
438
|
+
def write(self, port: str, client_id: str, data: bytes) -> int:
|
|
439
|
+
"""Write data to the serial port.
|
|
440
|
+
|
|
441
|
+
Only the current writer can write to the port.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
port: Serial port identifier
|
|
445
|
+
client_id: Client attempting to write (must be current writer)
|
|
446
|
+
data: Bytes to write to the serial port
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Number of bytes written, or -1 on error
|
|
450
|
+
"""
|
|
451
|
+
with self._lock:
|
|
452
|
+
if port not in self._sessions or not self._sessions[port].is_open:
|
|
453
|
+
logging.warning(f"Cannot write to closed/unknown port: {port}")
|
|
454
|
+
return -1
|
|
455
|
+
|
|
456
|
+
session = self._sessions[port]
|
|
457
|
+
|
|
458
|
+
if session.writer_client_id != client_id:
|
|
459
|
+
logging.warning(f"Client {client_id} cannot write to {port} " f"(current writer: {session.writer_client_id})")
|
|
460
|
+
return -1
|
|
461
|
+
|
|
462
|
+
if port not in self._serial_ports:
|
|
463
|
+
logging.error(f"Serial port object not found for {port}")
|
|
464
|
+
return -1
|
|
465
|
+
|
|
466
|
+
ser = self._serial_ports[port]
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
bytes_written = ser.write(data)
|
|
470
|
+
with self._lock:
|
|
471
|
+
if port in self._sessions:
|
|
472
|
+
self._sessions[port].total_bytes_written += bytes_written
|
|
473
|
+
logging.debug(f"Wrote {bytes_written} bytes to {port}")
|
|
474
|
+
return bytes_written
|
|
475
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
476
|
+
raise
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logging.error(f"Error writing to {port}: {e}")
|
|
479
|
+
return -1
|
|
480
|
+
|
|
481
|
+
def read_buffer(self, port: str, client_id: str, max_lines: int = 100) -> list[str]:
|
|
482
|
+
"""Read recent output from the buffer.
|
|
483
|
+
|
|
484
|
+
This does not remove lines from the buffer - all readers see the same
|
|
485
|
+
buffered output.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
port: Serial port identifier
|
|
489
|
+
client_id: Client requesting output (must be attached reader)
|
|
490
|
+
max_lines: Maximum number of lines to return
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
List of recent output lines (most recent last)
|
|
494
|
+
"""
|
|
495
|
+
with self._lock:
|
|
496
|
+
if port not in self._sessions:
|
|
497
|
+
logging.warning(f"Cannot read buffer from unknown port: {port}")
|
|
498
|
+
return []
|
|
499
|
+
|
|
500
|
+
session = self._sessions[port]
|
|
501
|
+
|
|
502
|
+
# Verify client is a reader
|
|
503
|
+
if client_id not in session.reader_client_ids:
|
|
504
|
+
logging.warning(f"Client {client_id} is not a reader on {port}")
|
|
505
|
+
return []
|
|
506
|
+
|
|
507
|
+
# Return copy of buffer (thread-safe snapshot)
|
|
508
|
+
buffer = session.output_buffer
|
|
509
|
+
if max_lines >= len(buffer):
|
|
510
|
+
return list(buffer)
|
|
511
|
+
else:
|
|
512
|
+
return list(buffer)[-max_lines:]
|
|
513
|
+
|
|
514
|
+
def get_session_info(self, port: str) -> dict[str, Any] | None:
|
|
515
|
+
"""Get information about a serial session.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
port: Serial port identifier
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Dictionary with session info, or None if port not found
|
|
522
|
+
"""
|
|
523
|
+
with self._lock:
|
|
524
|
+
if port not in self._sessions:
|
|
525
|
+
return None
|
|
526
|
+
return self._sessions[port].to_dict()
|
|
527
|
+
|
|
528
|
+
def get_all_sessions(self) -> dict[str, dict[str, Any]]:
|
|
529
|
+
"""Get information about all active serial sessions.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Dictionary mapping port names to session info dictionaries
|
|
533
|
+
"""
|
|
534
|
+
with self._lock:
|
|
535
|
+
return {port: session.to_dict() for port, session in self._sessions.items()}
|
|
536
|
+
|
|
537
|
+
def disconnect_client(self, client_id: str) -> None:
|
|
538
|
+
"""Cleanup all sessions for a disconnected client.
|
|
539
|
+
|
|
540
|
+
This removes the client from all reader lists, releases any writer
|
|
541
|
+
locks they hold, and closes ports they own if no other clients remain.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
client_id: Client identifier to clean up
|
|
545
|
+
"""
|
|
546
|
+
with self._lock:
|
|
547
|
+
ports_to_close = []
|
|
548
|
+
|
|
549
|
+
for port, session in self._sessions.items():
|
|
550
|
+
# Release writer if held by this client
|
|
551
|
+
if session.writer_client_id == client_id:
|
|
552
|
+
session.writer_client_id = None
|
|
553
|
+
if port in self._writer_conditions:
|
|
554
|
+
condition = self._writer_conditions[port]
|
|
555
|
+
# Notify outside the lock
|
|
556
|
+
self._lock.release()
|
|
557
|
+
try:
|
|
558
|
+
with condition:
|
|
559
|
+
condition.notify_all()
|
|
560
|
+
finally:
|
|
561
|
+
self._lock.acquire()
|
|
562
|
+
logging.info(f"Released writer on {port} for disconnected client {client_id}")
|
|
563
|
+
|
|
564
|
+
# Remove from readers
|
|
565
|
+
if client_id in session.reader_client_ids:
|
|
566
|
+
session.reader_client_ids.discard(client_id)
|
|
567
|
+
logging.debug(f"Removed {client_id} from readers on {port}")
|
|
568
|
+
|
|
569
|
+
# Remove callbacks
|
|
570
|
+
if port in self._reader_callbacks and client_id in self._reader_callbacks[port]:
|
|
571
|
+
del self._reader_callbacks[port][client_id]
|
|
572
|
+
|
|
573
|
+
# Mark port for closure if owner and no other readers
|
|
574
|
+
if session.owner_client_id == client_id and len(session.reader_client_ids) == 0:
|
|
575
|
+
ports_to_close.append(port)
|
|
576
|
+
|
|
577
|
+
# Close ports owned by this client with no remaining readers
|
|
578
|
+
for port in ports_to_close:
|
|
579
|
+
logging.info(f"Closing port {port} owned by disconnected client {client_id}")
|
|
580
|
+
self._close_port_internal(port)
|
|
581
|
+
|
|
582
|
+
def broadcast_output(self, port: str, data: bytes) -> None:
|
|
583
|
+
"""Internal: Distribute received data to all attached readers.
|
|
584
|
+
|
|
585
|
+
This method is called by the background reader thread when data is
|
|
586
|
+
received from the serial port.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
port: Serial port identifier
|
|
590
|
+
data: Raw bytes received from serial port
|
|
591
|
+
"""
|
|
592
|
+
try:
|
|
593
|
+
# Decode bytes to string, handling errors gracefully
|
|
594
|
+
text = data.decode("utf-8", errors="replace").rstrip()
|
|
595
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
596
|
+
raise
|
|
597
|
+
except Exception:
|
|
598
|
+
text = str(data)
|
|
599
|
+
|
|
600
|
+
if not text:
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
# Split into lines and add to buffer
|
|
604
|
+
lines = text.split("\n")
|
|
605
|
+
|
|
606
|
+
callbacks_to_call: list[tuple[Callable[[str, str], None], str]] = []
|
|
607
|
+
|
|
608
|
+
with self._lock:
|
|
609
|
+
if port not in self._sessions:
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
session = self._sessions[port]
|
|
613
|
+
session.total_bytes_read += len(data)
|
|
614
|
+
|
|
615
|
+
for line in lines:
|
|
616
|
+
if line: # Skip empty lines
|
|
617
|
+
session.output_buffer.append(line)
|
|
618
|
+
|
|
619
|
+
# Collect callbacks to call (call outside lock to avoid deadlocks)
|
|
620
|
+
if port in self._reader_callbacks:
|
|
621
|
+
for callback in self._reader_callbacks[port].values():
|
|
622
|
+
for line in lines:
|
|
623
|
+
if line:
|
|
624
|
+
callbacks_to_call.append((callback, line))
|
|
625
|
+
|
|
626
|
+
# Call callbacks outside the lock
|
|
627
|
+
for callback, line in callbacks_to_call:
|
|
628
|
+
try:
|
|
629
|
+
callback(port, line)
|
|
630
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
631
|
+
raise
|
|
632
|
+
except Exception as e:
|
|
633
|
+
logging.error(f"Error in reader callback for {port}: {e}")
|
|
634
|
+
|
|
635
|
+
def _serial_reader_loop(self, port: str, ser: Any, stop_event: threading.Event) -> None:
|
|
636
|
+
"""Background thread that reads from serial port and broadcasts output.
|
|
637
|
+
|
|
638
|
+
This method runs in a dedicated thread per port and continuously reads
|
|
639
|
+
data from the serial port, broadcasting it to all attached readers.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
port: Serial port identifier
|
|
643
|
+
ser: pyserial.Serial object
|
|
644
|
+
stop_event: Event to signal thread shutdown
|
|
645
|
+
"""
|
|
646
|
+
logging.debug(f"Serial reader thread started for {port}")
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
while not stop_event.is_set():
|
|
650
|
+
try:
|
|
651
|
+
# Check if there's data available
|
|
652
|
+
if ser.in_waiting:
|
|
653
|
+
data = ser.readline()
|
|
654
|
+
if data:
|
|
655
|
+
self.broadcast_output(port, data)
|
|
656
|
+
else:
|
|
657
|
+
# Small sleep to avoid busy-waiting
|
|
658
|
+
time.sleep(0.01)
|
|
659
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
660
|
+
raise
|
|
661
|
+
except Exception as e:
|
|
662
|
+
if not stop_event.is_set():
|
|
663
|
+
logging.error(f"Error reading from {port}: {e}")
|
|
664
|
+
# Brief sleep before retry
|
|
665
|
+
time.sleep(0.1)
|
|
666
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
667
|
+
logging.debug(f"Serial reader thread for {port} interrupted")
|
|
668
|
+
except Exception as e:
|
|
669
|
+
logging.error(f"Fatal error in reader thread for {port}: {e}")
|
|
670
|
+
finally:
|
|
671
|
+
logging.debug(f"Serial reader thread stopped for {port}")
|
|
672
|
+
|
|
673
|
+
def get_session_count(self) -> int:
|
|
674
|
+
"""Get the number of active serial sessions.
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Number of open serial sessions
|
|
678
|
+
"""
|
|
679
|
+
with self._lock:
|
|
680
|
+
return len(self._sessions)
|
|
681
|
+
|
|
682
|
+
def shutdown(self) -> None:
|
|
683
|
+
"""Shutdown the manager and close all serial sessions.
|
|
684
|
+
|
|
685
|
+
This method should be called during daemon shutdown to ensure all
|
|
686
|
+
serial ports are properly closed and threads are stopped.
|
|
687
|
+
"""
|
|
688
|
+
logging.info("Shutting down SharedSerialManager...")
|
|
689
|
+
|
|
690
|
+
with self._lock:
|
|
691
|
+
ports = list(self._sessions.keys())
|
|
692
|
+
|
|
693
|
+
# Close all ports (close_port handles locking internally)
|
|
694
|
+
for port in ports:
|
|
695
|
+
with self._lock:
|
|
696
|
+
if port in self._sessions:
|
|
697
|
+
self._close_port_internal(port)
|
|
698
|
+
|
|
699
|
+
logging.info("SharedSerialManager shutdown complete")
|
|
700
|
+
|
|
701
|
+
def reset_device(self, port: str, client_id: str) -> bool:
|
|
702
|
+
"""Reset the device connected to the serial port.
|
|
703
|
+
|
|
704
|
+
Uses DTR/RTS toggling to reset the device, which is common for
|
|
705
|
+
ESP32 and similar microcontrollers.
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
port: Serial port identifier
|
|
709
|
+
client_id: Client requesting the reset (must be writer)
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
True if reset was successful, False on error or unauthorized
|
|
713
|
+
"""
|
|
714
|
+
with self._lock:
|
|
715
|
+
if port not in self._sessions or not self._sessions[port].is_open:
|
|
716
|
+
logging.warning(f"Cannot reset device on closed/unknown port: {port}")
|
|
717
|
+
return False
|
|
718
|
+
|
|
719
|
+
session = self._sessions[port]
|
|
720
|
+
|
|
721
|
+
# Must be the writer to reset
|
|
722
|
+
if session.writer_client_id != client_id:
|
|
723
|
+
logging.warning(f"Client {client_id} cannot reset device on {port} " f"(current writer: {session.writer_client_id})")
|
|
724
|
+
return False
|
|
725
|
+
|
|
726
|
+
if port not in self._serial_ports:
|
|
727
|
+
logging.error(f"Serial port object not found for {port}")
|
|
728
|
+
return False
|
|
729
|
+
|
|
730
|
+
ser = self._serial_ports[port]
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
# Toggle DTR/RTS to reset device
|
|
734
|
+
ser.setDTR(False)
|
|
735
|
+
ser.setRTS(True)
|
|
736
|
+
time.sleep(0.1)
|
|
737
|
+
ser.setRTS(False)
|
|
738
|
+
time.sleep(0.1)
|
|
739
|
+
ser.setDTR(True)
|
|
740
|
+
|
|
741
|
+
logging.info(f"Reset device on {port}")
|
|
742
|
+
return True
|
|
743
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
744
|
+
raise
|
|
745
|
+
except Exception as e:
|
|
746
|
+
logging.error(f"Error resetting device on {port}: {e}")
|
|
747
|
+
return False
|
|
748
|
+
|
|
749
|
+
def clear_buffer(self, port: str, client_id: str) -> bool:
|
|
750
|
+
"""Clear the output buffer for a port.
|
|
751
|
+
|
|
752
|
+
Only the port owner can clear the buffer.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
port: Serial port identifier
|
|
756
|
+
client_id: Client requesting the clear (must be owner)
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
True if buffer was cleared, False on error
|
|
760
|
+
"""
|
|
761
|
+
with self._lock:
|
|
762
|
+
if port not in self._sessions:
|
|
763
|
+
logging.warning(f"Cannot clear buffer on unknown port: {port}")
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
session = self._sessions[port]
|
|
767
|
+
|
|
768
|
+
# Only owner can clear buffer
|
|
769
|
+
if session.owner_client_id != client_id:
|
|
770
|
+
logging.warning(f"Client {client_id} cannot clear buffer on {port} " f"(owner: {session.owner_client_id})")
|
|
771
|
+
return False
|
|
772
|
+
|
|
773
|
+
session.output_buffer.clear()
|
|
774
|
+
logging.debug(f"Cleared buffer on {port}")
|
|
775
|
+
return True
|
|
776
|
+
|
|
777
|
+
def set_baud_rate(self, port: str, client_id: str, baud_rate: int) -> bool:
|
|
778
|
+
"""Change the baud rate for an open port.
|
|
779
|
+
|
|
780
|
+
Only the port owner can change the baud rate.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
port: Serial port identifier
|
|
784
|
+
client_id: Client requesting the change (must be owner)
|
|
785
|
+
baud_rate: New baud rate
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
True if baud rate was changed, False on error
|
|
789
|
+
"""
|
|
790
|
+
with self._lock:
|
|
791
|
+
if port not in self._sessions or not self._sessions[port].is_open:
|
|
792
|
+
logging.warning(f"Cannot set baud rate on closed/unknown port: {port}")
|
|
793
|
+
return False
|
|
794
|
+
|
|
795
|
+
session = self._sessions[port]
|
|
796
|
+
|
|
797
|
+
# Only owner can change baud rate
|
|
798
|
+
if session.owner_client_id != client_id:
|
|
799
|
+
logging.warning(f"Client {client_id} cannot set baud rate on {port} " f"(owner: {session.owner_client_id})")
|
|
800
|
+
return False
|
|
801
|
+
|
|
802
|
+
if port not in self._serial_ports:
|
|
803
|
+
logging.error(f"Serial port object not found for {port}")
|
|
804
|
+
return False
|
|
805
|
+
|
|
806
|
+
ser = self._serial_ports[port]
|
|
807
|
+
|
|
808
|
+
try:
|
|
809
|
+
ser.baudrate = baud_rate
|
|
810
|
+
with self._lock:
|
|
811
|
+
if port in self._sessions:
|
|
812
|
+
self._sessions[port].baud_rate = baud_rate
|
|
813
|
+
logging.info(f"Changed baud rate on {port} to {baud_rate}")
|
|
814
|
+
return True
|
|
815
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
816
|
+
raise
|
|
817
|
+
except Exception as e:
|
|
818
|
+
logging.error(f"Error setting baud rate on {port}: {e}")
|
|
819
|
+
return False
|