fbuild 1.2.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. fbuild/__init__.py +390 -0
  2. fbuild/assets/example.txt +1 -0
  3. fbuild/build/__init__.py +117 -0
  4. fbuild/build/archive_creator.py +186 -0
  5. fbuild/build/binary_generator.py +444 -0
  6. fbuild/build/build_component_factory.py +131 -0
  7. fbuild/build/build_info_generator.py +624 -0
  8. fbuild/build/build_state.py +325 -0
  9. fbuild/build/build_utils.py +93 -0
  10. fbuild/build/compilation_executor.py +422 -0
  11. fbuild/build/compiler.py +165 -0
  12. fbuild/build/compiler_avr.py +574 -0
  13. fbuild/build/configurable_compiler.py +664 -0
  14. fbuild/build/configurable_linker.py +637 -0
  15. fbuild/build/flag_builder.py +214 -0
  16. fbuild/build/library_dependency_processor.py +185 -0
  17. fbuild/build/linker.py +708 -0
  18. fbuild/build/orchestrator.py +67 -0
  19. fbuild/build/orchestrator_avr.py +651 -0
  20. fbuild/build/orchestrator_esp32.py +878 -0
  21. fbuild/build/orchestrator_rp2040.py +719 -0
  22. fbuild/build/orchestrator_stm32.py +696 -0
  23. fbuild/build/orchestrator_teensy.py +580 -0
  24. fbuild/build/source_compilation_orchestrator.py +218 -0
  25. fbuild/build/source_scanner.py +516 -0
  26. fbuild/cli.py +717 -0
  27. fbuild/cli_utils.py +314 -0
  28. fbuild/config/__init__.py +16 -0
  29. fbuild/config/board_config.py +542 -0
  30. fbuild/config/board_loader.py +92 -0
  31. fbuild/config/ini_parser.py +369 -0
  32. fbuild/config/mcu_specs.py +88 -0
  33. fbuild/daemon/__init__.py +42 -0
  34. fbuild/daemon/async_client.py +531 -0
  35. fbuild/daemon/client.py +1505 -0
  36. fbuild/daemon/compilation_queue.py +293 -0
  37. fbuild/daemon/configuration_lock.py +865 -0
  38. fbuild/daemon/daemon.py +585 -0
  39. fbuild/daemon/daemon_context.py +293 -0
  40. fbuild/daemon/error_collector.py +263 -0
  41. fbuild/daemon/file_cache.py +332 -0
  42. fbuild/daemon/firmware_ledger.py +546 -0
  43. fbuild/daemon/lock_manager.py +508 -0
  44. fbuild/daemon/logging_utils.py +149 -0
  45. fbuild/daemon/messages.py +957 -0
  46. fbuild/daemon/operation_registry.py +288 -0
  47. fbuild/daemon/port_state_manager.py +249 -0
  48. fbuild/daemon/process_tracker.py +366 -0
  49. fbuild/daemon/processors/__init__.py +18 -0
  50. fbuild/daemon/processors/build_processor.py +248 -0
  51. fbuild/daemon/processors/deploy_processor.py +664 -0
  52. fbuild/daemon/processors/install_deps_processor.py +431 -0
  53. fbuild/daemon/processors/locking_processor.py +777 -0
  54. fbuild/daemon/processors/monitor_processor.py +285 -0
  55. fbuild/daemon/request_processor.py +457 -0
  56. fbuild/daemon/shared_serial.py +819 -0
  57. fbuild/daemon/status_manager.py +238 -0
  58. fbuild/daemon/subprocess_manager.py +316 -0
  59. fbuild/deploy/__init__.py +21 -0
  60. fbuild/deploy/deployer.py +67 -0
  61. fbuild/deploy/deployer_esp32.py +310 -0
  62. fbuild/deploy/docker_utils.py +315 -0
  63. fbuild/deploy/monitor.py +519 -0
  64. fbuild/deploy/qemu_runner.py +603 -0
  65. fbuild/interrupt_utils.py +34 -0
  66. fbuild/ledger/__init__.py +52 -0
  67. fbuild/ledger/board_ledger.py +560 -0
  68. fbuild/output.py +352 -0
  69. fbuild/packages/__init__.py +66 -0
  70. fbuild/packages/archive_utils.py +1098 -0
  71. fbuild/packages/arduino_core.py +412 -0
  72. fbuild/packages/cache.py +256 -0
  73. fbuild/packages/concurrent_manager.py +510 -0
  74. fbuild/packages/downloader.py +518 -0
  75. fbuild/packages/fingerprint.py +423 -0
  76. fbuild/packages/framework_esp32.py +538 -0
  77. fbuild/packages/framework_rp2040.py +349 -0
  78. fbuild/packages/framework_stm32.py +459 -0
  79. fbuild/packages/framework_teensy.py +346 -0
  80. fbuild/packages/github_utils.py +96 -0
  81. fbuild/packages/header_trampoline_cache.py +394 -0
  82. fbuild/packages/library_compiler.py +203 -0
  83. fbuild/packages/library_manager.py +549 -0
  84. fbuild/packages/library_manager_esp32.py +725 -0
  85. fbuild/packages/package.py +163 -0
  86. fbuild/packages/platform_esp32.py +383 -0
  87. fbuild/packages/platform_rp2040.py +400 -0
  88. fbuild/packages/platform_stm32.py +581 -0
  89. fbuild/packages/platform_teensy.py +312 -0
  90. fbuild/packages/platform_utils.py +131 -0
  91. fbuild/packages/platformio_registry.py +369 -0
  92. fbuild/packages/sdk_utils.py +231 -0
  93. fbuild/packages/toolchain.py +436 -0
  94. fbuild/packages/toolchain_binaries.py +196 -0
  95. fbuild/packages/toolchain_esp32.py +489 -0
  96. fbuild/packages/toolchain_metadata.py +185 -0
  97. fbuild/packages/toolchain_rp2040.py +436 -0
  98. fbuild/packages/toolchain_stm32.py +417 -0
  99. fbuild/packages/toolchain_teensy.py +404 -0
  100. fbuild/platform_configs/esp32.json +150 -0
  101. fbuild/platform_configs/esp32c2.json +144 -0
  102. fbuild/platform_configs/esp32c3.json +143 -0
  103. fbuild/platform_configs/esp32c5.json +151 -0
  104. fbuild/platform_configs/esp32c6.json +151 -0
  105. fbuild/platform_configs/esp32p4.json +149 -0
  106. fbuild/platform_configs/esp32s3.json +151 -0
  107. fbuild/platform_configs/imxrt1062.json +56 -0
  108. fbuild/platform_configs/rp2040.json +70 -0
  109. fbuild/platform_configs/rp2350.json +76 -0
  110. fbuild/platform_configs/stm32f1.json +59 -0
  111. fbuild/platform_configs/stm32f4.json +63 -0
  112. fbuild/py.typed +0 -0
  113. fbuild-1.2.8.dist-info/METADATA +468 -0
  114. fbuild-1.2.8.dist-info/RECORD +121 -0
  115. fbuild-1.2.8.dist-info/WHEEL +5 -0
  116. fbuild-1.2.8.dist-info/entry_points.txt +5 -0
  117. fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
  118. fbuild-1.2.8.dist-info/top_level.txt +2 -0
  119. fbuild_lint/__init__.py +0 -0
  120. fbuild_lint/ruff_plugins/__init__.py +0 -0
  121. fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
@@ -0,0 +1,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