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,1543 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Client Library for fbuild daemon.
|
|
3
|
+
|
|
4
|
+
This module provides asynchronous client classes for connecting to the fbuild daemon's
|
|
5
|
+
async server. It supports:
|
|
6
|
+
|
|
7
|
+
- Asyncio-based connection management
|
|
8
|
+
- Automatic reconnection with exponential backoff
|
|
9
|
+
- Request/response correlation with timeouts
|
|
10
|
+
- Event callback system for broadcasts
|
|
11
|
+
- Subscription management for events
|
|
12
|
+
- Both async and sync usage patterns
|
|
13
|
+
|
|
14
|
+
Example usage (async):
|
|
15
|
+
>>> async def main():
|
|
16
|
+
... client = AsyncDaemonClient()
|
|
17
|
+
... await client.connect("localhost", 8765)
|
|
18
|
+
... result = await client.acquire_lock("/project", "esp32", "/dev/ttyUSB0")
|
|
19
|
+
... print(f"Lock acquired: {result}")
|
|
20
|
+
... await client.disconnect()
|
|
21
|
+
|
|
22
|
+
Example usage (sync):
|
|
23
|
+
>>> client = SyncDaemonClient()
|
|
24
|
+
>>> client.connect("localhost", 8765)
|
|
25
|
+
>>> result = client.acquire_lock("/project", "esp32", "/dev/ttyUSB0")
|
|
26
|
+
>>> print(f"Lock acquired: {result}")
|
|
27
|
+
>>> client.disconnect()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import base64
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import socket
|
|
38
|
+
import time
|
|
39
|
+
import uuid
|
|
40
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from enum import Enum
|
|
43
|
+
from typing import Any, Callable, Coroutine
|
|
44
|
+
|
|
45
|
+
# Default configuration
|
|
46
|
+
DEFAULT_HOST = "localhost"
|
|
47
|
+
DEFAULT_PORT = 9876 # Must match async_server.py DEFAULT_PORT
|
|
48
|
+
DEFAULT_REQUEST_TIMEOUT = 30.0
|
|
49
|
+
DEFAULT_HEARTBEAT_INTERVAL = 10.0
|
|
50
|
+
DEFAULT_RECONNECT_DELAY = 1.0
|
|
51
|
+
DEFAULT_MAX_RECONNECT_DELAY = 60.0
|
|
52
|
+
DEFAULT_RECONNECT_BACKOFF_FACTOR = 2.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConnectionState(Enum):
|
|
56
|
+
"""Connection state enumeration."""
|
|
57
|
+
|
|
58
|
+
DISCONNECTED = "disconnected"
|
|
59
|
+
CONNECTING = "connecting"
|
|
60
|
+
CONNECTED = "connected"
|
|
61
|
+
RECONNECTING = "reconnecting"
|
|
62
|
+
CLOSED = "closed"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MessageType(Enum):
|
|
66
|
+
"""Message types for client-daemon communication."""
|
|
67
|
+
|
|
68
|
+
# Client connection management
|
|
69
|
+
CLIENT_CONNECT = "client_connect"
|
|
70
|
+
CLIENT_HEARTBEAT = "client_heartbeat"
|
|
71
|
+
CLIENT_DISCONNECT = "client_disconnect"
|
|
72
|
+
|
|
73
|
+
# Lock management
|
|
74
|
+
LOCK_ACQUIRE = "lock_acquire"
|
|
75
|
+
LOCK_RELEASE = "lock_release"
|
|
76
|
+
LOCK_STATUS = "lock_status"
|
|
77
|
+
LOCK_SUBSCRIBE = "lock_subscribe"
|
|
78
|
+
LOCK_UNSUBSCRIBE = "lock_unsubscribe"
|
|
79
|
+
|
|
80
|
+
# Firmware queries
|
|
81
|
+
FIRMWARE_QUERY = "firmware_query"
|
|
82
|
+
FIRMWARE_SUBSCRIBE = "firmware_subscribe"
|
|
83
|
+
FIRMWARE_UNSUBSCRIBE = "firmware_unsubscribe"
|
|
84
|
+
|
|
85
|
+
# Serial session management
|
|
86
|
+
SERIAL_ATTACH = "serial_attach"
|
|
87
|
+
SERIAL_DETACH = "serial_detach"
|
|
88
|
+
SERIAL_ACQUIRE_WRITER = "serial_acquire_writer"
|
|
89
|
+
SERIAL_RELEASE_WRITER = "serial_release_writer"
|
|
90
|
+
SERIAL_WRITE = "serial_write"
|
|
91
|
+
SERIAL_READ_BUFFER = "serial_read_buffer"
|
|
92
|
+
SERIAL_SUBSCRIBE = "serial_subscribe"
|
|
93
|
+
SERIAL_UNSUBSCRIBE = "serial_unsubscribe"
|
|
94
|
+
|
|
95
|
+
# Response and broadcast types
|
|
96
|
+
RESPONSE = "response"
|
|
97
|
+
BROADCAST = "broadcast"
|
|
98
|
+
ERROR = "error"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class PendingRequest:
|
|
103
|
+
"""Tracks a pending request awaiting response.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
request_id: Unique identifier for the request
|
|
107
|
+
message_type: Type of the request
|
|
108
|
+
future: Future to resolve when response arrives
|
|
109
|
+
timeout: Request timeout in seconds
|
|
110
|
+
created_at: Timestamp when request was created
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
request_id: str
|
|
114
|
+
message_type: MessageType
|
|
115
|
+
future: asyncio.Future[dict[str, Any]]
|
|
116
|
+
timeout: float
|
|
117
|
+
created_at: float = field(default_factory=time.time)
|
|
118
|
+
|
|
119
|
+
def is_expired(self) -> bool:
|
|
120
|
+
"""Check if request has timed out."""
|
|
121
|
+
return (time.time() - self.created_at) > self.timeout
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Subscription:
|
|
126
|
+
"""Tracks an active subscription.
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
subscription_id: Unique identifier for the subscription
|
|
130
|
+
event_type: Type of events being subscribed to
|
|
131
|
+
callback: Function to call when event is received
|
|
132
|
+
filter_key: Optional key to filter events (e.g., port name)
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
subscription_id: str
|
|
136
|
+
event_type: str
|
|
137
|
+
callback: Callable[[dict[str, Any]], None] | Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
|
|
138
|
+
filter_key: str | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DaemonClientError(Exception):
|
|
142
|
+
"""Base exception for daemon client errors."""
|
|
143
|
+
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ConnectionError(DaemonClientError):
|
|
148
|
+
"""Error connecting to daemon."""
|
|
149
|
+
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TimeoutError(DaemonClientError):
|
|
154
|
+
"""Request timeout error."""
|
|
155
|
+
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ProtocolError(DaemonClientError):
|
|
160
|
+
"""Protocol/message format error."""
|
|
161
|
+
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class AsyncDaemonClient:
|
|
166
|
+
"""Asynchronous client for connecting to the fbuild daemon.
|
|
167
|
+
|
|
168
|
+
This class provides a high-level async API for interacting with the daemon,
|
|
169
|
+
including connection management, request/response handling, and event subscriptions.
|
|
170
|
+
|
|
171
|
+
Features:
|
|
172
|
+
- Uses asyncio streams (asyncio.open_connection)
|
|
173
|
+
- Automatic reconnection with exponential backoff
|
|
174
|
+
- Heartbeat sending (configurable interval, default 10 seconds)
|
|
175
|
+
- Pending request tracking with timeouts
|
|
176
|
+
- Event callback system for broadcasts
|
|
177
|
+
- Thread-safe for use from sync code
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
>>> async with AsyncDaemonClient() as client:
|
|
181
|
+
... await client.connect("localhost", 8765)
|
|
182
|
+
... lock_acquired = await client.acquire_lock(
|
|
183
|
+
... project_dir="/path/to/project",
|
|
184
|
+
... environment="esp32",
|
|
185
|
+
... port="/dev/ttyUSB0"
|
|
186
|
+
... )
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
client_id: str | None = None,
|
|
192
|
+
heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL,
|
|
193
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
194
|
+
auto_reconnect: bool = True,
|
|
195
|
+
reconnect_delay: float = DEFAULT_RECONNECT_DELAY,
|
|
196
|
+
max_reconnect_delay: float = DEFAULT_MAX_RECONNECT_DELAY,
|
|
197
|
+
reconnect_backoff_factor: float = DEFAULT_RECONNECT_BACKOFF_FACTOR,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Initialize the async daemon client.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
client_id: Unique client identifier (auto-generated if None)
|
|
203
|
+
heartbeat_interval: Interval between heartbeats in seconds
|
|
204
|
+
request_timeout: Default timeout for requests in seconds
|
|
205
|
+
auto_reconnect: Whether to automatically reconnect on disconnect
|
|
206
|
+
reconnect_delay: Initial delay before reconnecting in seconds
|
|
207
|
+
max_reconnect_delay: Maximum delay between reconnect attempts
|
|
208
|
+
reconnect_backoff_factor: Factor to multiply delay on each retry
|
|
209
|
+
"""
|
|
210
|
+
self._client_id = client_id or str(uuid.uuid4())
|
|
211
|
+
self._heartbeat_interval = heartbeat_interval
|
|
212
|
+
self._request_timeout = request_timeout
|
|
213
|
+
self._auto_reconnect = auto_reconnect
|
|
214
|
+
self._reconnect_delay = reconnect_delay
|
|
215
|
+
self._max_reconnect_delay = max_reconnect_delay
|
|
216
|
+
self._reconnect_backoff_factor = reconnect_backoff_factor
|
|
217
|
+
|
|
218
|
+
# Connection state
|
|
219
|
+
self._state = ConnectionState.DISCONNECTED
|
|
220
|
+
self._host: str | None = None
|
|
221
|
+
self._port: int | None = None
|
|
222
|
+
self._reader: asyncio.StreamReader | None = None
|
|
223
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
224
|
+
|
|
225
|
+
# Request tracking
|
|
226
|
+
self._pending_requests: dict[str, PendingRequest] = {}
|
|
227
|
+
self._request_id_counter = 0
|
|
228
|
+
|
|
229
|
+
# Subscriptions
|
|
230
|
+
self._subscriptions: dict[str, Subscription] = {}
|
|
231
|
+
|
|
232
|
+
# Tasks
|
|
233
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
234
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
235
|
+
self._timeout_checker_task: asyncio.Task[None] | None = None
|
|
236
|
+
|
|
237
|
+
# Event loop reference (for thread-safe operations)
|
|
238
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
239
|
+
|
|
240
|
+
# Shutdown flag
|
|
241
|
+
self._shutdown_requested = False
|
|
242
|
+
|
|
243
|
+
# Logger
|
|
244
|
+
self._logger = logging.getLogger(f"AsyncDaemonClient[{self._client_id[:8]}]")
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def client_id(self) -> str:
|
|
248
|
+
"""Get the client ID."""
|
|
249
|
+
return self._client_id
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def state(self) -> ConnectionState:
|
|
253
|
+
"""Get the current connection state."""
|
|
254
|
+
return self._state
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def is_connected(self) -> bool:
|
|
258
|
+
"""Check if client is connected."""
|
|
259
|
+
return self._state == ConnectionState.CONNECTED
|
|
260
|
+
|
|
261
|
+
async def __aenter__(self) -> "AsyncDaemonClient":
|
|
262
|
+
"""Async context manager entry."""
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
async def __aexit__(
|
|
266
|
+
self,
|
|
267
|
+
exc_type: type | None, # noqa: ARG002
|
|
268
|
+
exc_val: Exception | None, # noqa: ARG002
|
|
269
|
+
exc_tb: Any, # noqa: ARG002
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Async context manager exit."""
|
|
272
|
+
await self.disconnect()
|
|
273
|
+
|
|
274
|
+
async def connect(
|
|
275
|
+
self,
|
|
276
|
+
host: str = DEFAULT_HOST,
|
|
277
|
+
port: int = DEFAULT_PORT,
|
|
278
|
+
timeout: float = 10.0,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Connect to the daemon server.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
host: Daemon host address
|
|
284
|
+
port: Daemon port number
|
|
285
|
+
timeout: Connection timeout in seconds
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ConnectionError: If connection fails
|
|
289
|
+
"""
|
|
290
|
+
if self._state == ConnectionState.CONNECTED:
|
|
291
|
+
self._logger.warning("Already connected, disconnecting first")
|
|
292
|
+
await self.disconnect()
|
|
293
|
+
|
|
294
|
+
self._host = host
|
|
295
|
+
self._port = port
|
|
296
|
+
self._state = ConnectionState.CONNECTING
|
|
297
|
+
self._shutdown_requested = False
|
|
298
|
+
self._loop = asyncio.get_event_loop()
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
self._logger.info(f"Connecting to daemon at {host}:{port}")
|
|
302
|
+
|
|
303
|
+
# Open connection with timeout
|
|
304
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
305
|
+
asyncio.open_connection(host, port),
|
|
306
|
+
timeout=timeout,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Send client connect message
|
|
310
|
+
await self._send_client_connect()
|
|
311
|
+
|
|
312
|
+
# Start background tasks
|
|
313
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
314
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
315
|
+
self._timeout_checker_task = asyncio.create_task(self._timeout_checker_loop())
|
|
316
|
+
|
|
317
|
+
self._state = ConnectionState.CONNECTED
|
|
318
|
+
self._logger.info(f"Connected to daemon at {host}:{port}")
|
|
319
|
+
|
|
320
|
+
except asyncio.TimeoutError:
|
|
321
|
+
self._state = ConnectionState.DISCONNECTED
|
|
322
|
+
raise ConnectionError(f"Connection timeout connecting to {host}:{port}")
|
|
323
|
+
except OSError as e:
|
|
324
|
+
self._state = ConnectionState.DISCONNECTED
|
|
325
|
+
raise ConnectionError(f"Failed to connect to {host}:{port}: {e}")
|
|
326
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
327
|
+
self._state = ConnectionState.DISCONNECTED
|
|
328
|
+
raise
|
|
329
|
+
except Exception as e:
|
|
330
|
+
self._state = ConnectionState.DISCONNECTED
|
|
331
|
+
raise ConnectionError(f"Unexpected error connecting to {host}:{port}: {e}")
|
|
332
|
+
|
|
333
|
+
async def disconnect(self, reason: str = "client requested") -> None:
|
|
334
|
+
"""Disconnect from the daemon server.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
reason: Reason for disconnection (for logging)
|
|
338
|
+
"""
|
|
339
|
+
if self._state in (ConnectionState.DISCONNECTED, ConnectionState.CLOSED):
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
self._logger.info(f"Disconnecting: {reason}")
|
|
343
|
+
self._shutdown_requested = True
|
|
344
|
+
self._state = ConnectionState.CLOSED
|
|
345
|
+
|
|
346
|
+
# Send disconnect message (best effort)
|
|
347
|
+
try:
|
|
348
|
+
if self._writer and not self._writer.is_closing():
|
|
349
|
+
await self._send_client_disconnect(reason)
|
|
350
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
351
|
+
raise
|
|
352
|
+
except Exception as e:
|
|
353
|
+
self._logger.debug(f"Error sending disconnect message: {e}")
|
|
354
|
+
|
|
355
|
+
# Cancel background tasks
|
|
356
|
+
if self._read_task and not self._read_task.done():
|
|
357
|
+
self._read_task.cancel()
|
|
358
|
+
try:
|
|
359
|
+
await self._read_task
|
|
360
|
+
except asyncio.CancelledError:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
if self._heartbeat_task and not self._heartbeat_task.done():
|
|
364
|
+
self._heartbeat_task.cancel()
|
|
365
|
+
try:
|
|
366
|
+
await self._heartbeat_task
|
|
367
|
+
except asyncio.CancelledError:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
if self._timeout_checker_task and not self._timeout_checker_task.done():
|
|
371
|
+
self._timeout_checker_task.cancel()
|
|
372
|
+
try:
|
|
373
|
+
await self._timeout_checker_task
|
|
374
|
+
except asyncio.CancelledError:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# Close connection
|
|
378
|
+
if self._writer and not self._writer.is_closing():
|
|
379
|
+
self._writer.close()
|
|
380
|
+
try:
|
|
381
|
+
await self._writer.wait_closed()
|
|
382
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
383
|
+
raise
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
self._reader = None
|
|
388
|
+
self._writer = None
|
|
389
|
+
self._state = ConnectionState.DISCONNECTED
|
|
390
|
+
|
|
391
|
+
# Cancel pending requests
|
|
392
|
+
for request_id, pending in list(self._pending_requests.items()):
|
|
393
|
+
if not pending.future.done():
|
|
394
|
+
pending.future.set_exception(ConnectionError("Disconnected"))
|
|
395
|
+
del self._pending_requests[request_id]
|
|
396
|
+
|
|
397
|
+
self._logger.info("Disconnected from daemon")
|
|
398
|
+
|
|
399
|
+
async def wait_for_connection(self, timeout: float = 30.0) -> None:
|
|
400
|
+
"""Wait for the client to be connected.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
timeout: Maximum time to wait in seconds
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
TimeoutError: If connection not established within timeout
|
|
407
|
+
"""
|
|
408
|
+
start_time = time.time()
|
|
409
|
+
while not self.is_connected:
|
|
410
|
+
if time.time() - start_time > timeout:
|
|
411
|
+
raise TimeoutError(f"Connection not established within {timeout}s")
|
|
412
|
+
await asyncio.sleep(0.1)
|
|
413
|
+
|
|
414
|
+
# =========================================================================
|
|
415
|
+
# Lock Management
|
|
416
|
+
# =========================================================================
|
|
417
|
+
|
|
418
|
+
async def acquire_lock(
|
|
419
|
+
self,
|
|
420
|
+
project_dir: str,
|
|
421
|
+
environment: str,
|
|
422
|
+
port: str,
|
|
423
|
+
lock_type: str = "exclusive",
|
|
424
|
+
timeout: float = 300.0,
|
|
425
|
+
description: str = "",
|
|
426
|
+
) -> bool:
|
|
427
|
+
"""Acquire a configuration lock.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
project_dir: Absolute path to project directory
|
|
431
|
+
environment: Build environment name
|
|
432
|
+
port: Serial port for the configuration
|
|
433
|
+
lock_type: Type of lock ("exclusive" or "shared_read")
|
|
434
|
+
timeout: Maximum time to wait for the lock in seconds
|
|
435
|
+
description: Human-readable description of the operation
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
True if lock was acquired, False otherwise
|
|
439
|
+
"""
|
|
440
|
+
response = await self._send_request(
|
|
441
|
+
MessageType.LOCK_ACQUIRE,
|
|
442
|
+
{
|
|
443
|
+
"project_dir": project_dir,
|
|
444
|
+
"environment": environment,
|
|
445
|
+
"port": port,
|
|
446
|
+
"lock_type": lock_type,
|
|
447
|
+
"timeout": timeout,
|
|
448
|
+
"description": description,
|
|
449
|
+
},
|
|
450
|
+
timeout=timeout + 10.0, # Add buffer for response
|
|
451
|
+
)
|
|
452
|
+
return response.get("success", False)
|
|
453
|
+
|
|
454
|
+
async def release_lock(
|
|
455
|
+
self,
|
|
456
|
+
project_dir: str,
|
|
457
|
+
environment: str,
|
|
458
|
+
port: str,
|
|
459
|
+
) -> bool:
|
|
460
|
+
"""Release a configuration lock.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
project_dir: Absolute path to project directory
|
|
464
|
+
environment: Build environment name
|
|
465
|
+
port: Serial port for the configuration
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
True if lock was released, False otherwise
|
|
469
|
+
"""
|
|
470
|
+
response = await self._send_request(
|
|
471
|
+
MessageType.LOCK_RELEASE,
|
|
472
|
+
{
|
|
473
|
+
"project_dir": project_dir,
|
|
474
|
+
"environment": environment,
|
|
475
|
+
"port": port,
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
return response.get("success", False)
|
|
479
|
+
|
|
480
|
+
async def get_lock_status(
|
|
481
|
+
self,
|
|
482
|
+
project_dir: str,
|
|
483
|
+
environment: str,
|
|
484
|
+
port: str,
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
"""Get the status of a configuration lock.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
project_dir: Absolute path to project directory
|
|
490
|
+
environment: Build environment name
|
|
491
|
+
port: Serial port for the configuration
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Dictionary with lock status information
|
|
495
|
+
"""
|
|
496
|
+
return await self._send_request(
|
|
497
|
+
MessageType.LOCK_STATUS,
|
|
498
|
+
{
|
|
499
|
+
"project_dir": project_dir,
|
|
500
|
+
"environment": environment,
|
|
501
|
+
"port": port,
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
async def subscribe_lock_changes(
|
|
506
|
+
self,
|
|
507
|
+
callback: Callable[[dict[str, Any]], None] | Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
|
|
508
|
+
filter_key: str | None = None,
|
|
509
|
+
) -> str:
|
|
510
|
+
"""Subscribe to lock change events.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
callback: Function to call when lock changes occur
|
|
514
|
+
filter_key: Optional key to filter events (e.g., specific port)
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Subscription ID for later unsubscription
|
|
518
|
+
"""
|
|
519
|
+
subscription_id = str(uuid.uuid4())
|
|
520
|
+
subscription = Subscription(
|
|
521
|
+
subscription_id=subscription_id,
|
|
522
|
+
event_type="lock_change",
|
|
523
|
+
callback=callback,
|
|
524
|
+
filter_key=filter_key,
|
|
525
|
+
)
|
|
526
|
+
self._subscriptions[subscription_id] = subscription
|
|
527
|
+
|
|
528
|
+
await self._send_request(
|
|
529
|
+
MessageType.LOCK_SUBSCRIBE,
|
|
530
|
+
{
|
|
531
|
+
"subscription_id": subscription_id,
|
|
532
|
+
"filter_key": filter_key,
|
|
533
|
+
},
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
return subscription_id
|
|
537
|
+
|
|
538
|
+
async def unsubscribe_lock_changes(self, subscription_id: str) -> bool:
|
|
539
|
+
"""Unsubscribe from lock change events.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
subscription_id: Subscription ID returned from subscribe_lock_changes
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
True if unsubscribed successfully
|
|
546
|
+
"""
|
|
547
|
+
if subscription_id not in self._subscriptions:
|
|
548
|
+
return False
|
|
549
|
+
|
|
550
|
+
await self._send_request(
|
|
551
|
+
MessageType.LOCK_UNSUBSCRIBE,
|
|
552
|
+
{"subscription_id": subscription_id},
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
del self._subscriptions[subscription_id]
|
|
556
|
+
return True
|
|
557
|
+
|
|
558
|
+
# =========================================================================
|
|
559
|
+
# Firmware Queries
|
|
560
|
+
# =========================================================================
|
|
561
|
+
|
|
562
|
+
async def query_firmware(
|
|
563
|
+
self,
|
|
564
|
+
port: str,
|
|
565
|
+
source_hash: str,
|
|
566
|
+
build_flags_hash: str | None = None,
|
|
567
|
+
) -> dict[str, Any]:
|
|
568
|
+
"""Query if firmware is current on a device.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
port: Serial port of the device
|
|
572
|
+
source_hash: Hash of the source files
|
|
573
|
+
build_flags_hash: Hash of build flags (optional)
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Dictionary with firmware status:
|
|
577
|
+
- is_current: True if firmware matches
|
|
578
|
+
- needs_redeploy: True if source changed
|
|
579
|
+
- firmware_hash: Hash of deployed firmware
|
|
580
|
+
- project_dir: Project directory of deployed firmware
|
|
581
|
+
- environment: Environment of deployed firmware
|
|
582
|
+
- upload_timestamp: When firmware was last uploaded
|
|
583
|
+
"""
|
|
584
|
+
return await self._send_request(
|
|
585
|
+
MessageType.FIRMWARE_QUERY,
|
|
586
|
+
{
|
|
587
|
+
"port": port,
|
|
588
|
+
"source_hash": source_hash,
|
|
589
|
+
"build_flags_hash": build_flags_hash,
|
|
590
|
+
},
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
async def subscribe_firmware_changes(
|
|
594
|
+
self,
|
|
595
|
+
callback: Callable[[dict[str, Any]], None] | Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
|
|
596
|
+
port: str | None = None,
|
|
597
|
+
) -> str:
|
|
598
|
+
"""Subscribe to firmware change events.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
callback: Function to call when firmware changes
|
|
602
|
+
port: Optional port to filter events
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
Subscription ID for later unsubscription
|
|
606
|
+
"""
|
|
607
|
+
subscription_id = str(uuid.uuid4())
|
|
608
|
+
subscription = Subscription(
|
|
609
|
+
subscription_id=subscription_id,
|
|
610
|
+
event_type="firmware_change",
|
|
611
|
+
callback=callback,
|
|
612
|
+
filter_key=port,
|
|
613
|
+
)
|
|
614
|
+
self._subscriptions[subscription_id] = subscription
|
|
615
|
+
|
|
616
|
+
await self._send_request(
|
|
617
|
+
MessageType.FIRMWARE_SUBSCRIBE,
|
|
618
|
+
{
|
|
619
|
+
"subscription_id": subscription_id,
|
|
620
|
+
"port": port,
|
|
621
|
+
},
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
return subscription_id
|
|
625
|
+
|
|
626
|
+
async def unsubscribe_firmware_changes(self, subscription_id: str) -> bool:
|
|
627
|
+
"""Unsubscribe from firmware change events.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
subscription_id: Subscription ID from subscribe_firmware_changes
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
True if unsubscribed successfully
|
|
634
|
+
"""
|
|
635
|
+
if subscription_id not in self._subscriptions:
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
await self._send_request(
|
|
639
|
+
MessageType.FIRMWARE_UNSUBSCRIBE,
|
|
640
|
+
{"subscription_id": subscription_id},
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
del self._subscriptions[subscription_id]
|
|
644
|
+
return True
|
|
645
|
+
|
|
646
|
+
# =========================================================================
|
|
647
|
+
# Serial Session Management
|
|
648
|
+
# =========================================================================
|
|
649
|
+
|
|
650
|
+
async def attach_serial(
|
|
651
|
+
self,
|
|
652
|
+
port: str,
|
|
653
|
+
baud_rate: int = 115200,
|
|
654
|
+
as_reader: bool = True,
|
|
655
|
+
) -> bool:
|
|
656
|
+
"""Attach to a serial session.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
port: Serial port to attach to
|
|
660
|
+
baud_rate: Baud rate for the connection
|
|
661
|
+
as_reader: Whether to attach as reader (True) or open port (False)
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
True if attached successfully
|
|
665
|
+
"""
|
|
666
|
+
response = await self._send_request(
|
|
667
|
+
MessageType.SERIAL_ATTACH,
|
|
668
|
+
{
|
|
669
|
+
"port": port,
|
|
670
|
+
"baud_rate": baud_rate,
|
|
671
|
+
"as_reader": as_reader,
|
|
672
|
+
},
|
|
673
|
+
)
|
|
674
|
+
return response.get("success", False)
|
|
675
|
+
|
|
676
|
+
async def detach_serial(
|
|
677
|
+
self,
|
|
678
|
+
port: str,
|
|
679
|
+
close_port: bool = False,
|
|
680
|
+
) -> bool:
|
|
681
|
+
"""Detach from a serial session.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
port: Serial port to detach from
|
|
685
|
+
close_port: Whether to close port if last reader
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
True if detached successfully
|
|
689
|
+
"""
|
|
690
|
+
response = await self._send_request(
|
|
691
|
+
MessageType.SERIAL_DETACH,
|
|
692
|
+
{
|
|
693
|
+
"port": port,
|
|
694
|
+
"close_port": close_port,
|
|
695
|
+
},
|
|
696
|
+
)
|
|
697
|
+
return response.get("success", False)
|
|
698
|
+
|
|
699
|
+
async def acquire_writer(
|
|
700
|
+
self,
|
|
701
|
+
port: str,
|
|
702
|
+
timeout: float = 10.0,
|
|
703
|
+
) -> bool:
|
|
704
|
+
"""Acquire write access to a serial port.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
port: Serial port to acquire write access for
|
|
708
|
+
timeout: Maximum time to wait for access
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
True if write access acquired
|
|
712
|
+
"""
|
|
713
|
+
response = await self._send_request(
|
|
714
|
+
MessageType.SERIAL_ACQUIRE_WRITER,
|
|
715
|
+
{
|
|
716
|
+
"port": port,
|
|
717
|
+
"timeout": timeout,
|
|
718
|
+
},
|
|
719
|
+
timeout=timeout + 5.0,
|
|
720
|
+
)
|
|
721
|
+
return response.get("success", False)
|
|
722
|
+
|
|
723
|
+
async def release_writer(self, port: str) -> bool:
|
|
724
|
+
"""Release write access to a serial port.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
port: Serial port to release write access for
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
True if write access released
|
|
731
|
+
"""
|
|
732
|
+
response = await self._send_request(
|
|
733
|
+
MessageType.SERIAL_RELEASE_WRITER,
|
|
734
|
+
{"port": port},
|
|
735
|
+
)
|
|
736
|
+
return response.get("success", False)
|
|
737
|
+
|
|
738
|
+
async def write_serial(
|
|
739
|
+
self,
|
|
740
|
+
port: str,
|
|
741
|
+
data: bytes,
|
|
742
|
+
acquire_writer: bool = True,
|
|
743
|
+
) -> int:
|
|
744
|
+
"""Write data to a serial port.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
port: Serial port to write to
|
|
748
|
+
data: Bytes to write
|
|
749
|
+
acquire_writer: Whether to auto-acquire writer if not held
|
|
750
|
+
|
|
751
|
+
Returns:
|
|
752
|
+
Number of bytes written
|
|
753
|
+
"""
|
|
754
|
+
# Base64 encode the data for JSON transport
|
|
755
|
+
encoded_data = base64.b64encode(data).decode("ascii")
|
|
756
|
+
|
|
757
|
+
response = await self._send_request(
|
|
758
|
+
MessageType.SERIAL_WRITE,
|
|
759
|
+
{
|
|
760
|
+
"port": port,
|
|
761
|
+
"data": encoded_data,
|
|
762
|
+
"acquire_writer": acquire_writer,
|
|
763
|
+
},
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
if not response.get("success", False):
|
|
767
|
+
return 0
|
|
768
|
+
|
|
769
|
+
return response.get("bytes_written", 0)
|
|
770
|
+
|
|
771
|
+
async def read_buffer(
|
|
772
|
+
self,
|
|
773
|
+
port: str,
|
|
774
|
+
max_lines: int = 100,
|
|
775
|
+
) -> list[str]:
|
|
776
|
+
"""Read buffered serial output.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
port: Serial port to read from
|
|
780
|
+
max_lines: Maximum number of lines to return
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
List of output lines
|
|
784
|
+
"""
|
|
785
|
+
response = await self._send_request(
|
|
786
|
+
MessageType.SERIAL_READ_BUFFER,
|
|
787
|
+
{
|
|
788
|
+
"port": port,
|
|
789
|
+
"max_lines": max_lines,
|
|
790
|
+
},
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if not response.get("success", False):
|
|
794
|
+
return []
|
|
795
|
+
|
|
796
|
+
return response.get("lines", [])
|
|
797
|
+
|
|
798
|
+
async def subscribe_serial_output(
|
|
799
|
+
self,
|
|
800
|
+
port: str,
|
|
801
|
+
callback: Callable[[dict[str, Any]], None] | Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
|
|
802
|
+
) -> str:
|
|
803
|
+
"""Subscribe to serial output events.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
port: Serial port to subscribe to
|
|
807
|
+
callback: Function to call when serial output is received
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
Subscription ID for later unsubscription
|
|
811
|
+
"""
|
|
812
|
+
subscription_id = str(uuid.uuid4())
|
|
813
|
+
subscription = Subscription(
|
|
814
|
+
subscription_id=subscription_id,
|
|
815
|
+
event_type="serial_output",
|
|
816
|
+
callback=callback,
|
|
817
|
+
filter_key=port,
|
|
818
|
+
)
|
|
819
|
+
self._subscriptions[subscription_id] = subscription
|
|
820
|
+
|
|
821
|
+
await self._send_request(
|
|
822
|
+
MessageType.SERIAL_SUBSCRIBE,
|
|
823
|
+
{
|
|
824
|
+
"subscription_id": subscription_id,
|
|
825
|
+
"port": port,
|
|
826
|
+
},
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
return subscription_id
|
|
830
|
+
|
|
831
|
+
async def unsubscribe_serial_output(self, subscription_id: str) -> bool:
|
|
832
|
+
"""Unsubscribe from serial output events.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
subscription_id: Subscription ID from subscribe_serial_output
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
True if unsubscribed successfully
|
|
839
|
+
"""
|
|
840
|
+
if subscription_id not in self._subscriptions:
|
|
841
|
+
return False
|
|
842
|
+
|
|
843
|
+
await self._send_request(
|
|
844
|
+
MessageType.SERIAL_UNSUBSCRIBE,
|
|
845
|
+
{"subscription_id": subscription_id},
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
del self._subscriptions[subscription_id]
|
|
849
|
+
return True
|
|
850
|
+
|
|
851
|
+
# =========================================================================
|
|
852
|
+
# Internal Methods
|
|
853
|
+
# =========================================================================
|
|
854
|
+
|
|
855
|
+
def _generate_request_id(self) -> str:
|
|
856
|
+
"""Generate a unique request ID."""
|
|
857
|
+
self._request_id_counter += 1
|
|
858
|
+
return f"{self._client_id[:8]}_{self._request_id_counter}_{int(time.time() * 1000)}"
|
|
859
|
+
|
|
860
|
+
async def _send_message(self, message: dict[str, Any]) -> None:
|
|
861
|
+
"""Send a message to the daemon.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
message: Message dictionary to send
|
|
865
|
+
|
|
866
|
+
Raises:
|
|
867
|
+
ConnectionError: If not connected or write fails
|
|
868
|
+
"""
|
|
869
|
+
if not self._writer or self._writer.is_closing():
|
|
870
|
+
raise ConnectionError("Not connected to daemon")
|
|
871
|
+
|
|
872
|
+
try:
|
|
873
|
+
# Add client_id and timestamp to all messages
|
|
874
|
+
message["client_id"] = self._client_id
|
|
875
|
+
message["timestamp"] = time.time()
|
|
876
|
+
|
|
877
|
+
# Serialize and send with newline delimiter
|
|
878
|
+
data = json.dumps(message) + "\n"
|
|
879
|
+
self._writer.write(data.encode("utf-8"))
|
|
880
|
+
await self._writer.drain()
|
|
881
|
+
|
|
882
|
+
self._logger.debug(f"Sent message: {message.get('type', 'unknown')}")
|
|
883
|
+
|
|
884
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
885
|
+
raise
|
|
886
|
+
except Exception as e:
|
|
887
|
+
self._logger.error(f"Error sending message: {e}")
|
|
888
|
+
raise ConnectionError(f"Failed to send message: {e}")
|
|
889
|
+
|
|
890
|
+
async def _send_request(
|
|
891
|
+
self,
|
|
892
|
+
message_type: MessageType,
|
|
893
|
+
payload: dict[str, Any],
|
|
894
|
+
timeout: float | None = None,
|
|
895
|
+
) -> dict[str, Any]:
|
|
896
|
+
"""Send a request and wait for response.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
message_type: Type of request
|
|
900
|
+
payload: Request payload
|
|
901
|
+
timeout: Request timeout (uses default if None)
|
|
902
|
+
|
|
903
|
+
Returns:
|
|
904
|
+
Response dictionary
|
|
905
|
+
|
|
906
|
+
Raises:
|
|
907
|
+
TimeoutError: If request times out
|
|
908
|
+
ConnectionError: If not connected
|
|
909
|
+
"""
|
|
910
|
+
if not self.is_connected:
|
|
911
|
+
raise ConnectionError("Not connected to daemon")
|
|
912
|
+
|
|
913
|
+
timeout = timeout or self._request_timeout
|
|
914
|
+
request_id = self._generate_request_id()
|
|
915
|
+
|
|
916
|
+
# Create future for response
|
|
917
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.Future()
|
|
918
|
+
|
|
919
|
+
# Track pending request
|
|
920
|
+
pending = PendingRequest(
|
|
921
|
+
request_id=request_id,
|
|
922
|
+
message_type=message_type,
|
|
923
|
+
future=future,
|
|
924
|
+
timeout=timeout,
|
|
925
|
+
)
|
|
926
|
+
self._pending_requests[request_id] = pending
|
|
927
|
+
|
|
928
|
+
try:
|
|
929
|
+
# Send request
|
|
930
|
+
await self._send_message(
|
|
931
|
+
{
|
|
932
|
+
"type": message_type.value,
|
|
933
|
+
"request_id": request_id,
|
|
934
|
+
**payload,
|
|
935
|
+
}
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
# Wait for response with timeout
|
|
939
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
940
|
+
|
|
941
|
+
except asyncio.TimeoutError:
|
|
942
|
+
self._logger.warning(f"Request {request_id} timed out after {timeout}s")
|
|
943
|
+
raise TimeoutError(f"Request timed out after {timeout}s")
|
|
944
|
+
|
|
945
|
+
finally:
|
|
946
|
+
# Clean up pending request
|
|
947
|
+
self._pending_requests.pop(request_id, None)
|
|
948
|
+
|
|
949
|
+
async def _send_client_connect(self) -> None:
|
|
950
|
+
"""Send client connect message."""
|
|
951
|
+
await self._send_message(
|
|
952
|
+
{
|
|
953
|
+
"type": MessageType.CLIENT_CONNECT.value,
|
|
954
|
+
"pid": os.getpid(),
|
|
955
|
+
"hostname": socket.gethostname(),
|
|
956
|
+
"version": "1.0.0", # TODO: Get from package version
|
|
957
|
+
}
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
async def _send_client_disconnect(self, reason: str) -> None:
|
|
961
|
+
"""Send client disconnect message."""
|
|
962
|
+
await self._send_message(
|
|
963
|
+
{
|
|
964
|
+
"type": MessageType.CLIENT_DISCONNECT.value,
|
|
965
|
+
"reason": reason,
|
|
966
|
+
}
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
async def _send_heartbeat(self) -> None:
|
|
970
|
+
"""Send heartbeat message."""
|
|
971
|
+
try:
|
|
972
|
+
await self._send_message({"type": MessageType.CLIENT_HEARTBEAT.value})
|
|
973
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
974
|
+
raise
|
|
975
|
+
except Exception as e:
|
|
976
|
+
self._logger.warning(f"Failed to send heartbeat: {e}")
|
|
977
|
+
|
|
978
|
+
async def _read_loop(self) -> None:
|
|
979
|
+
"""Background task to read messages from daemon."""
|
|
980
|
+
self._logger.debug("Read loop started")
|
|
981
|
+
|
|
982
|
+
try:
|
|
983
|
+
while not self._shutdown_requested and self._reader:
|
|
984
|
+
try:
|
|
985
|
+
# Read line with timeout
|
|
986
|
+
line = await asyncio.wait_for(
|
|
987
|
+
self._reader.readline(),
|
|
988
|
+
timeout=self._heartbeat_interval * 3,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
if not line:
|
|
992
|
+
# Connection closed
|
|
993
|
+
self._logger.warning("Connection closed by server")
|
|
994
|
+
break
|
|
995
|
+
|
|
996
|
+
# Parse message
|
|
997
|
+
try:
|
|
998
|
+
message = json.loads(line.decode("utf-8"))
|
|
999
|
+
await self._handle_message(message)
|
|
1000
|
+
except json.JSONDecodeError as e:
|
|
1001
|
+
self._logger.warning(f"Invalid JSON received: {e}")
|
|
1002
|
+
|
|
1003
|
+
except asyncio.TimeoutError:
|
|
1004
|
+
# No data received, check connection
|
|
1005
|
+
self._logger.debug("Read timeout, connection may be idle")
|
|
1006
|
+
continue
|
|
1007
|
+
|
|
1008
|
+
except asyncio.CancelledError:
|
|
1009
|
+
self._logger.debug("Read loop cancelled")
|
|
1010
|
+
raise
|
|
1011
|
+
|
|
1012
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
1013
|
+
raise
|
|
1014
|
+
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
self._logger.error(f"Read error: {e}")
|
|
1017
|
+
break
|
|
1018
|
+
|
|
1019
|
+
except asyncio.CancelledError:
|
|
1020
|
+
self._logger.debug("Read loop task cancelled")
|
|
1021
|
+
raise
|
|
1022
|
+
|
|
1023
|
+
# Handle disconnection
|
|
1024
|
+
if not self._shutdown_requested and self._auto_reconnect:
|
|
1025
|
+
self._logger.info("Connection lost, attempting reconnect")
|
|
1026
|
+
await self._reconnect()
|
|
1027
|
+
|
|
1028
|
+
async def _handle_message(self, message: dict[str, Any]) -> None:
|
|
1029
|
+
"""Handle an incoming message.
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
message: Parsed message dictionary
|
|
1033
|
+
"""
|
|
1034
|
+
msg_type = message.get("type", "")
|
|
1035
|
+
|
|
1036
|
+
if msg_type == MessageType.RESPONSE.value:
|
|
1037
|
+
# Handle response to pending request
|
|
1038
|
+
request_id = message.get("request_id")
|
|
1039
|
+
if request_id and request_id in self._pending_requests:
|
|
1040
|
+
pending = self._pending_requests[request_id]
|
|
1041
|
+
if not pending.future.done():
|
|
1042
|
+
pending.future.set_result(message)
|
|
1043
|
+
else:
|
|
1044
|
+
self._logger.warning(f"Received response for unknown request: {request_id}")
|
|
1045
|
+
|
|
1046
|
+
elif msg_type == MessageType.BROADCAST.value:
|
|
1047
|
+
# Handle broadcast event
|
|
1048
|
+
await self._handle_broadcast(message)
|
|
1049
|
+
|
|
1050
|
+
elif msg_type == MessageType.ERROR.value:
|
|
1051
|
+
# Handle error message
|
|
1052
|
+
error_msg = message.get("message", "Unknown error")
|
|
1053
|
+
request_id = message.get("request_id")
|
|
1054
|
+
if request_id and request_id in self._pending_requests:
|
|
1055
|
+
pending = self._pending_requests[request_id]
|
|
1056
|
+
if not pending.future.done():
|
|
1057
|
+
pending.future.set_exception(DaemonClientError(error_msg))
|
|
1058
|
+
else:
|
|
1059
|
+
self._logger.error(f"Received error: {error_msg}")
|
|
1060
|
+
|
|
1061
|
+
else:
|
|
1062
|
+
self._logger.debug(f"Received message of type: {msg_type}")
|
|
1063
|
+
|
|
1064
|
+
async def _handle_broadcast(self, message: dict[str, Any]) -> None:
|
|
1065
|
+
"""Handle a broadcast event.
|
|
1066
|
+
|
|
1067
|
+
Args:
|
|
1068
|
+
message: Broadcast message
|
|
1069
|
+
"""
|
|
1070
|
+
event_type = message.get("event_type", "")
|
|
1071
|
+
filter_key = message.get("filter_key")
|
|
1072
|
+
|
|
1073
|
+
for subscription in self._subscriptions.values():
|
|
1074
|
+
if subscription.event_type == event_type:
|
|
1075
|
+
# Check filter
|
|
1076
|
+
if subscription.filter_key is not None and subscription.filter_key != filter_key:
|
|
1077
|
+
continue
|
|
1078
|
+
|
|
1079
|
+
# Call callback
|
|
1080
|
+
try:
|
|
1081
|
+
result = subscription.callback(message)
|
|
1082
|
+
if asyncio.iscoroutine(result):
|
|
1083
|
+
await result
|
|
1084
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
1085
|
+
raise
|
|
1086
|
+
except Exception as e:
|
|
1087
|
+
self._logger.error(f"Error in subscription callback: {e}")
|
|
1088
|
+
|
|
1089
|
+
async def _heartbeat_loop(self) -> None:
|
|
1090
|
+
"""Background task to send periodic heartbeats."""
|
|
1091
|
+
self._logger.debug("Heartbeat loop started")
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
while not self._shutdown_requested:
|
|
1095
|
+
await asyncio.sleep(self._heartbeat_interval)
|
|
1096
|
+
if self.is_connected:
|
|
1097
|
+
await self._send_heartbeat()
|
|
1098
|
+
|
|
1099
|
+
except asyncio.CancelledError:
|
|
1100
|
+
self._logger.debug("Heartbeat loop cancelled")
|
|
1101
|
+
raise
|
|
1102
|
+
|
|
1103
|
+
async def _timeout_checker_loop(self) -> None:
|
|
1104
|
+
"""Background task to check for timed out requests."""
|
|
1105
|
+
self._logger.debug("Timeout checker loop started")
|
|
1106
|
+
|
|
1107
|
+
try:
|
|
1108
|
+
while not self._shutdown_requested:
|
|
1109
|
+
await asyncio.sleep(1.0)
|
|
1110
|
+
|
|
1111
|
+
# Check for expired requests
|
|
1112
|
+
expired = []
|
|
1113
|
+
for request_id, pending in list(self._pending_requests.items()):
|
|
1114
|
+
if pending.is_expired() and not pending.future.done():
|
|
1115
|
+
expired.append((request_id, pending))
|
|
1116
|
+
|
|
1117
|
+
# Cancel expired requests
|
|
1118
|
+
for request_id, pending in expired:
|
|
1119
|
+
self._logger.warning(f"Request {request_id} expired")
|
|
1120
|
+
pending.future.set_exception(TimeoutError(f"Request timed out after {pending.timeout}s"))
|
|
1121
|
+
self._pending_requests.pop(request_id, None)
|
|
1122
|
+
|
|
1123
|
+
except asyncio.CancelledError:
|
|
1124
|
+
self._logger.debug("Timeout checker loop cancelled")
|
|
1125
|
+
raise
|
|
1126
|
+
|
|
1127
|
+
async def _reconnect(self) -> None:
|
|
1128
|
+
"""Attempt to reconnect to the daemon."""
|
|
1129
|
+
if not self._host or not self._port:
|
|
1130
|
+
self._logger.error("Cannot reconnect: no host/port configured")
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
self._state = ConnectionState.RECONNECTING
|
|
1134
|
+
delay = self._reconnect_delay
|
|
1135
|
+
|
|
1136
|
+
while not self._shutdown_requested and self._auto_reconnect:
|
|
1137
|
+
self._logger.info(f"Attempting reconnect in {delay}s")
|
|
1138
|
+
await asyncio.sleep(delay)
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
await self.connect(self._host, self._port)
|
|
1142
|
+
self._logger.info("Reconnection successful")
|
|
1143
|
+
return
|
|
1144
|
+
|
|
1145
|
+
except ConnectionError as e:
|
|
1146
|
+
self._logger.warning(f"Reconnection failed: {e}")
|
|
1147
|
+
delay = min(delay * self._reconnect_backoff_factor, self._max_reconnect_delay)
|
|
1148
|
+
|
|
1149
|
+
self._state = ConnectionState.DISCONNECTED
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
class SyncDaemonClient:
|
|
1153
|
+
"""Synchronous wrapper around AsyncDaemonClient for use from sync code.
|
|
1154
|
+
|
|
1155
|
+
This class provides a synchronous API that internally uses the async client
|
|
1156
|
+
by running operations in a dedicated event loop thread.
|
|
1157
|
+
|
|
1158
|
+
Example:
|
|
1159
|
+
>>> client = SyncDaemonClient()
|
|
1160
|
+
>>> client.connect("localhost", 8765)
|
|
1161
|
+
>>> lock_acquired = client.acquire_lock("/project", "esp32", "/dev/ttyUSB0")
|
|
1162
|
+
>>> print(f"Lock acquired: {lock_acquired}")
|
|
1163
|
+
>>> client.disconnect()
|
|
1164
|
+
"""
|
|
1165
|
+
|
|
1166
|
+
def __init__(
|
|
1167
|
+
self,
|
|
1168
|
+
client_id: str | None = None,
|
|
1169
|
+
heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL,
|
|
1170
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
1171
|
+
auto_reconnect: bool = True,
|
|
1172
|
+
) -> None:
|
|
1173
|
+
"""Initialize the sync daemon client.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
client_id: Unique client identifier (auto-generated if None)
|
|
1177
|
+
heartbeat_interval: Interval between heartbeats in seconds
|
|
1178
|
+
request_timeout: Default timeout for requests in seconds
|
|
1179
|
+
auto_reconnect: Whether to automatically reconnect on disconnect
|
|
1180
|
+
"""
|
|
1181
|
+
self._async_client = AsyncDaemonClient(
|
|
1182
|
+
client_id=client_id,
|
|
1183
|
+
heartbeat_interval=heartbeat_interval,
|
|
1184
|
+
request_timeout=request_timeout,
|
|
1185
|
+
auto_reconnect=auto_reconnect,
|
|
1186
|
+
)
|
|
1187
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
1188
|
+
self._thread_executor = ThreadPoolExecutor(max_workers=1)
|
|
1189
|
+
self._loop_thread_started = False
|
|
1190
|
+
|
|
1191
|
+
def __enter__(self) -> "SyncDaemonClient":
|
|
1192
|
+
"""Context manager entry."""
|
|
1193
|
+
return self
|
|
1194
|
+
|
|
1195
|
+
def __exit__(
|
|
1196
|
+
self,
|
|
1197
|
+
_exc_type: type | None,
|
|
1198
|
+
_exc_val: Exception | None,
|
|
1199
|
+
_exc_tb: Any,
|
|
1200
|
+
) -> None:
|
|
1201
|
+
"""Context manager exit."""
|
|
1202
|
+
self.disconnect()
|
|
1203
|
+
self.close()
|
|
1204
|
+
|
|
1205
|
+
@property
|
|
1206
|
+
def client_id(self) -> str:
|
|
1207
|
+
"""Get the client ID."""
|
|
1208
|
+
return self._async_client.client_id
|
|
1209
|
+
|
|
1210
|
+
@property
|
|
1211
|
+
def is_connected(self) -> bool:
|
|
1212
|
+
"""Check if client is connected."""
|
|
1213
|
+
return self._async_client.is_connected
|
|
1214
|
+
|
|
1215
|
+
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
|
|
1216
|
+
"""Ensure event loop is running and return it."""
|
|
1217
|
+
if self._loop is None or not self._loop.is_running():
|
|
1218
|
+
self._loop = asyncio.new_event_loop()
|
|
1219
|
+
# Start loop in background thread
|
|
1220
|
+
import threading
|
|
1221
|
+
|
|
1222
|
+
self._loop_thread = threading.Thread(
|
|
1223
|
+
target=self._run_event_loop,
|
|
1224
|
+
daemon=True,
|
|
1225
|
+
)
|
|
1226
|
+
self._loop_thread.start()
|
|
1227
|
+
self._loop_thread_started = True
|
|
1228
|
+
|
|
1229
|
+
return self._loop
|
|
1230
|
+
|
|
1231
|
+
def _run_event_loop(self) -> None:
|
|
1232
|
+
"""Run the event loop in a background thread."""
|
|
1233
|
+
if self._loop:
|
|
1234
|
+
asyncio.set_event_loop(self._loop)
|
|
1235
|
+
self._loop.run_forever()
|
|
1236
|
+
|
|
1237
|
+
def _run_async(self, coro: Coroutine[Any, Any, Any]) -> Any:
|
|
1238
|
+
"""Run an async coroutine from sync code.
|
|
1239
|
+
|
|
1240
|
+
Args:
|
|
1241
|
+
coro: Coroutine to run
|
|
1242
|
+
|
|
1243
|
+
Returns:
|
|
1244
|
+
Result of the coroutine
|
|
1245
|
+
"""
|
|
1246
|
+
loop = self._ensure_loop()
|
|
1247
|
+
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
|
1248
|
+
return future.result()
|
|
1249
|
+
|
|
1250
|
+
def connect(
|
|
1251
|
+
self,
|
|
1252
|
+
host: str = DEFAULT_HOST,
|
|
1253
|
+
port: int = DEFAULT_PORT,
|
|
1254
|
+
timeout: float = 10.0,
|
|
1255
|
+
) -> None:
|
|
1256
|
+
"""Connect to the daemon server.
|
|
1257
|
+
|
|
1258
|
+
Args:
|
|
1259
|
+
host: Daemon host address
|
|
1260
|
+
port: Daemon port number
|
|
1261
|
+
timeout: Connection timeout in seconds
|
|
1262
|
+
"""
|
|
1263
|
+
self._run_async(self._async_client.connect(host, port, timeout))
|
|
1264
|
+
|
|
1265
|
+
def disconnect(self, reason: str = "client requested") -> None:
|
|
1266
|
+
"""Disconnect from the daemon server.
|
|
1267
|
+
|
|
1268
|
+
Args:
|
|
1269
|
+
reason: Reason for disconnection
|
|
1270
|
+
"""
|
|
1271
|
+
try:
|
|
1272
|
+
self._run_async(self._async_client.disconnect(reason))
|
|
1273
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
1274
|
+
raise
|
|
1275
|
+
except Exception:
|
|
1276
|
+
pass
|
|
1277
|
+
|
|
1278
|
+
def close(self) -> None:
|
|
1279
|
+
"""Close the client and cleanup resources."""
|
|
1280
|
+
if self._loop and self._loop.is_running():
|
|
1281
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
1282
|
+
|
|
1283
|
+
self._thread_executor.shutdown(wait=False)
|
|
1284
|
+
|
|
1285
|
+
def wait_for_connection(self, timeout: float = 30.0) -> None:
|
|
1286
|
+
"""Wait for connection to be established.
|
|
1287
|
+
|
|
1288
|
+
Args:
|
|
1289
|
+
timeout: Maximum time to wait
|
|
1290
|
+
"""
|
|
1291
|
+
self._run_async(self._async_client.wait_for_connection(timeout))
|
|
1292
|
+
|
|
1293
|
+
# =========================================================================
|
|
1294
|
+
# Lock Management
|
|
1295
|
+
# =========================================================================
|
|
1296
|
+
|
|
1297
|
+
def acquire_lock(
|
|
1298
|
+
self,
|
|
1299
|
+
project_dir: str,
|
|
1300
|
+
environment: str,
|
|
1301
|
+
port: str,
|
|
1302
|
+
lock_type: str = "exclusive",
|
|
1303
|
+
timeout: float = 300.0,
|
|
1304
|
+
description: str = "",
|
|
1305
|
+
) -> bool:
|
|
1306
|
+
"""Acquire a configuration lock.
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
project_dir: Absolute path to project directory
|
|
1310
|
+
environment: Build environment name
|
|
1311
|
+
port: Serial port for the configuration
|
|
1312
|
+
lock_type: Type of lock ("exclusive" or "shared_read")
|
|
1313
|
+
timeout: Maximum time to wait for the lock
|
|
1314
|
+
description: Human-readable description
|
|
1315
|
+
|
|
1316
|
+
Returns:
|
|
1317
|
+
True if lock was acquired
|
|
1318
|
+
"""
|
|
1319
|
+
return self._run_async(self._async_client.acquire_lock(project_dir, environment, port, lock_type, timeout, description))
|
|
1320
|
+
|
|
1321
|
+
def release_lock(
|
|
1322
|
+
self,
|
|
1323
|
+
project_dir: str,
|
|
1324
|
+
environment: str,
|
|
1325
|
+
port: str,
|
|
1326
|
+
) -> bool:
|
|
1327
|
+
"""Release a configuration lock.
|
|
1328
|
+
|
|
1329
|
+
Args:
|
|
1330
|
+
project_dir: Absolute path to project directory
|
|
1331
|
+
environment: Build environment name
|
|
1332
|
+
port: Serial port for the configuration
|
|
1333
|
+
|
|
1334
|
+
Returns:
|
|
1335
|
+
True if lock was released
|
|
1336
|
+
"""
|
|
1337
|
+
return self._run_async(self._async_client.release_lock(project_dir, environment, port))
|
|
1338
|
+
|
|
1339
|
+
def get_lock_status(
|
|
1340
|
+
self,
|
|
1341
|
+
project_dir: str,
|
|
1342
|
+
environment: str,
|
|
1343
|
+
port: str,
|
|
1344
|
+
) -> dict[str, Any]:
|
|
1345
|
+
"""Get the status of a configuration lock.
|
|
1346
|
+
|
|
1347
|
+
Args:
|
|
1348
|
+
project_dir: Absolute path to project directory
|
|
1349
|
+
environment: Build environment name
|
|
1350
|
+
port: Serial port for the configuration
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
Dictionary with lock status information
|
|
1354
|
+
"""
|
|
1355
|
+
return self._run_async(self._async_client.get_lock_status(project_dir, environment, port))
|
|
1356
|
+
|
|
1357
|
+
def subscribe_lock_changes(
|
|
1358
|
+
self,
|
|
1359
|
+
callback: Callable[[dict[str, Any]], None],
|
|
1360
|
+
filter_key: str | None = None,
|
|
1361
|
+
) -> str:
|
|
1362
|
+
"""Subscribe to lock change events.
|
|
1363
|
+
|
|
1364
|
+
Args:
|
|
1365
|
+
callback: Function to call when lock changes
|
|
1366
|
+
filter_key: Optional key to filter events
|
|
1367
|
+
|
|
1368
|
+
Returns:
|
|
1369
|
+
Subscription ID
|
|
1370
|
+
"""
|
|
1371
|
+
return self._run_async(self._async_client.subscribe_lock_changes(callback, filter_key))
|
|
1372
|
+
|
|
1373
|
+
# =========================================================================
|
|
1374
|
+
# Firmware Queries
|
|
1375
|
+
# =========================================================================
|
|
1376
|
+
|
|
1377
|
+
def query_firmware(
|
|
1378
|
+
self,
|
|
1379
|
+
port: str,
|
|
1380
|
+
source_hash: str,
|
|
1381
|
+
build_flags_hash: str | None = None,
|
|
1382
|
+
) -> dict[str, Any]:
|
|
1383
|
+
"""Query if firmware is current on a device.
|
|
1384
|
+
|
|
1385
|
+
Args:
|
|
1386
|
+
port: Serial port of the device
|
|
1387
|
+
source_hash: Hash of the source files
|
|
1388
|
+
build_flags_hash: Hash of build flags
|
|
1389
|
+
|
|
1390
|
+
Returns:
|
|
1391
|
+
Dictionary with firmware status
|
|
1392
|
+
"""
|
|
1393
|
+
return self._run_async(self._async_client.query_firmware(port, source_hash, build_flags_hash))
|
|
1394
|
+
|
|
1395
|
+
def subscribe_firmware_changes(
|
|
1396
|
+
self,
|
|
1397
|
+
callback: Callable[[dict[str, Any]], None],
|
|
1398
|
+
port: str | None = None,
|
|
1399
|
+
) -> str:
|
|
1400
|
+
"""Subscribe to firmware change events.
|
|
1401
|
+
|
|
1402
|
+
Args:
|
|
1403
|
+
callback: Function to call when firmware changes
|
|
1404
|
+
port: Optional port to filter events
|
|
1405
|
+
|
|
1406
|
+
Returns:
|
|
1407
|
+
Subscription ID
|
|
1408
|
+
"""
|
|
1409
|
+
return self._run_async(self._async_client.subscribe_firmware_changes(callback, port))
|
|
1410
|
+
|
|
1411
|
+
# =========================================================================
|
|
1412
|
+
# Serial Session Management
|
|
1413
|
+
# =========================================================================
|
|
1414
|
+
|
|
1415
|
+
def attach_serial(
|
|
1416
|
+
self,
|
|
1417
|
+
port: str,
|
|
1418
|
+
baud_rate: int = 115200,
|
|
1419
|
+
as_reader: bool = True,
|
|
1420
|
+
) -> bool:
|
|
1421
|
+
"""Attach to a serial session.
|
|
1422
|
+
|
|
1423
|
+
Args:
|
|
1424
|
+
port: Serial port to attach to
|
|
1425
|
+
baud_rate: Baud rate for the connection
|
|
1426
|
+
as_reader: Whether to attach as reader
|
|
1427
|
+
|
|
1428
|
+
Returns:
|
|
1429
|
+
True if attached successfully
|
|
1430
|
+
"""
|
|
1431
|
+
return self._run_async(self._async_client.attach_serial(port, baud_rate, as_reader))
|
|
1432
|
+
|
|
1433
|
+
def detach_serial(
|
|
1434
|
+
self,
|
|
1435
|
+
port: str,
|
|
1436
|
+
close_port: bool = False,
|
|
1437
|
+
) -> bool:
|
|
1438
|
+
"""Detach from a serial session.
|
|
1439
|
+
|
|
1440
|
+
Args:
|
|
1441
|
+
port: Serial port to detach from
|
|
1442
|
+
close_port: Whether to close port if last reader
|
|
1443
|
+
|
|
1444
|
+
Returns:
|
|
1445
|
+
True if detached successfully
|
|
1446
|
+
"""
|
|
1447
|
+
return self._run_async(self._async_client.detach_serial(port, close_port))
|
|
1448
|
+
|
|
1449
|
+
def acquire_writer(
|
|
1450
|
+
self,
|
|
1451
|
+
port: str,
|
|
1452
|
+
timeout: float = 10.0,
|
|
1453
|
+
) -> bool:
|
|
1454
|
+
"""Acquire write access to a serial port.
|
|
1455
|
+
|
|
1456
|
+
Args:
|
|
1457
|
+
port: Serial port
|
|
1458
|
+
timeout: Maximum time to wait
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
True if write access acquired
|
|
1462
|
+
"""
|
|
1463
|
+
return self._run_async(self._async_client.acquire_writer(port, timeout))
|
|
1464
|
+
|
|
1465
|
+
def release_writer(self, port: str) -> bool:
|
|
1466
|
+
"""Release write access to a serial port.
|
|
1467
|
+
|
|
1468
|
+
Args:
|
|
1469
|
+
port: Serial port
|
|
1470
|
+
|
|
1471
|
+
Returns:
|
|
1472
|
+
True if released
|
|
1473
|
+
"""
|
|
1474
|
+
return self._run_async(self._async_client.release_writer(port))
|
|
1475
|
+
|
|
1476
|
+
def write_serial(
|
|
1477
|
+
self,
|
|
1478
|
+
port: str,
|
|
1479
|
+
data: bytes,
|
|
1480
|
+
acquire_writer: bool = True,
|
|
1481
|
+
) -> int:
|
|
1482
|
+
"""Write data to a serial port.
|
|
1483
|
+
|
|
1484
|
+
Args:
|
|
1485
|
+
port: Serial port
|
|
1486
|
+
data: Bytes to write
|
|
1487
|
+
acquire_writer: Whether to auto-acquire writer
|
|
1488
|
+
|
|
1489
|
+
Returns:
|
|
1490
|
+
Number of bytes written
|
|
1491
|
+
"""
|
|
1492
|
+
return self._run_async(self._async_client.write_serial(port, data, acquire_writer))
|
|
1493
|
+
|
|
1494
|
+
def read_buffer(
|
|
1495
|
+
self,
|
|
1496
|
+
port: str,
|
|
1497
|
+
max_lines: int = 100,
|
|
1498
|
+
) -> list[str]:
|
|
1499
|
+
"""Read buffered serial output.
|
|
1500
|
+
|
|
1501
|
+
Args:
|
|
1502
|
+
port: Serial port
|
|
1503
|
+
max_lines: Maximum lines to return
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
List of output lines
|
|
1507
|
+
"""
|
|
1508
|
+
return self._run_async(self._async_client.read_buffer(port, max_lines))
|
|
1509
|
+
|
|
1510
|
+
def subscribe_serial_output(
|
|
1511
|
+
self,
|
|
1512
|
+
port: str,
|
|
1513
|
+
callback: Callable[[dict[str, Any]], None],
|
|
1514
|
+
) -> str:
|
|
1515
|
+
"""Subscribe to serial output events.
|
|
1516
|
+
|
|
1517
|
+
Args:
|
|
1518
|
+
port: Serial port
|
|
1519
|
+
callback: Function to call on output
|
|
1520
|
+
|
|
1521
|
+
Returns:
|
|
1522
|
+
Subscription ID
|
|
1523
|
+
"""
|
|
1524
|
+
return self._run_async(self._async_client.subscribe_serial_output(port, callback))
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# Convenience function to create a client
|
|
1528
|
+
def create_client(
|
|
1529
|
+
sync: bool = False,
|
|
1530
|
+
**kwargs: Any,
|
|
1531
|
+
) -> AsyncDaemonClient | SyncDaemonClient:
|
|
1532
|
+
"""Create a daemon client.
|
|
1533
|
+
|
|
1534
|
+
Args:
|
|
1535
|
+
sync: If True, create a SyncDaemonClient, otherwise AsyncDaemonClient
|
|
1536
|
+
**kwargs: Arguments to pass to client constructor
|
|
1537
|
+
|
|
1538
|
+
Returns:
|
|
1539
|
+
Client instance
|
|
1540
|
+
"""
|
|
1541
|
+
if sync:
|
|
1542
|
+
return SyncDaemonClient(**kwargs)
|
|
1543
|
+
return AsyncDaemonClient(**kwargs)
|