fbuild 1.2.8__py3-none-any.whl → 1.2.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. fbuild/__init__.py +5 -1
  2. fbuild/build/configurable_compiler.py +49 -6
  3. fbuild/build/configurable_linker.py +14 -9
  4. fbuild/build/orchestrator_esp32.py +6 -3
  5. fbuild/build/orchestrator_rp2040.py +6 -2
  6. fbuild/cli.py +300 -5
  7. fbuild/config/ini_parser.py +13 -1
  8. fbuild/daemon/__init__.py +11 -0
  9. fbuild/daemon/async_client.py +5 -4
  10. fbuild/daemon/async_client_lib.py +1543 -0
  11. fbuild/daemon/async_protocol.py +825 -0
  12. fbuild/daemon/async_server.py +2100 -0
  13. fbuild/daemon/client.py +425 -13
  14. fbuild/daemon/configuration_lock.py +13 -13
  15. fbuild/daemon/connection.py +508 -0
  16. fbuild/daemon/connection_registry.py +579 -0
  17. fbuild/daemon/daemon.py +517 -164
  18. fbuild/daemon/daemon_context.py +72 -1
  19. fbuild/daemon/device_discovery.py +477 -0
  20. fbuild/daemon/device_manager.py +821 -0
  21. fbuild/daemon/error_collector.py +263 -263
  22. fbuild/daemon/file_cache.py +332 -332
  23. fbuild/daemon/firmware_ledger.py +46 -123
  24. fbuild/daemon/lock_manager.py +508 -508
  25. fbuild/daemon/messages.py +431 -0
  26. fbuild/daemon/operation_registry.py +288 -288
  27. fbuild/daemon/processors/build_processor.py +34 -1
  28. fbuild/daemon/processors/deploy_processor.py +1 -3
  29. fbuild/daemon/processors/locking_processor.py +7 -7
  30. fbuild/daemon/request_processor.py +457 -457
  31. fbuild/daemon/shared_serial.py +7 -7
  32. fbuild/daemon/status_manager.py +238 -238
  33. fbuild/daemon/subprocess_manager.py +316 -316
  34. fbuild/deploy/docker_utils.py +182 -2
  35. fbuild/deploy/monitor.py +1 -1
  36. fbuild/deploy/qemu_runner.py +71 -13
  37. fbuild/ledger/board_ledger.py +46 -122
  38. fbuild/output.py +238 -2
  39. fbuild/packages/library_compiler.py +15 -5
  40. fbuild/packages/library_manager.py +12 -6
  41. fbuild-1.2.15.dist-info/METADATA +569 -0
  42. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
  43. fbuild-1.2.8.dist-info/METADATA +0 -468
  44. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
  45. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
  46. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
  47. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2100 @@
1
+ """
2
+ Async Daemon Server - Asyncio-based TCP server for fbuild daemon communication.
3
+
4
+ This module provides an asyncio-based server for handling client connections
5
+ to the fbuild daemon. It supports:
6
+
7
+ - TCP connections on localhost (configurable port, default 9876)
8
+ - Optional Unix socket support for better performance on Unix systems
9
+ - Client connection lifecycle management (connect, heartbeat, disconnect)
10
+ - Message routing to appropriate handlers
11
+ - Broadcast support for sending messages to all or specific clients
12
+ - Subscription system for events (locks, firmware, serial)
13
+
14
+ The server is designed to run alongside the existing file-based daemon loop,
15
+ sharing the DaemonContext for thread-safe access to daemon state.
16
+
17
+ Example:
18
+ >>> import asyncio
19
+ >>> from fbuild.daemon.daemon_context import create_daemon_context
20
+ >>> from fbuild.daemon.async_server import AsyncDaemonServer
21
+ >>>
22
+ >>> # Create daemon context
23
+ >>> context = create_daemon_context(...)
24
+ >>>
25
+ >>> # Create and start server
26
+ >>> server = AsyncDaemonServer(context, port=9876)
27
+ >>> asyncio.run(server.start())
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import base64
34
+ import json
35
+ import logging
36
+ import sys
37
+ import threading
38
+ import time
39
+ import uuid
40
+ from dataclasses import dataclass, field
41
+ from enum import Enum
42
+ from pathlib import Path
43
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine
44
+
45
+ if TYPE_CHECKING:
46
+ from fbuild.daemon.async_client import ClientConnectionManager
47
+ from fbuild.daemon.configuration_lock import ConfigurationLockManager
48
+ from fbuild.daemon.daemon_context import DaemonContext
49
+ from fbuild.daemon.device_manager import DeviceManager
50
+ from fbuild.daemon.firmware_ledger import FirmwareLedger
51
+ from fbuild.daemon.shared_serial import SharedSerialManager
52
+
53
+ # Default server configuration
54
+ DEFAULT_PORT = 9876
55
+ DEFAULT_HOST = "127.0.0.1"
56
+ # Heartbeat timeout: clients must send heartbeat every ~1s; if missed for 4s, disconnect
57
+ # Per TASK.md requirement: "If daemon misses heartbeats for ~3–4s, daemon closes the connection"
58
+ DEFAULT_HEARTBEAT_TIMEOUT = 4.0
59
+ DEFAULT_READ_BUFFER_SIZE = 65536
60
+ DEFAULT_WRITE_TIMEOUT = 10.0
61
+
62
+ # Message delimiter for framing
63
+ MESSAGE_DELIMITER = b"\n"
64
+
65
+
66
+ class SubscriptionType(Enum):
67
+ """Types of events clients can subscribe to."""
68
+
69
+ LOCKS = "locks" # Lock state changes
70
+ FIRMWARE = "firmware" # Firmware deployment events
71
+ SERIAL = "serial" # Serial port events
72
+ DEVICES = "devices" # Device lease events
73
+ STATUS = "status" # Daemon status updates
74
+ ALL = "all" # All events
75
+
76
+
77
+ class MessageType(Enum):
78
+ """Types of messages that can be sent/received."""
79
+
80
+ # Client lifecycle
81
+ CONNECT = "connect"
82
+ HEARTBEAT = "heartbeat"
83
+ DISCONNECT = "disconnect"
84
+
85
+ # Lock operations
86
+ LOCK_ACQUIRE = "lock_acquire"
87
+ LOCK_RELEASE = "lock_release"
88
+ LOCK_STATUS = "lock_status"
89
+
90
+ # Firmware operations
91
+ FIRMWARE_QUERY = "firmware_query"
92
+ FIRMWARE_RECORD = "firmware_record"
93
+
94
+ # Serial operations
95
+ SERIAL_ATTACH = "serial_attach"
96
+ SERIAL_DETACH = "serial_detach"
97
+ SERIAL_WRITE = "serial_write"
98
+ SERIAL_READ = "serial_read"
99
+
100
+ # Device operations
101
+ DEVICE_LIST = "device_list"
102
+ DEVICE_LEASE = "device_lease"
103
+ DEVICE_RELEASE = "device_release"
104
+ DEVICE_PREEMPT = "device_preempt"
105
+ DEVICE_STATUS = "device_status"
106
+
107
+ # Subscription
108
+ SUBSCRIBE = "subscribe"
109
+ UNSUBSCRIBE = "unsubscribe"
110
+
111
+ # Responses
112
+ RESPONSE = "response"
113
+ ERROR = "error"
114
+ BROADCAST = "broadcast"
115
+
116
+
117
+ @dataclass
118
+ class ClientConnection:
119
+ """Represents a connected client with its state.
120
+
121
+ Attributes:
122
+ client_id: Unique identifier for the client (UUID string)
123
+ reader: Asyncio stream reader for receiving messages
124
+ writer: Asyncio stream writer for sending messages
125
+ address: Client address (host, port) tuple
126
+ connected_at: Unix timestamp when client connected
127
+ last_heartbeat: Unix timestamp of last heartbeat received
128
+ subscriptions: Set of event types the client is subscribed to
129
+ metadata: Additional client metadata (pid, hostname, version, etc.)
130
+ is_connected: Whether the client is currently connected
131
+ lock: Lock for thread-safe writer access
132
+ """
133
+
134
+ client_id: str
135
+ reader: asyncio.StreamReader
136
+ writer: asyncio.StreamWriter
137
+ address: tuple[str, int]
138
+ connected_at: float = field(default_factory=time.time)
139
+ last_heartbeat: float = field(default_factory=time.time)
140
+ subscriptions: set[SubscriptionType] = field(default_factory=set)
141
+ metadata: dict[str, Any] = field(default_factory=dict)
142
+ is_connected: bool = True
143
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
144
+
145
+ def is_alive(self, timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT) -> bool:
146
+ """Check if client is still alive based on heartbeat timeout.
147
+
148
+ Args:
149
+ timeout_seconds: Maximum time since last heartbeat before considered dead.
150
+
151
+ Returns:
152
+ True if client is alive (heartbeat within timeout), False otherwise.
153
+ """
154
+ return self.is_connected and (time.time() - self.last_heartbeat) <= timeout_seconds
155
+
156
+ def update_heartbeat(self) -> None:
157
+ """Update the last heartbeat timestamp to current time."""
158
+ self.last_heartbeat = time.time()
159
+
160
+ def to_dict(self) -> dict[str, Any]:
161
+ """Convert to dictionary for JSON serialization."""
162
+ return {
163
+ "client_id": self.client_id,
164
+ "address": f"{self.address[0]}:{self.address[1]}",
165
+ "connected_at": self.connected_at,
166
+ "last_heartbeat": self.last_heartbeat,
167
+ "subscriptions": [s.value for s in self.subscriptions],
168
+ "metadata": self.metadata,
169
+ "is_connected": self.is_connected,
170
+ "is_alive": self.is_alive(),
171
+ "connection_duration": time.time() - self.connected_at,
172
+ "time_since_heartbeat": time.time() - self.last_heartbeat,
173
+ }
174
+
175
+
176
+ class AsyncDaemonServer:
177
+ """Asyncio-based TCP server for the fbuild daemon.
178
+
179
+ This server handles client connections and routes messages to appropriate
180
+ handlers. It integrates with the existing daemon through the DaemonContext,
181
+ using threading locks for thread-safe access to shared state.
182
+
183
+ The server supports:
184
+ - TCP connections on localhost (configurable port)
185
+ - Optional Unix socket support on Unix systems
186
+ - Client lifecycle management (connect, heartbeat, disconnect)
187
+ - Message routing to handlers for locks, firmware, and serial operations
188
+ - Broadcast messaging to all or subscribed clients
189
+ - Graceful shutdown handling
190
+
191
+ Example:
192
+ >>> server = AsyncDaemonServer(context, port=9876)
193
+ >>> # Start in background thread
194
+ >>> server.start_in_background()
195
+ >>> # ... daemon main loop runs ...
196
+ >>> # Stop server on shutdown
197
+ >>> server.stop()
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ host: str = DEFAULT_HOST,
203
+ port: int = DEFAULT_PORT,
204
+ unix_socket_path: Path | None = None,
205
+ heartbeat_timeout: float = DEFAULT_HEARTBEAT_TIMEOUT,
206
+ # Individual managers can be passed if context is not available
207
+ configuration_lock_manager: "ConfigurationLockManager | None" = None,
208
+ firmware_ledger: "FirmwareLedger | None" = None,
209
+ shared_serial_manager: "SharedSerialManager | None" = None,
210
+ client_manager: "ClientConnectionManager | None" = None,
211
+ device_manager: "DeviceManager | None" = None,
212
+ # Full context can also be passed (takes precedence)
213
+ context: "DaemonContext | None" = None,
214
+ ) -> None:
215
+ """Initialize the AsyncDaemonServer.
216
+
217
+ The server can be initialized either with individual managers or with a
218
+ full DaemonContext. If a context is provided, the individual managers
219
+ are extracted from it.
220
+
221
+ Args:
222
+ host: Host to bind to (default: 127.0.0.1)
223
+ port: Port to bind to (default: 9876)
224
+ unix_socket_path: Optional Unix socket path for Unix systems
225
+ heartbeat_timeout: Timeout in seconds for client heartbeats
226
+ configuration_lock_manager: ConfigurationLockManager for lock operations
227
+ firmware_ledger: FirmwareLedger for firmware tracking
228
+ shared_serial_manager: SharedSerialManager for serial port operations
229
+ client_manager: ClientConnectionManager for client tracking
230
+ device_manager: DeviceManager for device leasing
231
+ context: Full DaemonContext (if provided, individual managers are extracted)
232
+ """
233
+ self._host = host
234
+ self._port = port
235
+ self._unix_socket_path = unix_socket_path
236
+ self._heartbeat_timeout = heartbeat_timeout
237
+
238
+ # Extract managers from context if provided, otherwise use individual managers
239
+ if context is not None:
240
+ self._configuration_lock_manager = context.configuration_lock_manager
241
+ self._firmware_ledger = context.firmware_ledger
242
+ self._shared_serial_manager = context.shared_serial_manager
243
+ self._client_manager = context.client_manager
244
+ self._device_manager = getattr(context, "device_manager", None)
245
+ self._context = context # Keep reference for legacy access
246
+ else:
247
+ self._configuration_lock_manager = configuration_lock_manager
248
+ self._firmware_ledger = firmware_ledger
249
+ self._shared_serial_manager = shared_serial_manager
250
+ self._client_manager = client_manager
251
+ self._device_manager = device_manager
252
+ self._context = None # No full context available
253
+
254
+ # Client tracking
255
+ self._clients: dict[str, ClientConnection] = {}
256
+ self._clients_lock = asyncio.Lock()
257
+
258
+ # Server state
259
+ self._server: asyncio.Server | None = None
260
+ self._unix_server: asyncio.Server | None = None
261
+ self._is_running = False
262
+ self._shutdown_event: asyncio.Event | None = None
263
+
264
+ # Background tasks
265
+ self._heartbeat_task: asyncio.Task[None] | None = None
266
+ self._background_thread: threading.Thread | None = None
267
+ self._loop: asyncio.AbstractEventLoop | None = None
268
+
269
+ # Message handlers
270
+ self._handlers: dict[MessageType, Callable[[ClientConnection, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]] = {
271
+ MessageType.CONNECT: self._handle_connect,
272
+ MessageType.HEARTBEAT: self._handle_heartbeat,
273
+ MessageType.DISCONNECT: self._handle_disconnect,
274
+ MessageType.LOCK_ACQUIRE: self._handle_lock_acquire,
275
+ MessageType.LOCK_RELEASE: self._handle_lock_release,
276
+ MessageType.LOCK_STATUS: self._handle_lock_status,
277
+ MessageType.FIRMWARE_QUERY: self._handle_firmware_query,
278
+ MessageType.FIRMWARE_RECORD: self._handle_firmware_record,
279
+ MessageType.SERIAL_ATTACH: self._handle_serial_attach,
280
+ MessageType.SERIAL_DETACH: self._handle_serial_detach,
281
+ MessageType.SERIAL_WRITE: self._handle_serial_write,
282
+ MessageType.SERIAL_READ: self._handle_serial_read,
283
+ MessageType.DEVICE_LIST: self._handle_device_list,
284
+ MessageType.DEVICE_LEASE: self._handle_device_lease,
285
+ MessageType.DEVICE_RELEASE: self._handle_device_release,
286
+ MessageType.DEVICE_PREEMPT: self._handle_device_preempt,
287
+ MessageType.DEVICE_STATUS: self._handle_device_status,
288
+ MessageType.SUBSCRIBE: self._handle_subscribe,
289
+ MessageType.UNSUBSCRIBE: self._handle_unsubscribe,
290
+ }
291
+
292
+ logging.info(f"AsyncDaemonServer initialized (host={host}, port={port})")
293
+
294
+ @property
295
+ def is_running(self) -> bool:
296
+ """Check if the server is currently running."""
297
+ return self._is_running
298
+
299
+ @property
300
+ def client_count(self) -> int:
301
+ """Get the number of connected clients."""
302
+ return len(self._clients)
303
+
304
+ async def start(self) -> None:
305
+ """Start the async server and begin accepting connections.
306
+
307
+ This method runs the event loop and blocks until shutdown is requested.
308
+ For non-blocking operation, use start_in_background().
309
+ """
310
+ if self._is_running:
311
+ logging.warning("AsyncDaemonServer already running")
312
+ return
313
+
314
+ self._is_running = True
315
+ self._shutdown_event = asyncio.Event()
316
+
317
+ try:
318
+ # Start TCP server
319
+ self._server = await asyncio.start_server(
320
+ self._handle_client_connection,
321
+ self._host,
322
+ self._port,
323
+ )
324
+ addr = self._server.sockets[0].getsockname() if self._server.sockets else (self._host, self._port)
325
+ logging.info(f"AsyncDaemonServer listening on {addr[0]}:{addr[1]}")
326
+
327
+ # Start Unix socket server if path provided and on Unix
328
+ if self._unix_socket_path and sys.platform != "win32": # pragma: no cover
329
+ await self._start_unix_socket_server()
330
+
331
+ # Start heartbeat monitoring task
332
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_monitor())
333
+
334
+ # Wait for shutdown signal
335
+ await self._shutdown_event.wait()
336
+
337
+ except KeyboardInterrupt: # noqa: KBI002
338
+ raise
339
+ except Exception as e:
340
+ logging.error(f"AsyncDaemonServer error: {e}", exc_info=True)
341
+ raise
342
+ finally:
343
+ await self._cleanup()
344
+
345
+ async def _start_unix_socket_server(self) -> None: # pragma: no cover
346
+ """Start a Unix socket server for local connections (Unix only)."""
347
+ if self._unix_socket_path is None:
348
+ return
349
+
350
+ try:
351
+ # Remove existing socket file if present
352
+ if self._unix_socket_path.exists():
353
+ self._unix_socket_path.unlink()
354
+
355
+ # start_unix_server is only available on Unix platforms
356
+ start_unix_server = getattr(asyncio, "start_unix_server", None)
357
+ if start_unix_server is None:
358
+ logging.warning("Unix socket server not available on this platform")
359
+ return
360
+
361
+ self._unix_server = await start_unix_server(
362
+ self._handle_client_connection,
363
+ path=str(self._unix_socket_path),
364
+ )
365
+ logging.info(f"AsyncDaemonServer Unix socket listening on {self._unix_socket_path}")
366
+
367
+ except KeyboardInterrupt: # noqa: KBI002
368
+ raise
369
+ except Exception as e:
370
+ logging.error(f"Failed to start Unix socket server: {e}")
371
+
372
+ def start_in_background(self) -> None:
373
+ """Start the server in a background thread.
374
+
375
+ This method returns immediately, running the server's event loop
376
+ in a separate thread. Use stop() to shut down the server.
377
+ """
378
+ if self._is_running:
379
+ logging.warning("AsyncDaemonServer already running")
380
+ return
381
+
382
+ def run_loop() -> None:
383
+ self._loop = asyncio.new_event_loop()
384
+ asyncio.set_event_loop(self._loop)
385
+ try:
386
+ self._loop.run_until_complete(self.start())
387
+ except KeyboardInterrupt: # noqa: KBI002
388
+ raise
389
+ except Exception as e:
390
+ logging.error(f"Background server error: {e}", exc_info=True)
391
+ finally:
392
+ self._loop.close()
393
+
394
+ self._background_thread = threading.Thread(
395
+ target=run_loop,
396
+ name="AsyncDaemonServer",
397
+ daemon=True,
398
+ )
399
+ self._background_thread.start()
400
+ logging.info("AsyncDaemonServer started in background thread")
401
+
402
+ def stop(self) -> None:
403
+ """Stop the server and close all client connections.
404
+
405
+ This method signals the server to shut down and waits for cleanup
406
+ to complete. Safe to call from any thread.
407
+ """
408
+ if not self._is_running:
409
+ return
410
+
411
+ logging.info("Stopping AsyncDaemonServer...")
412
+
413
+ if self._loop and self._shutdown_event:
414
+ # Signal shutdown from the event loop thread
415
+ self._loop.call_soon_threadsafe(self._shutdown_event.set)
416
+
417
+ if self._background_thread and self._background_thread.is_alive():
418
+ self._background_thread.join(timeout=5.0)
419
+ if self._background_thread.is_alive():
420
+ logging.warning("Background thread did not stop cleanly")
421
+
422
+ self._is_running = False
423
+ logging.info("AsyncDaemonServer stopped")
424
+
425
+ async def _cleanup(self) -> None:
426
+ """Clean up server resources and close connections."""
427
+ logging.info("Cleaning up AsyncDaemonServer...")
428
+
429
+ # Cancel heartbeat task
430
+ if self._heartbeat_task and not self._heartbeat_task.done():
431
+ self._heartbeat_task.cancel()
432
+ try:
433
+ await self._heartbeat_task
434
+ except asyncio.CancelledError:
435
+ pass
436
+
437
+ # Close all client connections
438
+ async with self._clients_lock:
439
+ for client in list(self._clients.values()):
440
+ await self._close_client(client, "Server shutting down")
441
+ self._clients.clear()
442
+
443
+ # Close TCP server
444
+ if self._server:
445
+ self._server.close()
446
+ await self._server.wait_closed()
447
+ self._server = None
448
+
449
+ # Close Unix socket server
450
+ if self._unix_server:
451
+ self._unix_server.close()
452
+ await self._unix_server.wait_closed()
453
+ self._unix_server = None
454
+ if self._unix_socket_path and self._unix_socket_path.exists():
455
+ try:
456
+ self._unix_socket_path.unlink()
457
+ except OSError:
458
+ pass
459
+
460
+ self._is_running = False
461
+ logging.info("AsyncDaemonServer cleanup complete")
462
+
463
+ async def _handle_client_connection(
464
+ self,
465
+ reader: asyncio.StreamReader,
466
+ writer: asyncio.StreamWriter,
467
+ ) -> None:
468
+ """Handle a new client connection.
469
+
470
+ This coroutine is called for each new client connection. It manages
471
+ the client lifecycle: registration, message processing, and cleanup.
472
+
473
+ Args:
474
+ reader: Asyncio stream reader for receiving messages
475
+ writer: Asyncio stream writer for sending messages
476
+ """
477
+ addr = writer.get_extra_info("peername")
478
+ client_id = str(uuid.uuid4())
479
+
480
+ logging.info(f"New connection from {addr}, assigned client_id: {client_id}")
481
+
482
+ # Create client connection object
483
+ client = ClientConnection(
484
+ client_id=client_id,
485
+ reader=reader,
486
+ writer=writer,
487
+ address=addr if addr else ("unknown", 0),
488
+ )
489
+
490
+ # Register client
491
+ async with self._clients_lock:
492
+ self._clients[client_id] = client
493
+
494
+ try:
495
+ # Process messages until disconnection
496
+ await self._process_client_messages(client)
497
+
498
+ except asyncio.CancelledError:
499
+ logging.debug(f"Client {client_id} connection cancelled")
500
+
501
+ except KeyboardInterrupt: # noqa: KBI002
502
+ raise
503
+
504
+ except Exception as e:
505
+ logging.error(f"Error handling client {client_id}: {e}", exc_info=True)
506
+
507
+ finally:
508
+ # Clean up client
509
+ await self._disconnect_client(client_id, "Connection closed")
510
+
511
+ async def _process_client_messages(self, client: ClientConnection) -> None:
512
+ """Process messages from a client until disconnection.
513
+
514
+ Args:
515
+ client: The client connection to process messages for
516
+ """
517
+ buffer = b""
518
+
519
+ while client.is_connected:
520
+ try:
521
+ # Read data with timeout
522
+ data = await asyncio.wait_for(
523
+ client.reader.read(DEFAULT_READ_BUFFER_SIZE),
524
+ timeout=self._heartbeat_timeout * 2,
525
+ )
526
+
527
+ if not data:
528
+ # Connection closed by client
529
+ logging.debug(f"Client {client.client_id} closed connection")
530
+ break
531
+
532
+ buffer += data
533
+
534
+ # Process complete messages (delimited by newline)
535
+ while MESSAGE_DELIMITER in buffer:
536
+ message_bytes, buffer = buffer.split(MESSAGE_DELIMITER, 1)
537
+
538
+ if message_bytes:
539
+ await self._process_message(client, message_bytes)
540
+
541
+ except asyncio.TimeoutError:
542
+ # Check if client is still alive
543
+ if not client.is_alive(self._heartbeat_timeout):
544
+ logging.warning(f"Client {client.client_id} heartbeat timeout")
545
+ break
546
+
547
+ except asyncio.CancelledError:
548
+ raise
549
+
550
+ except KeyboardInterrupt: # noqa: KBI002
551
+ raise
552
+
553
+ except Exception as e:
554
+ logging.error(f"Error reading from client {client.client_id}: {e}")
555
+ break
556
+
557
+ async def _process_message(
558
+ self,
559
+ client: ClientConnection,
560
+ message_bytes: bytes,
561
+ ) -> None:
562
+ """Process a single message from a client.
563
+
564
+ Args:
565
+ client: The client that sent the message
566
+ message_bytes: Raw message bytes (JSON-encoded)
567
+ """
568
+ try:
569
+ # Parse JSON message
570
+ message = json.loads(message_bytes.decode("utf-8"))
571
+
572
+ # Extract message type
573
+ msg_type_str = message.get("type")
574
+ if not msg_type_str:
575
+ await self._send_error(client, "Missing message type")
576
+ return
577
+
578
+ try:
579
+ msg_type = MessageType(msg_type_str)
580
+ except ValueError:
581
+ await self._send_error(client, f"Unknown message type: {msg_type_str}")
582
+ return
583
+
584
+ # Get handler for message type
585
+ handler = self._handlers.get(msg_type)
586
+ if not handler:
587
+ await self._send_error(client, f"No handler for message type: {msg_type_str}")
588
+ return
589
+
590
+ # Call handler and send response
591
+ logging.debug(f"Processing {msg_type.value} from client {client.client_id}")
592
+ response = await handler(client, message.get("data", {}))
593
+ await self._send_response(client, response)
594
+
595
+ except json.JSONDecodeError as e:
596
+ logging.error(f"Invalid JSON from client {client.client_id}: {e}")
597
+ await self._send_error(client, f"Invalid JSON: {e}")
598
+
599
+ except KeyboardInterrupt: # noqa: KBI002
600
+ raise
601
+
602
+ except Exception as e:
603
+ logging.error(f"Error processing message from {client.client_id}: {e}", exc_info=True)
604
+ await self._send_error(client, f"Error processing message: {e}")
605
+
606
+ async def _send_message(
607
+ self,
608
+ client: ClientConnection,
609
+ msg_type: MessageType,
610
+ data: dict[str, Any],
611
+ ) -> bool:
612
+ """Send a message to a client.
613
+
614
+ Args:
615
+ client: The client to send to
616
+ msg_type: Type of message
617
+ data: Message data
618
+
619
+ Returns:
620
+ True if message was sent successfully, False otherwise
621
+ """
622
+ if not client.is_connected:
623
+ return False
624
+
625
+ message = {
626
+ "type": msg_type.value,
627
+ "data": data,
628
+ "timestamp": time.time(),
629
+ }
630
+
631
+ try:
632
+ message_bytes = json.dumps(message).encode("utf-8") + MESSAGE_DELIMITER
633
+
634
+ async with client.lock:
635
+ client.writer.write(message_bytes)
636
+ await asyncio.wait_for(
637
+ client.writer.drain(),
638
+ timeout=DEFAULT_WRITE_TIMEOUT,
639
+ )
640
+
641
+ return True
642
+
643
+ except asyncio.TimeoutError:
644
+ logging.warning(f"Timeout sending to client {client.client_id}")
645
+ return False
646
+
647
+ except KeyboardInterrupt: # noqa: KBI002
648
+ raise
649
+
650
+ except Exception as e:
651
+ logging.error(f"Error sending to client {client.client_id}: {e}")
652
+ return False
653
+
654
+ async def _send_response(
655
+ self,
656
+ client: ClientConnection,
657
+ data: dict[str, Any],
658
+ ) -> bool:
659
+ """Send a response message to a client.
660
+
661
+ Args:
662
+ client: The client to send to
663
+ data: Response data
664
+
665
+ Returns:
666
+ True if response was sent successfully, False otherwise
667
+ """
668
+ return await self._send_message(client, MessageType.RESPONSE, data)
669
+
670
+ async def _send_error(
671
+ self,
672
+ client: ClientConnection,
673
+ error_message: str,
674
+ ) -> bool:
675
+ """Send an error message to a client.
676
+
677
+ Args:
678
+ client: The client to send to
679
+ error_message: Error description
680
+
681
+ Returns:
682
+ True if error was sent successfully, False otherwise
683
+ """
684
+ return await self._send_message(
685
+ client,
686
+ MessageType.ERROR,
687
+ {"success": False, "error": error_message, "timestamp": time.time()},
688
+ )
689
+
690
+ async def broadcast(
691
+ self,
692
+ event_type: SubscriptionType,
693
+ data: dict[str, Any],
694
+ exclude_client_id: str | None = None,
695
+ ) -> int:
696
+ """Broadcast a message to all subscribed clients.
697
+
698
+ Args:
699
+ event_type: Type of event being broadcast
700
+ data: Event data
701
+ exclude_client_id: Optional client ID to exclude from broadcast
702
+
703
+ Returns:
704
+ Number of clients the message was sent to
705
+ """
706
+ sent_count = 0
707
+ broadcast_data = {
708
+ "event_type": event_type.value,
709
+ "data": data,
710
+ "timestamp": time.time(),
711
+ }
712
+
713
+ async with self._clients_lock:
714
+ for client in self._clients.values():
715
+ if client.client_id == exclude_client_id:
716
+ continue
717
+
718
+ # Check if client is subscribed to this event type
719
+ if SubscriptionType.ALL in client.subscriptions or event_type in client.subscriptions:
720
+ if await self._send_message(client, MessageType.BROADCAST, broadcast_data):
721
+ sent_count += 1
722
+
723
+ logging.debug(f"Broadcast {event_type.value} to {sent_count} clients")
724
+ return sent_count
725
+
726
+ async def send_to_client(
727
+ self,
728
+ client_id: str,
729
+ data: dict[str, Any],
730
+ ) -> bool:
731
+ """Send a message to a specific client.
732
+
733
+ Args:
734
+ client_id: Target client ID
735
+ data: Message data
736
+
737
+ Returns:
738
+ True if message was sent, False if client not found or send failed
739
+ """
740
+ async with self._clients_lock:
741
+ client = self._clients.get(client_id)
742
+
743
+ if not client:
744
+ logging.warning(f"Client {client_id} not found for direct message")
745
+ return False
746
+
747
+ return await self._send_message(client, MessageType.RESPONSE, data)
748
+
749
+ async def _close_client(
750
+ self,
751
+ client: ClientConnection,
752
+ reason: str,
753
+ ) -> None:
754
+ """Close a client connection.
755
+
756
+ Args:
757
+ client: The client to close
758
+ reason: Reason for closing the connection
759
+ """
760
+ if not client.is_connected:
761
+ return
762
+
763
+ client.is_connected = False
764
+ logging.info(f"Closing client {client.client_id}: {reason}")
765
+
766
+ try:
767
+ client.writer.close()
768
+ await asyncio.wait_for(client.writer.wait_closed(), timeout=2.0)
769
+ except KeyboardInterrupt: # noqa: KBI002
770
+ raise
771
+ except (asyncio.TimeoutError, Exception) as e:
772
+ logging.debug(f"Error closing client {client.client_id}: {e}")
773
+
774
+ async def _disconnect_client(
775
+ self,
776
+ client_id: str,
777
+ reason: str,
778
+ ) -> None:
779
+ """Disconnect a client and clean up resources.
780
+
781
+ This method removes the client from tracking, closes the connection,
782
+ and triggers cleanup callbacks in the DaemonContext.
783
+
784
+ Args:
785
+ client_id: Client ID to disconnect
786
+ reason: Reason for disconnection
787
+ """
788
+ async with self._clients_lock:
789
+ client = self._clients.pop(client_id, None)
790
+
791
+ if not client:
792
+ return
793
+
794
+ # Close the connection
795
+ await self._close_client(client, reason)
796
+
797
+ # Trigger cleanup (thread-safe - individual managers handle their own locking)
798
+ try:
799
+ # Release configuration locks held by this client
800
+ if self._configuration_lock_manager is not None:
801
+ released = self._configuration_lock_manager.release_all_client_locks(client_id)
802
+ if released > 0:
803
+ logging.info(f"Released {released} configuration locks for client {client_id}")
804
+
805
+ # Release device leases held by this client
806
+ if self._device_manager is not None:
807
+ released = self._device_manager.release_all_client_leases(client_id)
808
+ if released > 0:
809
+ logging.info(f"Released {released} device leases for client {client_id}")
810
+
811
+ # Disconnect from shared serial sessions
812
+ if self._shared_serial_manager is not None:
813
+ self._shared_serial_manager.disconnect_client(client_id)
814
+
815
+ # Unregister from client manager
816
+ if self._client_manager is not None:
817
+ self._client_manager.unregister_client(client_id)
818
+
819
+ except KeyboardInterrupt: # noqa: KBI002
820
+ raise
821
+ except Exception as e:
822
+ logging.error(f"Error during client cleanup for {client_id}: {e}")
823
+
824
+ # Broadcast disconnection event
825
+ await self.broadcast(
826
+ SubscriptionType.STATUS,
827
+ {
828
+ "event": "client_disconnected",
829
+ "client_id": client_id,
830
+ "reason": reason,
831
+ },
832
+ exclude_client_id=client_id,
833
+ )
834
+
835
+ async def _heartbeat_monitor(self) -> None:
836
+ """Background task to monitor client heartbeats and clean up dead clients."""
837
+ logging.debug("Heartbeat monitor started")
838
+
839
+ while self._is_running:
840
+ try:
841
+ await asyncio.sleep(self._heartbeat_timeout / 2)
842
+
843
+ dead_clients: list[str] = []
844
+
845
+ async with self._clients_lock:
846
+ for client_id, client in self._clients.items():
847
+ if not client.is_alive(self._heartbeat_timeout):
848
+ dead_clients.append(client_id)
849
+
850
+ for client_id in dead_clients:
851
+ logging.warning(f"Client {client_id} heartbeat timeout, disconnecting")
852
+ await self._disconnect_client(client_id, "Heartbeat timeout")
853
+
854
+ except asyncio.CancelledError:
855
+ break
856
+
857
+ except KeyboardInterrupt: # noqa: KBI002
858
+ raise
859
+
860
+ except Exception as e:
861
+ logging.error(f"Error in heartbeat monitor: {e}")
862
+
863
+ logging.debug("Heartbeat monitor stopped")
864
+
865
+ # =========================================================================
866
+ # Message Handlers - Client Lifecycle
867
+ # =========================================================================
868
+
869
+ async def _handle_connect(
870
+ self,
871
+ client: ClientConnection,
872
+ data: dict[str, Any],
873
+ ) -> dict[str, Any]:
874
+ """Handle client connect message.
875
+
876
+ Args:
877
+ client: The client connection
878
+ data: Connect request data (pid, hostname, version, etc.)
879
+
880
+ Returns:
881
+ Response data with connection confirmation
882
+ """
883
+ # Update client metadata
884
+ client.metadata = {
885
+ "pid": data.get("pid"),
886
+ "hostname": data.get("hostname", ""),
887
+ "version": data.get("version", ""),
888
+ }
889
+ client.update_heartbeat()
890
+
891
+ # Register with DaemonContext client manager
892
+ if self._client_manager is not None:
893
+ try:
894
+ self._client_manager.register_client(
895
+ client_id=client.client_id,
896
+ pid=data.get("pid", 0),
897
+ metadata=client.metadata,
898
+ )
899
+ except KeyboardInterrupt: # noqa: KBI002
900
+ raise
901
+ except Exception as e:
902
+ logging.error(f"Error registering client {client.client_id}: {e}")
903
+
904
+ logging.info(f"Client {client.client_id} connected (pid={data.get('pid')})")
905
+
906
+ # Broadcast connection event
907
+ await self.broadcast(
908
+ SubscriptionType.STATUS,
909
+ {
910
+ "event": "client_connected",
911
+ "client_id": client.client_id,
912
+ "metadata": client.metadata,
913
+ },
914
+ exclude_client_id=client.client_id,
915
+ )
916
+
917
+ return {
918
+ "success": True,
919
+ "client_id": client.client_id,
920
+ "message": "Connected successfully",
921
+ "total_clients": len(self._clients),
922
+ }
923
+
924
+ async def _handle_heartbeat(
925
+ self,
926
+ client: ClientConnection,
927
+ data: dict[str, Any], # noqa: ARG002
928
+ ) -> dict[str, Any]:
929
+ """Handle client heartbeat message.
930
+
931
+ Args:
932
+ client: The client connection
933
+ data: Heartbeat data (unused but required for handler signature)
934
+
935
+ Returns:
936
+ Response acknowledging the heartbeat
937
+ """
938
+ client.update_heartbeat()
939
+
940
+ # Update in DaemonContext client manager
941
+ if self._client_manager is not None:
942
+ self._client_manager.heartbeat(client.client_id)
943
+
944
+ logging.debug(f"Heartbeat from client {client.client_id}")
945
+
946
+ return {
947
+ "success": True,
948
+ "message": "Heartbeat acknowledged",
949
+ "timestamp": time.time(),
950
+ }
951
+
952
+ async def _handle_disconnect(
953
+ self,
954
+ client: ClientConnection,
955
+ data: dict[str, Any],
956
+ ) -> dict[str, Any]:
957
+ """Handle graceful client disconnect message.
958
+
959
+ Args:
960
+ client: The client connection
961
+ data: Disconnect data (optional reason)
962
+
963
+ Returns:
964
+ Response confirming disconnection
965
+ """
966
+ reason = data.get("reason", "Client requested disconnect")
967
+ logging.info(f"Client {client.client_id} disconnecting: {reason}")
968
+
969
+ # Schedule disconnection after response is sent
970
+ asyncio.create_task(self._disconnect_client(client.client_id, reason))
971
+
972
+ return {
973
+ "success": True,
974
+ "message": "Disconnect acknowledged",
975
+ }
976
+
977
+ # =========================================================================
978
+ # Message Handlers - Lock Operations
979
+ # =========================================================================
980
+
981
+ async def _handle_lock_acquire(
982
+ self,
983
+ client: ClientConnection,
984
+ data: dict[str, Any],
985
+ ) -> dict[str, Any]:
986
+ """Handle lock acquire request.
987
+
988
+ Args:
989
+ client: The client connection
990
+ data: Lock request data (project_dir, environment, port, lock_type, etc.)
991
+
992
+ Returns:
993
+ Response with lock acquisition result
994
+ """
995
+ from fbuild.daemon.messages import LockType
996
+
997
+ project_dir = data.get("project_dir", "")
998
+ environment = data.get("environment", "")
999
+ port = data.get("port", "")
1000
+ lock_type_str = data.get("lock_type", "exclusive")
1001
+ description = data.get("description", "")
1002
+ timeout = data.get("timeout", 300.0)
1003
+
1004
+ config_key = (project_dir, environment, port)
1005
+
1006
+ try:
1007
+ lock_type = LockType(lock_type_str)
1008
+ except ValueError:
1009
+ return {
1010
+ "success": False,
1011
+ "message": f"Invalid lock type: {lock_type_str}",
1012
+ }
1013
+
1014
+ # Check that configuration lock manager is available
1015
+ if self._configuration_lock_manager is None:
1016
+ return {
1017
+ "success": False,
1018
+ "message": "Lock manager not available",
1019
+ }
1020
+
1021
+ # Acquire lock (thread-safe through ConfigurationLockManager)
1022
+ try:
1023
+ if lock_type == LockType.EXCLUSIVE:
1024
+ acquired = self._configuration_lock_manager.acquire_exclusive(
1025
+ config_key,
1026
+ client.client_id,
1027
+ description,
1028
+ timeout,
1029
+ )
1030
+ else: # SHARED_READ
1031
+ acquired = self._configuration_lock_manager.acquire_shared_read(
1032
+ config_key,
1033
+ client.client_id,
1034
+ description,
1035
+ )
1036
+
1037
+ if acquired:
1038
+ logging.info(f"Client {client.client_id} acquired {lock_type.value} lock for {config_key}")
1039
+
1040
+ # Broadcast lock change
1041
+ await self.broadcast(
1042
+ SubscriptionType.LOCKS,
1043
+ {
1044
+ "event": "lock_acquired",
1045
+ "client_id": client.client_id,
1046
+ "config_key": {"project_dir": project_dir, "environment": environment, "port": port},
1047
+ "lock_type": lock_type.value,
1048
+ },
1049
+ )
1050
+
1051
+ return {
1052
+ "success": True,
1053
+ "message": f"{lock_type.value} lock acquired",
1054
+ "lock_state": f"locked_{lock_type.value}",
1055
+ }
1056
+ else:
1057
+ lock_status = self._configuration_lock_manager.get_lock_status(config_key)
1058
+ return {
1059
+ "success": False,
1060
+ "message": "Lock not available",
1061
+ "lock_state": lock_status.get("state", "unknown"),
1062
+ "holder_count": lock_status.get("holder_count", 0),
1063
+ "waiting_count": lock_status.get("waiting_count", 0),
1064
+ }
1065
+
1066
+ except KeyboardInterrupt: # noqa: KBI002
1067
+ raise
1068
+ except Exception as e:
1069
+ logging.error(f"Error acquiring lock for {client.client_id}: {e}")
1070
+ return {
1071
+ "success": False,
1072
+ "message": f"Lock acquisition error: {e}",
1073
+ }
1074
+
1075
+ async def _handle_lock_release(
1076
+ self,
1077
+ client: ClientConnection,
1078
+ data: dict[str, Any],
1079
+ ) -> dict[str, Any]:
1080
+ """Handle lock release request.
1081
+
1082
+ Args:
1083
+ client: The client connection
1084
+ data: Lock release data (project_dir, environment, port)
1085
+
1086
+ Returns:
1087
+ Response with lock release result
1088
+ """
1089
+ project_dir = data.get("project_dir", "")
1090
+ environment = data.get("environment", "")
1091
+ port = data.get("port", "")
1092
+
1093
+ config_key = (project_dir, environment, port)
1094
+
1095
+ # Check that configuration lock manager is available
1096
+ if self._configuration_lock_manager is None:
1097
+ return {
1098
+ "success": False,
1099
+ "message": "Lock manager not available",
1100
+ }
1101
+
1102
+ try:
1103
+ released = self._configuration_lock_manager.release(
1104
+ config_key,
1105
+ client.client_id,
1106
+ )
1107
+
1108
+ if released:
1109
+ logging.info(f"Client {client.client_id} released lock for {config_key}")
1110
+
1111
+ # Broadcast lock change
1112
+ await self.broadcast(
1113
+ SubscriptionType.LOCKS,
1114
+ {
1115
+ "event": "lock_released",
1116
+ "client_id": client.client_id,
1117
+ "config_key": {"project_dir": project_dir, "environment": environment, "port": port},
1118
+ },
1119
+ )
1120
+
1121
+ return {
1122
+ "success": True,
1123
+ "message": "Lock released",
1124
+ "lock_state": "unlocked",
1125
+ }
1126
+ else:
1127
+ return {
1128
+ "success": False,
1129
+ "message": "Client does not hold this lock",
1130
+ }
1131
+
1132
+ except KeyboardInterrupt: # noqa: KBI002
1133
+ raise
1134
+ except Exception as e:
1135
+ logging.error(f"Error releasing lock for {client.client_id}: {e}")
1136
+ return {
1137
+ "success": False,
1138
+ "message": f"Lock release error: {e}",
1139
+ }
1140
+
1141
+ async def _handle_lock_status(
1142
+ self,
1143
+ client: ClientConnection, # noqa: ARG002
1144
+ data: dict[str, Any],
1145
+ ) -> dict[str, Any]:
1146
+ """Handle lock status query.
1147
+
1148
+ Args:
1149
+ client: The client connection (unused but required for handler signature)
1150
+ data: Lock query data (project_dir, environment, port)
1151
+
1152
+ Returns:
1153
+ Response with current lock status
1154
+ """
1155
+ project_dir = data.get("project_dir", "")
1156
+ environment = data.get("environment", "")
1157
+ port = data.get("port", "")
1158
+
1159
+ config_key = (project_dir, environment, port)
1160
+
1161
+ # Check that configuration lock manager is available
1162
+ if self._configuration_lock_manager is None:
1163
+ return {
1164
+ "success": False,
1165
+ "message": "Lock manager not available",
1166
+ }
1167
+
1168
+ try:
1169
+ lock_status = self._configuration_lock_manager.get_lock_status(config_key)
1170
+ return {
1171
+ "success": True,
1172
+ **lock_status,
1173
+ }
1174
+
1175
+ except KeyboardInterrupt: # noqa: KBI002
1176
+ raise
1177
+ except Exception as e:
1178
+ logging.error(f"Error getting lock status: {e}")
1179
+ return {
1180
+ "success": False,
1181
+ "message": f"Lock status error: {e}",
1182
+ }
1183
+
1184
+ # =========================================================================
1185
+ # Message Handlers - Firmware Operations
1186
+ # =========================================================================
1187
+
1188
+ async def _handle_firmware_query(
1189
+ self,
1190
+ client: ClientConnection, # noqa: ARG002
1191
+ data: dict[str, Any],
1192
+ ) -> dict[str, Any]:
1193
+ """Handle firmware query request.
1194
+
1195
+ Args:
1196
+ client: The client connection (unused but required for handler signature)
1197
+ data: Query data (port, source_hash, build_flags_hash)
1198
+
1199
+ Returns:
1200
+ Response with firmware status
1201
+ """
1202
+ port = data.get("port", "")
1203
+ source_hash = data.get("source_hash", "")
1204
+ build_flags_hash = data.get("build_flags_hash")
1205
+
1206
+ # Check that firmware ledger is available
1207
+ if self._firmware_ledger is None:
1208
+ return {
1209
+ "success": False,
1210
+ "is_current": False,
1211
+ "needs_redeploy": True,
1212
+ "message": "Firmware ledger not available",
1213
+ }
1214
+
1215
+ try:
1216
+ entry = self._firmware_ledger.get_deployment(port)
1217
+
1218
+ if entry is None:
1219
+ return {
1220
+ "success": True,
1221
+ "is_current": False,
1222
+ "needs_redeploy": True,
1223
+ "message": "No firmware deployment recorded for this port",
1224
+ }
1225
+
1226
+ is_current = entry.source_hash == source_hash
1227
+ if build_flags_hash and entry.build_flags_hash != build_flags_hash:
1228
+ is_current = False
1229
+
1230
+ return {
1231
+ "success": True,
1232
+ "is_current": is_current,
1233
+ "needs_redeploy": not is_current,
1234
+ "firmware_hash": entry.firmware_hash,
1235
+ "project_dir": entry.project_dir,
1236
+ "environment": entry.environment,
1237
+ "upload_timestamp": entry.upload_timestamp,
1238
+ "message": "Firmware current" if is_current else "Firmware needs update",
1239
+ }
1240
+
1241
+ except KeyboardInterrupt: # noqa: KBI002
1242
+ raise
1243
+ except Exception as e:
1244
+ logging.error(f"Error querying firmware: {e}")
1245
+ return {
1246
+ "success": False,
1247
+ "is_current": False,
1248
+ "needs_redeploy": True,
1249
+ "message": f"Firmware query error: {e}",
1250
+ }
1251
+
1252
+ async def _handle_firmware_record(
1253
+ self,
1254
+ client: ClientConnection,
1255
+ data: dict[str, Any],
1256
+ ) -> dict[str, Any]:
1257
+ """Handle firmware record request.
1258
+
1259
+ Args:
1260
+ client: The client connection
1261
+ data: Record data (port, firmware_hash, source_hash, project_dir, environment)
1262
+
1263
+ Returns:
1264
+ Response confirming record creation
1265
+ """
1266
+ port = data.get("port", "")
1267
+ firmware_hash = data.get("firmware_hash", "")
1268
+ source_hash = data.get("source_hash", "")
1269
+ project_dir = data.get("project_dir", "")
1270
+ environment = data.get("environment", "")
1271
+ build_flags_hash = data.get("build_flags_hash")
1272
+
1273
+ # Check that firmware ledger is available
1274
+ if self._firmware_ledger is None:
1275
+ return {
1276
+ "success": False,
1277
+ "message": "Firmware ledger not available",
1278
+ }
1279
+
1280
+ try:
1281
+ self._firmware_ledger.record_deployment(
1282
+ port=port,
1283
+ firmware_hash=firmware_hash,
1284
+ source_hash=source_hash,
1285
+ project_dir=project_dir,
1286
+ environment=environment,
1287
+ build_flags_hash=build_flags_hash,
1288
+ )
1289
+
1290
+ logging.info(f"Recorded firmware deployment to {port} by client {client.client_id}")
1291
+
1292
+ # Broadcast firmware event
1293
+ await self.broadcast(
1294
+ SubscriptionType.FIRMWARE,
1295
+ {
1296
+ "event": "firmware_deployed",
1297
+ "port": port,
1298
+ "project_dir": project_dir,
1299
+ "environment": environment,
1300
+ "client_id": client.client_id,
1301
+ },
1302
+ )
1303
+
1304
+ return {
1305
+ "success": True,
1306
+ "message": "Firmware deployment recorded",
1307
+ }
1308
+
1309
+ except KeyboardInterrupt: # noqa: KBI002
1310
+ raise
1311
+ except Exception as e:
1312
+ logging.error(f"Error recording firmware: {e}")
1313
+ return {
1314
+ "success": False,
1315
+ "message": f"Firmware record error: {e}",
1316
+ }
1317
+
1318
+ # =========================================================================
1319
+ # Message Handlers - Serial Operations
1320
+ # =========================================================================
1321
+
1322
+ async def _handle_serial_attach(
1323
+ self,
1324
+ client: ClientConnection,
1325
+ data: dict[str, Any],
1326
+ ) -> dict[str, Any]:
1327
+ """Handle serial attach request.
1328
+
1329
+ Args:
1330
+ client: The client connection
1331
+ data: Attach data (port, baud_rate, as_reader)
1332
+
1333
+ Returns:
1334
+ Response with attach result
1335
+ """
1336
+ port = data.get("port", "")
1337
+ baud_rate = data.get("baud_rate", 115200)
1338
+ as_reader = data.get("as_reader", True)
1339
+
1340
+ # Check that shared serial manager is available
1341
+ if self._shared_serial_manager is None:
1342
+ return {
1343
+ "success": False,
1344
+ "message": "Serial manager not available",
1345
+ }
1346
+
1347
+ try:
1348
+ # Open port if not already open
1349
+ opened = self._shared_serial_manager.open_port(
1350
+ port,
1351
+ baud_rate,
1352
+ client.client_id,
1353
+ )
1354
+
1355
+ if as_reader:
1356
+ attached = self._shared_serial_manager.attach_reader(
1357
+ port,
1358
+ client.client_id,
1359
+ )
1360
+ else:
1361
+ attached = opened
1362
+
1363
+ if attached:
1364
+ session_info = self._shared_serial_manager.get_session_info(port)
1365
+
1366
+ # Broadcast serial event
1367
+ await self.broadcast(
1368
+ SubscriptionType.SERIAL,
1369
+ {
1370
+ "event": "client_attached",
1371
+ "port": port,
1372
+ "client_id": client.client_id,
1373
+ "as_reader": as_reader,
1374
+ },
1375
+ )
1376
+
1377
+ return {
1378
+ "success": True,
1379
+ "message": "Attached to serial port",
1380
+ "is_open": True,
1381
+ "reader_count": session_info.get("reader_count", 0) if session_info else 0,
1382
+ "has_writer": session_info.get("writer_client_id") is not None if session_info else False,
1383
+ }
1384
+ else:
1385
+ return {
1386
+ "success": False,
1387
+ "message": "Failed to attach to serial port",
1388
+ }
1389
+
1390
+ except KeyboardInterrupt: # noqa: KBI002
1391
+ raise
1392
+ except Exception as e:
1393
+ logging.error(f"Error attaching to serial: {e}")
1394
+ return {
1395
+ "success": False,
1396
+ "message": f"Serial attach error: {e}",
1397
+ }
1398
+
1399
+ async def _handle_serial_detach(
1400
+ self,
1401
+ client: ClientConnection,
1402
+ data: dict[str, Any],
1403
+ ) -> dict[str, Any]:
1404
+ """Handle serial detach request.
1405
+
1406
+ Args:
1407
+ client: The client connection
1408
+ data: Detach data (port, close_port)
1409
+
1410
+ Returns:
1411
+ Response with detach result
1412
+ """
1413
+ port = data.get("port", "")
1414
+ close_port = data.get("close_port", False)
1415
+
1416
+ # Check that shared serial manager is available
1417
+ if self._shared_serial_manager is None:
1418
+ return {
1419
+ "success": False,
1420
+ "message": "Serial manager not available",
1421
+ }
1422
+
1423
+ try:
1424
+ detached = self._shared_serial_manager.detach_reader(
1425
+ port,
1426
+ client.client_id,
1427
+ )
1428
+
1429
+ if close_port:
1430
+ self._shared_serial_manager.close_port(port, client.client_id)
1431
+
1432
+ if detached:
1433
+ # Broadcast serial event
1434
+ await self.broadcast(
1435
+ SubscriptionType.SERIAL,
1436
+ {
1437
+ "event": "client_detached",
1438
+ "port": port,
1439
+ "client_id": client.client_id,
1440
+ },
1441
+ )
1442
+
1443
+ return {
1444
+ "success": True,
1445
+ "message": "Detached from serial port",
1446
+ }
1447
+ else:
1448
+ return {
1449
+ "success": False,
1450
+ "message": "Client not attached to this port",
1451
+ }
1452
+
1453
+ except KeyboardInterrupt: # noqa: KBI002
1454
+ raise
1455
+ except Exception as e:
1456
+ logging.error(f"Error detaching from serial: {e}")
1457
+ return {
1458
+ "success": False,
1459
+ "message": f"Serial detach error: {e}",
1460
+ }
1461
+
1462
+ async def _handle_serial_write(
1463
+ self,
1464
+ client: ClientConnection,
1465
+ data: dict[str, Any],
1466
+ ) -> dict[str, Any]:
1467
+ """Handle serial write request.
1468
+
1469
+ Args:
1470
+ client: The client connection
1471
+ data: Write data (port, data as base64, acquire_writer)
1472
+
1473
+ Returns:
1474
+ Response with write result
1475
+ """
1476
+ port = data.get("port", "")
1477
+ data_b64 = data.get("data", "")
1478
+ acquire_writer = data.get("acquire_writer", True)
1479
+
1480
+ # Check that shared serial manager is available
1481
+ if self._shared_serial_manager is None:
1482
+ return {
1483
+ "success": False,
1484
+ "message": "Serial manager not available",
1485
+ }
1486
+
1487
+ try:
1488
+ # Decode base64 data
1489
+ write_data = base64.b64decode(data_b64)
1490
+
1491
+ # Acquire writer if needed
1492
+ if acquire_writer:
1493
+ acquired = self._shared_serial_manager.acquire_writer(
1494
+ port,
1495
+ client.client_id,
1496
+ timeout=5.0,
1497
+ )
1498
+ if not acquired:
1499
+ return {
1500
+ "success": False,
1501
+ "message": "Could not acquire writer access",
1502
+ }
1503
+
1504
+ # Write data
1505
+ bytes_written = self._shared_serial_manager.write(
1506
+ port,
1507
+ client.client_id,
1508
+ write_data,
1509
+ )
1510
+
1511
+ # Release writer if we acquired it
1512
+ if acquire_writer:
1513
+ self._shared_serial_manager.release_writer(port, client.client_id)
1514
+
1515
+ if bytes_written >= 0:
1516
+ return {
1517
+ "success": True,
1518
+ "message": f"Wrote {bytes_written} bytes",
1519
+ "bytes_written": bytes_written,
1520
+ }
1521
+ else:
1522
+ return {
1523
+ "success": False,
1524
+ "message": "Write failed",
1525
+ }
1526
+
1527
+ except KeyboardInterrupt: # noqa: KBI002
1528
+ raise
1529
+ except Exception as e:
1530
+ logging.error(f"Error writing to serial: {e}")
1531
+ return {
1532
+ "success": False,
1533
+ "message": f"Serial write error: {e}",
1534
+ }
1535
+
1536
+ async def _handle_serial_read(
1537
+ self,
1538
+ client: ClientConnection,
1539
+ data: dict[str, Any],
1540
+ ) -> dict[str, Any]:
1541
+ """Handle serial read (buffer) request.
1542
+
1543
+ Args:
1544
+ client: The client connection
1545
+ data: Read data (port, max_lines)
1546
+
1547
+ Returns:
1548
+ Response with buffered lines
1549
+ """
1550
+ port = data.get("port", "")
1551
+ max_lines = data.get("max_lines", 100)
1552
+
1553
+ # Check that shared serial manager is available
1554
+ if self._shared_serial_manager is None:
1555
+ return {
1556
+ "success": False,
1557
+ "message": "Serial manager not available",
1558
+ "lines": [],
1559
+ }
1560
+
1561
+ try:
1562
+ lines = self._shared_serial_manager.read_buffer(
1563
+ port,
1564
+ client.client_id,
1565
+ max_lines,
1566
+ )
1567
+
1568
+ session_info = self._shared_serial_manager.get_session_info(port)
1569
+
1570
+ return {
1571
+ "success": True,
1572
+ "message": f"Read {len(lines)} lines",
1573
+ "lines": lines,
1574
+ "buffer_size": session_info.get("buffer_size", 0) if session_info else 0,
1575
+ }
1576
+
1577
+ except KeyboardInterrupt: # noqa: KBI002
1578
+ raise
1579
+ except Exception as e:
1580
+ logging.error(f"Error reading serial buffer: {e}")
1581
+ return {
1582
+ "success": False,
1583
+ "message": f"Serial read error: {e}",
1584
+ "lines": [],
1585
+ }
1586
+
1587
+ # =========================================================================
1588
+ # Message Handlers - Subscription
1589
+ # =========================================================================
1590
+
1591
+ async def _handle_subscribe(
1592
+ self,
1593
+ client: ClientConnection,
1594
+ data: dict[str, Any],
1595
+ ) -> dict[str, Any]:
1596
+ """Handle subscription request.
1597
+
1598
+ Args:
1599
+ client: The client connection
1600
+ data: Subscribe data (event_types list)
1601
+
1602
+ Returns:
1603
+ Response confirming subscription
1604
+ """
1605
+ event_types = data.get("event_types", [])
1606
+
1607
+ for event_type_str in event_types:
1608
+ try:
1609
+ event_type = SubscriptionType(event_type_str)
1610
+ client.subscriptions.add(event_type)
1611
+ except ValueError:
1612
+ logging.warning(f"Unknown subscription type: {event_type_str}")
1613
+
1614
+ logging.debug(f"Client {client.client_id} subscribed to {[s.value for s in client.subscriptions]}")
1615
+
1616
+ return {
1617
+ "success": True,
1618
+ "message": "Subscribed",
1619
+ "subscriptions": [s.value for s in client.subscriptions],
1620
+ }
1621
+
1622
+ async def _handle_unsubscribe(
1623
+ self,
1624
+ client: ClientConnection,
1625
+ data: dict[str, Any],
1626
+ ) -> dict[str, Any]:
1627
+ """Handle unsubscription request.
1628
+
1629
+ Args:
1630
+ client: The client connection
1631
+ data: Unsubscribe data (event_types list)
1632
+
1633
+ Returns:
1634
+ Response confirming unsubscription
1635
+ """
1636
+ event_types = data.get("event_types", [])
1637
+
1638
+ for event_type_str in event_types:
1639
+ try:
1640
+ event_type = SubscriptionType(event_type_str)
1641
+ client.subscriptions.discard(event_type)
1642
+ except ValueError:
1643
+ pass
1644
+
1645
+ logging.debug(f"Client {client.client_id} now subscribed to {[s.value for s in client.subscriptions]}")
1646
+
1647
+ return {
1648
+ "success": True,
1649
+ "message": "Unsubscribed",
1650
+ "subscriptions": [s.value for s in client.subscriptions],
1651
+ }
1652
+
1653
+ # =========================================================================
1654
+ # Message Handlers - Device Operations
1655
+ # =========================================================================
1656
+
1657
+ async def _handle_device_list(
1658
+ self,
1659
+ client: ClientConnection, # noqa: ARG002
1660
+ data: dict[str, Any],
1661
+ ) -> dict[str, Any]:
1662
+ """Handle device list request.
1663
+
1664
+ Args:
1665
+ client: The client connection (unused but required for handler signature)
1666
+ data: List request data (include_disconnected, refresh)
1667
+
1668
+ Returns:
1669
+ Response with device list
1670
+ """
1671
+ include_disconnected = data.get("include_disconnected", False)
1672
+ refresh = data.get("refresh", False)
1673
+
1674
+ # Check that device manager is available
1675
+ if self._device_manager is None:
1676
+ return {
1677
+ "success": False,
1678
+ "message": "Device manager not available",
1679
+ "devices": [],
1680
+ "total_devices": 0,
1681
+ "connected_devices": 0,
1682
+ "total_leases": 0,
1683
+ }
1684
+
1685
+ try:
1686
+ # Refresh device inventory if requested
1687
+ if refresh:
1688
+ self._device_manager.refresh_devices()
1689
+
1690
+ # Get device status
1691
+ all_status = self._device_manager.get_all_leases()
1692
+
1693
+ # Filter devices based on include_disconnected
1694
+ devices = []
1695
+ for _device_id, device_state in all_status.get("devices", {}).items():
1696
+ if include_disconnected or device_state.get("is_connected", False):
1697
+ devices.append(device_state)
1698
+
1699
+ return {
1700
+ "success": True,
1701
+ "message": f"Found {len(devices)} device(s)",
1702
+ "devices": devices,
1703
+ "total_devices": all_status.get("total_devices", 0),
1704
+ "connected_devices": all_status.get("connected_devices", 0),
1705
+ "total_leases": all_status.get("total_leases", 0),
1706
+ }
1707
+
1708
+ except KeyboardInterrupt: # noqa: KBI002
1709
+ raise
1710
+ except Exception as e:
1711
+ logging.error(f"Error listing devices: {e}")
1712
+ return {
1713
+ "success": False,
1714
+ "message": f"Device list error: {e}",
1715
+ "devices": [],
1716
+ "total_devices": 0,
1717
+ "connected_devices": 0,
1718
+ "total_leases": 0,
1719
+ }
1720
+
1721
+ async def _handle_device_lease(
1722
+ self,
1723
+ client: ClientConnection,
1724
+ data: dict[str, Any],
1725
+ ) -> dict[str, Any]:
1726
+ """Handle device lease request.
1727
+
1728
+ Args:
1729
+ client: The client connection
1730
+ data: Lease request data (device_id, lease_type, description, allows_monitors, timeout)
1731
+
1732
+ Returns:
1733
+ Response with lease result
1734
+ """
1735
+ from fbuild.daemon.device_manager import LeaseType
1736
+
1737
+ device_id = data.get("device_id", "")
1738
+ lease_type_str = data.get("lease_type", "exclusive")
1739
+ description = data.get("description", "")
1740
+ allows_monitors = data.get("allows_monitors", True)
1741
+ timeout = data.get("timeout", 300.0)
1742
+
1743
+ if not device_id:
1744
+ return {
1745
+ "success": False,
1746
+ "message": "device_id is required",
1747
+ }
1748
+
1749
+ # Check that device manager is available
1750
+ if self._device_manager is None:
1751
+ return {
1752
+ "success": False,
1753
+ "message": "Device manager not available",
1754
+ }
1755
+
1756
+ try:
1757
+ lease_type = LeaseType(lease_type_str)
1758
+ except ValueError:
1759
+ return {
1760
+ "success": False,
1761
+ "message": f"Invalid lease type: {lease_type_str}. Must be 'exclusive' or 'monitor'",
1762
+ }
1763
+
1764
+ try:
1765
+ if lease_type == LeaseType.EXCLUSIVE:
1766
+ lease = self._device_manager.acquire_exclusive(
1767
+ device_id=device_id,
1768
+ client_id=client.client_id,
1769
+ description=description,
1770
+ allows_monitors=allows_monitors,
1771
+ timeout=timeout,
1772
+ )
1773
+ else: # MONITOR
1774
+ lease = self._device_manager.acquire_monitor(
1775
+ device_id=device_id,
1776
+ client_id=client.client_id,
1777
+ description=description,
1778
+ )
1779
+
1780
+ if lease:
1781
+ logging.info(f"Client {client.client_id} acquired {lease_type.value} lease for device {device_id} (lease_id={lease.lease_id})")
1782
+
1783
+ # Broadcast lease event
1784
+ await self.broadcast(
1785
+ SubscriptionType.DEVICES,
1786
+ {
1787
+ "event": "lease_acquired",
1788
+ "client_id": client.client_id,
1789
+ "device_id": device_id,
1790
+ "lease_id": lease.lease_id,
1791
+ "lease_type": lease_type.value,
1792
+ },
1793
+ )
1794
+
1795
+ return {
1796
+ "success": True,
1797
+ "message": f"{lease_type.value} lease acquired",
1798
+ "lease_id": lease.lease_id,
1799
+ "device_id": device_id,
1800
+ "lease_type": lease_type.value,
1801
+ "allows_monitors": lease.allows_monitors,
1802
+ }
1803
+ else:
1804
+ device_status = self._device_manager.get_device_status(device_id)
1805
+ return {
1806
+ "success": False,
1807
+ "message": "Lease not available",
1808
+ "device_id": device_id,
1809
+ "lease_type": lease_type.value,
1810
+ "is_connected": device_status.get("is_connected", False),
1811
+ "has_exclusive": device_status.get("exclusive_lease") is not None,
1812
+ }
1813
+
1814
+ except KeyboardInterrupt: # noqa: KBI002
1815
+ raise
1816
+ except Exception as e:
1817
+ logging.error(f"Error acquiring device lease for {client.client_id}: {e}")
1818
+ return {
1819
+ "success": False,
1820
+ "message": f"Device lease error: {e}",
1821
+ }
1822
+
1823
+ async def _handle_device_release(
1824
+ self,
1825
+ client: ClientConnection,
1826
+ data: dict[str, Any],
1827
+ ) -> dict[str, Any]:
1828
+ """Handle device lease release request.
1829
+
1830
+ Args:
1831
+ client: The client connection
1832
+ data: Release request data (lease_id)
1833
+
1834
+ Returns:
1835
+ Response with release result
1836
+ """
1837
+ lease_id = data.get("lease_id", "")
1838
+
1839
+ if not lease_id:
1840
+ return {
1841
+ "success": False,
1842
+ "message": "lease_id is required",
1843
+ }
1844
+
1845
+ # Check that device manager is available
1846
+ if self._device_manager is None:
1847
+ return {
1848
+ "success": False,
1849
+ "message": "Device manager not available",
1850
+ }
1851
+
1852
+ try:
1853
+ released = self._device_manager.release_lease(lease_id, client.client_id)
1854
+
1855
+ if released:
1856
+ logging.info(f"Client {client.client_id} released lease {lease_id}")
1857
+
1858
+ # Broadcast lease release event
1859
+ await self.broadcast(
1860
+ SubscriptionType.DEVICES,
1861
+ {
1862
+ "event": "lease_released",
1863
+ "client_id": client.client_id,
1864
+ "lease_id": lease_id,
1865
+ },
1866
+ )
1867
+
1868
+ return {
1869
+ "success": True,
1870
+ "message": "Lease released",
1871
+ "lease_id": lease_id,
1872
+ }
1873
+ else:
1874
+ return {
1875
+ "success": False,
1876
+ "message": "Lease not found or not owned by this client",
1877
+ "lease_id": lease_id,
1878
+ }
1879
+
1880
+ except KeyboardInterrupt: # noqa: KBI002
1881
+ raise
1882
+ except Exception as e:
1883
+ logging.error(f"Error releasing device lease {lease_id} for {client.client_id}: {e}")
1884
+ return {
1885
+ "success": False,
1886
+ "message": f"Device release error: {e}",
1887
+ }
1888
+
1889
+ async def _handle_device_preempt(
1890
+ self,
1891
+ client: ClientConnection,
1892
+ data: dict[str, Any],
1893
+ ) -> dict[str, Any]:
1894
+ """Handle device preemption request.
1895
+
1896
+ Forcibly takes the exclusive lease from the current holder.
1897
+ The reason is REQUIRED and must not be empty.
1898
+
1899
+ Args:
1900
+ client: The client connection
1901
+ data: Preempt request data (device_id, reason)
1902
+
1903
+ Returns:
1904
+ Response with preemption result
1905
+ """
1906
+ device_id = data.get("device_id", "")
1907
+ reason = data.get("reason", "")
1908
+
1909
+ if not device_id:
1910
+ return {
1911
+ "success": False,
1912
+ "message": "device_id is required",
1913
+ }
1914
+
1915
+ if not reason or not reason.strip():
1916
+ return {
1917
+ "success": False,
1918
+ "message": "reason is required and must not be empty",
1919
+ }
1920
+
1921
+ # Check that device manager is available
1922
+ if self._device_manager is None:
1923
+ return {
1924
+ "success": False,
1925
+ "message": "Device manager not available",
1926
+ }
1927
+
1928
+ try:
1929
+ success, preempted_client_id = self._device_manager.preempt_device(
1930
+ device_id=device_id,
1931
+ requesting_client_id=client.client_id,
1932
+ reason=reason,
1933
+ )
1934
+
1935
+ if success:
1936
+ logging.warning(f"PREEMPTION: {client.client_id} took device {device_id} from {preempted_client_id}. Reason: {reason}")
1937
+
1938
+ # Broadcast preemption event to all subscribers
1939
+ await self.broadcast(
1940
+ SubscriptionType.DEVICES,
1941
+ {
1942
+ "event": "device_preempted",
1943
+ "device_id": device_id,
1944
+ "preempted_by": client.client_id,
1945
+ "preempted_client_id": preempted_client_id,
1946
+ "reason": reason,
1947
+ },
1948
+ )
1949
+
1950
+ # Send direct notification to preempted client if they're still connected
1951
+ if preempted_client_id:
1952
+ preempted_client = await self.get_client_async(preempted_client_id)
1953
+ if preempted_client:
1954
+ await self._send_message(
1955
+ preempted_client,
1956
+ MessageType.BROADCAST,
1957
+ {
1958
+ "event_type": "device_preemption",
1959
+ "data": {
1960
+ "device_id": device_id,
1961
+ "preempted_by": client.client_id,
1962
+ "reason": reason,
1963
+ },
1964
+ "timestamp": time.time(),
1965
+ },
1966
+ )
1967
+
1968
+ # Get the new lease for the requester
1969
+ device_status = self._device_manager.get_device_status(device_id)
1970
+ new_lease = device_status.get("exclusive_lease")
1971
+
1972
+ return {
1973
+ "success": True,
1974
+ "message": f"Device preempted from {preempted_client_id}",
1975
+ "device_id": device_id,
1976
+ "preempted_client_id": preempted_client_id,
1977
+ "lease_id": new_lease.get("lease_id") if new_lease else None,
1978
+ "lease_type": "exclusive",
1979
+ }
1980
+ else:
1981
+ return {
1982
+ "success": False,
1983
+ "message": "Preemption failed - device may not have an exclusive holder",
1984
+ "device_id": device_id,
1985
+ }
1986
+
1987
+ except KeyboardInterrupt: # noqa: KBI002
1988
+ raise
1989
+ except Exception as e:
1990
+ logging.error(f"Error preempting device {device_id} for {client.client_id}: {e}")
1991
+ return {
1992
+ "success": False,
1993
+ "message": f"Device preemption error: {e}",
1994
+ }
1995
+
1996
+ async def _handle_device_status(
1997
+ self,
1998
+ client: ClientConnection, # noqa: ARG002
1999
+ data: dict[str, Any],
2000
+ ) -> dict[str, Any]:
2001
+ """Handle device status request.
2002
+
2003
+ Args:
2004
+ client: The client connection (unused but required for handler signature)
2005
+ data: Status request data (device_id)
2006
+
2007
+ Returns:
2008
+ Response with device status
2009
+ """
2010
+ device_id = data.get("device_id", "")
2011
+
2012
+ if not device_id:
2013
+ return {
2014
+ "success": False,
2015
+ "message": "device_id is required",
2016
+ }
2017
+
2018
+ # Check that device manager is available
2019
+ if self._device_manager is None:
2020
+ return {
2021
+ "success": False,
2022
+ "message": "Device manager not available",
2023
+ "device_id": device_id,
2024
+ "exists": False,
2025
+ }
2026
+
2027
+ try:
2028
+ status = self._device_manager.get_device_status(device_id)
2029
+
2030
+ if not status.get("exists", False):
2031
+ return {
2032
+ "success": True,
2033
+ "message": "Device not found",
2034
+ "device_id": device_id,
2035
+ "exists": False,
2036
+ "is_connected": False,
2037
+ }
2038
+
2039
+ return {
2040
+ "success": True,
2041
+ "message": "Device status retrieved",
2042
+ **status,
2043
+ }
2044
+
2045
+ except KeyboardInterrupt: # noqa: KBI002
2046
+ raise
2047
+ except Exception as e:
2048
+ logging.error(f"Error getting device status for {device_id}: {e}")
2049
+ return {
2050
+ "success": False,
2051
+ "message": f"Device status error: {e}",
2052
+ "device_id": device_id,
2053
+ }
2054
+
2055
+ # =========================================================================
2056
+ # Status and Introspection
2057
+ # =========================================================================
2058
+
2059
+ async def get_status(self) -> dict[str, Any]:
2060
+ """Get server status information.
2061
+
2062
+ Returns:
2063
+ Dictionary with server status
2064
+ """
2065
+ async with self._clients_lock:
2066
+ clients_info = {client_id: client.to_dict() for client_id, client in self._clients.items()}
2067
+
2068
+ return {
2069
+ "is_running": self._is_running,
2070
+ "host": self._host,
2071
+ "port": self._port,
2072
+ "client_count": len(clients_info),
2073
+ "clients": clients_info,
2074
+ "heartbeat_timeout": self._heartbeat_timeout,
2075
+ }
2076
+
2077
+ def get_client(self, client_id: str) -> ClientConnection | None:
2078
+ """Get a client connection by ID.
2079
+
2080
+ Note: This is not async-safe. Use with caution in async contexts.
2081
+
2082
+ Args:
2083
+ client_id: The client ID to look up
2084
+
2085
+ Returns:
2086
+ ClientConnection if found, None otherwise
2087
+ """
2088
+ return self._clients.get(client_id)
2089
+
2090
+ async def get_client_async(self, client_id: str) -> ClientConnection | None:
2091
+ """Get a client connection by ID (async-safe).
2092
+
2093
+ Args:
2094
+ client_id: The client ID to look up
2095
+
2096
+ Returns:
2097
+ ClientConnection if found, None otherwise
2098
+ """
2099
+ async with self._clients_lock:
2100
+ return self._clients.get(client_id)