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.
Files changed (47) hide show
  1. fbuild/__init__.py +5 -1
  2. fbuild/build/configurable_compiler.py +49 -6
  3. fbuild/build/configurable_linker.py +14 -9
  4. fbuild/build/orchestrator_esp32.py +6 -3
  5. fbuild/build/orchestrator_rp2040.py +6 -2
  6. fbuild/cli.py +300 -5
  7. fbuild/config/ini_parser.py +13 -1
  8. fbuild/daemon/__init__.py +11 -0
  9. fbuild/daemon/async_client.py +5 -4
  10. fbuild/daemon/async_client_lib.py +1543 -0
  11. fbuild/daemon/async_protocol.py +825 -0
  12. fbuild/daemon/async_server.py +2100 -0
  13. fbuild/daemon/client.py +425 -13
  14. fbuild/daemon/configuration_lock.py +13 -13
  15. fbuild/daemon/connection.py +508 -0
  16. fbuild/daemon/connection_registry.py +579 -0
  17. fbuild/daemon/daemon.py +517 -164
  18. fbuild/daemon/daemon_context.py +72 -1
  19. fbuild/daemon/device_discovery.py +477 -0
  20. fbuild/daemon/device_manager.py +821 -0
  21. fbuild/daemon/error_collector.py +263 -263
  22. fbuild/daemon/file_cache.py +332 -332
  23. fbuild/daemon/firmware_ledger.py +46 -123
  24. fbuild/daemon/lock_manager.py +508 -508
  25. fbuild/daemon/messages.py +431 -0
  26. fbuild/daemon/operation_registry.py +288 -288
  27. fbuild/daemon/processors/build_processor.py +34 -1
  28. fbuild/daemon/processors/deploy_processor.py +1 -3
  29. fbuild/daemon/processors/locking_processor.py +7 -7
  30. fbuild/daemon/request_processor.py +457 -457
  31. fbuild/daemon/shared_serial.py +7 -7
  32. fbuild/daemon/status_manager.py +238 -238
  33. fbuild/daemon/subprocess_manager.py +316 -316
  34. fbuild/deploy/docker_utils.py +182 -2
  35. fbuild/deploy/monitor.py +1 -1
  36. fbuild/deploy/qemu_runner.py +71 -13
  37. fbuild/ledger/board_ledger.py +46 -122
  38. fbuild/output.py +238 -2
  39. fbuild/packages/library_compiler.py +15 -5
  40. fbuild/packages/library_manager.py +12 -6
  41. fbuild-1.2.15.dist-info/METADATA +569 -0
  42. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
  43. fbuild-1.2.8.dist-info/METADATA +0 -468
  44. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
  45. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
  46. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
  47. {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)