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,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)
|