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.
- fbuild/__init__.py +5 -1
- fbuild/build/configurable_compiler.py +49 -6
- fbuild/build/configurable_linker.py +14 -9
- fbuild/build/orchestrator_esp32.py +6 -3
- fbuild/build/orchestrator_rp2040.py +6 -2
- fbuild/cli.py +300 -5
- fbuild/config/ini_parser.py +13 -1
- fbuild/daemon/__init__.py +11 -0
- fbuild/daemon/async_client.py +5 -4
- fbuild/daemon/async_client_lib.py +1543 -0
- fbuild/daemon/async_protocol.py +825 -0
- fbuild/daemon/async_server.py +2100 -0
- fbuild/daemon/client.py +425 -13
- fbuild/daemon/configuration_lock.py +13 -13
- fbuild/daemon/connection.py +508 -0
- fbuild/daemon/connection_registry.py +579 -0
- fbuild/daemon/daemon.py +517 -164
- fbuild/daemon/daemon_context.py +72 -1
- fbuild/daemon/device_discovery.py +477 -0
- fbuild/daemon/device_manager.py +821 -0
- fbuild/daemon/error_collector.py +263 -263
- fbuild/daemon/file_cache.py +332 -332
- fbuild/daemon/firmware_ledger.py +46 -123
- fbuild/daemon/lock_manager.py +508 -508
- fbuild/daemon/messages.py +431 -0
- fbuild/daemon/operation_registry.py +288 -288
- fbuild/daemon/processors/build_processor.py +34 -1
- fbuild/daemon/processors/deploy_processor.py +1 -3
- fbuild/daemon/processors/locking_processor.py +7 -7
- fbuild/daemon/request_processor.py +457 -457
- fbuild/daemon/shared_serial.py +7 -7
- fbuild/daemon/status_manager.py +238 -238
- fbuild/daemon/subprocess_manager.py +316 -316
- fbuild/deploy/docker_utils.py +182 -2
- fbuild/deploy/monitor.py +1 -1
- fbuild/deploy/qemu_runner.py +71 -13
- fbuild/ledger/board_ledger.py +46 -122
- fbuild/output.py +238 -2
- fbuild/packages/library_compiler.py +15 -5
- fbuild/packages/library_manager.py +12 -6
- fbuild-1.2.15.dist-info/METADATA +569 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
- fbuild-1.2.8.dist-info/METADATA +0 -468
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
- {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server-side connection registry for tracking active daemon client connections.
|
|
3
|
+
|
|
4
|
+
This module provides the daemon-side tracking of all connected clients,
|
|
5
|
+
their state, and platform slot assignments. It is used by the daemon process
|
|
6
|
+
to manage concurrent client connections and coordinate resource allocation.
|
|
7
|
+
|
|
8
|
+
Key concepts:
|
|
9
|
+
- ConnectionState: Server-side state for a single client connection
|
|
10
|
+
- PlatformSlot: A platform-specific resource slot (e.g., esp32s3, esp32c6)
|
|
11
|
+
- ConnectionRegistry: Thread-safe registry managing all connections and slots
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ConnectionState:
|
|
25
|
+
"""Server-side state for a single client connection.
|
|
26
|
+
|
|
27
|
+
This dataclass tracks the state of a connected client as seen by the daemon.
|
|
28
|
+
Each connection has a unique UUID and tracks project/environment context,
|
|
29
|
+
heartbeat status, and any held platform slots.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
connection_id: UUID for this connection
|
|
33
|
+
project_dir: Client's project directory
|
|
34
|
+
environment: Build environment (e.g., "esp32dev", "uno")
|
|
35
|
+
platform: Target platform (e.g., "esp32s3", "esp32c6", "uno")
|
|
36
|
+
connected_at: Connection timestamp (Unix time)
|
|
37
|
+
last_heartbeat: Last heartbeat received (Unix time)
|
|
38
|
+
firmware_uuid: UUID of current firmware (if deployed)
|
|
39
|
+
slot_held: Platform slot currently held (if any)
|
|
40
|
+
client_pid: Client process ID
|
|
41
|
+
client_hostname: Client hostname
|
|
42
|
+
client_version: Client version string
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
connection_id: str
|
|
46
|
+
project_dir: str
|
|
47
|
+
environment: str
|
|
48
|
+
platform: str
|
|
49
|
+
connected_at: float
|
|
50
|
+
last_heartbeat: float
|
|
51
|
+
firmware_uuid: str | None
|
|
52
|
+
slot_held: str | None
|
|
53
|
+
client_pid: int
|
|
54
|
+
client_hostname: str
|
|
55
|
+
client_version: str
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
"""Convert to dictionary for JSON serialization."""
|
|
59
|
+
return asdict(self)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_dict(cls, data: dict[str, Any]) -> "ConnectionState":
|
|
63
|
+
"""Create ConnectionState from dictionary."""
|
|
64
|
+
return cls(
|
|
65
|
+
connection_id=data["connection_id"],
|
|
66
|
+
project_dir=data["project_dir"],
|
|
67
|
+
environment=data["environment"],
|
|
68
|
+
platform=data["platform"],
|
|
69
|
+
connected_at=data["connected_at"],
|
|
70
|
+
last_heartbeat=data["last_heartbeat"],
|
|
71
|
+
firmware_uuid=data.get("firmware_uuid"),
|
|
72
|
+
slot_held=data.get("slot_held"),
|
|
73
|
+
client_pid=data["client_pid"],
|
|
74
|
+
client_hostname=data["client_hostname"],
|
|
75
|
+
client_version=data["client_version"],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def is_stale(self, timeout_seconds: float = 30.0) -> bool:
|
|
79
|
+
"""Check if this connection has missed heartbeats.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
timeout_seconds: Maximum allowed time since last heartbeat
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if the connection is stale (heartbeat timeout exceeded)
|
|
86
|
+
"""
|
|
87
|
+
return (time.time() - self.last_heartbeat) > timeout_seconds
|
|
88
|
+
|
|
89
|
+
def get_age_seconds(self) -> float:
|
|
90
|
+
"""Get how long this connection has been active.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Connection age in seconds
|
|
94
|
+
"""
|
|
95
|
+
return time.time() - self.connected_at
|
|
96
|
+
|
|
97
|
+
def get_idle_seconds(self) -> float:
|
|
98
|
+
"""Get how long since last heartbeat.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Seconds since last heartbeat
|
|
102
|
+
"""
|
|
103
|
+
return time.time() - self.last_heartbeat
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class PlatformSlot:
|
|
108
|
+
"""A platform slot on the daemon (e.g., esp32s3, esp32c6, uno).
|
|
109
|
+
|
|
110
|
+
Platform slots represent exclusive access to build/deploy for a specific
|
|
111
|
+
platform. Only one connection can hold a slot at a time, ensuring that
|
|
112
|
+
concurrent operations on the same platform are serialized.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
platform: Platform identifier (e.g., "esp32s3", "esp32c6", "uno")
|
|
116
|
+
current_connection_id: UUID of connection holding slot (None if free)
|
|
117
|
+
current_firmware_uuid: UUID of deployed firmware (None if none)
|
|
118
|
+
last_build_hash: Hash of last successful build (for incremental builds)
|
|
119
|
+
locked_at: Timestamp when slot was acquired (None if free)
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
platform: str
|
|
123
|
+
current_connection_id: str | None = None
|
|
124
|
+
current_firmware_uuid: str | None = None
|
|
125
|
+
last_build_hash: str | None = None
|
|
126
|
+
locked_at: float | None = None
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict[str, Any]:
|
|
129
|
+
"""Convert to dictionary for JSON serialization."""
|
|
130
|
+
return asdict(self)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_dict(cls, data: dict[str, Any]) -> "PlatformSlot":
|
|
134
|
+
"""Create PlatformSlot from dictionary."""
|
|
135
|
+
return cls(
|
|
136
|
+
platform=data["platform"],
|
|
137
|
+
current_connection_id=data.get("current_connection_id"),
|
|
138
|
+
current_firmware_uuid=data.get("current_firmware_uuid"),
|
|
139
|
+
last_build_hash=data.get("last_build_hash"),
|
|
140
|
+
locked_at=data.get("locked_at"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def is_free(self) -> bool:
|
|
144
|
+
"""Check if this slot is available.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if no connection currently holds this slot
|
|
148
|
+
"""
|
|
149
|
+
return self.current_connection_id is None
|
|
150
|
+
|
|
151
|
+
def is_held_by(self, connection_id: str) -> bool:
|
|
152
|
+
"""Check if this slot is held by a specific connection.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
connection_id: Connection UUID to check
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if the specified connection holds this slot
|
|
159
|
+
"""
|
|
160
|
+
return self.current_connection_id == connection_id
|
|
161
|
+
|
|
162
|
+
def get_lock_duration(self) -> float | None:
|
|
163
|
+
"""Get how long this slot has been locked.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Lock duration in seconds, or None if slot is free
|
|
167
|
+
"""
|
|
168
|
+
if self.locked_at is None:
|
|
169
|
+
return None
|
|
170
|
+
return time.time() - self.locked_at
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ConnectionRegistry:
|
|
174
|
+
"""Server-side registry of all active client connections.
|
|
175
|
+
|
|
176
|
+
This class manages the state of all connected clients and their platform
|
|
177
|
+
slot assignments. It is thread-safe and uses a single lock for all
|
|
178
|
+
mutations to ensure consistency.
|
|
179
|
+
|
|
180
|
+
The registry supports:
|
|
181
|
+
- Connection lifecycle (register, unregister, heartbeat)
|
|
182
|
+
- Platform slot acquisition and release
|
|
183
|
+
- Stale connection detection and cleanup
|
|
184
|
+
- Firmware UUID tracking per connection
|
|
185
|
+
|
|
186
|
+
Typical usage:
|
|
187
|
+
registry = ConnectionRegistry(heartbeat_timeout=30.0)
|
|
188
|
+
|
|
189
|
+
# Register a new connection
|
|
190
|
+
state = registry.register_connection(
|
|
191
|
+
connection_id="uuid-1234",
|
|
192
|
+
project_dir="/path/to/project",
|
|
193
|
+
environment="esp32dev",
|
|
194
|
+
platform="esp32s3",
|
|
195
|
+
client_pid=12345,
|
|
196
|
+
client_hostname="localhost",
|
|
197
|
+
client_version="1.2.11"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Acquire platform slot
|
|
201
|
+
if registry.acquire_slot("uuid-1234", "esp32s3"):
|
|
202
|
+
# Do work...
|
|
203
|
+
registry.release_slot("uuid-1234")
|
|
204
|
+
|
|
205
|
+
# Cleanup on disconnect
|
|
206
|
+
registry.unregister_connection("uuid-1234")
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, heartbeat_timeout: float = 30.0) -> None:
|
|
210
|
+
"""Initialize the connection registry.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
heartbeat_timeout: Maximum seconds allowed between heartbeats
|
|
214
|
+
before a connection is considered stale. Default is 30 seconds.
|
|
215
|
+
"""
|
|
216
|
+
self._lock = threading.Lock()
|
|
217
|
+
self._heartbeat_timeout = heartbeat_timeout
|
|
218
|
+
self._connections: dict[str, ConnectionState] = {}
|
|
219
|
+
self._platform_slots: dict[str, PlatformSlot] = {}
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def connections(self) -> dict[str, ConnectionState]:
|
|
223
|
+
"""Get a copy of the connections dictionary.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Copy of connection_id -> ConnectionState mapping
|
|
227
|
+
"""
|
|
228
|
+
with self._lock:
|
|
229
|
+
return dict(self._connections)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def platform_slots(self) -> dict[str, PlatformSlot]:
|
|
233
|
+
"""Get a copy of the platform slots dictionary.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Copy of platform -> PlatformSlot mapping
|
|
237
|
+
"""
|
|
238
|
+
with self._lock:
|
|
239
|
+
return dict(self._platform_slots)
|
|
240
|
+
|
|
241
|
+
def register_connection(
|
|
242
|
+
self,
|
|
243
|
+
connection_id: str,
|
|
244
|
+
project_dir: str,
|
|
245
|
+
environment: str,
|
|
246
|
+
platform: str,
|
|
247
|
+
client_pid: int,
|
|
248
|
+
client_hostname: str,
|
|
249
|
+
client_version: str,
|
|
250
|
+
) -> ConnectionState:
|
|
251
|
+
"""Register a new client connection.
|
|
252
|
+
|
|
253
|
+
Creates a new ConnectionState for the client and adds it to the registry.
|
|
254
|
+
If a connection with the same ID already exists, it will be replaced.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
connection_id: Unique UUID for this connection
|
|
258
|
+
project_dir: Client's project directory
|
|
259
|
+
environment: Build environment name
|
|
260
|
+
platform: Target platform (e.g., "esp32s3")
|
|
261
|
+
client_pid: Client process ID
|
|
262
|
+
client_hostname: Client hostname
|
|
263
|
+
client_version: Client version string
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
The newly created ConnectionState
|
|
267
|
+
"""
|
|
268
|
+
now = time.time()
|
|
269
|
+
state = ConnectionState(
|
|
270
|
+
connection_id=connection_id,
|
|
271
|
+
project_dir=project_dir,
|
|
272
|
+
environment=environment,
|
|
273
|
+
platform=platform,
|
|
274
|
+
connected_at=now,
|
|
275
|
+
last_heartbeat=now,
|
|
276
|
+
firmware_uuid=None,
|
|
277
|
+
slot_held=None,
|
|
278
|
+
client_pid=client_pid,
|
|
279
|
+
client_hostname=client_hostname,
|
|
280
|
+
client_version=client_version,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
with self._lock:
|
|
284
|
+
# If connection already exists, clean up any held resources first
|
|
285
|
+
if connection_id in self._connections:
|
|
286
|
+
logger.warning(f"Re-registering existing connection {connection_id}, cleaning up old state")
|
|
287
|
+
self._release_slot_unlocked(connection_id)
|
|
288
|
+
|
|
289
|
+
self._connections[connection_id] = state
|
|
290
|
+
logger.info(f"Registered connection {connection_id} from {client_hostname} (pid={client_pid})")
|
|
291
|
+
|
|
292
|
+
return state
|
|
293
|
+
|
|
294
|
+
def unregister_connection(self, connection_id: str) -> bool:
|
|
295
|
+
"""Unregister a client connection.
|
|
296
|
+
|
|
297
|
+
Removes the connection from the registry and releases any held slots.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
connection_id: UUID of the connection to unregister
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
True if the connection was found and removed, False if not found
|
|
304
|
+
"""
|
|
305
|
+
with self._lock:
|
|
306
|
+
if connection_id not in self._connections:
|
|
307
|
+
logger.warning(f"Attempted to unregister unknown connection {connection_id}")
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# Release any held slot
|
|
311
|
+
self._release_slot_unlocked(connection_id)
|
|
312
|
+
|
|
313
|
+
# Remove the connection
|
|
314
|
+
state = self._connections.pop(connection_id)
|
|
315
|
+
logger.info(f"Unregistered connection {connection_id} (was connected for {state.get_age_seconds():.1f}s)")
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def update_heartbeat(self, connection_id: str) -> bool:
|
|
319
|
+
"""Update the heartbeat timestamp for a connection.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
connection_id: UUID of the connection to update
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
True if the connection was found and updated, False if not found
|
|
326
|
+
"""
|
|
327
|
+
with self._lock:
|
|
328
|
+
if connection_id not in self._connections:
|
|
329
|
+
logger.debug(f"Heartbeat for unknown connection {connection_id}")
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
self._connections[connection_id].last_heartbeat = time.time()
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def check_stale_connections(self) -> list[str]:
|
|
336
|
+
"""Check for connections that have missed heartbeats.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
List of connection IDs that are stale (heartbeat timeout exceeded)
|
|
340
|
+
"""
|
|
341
|
+
stale_ids: list[str] = []
|
|
342
|
+
now = time.time()
|
|
343
|
+
|
|
344
|
+
with self._lock:
|
|
345
|
+
for conn_id, state in self._connections.items():
|
|
346
|
+
if (now - state.last_heartbeat) > self._heartbeat_timeout:
|
|
347
|
+
stale_ids.append(conn_id)
|
|
348
|
+
|
|
349
|
+
return stale_ids
|
|
350
|
+
|
|
351
|
+
def cleanup_stale_connections(self) -> int:
|
|
352
|
+
"""Clean up all stale connections.
|
|
353
|
+
|
|
354
|
+
Removes connections that have exceeded the heartbeat timeout and
|
|
355
|
+
releases any slots they were holding.
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Number of connections that were cleaned up
|
|
359
|
+
"""
|
|
360
|
+
stale_ids = self.check_stale_connections()
|
|
361
|
+
|
|
362
|
+
for conn_id in stale_ids:
|
|
363
|
+
with self._lock:
|
|
364
|
+
if conn_id in self._connections:
|
|
365
|
+
state = self._connections[conn_id]
|
|
366
|
+
idle_time = state.get_idle_seconds()
|
|
367
|
+
logger.warning(f"Cleaning up stale connection {conn_id} (idle for {idle_time:.1f}s)")
|
|
368
|
+
self._release_slot_unlocked(conn_id)
|
|
369
|
+
self._connections.pop(conn_id, None)
|
|
370
|
+
|
|
371
|
+
if stale_ids:
|
|
372
|
+
logger.info(f"Cleaned up {len(stale_ids)} stale connection(s)")
|
|
373
|
+
|
|
374
|
+
return len(stale_ids)
|
|
375
|
+
|
|
376
|
+
def acquire_slot(self, connection_id: str, platform: str) -> bool:
|
|
377
|
+
"""Acquire a platform slot for a connection.
|
|
378
|
+
|
|
379
|
+
Attempts to acquire exclusive access to a platform slot. If the slot
|
|
380
|
+
is already held by another connection, this will fail.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
connection_id: UUID of the connection requesting the slot
|
|
384
|
+
platform: Platform to acquire (e.g., "esp32s3")
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
True if the slot was acquired, False if unavailable or error
|
|
388
|
+
"""
|
|
389
|
+
with self._lock:
|
|
390
|
+
# Verify connection exists
|
|
391
|
+
if connection_id not in self._connections:
|
|
392
|
+
logger.warning(f"Cannot acquire slot for unknown connection {connection_id}")
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
state = self._connections[connection_id]
|
|
396
|
+
|
|
397
|
+
# Check if connection already holds a slot
|
|
398
|
+
if state.slot_held is not None:
|
|
399
|
+
if state.slot_held == platform:
|
|
400
|
+
# Already holds this slot
|
|
401
|
+
logger.debug(f"Connection {connection_id} already holds slot {platform}")
|
|
402
|
+
return True
|
|
403
|
+
else:
|
|
404
|
+
# Holds a different slot - must release first
|
|
405
|
+
logger.warning(f"Connection {connection_id} already holds slot {state.slot_held}, cannot acquire {platform}")
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
# Get or create the platform slot
|
|
409
|
+
if platform not in self._platform_slots:
|
|
410
|
+
self._platform_slots[platform] = PlatformSlot(platform=platform)
|
|
411
|
+
|
|
412
|
+
slot = self._platform_slots[platform]
|
|
413
|
+
|
|
414
|
+
# Check if slot is available
|
|
415
|
+
if slot.current_connection_id is not None and slot.current_connection_id != connection_id:
|
|
416
|
+
logger.debug(f"Slot {platform} is held by {slot.current_connection_id}, cannot acquire for {connection_id}")
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
# Acquire the slot
|
|
420
|
+
slot.current_connection_id = connection_id
|
|
421
|
+
slot.locked_at = time.time()
|
|
422
|
+
state.slot_held = platform
|
|
423
|
+
|
|
424
|
+
logger.info(f"Connection {connection_id} acquired slot {platform}")
|
|
425
|
+
return True
|
|
426
|
+
|
|
427
|
+
def release_slot(self, connection_id: str) -> bool:
|
|
428
|
+
"""Release the platform slot held by a connection.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
connection_id: UUID of the connection releasing its slot
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
True if a slot was released, False if no slot was held
|
|
435
|
+
"""
|
|
436
|
+
with self._lock:
|
|
437
|
+
return self._release_slot_unlocked(connection_id)
|
|
438
|
+
|
|
439
|
+
def _release_slot_unlocked(self, connection_id: str) -> bool:
|
|
440
|
+
"""Internal: Release slot without acquiring lock.
|
|
441
|
+
|
|
442
|
+
Must be called while holding self._lock.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
connection_id: UUID of the connection
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
True if a slot was released
|
|
449
|
+
"""
|
|
450
|
+
if connection_id not in self._connections:
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
state = self._connections[connection_id]
|
|
454
|
+
|
|
455
|
+
if state.slot_held is None:
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
platform = state.slot_held
|
|
459
|
+
if platform in self._platform_slots:
|
|
460
|
+
slot = self._platform_slots[platform]
|
|
461
|
+
if slot.current_connection_id == connection_id:
|
|
462
|
+
slot.current_connection_id = None
|
|
463
|
+
slot.locked_at = None
|
|
464
|
+
logger.info(f"Connection {connection_id} released slot {platform}")
|
|
465
|
+
|
|
466
|
+
state.slot_held = None
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
def set_firmware_uuid(self, connection_id: str, firmware_uuid: str) -> bool:
|
|
470
|
+
"""Set the firmware UUID for a connection.
|
|
471
|
+
|
|
472
|
+
Also updates the platform slot's firmware UUID if the connection
|
|
473
|
+
holds a slot.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
connection_id: UUID of the connection
|
|
477
|
+
firmware_uuid: UUID of the deployed firmware
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
True if the connection was found and updated, False if not found
|
|
481
|
+
"""
|
|
482
|
+
with self._lock:
|
|
483
|
+
if connection_id not in self._connections:
|
|
484
|
+
logger.warning(f"Cannot set firmware UUID for unknown connection {connection_id}")
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
state = self._connections[connection_id]
|
|
488
|
+
state.firmware_uuid = firmware_uuid
|
|
489
|
+
|
|
490
|
+
# Also update the platform slot if held
|
|
491
|
+
if state.slot_held and state.slot_held in self._platform_slots:
|
|
492
|
+
self._platform_slots[state.slot_held].current_firmware_uuid = firmware_uuid
|
|
493
|
+
logger.debug(f"Updated firmware UUID for slot {state.slot_held}: {firmware_uuid}")
|
|
494
|
+
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
def get_connection(self, connection_id: str) -> ConnectionState | None:
|
|
498
|
+
"""Get the state of a specific connection.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
connection_id: UUID of the connection to retrieve
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
ConnectionState if found, None otherwise
|
|
505
|
+
"""
|
|
506
|
+
with self._lock:
|
|
507
|
+
return self._connections.get(connection_id)
|
|
508
|
+
|
|
509
|
+
def get_all_connections(self) -> list[ConnectionState]:
|
|
510
|
+
"""Get all active connections.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
List of all ConnectionState objects
|
|
514
|
+
"""
|
|
515
|
+
with self._lock:
|
|
516
|
+
return list(self._connections.values())
|
|
517
|
+
|
|
518
|
+
def get_slot_status(self, platform: str) -> PlatformSlot | None:
|
|
519
|
+
"""Get the status of a specific platform slot.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
platform: Platform to query (e.g., "esp32s3")
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
PlatformSlot if it exists, None otherwise
|
|
526
|
+
"""
|
|
527
|
+
with self._lock:
|
|
528
|
+
return self._platform_slots.get(platform)
|
|
529
|
+
|
|
530
|
+
def get_all_slots(self) -> dict[str, PlatformSlot]:
|
|
531
|
+
"""Get all platform slots.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
Copy of platform -> PlatformSlot mapping
|
|
535
|
+
"""
|
|
536
|
+
with self._lock:
|
|
537
|
+
return dict(self._platform_slots)
|
|
538
|
+
|
|
539
|
+
def release_all_client_resources(self, connection_id: str) -> None:
|
|
540
|
+
"""Release all resources held by a client connection.
|
|
541
|
+
|
|
542
|
+
This is called during graceful disconnect or stale connection cleanup.
|
|
543
|
+
It releases any held slots and performs any necessary cleanup.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
connection_id: UUID of the connection to clean up
|
|
547
|
+
"""
|
|
548
|
+
with self._lock:
|
|
549
|
+
if connection_id not in self._connections:
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
# Release slot if held
|
|
553
|
+
self._release_slot_unlocked(connection_id)
|
|
554
|
+
|
|
555
|
+
# Clear firmware UUID
|
|
556
|
+
state = self._connections[connection_id]
|
|
557
|
+
state.firmware_uuid = None
|
|
558
|
+
|
|
559
|
+
logger.info(f"Released all resources for connection {connection_id}")
|
|
560
|
+
|
|
561
|
+
def to_dict(self) -> dict[str, Any]:
|
|
562
|
+
"""Convert registry state to dictionary for status reporting.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
Dictionary containing:
|
|
566
|
+
- connections: List of connection state dicts
|
|
567
|
+
- platform_slots: Dict of platform -> slot state dicts
|
|
568
|
+
- connection_count: Number of active connections
|
|
569
|
+
- slot_count: Number of platform slots
|
|
570
|
+
- heartbeat_timeout: Configured heartbeat timeout
|
|
571
|
+
"""
|
|
572
|
+
with self._lock:
|
|
573
|
+
return {
|
|
574
|
+
"connections": [state.to_dict() for state in self._connections.values()],
|
|
575
|
+
"platform_slots": {platform: slot.to_dict() for platform, slot in self._platform_slots.items()},
|
|
576
|
+
"connection_count": len(self._connections),
|
|
577
|
+
"slot_count": len(self._platform_slots),
|
|
578
|
+
"heartbeat_timeout": self._heartbeat_timeout,
|
|
579
|
+
}
|