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,531 @@
1
+ """
2
+ Async Client Connection Manager for fbuild daemon.
3
+
4
+ This module provides classes for managing asynchronous client connections,
5
+ including connection tracking, heartbeat mechanism, and connection lifecycle.
6
+
7
+ Features:
8
+ - Track connected clients by unique client_id (UUID string)
9
+ - Heartbeat mechanism to detect dead clients
10
+ - Track client metadata: pid, connect_time, last_heartbeat, attached resources
11
+ - Cleanup on client disconnect (release locks, detach from serial sessions)
12
+ - Support for registering cleanup callbacks
13
+ """
14
+
15
+ import logging
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from dataclasses import asdict, dataclass, field
20
+ from typing import Any, Callable
21
+
22
+ # Default heartbeat timeout: clients not sending heartbeat in this time are considered dead
23
+ DEFAULT_HEARTBEAT_TIMEOUT = 30.0
24
+
25
+ # Cleanup interval for checking dead clients
26
+ CLEANUP_INTERVAL = 10.0
27
+
28
+
29
+ @dataclass
30
+ class ClientInfo:
31
+ """Information about a connected client.
32
+
33
+ Attributes:
34
+ client_id: Unique identifier for the client (UUID string)
35
+ pid: Process ID of the client
36
+ connect_time: Unix timestamp when client connected
37
+ last_heartbeat: Unix timestamp of last heartbeat received
38
+ metadata: Additional client metadata (e.g., version, hostname)
39
+ attached_resources: Set of resource keys this client is attached to
40
+ """
41
+
42
+ client_id: str
43
+ pid: int
44
+ connect_time: float = field(default_factory=time.time)
45
+ last_heartbeat: float = field(default_factory=time.time)
46
+ metadata: dict[str, Any] = field(default_factory=dict)
47
+ attached_resources: set[str] = field(default_factory=set)
48
+
49
+ def is_alive(self, timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT) -> bool:
50
+ """Check if client is still alive based on heartbeat timeout.
51
+
52
+ Args:
53
+ timeout_seconds: Maximum time since last heartbeat before considered dead.
54
+
55
+ Returns:
56
+ True if client is alive (heartbeat within timeout), False otherwise.
57
+ """
58
+ return (time.time() - self.last_heartbeat) <= timeout_seconds
59
+
60
+ def time_since_heartbeat(self) -> float:
61
+ """Get time in seconds since last heartbeat."""
62
+ return time.time() - self.last_heartbeat
63
+
64
+ def connection_duration(self) -> float:
65
+ """Get total connection duration in seconds."""
66
+ return time.time() - self.connect_time
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ """Convert to dictionary for JSON serialization."""
70
+ return {
71
+ "client_id": self.client_id,
72
+ "pid": self.pid,
73
+ "connect_time": self.connect_time,
74
+ "last_heartbeat": self.last_heartbeat,
75
+ "metadata": self.metadata,
76
+ "attached_resources": list(self.attached_resources),
77
+ "is_alive": self.is_alive(),
78
+ "time_since_heartbeat": self.time_since_heartbeat(),
79
+ "connection_duration": self.connection_duration(),
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class ClientConnectMessage:
85
+ """Client -> Daemon: Connection request message.
86
+
87
+ Sent when a client connects to the daemon to register itself.
88
+
89
+ Attributes:
90
+ client_id: Unique identifier for the client (generated by client or daemon)
91
+ pid: Process ID of the client
92
+ metadata: Additional client metadata (e.g., version, hostname)
93
+ timestamp: Unix timestamp when message was created
94
+ """
95
+
96
+ client_id: str
97
+ pid: int
98
+ metadata: dict[str, Any] = field(default_factory=dict)
99
+ timestamp: float = field(default_factory=time.time)
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ """Convert to dictionary for JSON serialization."""
103
+ return asdict(self)
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> "ClientConnectMessage":
107
+ """Create ClientConnectMessage from dictionary."""
108
+ return cls(
109
+ client_id=data["client_id"],
110
+ pid=data["pid"],
111
+ metadata=data.get("metadata", {}),
112
+ timestamp=data.get("timestamp", time.time()),
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class ClientHeartbeatMessage:
118
+ """Client -> Daemon: Periodic heartbeat message.
119
+
120
+ Sent periodically by clients to indicate they are still alive.
121
+
122
+ Attributes:
123
+ client_id: Unique identifier for the client
124
+ timestamp: Unix timestamp when heartbeat was sent
125
+ """
126
+
127
+ client_id: str
128
+ timestamp: float = field(default_factory=time.time)
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ """Convert to dictionary for JSON serialization."""
132
+ return asdict(self)
133
+
134
+ @classmethod
135
+ def from_dict(cls, data: dict[str, Any]) -> "ClientHeartbeatMessage":
136
+ """Create ClientHeartbeatMessage from dictionary."""
137
+ return cls(
138
+ client_id=data["client_id"],
139
+ timestamp=data.get("timestamp", time.time()),
140
+ )
141
+
142
+
143
+ @dataclass
144
+ class ClientDisconnectMessage:
145
+ """Client -> Daemon: Graceful disconnect message.
146
+
147
+ Sent when a client is gracefully disconnecting.
148
+
149
+ Attributes:
150
+ client_id: Unique identifier for the client
151
+ reason: Optional reason for disconnection
152
+ timestamp: Unix timestamp when disconnect was initiated
153
+ """
154
+
155
+ client_id: str
156
+ reason: str | None = None
157
+ timestamp: float = field(default_factory=time.time)
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ """Convert to dictionary for JSON serialization."""
161
+ return asdict(self)
162
+
163
+ @classmethod
164
+ def from_dict(cls, data: dict[str, Any]) -> "ClientDisconnectMessage":
165
+ """Create ClientDisconnectMessage from dictionary."""
166
+ return cls(
167
+ client_id=data["client_id"],
168
+ reason=data.get("reason"),
169
+ timestamp=data.get("timestamp", time.time()),
170
+ )
171
+
172
+
173
+ class ClientConnectionManager:
174
+ """Manages client connections with heartbeat monitoring and cleanup.
175
+
176
+ This class provides a centralized manager for tracking client connections
177
+ to the daemon. It handles:
178
+ - Client registration and unregistration
179
+ - Heartbeat tracking for connection health
180
+ - Resource attachment tracking per client
181
+ - Automatic cleanup of dead clients
182
+ - Cleanup callbacks for resource release
183
+
184
+ Example:
185
+ >>> manager = ClientConnectionManager()
186
+ >>>
187
+ >>> # Register a new client
188
+ >>> client_id = manager.generate_client_id()
189
+ >>> client_info = manager.register_client(client_id, pid=12345)
190
+ >>>
191
+ >>> # Send periodic heartbeats
192
+ >>> manager.heartbeat(client_id)
193
+ >>>
194
+ >>> # Attach resources
195
+ >>> manager.attach_resource(client_id, "port:/dev/ttyUSB0")
196
+ >>>
197
+ >>> # Cleanup dead clients
198
+ >>> dead_clients = manager.cleanup_dead_clients()
199
+ """
200
+
201
+ def __init__(self) -> None:
202
+ """Initialize the ClientConnectionManager."""
203
+ self._lock = threading.Lock()
204
+ self._clients: dict[str, ClientInfo] = {}
205
+ self._cleanup_callbacks: list[Callable[[str], None]] = []
206
+
207
+ def generate_client_id(self) -> str:
208
+ """Generate a unique client ID using UUID4.
209
+
210
+ Returns:
211
+ A unique client ID string.
212
+ """
213
+ return str(uuid.uuid4())
214
+
215
+ def register_client(
216
+ self,
217
+ client_id: str,
218
+ pid: int,
219
+ metadata: dict[str, Any] | None = None,
220
+ ) -> ClientInfo:
221
+ """Register a new client connection.
222
+
223
+ If a client with the same ID already exists, it will be replaced
224
+ (after calling cleanup callbacks for the old client).
225
+
226
+ Args:
227
+ client_id: Unique identifier for the client.
228
+ pid: Process ID of the client.
229
+ metadata: Optional additional client metadata.
230
+
231
+ Returns:
232
+ ClientInfo object for the registered client.
233
+ """
234
+ with self._lock:
235
+ # If client already exists, unregister it first
236
+ if client_id in self._clients:
237
+ logging.warning(f"Client {client_id} already registered, replacing existing connection")
238
+ # Call cleanup callbacks outside the lock
239
+ self._call_cleanup_callbacks_unlocked(client_id)
240
+
241
+ current_time = time.time()
242
+ client_info = ClientInfo(
243
+ client_id=client_id,
244
+ pid=pid,
245
+ connect_time=current_time,
246
+ last_heartbeat=current_time,
247
+ metadata=metadata or {},
248
+ attached_resources=set(),
249
+ )
250
+ self._clients[client_id] = client_info
251
+ logging.info(f"Client registered: {client_id} (pid={pid})")
252
+ return client_info
253
+
254
+ def unregister_client(self, client_id: str) -> bool:
255
+ """Unregister a client connection.
256
+
257
+ This calls any registered cleanup callbacks before removing the client.
258
+
259
+ Args:
260
+ client_id: Unique identifier for the client.
261
+
262
+ Returns:
263
+ True if client was unregistered, False if client not found.
264
+ """
265
+ with self._lock:
266
+ if client_id not in self._clients:
267
+ logging.warning(f"Cannot unregister unknown client: {client_id}")
268
+ return False
269
+
270
+ client_info = self._clients[client_id]
271
+ resource_count = len(client_info.attached_resources)
272
+
273
+ # Call cleanup callbacks (this may release resources)
274
+ self._call_cleanup_callbacks_unlocked(client_id)
275
+
276
+ # Remove client from registry
277
+ del self._clients[client_id]
278
+ logging.info(f"Client unregistered: {client_id} " f"(had {resource_count} attached resources)")
279
+ return True
280
+
281
+ def heartbeat(self, client_id: str) -> bool:
282
+ """Update last heartbeat time for a client.
283
+
284
+ Args:
285
+ client_id: Unique identifier for the client.
286
+
287
+ Returns:
288
+ True if heartbeat was recorded, False if client not found.
289
+ """
290
+ with self._lock:
291
+ if client_id not in self._clients:
292
+ logging.debug(f"Heartbeat from unknown client: {client_id}")
293
+ return False
294
+
295
+ self._clients[client_id].last_heartbeat = time.time()
296
+ logging.debug(f"Heartbeat received from client: {client_id}")
297
+ return True
298
+
299
+ def get_client(self, client_id: str) -> ClientInfo | None:
300
+ """Get client information by ID.
301
+
302
+ Args:
303
+ client_id: Unique identifier for the client.
304
+
305
+ Returns:
306
+ ClientInfo if client exists, None otherwise.
307
+ """
308
+ with self._lock:
309
+ return self._clients.get(client_id)
310
+
311
+ def get_all_clients(self) -> dict[str, ClientInfo]:
312
+ """Get all registered clients.
313
+
314
+ Returns:
315
+ Dictionary mapping client_id to ClientInfo for all registered clients.
316
+ """
317
+ with self._lock:
318
+ # Return a copy to prevent external modification
319
+ return dict(self._clients)
320
+
321
+ def is_client_alive(
322
+ self,
323
+ client_id: str,
324
+ timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT,
325
+ ) -> bool:
326
+ """Check if a client is alive based on heartbeat timeout.
327
+
328
+ Args:
329
+ client_id: Unique identifier for the client.
330
+ timeout_seconds: Maximum time since last heartbeat before considered dead.
331
+
332
+ Returns:
333
+ True if client exists and is alive, False otherwise.
334
+ """
335
+ with self._lock:
336
+ client_info = self._clients.get(client_id)
337
+ if client_info is None:
338
+ return False
339
+ return client_info.is_alive(timeout_seconds)
340
+
341
+ def cleanup_dead_clients(
342
+ self,
343
+ timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT,
344
+ ) -> list[str]:
345
+ """Clean up clients that have not sent a heartbeat within the timeout.
346
+
347
+ This calls cleanup callbacks for each dead client before removing them.
348
+
349
+ Args:
350
+ timeout_seconds: Maximum time since last heartbeat before considered dead.
351
+
352
+ Returns:
353
+ List of client IDs that were cleaned up.
354
+ """
355
+ dead_clients: list[str] = []
356
+
357
+ with self._lock:
358
+ # Find dead clients
359
+ for client_id, client_info in list(self._clients.items()):
360
+ if not client_info.is_alive(timeout_seconds):
361
+ dead_clients.append(client_id)
362
+ logging.warning(f"Client {client_id} is dead " f"(no heartbeat for {client_info.time_since_heartbeat():.1f}s)")
363
+
364
+ # Clean up dead clients
365
+ for client_id in dead_clients:
366
+ # Call cleanup callbacks
367
+ self._call_cleanup_callbacks_unlocked(client_id)
368
+
369
+ # Remove from registry
370
+ del self._clients[client_id]
371
+ logging.info(f"Dead client cleaned up: {client_id}")
372
+
373
+ return dead_clients
374
+
375
+ def attach_resource(self, client_id: str, resource_key: str) -> bool:
376
+ """Attach a resource to a client for tracking.
377
+
378
+ This allows tracking which resources a client is using, so they
379
+ can be released when the client disconnects.
380
+
381
+ Args:
382
+ client_id: Unique identifier for the client.
383
+ resource_key: Key identifying the resource (e.g., "port:/dev/ttyUSB0").
384
+
385
+ Returns:
386
+ True if resource was attached, False if client not found.
387
+ """
388
+ with self._lock:
389
+ if client_id not in self._clients:
390
+ logging.warning(f"Cannot attach resource to unknown client: {client_id}")
391
+ return False
392
+
393
+ self._clients[client_id].attached_resources.add(resource_key)
394
+ logging.debug(f"Resource attached to client {client_id}: {resource_key}")
395
+ return True
396
+
397
+ def detach_resource(self, client_id: str, resource_key: str) -> bool:
398
+ """Detach a resource from a client.
399
+
400
+ Args:
401
+ client_id: Unique identifier for the client.
402
+ resource_key: Key identifying the resource.
403
+
404
+ Returns:
405
+ True if resource was detached, False if client or resource not found.
406
+ """
407
+ with self._lock:
408
+ if client_id not in self._clients:
409
+ logging.warning(f"Cannot detach resource from unknown client: {client_id}")
410
+ return False
411
+
412
+ resources = self._clients[client_id].attached_resources
413
+ if resource_key not in resources:
414
+ logging.warning(f"Resource not attached to client {client_id}: {resource_key}")
415
+ return False
416
+
417
+ resources.discard(resource_key)
418
+ logging.debug(f"Resource detached from client {client_id}: {resource_key}")
419
+ return True
420
+
421
+ def get_client_resources(self, client_id: str) -> set[str]:
422
+ """Get all resources attached to a client.
423
+
424
+ Args:
425
+ client_id: Unique identifier for the client.
426
+
427
+ Returns:
428
+ Set of resource keys attached to the client (empty set if not found).
429
+ """
430
+ with self._lock:
431
+ client_info = self._clients.get(client_id)
432
+ if client_info is None:
433
+ return set()
434
+ # Return a copy to prevent external modification
435
+ return set(client_info.attached_resources)
436
+
437
+ def register_cleanup_callback(
438
+ self,
439
+ callback: Callable[[str], None],
440
+ ) -> None:
441
+ """Register a callback to be called when a client disconnects.
442
+
443
+ The callback receives the client_id of the disconnecting client.
444
+ Callbacks are called in the order they were registered.
445
+
446
+ Args:
447
+ callback: Function to call with client_id when client disconnects.
448
+ """
449
+ with self._lock:
450
+ self._cleanup_callbacks.append(callback)
451
+ logging.debug(f"Cleanup callback registered " f"(total callbacks: {len(self._cleanup_callbacks)})")
452
+
453
+ def _call_cleanup_callbacks_unlocked(self, client_id: str) -> None:
454
+ """Call all cleanup callbacks for a client (must hold lock).
455
+
456
+ This is an internal method that calls cleanup callbacks while
457
+ the lock is held. Callbacks should be fast and non-blocking.
458
+
459
+ Args:
460
+ client_id: Unique identifier for the disconnecting client.
461
+ """
462
+ for callback in self._cleanup_callbacks:
463
+ try:
464
+ callback(client_id)
465
+ except KeyboardInterrupt: # noqa: KBI002
466
+ raise
467
+ except Exception as e:
468
+ logging.error(f"Error in cleanup callback for client {client_id}: {e}")
469
+
470
+ def get_client_count(self) -> int:
471
+ """Get the number of registered clients.
472
+
473
+ Returns:
474
+ Number of currently registered clients.
475
+ """
476
+ with self._lock:
477
+ return len(self._clients)
478
+
479
+ def get_alive_client_count(
480
+ self,
481
+ timeout_seconds: float = DEFAULT_HEARTBEAT_TIMEOUT,
482
+ ) -> int:
483
+ """Get the number of alive clients.
484
+
485
+ Args:
486
+ timeout_seconds: Maximum time since last heartbeat before considered dead.
487
+
488
+ Returns:
489
+ Number of alive clients.
490
+ """
491
+ with self._lock:
492
+ return sum(1 for client in self._clients.values() if client.is_alive(timeout_seconds))
493
+
494
+ def get_status(self) -> dict[str, Any]:
495
+ """Get status information about the client manager.
496
+
497
+ Returns:
498
+ Dictionary with client manager status information.
499
+ """
500
+ with self._lock:
501
+ clients_info = {client_id: client.to_dict() for client_id, client in self._clients.items()}
502
+
503
+ return {
504
+ "total_clients": len(self._clients),
505
+ "alive_clients": sum(1 for client in self._clients.values() if client.is_alive()),
506
+ "dead_clients": sum(1 for client in self._clients.values() if not client.is_alive()),
507
+ "callback_count": len(self._cleanup_callbacks),
508
+ "clients": clients_info,
509
+ }
510
+
511
+ def clear_all_clients(self) -> int:
512
+ """Clear all clients (use with caution - for daemon restart).
513
+
514
+ This calls cleanup callbacks for each client before clearing.
515
+
516
+ Returns:
517
+ Number of clients cleared.
518
+ """
519
+ with self._lock:
520
+ count = len(self._clients)
521
+
522
+ # Call cleanup callbacks for each client
523
+ for client_id in list(self._clients.keys()):
524
+ self._call_cleanup_callbacks_unlocked(client_id)
525
+
526
+ self._clients.clear()
527
+
528
+ if count > 0:
529
+ logging.info(f"Cleared all {count} clients")
530
+
531
+ return count