fbuild 1.2.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fbuild/__init__.py +390 -0
- fbuild/assets/example.txt +1 -0
- fbuild/build/__init__.py +117 -0
- fbuild/build/archive_creator.py +186 -0
- fbuild/build/binary_generator.py +444 -0
- fbuild/build/build_component_factory.py +131 -0
- fbuild/build/build_info_generator.py +624 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +93 -0
- fbuild/build/compilation_executor.py +422 -0
- fbuild/build/compiler.py +165 -0
- fbuild/build/compiler_avr.py +574 -0
- fbuild/build/configurable_compiler.py +664 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +214 -0
- fbuild/build/library_dependency_processor.py +185 -0
- fbuild/build/linker.py +708 -0
- fbuild/build/orchestrator.py +67 -0
- fbuild/build/orchestrator_avr.py +651 -0
- fbuild/build/orchestrator_esp32.py +878 -0
- fbuild/build/orchestrator_rp2040.py +719 -0
- fbuild/build/orchestrator_stm32.py +696 -0
- fbuild/build/orchestrator_teensy.py +580 -0
- fbuild/build/source_compilation_orchestrator.py +218 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +717 -0
- fbuild/cli_utils.py +314 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +542 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +369 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +42 -0
- fbuild/daemon/async_client.py +531 -0
- fbuild/daemon/client.py +1505 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/configuration_lock.py +865 -0
- fbuild/daemon/daemon.py +585 -0
- fbuild/daemon/daemon_context.py +293 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/firmware_ledger.py +546 -0
- fbuild/daemon/lock_manager.py +508 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +957 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/port_state_manager.py +249 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +18 -0
- fbuild/daemon/processors/build_processor.py +248 -0
- fbuild/daemon/processors/deploy_processor.py +664 -0
- fbuild/daemon/processors/install_deps_processor.py +431 -0
- fbuild/daemon/processors/locking_processor.py +777 -0
- fbuild/daemon/processors/monitor_processor.py +285 -0
- fbuild/daemon/request_processor.py +457 -0
- fbuild/daemon/shared_serial.py +819 -0
- fbuild/daemon/status_manager.py +238 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +21 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +310 -0
- fbuild/deploy/docker_utils.py +315 -0
- fbuild/deploy/monitor.py +519 -0
- fbuild/deploy/qemu_runner.py +603 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/ledger/__init__.py +52 -0
- fbuild/ledger/board_ledger.py +560 -0
- fbuild/output.py +352 -0
- fbuild/packages/__init__.py +66 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +256 -0
- fbuild/packages/concurrent_manager.py +510 -0
- fbuild/packages/downloader.py +518 -0
- fbuild/packages/fingerprint.py +423 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_rp2040.py +349 -0
- fbuild/packages/framework_stm32.py +459 -0
- fbuild/packages/framework_teensy.py +346 -0
- fbuild/packages/github_utils.py +96 -0
- fbuild/packages/header_trampoline_cache.py +394 -0
- fbuild/packages/library_compiler.py +203 -0
- fbuild/packages/library_manager.py +549 -0
- fbuild/packages/library_manager_esp32.py +725 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_rp2040.py +400 -0
- fbuild/packages/platform_stm32.py +581 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +369 -0
- fbuild/packages/sdk_utils.py +231 -0
- fbuild/packages/toolchain.py +436 -0
- fbuild/packages/toolchain_binaries.py +196 -0
- fbuild/packages/toolchain_esp32.py +489 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_rp2040.py +436 -0
- fbuild/packages/toolchain_stm32.py +417 -0
- fbuild/packages/toolchain_teensy.py +404 -0
- fbuild/platform_configs/esp32.json +150 -0
- fbuild/platform_configs/esp32c2.json +144 -0
- fbuild/platform_configs/esp32c3.json +143 -0
- fbuild/platform_configs/esp32c5.json +151 -0
- fbuild/platform_configs/esp32c6.json +151 -0
- fbuild/platform_configs/esp32p4.json +149 -0
- fbuild/platform_configs/esp32s3.json +151 -0
- fbuild/platform_configs/imxrt1062.json +56 -0
- fbuild/platform_configs/rp2040.json +70 -0
- fbuild/platform_configs/rp2350.json +76 -0
- fbuild/platform_configs/stm32f1.json +59 -0
- fbuild/platform_configs/stm32f4.json +63 -0
- fbuild/py.typed +0 -0
- fbuild-1.2.8.dist-info/METADATA +468 -0
- fbuild-1.2.8.dist-info/RECORD +121 -0
- fbuild-1.2.8.dist-info/WHEEL +5 -0
- fbuild-1.2.8.dist-info/entry_points.txt +5 -0
- fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
- fbuild-1.2.8.dist-info/top_level.txt +2 -0
- fbuild_lint/__init__.py +0 -0
- fbuild_lint/ruff_plugins/__init__.py +0 -0
- fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
|
@@ -0,0 +1,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
|